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
@@ -189,25 +189,29 @@ export interface CompositionOptions<S extends object, O extends object, C extend
* or any specific protocol. It creates shared reactive state, wires
* each behavior to that state, and returns the composition interface.
*
* The state, owners, and config types are inferred from the behaviors:
* each behavior declares what it needs via its parameter type, and the
* engine computes the intersection of all requirements.
* Two ways to call:
*
* 1. **Inferred** — pass behaviors and let TypeScript intersect their
* deps to compute the engine's state, owners, and config shapes.
* Best when behaviors declare narrow per-feature shapes.
* 2. **Explicit** — supply `<S, O, C>` type arguments and the engine
* uses those shapes directly. Best for engines that aggregate many
* wrapper-style behaviors all sharing the same `Behavior<S, O, C>`
* type — TypeScript's distributive intersection inference can drop
* types in that case, so explicit arguments are more reliable.
*
* @param behaviors - Array of behavior functions
* @param options - Optional config, initial state, and initial owners
*
* @example
* ```ts
* // Minimal — just behaviors
* const engine = createComposition([myBehavior]);
* // 1. Inferred
* const engine = createComposition([resolvePresentation, selectVideoTrack]);
*
* // With config and initial state
* const engine = createComposition(
* [resolvePresentation, selectVideoTrack, loadVideoSegments],
* {
* config: { initialBandwidth: 2_000_000 },
* initialState: { bandwidthState: initialBandwidthState() },
* }
* // 2. Explicit (engine declares its full state/owners/config up front)
* const engine = createComposition<MyState, MyOwners, MyConfig>(
* [behavior1, behavior2, ...],
* { config, initialState, initialOwners }
* );
* ```
*/
@@ -218,19 +222,21 @@ export function createComposition<const Behaviors extends readonly AnyBehavior[]
ResolveBehaviorOwners<Behaviors>,
ResolveBehaviorConfig<Behaviors>
>
): Composition<ResolveBehaviorState<Behaviors>, ResolveBehaviorOwners<Behaviors>> {
type S = ResolveBehaviorState<Behaviors>;
type O = ResolveBehaviorOwners<Behaviors>;
type C = ResolveBehaviorConfig<Behaviors>;
): Composition<ResolveBehaviorState<Behaviors>, ResolveBehaviorOwners<Behaviors>>;
export function createComposition<S extends object, O extends object, C extends object>(
behaviors: readonly Behavior<S, O, C>[],
options?: CompositionOptions<S, O, C>
): Composition<S, O>;
export function createComposition(
behaviors: readonly AnyBehavior[],
options?: CompositionOptions<object, object, object>
): Composition<object, object> {
const state = signal(options?.initialState ?? {});
const owners = signal(options?.initialOwners ?? {});
const config = options?.config ?? {};
const state = signal((options?.initialState ?? {}) as S);
const owners = signal((options?.initialOwners ?? {}) as O);
const config = (options?.config ?? {}) as C;
const deps: BehaviorDeps<S, O, C> = { state, owners, config };
// ValidateComposition resolves to [...Behaviors] for valid compositions;
// the cast is needed because the type is unresolved in the generic context.
const cleanups = (behaviors as unknown as AnyBehavior[]).map((f) => f(deps));
const deps: BehaviorDeps<object, object, object> = { state, owners, config };
const cleanups = behaviors.map((f) => f(deps));
return {
state,
@@ -248,7 +254,7 @@ export function createComposition<const Behaviors extends readonly AnyBehavior[]
await Promise.all(results);
// Clear any keys behaviors registered — or callers seeded via
// initialOwners — so the composition ends with an empty owners map.
owners.set({} as O);
owners.set({});
},
};
}
@@ -4,6 +4,24 @@ import type { Signal } from '../../signals/primitives';
import { update } from '../../signals/primitives';
import { createComposition } from '../create-composition';
// =============================================================================
// Host-agnostic stand-in types
// -----------------------------------------------------------------------------
// Core is DOM-free. The tests need a small, concrete type to stand in for the
// kind of thing a user would pass as an owner — something with a writable
// surface and clear subtype relationships for covariance / non-covariance tests.
// =============================================================================
interface Surface {
textContent?: string | null;
}
interface VideoSurface extends Surface {
kind: 'video';
}
interface CanvasSurface extends Surface {
kind: 'canvas';
}
// =============================================================================
// Test behaviors
// =============================================================================
@@ -21,7 +39,7 @@ function render({
config,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ renderElement?: HTMLElement }>;
owners: Signal<{ renderElement?: Surface }>;
config: { defaultText?: string };
}) {
return effect(() => {
@@ -60,7 +78,7 @@ describe('createComposition type errors', () => {
});
it('errors when initialOwners has wrong types', () => {
// @ts-expect-error — renderElement expects HTMLElement, not number
// @ts-expect-error — renderElement expects Surface, not number
createComposition([render], { initialOwners: { renderElement: 42 } });
});
@@ -94,10 +112,10 @@ describe('createComposition type errors', () => {
});
it('errors when composing behaviors with incompatible owners class types', () => {
const expectsCanvas = (_deps: { owners: Signal<{ el?: HTMLCanvasElement }> }) => {};
const expectsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {};
const expectsCanvas = (_deps: { owners: Signal<{ el?: CanvasSurface }> }) => {};
const expectsVideo = (_deps: { owners: Signal<{ el?: VideoSurface }> }) => {};
// @ts-expect-error — neither HTMLCanvasElement nor HTMLVideoElement extends the other
// @ts-expect-error — neither CanvasSurface nor VideoSurface extends the other
createComposition([expectsCanvas, expectsVideo]);
});
@@ -106,16 +124,16 @@ describe('createComposition type errors', () => {
// =========================================================================
it('allows composing behaviors with owners in a subtype relationship', () => {
const expectsElement = (_deps: { owners: Signal<{ el?: HTMLElement }> }) => {};
const expectsVideo = (_deps: { owners: Signal<{ el?: HTMLVideoElement }> }) => {};
const expectsSurface = (_deps: { owners: Signal<{ el?: Surface }> }) => {};
const expectsVideo = (_deps: { owners: Signal<{ el?: VideoSurface }> }) => {};
// No error — HTMLVideoElement extends HTMLElement
createComposition([expectsElement, expectsVideo]);
// No error — VideoSurface extends Surface
createComposition([expectsSurface, expectsVideo]);
});
it('allows composing behaviors that omit owners', () => {
const stateOnly = (_deps: { state: Signal<{ count?: number }> }) => {};
const withOwners = (_deps: { state: Signal<{ count?: number }>; owners: Signal<{ el?: HTMLElement }> }) => {};
const withOwners = (_deps: { state: Signal<{ count?: number }>; owners: Signal<{ el?: Surface }> }) => {};
// No error — omitting owners is not a conflict
createComposition([stateOnly, withOwners]);
@@ -139,7 +157,7 @@ describe('createComposition type errors', () => {
it('allows composing behaviors where each omits different channels', () => {
const onlyState = (_deps: { state: Signal<{ count?: number }> }) => {};
const onlyOwners = (_deps: { owners: Signal<{ el?: HTMLElement }> }) => {};
const onlyOwners = (_deps: { owners: Signal<{ el?: Surface }> }) => {};
const onlyConfig = (_deps: { config: { interval?: number } }) => {};
// No error — behaviors with disjoint channels don't conflict
@@ -161,7 +179,7 @@ describe('createComposition type errors', () => {
});
it('allows resetting optional owners fields to undefined', () => {
const behavior = (_deps: { owners: Signal<{ el?: HTMLElement }> }) => {};
const behavior = (_deps: { owners: Signal<{ el?: Surface }> }) => {};
const engine = createComposition([behavior]);
// No error — clearing an owner (e.g. on source switch)
@@ -11,6 +11,18 @@ import {
type ResolveBehaviorState,
} from '../create-composition';
// =============================================================================
// Host-agnostic stand-in types
// -----------------------------------------------------------------------------
// Core is DOM-free. The tests need a small, concrete type to stand in for the
// kind of thing a user would pass as an owner — something with a writable
// surface and clear subtype relationships for covariance tests.
// =============================================================================
interface Surface {
textContent?: string | null;
}
// =============================================================================
// Test behaviors — concrete parameter types
// =============================================================================
@@ -28,7 +40,7 @@ function render({
config,
}: {
state: Signal<{ count?: number }>;
owners: Signal<{ renderElement?: HTMLElement }>;
owners: Signal<{ renderElement?: Surface }>;
config: { defaultText?: string };
}) {
return effect(() => {
@@ -87,7 +99,7 @@ describe('InferBehaviorState', () => {
describe('InferBehaviorOwners', () => {
it('extracts owners type from a behavior that uses owners', () => {
expectTypeOf<InferBehaviorOwners<typeof render>>().toEqualTypeOf<{ renderElement?: HTMLElement }>();
expectTypeOf<InferBehaviorOwners<typeof render>>().toEqualTypeOf<{ renderElement?: Surface }>();
});
it('returns object for a behavior with no owners in params', () => {
@@ -136,8 +148,8 @@ describe('ResolveBehaviorOwners', () => {
it('resolves owners from mixed behaviors (some without owners)', () => {
// counter has no owners, render has renderElement
type Behaviors = [typeof counter, typeof render];
// object & { renderElement?: HTMLElement } should simplify
expectTypeOf<ResolveBehaviorOwners<Behaviors>>().toExtend<{ renderElement?: HTMLElement }>();
// object & { renderElement?: Surface } should simplify
expectTypeOf<ResolveBehaviorOwners<Behaviors>>().toExtend<{ renderElement?: Surface }>();
});
});
@@ -178,11 +190,11 @@ describe('createComposition', () => {
const engine = createComposition([counter, render, persist], {
initialState: { count: 0 },
config: { interval: 250, defaultText: '--', saveEvery: 5 },
initialOwners: { renderElement: null as unknown as HTMLElement },
initialOwners: { renderElement: null as unknown as Surface },
});
expectTypeOf(engine.state.get()).toExtend<{ count?: number }>();
expectTypeOf(engine.owners.get()).toExtend<{ renderElement?: HTMLElement }>();
expectTypeOf(engine.owners.get()).toExtend<{ renderElement?: Surface }>();
});
it('infers combined state from behaviors with different state shapes', () => {
@@ -225,12 +237,12 @@ describe('createComposition', () => {
it('allows update() with undefined for optional owners fields', () => {
const engine = createComposition([render], {
initialOwners: { renderElement: null as unknown as HTMLElement },
initialOwners: { renderElement: null as unknown as Surface },
});
// Clearing an owner (e.g. on source switch)
update(engine.owners, { renderElement: undefined });
expectTypeOf(engine.owners.get().renderElement).toEqualTypeOf<HTMLElement | undefined>();
expectTypeOf(engine.owners.get().renderElement).toEqualTypeOf<Surface | undefined>();
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { anyAbortSignal } from '@videojs/utils/events';
import { generateId } from '../utils/generate-id';
import { generateId } from '@videojs/utils/string';
// =============================================================================
// DeepReadonly
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2022", "WebWorker"],
"exactOptionalPropertyTypes": false,
"declarationDir": "../../types/core"
},
"references": [{ "path": "../../../utils" }],
"include": ["./**/*.ts"]
}
@@ -1,16 +0,0 @@
/**
* Generate unique ID for HAM objects.
*
* Uses timestamp + random number for sufficient uniqueness.
* IDs are strings without decimals.
*
* @returns Unique string ID in format: timestamp-random
*
* @example
* ```ts
* const id = generateId(); // "1738423156789-542891"
* ```
*/
export function generateId(): string {
return `${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
}
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { generateId } from '../generate-id';
describe('generateId', () => {
it('generates a string ID', () => {
const id = generateId();
expect(typeof id).toBe('string');
});
it('generates unique IDs', () => {
const id1 = generateId();
const id2 = generateId();
const id3 = generateId();
expect(id1).not.toBe(id2);
expect(id2).not.toBe(id3);
expect(id1).not.toBe(id3);
});
it('generates IDs without decimals', () => {
const id = generateId();
expect(id).not.toMatch(/\./);
});
it('generates IDs in consistent format', () => {
const id = generateId();
// Should be timestamp-random format
expect(id).toMatch(/^\d+-\d+$/);
});
it('generates different IDs in rapid succession', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) {
ids.add(generateId());
}
// All should be unique
expect(ids.size).toBe(100);
});
});