@videojs/spf 10.0.0-beta.14 → 10.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/default/core/buffer/forward-buffer.js.map +1 -1
  2. package/dist/default/core/create-machine-actor.js +91 -0
  3. package/dist/default/core/create-machine-actor.js.map +1 -0
  4. package/dist/default/core/create-machine-reactor.js +83 -0
  5. package/dist/default/core/create-machine-reactor.js.map +1 -0
  6. package/dist/default/core/create-transition-actor.js +46 -0
  7. package/dist/default/core/create-transition-actor.js.map +1 -0
  8. package/dist/default/core/features/resolve-presentation.js +42 -46
  9. package/dist/default/core/features/resolve-presentation.js.map +1 -1
  10. package/dist/default/core/features/resolve-track.js +1 -1
  11. package/dist/default/core/machine.js +26 -0
  12. package/dist/default/core/machine.js.map +1 -0
  13. package/dist/default/core/signals/primitives.js +1 -1
  14. package/dist/default/core/task.js +76 -7
  15. package/dist/default/core/task.js.map +1 -1
  16. package/dist/default/core/types/index.js.map +1 -1
  17. package/dist/default/dom/features/end-of-stream.js +5 -5
  18. package/dist/default/dom/features/end-of-stream.js.map +1 -1
  19. package/dist/default/dom/features/load-text-track-cues.js +80 -137
  20. package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
  21. package/dist/default/dom/features/segment-loader-actor.js +150 -103
  22. package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
  23. package/dist/default/dom/features/sync-text-tracks.js +95 -0
  24. package/dist/default/dom/features/sync-text-tracks.js.map +1 -0
  25. package/dist/default/dom/features/text-track-segment-loader-actor.js +55 -0
  26. package/dist/default/dom/features/text-track-segment-loader-actor.js.map +1 -0
  27. package/dist/default/dom/features/text-tracks-actor.js +42 -0
  28. package/dist/default/dom/features/text-tracks-actor.js.map +1 -0
  29. package/dist/default/dom/features/track-playback-initiated.js +51 -38
  30. package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
  31. package/dist/default/dom/media/source-buffer-actor.js +52 -91
  32. package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
  33. package/dist/default/dom/network/fetch.js +0 -1
  34. package/dist/default/dom/network/fetch.js.map +1 -1
  35. package/dist/default/dom/playback-engine/engine.js +4 -13
  36. package/dist/default/dom/playback-engine/engine.js.map +1 -1
  37. package/dist/dev/core/actor.d.ts +19 -10
  38. package/dist/dev/core/actor.d.ts.map +1 -1
  39. package/dist/dev/core/buffer/forward-buffer.js.map +1 -1
  40. package/dist/dev/core/create-machine-actor.d.ts +12 -0
  41. package/dist/dev/core/create-machine-actor.d.ts.map +1 -0
  42. package/dist/dev/core/create-machine-actor.js +91 -0
  43. package/dist/dev/core/create-machine-actor.js.map +1 -0
  44. package/dist/dev/core/create-machine-reactor.d.ts +8 -0
  45. package/dist/dev/core/create-machine-reactor.d.ts.map +1 -0
  46. package/dist/dev/core/create-machine-reactor.js +83 -0
  47. package/dist/dev/core/create-machine-reactor.js.map +1 -0
  48. package/dist/dev/core/create-transition-actor.d.ts +23 -0
  49. package/dist/dev/core/create-transition-actor.d.ts.map +1 -0
  50. package/dist/dev/core/create-transition-actor.js +46 -0
  51. package/dist/dev/core/create-transition-actor.js.map +1 -0
  52. package/dist/dev/core/features/resolve-presentation.js +42 -46
  53. package/dist/dev/core/features/resolve-presentation.js.map +1 -1
  54. package/dist/dev/core/features/resolve-track.js +1 -1
  55. package/dist/dev/core/machine.d.ts +21 -0
  56. package/dist/dev/core/machine.d.ts.map +1 -0
  57. package/dist/dev/core/machine.js +26 -0
  58. package/dist/dev/core/machine.js.map +1 -0
  59. package/dist/dev/core/signals/primitives.js +1 -1
  60. package/dist/dev/core/task.js +76 -7
  61. package/dist/dev/core/task.js.map +1 -1
  62. package/dist/dev/core/types/index.d.ts +1 -8
  63. package/dist/dev/core/types/index.d.ts.map +1 -1
  64. package/dist/dev/core/types/index.js.map +1 -1
  65. package/dist/dev/dom/features/end-of-stream.js +5 -5
  66. package/dist/dev/dom/features/end-of-stream.js.map +1 -1
  67. package/dist/dev/dom/features/load-text-track-cues.d.ts +50 -26
  68. package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
  69. package/dist/dev/dom/features/load-text-track-cues.js +80 -137
  70. package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
  71. package/dist/dev/dom/features/segment-loader-actor.js +150 -103
  72. package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
  73. package/dist/dev/dom/features/sync-text-tracks.js +95 -0
  74. package/dist/dev/dom/features/sync-text-tracks.js.map +1 -0
  75. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts +12 -0
  76. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts.map +1 -0
  77. package/dist/dev/dom/features/text-track-segment-loader-actor.js +55 -0
  78. package/dist/dev/dom/features/text-track-segment-loader-actor.js.map +1 -0
  79. package/dist/dev/dom/features/text-tracks-actor.d.ts +31 -0
  80. package/dist/dev/dom/features/text-tracks-actor.d.ts.map +1 -0
  81. package/dist/dev/dom/features/text-tracks-actor.js +42 -0
  82. package/dist/dev/dom/features/text-tracks-actor.js.map +1 -0
  83. package/dist/dev/dom/features/track-playback-initiated.d.ts +11 -9
  84. package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
  85. package/dist/dev/dom/features/track-playback-initiated.js +51 -38
  86. package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
  87. package/dist/dev/dom/media/source-buffer-actor.d.ts +14 -8
  88. package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
  89. package/dist/dev/dom/media/source-buffer-actor.js +52 -91
  90. package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
  91. package/dist/dev/dom/network/fetch.js +0 -1
  92. package/dist/dev/dom/network/fetch.js.map +1 -1
  93. package/dist/dev/dom/playback-engine/engine.d.ts +4 -3
  94. package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
  95. package/dist/dev/dom/playback-engine/engine.js +4 -13
  96. package/dist/dev/dom/playback-engine/engine.js.map +1 -1
  97. package/package.json +5 -4
  98. package/dist/default/dom/features/setup-text-tracks.js +0 -67
  99. package/dist/default/dom/features/setup-text-tracks.js.map +0 -1
  100. package/dist/default/dom/features/sync-selected-text-track-from-dom.js +0 -54
  101. package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  102. package/dist/default/dom/features/sync-text-track-modes.js +0 -33
  103. package/dist/default/dom/features/sync-text-track-modes.js.map +0 -1
  104. package/dist/dev/dom/features/setup-text-tracks.js +0 -67
  105. package/dist/dev/dom/features/setup-text-tracks.js.map +0 -1
  106. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +0 -54
  107. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  108. package/dist/dev/dom/features/sync-text-track-modes.js +0 -33
  109. package/dist/dev/dom/features/sync-text-track-modes.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"forward-buffer.js","names":[],"sources":["../../../../src/core/buffer/forward-buffer.ts"],"sourcesContent":["/**\n * Forward Buffer Strategy (Simple)\n *\n * Determines which segments to load for forward buffer management.\n * V1 uses simple fixed-duration strategy (buffer N seconds ahead).\n */\n\nimport type { Segment } from '../types';\n\n/**\n * Forward buffer configuration.\n */\nexport interface ForwardBufferConfig {\n /**\n * Duration in seconds to buffer ahead of current playback position.\n * Default: 30 seconds.\n */\n bufferDuration: number;\n}\n\n/**\n * Default forward buffer configuration.\n */\nexport const DEFAULT_FORWARD_BUFFER_CONFIG: ForwardBufferConfig = {\n bufferDuration: 30,\n};\n\n/**\n * Get segments that need to be loaded for forward buffer.\n *\n * Determines which segments to load to maintain target buffer duration.\n * Handles discontiguous buffering (gaps after seeks).\n *\n * Algorithm:\n * 1. Calculate target time: currentTime + bufferDuration\n * 2. Find all segments in range [currentTime, targetTime)\n * 3. Filter out segments already buffered at that time position\n * 4. Return segments to load (fills gaps + extends to target)\n *\n * @param segments - All available segments from playlist\n * @param bufferedSegments - Segments already buffered (ordered by startTime)\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Array of segments to load (empty if buffer is sufficient)\n *\n * @example\n * // After seek: buffered [0-12, 18-30], playing at 7s\n * const toLoad = getSegmentsToLoad(segments, buffered, 7, { bufferDuration: 24 });\n * // Returns [seg-12, seg-30] (fills gap, extends to target 31s)\n */\n/**\n * Calculate the start time from which to flush forward buffer content.\n *\n * Content that starts at or beyond `currentTime + bufferDuration` is no\n * longer needed for the current playback position and should be removed\n * from the SourceBuffer. This prevents unbounded accumulation of scattered\n * SourceBuffer content after seeks, which can cause QuotaExceededError on\n * long-form content.\n *\n * Returns `Infinity` when nothing needs flushing (no buffered segments\n * exist beyond the threshold).\n *\n * @param bufferedSegments - Segments currently tracked in the buffer model\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Start time to flush from (flush range: [flushStart, Infinity)),\n * or Infinity if no flush is needed\n *\n * @example\n * // Playing at 0s, buffered [0,6,12,18,24,30,36], bufferDuration=30\n * const flushStart = calculateForwardFlushPoint(segments, 0);\n * // Returns 30 — flush [30, Infinity), keep [0, 30)\n */\nexport function calculateForwardFlushPoint(\n bufferedSegments: readonly Segment[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): number {\n if (bufferedSegments.length === 0) return Infinity;\n\n const threshold = currentTime + config.bufferDuration;\n\n // Find segments that start at or beyond the threshold\n const beyond = bufferedSegments.filter((seg) => seg.startTime >= threshold);\n\n if (beyond.length === 0) return Infinity;\n\n // Flush from the earliest such segment onward\n return Math.min(...beyond.map((seg) => seg.startTime));\n}\n\nexport function getSegmentsToLoad(\n segments: readonly Segment[],\n bufferedSegments: readonly Segment[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): Segment[] {\n if (segments.length === 0) {\n return [];\n }\n\n // Calculate target buffer end time\n const targetTime = currentTime + config.bufferDuration;\n\n // Create set of buffered segment start times for fast lookup\n // V1 simple: if ANY segment is buffered at a given time, don't load for that time\n // V2 (future): would compare by startTime + bitrate/track for quality switching\n const bufferedStartTimes = new Set(bufferedSegments.map((seg) => seg.startTime));\n\n // Find segments to load:\n // - Overlaps buffer window [currentTime, targetTime)\n // - Not already buffered at that time position\n const toLoad = segments.filter((seg) => {\n // Segment must overlap the buffer window\n const segmentEnd = seg.startTime + seg.duration;\n const isInRange = seg.startTime < targetTime && segmentEnd > currentTime;\n\n // Must not have a segment buffered at this time position\n const isNotBuffered = !bufferedStartTimes.has(seg.startTime);\n\n return isInRange && isNotBuffered;\n });\n\n return toLoad;\n}\n"],"mappings":";;;;AAuBA,MAAa,gCAAqD,EAChE,gBAAgB,IACjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDD,SAAgB,2BACd,kBACA,aACA,SAA8B,+BACtB;AACR,KAAI,iBAAiB,WAAW,EAAG,QAAO;CAE1C,MAAM,YAAY,cAAc,OAAO;CAGvC,MAAM,SAAS,iBAAiB,QAAQ,QAAQ,IAAI,aAAa,UAAU;AAE3E,KAAI,OAAO,WAAW,EAAG,QAAO;AAGhC,QAAO,KAAK,IAAI,GAAG,OAAO,KAAK,QAAQ,IAAI,UAAU,CAAC;;AAGxD,SAAgB,kBACd,UACA,kBACA,aACA,SAA8B,+BACnB;AACX,KAAI,SAAS,WAAW,EACtB,QAAO,EAAE;CAIX,MAAM,aAAa,cAAc,OAAO;CAKxC,MAAM,qBAAqB,IAAI,IAAI,iBAAiB,KAAK,QAAQ,IAAI,UAAU,CAAC;AAgBhF,QAXe,SAAS,QAAQ,QAAQ;EAEtC,MAAM,aAAa,IAAI,YAAY,IAAI;EACvC,MAAM,YAAY,IAAI,YAAY,cAAc,aAAa;EAG7D,MAAM,gBAAgB,CAAC,mBAAmB,IAAI,IAAI,UAAU;AAE5D,SAAO,aAAa;GACpB"}
