Files
NodecastV2/test/proxy-stream.test.js
Technomancer702 e646e36869 fix(proxy): stream binary media with cancellation and bounded HLS buffers
Adapted from MikaelKW/nodecast-tv-plus commit 011b634417b2ecd2a2844df9808b42de831ba194. Use awaited pipeline, abort upstream on disconnect, avoid retries after headers, preserve real range support, and rewrite bounded manifests using relative proxy paths. Cover interrupted streams, first-byte delivery, ranges, redirects, split signatures, and invalid inputs.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
2026-10-12 16:06:52 -07:00

122 lines
5.7 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const proxyStream = require('../server/services/proxyStream');
async function server(t, handler) {
const instance = http.createServer(handler);
await new Promise(resolve => instance.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => {
instance.close(resolve);
instance.closeAllConnections();
}));
return `http://127.0.0.1:${instance.address().port}`;
}
async function fixture(t, upstreamHandler) {
const upstream = await server(t, upstreamHandler);
const app = express(), router = express.Router();
router.get('/stream', proxyStream);
app.use('/api/proxy', router);
const base = await server(t, app);
return { upstream, base, proxy: route => `${base}/api/proxy/stream?url=${encodeURIComponent(upstream + route)}` };
}
test('binary proxy delivers the first chunk before upstream finishes', async t => {
let finish;
const { proxy } = await fixture(t, (req, res) => {
res.writeHead(200, { 'Content-Type': 'video/mp4' });
res.write('first-media-chunk');
finish = () => res.end('last-media-chunk');
});
const response = await fetch(proxy('/video'), { signal: AbortSignal.timeout(3000) });
const reader = response.body.getReader();
assert.equal(Buffer.from((await reader.read()).value).toString(), 'first-media-chunk');
finish();
assert.equal(Buffer.from((await reader.read()).value).toString(), 'last-media-chunk');
assert.equal((await reader.read()).done, true);
});
test('proxy forwards ranges and reports when the origin ignores them', async t => {
const { proxy } = await fixture(t, (req, res) => {
assert.equal(req.headers.range, 'bytes=2-5');
if (req.url === '/honored') {
res.writeHead(206, { 'Content-Length': 4, 'Content-Range': 'bytes 2-5/10', 'Accept-Ranges': 'bytes' });
res.end('2345');
} else { res.writeHead(200, { 'Content-Length': 10 }); res.end('0123456789'); }
});
const honored = await fetch(proxy('/honored'), { headers: { Range: 'bytes=2-5' } });
assert.equal(honored.status, 206);
assert.equal(honored.headers.get('content-range'), 'bytes 2-5/10');
assert.equal(await honored.text(), '2345');
const ignored = await fetch(proxy('/ignored'), { headers: { Range: 'bytes=2-5' } });
assert.equal(ignored.status, 200);
assert.equal(ignored.headers.get('accept-ranges'), 'none');
assert.equal(await ignored.text(), '0123456789');
});
test('HLS rewrite handles split signatures, encryption keys, and the final redirected URL', async t => {
const { proxy, upstream } = await fixture(t, (req, res) => {
if (req.url === '/start') { res.writeHead(302, { Location: '/nested/guide.m3u8' }); return res.end(); }
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
res.write('#EXT');
setImmediate(() => res.end('M3U\n#EXT-X-KEY:METHOD=AES-128,URI="key.bin"\nsegment.ts\n'));
});
const response = await fetch(proxy('/start'));
const manifest = await response.text();
assert.equal(response.headers.get('content-type'), 'application/vnd.apple.mpegurl; charset=utf-8');
assert.ok(manifest.includes(`/api/proxy/stream?url=${encodeURIComponent(upstream + '/nested/key.bin')}`));
assert.ok(manifest.includes(`/api/proxy/stream?url=${encodeURIComponent(upstream + '/nested/segment.ts')}`));
assert.ok(!manifest.includes('http://localhost'));
});
test('oversized HLS manifests are rejected with a bounded buffer', async t => {
const { proxy } = await fixture(t, (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' });
res.end('#EXTM3U\n' + '#'.repeat(5 * 1024 * 1024));
});
const response = await fetch(proxy('/oversized'));
assert.equal(response.status, 502);
assert.match((await response.json()).error, /size limit/);
});
test('client cancellation closes upstream and upstream disconnects do not crash the process', async t => {
let upstreamClosed;
const closed = new Promise(resolve => { upstreamClosed = resolve; });
const { proxy } = await fixture(t, (req, res) => {
if (req.url === '/healthy') return res.end('healthy-media');
res.writeHead(200, { 'Content-Type': 'video/mp4' });
res.write('first-media-chunk');
if (req.url === '/disconnect') setTimeout(() => res.destroy(), 25);
else res.once('close', upstreamClosed);
});
const response = await fetch(proxy('/cancel'));
const reader = response.body.getReader();
await reader.read();
await reader.cancel();
await Promise.race([closed, new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error('Upstream not cancelled')), 2000);
timer.unref();
})]);
const broken = await fetch(proxy('/disconnect'));
await assert.rejects(broken.arrayBuffer());
assert.equal(await (await fetch(proxy('/healthy'))).text(), 'healthy-media');
});
test('one transient upstream error is retried; malformed query values return 400', async t => {
let calls = 0;
const { proxy, base } = await fixture(t, (req, res) => {
calls++;
if (calls === 1) { res.writeHead(503); return res.end('retry'); }
res.end('recovered-media');
});
assert.equal(await (await fetch(proxy('/retry'))).text(), 'recovered-media');
assert.equal(calls, 2);
for (const query of ['url=file%3A%2F%2F%2Fetc%2Fpasswd', 'url[x]=http%3A%2F%2Fexample.invalid',
'url=http%3A%2F%2Fexample.invalid&url=http%3A%2F%2Fexample.invalid']) {
assert.equal((await fetch(`${base}/api/proxy/stream?${query}`)).status, 400);
}
assert.equal(calls, 2);
});