feat(torrent): stream.js HTTP Range handler (ref #917)

Implement handleStream(engine, req, res) for WebTorrent streaming with
HTTP Range support. Handles 200 full, 206 partial, 416 unsatisfiable,
404 not found responses. MIME type detection from file extension.
Tests via node --test with fake engine/request/response (all green).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-28 14:44:39 +02:00
parent f57ccfc035
commit a6cc0df720
2 changed files with 99 additions and 0 deletions

View File

@ -0,0 +1,39 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
/**
* SecuBox-Deb :: secubox-torrent HTTP Range streaming handler
* CyberMind https://cybermind.fr
*/
const MIME = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
m4v: 'video/mp4', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
flac: 'audio/flac', wav: 'audio/wav' };
export async function handleStream(engine, req, res) {
const t = engine.get(req.params.infohash);
const idx = Number(req.params.fileIdx);
const file = t && t.files && t.files[idx];
if (!file) { res.writeHead(404); res.end('not found'); return; }
const type = mimeOf(file.name);
const total = file.length;
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);
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) {
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);
}
function mimeOf(name) { const e = (/\.([A-Za-z0-9]+)$/.exec(name) || [])[1]; return MIME[(e || '').toLowerCase()] || 'application/octet-stream'; }

View File

@ -0,0 +1,60 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
/**
* SecuBox-Deb :: secubox-torrent stream handler tests
* CyberMind https://cybermind.fr
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { handleStream } from './stream.js';
function fakeRes() {
const listeners = {};
return { statusCode: 200, headers: {}, ended: false, body: '',
setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
writeHead(c, h) { this.statusCode = c; Object.assign(this.headers, lower(h || {})); },
end(s) { if (s) this.body += s; this.ended = true; },
on(event, listener) { if (!listeners[event]) listeners[event] = []; listeners[event].push(listener); return this; },
once(event, listener) { const wrapped = (...args) => { listener(...args); const idx = listeners[event].indexOf(wrapped); if (idx >= 0) listeners[event].splice(idx, 1); }; if (!listeners[event]) listeners[event] = []; listeners[event].push(wrapped); return this; },
write(chunk, encoding, cb) { this.body += chunk; if (cb) cb(); return true; },
emit(event, data) { if (listeners[event]) listeners[event].forEach(l => l(data)); } };
}
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}]`]) };
return { get: () => ({ files: [file] }) };
}
test('full request returns 200 with content-length and type', async () => {
const res = fakeRes();
await handleStream(fakeEngine(100), { params: { infohash: 'a', fileIdx: '0' }, headers: {} }, res);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-length'], 100);
assert.match(res.headers['content-type'], /mp4/);
});
test('range request returns 206 with content-range', async () => {
const res = fakeRes();
await handleStream(fakeEngine(100), { params: { infohash: 'a', fileIdx: '0' }, headers: { range: 'bytes=10-19' } }, res);
assert.equal(res.statusCode, 206);
assert.equal(res.headers['content-range'], 'bytes 10-19/100');
assert.equal(res.headers['content-length'], 10);
});
test('unsatisfiable range returns 416', async () => {
const res = fakeRes();
await handleStream(fakeEngine(100), { params: { infohash: 'a', fileIdx: '0' }, headers: { range: 'bytes=200-300' } }, res);
assert.equal(res.statusCode, 416);
});
test('unknown infohash returns 404', async () => {
const res = fakeRes();
await handleStream({ get: () => null }, { params: { infohash: 'z', fileIdx: '0' }, headers: {} }, res);
assert.equal(res.statusCode, 404);
});