wip(spf): collapse step deps to the { state, context, config } triple

The per-step `deps` was a bespoke bag: per-actor primitives
(sourceBufferActor/fetchBytes, textTracksActor/resolveSegment) hoisted
alongside the composition passthrough, with context/config dead-carried
(no step read them). Everything those primitives point at is already
reachable from { state, context, config }, so make that the whole deps.

Each loader folds its own wiring into the passthrough `config`, and base
steps read it via a typed accessor (stepWiring / textStepWiring) -- the
cast pattern relocation already uses for deps.state. The fold keeps the
invariant true in standalone/test use too (empty compositionDeps), so
the v/a loader's standalone tests are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-07-13 08:07:30 -07:00
co-authored by Claude Opus 4.8
parent 8a9e2911fd
commit 9bfb820af6
3 changed files with 81 additions and 39 deletions
@@ -137,22 +137,32 @@ export interface Frame {
*/
export type LoadStep = (frame: Frame, signal: AbortSignal, deps: StepDeps) => void | Promise<void>;
/** Per-actor + composition dependencies, passed to each {@link LoadStep} on every call. */
/**
* The uniform passthrough handed to every {@link LoadStep} on every call — the
* composition triple, nothing more. `state`/`context` are the full composition signal
* maps; `config` is the threaded config with the loader's own wiring folded in (see
* {@link stepWiring} + `createSegmentLoaderActor`). All three are typed loose: the
* loader is a conduit and never reads them; a step asserts the slots it knows are
* present (composition steps read `state`; base steps read the folded wiring off
* `config`).
*/
export interface StepDeps {
sourceBufferActor: SourceBufferActor;
fetchBytes: FetchBytes;
/**
* The composition deps — full `state`/`context` signal maps + engine `config` —
* passed opaquely so a step that needs composition signals reads them at call
* time (e.g. relocation writing/reading `state.mediaContainerData`). Typed loose:
* the loader is a conduit and never reads them; a step asserts the slots it knows
* the composition provides. Base steps (`fetch`/`dispatch`) ignore them.
*/
state: AnySlotMap;
context: AnySlotMap;
config: object;
}
/**
* Base-step view of the loader's own wiring. `createSegmentLoaderActor` folds its
* `sourceBufferActor` + `fetch` into the threaded `config` so base steps read them
* from the uniform passthrough — present whether the loader runs inside a composition
* or standalone. `config` is loose (`object`), so assert the shape here (one cast, like
* relocation's `containerSlot`).
*/
function stepWiring(deps: StepDeps): { sourceBufferActor: SourceBufferActor; fetch: FetchBytes } {
return deps.config as { sourceBufferActor: SourceBufferActor; fetch: FetchBytes };
}
/**
* Builds the ordered step list for each message type. Called **once per actor**
* — its role is per-actor instantiation, so stateful steps (relocation's origin
@@ -253,13 +263,15 @@ function toMessage({ op, data, meta }: Frame): IndividualSourceBufferMessage {
export const fetchStep: LoadStep = async (frame, signal, deps) => {
const { op } = frame;
if (op.type === 'remove') return; // fetchStep only appears in append pipelines
frame.data = await deps.fetchBytes(op, op.type === 'append-init' ? { signal, minChunkSize: Infinity } : { signal });
const { fetch } = stepWiring(deps);
frame.data = await fetch(op, op.type === 'append-init' ? { signal, minChunkSize: Infinity } : { signal });
};
/** Dispatch the frame's message to the SourceBufferActor and await its return to idle. */
export const dispatchStep: LoadStep = async (frame, signal, deps) => {
deps.sourceBufferActor.send(toMessage(frame));
await waitForIdle(deps.sourceBufferActor.snapshot, signal);
const { sourceBufferActor } = stepWiring(deps);
sourceBufferActor.send(toMessage(frame));
await waitForIdle(sourceBufferActor.snapshot, signal);
};
/** Tier 0 default: fetch (for ops that carry bytes) then dispatch. No relocation vocabulary. */
@@ -341,14 +353,21 @@ export function createSegmentLoaderActor(
sourceBufferActor: SourceBufferActor,
fetchBytes: FetchBytes,
config: SegmentLoaderActorConfig = {},
compositionDeps: Pick<StepDeps, 'state' | 'context' | 'config'> = { state: {}, context: {}, config: {} }
compositionDeps: StepDeps = { state: {}, context: {}, config: {} }
): SegmentLoaderActor {
type UserState = Exclude<SegmentLoaderActorState, 'destroyed'>;
type Ctx = HandlerContext<UserState, SegmentLoaderActorContext, () => SerialRunner>;
const forwardBufferConfig: ForwardBufferConfig = { ...DEFAULT_FORWARD_BUFFER_CONFIG, ...config.forwardBuffer };
const backBufferConfig: BackBufferConfig = { ...DEFAULT_BACK_BUFFER_CONFIG, ...config.backBuffer };
const deps: StepDeps = { sourceBufferActor, fetchBytes, ...compositionDeps };
// Fold the loader's own wiring into the passthrough `config` (see `stepWiring`) so
// base steps read it from the uniform `{state,context,config}` — present in both
// composition and standalone use.
const deps: StepDeps = {
state: compositionDeps.state,
context: compositionDeps.context,
config: { ...compositionDeps.config, sourceBufferActor, fetch: fetchBytes },
};
// Built once per actor (fresh stateful steps per source); default is `fetch → dispatch`.
const pipelines = (config.messagePipelines ?? DEFAULT_MESSAGE_PIPELINES)();
@@ -89,24 +89,34 @@ export interface TextFrame<C extends Cue = Cue> {
export type TextLoadStep<C extends Cue = Cue> = (
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps<C>
deps: TextStepDeps
) => void | Promise<void>;
/** Per-actor + composition dependencies, passed to each {@link TextLoadStep} on every call — the text analog of `StepDeps`. */
export interface TextStepDeps<C extends Cue = Cue> {
textTracksActor: TextTracksActor<C>;
resolveSegment: TextTrackSegmentResolver<C>;
/**
* The composition deps — full `state`/`context` signal maps + engine `config`
* passed opaquely so a step that needs composition signals reads them at call
* time (e.g. relocation reading the primary A/V track's `startMediaTime`). Typed
* loose: the loader is a conduit and never reads them; base steps ignore them.
*/
/**
* The uniform passthrough handed to each {@link TextLoadStep} — the composition triple,
* the text analog of `StepDeps`. `state`/`context` are the composition signal maps;
* `config` is the threaded config with the loader's wiring folded in (see
* {@link textStepWiring} + `createTextTrackSegmentLoaderActor`). Typed loose: composition
* steps read `state`; base steps read the folded wiring off `config`.
*/
export interface TextStepDeps {
state: AnySlotMap;
context: AnySlotMap;
config: object;
}
/**
* Base-step view of the loader's wiring, folded into `config` by
* `createTextTrackSegmentLoaderActor` so base steps read it from the uniform passthrough
* — present in both composition and standalone use. `config` is loose (`object`), so
* assert the shape here (mirrors the v/a loader's `stepWiring`).
*/
export function textStepWiring<C extends Cue>(
deps: TextStepDeps
): { textTracksActor: TextTracksActor<C>; resolveSegment: TextTrackSegmentResolver<C> } {
return deps.config as { textTracksActor: TextTracksActor<C>; resolveSegment: TextTrackSegmentResolver<C> };
}
/**
* Builds the ordered step list, called **once per actor** (mirrors the v/a loader's
* `MessagePipelines`, but text has a single op type so it's a flat array, not a
@@ -145,17 +155,17 @@ interface TextLoadTask {
// =============================================================================
// Base steps are generic over the cue type `C` (generic arrow consts, not
// `TextLoadStep<Cue>` values): `TextStepDeps` is contravariant in `C` via
// `textTracksActor.send`, so a `Cue`-typed step wouldn't slot into a `VTTCue`
// pipeline. A generic function assigns to any `TextLoadStep<C>` instantiation.
// `TextLoadStep<Cue>` values): each touches `C` — `frame.cues: C[]` and the
// `C`-typed `textStepWiring<C>` — so a `Cue`-typed const wouldn't slot into a
// `VTTCue` pipeline. A generic function assigns to any `TextLoadStep<C>`.
/** Resolve the op's cues (via the injected host primitive) into the frame. The text analog of `fetchStep`. */
export const resolveCuesStep = async <C extends Cue>(
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps<C>
deps: TextStepDeps
): Promise<void> => {
const cues = await deps.resolveSegment(frame.op.segment.url);
const cues = await textStepWiring<C>(deps).resolveSegment(frame.op.segment.url);
if (signal.aborted) return;
frame.cues = cues;
};
@@ -164,10 +174,10 @@ export const resolveCuesStep = async <C extends Cue>(
export const dispatchCuesStep = <C extends Cue>(
frame: TextFrame<C>,
_signal: AbortSignal,
deps: TextStepDeps<C>
deps: TextStepDeps
): void => {
const { op } = frame;
deps.textTracksActor.send({
textStepWiring<C>(deps).textTracksActor.send({
type: 'add-cues',
meta: {
trackId: op.trackId,
@@ -216,13 +226,20 @@ export function createTextTrackSegmentLoaderActor<C extends Cue>(
// Composition deps threaded opaquely into each step's `TextStepDeps` (relocation
// reads composition state). The loader never reads them. Defaults empty for
// standalone / base-pipeline use.
compositionDeps: Pick<TextStepDeps<C>, 'state' | 'context' | 'config'> = { state: {}, context: {}, config: {} }
compositionDeps: TextStepDeps = { state: {}, context: {}, config: {} }
): TextTrackSegmentLoaderActor {
type UserState = Exclude<TextTrackSegmentLoaderActorState, 'destroyed'>;
type Ctx = HandlerContext<UserState, TextTrackSegmentLoaderActorContext, () => SerialRunner>;
const forwardBufferConfig: ForwardBufferConfig = { ...DEFAULT_FORWARD_BUFFER_CONFIG, ...config.forwardBuffer };
const deps: TextStepDeps<C> = { textTracksActor, resolveSegment, ...compositionDeps };
// Fold the loader's wiring into the passthrough `config` (see `textStepWiring`) so
// base steps read it from the uniform `{state,context,config}` — present in both
// composition and standalone use.
const deps: TextStepDeps = {
state: compositionDeps.state,
context: compositionDeps.context,
config: { ...compositionDeps.config, textTracksActor, resolveSegment },
};
// Built once per actor; default is `resolveCues → dispatchCues`.
const pipeline = (config.messagePipelines ?? DEFAULT_TEXT_MESSAGE_PIPELINES)();
@@ -32,7 +32,12 @@ import {
type MessagePipelines,
type StepDeps,
} from '../../actors/dom/segment-loader';
import { dispatchCuesStep, type TextLoadStep, type TextMessagePipelines } from '../../actors/text-track-segment-loader';
import {
dispatchCuesStep,
type TextLoadStep,
type TextMessagePipelines,
textStepWiring,
} from '../../actors/text-track-segment-loader';
import { peekHead } from '../../primitives/head-peek';
import type { EstablishStartMediaTimeState } from '../establish-start-media-time';
@@ -139,14 +144,15 @@ function awaitDefined(read: () => number | undefined): Promise<number> {
/**
* Resolve step for the relocation text pipeline. Reuses the injected host resolver
* (`deps.resolveSegment`) for cues and fetches the `X-TIMESTAMP-MAP` header in
* parallel, stashing it on `frame.metadata` for `relocateCuesStep`. Replaces the
* (the loader's folded `resolveSegment`, via `textStepWiring`) for cues and fetches
* the `X-TIMESTAMP-MAP` header in parallel, stashing it on `frame.metadata` for
* `relocateCuesStep`. Replaces the
* base `resolveCuesStep` (which fetches cues only) — text's native `<track>` parser
* discards the header, so the map needs its own raw-bytes fetch.
*/
const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
const [cues, metadata] = await Promise.all([
deps.resolveSegment(frame.op.segment.url),
textStepWiring<VTTCue>(deps).resolveSegment(frame.op.segment.url),
resolveVttSegmentMetadata(frame.op.segment.url),
]);
if (signal.aborted) return;