1
+ {"version":3,"file":"forward-buffer.js","names":[],"sources":["../../../../src/core/buffer/forward-buffer.ts"],"sourcesContent":["/**\n * Forward Buffer Strategy (Simple)\n *\n * Determines which segments to load for forward buffer management.\n * V1 uses simple fixed-duration strategy (buffer N seconds ahead).\n */\n\nimport type { Segment } from '../types';\n\n/**\n * Forward buffer configuration.\n */\nexport interface ForwardBufferConfig {\n /**\n * Duration in seconds to buffer ahead of current playback position.\n * Default: 30 seconds.\n */\n bufferDuration: number;\n}\n\n/**\n * Default forward buffer configuration.\n */\nexport const DEFAULT_FORWARD_BUFFER_CONFIG: ForwardBufferConfig = {\n bufferDuration: 30,\n};\n\n/**\n * Get segments that need to be loaded for forward buffer.\n *\n * Determines which segments to load to maintain target buffer duration.\n * Handles discontiguous buffering (gaps after seeks).\n *\n * Algorithm:\n * 1. Calculate target time: currentTime + bufferDuration\n * 2. Find all segments in range [currentTime, targetTime)\n * 3. Filter out segments already buffered at that time position\n * 4. Return segments to load (fills gaps + extends to target)\n *\n * @param segments - All available segments from playlist\n * @param bufferedSegments - Segments already buffered (ordered by startTime)\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Array of segments to load (empty if buffer is sufficient)\n *\n * @example\n * // After seek: buffered [0-12, 18-30], playing at 7s\n * const toLoad = getSegmentsToLoad(segments, buffered, 7, { bufferDuration: 24 });\n * // Returns [seg-12, seg-30] (fills gap, extends to target 31s)\n */\n/**\n * Calculate the start time from which to flush forward buffer content.\n *\n * Content that starts at or beyond `currentTime + bufferDuration` is no\n * longer needed for the current playback position and should be removed\n * from the SourceBuffer. This prevents unbounded accumulation of scattered\n * SourceBuffer content after seeks, which can cause QuotaExceededError on\n * long-form content.\n *\n * Returns `Infinity` when nothing needs flushing (no buffered segments\n * exist beyond the threshold).\n *\n * @param bufferedSegments - Segments currently tracked in the buffer model\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Start time to flush from (flush range: [flushStart, Infinity)),\n * or Infinity if no flush is needed\n *\n * @example\n * // Playing at 0s, buffered [0,6,12,18,24,30,36], bufferDuration=30\n * const flushStart = calculateForwardFlushPoint(segments, 0);\n * // Returns 30 — flush [30, Infinity), keep [0, 30)\n */\nexport function calculateForwardFlushPoint(\n bufferedSegments: readonly Segment[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): number {\n if (bufferedSegments.length === 0) return Infinity;\n\n const threshold = currentTime + config.bufferDuration;\n\n // Find segments that start at or beyond the threshold\n const beyond = bufferedSegments.filter((seg) => seg.startTime >= threshold);\n\n if (beyond.length === 0) return Infinity;\n\n // Flush from the earliest such segment onward\n return Math.min(...beyond.map((seg) => seg.startTime));\n}\n\nexport function getSegmentsToLoad(\n segments: readonly Segment[],\n bufferedSegments: readonly Pick<Segment, 'startTime' | 'duration'>[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): Segment[] {\n if (segments.length === 0) {\n return [];\n }\n\n // Calculate target buffer end time\n const targetTime = currentTime + config.bufferDuration;\n\n // Create set of buffered segment start times for fast lookup\n // V1 simple: if ANY segment is buffered at a given time, don't load for that time\n // V2 (future): would compare by startTime + bitrate/track for quality switching\n const bufferedStartTimes = new Set(bufferedSegments.map((seg) => seg.startTime));\n\n // Find segments to load:\n // - Overlaps buffer window [currentTime, targetTime)\n // - Not already buffered at that time position\n const toLoad = segments.filter((seg) => {\n // Segment must overlap the buffer window\n const segmentEnd = seg.startTime + seg.duration;\n const isInRange = seg.startTime < targetTime && segmentEnd > currentTime;\n\n // Must not have a segment buffered at this time position\n const isNotBuffered = !bufferedStartTimes.has(seg.startTime);\n\n return isInRange && isNotBuffered;\n });\n\n return toLoad;\n}\n"],"mappings":";;;;AAuBA,MAAa,gCAAqD,EAChE,gBAAgB,IACjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDD,SAAgB,2BACd,kBACA,aACA,SAA8B,+BACtB;AACR,KAAI,iBAAiB,WAAW,EAAG,QAAO;CAE1C,MAAM,YAAY,cAAc,OAAO;CAGvC,MAAM,SAAS,iBAAiB,QAAQ,QAAQ,IAAI,aAAa,UAAU;AAE3E,KAAI,OAAO,WAAW,EAAG,QAAO;AAGhC,QAAO,KAAK,IAAI,GAAG,OAAO,KAAK,QAAQ,IAAI,UAAU,CAAC;;AAGxD,SAAgB,kBACd,UACA,kBACA,aACA,SAA8B,+BACnB;AACX,KAAI,SAAS,WAAW,EACtB,QAAO,EAAE;CAIX,MAAM,aAAa,cAAc,OAAO;CAKxC,MAAM,qBAAqB,IAAI,IAAI,iBAAiB,KAAK,QAAQ,IAAI,UAAU,CAAC;AAgBhF,QAXe,SAAS,QAAQ,QAAQ;EAEtC,MAAM,aAAa,IAAI,YAAY,IAAI;EACvC,MAAM,YAAY,IAAI,YAAY,cAAc,aAAa;EAG7D,MAAM,gBAAgB,CAAC,mBAAmB,IAAI,IAAI,UAAU;AAE5D,SAAO,aAAa;GACpB"}
@@ -0,0 +1,91 @@
1
+ import { untrack, update } from "./signals/primitives.js";
2
+ import { createMachineCore } from "./machine.js";
3
+ //#region src/core/create-machine-actor.ts
4
+ /**
5
+ * Creates a message-driven actor from a declarative definition.
6
+ *
7
+ * The actor owns a reactive snapshot signal (state + context), an optional
8
+ * runner, and dispatches incoming messages to per-state handlers. `'destroyed'`
9
+ * is always the implicit terminal state — `destroy()` transitions there
10
+ * unconditionally and all subsequent `send()` calls are no-ops.
11
+ *
12
+ * When a state declares `onSettled`, the framework calls `runner.whenSettled()`
13
+ * after the handler returns. The runner owns the generation-token logic — if
14
+ * new tasks are scheduled before the current batch settles, the callback is
15
+ * automatically superseded.
16
+ *
17
+ * @example
18
+ * const actor = createMachineActor({
19
+ * runner: () => new SerialRunner(),
20
+ * initial: 'idle',
21
+ * context: {},
22
+ * states: {
23
+ * idle: {
24
+ * on: {
25
+ * load: (msg, { transition, runner }) => {
26
+ * segments.forEach(s => runner.schedule(new Task(...)));
27
+ * transition('loading');
28
+ * }
29
+ * }
30
+ * },
31
+ * loading: {
32
+ * onSettled: 'idle',
33
+ * on: {
34
+ * load: (msg, { runner }) => {
35
+ * runner.abortAll();
36
+ * segments.forEach(s => runner.schedule(new Task(...)));
37
+ * }
38
+ * }
39
+ * }
40
+ * }
41
+ * });
42
+ */
43
+ function createMachineActor(def) {
44
+ const runner = def.runner?.();
45
+ const { snapshotSignal, getState, transition } = createMachineCore({
46
+ value: def.initial,
47
+ context: def.context
48
+ });
49
+ const getContext = () => untrack(() => snapshotSignal.get().context);
50
+ const setContext = (context) => {
51
+ update(snapshotSignal, { context });
52
+ };
53
+ return {
54
+ get snapshot() {
55
+ return snapshotSignal;
56
+ },
57
+ send(message) {
58
+ const state = getState();
59
+ if (state === "destroyed") return;
60
+ const handler = def.states[state]?.on?.[message.type];
61
+ if (!handler) return;
62
+ handler(message, {
63
+ context: getContext(),
64
+ getContext,
65
+ transition: (to) => transition(to),
66
+ setContext,
67
+ ...runner ? { runner } : {}
68
+ });
69
+ const newState = getState();
70
+ if (newState !== "destroyed") {
71
+ const newStateDef = def.states[newState];
72
+ if (newStateDef?.onSettled && runner) {
73
+ const targetState = newStateDef.onSettled;
74
+ runner.whenSettled(() => {
75
+ if (getState() !== newState) return;
76
+ transition(targetState);
77
+ });
78
+ }
79
+ }
80
+ },
81
+ destroy() {
82
+ if (getState() === "destroyed") return;
83
+ runner?.destroy();
84
+ transition("destroyed");
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ export { createMachineActor };
90
+
91
+ //# sourceMappingURL=create-machine-actor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-machine-actor.js","names":[],"sources":["../../../src/core/create-machine-actor.ts"],"sourcesContent":["import type { ActorSnapshot, SignalActor } from './actor';\nimport { createMachineCore } from './machine';\nimport { untrack, update } from './signals/primitives';\nimport type { TaskLike } from './task';\n\n// =============================================================================\n// Runner interfaces\n// =============================================================================\n\n/**\n * Minimal interface for any runner that can be used with createMachineActor.\n */\nexport interface RunnerLike {\n schedule<Value = void, Err = unknown>(task: TaskLike<Value, Err>): Promise<Value>;\n abortAll(): void;\n destroy(): void;\n whenSettled(callback: () => void): void;\n}\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * Context passed to message handlers.\n * `runner` is present and typed as the exact runner instance only when the\n * definition includes a runner factory.\n */\nexport type HandlerContext<\n UserState extends string,\n Context extends object,\n RunnerFactory extends (() => RunnerLike) | undefined,\n> = {\n transition: (to: UserState) => void;\n /** Context snapshot captured at dispatch time. Stale after any `setContext` call. */\n context: Context;\n /**\n * Live untracked read of the current context. Use in async task closures that\n * execute after the handler returns — e.g. `getCtx: getContext` passed to tasks\n * scheduled on the runner, so each task reads the context committed by the\n * previous task rather than the stale snapshot from dispatch time.\n */\n getContext: () => Context;\n setContext: (next: Context) => void;\n} & (RunnerFactory extends () => infer R ? { runner: R } : object);\n\n/**\n * Definition for a single user-defined state.\n */\nexport type ActorStateDefinition<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined,\n> = {\n /**\n * When the actor's runner settles while in this state, automatically\n * transition to this state. The framework owns the generation-token logic —\n * re-registering after each `runner.schedule()` call so that\n * `abortAll()` + reschedule correctly supersedes stale callbacks.\n */\n onSettled?: UserState;\n /** Message handlers active in this state. Messages with no handler are silently dropped. */\n on?: {\n [M in Message as M['type']]?: (\n message: Extract<Message, { type: M['type'] }>,\n ctx: HandlerContext<UserState, Context, RunnerFactory>\n ) => void;\n };\n};\n\n/**\n * Full actor definition passed to `createMachineActor`.\n *\n * `UserState` is the set of domain-meaningful states. `'destroyed'` is always\n * added by the framework as the implicit terminal state — do not include it here.\n */\nexport type ActorDefinition<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined = undefined,\n> = {\n /**\n * Runner factory — called once at `createMachineActor()` time.\n * The runner lives for the full actor lifetime and is destroyed with it.\n *\n * @example\n * runner: () => new SerialRunner()\n */\n runner?: RunnerFactory;\n /** Initial state. */\n initial: UserState;\n /** Initial context. */\n context: Context;\n /**\n * Per-state definitions. States with no definition silently drop all messages.\n * All user-defined states must appear as keys in the `UserState` union.\n */\n states: Partial<Record<UserState, ActorStateDefinition<UserState, Context, Message, RunnerFactory>>>;\n};\n\n// =============================================================================\n// Live actor interface\n// =============================================================================\n\n/** Live actor instance returned by `createMachineActor`. */\nexport interface MessageActor<State extends string, Context extends object, Message extends { type: string }>\n extends SignalActor<State, Context> {\n send(message: Message): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a message-driven actor from a declarative definition.\n *\n * The actor owns a reactive snapshot signal (state + context), an optional\n * runner, and dispatches incoming messages to per-state handlers. `'destroyed'`\n * is always the implicit terminal state — `destroy()` transitions there\n * unconditionally and all subsequent `send()` calls are no-ops.\n *\n * When a state declares `onSettled`, the framework calls `runner.whenSettled()`\n * after the handler returns. The runner owns the generation-token logic — if\n * new tasks are scheduled before the current batch settles, the callback is\n * automatically superseded.\n *\n * @example\n * const actor = createMachineActor({\n * runner: () => new SerialRunner(),\n * initial: 'idle',\n * context: {},\n * states: {\n * idle: {\n * on: {\n * load: (msg, { transition, runner }) => {\n * segments.forEach(s => runner.schedule(new Task(...)));\n * transition('loading');\n * }\n * }\n * },\n * loading: {\n * onSettled: 'idle',\n * on: {\n * load: (msg, { runner }) => {\n * runner.abortAll();\n * segments.forEach(s => runner.schedule(new Task(...)));\n * }\n * }\n * }\n * }\n * });\n */\nexport function createMachineActor<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined = undefined,\n>(\n def: ActorDefinition<UserState, Context, Message, RunnerFactory>\n): MessageActor<UserState | 'destroyed', Context, Message> {\n type FullState = UserState | 'destroyed';\n\n const runner = def.runner?.() as RunnerLike | undefined;\n const { snapshotSignal, getState, transition } = createMachineCore<FullState, ActorSnapshot<FullState, Context>>({\n value: def.initial as FullState,\n context: def.context,\n });\n\n const getContext = (): Context => untrack(() => snapshotSignal.get().context);\n\n const setContext = (context: Context): void => {\n update(snapshotSignal, { context });\n };\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n send(message: Message): void {\n const state = getState();\n if (state === 'destroyed') return;\n const stateDef = def.states[state as UserState];\n const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as\n | ((msg: Message, ctx: HandlerContext<UserState, Context, RunnerFactory>) => void)\n | undefined;\n if (!handler) return;\n handler(message, {\n context: getContext(),\n getContext,\n transition: (to: UserState) => transition(to as FullState),\n setContext,\n ...(runner ? { runner } : {}),\n } as HandlerContext<UserState, Context, RunnerFactory>);\n // Register onSettled after the handler so we read the post-transition state.\n const newState = getState();\n if (newState !== 'destroyed') {\n const newStateDef = def.states[newState as UserState];\n if (newStateDef?.onSettled && runner) {\n const targetState = newStateDef.onSettled as FullState;\n runner.whenSettled(() => {\n if (getState() !== newState) return;\n transition(targetState);\n });\n }\n }\n },\n\n destroy(): void {\n if (getState() === 'destroyed') return;\n runner?.destroy();\n transition('destroyed');\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2JA,SAAgB,mBAMd,KACyD;CAGzD,MAAM,SAAS,IAAI,UAAU;CAC7B,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAAgE;EAC/G,OAAO,IAAI;EACX,SAAS,IAAI;EACd,CAAC;CAEF,MAAM,mBAA4B,cAAc,eAAe,KAAK,CAAC,QAAQ;CAE7E,MAAM,cAAc,YAA2B;AAC7C,SAAO,gBAAgB,EAAE,SAAS,CAAC;;AAGrC,QAAO;EACL,IAAI,WAAW;AACb,UAAO;;EAGT,KAAK,SAAwB;GAC3B,MAAM,QAAQ,UAAU;AACxB,OAAI,UAAU,YAAa;GAE3B,MAAM,UADW,IAAI,OAAO,QACF,KAAK,QAAQ;AAGvC,OAAI,CAAC,QAAS;AACd,WAAQ,SAAS;IACf,SAAS,YAAY;IACrB;IACA,aAAa,OAAkB,WAAW,GAAgB;IAC1D;IACA,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;IAC7B,CAAsD;GAEvD,MAAM,WAAW,UAAU;AAC3B,OAAI,aAAa,aAAa;IAC5B,MAAM,cAAc,IAAI,OAAO;AAC/B,QAAI,aAAa,aAAa,QAAQ;KACpC,MAAM,cAAc,YAAY;AAChC,YAAO,kBAAkB;AACvB,UAAI,UAAU,KAAK,SAAU;AAC7B,iBAAW,YAAY;OACvB;;;;EAKR,UAAgB;AACd,OAAI,UAAU,KAAK,YAAa;AAChC,WAAQ,SAAS;AACjB,cAAW,YAAY;;EAE1B"}
@@ -0,0 +1,83 @@
1
+ import { effect } from "./signals/effect.js";
2
+ import { untrack } from "./signals/primitives.js";
3
+ import { createMachineCore } from "./machine.js";
4
+ //#region src/core/create-machine-reactor.ts
5
+ const toArray = (x) => x === void 0 ? [] : Array.isArray(x) ? x : [x];
6
+ /**
7
+ * Creates a reactive Reactor from a declarative definition.
8
+ *
9
+ * A Reactor is driven by subscriptions to external signals rather than
10
+ * imperative messages. Each state holds an array of effect functions —
11
+ * every element becomes one independent `effect()` call gated on that state,
12
+ * with its own dependency tracking and cleanup lifecycle.
13
+ *
14
+ * `'destroying'` and `'destroyed'` are always implicit terminal states.
15
+ * `destroy()` transitions through both in sequence: `'destroying'` first (for
16
+ * potential async teardown in a future extension), then immediately `'destroyed'`
17
+ * for the synchronous base case. Active effect cleanups fire via disposal.
18
+ *
19
+ * @example
20
+ * const reactor = createMachineReactor({
21
+ * initial: 'waiting',
22
+ * monitor: () => srcSignal.get() ? 'active' : 'waiting',
23
+ * states: {
24
+ * active: {
25
+ * // entry: runs once on state entry; fn body is automatically untracked.
26
+ * entry: () => listen(el, 'play', handler),
27
+ * // effects: re-runs whenever tracked signals change.
28
+ * effects: () => { currentTimeSignal.get(); return cleanup; },
29
+ * },
30
+ * waiting: {},
31
+ * }
32
+ * });
33
+ */
34
+ function createMachineReactor(def) {
35
+ const { snapshotSignal, getState, transition } = createMachineCore({ value: def.initial });
36
+ const effectDisposals = [];
37
+ const wrapResult = (result) => {
38
+ if (!result) return void 0;
39
+ if (typeof result === "function") return result;
40
+ return () => result.abort();
41
+ };
42
+ const untracked = (baseCall) => () => untrack(baseCall);
43
+ const isTerminal = (snapshot) => snapshot.value === "destroying" || snapshot.value === "destroyed";
44
+ const descriptors = [...toArray(def.monitor).map((fn) => ({
45
+ fn: () => {
46
+ const target = fn();
47
+ if (target !== getState()) transition(target);
48
+ },
49
+ shouldSkip: isTerminal
50
+ })), ...Object.entries(def.states).flatMap(([state, stateDef]) => {
51
+ const isNotState = (snapshot) => snapshot.value !== state;
52
+ return [...toArray(stateDef.entry).map((fn) => ({
53
+ fn,
54
+ shouldSkip: isNotState,
55
+ toFnCall: untracked
56
+ })), ...toArray(stateDef.effects).map((fn) => ({
57
+ fn,
58
+ shouldSkip: isNotState
59
+ }))];
60
+ })];
61
+ const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }) => effect(() => {
62
+ if (shouldSkip(snapshotSignal.get())) return;
63
+ const baseCall = () => fn();
64
+ return wrapResult(toFnCall(baseCall)());
65
+ });
66
+ effectDisposals.push(...descriptors.map(toEffect));
67
+ return {
68
+ get snapshot() {
69
+ return snapshotSignal;
70
+ },
71
+ destroy() {
72
+ const state = getState();
73
+ if (state === "destroying" || state === "destroyed") return;
74
+ transition("destroying");
75
+ transition("destroyed");
76
+ for (const dispose of effectDisposals) dispose();
77
+ }
78
+ };
79
+ }
80
+ //#endregion
81
+ export { createMachineReactor };
82
+
83
+ //# sourceMappingURL=create-machine-reactor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-machine-reactor.js","names":[],"sources":["../../../src/core/create-machine-reactor.ts"],"sourcesContent":["import type { Machine, MachineSnapshot } from './machine';\nimport { createMachineCore } from './machine';\nimport { effect } from './signals/effect';\nimport { untrack } from './signals/primitives';\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * A reactive state-deriving function used in the `monitor` field.\n *\n * Returns the target state the reactor should be in. Any signals read inside\n * the fn body create reactive dependencies — the framework re-evaluates it when\n * those signals change and automatically calls `transition()` when the returned\n * state differs from the current one.\n */\nexport type ReactorDeriveFn<State extends string> = () => State;\n\n/**\n * An effect function used in reactor `entry` and `effects` blocks.\n *\n * May return a cleanup function that runs before each re-evaluation and on\n * state exit (including destroy).\n */\nexport type ReactorEffectFn = () => (() => void) | { abort(): void } | void;\n\n/**\n * Per-state effect grouping for a single reactor state.\n *\n * - `entry` effects run once on state entry. The fn body is automatically\n * untracked — no `untrack()` calls are needed inside. Use this for\n * one-time setup: reading current values, attaching event listeners, etc.\n * - `effects` run on state entry and re-run whenever a signal read inside\n * the fn body changes. Use `untrack()` for reads you do not want to track.\n * Use this for work that must stay in sync with reactive state.\n *\n * Both are optional; pass `{}` for states with no effects.\n */\nexport type ReactorStateDefinition = {\n entry?: ReactorEffectFn | ReactorEffectFn[];\n effects?: ReactorEffectFn | ReactorEffectFn[];\n};\n\n/**\n * Full reactor definition passed to `createMachineReactor`.\n *\n * `State` is the set of domain-meaningful states. `'destroying'` and\n * `'destroyed'` are always added by the framework as implicit terminal states —\n * do not include them here.\n */\nexport type ReactorDefinition<State extends string> = {\n /** Initial state. */\n initial: State;\n /**\n * Reactive state derivation. Registered before per-state effects — the\n * ordering guarantee ensures transitions fired here take effect before\n * per-state effects re-evaluate in the same flush.\n */\n monitor?: ReactorDeriveFn<State> | ReactorDeriveFn<State>[];\n /**\n * Per-state effect groupings. Every valid state must be declared — pass `{}`\n * for states with no effects. `entry` and `effects` each become independent\n * `effect()` calls gated on that state, with their own cleanup lifecycles.\n */\n states: Record<State, ReactorStateDefinition>;\n};\n\n// =============================================================================\n// Live reactor interface\n// =============================================================================\n\n/** Live reactor instance returned by `createMachineReactor`. */\nexport type Reactor<State extends string> = Machine<MachineSnapshot<State>>;\n\n// =============================================================================\n// Implementation helpers\n// =============================================================================\n\nconst toArray = <T>(x: T | T[] | undefined): T[] => (x === undefined ? [] : Array.isArray(x) ? x : [x]);\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a reactive Reactor from a declarative definition.\n *\n * A Reactor is driven by subscriptions to external signals rather than\n * imperative messages. Each state holds an array of effect functions —\n * every element becomes one independent `effect()` call gated on that state,\n * with its own dependency tracking and cleanup lifecycle.\n *\n * `'destroying'` and `'destroyed'` are always implicit terminal states.\n * `destroy()` transitions through both in sequence: `'destroying'` first (for\n * potential async teardown in a future extension), then immediately `'destroyed'`\n * for the synchronous base case. Active effect cleanups fire via disposal.\n *\n * @example\n * const reactor = createMachineReactor({\n * initial: 'waiting',\n * monitor: () => srcSignal.get() ? 'active' : 'waiting',\n * states: {\n * active: {\n * // entry: runs once on state entry; fn body is automatically untracked.\n * entry: () => listen(el, 'play', handler),\n * // effects: re-runs whenever tracked signals change.\n * effects: () => { currentTimeSignal.get(); return cleanup; },\n * },\n * waiting: {},\n * }\n * });\n */\nexport function createMachineReactor<State extends string>(\n def: ReactorDefinition<State>\n): Reactor<State | 'destroying' | 'destroyed'> {\n type FullState = State | 'destroying' | 'destroyed';\n\n const { snapshotSignal, getState, transition } = createMachineCore<FullState, MachineSnapshot<FullState>>({\n value: def.initial as FullState,\n });\n\n const effectDisposals: Array<() => void> = [];\n\n const wrapResult = (result: ReturnType<ReactorEffectFn>) => {\n if (!result) return undefined;\n if (typeof result === 'function') return result;\n return () => result.abort();\n };\n\n type EffectCall = () => ReturnType<ReactorEffectFn>;\n\n type EffectDescriptor = {\n fn: ReactorEffectFn;\n shouldSkip: (snapshot: { value: FullState }) => boolean;\n toFnCall?: (baseCall: EffectCall) => EffectCall;\n };\n\n const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall);\n\n const isTerminal = (snapshot: { value: FullState }) =>\n snapshot.value === 'destroying' || snapshot.value === 'destroyed';\n\n // `monitor` descriptors are built first — the ordering guarantee ensures\n // transitions they trigger take effect before per-state effects re-evaluate\n // in the same flush. See the comment on effect registration order in the\n // previous implementation for full details.\n const descriptors: EffectDescriptor[] = [\n ...toArray(def.monitor).map((fn) => ({\n fn: () => {\n const target = fn();\n if (target !== (getState() as State)) transition(target as FullState);\n },\n shouldSkip: isTerminal,\n })),\n ...(Object.entries(def.states) as Array<[State, ReactorStateDefinition]>).flatMap(([state, stateDef]) => {\n const isNotState = (snapshot: { value: FullState }) => snapshot.value !== state;\n return [\n ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })),\n ...toArray(stateDef.effects).map((fn) => ({ fn, shouldSkip: isNotState })),\n ];\n }),\n ];\n\n const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }: EffectDescriptor) =>\n effect(() => {\n const snapshot = snapshotSignal.get();\n if (shouldSkip(snapshot)) return;\n const baseCall = () => fn();\n return wrapResult(toFnCall(baseCall)());\n });\n\n effectDisposals.push(...descriptors.map(toEffect));\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n destroy(): void {\n const state = getState();\n if (state === 'destroying' || state === 'destroyed') return;\n // Two-step teardown: transition through 'destroying' first to leave room\n // for async teardown in a future extension, then immediately 'destroyed'\n // for the synchronous base case. Active effect cleanups fire via disposal.\n transition('destroying');\n transition('destroyed');\n for (const dispose of effectDisposals) dispose();\n },\n };\n}\n"],"mappings":";;;;AA+EA,MAAM,WAAc,MAAiC,MAAM,KAAA,IAAY,EAAE,GAAG,MAAM,QAAQ,EAAE,GAAG,IAAI,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCtG,SAAgB,qBACd,KAC6C;CAG7C,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAAyD,EACxG,OAAO,IAAI,SACZ,CAAC;CAEF,MAAM,kBAAqC,EAAE;CAE7C,MAAM,cAAc,WAAwC;AAC1D,MAAI,CAAC,OAAQ,QAAO,KAAA;AACpB,MAAI,OAAO,WAAW,WAAY,QAAO;AACzC,eAAa,OAAO,OAAO;;CAW7B,MAAM,aAA2C,mBAAmB,QAAQ,SAAS;CAErF,MAAM,cAAc,aAClB,SAAS,UAAU,gBAAgB,SAAS,UAAU;CAMxD,MAAM,cAAkC,CACtC,GAAG,QAAQ,IAAI,QAAQ,CAAC,KAAK,QAAQ;EACnC,UAAU;GACR,MAAM,SAAS,IAAI;AACnB,OAAI,WAAY,UAAU,CAAY,YAAW,OAAoB;;EAEvE,YAAY;EACb,EAAE,EACH,GAAI,OAAO,QAAQ,IAAI,OAAO,CAA4C,SAAS,CAAC,OAAO,cAAc;EACvG,MAAM,cAAc,aAAmC,SAAS,UAAU;AAC1E,SAAO,CACL,GAAG,QAAQ,SAAS,MAAM,CAAC,KAAK,QAAQ;GAAE;GAAI,YAAY;GAAY,UAAU;GAAW,EAAE,EAC7F,GAAG,QAAQ,SAAS,QAAQ,CAAC,KAAK,QAAQ;GAAE;GAAI,YAAY;GAAY,EAAE,CAC3E;GACD,CACH;CAED,MAAM,YAAY,EAAE,IAAI,YAAY,YAAY,aAAa,eAC3D,aAAa;AAEX,MAAI,WADa,eAAe,KAAK,CACb,CAAE;EAC1B,MAAM,iBAAiB,IAAI;AAC3B,SAAO,WAAW,SAAS,SAAS,EAAE,CAAC;GACvC;AAEJ,iBAAgB,KAAK,GAAG,YAAY,IAAI,SAAS,CAAC;AAElD,QAAO;EACL,IAAI,WAAW;AACb,UAAO;;EAGT,UAAgB;GACd,MAAM,QAAQ,UAAU;AACxB,OAAI,UAAU,gBAAgB,UAAU,YAAa;AAIrD,cAAW,aAAa;AACxB,cAAW,YAAY;AACvB,QAAK,MAAM,WAAW,gBAAiB,UAAS;;EAEnD"}
@@ -0,0 +1,46 @@
1
+ import { untrack, update } from "./signals/primitives.js";
2
+ import { createMachineCore } from "./machine.js";
3
+ //#region src/core/create-transition-actor.ts
4
+ /**
5
+ * Creates a reducer-shaped actor from an initial context and a reducer function.
6
+ *
7
+ * The reducer receives the current context and a message and returns the next
8
+ * context. Returning the same reference (by identity) skips the signal update —
9
+ * so early-returning `context` unchanged is both the no-op and the optimization.
10
+ *
11
+ * Side effects (e.g. DOM mutations) may be performed inside the reducer.
12
+ * They run synchronously before the signal is updated.
13
+ *
14
+ * @example
15
+ * const actor = createTransitionActor(
16
+ * { count: 0 },
17
+ * (context, message: { type: 'increment' }) => ({ count: context.count + 1 })
18
+ * );
19
+ */
20
+ function createTransitionActor(initialContext, reducer) {
21
+ const { snapshotSignal, getState, transition } = createMachineCore({
22
+ value: "active",
23
+ context: initialContext
24
+ });
25
+ const getContext = () => untrack(() => snapshotSignal.get().context);
26
+ const setContext = (context) => update(snapshotSignal, { context });
27
+ return {
28
+ get snapshot() {
29
+ return snapshotSignal;
30
+ },
31
+ send(message) {
32
+ if (getState() === "destroyed") return;
33
+ const context = getContext();
34
+ const newContext = reducer(context, message);
35
+ if (newContext !== context) setContext(newContext);
36
+ },
37
+ destroy() {
38
+ if (getState() === "destroyed") return;
39
+ transition("destroyed");
40
+ }
41
+ };
42
+ }
43
+ //#endregion
44
+ export { createTransitionActor };
45
+
46
+ //# sourceMappingURL=create-transition-actor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-transition-actor.js","names":[],"sources":["../../../src/core/create-transition-actor.ts"],"sourcesContent":["import type { ActorSnapshot } from './actor';\nimport type { Machine } from './machine';\nimport { createMachineCore } from './machine';\nimport { untrack, update } from './signals/primitives';\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;EAAgB,CAAC;CAE/C,MAAM,mBAA4B,cAAc,eAAe,KAAK,CAAC,QAAQ;CAC7E,MAAM,cAAc,YAA2B,OAAO,gBAAgB,EAAE,SAAS,CAAC;AAElF,QAAO;EACL,IAAI,WAAW;AACb,UAAO;;EAGT,KAAK,SAAwB;AAC3B,OAAI,UAAU,KAAK,YAAa;GAChC,MAAM,UAAU,YAAY;GAC5B,MAAM,aAAa,QAAQ,SAAS,QAAQ;AAC5C,OAAI,eAAe,QAAS,YAAW,WAAW;;EAGpD,UAAgB;AACd,OAAI,UAAU,KAAK,YAAa;AAChC,cAAW,YAAY;;EAE1B"}
@@ -1,18 +1,9 @@
1
- import { effect } from "../signals/effect.js";
2
1
  import { computed, update } from "../signals/primitives.js";
3
2
  import { fetchResolvable, getResponseText } from "../../dom/network/fetch.js";
3
+ import { createMachineReactor } from "../create-machine-reactor.js";
4
4
  import { parseMultivariantPlaylist } from "../hls/parse-multivariant.js";
5
5
  //#region src/core/features/resolve-presentation.ts
6
6
  /**
7
- * Type guard to check if presentation is unresolved.
8
- */
9
- function isUnresolved(presentation) {
10
- return presentation !== void 0 && "url" in presentation && !("id" in presentation);
11
- }
12
- function canResolve(state) {
13
- return isUnresolved(state.presentation);
14
- }
15
- /**
16
7
  * Determines if resolution conditions are met based on preload policy and playback state.
17
8
  *
18
9
  * Resolution conditions:
@@ -27,49 +18,54 @@ function shouldResolve(state) {
27
18
  return ["auto", "metadata"].includes(preload) || !!playbackInitiated;
28
19
  }
29
20
  /**
21
+ * Derives the correct state from current state conditions.
22
+ *
23
+ * States are mutually exclusive and exhaustive:
24
+ * - `'preconditions-unmet'`: no presentation, or presentation has no URL
25
+ * - `'idle'`: URL present, unresolved (no id), shouldResolve not met
26
+ * - `'resolving'`: URL present, unresolved (no id), shouldResolve met
27
+ * - `'resolved'`: URL present, resolved (has id)
28
+ */
29
+ function deriveState(state) {
30
+ const { presentation } = state;
31
+ if (!presentation || !("url" in presentation)) return "preconditions-unmet";
32
+ if ("id" in presentation) return "resolved";
33
+ return shouldResolve(state) ? "resolving" : "idle";
34
+ }
35
+ /**
30
36
  * Resolves unresolved presentations using reactive composition.
31
37
  *
32
- * Triggers resolution when:
33
- * - State-driven: Unresolved presentation + preload allows (auto/metadata)
34
- * - Playback-driven: playbackInitiated is true
38
+ * FSM driven by `deriveState` — a single `always` monitor keeps the state in
39
+ * sync with conditions at all times. `'resolving'` additionally runs the fetch
40
+ * task and returns an AbortController so the framework aborts it on state exit.
35
41
  *
36
42
  * @example
37
- * ```ts
38
- * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false });
39
- *
40
- * const cleanup = resolvePresentation({ state });
41
- *
42
- * // State-driven: resolves immediately when preload allows
43
- * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } });
44
- *
45
- * // Playback-driven: resolves when playbackInitiated is set
46
- * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true });
47
- * ```
43
+ * const reactor = resolvePresentation({ state });
44
+ * // later:
45
+ * reactor.destroy();
48
46
  */
49
47
  function resolvePresentation({ state }) {
50
- const canResolveSignal = computed(() => canResolve(state.get()));
51
- const shouldResolveSignal = computed(() => shouldResolve(state.get()));
52
- let resolving = false;
53
- let abortController = null;
54
- const cleanupEffect = effect(() => {
55
- if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return;
56
- const presentation = state.get().presentation;
57
- resolving = true;
58
- abortController = new AbortController();
59
- fetchResolvable(presentation, { signal: abortController.signal }).then((response) => getResponseText(response)).then((text) => {
60
- update(state, { presentation: parseMultivariantPlaylist(text, presentation) });
61
- }).catch((error) => {
62
- if (error instanceof Error && error.name === "AbortError") return;
63
- throw error;
64
- }).finally(() => {
65
- resolving = false;
66
- abortController = null;
67
- });
48
+ const derivedStateSignal = computed(() => deriveState(state.get()));
49
+ return createMachineReactor({
50
+ initial: "preconditions-unmet",
51
+ monitor: () => derivedStateSignal.get(),
52
+ states: {
53
+ "preconditions-unmet": {},
54
+ idle: {},
55
+ resolving: { entry: () => {
56
+ const presentation = state.get().presentation;
57
+ const ac = new AbortController();
58
+ fetchResolvable(presentation, { signal: ac.signal }).then((response) => getResponseText(response)).then((text) => {
59
+ update(state, { presentation: parseMultivariantPlaylist(text, presentation) });
60
+ }).catch((error) => {
61
+ if (error instanceof Error && error.name === "AbortError") return;
62
+ throw error;
63
+ });
64
+ return ac;
65
+ } },
66
+ resolved: {}
67
+ }
68
68
  });
69
- return () => {
70
- abortController?.abort();
71
- cleanupEffect();
72
- };
73
69
  }
74
70
  //#endregion
75
71
  export { resolvePresentation };
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-presentation.js","names":[],"sources":["../../../../src/core/features/resolve-presentation.ts"],"sourcesContent":["import { fetchResolvable, getResponseText } from '../../dom/network/fetch';\nimport { parseMultivariantPlaylist } from '../hls/parse-multivariant';\nimport { effect } from '../signals/effect';\nimport { computed, type Signal, update } from '../signals/primitives';\nimport type { AddressableObject, Presentation } from '../types';\n\n/**\n * Unresolved presentation - has a URL but no data yet.\n * Identical to AddressableObject per user requirement.\n */\nexport type UnresolvedPresentation = AddressableObject;\n\n/**\n * State shape for presentation resolution.\n */\nexport interface PresentationState {\n presentation?: UnresolvedPresentation | Presentation | undefined;\n preload?: 'auto' | 'metadata' | 'none' | undefined;\n /** True once the user has initiated playback — enables resolution regardless of preload. */\n playbackInitiated?: boolean;\n}\n\n/**\n * Type guard to check if presentation is unresolved.\n */\nexport function isUnresolved(\n presentation: UnresolvedPresentation | Presentation | undefined\n): presentation is UnresolvedPresentation {\n return presentation !== undefined && 'url' in presentation && !('id' in presentation);\n}\n\nexport function canResolve(\n state: PresentationState\n): state is PresentationState & { presentation: UnresolvedPresentation } {\n return isUnresolved(state.presentation);\n}\n\n/**\n * Determines if resolution conditions are met based on preload policy and playback state.\n *\n * Resolution conditions:\n * - State-driven: preload is 'auto' or 'metadata'\n * - Playback-driven: playbackInitiated is true\n *\n * @param state - Current presentation state\n * @returns true if resolution conditions are met\n */\nexport function shouldResolve(state: PresentationState): boolean {\n const { preload, playbackInitiated } = state;\n return (\n // State-driven: preload allows (auto/metadata)\n ['auto', 'metadata'].includes(preload as any) ||\n // Playback-driven: user has initiated playback\n !!playbackInitiated\n );\n}\n\n/**\n * Resolves unresolved presentations using reactive composition.\n *\n * Triggers resolution when:\n * - State-driven: Unresolved presentation + preload allows (auto/metadata)\n * - Playback-driven: playbackInitiated is true\n *\n * @example\n * ```ts\n * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false });\n *\n * const cleanup = resolvePresentation({ state });\n *\n * // State-driven: resolves immediately when preload allows\n * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } });\n *\n * // Playback-driven: resolves when playbackInitiated is set\n * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true });\n * ```\n */\nexport function resolvePresentation<S extends PresentationState>({ state }: { state: Signal<S> }): () => void {\n const canResolveSignal = computed(() => canResolve(state.get()));\n const shouldResolveSignal = computed(() => shouldResolve(state.get()));\n\n let resolving = false;\n let abortController: AbortController | null = null;\n\n const cleanupEffect = effect(() => {\n if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return;\n\n const presentation = state.get().presentation as UnresolvedPresentation;\n resolving = true;\n abortController = new AbortController();\n\n fetchResolvable(presentation, { signal: abortController.signal })\n .then((response) => getResponseText(response))\n .then((text) => {\n const parsed = parseMultivariantPlaylist(text, presentation);\n const patch: Partial<PresentationState> = { presentation: parsed };\n update(state, patch);\n })\n .catch((error) => {\n if (error instanceof Error && error.name === 'AbortError') return;\n throw error;\n })\n .finally(() => {\n resolving = false;\n abortController = null;\n });\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;AAyBA,SAAgB,aACd,cACwC;AACxC,QAAO,iBAAiB,KAAA,KAAa,SAAS,gBAAgB,EAAE,QAAQ;;AAG1E,SAAgB,WACd,OACuE;AACvE,QAAO,aAAa,MAAM,aAAa;;;;;;;;;;;;AAazC,SAAgB,cAAc,OAAmC;CAC/D,MAAM,EAAE,SAAS,sBAAsB;AACvC,QAEE,CAAC,QAAQ,WAAW,CAAC,SAAS,QAAe,IAE7C,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;AAwBN,SAAgB,oBAAiD,EAAE,SAA2C;CAC5G,MAAM,mBAAmB,eAAe,WAAW,MAAM,KAAK,CAAC,CAAC;CAChE,MAAM,sBAAsB,eAAe,cAAc,MAAM,KAAK,CAAC,CAAC;CAEtE,IAAI,YAAY;CAChB,IAAI,kBAA0C;CAE9C,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,iBAAiB,KAAK,IAAI,CAAC,oBAAoB,KAAK,IAAI,UAAW;EAExE,MAAM,eAAe,MAAM,KAAK,CAAC;AACjC,cAAY;AACZ,oBAAkB,IAAI,iBAAiB;AAEvC,kBAAgB,cAAc,EAAE,QAAQ,gBAAgB,QAAQ,CAAC,CAC9D,MAAM,aAAa,gBAAgB,SAAS,CAAC,CAC7C,MAAM,SAAS;AAGd,UAAO,OADmC,EAAE,cAD7B,0BAA0B,MAAM,aAAa,EACM,CAC9C;IACpB,CACD,OAAO,UAAU;AAChB,OAAI,iBAAiB,SAAS,MAAM,SAAS,aAAc;AAC3D,SAAM;IACN,CACD,cAAc;AACb,eAAY;AACZ,qBAAkB;IAClB;GACJ;AAEF,cAAa;AACX,mBAAiB,OAAO;AACxB,iBAAe"}
1
+ {"version":3,"file":"resolve-presentation.js","names":[],"sources":["../../../../src/core/features/resolve-presentation.ts"],"sourcesContent":["import { fetchResolvable, getResponseText } from '../../dom/network/fetch';\nimport type { Reactor } from '../create-machine-reactor';\nimport { createMachineReactor } from '../create-machine-reactor';\nimport { parseMultivariantPlaylist } from '../hls/parse-multivariant';\nimport { computed, type Signal, update } from '../signals/primitives';\nimport type { AddressableObject, Presentation } from '../types';\n\n/**\n * Unresolved presentation - has a URL but no data yet.\n * Identical to AddressableObject per user requirement.\n */\nexport type UnresolvedPresentation = AddressableObject;\n\n/**\n * State shape for presentation resolution.\n */\nexport interface PresentationState {\n presentation?: UnresolvedPresentation | Presentation | undefined;\n preload?: 'auto' | 'metadata' | 'none' | undefined;\n /** True once the user has initiated playback — enables resolution regardless of preload. */\n playbackInitiated?: boolean;\n}\n\n/**\n * Type guard to check if presentation is unresolved.\n */\nexport function isUnresolved(\n presentation: UnresolvedPresentation | Presentation | undefined\n): presentation is UnresolvedPresentation {\n return presentation !== undefined && 'url' in presentation && !('id' in presentation);\n}\n\nexport function canResolve(\n state: PresentationState\n): state is PresentationState & { presentation: UnresolvedPresentation } {\n return isUnresolved(state.presentation);\n}\n\n/**\n * Determines if resolution conditions are met based on preload policy and playback state.\n *\n * Resolution conditions:\n * - State-driven: preload is 'auto' or 'metadata'\n * - Playback-driven: playbackInitiated is true\n *\n * @param state - Current presentation state\n * @returns true if resolution conditions are met\n */\nexport function shouldResolve(state: PresentationState): boolean {\n const { preload, playbackInitiated } = state;\n return (\n // State-driven: preload allows (auto/metadata)\n ['auto', 'metadata'].includes(preload as any) ||\n // Playback-driven: user has initiated playback\n !!playbackInitiated\n );\n}\n\nexport type ResolvePresentationState = 'preconditions-unmet' | 'idle' | 'resolving' | 'resolved';\n\n/**\n * Derives the correct state from current state conditions.\n *\n * States are mutually exclusive and exhaustive:\n * - `'preconditions-unmet'`: no presentation, or presentation has no URL\n * - `'idle'`: URL present, unresolved (no id), shouldResolve not met\n * - `'resolving'`: URL present, unresolved (no id), shouldResolve met\n * - `'resolved'`: URL present, resolved (has id)\n */\nfunction deriveState(state: PresentationState): ResolvePresentationState {\n const { presentation } = state;\n if (!presentation || !('url' in presentation)) return 'preconditions-unmet';\n if ('id' in presentation) return 'resolved';\n return shouldResolve(state) ? 'resolving' : 'idle';\n}\n\n/**\n * Resolves unresolved presentations using reactive composition.\n *\n * FSM driven by `deriveState` a single `always` monitor keeps the state in\n * sync with conditions at all times. `'resolving'` additionally runs the fetch\n * task and returns an AbortController so the framework aborts it on state exit.\n *\n * @example\n * const reactor = resolvePresentation({ state });\n * // later:\n * reactor.destroy();\n */\nexport function resolvePresentation<S extends PresentationState>({\n state,\n}: {\n state: Signal<S>;\n}): Reactor<ResolvePresentationState | 'destroying' | 'destroyed'> {\n const derivedStateSignal = computed(() => deriveState(state.get()));\n\n return createMachineReactor<ResolvePresentationState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n idle: {},\n resolving: {\n // Entry: start fetch on state entry; return AbortController so the\n // framework aborts the in-flight request on state exit.\n entry: () => {\n const presentation = state.get().presentation as UnresolvedPresentation;\n const ac = new AbortController();\n\n fetchResolvable(presentation, { signal: ac.signal })\n .then((response) => getResponseText(response))\n .then((text) => {\n const parsed = parseMultivariantPlaylist(text, presentation);\n update(state, { presentation: parsed } as Partial<S>);\n })\n .catch((error) => {\n if (error instanceof Error && error.name === 'AbortError') return;\n throw error;\n });\n\n return ac;\n },\n },\n resolved: {},\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgDA,SAAgB,cAAc,OAAmC;CAC/D,MAAM,EAAE,SAAS,sBAAsB;AACvC,QAEE,CAAC,QAAQ,WAAW,CAAC,SAAS,QAAe,IAE7C,CAAC,CAAC;;;;;;;;;;;AAeN,SAAS,YAAY,OAAoD;CACvE,MAAM,EAAE,iBAAiB;AACzB,KAAI,CAAC,gBAAgB,EAAE,SAAS,cAAe,QAAO;AACtD,KAAI,QAAQ,aAAc,QAAO;AACjC,QAAO,cAAc,MAAM,GAAG,cAAc;;;;;;;;;;;;;;AAe9C,SAAgB,oBAAiD,EAC/D,SAGiE;CACjE,MAAM,qBAAqB,eAAe,YAAY,MAAM,KAAK,CAAC,CAAC;AAEnE,QAAO,qBAA+C;EACpD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GACzB,MAAM,EAAE;GACR,WAAW,EAGT,aAAa;IACX,MAAM,eAAe,MAAM,KAAK,CAAC;IACjC,MAAM,KAAK,IAAI,iBAAiB;AAEhC,oBAAgB,cAAc,EAAE,QAAQ,GAAG,QAAQ,CAAC,CACjD,MAAM,aAAa,gBAAgB,SAAS,CAAC,CAC7C,MAAM,SAAS;AAEd,YAAO,OAAO,EAAE,cADD,0BAA0B,MAAM,aAAa,EACtB,CAAe;MACrD,CACD,OAAO,UAAU;AAChB,SAAI,iBAAiB,SAAS,MAAM,SAAS,aAAc;AAC3D,WAAM;MACN;AAEJ,WAAO;MAEV;GACD,UAAU,EAAE;GACb;EACF,CAAC"}
@@ -2,8 +2,8 @@ import { effect } from "../signals/effect.js";
2
2
  import { isResolvedTrack } from "../types/index.js";
3
3
  import { getSelectedTrack } from "../utils/track-selection.js";
4
4
  import { fetchResolvable, getResponseText } from "../../dom/network/fetch.js";
5
- import { parseMediaPlaylist } from "../hls/parse-media-playlist.js";
6
5
  import { ConcurrentRunner, Task } from "../task.js";
6
+ import { parseMediaPlaylist } from "../hls/parse-media-playlist.js";
7
7
  //#region src/core/features/resolve-track.ts
8
8
  function canResolve(state, config) {
9
9
  const track = getSelectedTrack(state, config.type);
@@ -0,0 +1,26 @@
1
+ import { signal, untrack, update } from "./signals/primitives.js";
2
+ //#region src/core/machine.ts
3
+ /**
4
+ * Provisions the shared mechanics for all machine-like primitives: a snapshot
5
+ * signal, an untracked state reader, and a transition function.
6
+ *
7
+ * Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the
8
+ * public API.
9
+ */
10
+ function createMachineCore(initialSnapshot) {
11
+ const snapshotSignal = signal(initialSnapshot);
12
+ const getState = () => untrack(() => snapshotSignal.get().value);
13
+ const transition = (to) => update(snapshotSignal, (current) => ({
14
+ ...current,
15
+ value: to
16
+ }));
17
+ return {
18
+ snapshotSignal,
19
+ getState,
20
+ transition
21
+ };
22
+ }
23
+ //#endregion
24
+ export { createMachineCore };
25
+
26
+ //# sourceMappingURL=machine.js.map
@@ -0,0 +1 @@
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,gBAAgB;CAC9C,MAAM,iBAA4B,cAAc,eAAe,KAAK,CAAC,MAAM;CAC3E,MAAM,cAAc,OAAwB,OAAO,iBAAiB,aAAa;EAAE,GAAG;EAAS,OAAO;EAAI,EAAE;AAC5G,QAAO;EAAE;EAAgB;EAAU;EAAY"}
@@ -27,6 +27,6 @@ function update(signal, updater) {
27
27
  });
28
28
  }
29
29
  //#endregion
30
- export { computed, signal, update };
30
+ export { computed, signal, untrack, update };
31
31
 
32
32
  //# sourceMappingURL=primitives.js.map
@@ -62,18 +62,56 @@ var Task = class {
62
62
  */
63
63
  var ConcurrentRunner = class {
64
64
  #pending = /* @__PURE__ */ new Map();
65
+ #settled = Promise.resolve();
66
+ #resolveSettled = null;
67
+ #destroyed = false;
65
68
  schedule(task) {
66
- if (this.#pending.has(task.id)) return;
67
- this.#pending.set(task.id, task);
68
- task.run().catch((error) => {
69
- if (!(error instanceof Error && error.name === "AbortError")) throw error;
70
- }).finally(() => {
69
+ if (this.#destroyed) return Promise.resolve();
70
+ const existing = this.#pending.get(task.id);
71
+ if (existing) return existing.promise;
72
+ if (this.#pending.size === 0) this.#settled = new Promise((resolve) => {
73
+ this.#resolveSettled = resolve;
74
+ });
75
+ const promise = task.run();
76
+ promise.catch(() => {});
77
+ const cleanup = () => {
71
78
  this.#pending.delete(task.id);
79
+ if (this.#pending.size === 0) {
80
+ this.#resolveSettled?.();
81
+ this.#resolveSettled = null;
82
+ }
83
+ };
84
+ promise.then(cleanup, cleanup);
85
+ this.#pending.set(task.id, {
86
+ task,
87
+ promise
72
88
  });
89
+ return promise;
90
+ }
91
+ /**
92
+ * Registers a callback to fire when all currently in-flight tasks settle.
93
+ * If the runner is already idle, the callback is never called. If abortAll()
94
+ * is called before the batch settles, the callback is superseded and silently
95
+ * dropped — no stale callbacks, no generation token required by the caller.
96
+ */
97
+ whenSettled(callback) {
98
+ if (this.#pending.size === 0) return;
99
+ const captured = this.#settled;
100
+ captured.then(() => {
101
+ if (this.#settled !== captured) return;
102
+ callback();
103
+ }, () => {});
73
104
  }
74
105
  abortAll() {
75
- for (const task of this.#pending.values()) task.abort();
106
+ for (const { task } of this.#pending.values()) task.abort();
76
107
  this.#pending.clear();
108
+ this.#resolveSettled?.();
109
+ this.#resolveSettled = null;
110
+ this.#settled = Promise.resolve();
111
+ }
112
+ destroy() {
113
+ this.#destroyed = true;
114
+ this.abortAll();
77
115
  }
78
116
  };
79
117
  /**
@@ -94,7 +132,9 @@ var SerialRunner = class {
94
132
  #chain = Promise.resolve();
95
133
  #pending = /* @__PURE__ */ new Set();
96
134
  #current = null;
135
+ #destroyed = false;
97
136
  schedule(task) {
137
+ if (this.#destroyed) return Promise.resolve();
98
138
  const t = task;
99
139
  this.#pending.add(t);
100
140
  const result = this.#chain.then(() => {
@@ -107,12 +147,41 @@ var SerialRunner = class {
107
147
  this.#chain = result.then(() => {}, () => {});
108
148
  return result;
109
149
  }
110
- abortAll() {
150
+ /**
151
+ * A promise that resolves when all currently-scheduled tasks have settled.
152
+ * Use the reference as a generation token: capture it after scheduling a
153
+ * batch, then check identity in the resolution callback to detect whether
154
+ * a subsequent abortAll() + new batch has superseded this one.
155
+ */
156
+ get settled() {
157
+ return this.#chain;
158
+ }
159
+ /**
160
+ * Registers a callback to fire when all currently-pending tasks settle.
161
+ * If the runner is already idle (no pending or running tasks), the callback
162
+ * is never called. If new tasks are scheduled before the current batch
163
+ * settles, the callback is superseded and silently dropped — no stale
164
+ * callbacks, no generation token required by the caller.
165
+ */
166
+ whenSettled(callback) {
167
+ if (this.#pending.size === 0 && this.#current === null) return;
168
+ const currentChain = this.#chain;
169
+ currentChain.then(() => {
170
+ if (this.#chain !== currentChain) return;
171
+ callback();
172
+ }, () => {});
173
+ }
174
+ /** Aborts and clears queued tasks without touching the in-flight task. */
175
+ abortPending() {
111
176
  for (const task of this.#pending) task.abort();
112
177
  this.#pending.clear();
178
+ }
179
+ abortAll() {
180
+ this.abortPending();
113
181
  this.#current?.abort();
114
182
  }
115
183
  destroy() {
184
+ this.#destroyed = true;
116
185
  this.abortAll();
117
186
  }
118
187
  };