Initial commit: OTA chunk transfer protocol implementation
- Protocol definition with 6-byte header (chunk_idx: 2B, payload: N B, crc32: 4B) - Node.js server with chunk-based HTTP delivery - C client library with resumable download support - CRC32 implementation (IEEE 802.3) - Test suites for both server and CRC32 - 1024B default chunk size (0.59% overhead)
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @file server.js
|
||||
* @brief Node.js Chunk Transfer Server
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class ChunkServer extends EventEmitter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.config = {
|
||||
port: 8080,
|
||||
rootPath: '.',
|
||||
chunkSize: 1024,
|
||||
...config
|
||||
};
|
||||
this.server = null;
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CRC32 (IEEE 802.3)
|
||||
*/
|
||||
static crc32(data) {
|
||||
const table = ChunkServer.crc32Table;
|
||||
let crc = 0xFFFFFFFF;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const index = (crc ^ data[i]) & 0xFF;
|
||||
crc = (crc >>> 8) ^ table[index];
|
||||
}
|
||||
|
||||
return crc ^ 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRC32 lookup table
|
||||
*/
|
||||
static get crc32Table() {
|
||||
if (!ChunkServer._crc32Table) {
|
||||
const table = new Uint32Array(256);
|
||||
const poly = 0xEDB88320;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = (crc & 1) ? ((crc >>> 1) ^ poly) : (crc >>> 1);
|
||||
}
|
||||
table[i] = crc >>> 0;
|
||||
}
|
||||
ChunkServer._crc32Table = table;
|
||||
}
|
||||
return ChunkServer._crc32Table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start server
|
||||
*/
|
||||
start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res).catch(err => {
|
||||
console.error('Request error:', err);
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
});
|
||||
});
|
||||
|
||||
this.server.listen(this.config.port, () => {
|
||||
console.log(`Chunk server running on port ${this.config.port}`);
|
||||
console.log(`Root path: ${this.config.rootPath}`);
|
||||
console.log(`Chunk size: ${this.config.chunkSize} bytes`);
|
||||
this.running = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop server
|
||||
*/
|
||||
stop() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
this.server.close(err => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
console.log('Server stopped');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP request
|
||||
*/
|
||||
async handleRequest(req, res) {
|
||||
// Parse URL
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
// Only handle /ota endpoint
|
||||
if (url.pathname !== '/ota') {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = url.searchParams.get('file');
|
||||
const chunkParam = url.searchParams.get('chunk');
|
||||
|
||||
if (!filename) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('Missing file parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Prevent path traversal
|
||||
const safeFilename = path.basename(filename);
|
||||
const filePath = path.join(this.config.rootPath, safeFilename);
|
||||
|
||||
// Check file exists
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = stat.size;
|
||||
const totalChunks = Math.ceil(fileSize / this.config.chunkSize);
|
||||
|
||||
// Calculate chunk index
|
||||
let chunkIdx;
|
||||
if (chunkParam !== null) {
|
||||
chunkIdx = parseInt(chunkParam, 10);
|
||||
if (isNaN(chunkIdx) || chunkIdx < 0 || chunkIdx >= totalChunks) {
|
||||
res.writeHead(416, {
|
||||
'Content-Type': 'text/plain',
|
||||
'Content-Range': `chunks 0-${totalChunks-1}/${totalChunks}`
|
||||
});
|
||||
res.end('Range Not Satisfiable');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
chunkIdx = 0;
|
||||
}
|
||||
|
||||
// Read chunk data
|
||||
const offset = chunkIdx * this.config.chunkSize;
|
||||
const length = Math.min(this.config.chunkSize, fileSize - offset);
|
||||
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
fs.readSync(fd, buffer, 0, length, offset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// Calculate CRC32
|
||||
const crc32 = ChunkServer.crc32(buffer);
|
||||
|
||||
// Build response: [chunk_idx: 2B][payload: N B][crc32: 4B]
|
||||
const responseBuffer = Buffer.alloc(2 + length + 4);
|
||||
responseBuffer.writeUInt16LE(chunkIdx, 0);
|
||||
buffer.copy(responseBuffer, 2);
|
||||
responseBuffer.writeUInt32LE(crc32 >>> 0, 2 + length); // Ensure unsigned
|
||||
|
||||
// Set headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': responseBuffer.length,
|
||||
'X-Chunk-Index': chunkIdx,
|
||||
'X-Chunk-Size': length,
|
||||
'X-Total-Chunks': totalChunks,
|
||||
'X-File-Size': fileSize
|
||||
};
|
||||
|
||||
// Response status
|
||||
if (chunkIdx > 0) {
|
||||
res.writeHead(206, headers); // Partial Content for resumed chunks
|
||||
} else {
|
||||
res.writeHead(200, headers); // OK for first chunk
|
||||
}
|
||||
|
||||
res.end(responseBuffer);
|
||||
|
||||
// Emit event
|
||||
this.emit('chunk-sent', {
|
||||
file: filename,
|
||||
chunkIdx: chunkIdx,
|
||||
chunkSize: length,
|
||||
totalChunks: totalChunks,
|
||||
fileSize: fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
module.exports = ChunkServer;
|
||||
|
||||
// CLI usage
|
||||
if (require.main === module) {
|
||||
const port = process.argv[2] || 8080;
|
||||
const rootPath = process.argv[3] || '.';
|
||||
|
||||
const server = new ChunkServer({ port: parseInt(port), rootPath });
|
||||
|
||||
server.on('chunk-sent', (info) => {
|
||||
console.log(`Sent chunk ${info.chunkIdx}/${info.totalChunks} for ${info.file} (${info.chunkSize}B)`);
|
||||
});
|
||||
|
||||
server.start().then(() => {
|
||||
console.log(`Server started on port ${port}`);
|
||||
console.log(`Root path: ${rootPath}`);
|
||||
}).catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user