Files
km b1c3ec3af4 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)
2026-03-30 06:35:25 +09:00

244 lines
6.9 KiB
JavaScript

/**
* @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);
});
}