build(spf): build26 from 45504a2b

This commit is contained in:
publish
2026-08-05 18:57:39 +02:00
commit 8920135306
424 changed files with 17907 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { Machine, MachineSnapshot } from "../machine.js";
//#region src/core/actors/actor.d.ts
/**
* Complete actor snapshot: finite state + non-finite context.
* Extends `MachineSnapshot` with context — the non-finite data managed by the actor.
*/
interface ActorSnapshot<State extends string, Context extends object> extends MachineSnapshot<State> {
context: Context;
}
/** Generic actor interface: owns its snapshot as a reactive signal. */
interface SignalActor<State extends string, Context extends object> extends Machine<ActorSnapshot<State, Context>> {}
/**
* A message-driven actor with no reactive snapshot.
*
* Use for actors that coordinate async work but have no state that external
* consumers need to observe. Analogous to XState's `fromCallback`.
*/
interface CallbackActor<Message extends {
type: string;
}> {
send(message: Message): void;
destroy(): void;
}
//#endregion
export { ActorSnapshot, CallbackActor, SignalActor };
//# sourceMappingURL=actor.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"actor.d.ts","names":[],"sources":["../../../../src/core/actors/actor.ts"],"mappings":";;;;;;UAeiB,cAAc,sBAAsB,gCAAgC,gBAAgB;EACnG,SAAS;;;UAIM,YAAY,sBAAsB,gCACzC,QAAQ,cAAc,OAAO;;;;;;;UAQtB,cAAc;EAAkB;;EAC/C,KAAK,SAAS;EACd"}
+128
View File
@@ -0,0 +1,128 @@
import { TaskLike } from "../tasks/task.js";
import { SignalActor } from "./actor.js";
//#region src/core/actors/create-machine-actor.d.ts
/**
* Minimal interface for any runner that can be used with createMachineActor.
*/
interface RunnerLike {
schedule<Value = void, Err = unknown>(task: TaskLike<Value, Err>): Promise<Value>;
abortAll(): void;
destroy(): void;
whenSettled(callback: () => void): void;
}
/**
* Context passed to message handlers.
* `runner` is present and typed as the exact runner instance only when the
* definition includes a runner factory.
*/
type HandlerContext<UserState extends string, Context extends object, RunnerFactory extends (() => RunnerLike) | undefined> = {
transition: (to: UserState) => void;
/** Context snapshot captured at dispatch time. Stale after any `setContext` call. */
context: Context;
/**
* Live untracked read of the current context. Use in async task closures that
* execute after the handler returns — e.g. `getCtx: getContext` passed to tasks
* scheduled on the runner, so each task reads the context committed by the
* previous task rather than the stale snapshot from dispatch time.
*/
getContext: () => Context;
setContext: (next: Context) => void;
} & (RunnerFactory extends (() => infer R) ? {
runner: R;
} : object);
/**
* Definition for a single user-defined state.
*/
type ActorStateDefinition<UserState extends string, Context extends object, Message extends {
type: string;
}, RunnerFactory extends (() => RunnerLike) | undefined> = {
/**
* When the actor's runner settles while in this state, automatically
* transition to this state. The framework owns the generation-token logic —
* re-registering after each `runner.schedule()` call so that
* `abortAll()` + reschedule correctly supersedes stale callbacks.
*/
onSettled?: UserState;
/** Message handlers active in this state. Messages with no handler are silently dropped. */
on?: { [M in Message as M['type']]?: (message: Extract<Message, {
type: M['type'];
}>, ctx: HandlerContext<UserState, Context, RunnerFactory>) => void; };
};
/**
* Full actor definition passed to `createMachineActor`.
*
* `UserState` is the set of domain-meaningful states. `'destroyed'` is always
* added by the framework as the implicit terminal state — do not include it here.
*/
type ActorDefinition<UserState extends string, Context extends object, Message extends {
type: string;
}, RunnerFactory extends (() => RunnerLike) | undefined = undefined> = {
/**
* Runner factory — called once at `createMachineActor()` time.
* The runner lives for the full actor lifetime and is destroyed with it.
*
* @example
* runner: () => new SerialRunner()
*/
runner?: RunnerFactory;
/** Initial state. */
initial: UserState;
/** Initial context. */
context: Context;
/**
* Per-state definitions. States with no definition silently drop all messages.
* All user-defined states must appear as keys in the `UserState` union.
*/
states: Partial<Record<UserState, ActorStateDefinition<UserState, Context, Message, RunnerFactory>>>;
};
/** Live actor instance returned by `createMachineActor`. */
interface MessageActor<State extends string, Context extends object, Message extends {
type: string;
}> extends SignalActor<State, Context> {
send(message: Message): void;
}
/**
* Creates a message-driven actor from a declarative definition.
*
* The actor owns a reactive snapshot signal (state + context), an optional
* runner, and dispatches incoming messages to per-state handlers. `'destroyed'`
* is always the implicit terminal state — `destroy()` transitions there
* unconditionally and all subsequent `send()` calls are no-ops.
*
* When a state declares `onSettled`, the framework calls `runner.whenSettled()`
* after the handler returns. The runner owns the generation-token logic — if
* new tasks are scheduled before the current batch settles, the callback is
* automatically superseded.
*
* @example
* const actor = createMachineActor({
* runner: () => new SerialRunner(),
* initial: 'idle',
* context: {},
* states: {
* idle: {
* on: {
* load: (msg, { transition, runner }) => {
* segments.forEach(s => runner.schedule(new Task(...)));
* transition('loading');
* }
* }
* },
* loading: {
* onSettled: 'idle',
* on: {
* load: (msg, { runner }) => {
* runner.abortAll();
* segments.forEach(s => runner.schedule(new Task(...)));
* }
* }
* }
* }
* });
*/
declare function createMachineActor<UserState extends string, Context extends object, Message extends {
type: string;
}, RunnerFactory extends (() => RunnerLike) | undefined = undefined>(def: ActorDefinition<UserState, Context, Message, RunnerFactory>): MessageActor<UserState | 'destroyed', Context, Message>;
//#endregion
export { ActorDefinition, ActorStateDefinition, HandlerContext, MessageActor, RunnerLike, createMachineActor };
//# sourceMappingURL=create-machine-actor.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"create-machine-actor.d.ts","names":[],"sources":["../../../../src/core/actors/create-machine-actor.ts"],"mappings":";;;;;;UAYiB;EACf,SAAS,cAAc,eAAe,MAAM,SAAS,OAAO,OAAO,QAAQ;EAC3E;EACA;EACA,YAAY;;;;;;;KAYF,eACV,0BACA,wBACA,6BAA6B;EAE7B,aAAa,IAAI;;EAEjB,SAAS;;;;;;;EAOT,kBAAkB;EAClB,aAAa,MAAM;KAChB,mCAAkC;EAAM,QAAQ;;;;;KAKzC,qBACV,0BACA,wBACA;EAAkB;GAClB,6BAA6B;;;;;;;EAQ7B,YAAY;;EAEZ,QACG,KAAK,WAAW,cACf,SAAS,QAAQ;IAAW,MAAM;MAClC,KAAK,eAAe,WAAW,SAAS;;;;;;;;KAWlC,gBACV,0BACA,wBACA;EAAkB;GAClB,6BAA6B;;;;;;;;EAS7B,SAAS;;EAET,SAAS;;EAET,SAAS;;;;;EAKT,QAAQ,QAAQ,OAAO,WAAW,qBAAqB,WAAW,SAAS,SAAS;;;UAQrE,aAAa,sBAAsB,wBAAwB;EAAkB;WACpF,YAAY,OAAO;EAC3B,KAAK,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8CA,mBACd,0BACA,wBACA;EAAkB;GAClB,6BAA6B,qCAE7B,KAAK,gBAAgB,WAAW,SAAS,SAAS,iBACjD,aAAa,yBAAyB,SAAS"}
+91
View File
@@ -0,0 +1,91 @@
import { untrack, update } from "../signals/primitives.js";
import { createMachineCore } from "../machine.js";
//#region src/core/actors/create-machine-actor.ts
/**
* Creates a message-driven actor from a declarative definition.
*
* The actor owns a reactive snapshot signal (state + context), an optional
* runner, and dispatches incoming messages to per-state handlers. `'destroyed'`
* is always the implicit terminal state — `destroy()` transitions there
* unconditionally and all subsequent `send()` calls are no-ops.
*
* When a state declares `onSettled`, the framework calls `runner.whenSettled()`
* after the handler returns. The runner owns the generation-token logic — if
* new tasks are scheduled before the current batch settles, the callback is
* automatically superseded.
*
* @example
* const actor = createMachineActor({
* runner: () => new SerialRunner(),
* initial: 'idle',
* context: {},
* states: {
* idle: {
* on: {
* load: (msg, { transition, runner }) => {
* segments.forEach(s => runner.schedule(new Task(...)));
* transition('loading');
* }
* }
* },
* loading: {
* onSettled: 'idle',
* on: {
* load: (msg, { runner }) => {
* runner.abortAll();
* segments.forEach(s => runner.schedule(new Task(...)));
* }
* }
* }
* }
* });
*/
function createMachineActor(def) {
const runner = def.runner?.();
const { snapshotSignal, getState, transition } = createMachineCore({
value: def.initial,
context: def.context
});
const getContext = () => untrack(() => snapshotSignal.get().context);
const setContext = (context) => {
update(snapshotSignal, { context });
};
return {
get snapshot() {
return snapshotSignal;
},
send(message) {
const state = getState();
if (state === "destroyed") return;
const handler = def.states[state]?.on?.[message.type];
if (!handler) return;
handler(message, {
context: getContext(),
getContext,
transition: (to) => transition(to),
setContext,
...runner ? { runner } : {}
});
const newState = getState();
if (newState !== "destroyed") {
const newStateDef = def.states[newState];
if (newStateDef?.onSettled && runner) {
const targetState = newStateDef.onSettled;
runner.whenSettled(() => {
if (getState() !== newState) return;
transition(targetState);
});
}
}
},
destroy() {
if (getState() === "destroyed") return;
runner?.destroy();
transition("destroyed");
}
};
}
//#endregion
export { createMachineActor };
//# sourceMappingURL=create-machine-actor.js.map
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
import { ActorSnapshot } from "./actor.js";
import { Machine } from "../machine.js";
//#region src/core/actors/create-transition-actor.d.ts
/**
* A reducer-shaped actor: `(context, message) => context`.
*
* No finite states — the snapshot carries `value: 'active' | 'destroyed'`
* as a universal lifecycle marker rather than domain state. The interesting
* state is entirely in the context, which is reactive via `snapshot`.
*
* Use this when the actor has context that needs to be reactive but no
* meaningful state machine (e.g., a message-driven model with DOM side
* effects). For actors that need per-state behavior, use `createMachineActor`.
*/
interface TransitionActor<Context extends object, Message extends {
type: string;
}> extends Machine<ActorSnapshot<'active' | 'destroyed', Context>> {
send(message: Message): void;
}
/**
* Creates a reducer-shaped actor from an initial context and a reducer function.
*
* The reducer receives the current context and a message and returns the next
* context. Returning the same reference (by identity) skips the signal update —
* so early-returning `context` unchanged is both the no-op and the optimization.
*
* Side effects (e.g. DOM mutations) may be performed inside the reducer.
* They run synchronously before the signal is updated.
*
* @example
* const actor = createTransitionActor(
* { count: 0 },
* (context, message: { type: 'increment' }) => ({ count: context.count + 1 })
* );
*/
declare function createTransitionActor<Context extends object, Message extends {
type: string;
}>(initialContext: Context, reducer: (context: Context, message: Message) => Context): TransitionActor<Context, Message>;
//#endregion
export { TransitionActor, createTransitionActor };
//# sourceMappingURL=create-transition-actor.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"create-transition-actor.d.ts","names":[],"sources":["../../../../src/core/actors/create-transition-actor.ts"],"mappings":";;;;;;;;;;;;;;UAoBiB,gBAAgB,wBAAwB;EAAkB;WACjE,QAAQ,sCAAsC;EACtD,KAAK,SAAS;;;;;;;;;;;;;;;;;;iBAuBA,sBAAsB,wBAAwB;EAAkB;GAC9E,gBAAgB,SAChB,UAAU,SAAS,SAAS,SAAS,YAAY,UAChD,gBAAgB,SAAS"}
+46
View File
@@ -0,0 +1,46 @@
import { untrack, update } from "../signals/primitives.js";
import { createMachineCore } from "../machine.js";
//#region src/core/actors/create-transition-actor.ts
/**
* Creates a reducer-shaped actor from an initial context and a reducer function.
*
* The reducer receives the current context and a message and returns the next
* context. Returning the same reference (by identity) skips the signal update —
* so early-returning `context` unchanged is both the no-op and the optimization.
*
* Side effects (e.g. DOM mutations) may be performed inside the reducer.
* They run synchronously before the signal is updated.
*
* @example
* const actor = createTransitionActor(
* { count: 0 },
* (context, message: { type: 'increment' }) => ({ count: context.count + 1 })
* );
*/
function createTransitionActor(initialContext, reducer) {
const { snapshotSignal, getState, transition } = createMachineCore({
value: "active",
context: initialContext
});
const getContext = () => untrack(() => snapshotSignal.get().context);
const setContext = (context) => update(snapshotSignal, { context });
return {
get snapshot() {
return snapshotSignal;
},
send(message) {
if (getState() === "destroyed") return;
const context = getContext();
const newContext = reducer(context, message);
if (newContext !== context) setContext(newContext);
},
destroy() {
if (getState() === "destroyed") return;
transition("destroyed");
}
};
}
//#endregion
export { createTransitionActor };
//# sourceMappingURL=create-transition-actor.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"create-transition-actor.js","names":[],"sources":["../../../../src/core/actors/create-transition-actor.ts"],"sourcesContent":["import type { Machine } from '../machine';\nimport { createMachineCore } from '../machine';\nimport { untrack, update } from '../signals/primitives';\nimport type { ActorSnapshot } from './actor';\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * A reducer-shaped actor: `(context, message) => context`.\n *\n * No finite states — the snapshot carries `value: 'active' | 'destroyed'`\n * as a universal lifecycle marker rather than domain state. The interesting\n * state is entirely in the context, which is reactive via `snapshot`.\n *\n * Use this when the actor has context that needs to be reactive but no\n * meaningful state machine (e.g., a message-driven model with DOM side\n * effects). For actors that need per-state behavior, use `createMachineActor`.\n */\nexport interface TransitionActor<Context extends object, Message extends { type: string }>\n extends Machine<ActorSnapshot<'active' | 'destroyed', Context>> {\n send(message: Message): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a reducer-shaped actor from an initial context and a reducer function.\n *\n * The reducer receives the current context and a message and returns the next\n * context. Returning the same reference (by identity) skips the signal update —\n * so early-returning `context` unchanged is both the no-op and the optimization.\n *\n * Side effects (e.g. DOM mutations) may be performed inside the reducer.\n * They run synchronously before the signal is updated.\n *\n * @example\n * const actor = createTransitionActor(\n * { count: 0 },\n * (context, message: { type: 'increment' }) => ({ count: context.count + 1 })\n * );\n */\nexport function createTransitionActor<Context extends object, Message extends { type: string }>(\n initialContext: Context,\n reducer: (context: Context, message: Message) => Context\n): TransitionActor<Context, Message> {\n const { snapshotSignal, getState, transition } = createMachineCore<\n 'active' | 'destroyed',\n ActorSnapshot<'active' | 'destroyed', Context>\n >({ value: 'active', context: initialContext });\n\n const getContext = (): Context => untrack(() => snapshotSignal.get().context);\n const setContext = (context: Context): void => update(snapshotSignal, { context });\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n send(message: Message): void {\n if (getState() === 'destroyed') return;\n const context = getContext();\n const newContext = reducer(context, message);\n if (newContext !== context) setContext(newContext);\n },\n\n destroy(): void {\n if (getState() === 'destroyed') return;\n transition('destroyed');\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6CA,SAAgB,sBACd,gBACA,SACmC;CACnC,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAG/C;EAAE,OAAO;EAAU,SAAS;CAAe,CAAC;CAE9C,MAAM,mBAA4B,cAAc,eAAe,IAAI,CAAC,CAAC,OAAO;CAC5E,MAAM,cAAc,YAA2B,OAAO,gBAAgB,EAAE,QAAQ,CAAC;CAEjF,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA,KAAK,SAAwB;GAC3B,IAAI,SAAS,MAAM,aAAa;GAChC,MAAM,UAAU,WAAW;GAC3B,MAAM,aAAa,QAAQ,SAAS,OAAO;GAC3C,IAAI,eAAe,SAAS,WAAW,UAAU;EACnD;EAEA,UAAgB;GACd,IAAI,SAAS,MAAM,aAAa;GAChC,WAAW,WAAW;EACxB;CACF;AACF"}
+273
View File
@@ -0,0 +1,273 @@
import { ReadonlySignal, Signal } from "../signals/primitives.js";
//#region src/core/composition/create-composition.d.ts
/**
* Cleanup returned by a behavior. Behaviors may return:
* - `void` / `undefined` — no cleanup needed
* - A function — called on destroy (may return a Promise)
* - An object with `destroy()` — called on destroy (may return a Promise)
*/
type BehaviorCleanup = void | (() => void | Promise<void>) | {
destroy(): void | Promise<void>;
};
/**
* A signal map keyed by the fields of `S`. Each field is a writable signal.
*
* Optional fields on `S` map to required signal slots whose value type
* includes `undefined`, ensuring every key has a signal even when the
* underlying value is absent.
*
* Used in two roles:
* - Engine-side **construction**: `Composition<S, C>` exposes its public
* surface as `StateSignals<S>` (everything writable) so external code
* can read or write any slot.
* - Behavior **input convenience**: a behavior that writes to every slot
* can type its setup state param as `StateSignals<{ ... }>` rather than
* spelling out per-slot `Signal<T>` types.
*
* Behaviors that mix read-only and writable slots type the setup param
* directly as a slot map (`{ x: Signal<T>; y: ReadonlySignal<U> }`)
* instead of going through `StateSignals<>`.
*/
type StateSignals<S extends object> = { [K in keyof S]-?: Signal<S[K]>; };
/**
* A signal map keyed by the fields of `C`. Each field is a writable signal
* for a platform object or actor reference. Same dual role as
* `StateSignals<S>` — see its docblock.
*/
type ContextSignals<C extends object> = { [K in keyof C]-?: Signal<C[K]>; };
/**
* Slot-map shape — a record where each value is at least a `ReadonlySignal`.
* `Signal<T>` is structurally a subtype of `ReadonlySignal<T>` (it adds
* `.set()`), so a writable slot satisfies this bound too.
*
* This is the bound used for behavior `state` / `context` slot maps. It
* lets a single behavior declare a *heterogeneous* slot map where some
* slots are `Signal<T>` (writable) and others are `ReadonlySignal<T>`
* (read-only) — making read/write intent explicit at the call site and
* giving body-level enforcement (TS rejects `.set()` on a read-only slot).
*/
type AnySlotMap = Record<PropertyKey, ReadonlySignal<unknown>>;
/**
* The deps object passed to each behavior by the composition.
*
* - `state` — slot map for state fields (reactive data). Per-slot read/
* write intent expressed via `Signal<T>` vs `ReadonlySignal<T>`.
* - `context` — slot map for platform objects and actor references.
* - `config` — static configuration, passed once at composition creation.
*/
interface BehaviorDeps<StateMap extends AnySlotMap, ContextMap extends AnySlotMap, Cfg extends object> {
state: StateMap;
context: ContextMap;
config: Cfg;
}
/**
* A behavior announces the state and context keys it needs alongside a
* `setup` function that receives deps (state, context, config) and
* returns an optional cleanup handle.
*
* The `stateKeys` / `contextKeys` declarations are the runtime expression
* of the behavior's contract — the caller (e.g. `createComposition`) uses
* them to know which signals to provide. The setup parameter type
* declares the *slot map* (per-slot `Signal<T>` vs `ReadonlySignal<T>`);
* together they form a complete contract.
*
* Manual `Behavior<>` literals (e.g. engine wrappers that forward keys
* from a wrapped behavior, or pass-through behaviors like `shareSignals`)
* opt out of exhaustiveness — the type alias is permissive (subset).
* Source behaviors should use `defineBehavior` to get exhaustiveness
* enforcement at the call site.
*/
interface Behavior<StateMap extends AnySlotMap = Empty, ContextMap extends AnySlotMap = Empty, Cfg extends object = Empty> {
/** State keys this behavior reads/writes. Subset of `keyof StateMap`. */
stateKeys: readonly (keyof StateMap)[];
/** Context keys this behavior reads/writes. Subset of `keyof ContextMap`. */
contextKeys: readonly (keyof ContextMap)[];
setup: (deps: BehaviorDeps<StateMap, ContextMap, Cfg>) => BehaviorCleanup;
}
/** A behavior with an unconstrained setup — used as a generic bound. */
type AnyBehavior = {
stateKeys: readonly PropertyKey[];
contextKeys: readonly PropertyKey[];
setup: (deps: any) => BehaviorCleanup;
};
/** Extract the deps type from a behavior's setup function. */
type DepsOf<B> = B extends {
setup: (deps: infer D, ...args: any[]) => any;
} ? D : never;
/**
* Empty-object fallback used when a behavior omits state, context, or config.
*
* Using `{}` rather than `object` is deliberate — `object & {x: T}` collapses
* to `{x: never}` under TS's union-to-intersection conversion in some inference
* contexts (likely a TS quirk around the `object` upper bound), whereas
* `{} & {x: T}` simplifies cleanly to `{x: T}`.
*/
type Empty = {};
/**
* Unwrap a signal map back to its state/context shape.
*
* Inferring through `{ get(): infer V }` rather than `Signal<infer V>`
* sidesteps `Signal`'s nominal/invariance behaviour — the conditional
* matches structurally on the read side, and `V` is inferred covariantly.
*/
type UnwrapSignals<M> = M extends object ? { [K in keyof M]: M[K] extends {
get(): infer V;
} ? V : never; } : Empty;
/** Infer the state shape a behavior requires from its deps parameter. */
type InferBehaviorState<F> = DepsOf<F> extends {
state: infer M;
} ? UnwrapSignals<M> : Empty;
/** Infer the context shape a behavior requires from its deps parameter. */
type InferBehaviorContext<F> = DepsOf<F> extends {
context: infer M;
} ? UnwrapSignals<M> : Empty;
/** Infer the config shape a behavior requires from its deps parameter. */
type InferBehaviorConfig<F> = DepsOf<F> extends {
config: infer C extends object;
} ? C : Empty;
/**
* Recursively intersect a per-behavior projection across the tuple.
*
* Iterating over the tuple directly avoids `UnionToIntersection`'s
* function-contravariance trick, which produces unstable intersections
* (collapsing concrete fields to `never` or unrelated types) when one of the
* union members is the empty `{}` fallback.
*/
type IntersectBehaviors<Behaviors extends readonly AnyBehavior[], Project extends object> = Behaviors extends readonly [infer First extends AnyBehavior, ...infer Rest extends readonly AnyBehavior[]] ? Apply<Project, First> & IntersectBehaviors<Rest, Project> : Empty;
/**
* Apply a projection (one of the marker types below) to a single behavior.
* Encoded as a discriminated dispatch so the recursion above can stay generic
* and we don't have to write three near-identical recursive types.
*/
type Apply<Project extends object, F> = Project extends {
kind: 'state';
} ? InferBehaviorState<F> : Project extends {
kind: 'context';
} ? InferBehaviorContext<F> : Project extends {
kind: 'config';
} ? InferBehaviorConfig<F> : never;
type StateProjection = {
kind: 'state';
};
type ContextProjection = {
kind: 'context';
};
type ConfigProjection = {
kind: 'config';
};
/** Resolve the combined state shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorState<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, StateProjection> extends (infer R extends object) ? R : Empty;
/** Resolve the combined context shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorContext<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, ContextProjection> extends (infer R extends object) ? R : Empty;
/** Resolve the combined config shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorConfig<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, ConfigProjection> extends (infer R extends object) ? R : Empty;
/**
* True if any property in `T` collapsed to `undefined` or `never` — indicating
* a type conflict from intersecting incompatible behavior requirements.
*
* - Required conflicts: `{ v: number } & { v: string }` → `{ v: never }` — caught via `[never] extends [undefined]`
* - Optional conflicts: `{ v?: number } & { v?: string }` → `{ v?: undefined }` — caught directly
*/
type HasConflict<T extends object> = true extends { [K in keyof T]: [T[K]] extends [undefined] ? true : never; }[keyof T] ? true : false;
/**
* Validate that a behavior composition has no type conflicts.
* Returns the behaviors tuple if valid, or an error message type if conflicts are detected.
*
* State, context, and config are all checked the same way — by intersecting
* each behavior's requirement and looking for collapsed fields. The
* intersection-based check applies the same rule to context as to state, so
* two behaviors that disagree on a context field's type (e.g. `Surface` vs
* `VideoSurface`) surface a conflict at compose time. The prior subtype-based
* approach for owners is gone — the unified rule is simpler and catches the
* cases where two behaviors silently agreed on a wider supertype.
*/
type ValidateComposition<Behaviors extends readonly AnyBehavior[]> = HasConflict<ResolveBehaviorState<Behaviors>> extends true ? 'Error: behaviors have conflicting state types' : HasConflict<ResolveBehaviorContext<Behaviors>> extends true ? 'Error: behaviors have conflicting context types' : HasConflict<ResolveBehaviorConfig<Behaviors>> extends true ? 'Error: behaviors have conflicting config types' : [...Behaviors];
/**
* A composition of behaviors with shared state and context signal maps.
*/
interface Composition<S extends object, C extends object> {
state: StateSignals<S>;
context: ContextSignals<C>;
destroy(): Promise<void>;
}
/**
* Options for `createComposition`.
*
* Composition derives the state and context signal maps from each
* behavior's declared `stateKeys` / `contextKeys`; `initialState` and
* `initialContext` seed those signals at creation time. Any unseeded
* signal starts as `undefined`.
*/
interface CompositionOptions<S extends object, C extends object, Cfg extends object> {
/** Static configuration passed to every behavior. */
config?: Cfg;
/** Initial values for state signals — any subset of `keyof S`. */
initialState?: Partial<S>;
/** Initial values for context signals — any subset of `keyof C`. */
initialContext?: Partial<C>;
}
declare function createComposition<const Behaviors extends readonly AnyBehavior[]>(behaviors: ValidateComposition<Behaviors>, options?: CompositionOptions<ResolveBehaviorState<Behaviors>, ResolveBehaviorContext<Behaviors>, ResolveBehaviorConfig<Behaviors>>): Composition<ResolveBehaviorState<Behaviors>, ResolveBehaviorContext<Behaviors>>;
/**
* Compose-time exhaustiveness check.
*
* Adds a phantom error tag to the parameter shape when `Keys` does not
* cover every key in `Slot`. The user's value won't satisfy the phantom
* field requirement, so TS surfaces the failure at the call site with a
* descriptive message. When exhaustive, the tag is `Empty` and adds no
* constraint.
*/
type ExhaustiveKeys<Keys extends readonly PropertyKey[], Slot extends object, Name extends string> = [keyof Slot] extends [Keys[number]] ? Empty : { [K in `Error: ${Name}Keys must list every key in the typed slice`]: Exclude<keyof Slot, Keys[number]>; };
/**
* Typed factory for behaviors that enforces single-behavior key/param
* consistency: declared `stateKeys` must equal `keyof S` (where `S` is
* inferred from the setup's `state` parameter type), and same for
* `contextKeys` / `C`.
*
* The `const` modifier on `SK` / `CK` captures literal tuples so e.g.
* `stateKeys: ['preload']` infers as `readonly ['preload']`, no `as
* const` needed at the call site.
*
* Cross-behavior consistency at `createComposition` is unchanged — the
* existing `IntersectBehaviors` machinery still runs over each
* behavior's setup param type.
*
* @example
* ```ts
* export const syncPreload = defineBehavior({
* stateKeys: ['preload'],
* contextKeys: ['mediaElement'],
* setup: ({ state, context }: {
* state: StateSignals<{ preload?: 'auto' | 'metadata' | 'none' }>;
* context: ContextSignals<{ mediaElement?: HTMLMediaElement | undefined }>;
* }) => { ... },
* });
* ```
*/
/**
* Deps shape for a behavior whose deps slot is empty (no keys). When a
* slot is empty, the corresponding deps field is optional — callers
* (typically tests) can omit it, and it defaults to `{}` at runtime via
* `createComposition`.
*
* When a slot has at least one key, the behavior reads `state.foo` /
* `context.bar` / `config.baz` and we need the field to be required so
* the access is type-safe.
*/
type RequireIfNonEmpty<Key extends string, T extends object> = keyof T extends never ? { [K in Key]?: T; } : { [K in Key]: T; };
type DepsForCfg<StateMap extends AnySlotMap, ContextMap extends AnySlotMap, Cfg extends object> = RequireIfNonEmpty<'state', StateMap> & RequireIfNonEmpty<'context', ContextMap> & RequireIfNonEmpty<'config', Cfg>;
declare function defineBehavior<StateMap extends AnySlotMap = Empty, ContextMap extends AnySlotMap = Empty, Cfg extends object = Empty, const SK extends readonly (keyof StateMap)[] = readonly [], const CK extends readonly (keyof ContextMap)[] = readonly [], R extends BehaviorCleanup = BehaviorCleanup>(behavior: {
stateKeys: SK;
contextKeys: CK;
setup: (deps: {
state: StateMap;
context: ContextMap;
config: Cfg;
}) => R;
} & ExhaustiveKeys<SK, StateMap, 'state'> & ExhaustiveKeys<CK, ContextMap, 'context'>): {
stateKeys: SK;
contextKeys: CK;
setup: (deps: DepsForCfg<StateMap, ContextMap, Cfg>) => R;
};
//#endregion
export { AnySlotMap, Behavior, BehaviorCleanup, BehaviorDeps, Composition, CompositionOptions, ContextSignals, InferBehaviorConfig, InferBehaviorContext, InferBehaviorState, ResolveBehaviorConfig, ResolveBehaviorContext, ResolveBehaviorState, StateSignals, createComposition, defineBehavior };
//# sourceMappingURL=create-composition.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"create-composition.d.ts","names":[],"sources":["../../../../src/core/composition/create-composition.ts"],"mappings":";;;;;;;;KAQY,uCAAuC;EAAmB,kBAAkB;;;;;;;;;;;;;;;;;;;;;KAqB5E,aAAa,uBAAuB,WAAW,MAAM,OAAO,EAAE;;;;;;KAO9D,eAAe,uBAAuB,WAAW,MAAM,OAAO,EAAE;;;;;;;;;;;;KAahE,aAAa,OAAO,aAAa;;;;;;;;;UAU5B,aAAa,iBAAiB,YAAY,mBAAmB,YAAY;EACxF,OAAO;EACP,SAAS;EACT,QAAQ;;;;;;;;;;;;;;;;;;;UAoBO,SACf,iBAAiB,aAAa,OAC9B,mBAAmB,aAAa,OAChC,qBAAqB;;EAGrB,2BAA2B;;EAE3B,6BAA6B;EAC7B,QAAQ,MAAM,aAAa,UAAU,YAAY,SAAS;;;KAQvD;EACH,oBAAoB;EACpB,sBAAsB;EACtB,QAAQ,cAAc;;;KAInB,OAAO,KAAK;EAAY,QAAQ,YAAY,MAAM;IAAwB;;;;;;;;;KAW1E;;;;;;;;KASA,cAAc,KAAK,sBAAsB,WAAW,IAAI,EAAE;EAAa,aAAa;IAAM,eAAc;;KAGjG,mBAAmB,KAAK,OAAO;EAAa,aAAa;IAAM,cAAc,KAAK;;KAGlF,qBAAqB,KAAK,OAAO;EAAa,eAAe;IAAM,cAAc,KAAK;;KAGtF,oBAAoB,KAAK,OAAO;EAAa,cAAc;IAAqB,IAAI;;;;;;;;;KAU3F,mBAAmB,2BAA2B,eAAe,0BAA0B,kCACpF,cAAc,sBACX,sBAAsB,iBAE7B,MAAM,SAAS,SAAS,mBAAmB,MAAM,WACjD;;;;;;KAOC,MAAM,wBAAwB,KAAK;EAAkB;IACtD,mBAAmB,KACnB;EAAkB;IAChB,qBAAqB,KACrB;EAAkB;IAChB,oBAAoB;KAGvB;EAAoB;;KACpB;EAAsB;;KACtB;EAAqB;;;KAGd,qBAAqB,2BAA2B,iBAC1D,mBAAmB,WAAW,gCAA+B,oBAAmB,IAAI;;KAG1E,uBAAuB,2BAA2B,iBAC5D,mBAAmB,WAAW,kCAAiC,oBAAmB,IAAI;;KAG5E,sBAAsB,2BAA2B,iBAC3D,mBAAmB,WAAW,iCAAgC,oBAAmB,IAAI;;;;;;;;KASlF,YAAY,oCACd,WAAW,KAAK,EAAE,gDACb;;;;;;;;;;;;;KAoBH,oBAAoB,2BAA2B,iBAClD,YAAY,qBAAqB,6EAE7B,YAAY,uBAAuB,+EAEjC,YAAY,sBAAsB,kFAE5B;;;;UASG,YAAY,kBAAkB;EAC7C,OAAO,aAAa;EACpB,SAAS,eAAe;EACxB,WAAW;;;;;;;;;;UAWI,mBAAmB,kBAAkB,kBAAkB;;EAEtE,SAAS;;EAET,eAAe,QAAQ;;EAEvB,iBAAiB,QAAQ;;iBA2DX,wBAAwB,2BAA2B,eACjE,WAAW,oBAAoB,YAC/B,UAAU,mBACR,qBAAqB,YACrB,uBAAuB,YACvB,sBAAsB,cAEvB,YAAY,qBAAqB,YAAY,uBAAuB;;;;;;;;;;KA8DlE,eAAe,sBAAsB,eAAe,qBAAqB,8BACtE,eACG,gBACP,WACG,eAAe,oDAAoD,cAAc,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAsCzF,kBAAkB,oBAAoB,0BAA0B,qBAC9D,KAAK,OAAO,UACZ,KAAK,MAAM;KAEb,WAAW,iBAAiB,YAAY,mBAAmB,YAAY,sBAAsB,2BAEhG,YAEA,6BAA6B,cAC7B,4BAA4B;iBAEd,eACd,iBAAiB,aAAa,OAC9B,mBAAmB,aAAa,OAChC,qBAAqB,aACf,2BAA2B,iCAC3B,2BAA2B,6BACjC,UAAU,kBAAkB,iBAE5B;EACE,WAAW;EACX,aAAa;EACb,QAAQ;IAAQ,OAAO;IAAU,SAAS;IAAY,QAAQ;QAAU;IACtE,eAAe,IAAI,qBACrB,eAAe,IAAI;EAErB,WAAW;EACX,aAAa;EACb,QAAQ,MAAM,WAAW,UAAU,YAAY,SAAS"}
+85
View File
@@ -0,0 +1,85 @@
import { signal } from "../signals/primitives.js";
//#region src/core/composition/create-composition.ts
/**
* Create a composition from a set of behaviors.
*
* Composition unions the behaviors' declared `stateKeys` / `contextKeys`
* to know which signals to create. Each signal is seeded from
* `initialState` / `initialContext` when supplied, defaulting to
* `undefined`. Behaviors are responsible for writing their own slots
* once their preconditions are met.
*
* Cross-behavior type conflicts (e.g. two behaviors disagreeing on a
* field's type) surface as a compose-time type error via
* `ValidateComposition`.
*
* @example
* ```ts
* const composition = createComposition([resolvePresentation, switchVideoTrack], {
* config: { parsePresentation: parseMultivariantPlaylist, initialBandwidth: 2_000_000 },
* initialState: { bandwidthState: { fastEstimate: 0, ... } },
* });
* ```
*/
/**
* Create a typed signal map for a given set of keys, seeded from an
* optional partial initial value.
*
* Pipeline: `Set` dedupes the iterable (insertion order preserved, so
* first occurrence wins) → `Object.fromEntries` materializes one
* `signal()` per unique key, seeded from `initial[key]` or `undefined`.
*
* Per-key value types live in TypeScript only — at runtime every signal
* is `Signal<unknown>`. The boundary cast at the return narrows the wide
* `Record<PropertyKey, Signal<unknown>>` shape to the caller's expected
* per-key types from `S`.
*
* Used by `createComposition` to derive engine state/context maps from
* the union of behaviors' declared `stateKeys` / `contextKeys`.
*
* @example
* ```ts
* interface State { count?: number; label?: string }
* const state = buildSignalMap<State>(['count', 'label'], { count: 5 });
* state.count.get(); // 5
* state.label.get(); // undefined
* ```
*/
function buildSignalMap(keys, initial) {
const init = initial;
const uniqueKeys = new Set(keys);
return Object.fromEntries([...uniqueKeys].map((key) => [key, signal(init[key])]));
}
function createComposition(behaviors, options) {
const validBehaviors = behaviors;
const state = buildSignalMap(validBehaviors.flatMap((b) => b.stateKeys), options?.initialState ?? {});
const context = buildSignalMap(validBehaviors.flatMap((b) => b.contextKeys), options?.initialContext ?? {});
const deps = {
state,
context,
config: options?.config ?? {}
};
const cleanups = validBehaviors.map((behavior) => behavior.setup(deps));
return {
state,
context,
async destroy() {
const results = [];
for (const cleanup of cleanups) {
if (cleanup == null) continue;
if (typeof cleanup === "function") results.push(cleanup());
else if ("destroy" in cleanup) results.push(cleanup.destroy());
}
await Promise.all(results);
for (const sig of Object.values(state)) sig.set(void 0);
for (const sig of Object.values(context)) sig.set(void 0);
}
};
}
function defineBehavior(behavior) {
return behavior;
}
//#endregion
export { buildSignalMap, createComposition, defineBehavior };
//# sourceMappingURL=create-composition.js.map
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
import { Behavior, ContextSignals, StateSignals } from "./create-composition.js";
//#region src/core/composition/share-signals.d.ts
/**
* Config consumed by the `shareSignals` behavior.
*
* The callback fires once during composition setup with the composition's
* state and context signal refs. Capture them to drive the composition
* externally (writes) or observe its state (reads).
*
* The callback runs while other behaviors are still in their setup phase —
* for the typical "capture refs, use later" pattern this is fine (signal
* refs are stable identities), but reading inside the callback may yield
* only initial-seed values rather than what later behaviors write.
*/
interface ShareSignalsConfig<S extends object, C extends object> {
onSignalsReady?: (signals: {
state: StateSignals<S>;
context: ContextSignals<C>;
}) => void;
}
/**
* Behavior factory that hands the composition's signal refs to a
* consumer-supplied callback (`config.onSignalsReady`) at setup time.
*
* Generic over `S` and `C` — the caller instantiates with their own
* state/context types, and the callback's parameter shape is fully
* type-driven from those. Suitable for both reads and writes (per-slot
* intent can be expressed by typing captured refs as `Signal<T>` or
* `ReadonlySignal<T>` at the call site).
*
* By default declares no keys; the composition's state/context maps come from
* other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize*
* consumer-input slots that no other behavior produces — a slot the consumer
* writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals
* is the consumer boundary, so it's the natural place to bring those slots into
* existence; readers then treat them as optional.
*
* Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty,
* possibly partial) key arrays don't trip the exhaustiveness check — the
* setup-param state/context shapes describe what the callback receives (the
* full `S` / `C`), not the subset this behavior materializes.
*/
declare function makeShareSignals<S extends object, C extends object>(inputStateKeys?: readonly (keyof S)[], inputContextKeys?: readonly (keyof C)[]): Behavior<StateSignals<S>, ContextSignals<C>, ShareSignalsConfig<S, C>>;
//#endregion
export { ShareSignalsConfig, makeShareSignals };
//# sourceMappingURL=share-signals.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"share-signals.d.ts","names":[],"sources":["../../../../src/core/composition/share-signals.ts"],"mappings":";;;;;;;;;;;;;;UAciB,mBAAmB,kBAAkB;EACpD,kBAAkB;IAAW,OAAO,aAAa;IAAI,SAAS,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;iBAyB/D,iBAAiB,kBAAkB,kBACjD,iCAAgC,MAChC,mCAAkC,OACjC,SAAS,aAAa,IAAI,eAAe,IAAI,mBAAmB,GAAG"}
+39
View File
@@ -0,0 +1,39 @@
//#region src/core/composition/share-signals.ts
/**
* Behavior factory that hands the composition's signal refs to a
* consumer-supplied callback (`config.onSignalsReady`) at setup time.
*
* Generic over `S` and `C` — the caller instantiates with their own
* state/context types, and the callback's parameter shape is fully
* type-driven from those. Suitable for both reads and writes (per-slot
* intent can be expressed by typing captured refs as `Signal<T>` or
* `ReadonlySignal<T>` at the call site).
*
* By default declares no keys; the composition's state/context maps come from
* other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize*
* consumer-input slots that no other behavior produces — a slot the consumer
* writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals
* is the consumer boundary, so it's the natural place to bring those slots into
* existence; readers then treat them as optional.
*
* Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty,
* possibly partial) key arrays don't trip the exhaustiveness check — the
* setup-param state/context shapes describe what the callback receives (the
* full `S` / `C`), not the subset this behavior materializes.
*/
function makeShareSignals(inputStateKeys = [], inputContextKeys = []) {
return {
stateKeys: inputStateKeys,
contextKeys: inputContextKeys,
setup: ({ state, context, config }) => {
config.onSignalsReady?.({
state,
context
});
}
};
}
//#endregion
export { makeShareSignals };
//# sourceMappingURL=share-signals.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"share-signals.js","names":[],"sources":["../../../../src/core/composition/share-signals.ts"],"sourcesContent":["import type { Behavior, ContextSignals, StateSignals } from './create-composition';\n\n/**\n * Config consumed by the `shareSignals` behavior.\n *\n * The callback fires once during composition setup with the composition's\n * state and context signal refs. Capture them to drive the composition\n * externally (writes) or observe its state (reads).\n *\n * The callback runs while other behaviors are still in their setup phase —\n * for the typical \"capture refs, use later\" pattern this is fine (signal\n * refs are stable identities), but reading inside the callback may yield\n * only initial-seed values rather than what later behaviors write.\n */\nexport interface ShareSignalsConfig<S extends object, C extends object> {\n onSignalsReady?: (signals: { state: StateSignals<S>; context: ContextSignals<C> }) => void;\n}\n\n/**\n * Behavior factory that hands the composition's signal refs to a\n * consumer-supplied callback (`config.onSignalsReady`) at setup time.\n *\n * Generic over `S` and `C` — the caller instantiates with their own\n * state/context types, and the callback's parameter shape is fully\n * type-driven from those. Suitable for both reads and writes (per-slot\n * intent can be expressed by typing captured refs as `Signal<T>` or\n * `ReadonlySignal<T>` at the call site).\n *\n * By default declares no keys; the composition's state/context maps come from\n * other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize*\n * consumer-input slots that no other behavior produces — a slot the consumer\n * writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals\n * is the consumer boundary, so it's the natural place to bring those slots into\n * existence; readers then treat them as optional.\n *\n * Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty,\n * possibly partial) key arrays don't trip the exhaustiveness check — the\n * setup-param state/context shapes describe what the callback receives (the\n * full `S` / `C`), not the subset this behavior materializes.\n */\nexport function makeShareSignals<S extends object, C extends object>(\n inputStateKeys: readonly (keyof S)[] = [],\n inputContextKeys: readonly (keyof C)[] = []\n): Behavior<StateSignals<S>, ContextSignals<C>, ShareSignalsConfig<S, C>> {\n return {\n stateKeys: inputStateKeys,\n contextKeys: inputContextKeys,\n setup: ({ state, context, config }) => {\n config.onSignalsReady?.({ state, context });\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,iBACd,iBAAuC,CAAC,GACxC,mBAAyC,CAAC,GAC8B;CACxE,OAAO;EACL,WAAW;EACX,aAAa;EACb,QAAQ,EAAE,OAAO,SAAS,aAAa;GACrC,OAAO,iBAAiB;IAAE;IAAO;GAAQ,CAAC;EAC5C;CACF;AACF"}
+21
View File
@@ -0,0 +1,21 @@
import { ReadonlySignal } from "./signals/primitives.js";
import "../index.js";
//#region src/core/machine.d.ts
/**
* Base snapshot for all machine-like primitives (Actors and Reactors).
* Carries only the finite state value. Actors extend this with `context`.
*/
interface MachineSnapshot<State extends string> {
value: State;
}
/**
* Shared interface for all machine-like primitives.
* Both Actors (message-driven) and Reactors (signal-driven) implement this.
*/
interface Machine<Snapshot extends MachineSnapshot<string>> {
readonly snapshot: ReadonlySignal<Snapshot>;
destroy(): void;
}
//#endregion
export { Machine, MachineSnapshot };
//# sourceMappingURL=machine.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"machine.d.ts","names":[],"sources":["../../../src/core/machine.ts"],"mappings":";;;;;;;UAWiB,gBAAgB;EAC/B,OAAO;;;;;;UAWQ,QAAQ,iBAAiB;WAC/B,UAAU,eAAe;EAClC"}
+26
View File
@@ -0,0 +1,26 @@
import { signal, untrack, update } from "./signals/primitives.js";
//#region src/core/machine.ts
/**
* Provisions the shared mechanics for all machine-like primitives: a snapshot
* signal, an untracked state reader, and a transition function.
*
* Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the
* public API.
*/
function createMachineCore(initialSnapshot) {
const snapshotSignal = signal(initialSnapshot);
const getState = () => untrack(() => snapshotSignal.get().value);
const transition = (to) => update(snapshotSignal, (current) => ({
...current,
value: to
}));
return {
snapshotSignal,
getState,
transition
};
}
//#endregion
export { createMachineCore };
//# sourceMappingURL=machine.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"machine.js","names":[],"sources":["../../../src/core/machine.ts"],"sourcesContent":["import type { ReadonlySignal } from './signals/primitives';\nimport { signal, untrack, update } from './signals/primitives';\n\n// =============================================================================\n// Shared snapshot type\n// =============================================================================\n\n/**\n * Base snapshot for all machine-like primitives (Actors and Reactors).\n * Carries only the finite state value. Actors extend this with `context`.\n */\nexport interface MachineSnapshot<State extends string> {\n value: State;\n}\n\n// =============================================================================\n// Shared interface\n// =============================================================================\n\n/**\n * Shared interface for all machine-like primitives.\n * Both Actors (message-driven) and Reactors (signal-driven) implement this.\n */\nexport interface Machine<Snapshot extends MachineSnapshot<string>> {\n readonly snapshot: ReadonlySignal<Snapshot>;\n destroy(): void;\n}\n\n// =============================================================================\n// Shared core factory\n// =============================================================================\n\n/**\n * Provisions the shared mechanics for all machine-like primitives: a snapshot\n * signal, an untracked state reader, and a transition function.\n *\n * Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the\n * public API.\n */\nexport function createMachineCore<FullState extends string, Snapshot extends MachineSnapshot<FullState>>(\n initialSnapshot: Snapshot\n) {\n const snapshotSignal = signal(initialSnapshot);\n const getState = (): FullState => untrack(() => snapshotSignal.get().value);\n const transition = (to: FullState): void => update(snapshotSignal, (current) => ({ ...current, value: to }));\n return { snapshotSignal, getState, transition };\n}\n"],"mappings":";;;;;;;;;AAuCA,SAAgB,kBACd,iBACA;CACA,MAAM,iBAAiB,OAAO,eAAe;CAC7C,MAAM,iBAA4B,cAAc,eAAe,IAAI,CAAC,CAAC,KAAK;CAC1E,MAAM,cAAc,OAAwB,OAAO,iBAAiB,aAAa;EAAE,GAAG;EAAS,OAAO;CAAG,EAAE;CAC3G,OAAO;EAAE;EAAgB;EAAU;CAAW;AAChD"}
+93
View File
@@ -0,0 +1,93 @@
import { Machine, MachineSnapshot } from "../machine.js";
//#region src/core/reactors/create-machine-reactor.d.ts
/**
* A reactive state-deriving function used in the `monitor` field.
*
* Returns the target state the reactor should be in. Any signals read inside
* the fn body create reactive dependencies — the framework re-evaluates it when
* those signals change and automatically calls `transition()` when the returned
* state differs from the current one.
*/
type ReactorDeriveFn<State extends string> = () => State;
/**
* An effect function used in reactor `entry` and `effects` blocks.
*
* May return a cleanup function that runs before each re-evaluation and on
* state exit (including destroy).
*/
type ReactorEffectFn = () => (() => void) | {
abort(): void;
} | void;
/**
* Per-state effect grouping for a single reactor state.
*
* - `entry` effects run once on state entry. The fn body is automatically
* untracked — no `untrack()` calls are needed inside. Use this for
* one-time setup: reading current values, attaching event listeners, etc.
* - `effects` run on state entry and re-run whenever a signal read inside
* the fn body changes. Use `untrack()` for reads you do not want to track.
* Use this for work that must stay in sync with reactive state.
*
* Both are optional; pass `{}` for states with no effects.
*/
type ReactorStateDefinition = {
entry?: ReactorEffectFn | ReactorEffectFn[];
effects?: ReactorEffectFn | ReactorEffectFn[];
};
/**
* Full reactor definition passed to `createMachineReactor`.
*
* `State` is the set of domain-meaningful states. `'destroying'` and
* `'destroyed'` are always added by the framework as implicit terminal states —
* do not include them here.
*/
type ReactorDefinition<State extends string> = {
/** Initial state. */
initial: State;
/**
* Reactive state derivation. Registered before per-state effects — the
* ordering guarantee ensures transitions fired here take effect before
* per-state effects re-evaluate in the same flush.
*/
monitor?: ReactorDeriveFn<State> | ReactorDeriveFn<State>[];
/**
* Per-state effect groupings. Every valid state must be declared — pass `{}`
* for states with no effects. `entry` and `effects` each become independent
* `effect()` calls gated on that state, with their own cleanup lifecycles.
*/
states: Record<State, ReactorStateDefinition>;
};
/** Live reactor instance returned by `createMachineReactor`. */
type Reactor<State extends string> = Machine<MachineSnapshot<State>>;
/**
* Creates a reactive Reactor from a declarative definition.
*
* A Reactor is driven by subscriptions to external signals rather than
* imperative messages. Each state holds an array of effect functions —
* every element becomes one independent `effect()` call gated on that state,
* with its own dependency tracking and cleanup lifecycle.
*
* `'destroying'` and `'destroyed'` are always implicit terminal states.
* `destroy()` transitions through both in sequence: `'destroying'` first (for
* potential async teardown in a future extension), then immediately `'destroyed'`
* for the synchronous base case. Active effect cleanups fire via disposal.
*
* @example
* const reactor = createMachineReactor({
* initial: 'waiting',
* monitor: () => srcSignal.get() ? 'active' : 'waiting',
* states: {
* active: {
* // entry: runs once on state entry; fn body is automatically untracked.
* entry: () => listen(el, 'play', handler),
* // effects: re-runs whenever tracked signals change.
* effects: () => { currentTimeSignal.get(); return cleanup; },
* },
* waiting: {},
* }
* });
*/
declare function createMachineReactor<State extends string>(def: ReactorDefinition<State>): Reactor<State | 'destroying' | 'destroyed'>;
//#endregion
export { Reactor, ReactorDefinition, ReactorDeriveFn, ReactorEffectFn, ReactorStateDefinition, createMachineReactor };
//# sourceMappingURL=create-machine-reactor.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"create-machine-reactor.d.ts","names":[],"sources":["../../../../src/core/reactors/create-machine-reactor.ts"],"mappings":";;;;;;;;;;KAiBY,gBAAgB,8BAA8B;;;;;;;KAQ9C;EAAyC;;;;;;;;;;;;;;KAczC;EACV,QAAQ,kBAAkB;EAC1B,UAAU,kBAAkB;;;;;;;;;KAUlB,kBAAkB;;EAE5B,SAAS;;;;;;EAMT,UAAU,gBAAgB,SAAS,gBAAgB;;;;;;EAMnD,QAAQ,OAAO,OAAO;;;KAQZ,QAAQ,wBAAwB,QAAQ,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCpD,qBAAqB,sBACnC,KAAK,kBAAkB,SACtB,QAAQ"}
+83
View File
@@ -0,0 +1,83 @@
import { untrack } from "../signals/primitives.js";
import { effect } from "../signals/effect.js";
import { createMachineCore } from "../machine.js";
//#region src/core/reactors/create-machine-reactor.ts
const toArray = (x) => x === void 0 ? [] : Array.isArray(x) ? x : [x];
/**
* Creates a reactive Reactor from a declarative definition.
*
* A Reactor is driven by subscriptions to external signals rather than
* imperative messages. Each state holds an array of effect functions —
* every element becomes one independent `effect()` call gated on that state,
* with its own dependency tracking and cleanup lifecycle.
*
* `'destroying'` and `'destroyed'` are always implicit terminal states.
* `destroy()` transitions through both in sequence: `'destroying'` first (for
* potential async teardown in a future extension), then immediately `'destroyed'`
* for the synchronous base case. Active effect cleanups fire via disposal.
*
* @example
* const reactor = createMachineReactor({
* initial: 'waiting',
* monitor: () => srcSignal.get() ? 'active' : 'waiting',
* states: {
* active: {
* // entry: runs once on state entry; fn body is automatically untracked.
* entry: () => listen(el, 'play', handler),
* // effects: re-runs whenever tracked signals change.
* effects: () => { currentTimeSignal.get(); return cleanup; },
* },
* waiting: {},
* }
* });
*/
function createMachineReactor(def) {
const { snapshotSignal, getState, transition } = createMachineCore({ value: def.initial });
const effectDisposals = [];
const wrapResult = (result) => {
if (!result) return void 0;
if (typeof result === "function") return result;
return () => result.abort();
};
const untracked = (baseCall) => () => untrack(baseCall);
const isTerminal = (snapshot) => snapshot.value === "destroying" || snapshot.value === "destroyed";
const descriptors = [...toArray(def.monitor).map((fn) => ({
fn: () => {
const target = fn();
if (target !== getState()) transition(target);
},
shouldSkip: isTerminal
})), ...Object.entries(def.states).flatMap(([state, stateDef]) => {
const isNotState = (snapshot) => snapshot.value !== state;
return [...toArray(stateDef.entry).map((fn) => ({
fn,
shouldSkip: isNotState,
toFnCall: untracked
})), ...toArray(stateDef.effects).map((fn) => ({
fn,
shouldSkip: isNotState
}))];
})];
const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }) => effect(() => {
if (shouldSkip(snapshotSignal.get())) return;
const baseCall = () => fn();
return wrapResult(toFnCall(baseCall)());
});
effectDisposals.push(...descriptors.map(toEffect));
return {
get snapshot() {
return snapshotSignal;
},
destroy() {
const state = getState();
if (state === "destroying" || state === "destroyed") return;
transition("destroying");
transition("destroyed");
for (const dispose of effectDisposals) dispose();
}
};
}
//#endregion
export { createMachineReactor };
//# sourceMappingURL=create-machine-reactor.js.map
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
//#region src/core/signals/effect.d.ts
/**
* Run a side effect whenever its signal dependencies change.
*
* Executes immediately (synchronous initial run), then re-runs on the next
* microtask after any dependency changes. If the callback returns a function,
* it is called before each re-run and when the effect is stopped — the same
* cleanup contract as Preact Signals, Maverick Signals, and Svelte 5 $effect.
*
* Returns a cleanup function that stops the effect.
*/
declare function effect(fn: () => (() => void) | void): () => void;
//#endregion
export { effect };
//# sourceMappingURL=effect.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"effect.d.ts","names":[],"sources":["../../../../src/core/signals/effect.ts"],"mappings":";;;;;;;;;;;iBA8BgB,OAAO"}
+41
View File
@@ -0,0 +1,41 @@
import { Signal } from "signal-polyfill";
//#region src/core/signals/effect.ts
const pending = /* @__PURE__ */ new Set();
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(runPending);
});
function runPending() {
for (const c of watcher.getPending()) pending.add(c);
watcher.watch();
for (const c of pending) {
pending.delete(c);
c.get();
}
}
/**
* Run a side effect whenever its signal dependencies change.
*
* Executes immediately (synchronous initial run), then re-runs on the next
* microtask after any dependency changes. If the callback returns a function,
* it is called before each re-run and when the effect is stopped — the same
* cleanup contract as Preact Signals, Maverick Signals, and Svelte 5 $effect.
*
* Returns a cleanup function that stops the effect.
*/
function effect(fn) {
let cleanup;
const c = new Signal.Computed(() => {
if (typeof cleanup === "function") cleanup();
cleanup = fn();
});
watcher.watch(c);
c.get();
return () => {
watcher.unwatch(c);
if (typeof cleanup === "function") cleanup();
};
}
//#endregion
export { effect };
//# sourceMappingURL=effect.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"effect.js","names":[],"sources":["../../../../src/core/signals/effect.ts"],"sourcesContent":["import { Signal } from 'signal-polyfill';\n\n// Computeds waiting to re-run after their dependencies changed.\nconst pending = new Set<Signal.Computed<void>>();\n\nconst watcher = new Signal.subtle.Watcher(() => {\n queueMicrotask(runPending);\n});\n\nfunction runPending() {\n for (const c of watcher.getPending()) {\n pending.add(c as Signal.Computed<void>);\n }\n watcher.watch(); // re-arm before running effects, in case they write signals\n for (const c of pending) {\n pending.delete(c);\n c.get(); // re-run the effect body\n }\n}\n\n/**\n * Run a side effect whenever its signal dependencies change.\n *\n * Executes immediately (synchronous initial run), then re-runs on the next\n * microtask after any dependency changes. If the callback returns a function,\n * it is called before each re-run and when the effect is stopped — the same\n * cleanup contract as Preact Signals, Maverick Signals, and Svelte 5 $effect.\n *\n * Returns a cleanup function that stops the effect.\n */\nexport function effect(fn: () => (() => void) | void): () => void {\n let cleanup: (() => void) | void;\n const c = new Signal.Computed(() => {\n if (typeof cleanup === 'function') cleanup();\n cleanup = fn();\n });\n watcher.watch(c);\n c.get(); // initial run\n return () => {\n watcher.unwatch(c);\n if (typeof cleanup === 'function') cleanup();\n };\n}\n"],"mappings":";;AAGA,MAAM,0BAAU,IAAI,IAA2B;AAE/C,MAAM,UAAU,IAAI,OAAO,OAAO,cAAc;CAC9C,eAAe,UAAU;AAC3B,CAAC;AAED,SAAS,aAAa;CACpB,KAAK,MAAM,KAAK,QAAQ,WAAW,GACjC,QAAQ,IAAI,CAA0B;CAExC,QAAQ,MAAM;CACd,KAAK,MAAM,KAAK,SAAS;EACvB,QAAQ,OAAO,CAAC;EAChB,EAAE,IAAI;CACR;AACF;;;;;;;;;;;AAYA,SAAgB,OAAO,IAA2C;CAChE,IAAI;CACJ,MAAM,IAAI,IAAI,OAAO,eAAe;EAClC,IAAI,OAAO,YAAY,YAAY,QAAQ;EAC3C,UAAU,GAAG;CACf,CAAC;CACD,QAAQ,MAAM,CAAC;CACf,EAAE,IAAI;CACN,aAAa;EACX,QAAQ,QAAQ,CAAC;EACjB,IAAI,OAAO,YAAY,YAAY,QAAQ;CAC7C;AACF"}
+46
View File
@@ -0,0 +1,46 @@
import { Signal } from "signal-polyfill";
//#region src/core/signals/primitives.d.ts
/** Read a signal value without tracking it as a dependency. */
declare const untrack: <T>(fn: () => T) => T;
/** A writable reactive value (read + write). */
type Signal$1<T> = Signal.State<T>;
/** A derived reactive value that re-evaluates when its dependencies change (read-only). */
type Computed<T> = Signal.Computed<T>;
/** A read-only view of a reactive value. */
type ReadonlySignal<T> = Omit<Signal.State<T>, 'set'>;
interface SignalOptions<T> {
equals?: (t: T, t2: T) => boolean;
}
/** Create a writable reactive value. */
declare function signal<T>(initialValue: T, options?: SignalOptions<T>): Signal$1<T>;
/** Create a computed reactive value. */
declare function computed<T>(fn: () => T, options?: SignalOptions<T>): Computed<T>;
/**
* Update a writable signal. Two forms:
*
* - **Updater function** `(current) => next`. Works for any signal type,
* including `Signal<T | undefined>` — handle undefined in the updater.
* - **Partial object** to merge into the current state. Requires
* `T extends object`.
*
* @example
* update(state, { playbackRate: 2 });
* update(state, (s) => ({ ...s, count: s.count + 1 }));
* update(maybeUndefinedSignal, (current) => current ?? defaultValue);
*/
declare function update<T>(signal: Signal$1<T>, updater: (current: T) => T): void;
declare function update<T extends object>(signal: Signal$1<T>, updater: Partial<T>): void;
/**
* Read every signal in a map and return a plain object snapshot. Each read
* tracks in the surrounding Computed/Effect — equivalent to calling `.get()`
* on a single `Signal<S>` over the merged shape.
*
* Convenience for behaviors that pass whole state/context snapshots to pure
* helpers; prefer per-field reads when only a few fields are needed.
*/
declare function snapshot<M extends Record<string, ReadonlySignal<unknown>>>(map: M): { [K in keyof M]: M[K] extends {
get(): infer V;
} ? V : never; };
//#endregion
export { Computed, ReadonlySignal, Signal$1 as Signal, SignalOptions, computed, signal, snapshot, untrack, update };
//# sourceMappingURL=primitives.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"primitives.d.ts","names":[],"sources":["../../../../src/core/signals/primitives.ts"],"mappings":";;;cAGa,UAAU,GAAG,UAAU,MAAM;;KAqB9B,SAAO,KAAK,OAAS,MAAM;;KAG3B,SAAS,KAAK,OAAS,SAAS;;KAGhC,eAAe,KAAK,KAAK,OAAS,MAAM;UAEnC,cAAc;EAC7B,UAAU,GAAG,GAAG,IAAI;;;iBAIN,OAAO,GAAG,cAAc,GAAG,UAAU,cAAc,KAAK,SAAO;;iBAK/D,SAAS,GAAG,UAAU,GAAG,UAAU,cAAc,KAAK,SAAS;;;;;;;;;;;;;;iBAiB/D,OAAO,GAAG,QAAQ,SAAO,IAAI,UAAU,SAAS,MAAM;iBACtD,OAAO,kBAAkB,QAAQ,SAAO,IAAI,SAAS,QAAQ;;;;;;;;;iBAqC7D,SAAS,UAAU,eAAe,0BAChD,KAAK,OACD,WAAW,IAAI,EAAE;EAAa,aAAa;IAAM"}
+54
View File
@@ -0,0 +1,54 @@
import { Signal } from "signal-polyfill";
//#region src/core/signals/primitives.ts
/** Read a signal value without tracking it as a dependency. */
const untrack = Signal.subtle.untrack;
/**
* Read a signal's current value without tracking it as a dependency. Sugar
* for `untrack(() => signal.get())` to reduce boilerplate at single-read
* sites. Structurally typed to accept any signal-like (Signal, Computed,
* ReadonlySignal).
*
* Accepts an optional `transform` to project the value in the same call;
* the default is the identity function so the single-arg form returns `T`
* unchanged.
*
* @example
* const value = peek(someSignal);
* const id = peek(presentationSignal, (p) => p?.id);
*/
function peek(source, transform = (v) => v) {
return untrack(() => transform(source.get()));
}
/** Create a writable reactive value. */
function signal(initialValue, options) {
return new Signal.State(initialValue, options);
}
/** Create a computed reactive value. */
function computed(fn, options) {
return new Signal.Computed(fn, options);
}
function update(signal, updater) {
const current = untrack(() => signal.get());
if (typeof updater === "function") signal.set(updater(current));
else signal.set({
...current,
...updater
});
}
/**
* Read every signal in a map and return a plain object snapshot. Each read
* tracks in the surrounding Computed/Effect — equivalent to calling `.get()`
* on a single `Signal<S>` over the merged shape.
*
* Convenience for behaviors that pass whole state/context snapshots to pure
* helpers; prefer per-field reads when only a few fields are needed.
*/
function snapshot(map) {
const out = {};
for (const key in map) out[key] = map[key].get();
return out;
}
//#endregion
export { computed, peek, signal, snapshot, untrack, update };
//# sourceMappingURL=primitives.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"primitives.js","names":["SignalNS"],"sources":["../../../../src/core/signals/primitives.ts"],"sourcesContent":["import { Signal as SignalNS } from 'signal-polyfill';\n\n/** Read a signal value without tracking it as a dependency. */\nexport const untrack: <T>(fn: () => T) => T = SignalNS.subtle.untrack;\n\n/**\n * Read a signal's current value without tracking it as a dependency. Sugar\n * for `untrack(() => signal.get())` to reduce boilerplate at single-read\n * sites. Structurally typed to accept any signal-like (Signal, Computed,\n * ReadonlySignal).\n *\n * Accepts an optional `transform` to project the value in the same call;\n * the default is the identity function so the single-arg form returns `T`\n * unchanged.\n *\n * @example\n * const value = peek(someSignal);\n * const id = peek(presentationSignal, (p) => p?.id);\n */\nexport function peek<T, R = T>(source: { get(): T }, transform: (value: T) => R = (v: T) => v as unknown as R): R {\n return untrack(() => transform(source.get()));\n}\n\n/** A writable reactive value (read + write). */\nexport type Signal<T> = SignalNS.State<T>;\n\n/** A derived reactive value that re-evaluates when its dependencies change (read-only). */\nexport type Computed<T> = SignalNS.Computed<T>;\n\n/** A read-only view of a reactive value. */\nexport type ReadonlySignal<T> = Omit<SignalNS.State<T>, 'set'>;\n\nexport interface SignalOptions<T> {\n equals?: (t: T, t2: T) => boolean;\n}\n\n/** Create a writable reactive value. */\nexport function signal<T>(initialValue: T, options?: SignalOptions<T>): Signal<T> {\n return new SignalNS.State(initialValue, options as SignalNS.Options<T>);\n}\n\n/** Create a computed reactive value. */\nexport function computed<T>(fn: () => T, options?: SignalOptions<T>): Computed<T> {\n return new SignalNS.Computed(fn, options as SignalNS.Options<T>);\n}\n\n/**\n * Update a writable signal. Two forms:\n *\n * - **Updater function** `(current) => next`. Works for any signal type,\n * including `Signal<T | undefined>` — handle undefined in the updater.\n * - **Partial object** to merge into the current state. Requires\n * `T extends object`.\n *\n * @example\n * update(state, { playbackRate: 2 });\n * update(state, (s) => ({ ...s, count: s.count + 1 }));\n * update(maybeUndefinedSignal, (current) => current ?? defaultValue);\n */\nexport function update<T>(signal: Signal<T>, updater: (current: T) => T): void;\nexport function update<T extends object>(signal: Signal<T>, updater: Partial<T>): void;\nexport function update<T>(signal: Signal<T>, updater: ((current: T) => T) | object): void {\n const current = untrack(() => signal.get());\n if (typeof updater === 'function') {\n signal.set((updater as (current: T) => T)(current));\n } else {\n // Partial<T> form — `T extends object` enforced by the public overload.\n signal.set({ ...(current as object), ...updater } as T);\n }\n}\n\n/**\n * Equality comparator for objects with an optional `id` field. Designed for\n * use as a `computed` `equals` option when reacting to identity changes\n * (Ham-shaped objects, JSON-API-shaped resources) while filtering internal\n * updates that preserve the id.\n *\n * Handles undefined inputs symmetrically: both undefined → equal; one\n * undefined → different.\n *\n * @example\n * const presentationById = computed(() => state.presentation.get(), {\n * equals: equalsById,\n * });\n */\nexport function equalsById<T extends { id?: string }>(a: T | undefined, b: T | undefined): boolean {\n return a?.id === b?.id;\n}\n\n/**\n * Read every signal in a map and return a plain object snapshot. Each read\n * tracks in the surrounding Computed/Effect — equivalent to calling `.get()`\n * on a single `Signal<S>` over the merged shape.\n *\n * Convenience for behaviors that pass whole state/context snapshots to pure\n * helpers; prefer per-field reads when only a few fields are needed.\n */\nexport function snapshot<M extends Record<string, ReadonlySignal<unknown>>>(\n map: M\n): { [K in keyof M]: M[K] extends { get(): infer V } ? V : never } {\n const out = {} as { [K in keyof M]: M[K] extends { get(): infer V } ? V : never };\n for (const key in map) {\n out[key] = map[key]!.get() as never;\n }\n return out;\n}\n"],"mappings":";;;AAGA,MAAa,UAAiCA,OAAS,OAAO;;;;;;;;;;;;;;;AAgB9D,SAAgB,KAAe,QAAsB,aAA8B,MAAS,GAAsB;CAChH,OAAO,cAAc,UAAU,OAAO,IAAI,CAAC,CAAC;AAC9C;;AAgBA,SAAgB,OAAU,cAAiB,SAAuC;CAChF,OAAO,IAAIA,OAAS,MAAM,cAAc,OAA8B;AACxE;;AAGA,SAAgB,SAAY,IAAa,SAAyC;CAChF,OAAO,IAAIA,OAAS,SAAS,IAAI,OAA8B;AACjE;AAiBA,SAAgB,OAAU,QAAmB,SAA6C;CACxF,MAAM,UAAU,cAAc,OAAO,IAAI,CAAC;CAC1C,IAAI,OAAO,YAAY,YACrB,OAAO,IAAK,QAA8B,OAAO,CAAC;MAGlD,OAAO,IAAI;EAAE,GAAI;EAAoB,GAAG;CAAQ,CAAM;AAE1D;;;;;;;;;AA4BA,SAAgB,SACd,KACiE;CACjE,MAAM,MAAM,CAAC;CACb,KAAK,MAAM,OAAO,KAChB,IAAI,OAAO,IAAI,IAAI,CAAE,IAAI;CAE3B,OAAO;AACT"}
+115
View File
@@ -0,0 +1,115 @@
//#region src/core/tasks/task.d.ts
/** Recursively marks all properties as readonly. */
type DeepReadonly<T> = T extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>> : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]>; } : T;
type TaskStatus = 'pending' | 'running' | 'done' | 'error';
/**
* Configuration for a Task.
*/
interface TaskConfig {
/**
* Identifier for this task.
* - string: used as-is
* - () => string: called once at construction time
* - undefined: a unique ID is generated via generateId()
*/
id?: string | (() => string);
/**
* Optional external AbortSignal to compose with the task's internal one.
* The task's work is aborted when either the internal controller (via abort())
* or this external signal fires — whichever comes first.
*/
signal?: AbortSignal;
}
/**
* Minimal contract for a schedulable unit of async work.
*/
interface TaskLike<TValue = void, TError = unknown> {
readonly id: string;
readonly status: TaskStatus;
readonly value: DeepReadonly<TValue> | undefined;
readonly error: DeepReadonly<TError> | undefined;
run(): Promise<TValue>;
abort(): void;
}
/**
* Generic reusable task that wraps an async run function.
*
* Owns its own AbortController so it can always be aborted independently.
* Optionally composes an external AbortSignal so that a parent's cancellation
* propagates into the task's work without requiring the caller to track the
* task separately.
*
* Ordering guarantee: `value` is written before `status` transitions to `'done'`;
* `error` is written before `status` transitions to `'error'`. Any reader
* observing `status === 'done'` is guaranteed `value` is already present.
*/
declare class Task<TValue = void, TError = unknown> implements TaskLike<TValue, TError> {
#private;
readonly id: string;
constructor(runFn: (signal: AbortSignal) => Promise<TValue>, config?: TaskConfig);
get status(): TaskStatus;
get value(): DeepReadonly<TValue> | undefined;
get error(): DeepReadonly<TError> | undefined;
run(): Promise<TValue>;
abort(): void;
}
/**
* Runs tasks concurrently, deduplicated by task id.
*
* If a task with a given id is already in flight, subsequent schedule() calls
* for that id are silently ignored until the first completes. Tasks are stored
* so abortAll() can cancel any in-flight work (e.g. on engine cleanup).
*/
declare class ConcurrentRunner {
#private;
schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): Promise<TValue>;
/**
* Registers a callback to fire when all currently in-flight tasks settle.
* If the runner is already idle, the callback is never called. If abortAll()
* is called before the batch settles, the callback is superseded and silently
* dropped — no stale callbacks, no generation token required by the caller.
*/
whenSettled(callback: () => void): void;
abortAll(): void;
destroy(): void;
}
/**
* Runs tasks one at a time in submission order.
*
* Each schedule() call returns a Promise that resolves or rejects with the
* task's result when it is eventually executed. Tasks wait in queue until the
* prior task completes.
*
* Serialization is achieved by chaining each task's run() onto the tail of a
* shared promise chain — no explicit queue or drain loop needed.
*
* abortAll() aborts all pending (not yet started) tasks and the currently
* in-flight task. Pending tasks still run briefly but receive an aborted
* signal and are expected to exit early.
*/
declare class SerialRunner {
#private;
schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): Promise<TValue>;
/**
* A promise that resolves when all currently-scheduled tasks have settled.
* Use the reference as a generation token: capture it after scheduling a
* batch, then check identity in the resolution callback to detect whether
* a subsequent abortAll() + new batch has superseded this one.
*/
get settled(): Promise<void>;
/**
* Registers a callback to fire when all currently-pending tasks settle.
* If the runner is already idle (no pending or running tasks), the callback
* is never called. If new tasks are scheduled before the current batch
* settles, the callback is superseded and silently dropped — no stale
* callbacks, no generation token required by the caller.
*/
whenSettled(callback: () => void): void;
/** Aborts and clears queued tasks without touching the in-flight task. */
abortPending(): void;
abortAll(): void;
destroy(): void;
}
//#endregion
export { ConcurrentRunner, DeepReadonly, SerialRunner, Task, TaskConfig, TaskLike, TaskStatus };
//# sourceMappingURL=task.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"task.d.ts","names":[],"sources":["../../../../src/core/tasks/task.ts"],"mappings":";;KAQY,aAAa,KAAK,iBAAiB,OAC3C,cAAc,aAAa,MAC3B,+BACc,WAAW,IAAI,aAAa,EAAE,SAC1C;KAMM;;;;UAKK;;;;;;;EAOf;;;;;;EAOA,SAAS;;;;;UAMM,SAAS,eAAe;WAC9B;WACA,QAAQ;WACR,OAAO,aAAa;WACpB,OAAO,aAAa;EAC7B,OAAO,QAAQ;EACf;;;;;;;;;;;;;;cAeW,KAAK,eAAe,6BAA6B,SAAS,QAAQ;;WACpE;EAST,YAAY,QAAQ,QAAQ,gBAAgB,QAAQ,SAAS,SAAS;MASlE,UAAU;MAIV,SAAS,aAAa;MAItB,SAAS,aAAa;EAIpB,OAAO,QAAQ;EAcrB;;;;;;;;;cAgBW;;EAMX,SAAS,eAAe,kBAAkB,MAAM,SAAS,QAAQ,UAAU,QAAQ;;;;;;;EAkCnF,YAAY;EAYZ;EAWA;;;;;;;;;;;;;;;;cAwBW;;EAMX,SAAS,eAAe,kBAAkB,MAAM,SAAS,QAAQ,UAAU,QAAQ;;;;;;;MA8B/E,WAAW;;;;;;;;EAWf,YAAY;;EAaZ;EAKA;EAKA"}
+191
View File
@@ -0,0 +1,191 @@
import { anyAbortSignal } from "@videojs/utils/events";
import { generateId } from "@videojs/utils/string";
//#region src/core/tasks/task.ts
/**
* Generic reusable task that wraps an async run function.
*
* Owns its own AbortController so it can always be aborted independently.
* Optionally composes an external AbortSignal so that a parent's cancellation
* propagates into the task's work without requiring the caller to track the
* task separately.
*
* Ordering guarantee: `value` is written before `status` transitions to `'done'`;
* `error` is written before `status` transitions to `'error'`. Any reader
* observing `status === 'done'` is guaranteed `value` is already present.
*/
var Task = class {
id;
#runFn;
#abortController = new AbortController();
#signal;
#status = "pending";
#value = void 0;
#error = void 0;
constructor(runFn, config) {
this.#runFn = runFn;
const rawId = config?.id;
this.id = typeof rawId === "function" ? rawId() : rawId ?? generateId();
this.#signal = config?.signal ? anyAbortSignal([this.#abortController.signal, config.signal]) : this.#abortController.signal;
}
get status() {
return this.#status;
}
get value() {
return this.#value;
}
get error() {
return this.#error;
}
async run() {
this.#status = "running";
try {
const result = await this.#runFn(this.#signal);
this.#value = result;
this.#status = "done";
return result;
} catch (e) {
this.#error = e;
this.#status = "error";
throw e;
}
}
abort() {
this.#abortController.abort();
}
};
/**
* Runs tasks concurrently, deduplicated by task id.
*
* If a task with a given id is already in flight, subsequent schedule() calls
* for that id are silently ignored until the first completes. Tasks are stored
* so abortAll() can cancel any in-flight work (e.g. on engine cleanup).
*/
var ConcurrentRunner = class {
#pending = /* @__PURE__ */ new Map();
#settled = Promise.resolve();
#resolveSettled = null;
#destroyed = false;
schedule(task) {
if (this.#destroyed) return Promise.resolve();
const existing = this.#pending.get(task.id);
if (existing) return existing.promise;
if (this.#pending.size === 0) this.#settled = new Promise((resolve) => {
this.#resolveSettled = resolve;
});
const promise = task.run();
promise.catch(() => {});
const cleanup = () => {
this.#pending.delete(task.id);
if (this.#pending.size === 0) {
this.#resolveSettled?.();
this.#resolveSettled = null;
}
};
promise.then(cleanup, cleanup);
this.#pending.set(task.id, {
task,
promise
});
return promise;
}
/**
* Registers a callback to fire when all currently in-flight tasks settle.
* If the runner is already idle, the callback is never called. If abortAll()
* is called before the batch settles, the callback is superseded and silently
* dropped — no stale callbacks, no generation token required by the caller.
*/
whenSettled(callback) {
if (this.#pending.size === 0) return;
const captured = this.#settled;
captured.then(() => {
if (this.#settled !== captured) return;
callback();
}, () => {});
}
abortAll() {
for (const { task } of this.#pending.values()) task.abort();
this.#pending.clear();
this.#resolveSettled?.();
this.#resolveSettled = null;
this.#settled = Promise.resolve();
}
destroy() {
this.#destroyed = true;
this.abortAll();
}
};
/**
* Runs tasks one at a time in submission order.
*
* Each schedule() call returns a Promise that resolves or rejects with the
* task's result when it is eventually executed. Tasks wait in queue until the
* prior task completes.
*
* Serialization is achieved by chaining each task's run() onto the tail of a
* shared promise chain — no explicit queue or drain loop needed.
*
* abortAll() aborts all pending (not yet started) tasks and the currently
* in-flight task. Pending tasks still run briefly but receive an aborted
* signal and are expected to exit early.
*/
var SerialRunner = class {
#chain = Promise.resolve();
#pending = /* @__PURE__ */ new Set();
#current = null;
#destroyed = false;
schedule(task) {
if (this.#destroyed) return Promise.resolve();
const t = task;
this.#pending.add(t);
const result = this.#chain.then(() => {
this.#pending.delete(t);
this.#current = t;
return task.run();
}).finally(() => {
this.#current = null;
});
this.#chain = result.then(() => {}, () => {});
return result;
}
/**
* A promise that resolves when all currently-scheduled tasks have settled.
* Use the reference as a generation token: capture it after scheduling a
* batch, then check identity in the resolution callback to detect whether
* a subsequent abortAll() + new batch has superseded this one.
*/
get settled() {
return this.#chain;
}
/**
* Registers a callback to fire when all currently-pending tasks settle.
* If the runner is already idle (no pending or running tasks), the callback
* is never called. If new tasks are scheduled before the current batch
* settles, the callback is superseded and silently dropped — no stale
* callbacks, no generation token required by the caller.
*/
whenSettled(callback) {
if (this.#pending.size === 0 && this.#current === null) return;
const currentChain = this.#chain;
currentChain.then(() => {
if (this.#chain !== currentChain) return;
callback();
}, () => {});
}
/** Aborts and clears queued tasks without touching the in-flight task. */
abortPending() {
for (const task of this.#pending) task.abort();
this.#pending.clear();
}
abortAll() {
this.abortPending();
this.#current?.abort();
}
destroy() {
this.#destroyed = true;
this.abortAll();
}
};
//#endregion
export { ConcurrentRunner, SerialRunner, Task };
//# sourceMappingURL=task.js.map
File diff suppressed because one or more lines are too long