import { expect, test } from 'vitest'; import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { ALL_FORMATS, EncodedPacket, EncodedPacketSink, FilePathSource, Input, Logging, LogLevel, UrlSource, } from '../../src/index.js'; const __dirname = new URL('.', import.meta.url).pathname; const videoFilePath = path.join(__dirname, '..', 'public/video.mp4'); test('UrlSource works against a server without range request support', async () => { const server = await startRangelessServer(); const logs = captureLogs(); try { using input = new Input({ source: new UrlSource(server.url), formats: ALL_FORMATS, }); const track = await input.getPrimaryVideoTrack(); if (!track) throw new Error('No video track found'); const sink = new EncodedPacketSink(track); const timestamps: number[] = []; for await (const packet of sink.packets()) { timestamps.push(packet.timestamp); } expect(timestamps).toHaveLength(125); // The default cache size exceeds the file size, so random access back to the start of the file still works const firstPacket = await sink.getFirstPacket(); if (!firstPacket) throw new Error('No first packet found'); expect(firstPacket.timestamp).toBe(0); expect(firstPacket.data.byteLength).toBeGreaterThan(0); expect(logs.warnings.filter( x => x.includes('did not respond to a range request with 206 Partial Content'), )).toHaveLength(1); } finally { logs.stop(); server.close(); } }); test('UrlSource throws when reading from an evicted region in sequential mode', async () => { const server = await startRangelessServer(); const logs = captureLogs(); try { using input = new Input({ // Much smaller than the file, so the start of the file will get evicted during full iteration source: new UrlSource(server.url, { maxCacheSize: 2 ** 20 /* 1 MiB */ }), formats: ALL_FORMATS, }); const track = await input.getPrimaryVideoTrack(); if (!track) throw new Error('No video track found'); const sink = new EncodedPacketSink(track); const timestamps: number[] = []; for await (const packet of sink.packets()) { timestamps.push(packet.timestamp); } expect(timestamps).toHaveLength(125); await expect(sink.getFirstPacket()).rejects.toThrow(/already-evicted part of the cache/); expect(logs.warnings.filter( x => x.includes('did not respond to a range request with 206 Partial Content'), )).toHaveLength(1); } finally { logs.stop(); server.close(); } }); test('UrlSource resumes with correct data when the connection dies in sequential mode', async () => { // The server kills the connection partway through the first two responses. Since it doesn't support range // requests, each resume response starts back at byte 0 and the already-delivered prefix must be skipped over // exactly; any off-by-one would corrupt the packet data. const server = await startRangelessServer({ responseByteLimits: [700_000, 1_500_000] }); const logs = captureLogs(); try { using referenceInput = new Input({ source: new FilePathSource(videoFilePath), formats: ALL_FORMATS, }); const referenceTrack = await referenceInput.getPrimaryVideoTrack(); if (!referenceTrack) throw new Error('No video track found'); const referencePackets: EncodedPacket[] = []; for await (const packet of new EncodedPacketSink(referenceTrack).packets()) { referencePackets.push(packet); } using input = new Input({ source: new UrlSource(server.url, { getRetryDelay: () => 0 }), formats: ALL_FORMATS, }); const track = await input.getPrimaryVideoTrack(); if (!track) throw new Error('No video track found'); let packetIndex = 0; for await (const packet of new EncodedPacketSink(track).packets()) { const referencePacket = referencePackets[packetIndex]!; expect(packet.timestamp).toBe(referencePacket.timestamp); expect(Buffer.from(packet.data).equals(Buffer.from(referencePacket.data))).toBe(true); packetIndex++; } expect(packetIndex).toBe(referencePackets.length); // Initial request plus one resume per killed response expect(server.requestCount()).toBe(3); expect(logs.errors.filter(x => x.includes('Attempting to resume'))).toHaveLength(2); } finally { logs.stop(); server.close(); } }); test('UrlSource with maxCacheSize: Infinity allows random access against a rangeless server', async () => { const server = await startRangelessServer(); const logs = captureLogs(); try { using input = new Input({ // This is the escape hatch recommended by the eviction error message: with an infinite cache, nothing // is ever evicted and random access keeps working source: new UrlSource(server.url, { maxCacheSize: Infinity }), formats: ALL_FORMATS, }); const track = await input.getPrimaryVideoTrack(); if (!track) throw new Error('No video track found'); const sink = new EncodedPacketSink(track); const timestamps: number[] = []; for await (const packet of sink.packets()) { timestamps.push(packet.timestamp); } expect(timestamps).toHaveLength(125); const firstPacket = await sink.getFirstPacket(); if (!firstPacket) throw new Error('No first packet found'); expect(firstPacket.timestamp).toBe(0); expect(firstPacket.data.byteLength).toBeGreaterThan(0); expect(logs.warnings.filter( x => x.includes('did not respond to a range request with 206 Partial Content'), )).toHaveLength(1); } finally { logs.stop(); server.close(); } }); test('UrlSource in sequential mode downloads lazily and aborts the response on dispose', async () => { // The resource is padded to a size far beyond what kernel socket buffers can swallow. This matters: the server // can only observe the client's laziness through backpressure, and on some systems (like Linux with its TCP // buffer autotuning), the kernel happily buffers multiple megabytes of sent data that the client never read. const paddingSize = 2 ** 26; // 64 MiB const totalSize = fs.statSync(videoFilePath).size + paddingSize; const server = await startRangelessServer({ trailingPaddingSize: paddingSize }); const logs = captureLogs(); const input = new Input({ source: new UrlSource(server.url), formats: ALL_FORMATS, }); try { const track = await input.getPrimaryVideoTrack(); if (!track) throw new Error('No video track found'); // If the client were downloading eagerly, the entire resource would easily arrive during this window. // Instead, the server is expected to stall, since data is only pulled down when reads demand it. await new Promise(resolve => setTimeout(resolve, 3000)); expect(server.bytesSent()).toBeLessThan(totalSize / 2); // The response is merely suspended, not dead: reading still works const sink = new EncodedPacketSink(track); const firstPacket = await sink.getFirstPacket(); if (!firstPacket) throw new Error('No first packet found'); expect(firstPacket.timestamp).toBe(0); expect(firstPacket.data.byteLength).toBeGreaterThan(0); input.dispose(); // Give the abort a moment to propagate to the server await new Promise(resolve => setTimeout(resolve, 250)); // Disposal terminated the response early, long before the entire resource was sent expect(server.abortedResponses()).toBe(1); expect(server.bytesSent()).toBeLessThan(totalSize / 2); } finally { input.dispose(); logs.stop(); server.close(); } }, 10_000); const startRangelessServer = async ( options: { responseByteLimits?: number[]; trailingPaddingSize?: number } = {}, ) => { const fileSize = fs.statSync(videoFilePath).size; const paddingSize = options.trailingPaddingSize ?? 0; let requestCount = 0; let bytesSent = 0; let abortedResponses = 0; const server = http.createServer((req, res) => { const byteLimit = options.responseByteLimits?.[requestCount] ?? Infinity; requestCount++; res.on('error', () => {}); // The client may abort the connection at any time res.on('close', () => { if (!res.writableFinished) { abortedResponses++; } }); res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': fileSize + paddingSize, }); // Stream the file with backpressure and a small chunk size, so that the amount of sent bytes closely // tracks how much the client actually consumes const stream = fs.createReadStream(videoFilePath, { highWaterMark: 2 ** 14 }); let bytesSentThisResponse = 0; stream.on('data', (chunk) => { const canContinue = res.write(chunk); bytesSent += chunk.length; bytesSentThisResponse += chunk.length; if (bytesSentThisResponse >= byteLimit) { stream.destroy(); res.destroy(); // Kill the connection mid-response return; } if (!canContinue) { stream.pause(); res.once('drain', () => stream.resume()); } }); stream.on('end', () => { if (paddingSize === 0) { res.end(); return; } // Append a giant 'free' box: valid ISOBMFF, but never read by the demuxer const header = Buffer.alloc(8); header.writeUInt32BE(paddingSize, 0); header.write('free', 4, 'latin1'); const zeroes = Buffer.alloc(2 ** 14); let paddingSent = 0; const writeMore = () => { while (paddingSent < paddingSize) { const chunk = paddingSent === 0 ? header : zeroes.subarray(0, Math.min(paddingSize - paddingSent, zeroes.length)); paddingSent += chunk.length; bytesSent += chunk.length; if (!res.write(chunk)) { res.once('drain', writeMore); return; } } res.end(); }; writeMore(); }); }); await new Promise(resolve => server.listen(0, resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Unexpected server address'); return { url: `http://localhost:${address.port}/video.mp4`, requestCount: () => requestCount, bytesSent: () => bytesSent, abortedResponses: () => abortedResponses, close: () => { server.closeAllConnections(); server.close(); }, }; }; /** Collects logged warnings and errors, keeping the console clean while doing so. */ const captureLogs = () => { const warnings: string[] = []; const errors: string[] = []; const unsubscribeWarn = Logging.on('warn', args => warnings.push(args.map(String).join(' '))); const unsubscribeError = Logging.on('error', args => errors.push(args.map(String).join(' '))); const previousLogLevel = Logging.level; Logging.level = LogLevel.Silent; return { warnings, errors, stop: () => { unsubscribeWarn(); unsubscribeError(); Logging.level = previousLogLevel; }, }; };