feat(spf): HLS engine composition walkthrough + doc-driven cleanups (#1512)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-05-05 12:07:26 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 17d44a5d32
commit 0cfd3bb395
103 changed files with 2509 additions and 1277 deletions
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import { ChunkedStreamIterable } from '../chunked-stream-iterable';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++]!);
} else {
controller.close();
}
},
});
}
function bytes(size: number, fill = 1): Uint8Array {
return new Uint8Array(size).fill(fill);
}
async function collect(iterable: AsyncIterable<Uint8Array>): Promise<Uint8Array[]> {
const result: Uint8Array[] = [];
for await (const chunk of iterable) {
result.push(chunk);
}
return result;
}
function totalBytes(chunks: Uint8Array[]): number {
return chunks.reduce((sum, c) => sum + c.length, 0);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChunkedStreamIterable', () => {
it('exposes minChunkSize', () => {
const stream = makeStream();
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 1024 });
expect(iterable.minChunkSize).toBe(1024);
});
it('defaults minChunkSize to 128 KB', () => {
const stream = makeStream();
const iterable = new ChunkedStreamIterable(stream);
expect(iterable.minChunkSize).toBe(2 ** 17);
});
it('yields a single chunk when it meets minChunkSize exactly', async () => {
const minChunkSize = 64;
const stream = makeStream(bytes(64));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(64);
});
it('yields a single chunk when it exceeds minChunkSize', async () => {
const minChunkSize = 64;
const stream = makeStream(bytes(100));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(100);
});
it('accumulates small chunks until minChunkSize is met', async () => {
const minChunkSize = 64;
// 3 × 30-byte chunks — first two should accumulate, third triggers flush at 90 bytes
const stream = makeStream(bytes(30, 1), bytes(30, 2), bytes(30, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(90);
});
it('flushes remaining bytes on stream end even if below minChunkSize', async () => {
const minChunkSize = 128;
const stream = makeStream(bytes(50));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(50);
});
it('preserves all bytes across multiple yielded chunks', async () => {
const minChunkSize = 50;
// 3 × 40-byte chunks → first two accumulate to 80 (≥50, yield), third is remainder
const stream = makeStream(bytes(40, 1), bytes(40, 2), bytes(40, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(totalBytes(chunks)).toBe(120);
});
it('concatenates chunk bytes correctly', async () => {
const minChunkSize = 4;
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4]);
const stream = makeStream(a, b);
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(Array.from(chunks[0]!)).toEqual([1, 2, 3, 4]);
});
it('yields nothing for an empty stream', async () => {
const stream = makeStream();
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize: 64 }));
expect(chunks).toHaveLength(0);
});
it('propagates errors from the underlying stream', async () => {
const errorStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error('network failure'));
},
});
await expect(collect(new ChunkedStreamIterable(errorStream, { minChunkSize: 64 }))).rejects.toThrow(
'network failure'
);
});
it('releases the reader lock after normal completion', async () => {
const stream = makeStream(bytes(10));
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 64 });
await collect(iterable);
// If lock was not released, getReader() would throw
expect(() => stream.getReader()).not.toThrow();
});
it('releases the reader lock after an error', async () => {
const errorStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error('fail'));
},
});
const iterable = new ChunkedStreamIterable(errorStream, { minChunkSize: 64 });
await expect(collect(iterable)).rejects.toThrow();
// Lock should be released even though we errored
expect(errorStream.locked).toBe(false);
});
it('handles multiple large chunks correctly', async () => {
const minChunkSize = 50;
// Each chunk already meets minChunkSize → each yielded individually
const stream = makeStream(bytes(60, 1), bytes(70, 2), bytes(80, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(3);
expect(chunks[0]!.length).toBe(60);
expect(chunks[1]!.length).toBe(70);
expect(chunks[2]!.length).toBe(80);
});
});
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource, ResponseLike } from '../fetch';
import { fetchResolvable, fetchResolvableStream, getResponseText } from '../fetch';
describe('fetchResolvable', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('fetches from Resource URL', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('content'));
const addressable: Resource = {
url: 'https://example.com/playlist.m3u8',
};
const response = await fetchResolvable(addressable);
expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request));
expect(response).toBeInstanceOf(Response);
});
it('returns Response from fetch', async () => {
const mockResponse = new Response('test content');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse);
const response = await fetchResolvable({ url: 'https://example.com/test.m3u8' });
expect(response).toBe(mockResponse);
});
it('accepts Resource with just url', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
const addressable: Resource = {
url: 'https://example.com/playlist.m3u8',
};
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
});
it('accepts Resource with byteRange', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
const addressable: Resource = {
url: 'https://example.com/segment.m4s',
byteRange: { start: 1000, end: 1999 },
};
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
});
it('handles zero-offset byte range', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
const addressable: Resource = {
url: 'https://example.com/init.mp4',
byteRange: { start: 0, end: 999 },
};
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
});
});
describe('fetchResolvableStream', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
function makeBodyStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++]!);
} else {
controller.close();
}
},
});
}
async function collect(gen: AsyncGenerator<Uint8Array>): Promise<Uint8Array[]> {
const result: Uint8Array[] = [];
for await (const chunk of gen) result.push(chunk);
return result;
}
it('yields chunks from the response body', async () => {
const data = new Uint8Array(256).fill(0xff);
const body = makeBodyStream(data);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body));
const chunks = await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 128 }));
const total = chunks.reduce((sum, c) => sum + c.length, 0);
expect(total).toBe(256);
});
it('passes the URL and byte-range header through to fetch', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
await collect(
fetchResolvableStream(
{ url: 'https://example.com/seg.m4s', byteRange: { start: 0, end: 99 } },
{ minChunkSize: 64 }
)
);
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
expect(req.headers.get('Range')).toBe('bytes=0-99');
});
it('throws when the response has no body', async () => {
const nullBodyResponse = new Response(null, { status: 204 });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(nullBodyResponse);
await expect(collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }))).rejects.toThrow(
'Response has no body'
);
});
it('does not pass minChunkSize as a fetch RequestInit option', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 512 }));
// fetch should have been called with a Request, not an object with minChunkSize
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
expect(req).toBeInstanceOf(Request);
});
});
describe('getResponseText', () => {
it('extracts text from ResponseLike', async () => {
const response: ResponseLike = {
text: async () => '#EXTM3U\n#EXT-X-VERSION:7',
};
const text = await getResponseText(response);
expect(text).toBe('#EXTM3U\n#EXT-X-VERSION:7');
});
it('returns promise from response.text()', async () => {
const mockText = vi.fn().mockResolvedValue('playlist content');
const response: ResponseLike = {
text: mockText,
};
const text = await getResponseText(response);
expect(mockText).toHaveBeenCalled();
expect(text).toBe('playlist content');
});
it('works with actual Response object', async () => {
const response = new Response('#EXTM3U');
const text = await getResponseText(response);
expect(text).toBe('#EXTM3U');
});
it('accepts minimal ResponseLike interface', () => {
const response: ResponseLike = {
text: async () => 'content',
};
expect(response.text).toBeDefined();
});
});