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,57 @@
const DEFAULT_MIN_CHUNK_SIZE = 2 ** 17; // 128 KB
export interface ChunkedStreamIterableOptions {
minChunkSize?: number;
}
/**
* Adapts a `ReadableStream<Uint8Array>` (e.g. `response.body`) into an
* `AsyncIterable<Uint8Array>` that yields chunks no smaller than
* `minChunkSize` bytes. Smaller network chunks are accumulated and yielded
* together once the threshold is met. Any remainder is flushed on stream end.
*
* Errors from the underlying stream propagate naturally — the reader lock is
* always released via `finally`.
*/
export class ChunkedStreamIterable implements AsyncIterable<Uint8Array> {
readonly minChunkSize: number;
#readableStream: ReadableStream<Uint8Array>;
constructor(
readableStream: ReadableStream<Uint8Array>,
{ minChunkSize = DEFAULT_MIN_CHUNK_SIZE }: ChunkedStreamIterableOptions = {}
) {
this.#readableStream = readableStream;
this.minChunkSize = minChunkSize;
}
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
let pending: Uint8Array | undefined;
const reader = this.#readableStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (pending) yield pending;
break;
}
pending = pending ? concat(pending, value) : value;
if (pending.length >= this.minChunkSize) {
yield pending;
pending = undefined;
}
}
} finally {
reader.releaseLock();
}
}
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const result = new Uint8Array(a.length + b.length);
result.set(a);
result.set(b, a.length);
return result;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* HTTP Fetch Wrapper
*
* Two-function approach for composability:
* 1. fetchResolvable() - Fetch Resource (handles byte ranges)
* 2. getResponseText() - Extract text from Response
* 3. fetchResolvableStream() - Stream body as Uint8Array chunks
*/
import { ChunkedStreamIterable, type ChunkedStreamIterableOptions } from './chunked-stream-iterable';
/**
* Minimal Response-like interface for text extraction.
* Allows testing without full Response object.
*/
export interface ResponseLike {
text(): Promise<string>;
}
/**
* An HTTP-addressable resource — URL plus optional byte range.
* Media's `AddressableObject` (and anything else with the same shape)
* is structurally compatible; kept local so this module stays
* domain-agnostic.
*/
export interface Resource {
url: string;
byteRange?: {
start: number;
end: number;
};
}
/**
* Fetch resolvable from a Resource.
*
* Handles byte range requests if byteRange is present.
* Returns native fetch Response for composability (can extract text, stream, etc.).
*
* @param addressable - Resource to fetch (url + optional byteRange)
* @returns Promise resolving to Response
*
* @example
* const response = await fetchResolvable({ url: 'https://example.com/segment.m4s' });
* const text = await getResponseText(response);
*
* @example
* // With byte range
* const response = await fetchResolvable({
* url: 'https://example.com/file.mp4',
* byteRange: { start: 1000, end: 1999 }
* });
*/
export async function fetchResolvable(addressable: Resource, options?: RequestInit): Promise<Response> {
const headers = new Headers(options?.headers);
// Add Range header for byte range requests
if (addressable.byteRange) {
const { start, end } = addressable.byteRange;
headers.set('Range', `bytes=${start}-${end}`);
}
const request = new Request(addressable.url, {
method: 'GET',
headers,
...options,
});
return fetch(request);
}
/**
* Fetch resolvable as bytes.
*
* Convenience wrapper around fetchResolvable that resolves the body as an
* ArrayBuffer. Use this when you need the raw bytes (e.g. segment appends).
* For text or streaming consumption, use fetchResolvable directly.
*/
export async function fetchResolvableBytes(addressable: Resource, options?: RequestInit): Promise<ArrayBuffer> {
const response = await fetchResolvable(addressable, options);
return response.arrayBuffer();
}
/**
* Fetch resolvable as a stream of Uint8Array chunks.
*
* Convenience wrapper around fetchResolvable that yields the body as chunks
* via ChunkedStreamIterable. Headers are awaited before the first chunk is
* yielded (TTFB is accounted for before iteration begins).
*
* Throws if the response body is null (e.g. non-body HTTP status).
* Errors from the underlying stream propagate naturally as thrown errors.
*/
export async function* fetchResolvableStream(
addressable: Resource,
options?: RequestInit & ChunkedStreamIterableOptions
): AsyncGenerator<Uint8Array> {
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error('Response has no body');
yield* new ChunkedStreamIterable(response.body, ...(minChunkSize !== undefined ? [{ minChunkSize }] : []));
}
/**
* Extract text from Response.
*
* Accepts minimal Response-like object (just needs text() method).
* Returns promise from response.text().
*
* @param response - Response-like object with text() method
* @returns Promise resolving to text content
*
* @example
* const response = await fetchResolvable(addressable);
* const text = await getResponseText(response);
*/
export function getResponseText(response: ResponseLike): Promise<string> {
return response.text();
}
@@ -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();
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2022", "WebWorker"],
"exactOptionalPropertyTypes": false,
"declarationDir": "../../types/network"
},
"references": [],
"include": ["./**/*.ts"]
}