mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.7
parent
17d44a5d32
commit
0cfd3bb395
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"declarationDir": "../../types/network"
|
||||
},
|
||||
"references": [],
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user