7.3 KiB
@videojs/spf source layout
Guidance for AI agents and contributors working in packages/spf/src/.
Top-level layout
src/
core/ SPF runtime primitives — signals, effects, tasks, actors, reactors, composition
media/ CML-like media building blocks — types, parsers, ABR, buffer logic, MSE/VTT primitives
network/ HTTP fetch utilities, chunked-stream iterables
playback/ Playback domain — composes core+media+network into engines
primitives/ Signal-aware playback building blocks composed by behaviors (no behavior lifecycle)
behaviors/ Compositional behaviors that drive playback (use signals/effects)
actors/ Actor factories used inside behaviors
engines/ Assembled playback engines (currently just hls/)
media/, network/, and playback/behaviors/ each have a dom/ subdirectory for DOM-bound code (browser APIs like HTMLMediaElement, MediaSource, VTTCue). DOM-free code lives outside those subdirs.
Dependency rules
| From → To | Allowed? | Enforced by |
|---|---|---|
media/ → core/ |
❌ | media/tsconfig.json does not reference core |
media/ → network/ |
❌ | media/tsconfig.json does not reference network |
media/ → playback/ |
❌ | playback/ not referenced |
network/ → core/ |
❌ | network/tsconfig.json has empty references |
network/ → media/ |
❌ | same |
network/ → playback/ |
❌ | same |
core/ → media/, network/, playback/ |
❌ | core/tsconfig.json does not reference downstream subtrees |
core/ → DOM |
❌ | core/tsconfig.json lib excludes DOM |
media/ (non-dom) → DOM |
❌ | media/tsconfig.json lib excludes DOM |
network/ → DOM |
❌ | network/tsconfig.json lib excludes DOM |
playback/primitives/ → DOM |
❌ | tsconfig lib excludes DOM |
playback/behaviors/ (non-dom) → DOM |
❌ | tsconfig lib excludes DOM |
playback/actors/ (non-dom) → DOM |
❌ | tsconfig lib excludes DOM |
playback/behaviors/ → playback/primitives/ |
✅ | references |
playback/ → core/, network/, media/ |
✅ | references in playback tsconfigs |
playback/engines/hls/ → playback/behaviors/, playback/actors/ |
✅ | references |
playback/engines/hls/ → core/, media/ |
✅ — engines compose primitives directly | references |
The substance: core/, media/, network/ are framework-agnostic foundations. playback/ is the SPF-integrated layer that composes them. Engines live at the top of the dependency tree and may reach into any foundation.
Where to put new code
- Pure media/streaming logic (parsers, types, selection algorithms, MSE/VTT helpers without signals):
media/ormedia/dom/. Must not import fromcore/. - Signal-aware playback primitive (a composable building block that reads/writes
coresignals but has no behavior lifecycle — noeffect/computed/subscribe, not invoked by composition):playback/primitives/. These are composed by behaviors/actors/engines (e.g.failoverFetch, a fetch decorator that reads selected-track signals at fetch time;track-types, the per-type config bundles). They live at theplayback/layer rather thanmedia//network/precisely because they touchcore/— that's the line below. - Compositional behavior driving state (uses
effect/computed/updateagainst owners or state signals):playback/behaviors/orplayback/behaviors/dom/. - Actor factories (long-lived stateful units that receive messages):
playback/actors/orplayback/actors/dom/. - Engine compositions (wiring behaviors+actors+config into a
createCompositioncall):playback/engines/<name>/. - Generic, framework-agnostic utilities that aren't media-specific: prefer
@videojs/utilsover creating new homes inside spf.
If a module looks like a media//network/ primitive but reaches into core/, that's a smell — first consider whether the signal binding can move to the call site (see onMediaSourceReadyStateChange for a callback-shaped primitive that lets the consumer create the signal), keeping the primitive itself core-free. Only when a building block genuinely needs live signal access — e.g. failoverFetch is constructed once but its returned fetch reads presentation/selected-track signals lazily on every call — does it belong at the playback/ layer, in playback/primitives/.
Conversely: if a function inside playback/behaviors/ (or playback/actors/) has no core/ dependency — no signals, effects, or reactors — it probably belongs in a layer below (media/, network/, or @videojs/utils). Same layering principle, opposite direction. When reviewing a behavior or actor file, scan its top-level helpers; any pure data-manipulation / lookup / format-handling code with no reactive concerns is a candidate to extract.
DOM-vs-not enforcement
The DOM/no-DOM split is enforced by lib settings on each subtree's tsconfig.json:
- DOM-free subtrees use
lib: ["ES2022", "WebWorker"]— referencingHTMLMediaElement,document, etc. fails typecheck. - DOM subtrees use
lib: ["ES2022", "DOM", "DOM.Iterable"].
This is structural — you cannot accidentally import DOM types into a non-DOM module without surfacing a typecheck error.
Public exports
External consumers go through three entry points (defined in packages/spf/package.json):
@videojs/spf— main entry (src/index.ts): runtime-agnostic primitives + media/network helpers.@videojs/spf/dom— DOM-bound exports (src/dom.ts): re-exports fromplayback/behaviors/dom/andmedia/dom/.@videojs/spf/hls— HLS engine (src/playback/engines/hls/index.ts).
Internal paths are not part of the public API. Don't import from @videojs/spf/playback/... or similar deep paths in other workspace packages.
Vitest projects
packages/spf/vitest.config.ts shards tests by area:
core,media,network,behaviors— Node, no browser (thebehaviorsproject also coversplayback/actors/andplayback/primitives/)dom— Chromium via Playwright, covers all**/dom/**/*.test.tsacross subtreesplayback-engines— Chromium, covers engines/types— type-only tests via tsgo
When adding a test in a new location, check the project globs match.
Code style
SPF-specific style preferences. These extend the workspace-wide rules in the root CLAUDE.md — they apply across packages/spf/src/, not just behaviors. (Style guidance specific to a single domain — behavior naming, reactor state names, signal access patterns — lives in internal/design/spf/conventions/ instead.)
Prefer const + ternary over let + conditional reassignment
For single-branch derivations of one value, use a const with a ternary rather than a let that gets conditionally overwritten:
// Good
const duration = maxBufferedEnd > target ? maxBufferedEnd : target;
// Avoid
let duration = target;
if (maxBufferedEnd > duration) {
duration = maxBufferedEnd;
}
The const-ternary form makes the value's identity immutable from its first binding — readers don't have to scan ahead for reassignment, and the dependency on each branch's inputs is structurally clear. let is appropriate when the variable is genuinely mutable across multiple statements (accumulators, loop indices, state that flips on conditions other than a single-shot derivation); reserve it for those cases.