fix(torrent): stream.js Critical/Important review fixes (ref #917)

Critical: Change file.stream({start,end}) → file.createReadStream({start,end})
to match real webtorrent ^2.5.1 API (WHATWG ReadableStream vs Node Readable).

Important #1: RFC 7233 compliance — clamp end >= total to total-1 instead
of rejecting with 416. Only reject when start >= total or start > end.

Important #2: Attach stream error handlers to prevent unhandled crashes
on peer disconnect or missing pieces.

Minor: Add tests for range clamping (bytes=50-999999 on 100-byte file)
and out-of-range fileIdx. All 6 tests passing (green).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-28 14:51:18 +02:00
parent a6cc0df720
commit d45803db0c
2 changed files with 25 additions and 6 deletions

View File

@ -22,18 +22,23 @@ export async function handleStream(engine, req, res) {
const range = req.headers.range;
if (!range) {
res.writeHead(200, { 'Content-Length': total, 'Content-Type': type, 'Accept-Ranges': 'bytes' });
file.stream({ start: 0, end: total - 1 }).pipe(res);
const stream = file.createReadStream({ start: 0, end: total - 1 });
stream.on('error', () => { try { res.destroy(); } catch {} });
stream.pipe(res);
return;
}
const m = /bytes=(\d+)-(\d*)/.exec(range);
const start = m ? Number(m[1]) : 0;
const end = m && m[2] ? Number(m[2]) : total - 1;
if (start >= total || end >= total || start > end) {
let start = m ? Number(m[1]) : 0;
let end = m && m[2] ? Number(m[2]) : total - 1;
if (end >= total) end = total - 1;
if (start >= total || start > end) {
res.writeHead(416, { 'Content-Range': `bytes */${total}` }); res.end(); return;
}
res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': end - start + 1, 'Content-Type': type, 'Accept-Ranges': 'bytes' });
file.stream({ start, end }).pipe(res);
const stream = file.createReadStream({ start, end });
stream.on('error', () => { try { res.destroy(); } catch {} });
stream.pipe(res);
}
function mimeOf(name) { const e = (/\.([A-Za-z0-9]+)$/.exec(name) || [])[1]; return MIME[(e || '').toLowerCase()] || 'application/octet-stream'; }

View File

@ -27,7 +27,7 @@ function fakeRes() {
const lower = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k.toLowerCase(), v]));
function fakeEngine(len = 100) {
const file = { name: 'movie.mp4', length: len,
stream: ({ start, end }) => Readable.from([`bytes[${start}-${end}]`]) };
createReadStream: ({ start, end }) => Readable.from([`bytes[${start}-${end}]`]) };
return { get: () => ({ files: [file] }) };
}
@ -58,3 +58,17 @@ test('unknown infohash returns 404', async () => {
await handleStream({ get: () => null }, { params: { infohash: 'z', fileIdx: '0' }, headers: {} }, res);
assert.equal(res.statusCode, 404);
});
test('range clamping: bytes=50-999999 on 100-byte file returns 206 with clamped end', async () => {
const res = fakeRes();
await handleStream(fakeEngine(100), { params: { infohash: 'a', fileIdx: '0' }, headers: { range: 'bytes=50-999999' } }, res);
assert.equal(res.statusCode, 206);
assert.equal(res.headers['content-range'], 'bytes 50-99/100');
assert.equal(res.headers['content-length'], 50);
});
test('out-of-range fileIdx returns 404', async () => {
const res = fakeRes();
await handleStream(fakeEngine(100), { params: { infohash: 'a', fileIdx: '5' }, headers: {} }, res);
assert.equal(res.statusCode, 404);
});