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
@@ -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,5 +1,5 @@
|
||||
import { anyAbortSignal } from '@videojs/utils/events';
|
||||
import { generateId } from '../utils/generate-id';
|
||||
import { generateId } from '@videojs/utils/string';
|
||||
|
||||
// =============================================================================
|
||||
// DeepReadonly
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user