@nimbus-sh/fabric 0.1.0 → 0.3.0

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 (104) hide show
  1. package/README.md +208 -293
  2. package/dist/bindings.js +5 -5
  3. package/dist/budgets.d.ts +132 -0
  4. package/dist/budgets.d.ts.map +1 -0
  5. package/dist/budgets.js +248 -0
  6. package/dist/composition.d.ts +3 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +2 -0
  9. package/dist/connections.d.ts +81 -0
  10. package/dist/connections.d.ts.map +1 -0
  11. package/dist/connections.js +114 -0
  12. package/dist/derived.d.ts +65 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +95 -0
  15. package/dist/do-calls.d.ts +94 -0
  16. package/dist/do-calls.d.ts.map +1 -0
  17. package/dist/do-calls.js +111 -0
  18. package/dist/facet-pool.d.ts +90 -0
  19. package/dist/facet-pool.d.ts.map +1 -0
  20. package/dist/facet-pool.js +113 -0
  21. package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
  22. package/dist/fanout.d.ts.map +1 -0
  23. package/dist/{fanout-pool.js → fanout.js} +20 -20
  24. package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/fenced-work.js +241 -0
  27. package/dist/generation.d.ts +69 -0
  28. package/dist/generation.d.ts.map +1 -0
  29. package/dist/generation.js +118 -0
  30. package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
  31. package/dist/image-store.d.ts.map +1 -0
  32. package/dist/{facet-image-store.js → image-store.js} +4 -4
  33. package/dist/index.d.ts +16 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -8
  36. package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
  37. package/dist/isolate-pool.d.ts.map +1 -0
  38. package/dist/{loader-pool.js → isolate-pool.js} +20 -20
  39. package/dist/journal.d.ts +111 -0
  40. package/dist/journal.d.ts.map +1 -0
  41. package/dist/journal.js +177 -0
  42. package/dist/outbox.d.ts +249 -0
  43. package/dist/outbox.d.ts.map +1 -0
  44. package/dist/outbox.js +355 -0
  45. package/dist/process-fabric.d.ts +33 -15
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +25 -15
  48. package/dist/process-host.d.ts +1 -1
  49. package/dist/process-host.d.ts.map +1 -1
  50. package/dist/process-host.js +19 -11
  51. package/dist/sealed.d.ts +78 -0
  52. package/dist/sealed.d.ts.map +1 -0
  53. package/dist/sealed.js +145 -0
  54. package/dist/timers.d.ts +138 -0
  55. package/dist/timers.d.ts.map +1 -0
  56. package/dist/timers.js +231 -0
  57. package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +24 -12
  60. package/dist/workerd-facet-host.d.ts +67 -70
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +129 -181
  63. package/examples/agent-core-adapter.ts +191 -0
  64. package/package.json +4 -2
  65. package/src/bindings.ts +6 -6
  66. package/src/budgets.ts +308 -0
  67. package/src/composition.ts +16 -0
  68. package/src/connections.ts +140 -0
  69. package/src/derived.ts +135 -0
  70. package/src/do-calls.ts +156 -0
  71. package/src/facet-pool.ts +157 -0
  72. package/src/{fanout-pool.ts → fanout.ts} +35 -35
  73. package/src/{launch-journal.ts → fenced-work.ts} +129 -42
  74. package/src/generation.ts +144 -0
  75. package/src/{facet-image-store.ts → image-store.ts} +9 -9
  76. package/src/index.ts +16 -8
  77. package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
  78. package/src/journal.ts +242 -0
  79. package/src/node-async-hooks.d.ts +14 -0
  80. package/src/outbox.ts +520 -0
  81. package/src/process-fabric.ts +43 -34
  82. package/src/process-host.ts +22 -20
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
  86. package/src/workerd-facet-host.ts +159 -208
  87. package/dist/alarms.d.ts +0 -134
  88. package/dist/alarms.d.ts.map +0 -1
  89. package/dist/alarms.js +0 -214
  90. package/dist/ctx-exports.d.ts +0 -47
  91. package/dist/ctx-exports.d.ts.map +0 -1
  92. package/dist/ctx-exports.js +0 -54
  93. package/dist/facet-image-store.d.ts.map +0 -1
  94. package/dist/fanout-pool.d.ts.map +0 -1
  95. package/dist/launch-journal.d.ts.map +0 -1
  96. package/dist/launch-journal.js +0 -154
  97. package/dist/launch-pacer.d.ts.map +0 -1
  98. package/dist/loader-ledger.d.ts +0 -57
  99. package/dist/loader-ledger.d.ts.map +0 -1
  100. package/dist/loader-ledger.js +0 -91
  101. package/dist/loader-pool.d.ts.map +0 -1
  102. package/src/alarms.ts +0 -275
  103. package/src/ctx-exports.ts +0 -77
  104. package/src/loader-ledger.ts +0 -112
@@ -0,0 +1,78 @@
1
+ /**
2
+ * sealed.ts — prototype-chain RPC sealing, and the versioned surface
3
+ * constants that make it safe to run over the Agents SDK.
4
+ *
5
+ * Cloudflare resolves `stub.foo(...)` on the receiver's PROTOTYPE CHAIN.
6
+ * Proteus verified the three consequences against real workerd
7
+ * (`cf-backend/src/rpc-surface.ts:4-22`, catalog `rpc.prototype_chain`,
8
+ * proven-by-probe): TypeScript `private` is erased, so private methods ARE
9
+ * callable over RPC; superclass methods are reachable too — inherited
10
+ * `Agent.sql` hands any stub-holder arbitrary SQL against the receiver's
11
+ * storage, and `Agent` + `Server` alone contribute hundreds of reachable
12
+ * names; and OWN instance properties are NOT reachable — workerd rejects
13
+ * them exactly as it rejects a missing name, including when the own
14
+ * property shadows a prototype method.
15
+ *
16
+ * That third consequence is the primitive: {@link sealRpcSurface} copies
17
+ * every reachable member that is NOT on the declared surface down onto the
18
+ * instance as a non-enumerable own property. In-process behaviour is
19
+ * unchanged — `this.x(...)` finds the same function object, `super.x()`
20
+ * still reaches the prototype, accessors stay accessors. From outside, the
21
+ * name has ceased to exist. Call it as the LAST statement of the
22
+ * constructor, after every base class installed its wrappers.
23
+ *
24
+ * The surface constants are the part that breaks whenever the SDK moves,
25
+ * which is why fabric owns them: Proteus reverse-engineered its facet
26
+ * surface from `agents/dist` by hand, and a fabric test diffs these
27
+ * constants against the INSTALLED packages so drift is caught by CI, not by
28
+ * a leak. Verified against agents@0.20.1 and partyserver@0.5.10
29
+ * (2026-08-19); an SDK upgrade that changes the cross-stub set fails that
30
+ * test and demands re-derivation, which is the intended failure.
31
+ */
32
+ /**
33
+ * Every name RPC can resolve on `target`: own property names of every
34
+ * prototype up to (and excluding) Object.prototype, minus `constructor` and
35
+ * minus anything `target` already carries as an own property — own
36
+ * properties are not RPC-reachable, so they need no seal. The single
37
+ * definition {@link sealRpcSurface} and its tests both work from.
38
+ */
39
+ export declare function rpcReachableNames(target: object): string[];
40
+ /**
41
+ * Shadow every RPC-reachable member not on `surface` with a non-enumerable
42
+ * own property carrying the SAME descriptor — the same function object, the
43
+ * same accessor pair — so in-process behaviour cannot change while the name
44
+ * stops resolving over RPC. Surface names the class lacks are ignored: a
45
+ * surface is a ceiling, and the runtime already denies what does not exist.
46
+ */
47
+ export declare function sealRpcSurface<Instance extends object>(instance: Instance, surface: readonly string[]): void;
48
+ /**
49
+ * The platform half of any surface built on partyserver's `Server` (and
50
+ * therefore the Agents SDK's `Agent`): what the RUNTIME and the SDK's own
51
+ * routing must still reach after sealing. From the consumer's verified list
52
+ * (`rpc-surface.ts:63-86`): `setName` is called by `getServerByName` before
53
+ * the stub is returned; `_initAndFetch` is `setName` plus `fetch`, so it
54
+ * exposes nothing new; the WebSocket handlers' arguments cannot cross an
55
+ * RPC boundary anyway.
56
+ */
57
+ export declare const PLATFORM_RPC_SURFACE: readonly ["fetch", "setName", "_initAndFetch", "alarm", "webSocketMessage", "webSocketClose", "webSocketError"];
58
+ /**
59
+ * The Agents SDK's own cross-stub facet protocol at agents@0.20.1: every
60
+ * `_cf_` method the SDK calls on a receiver that is not `this`, derived
61
+ * from `agents/dist` (the SDK's ACTUAL cross-stub surface, not a prefix
62
+ * rule — Agent's prototype defines 58 `_cf_` methods and only these are
63
+ * cross-called). An Agent that hosts SDK facets must keep these; one that
64
+ * does not (Proteus's UserDO) should not carry them at all.
65
+ */
66
+ export declare const AGENTS_FACET_RPC_SURFACE: readonly ["_cf_acquireFacetKeepAlive", "_cf_broadcastToSubAgent", "_cf_cancelScheduleForFacet", "_cf_checkRunFibersForFacet", "_cf_cleanupFacetPrefix", "_cf_closeSubAgentConnection", "_cf_destroyDescendantFacet", "_cf_dispatchScheduledCallback", "_cf_getScheduleForFacet", "_cf_handleSubAgentWebSocketClose", "_cf_handleSubAgentWebSocketConnect", "_cf_handleSubAgentWebSocketMessage", "_cf_initAsFacet", "_cf_listSchedulesForFacet", "_cf_registerFacetRun", "_cf_releaseFacetKeepAlive", "_cf_scheduleEveryForFacet", "_cf_scheduleForFacet", "_cf_sendToSubAgentConnection", "_cf_setSubAgentConnectionState", "_cf_subAgentConnectionMetas", "_cf_unregisterFacetRun"];
67
+ /**
68
+ * Cross-stub `_cf_` members deliberately absent from EVERY surface: each
69
+ * takes a method NAME and calls it on the receiver, which would re-open
70
+ * everything sealing closes. Sealing them fail-closes the SDK features
71
+ * built on them (`parentAgent()` proxies, workflow-to-agent bridges) — the
72
+ * consumer accepts exactly that cost, and fail-closed is the right default
73
+ * for a bridge that dispatches arbitrary names. (`_cf_invokeStubMethod` is
74
+ * only ever self-called in the pinned dist, but it is prototype-defined, so
75
+ * it is named here and sealed.)
76
+ */
77
+ export declare const AGENTS_INVOKE_BRIDGES: readonly ["_cf_invokeAgentPath", "_cf_invokeStubMethod", "_cf_invokeSubAgent", "_cf_invokeSubAgentPath"];
78
+ //# sourceMappingURL=sealed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sealed.d.ts","sourceRoot":"","sources":["../src/sealed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAY1D;AAYD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,SAAS,MAAM,EACpD,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,IAAI,CAON;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,iHAQvB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,upBAuB3B,CAAC;AAEX;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,0GAKxB,CAAC"}
package/dist/sealed.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * sealed.ts — prototype-chain RPC sealing, and the versioned surface
3
+ * constants that make it safe to run over the Agents SDK.
4
+ *
5
+ * Cloudflare resolves `stub.foo(...)` on the receiver's PROTOTYPE CHAIN.
6
+ * Proteus verified the three consequences against real workerd
7
+ * (`cf-backend/src/rpc-surface.ts:4-22`, catalog `rpc.prototype_chain`,
8
+ * proven-by-probe): TypeScript `private` is erased, so private methods ARE
9
+ * callable over RPC; superclass methods are reachable too — inherited
10
+ * `Agent.sql` hands any stub-holder arbitrary SQL against the receiver's
11
+ * storage, and `Agent` + `Server` alone contribute hundreds of reachable
12
+ * names; and OWN instance properties are NOT reachable — workerd rejects
13
+ * them exactly as it rejects a missing name, including when the own
14
+ * property shadows a prototype method.
15
+ *
16
+ * That third consequence is the primitive: {@link sealRpcSurface} copies
17
+ * every reachable member that is NOT on the declared surface down onto the
18
+ * instance as a non-enumerable own property. In-process behaviour is
19
+ * unchanged — `this.x(...)` finds the same function object, `super.x()`
20
+ * still reaches the prototype, accessors stay accessors. From outside, the
21
+ * name has ceased to exist. Call it as the LAST statement of the
22
+ * constructor, after every base class installed its wrappers.
23
+ *
24
+ * The surface constants are the part that breaks whenever the SDK moves,
25
+ * which is why fabric owns them: Proteus reverse-engineered its facet
26
+ * surface from `agents/dist` by hand, and a fabric test diffs these
27
+ * constants against the INSTALLED packages so drift is caught by CI, not by
28
+ * a leak. Verified against agents@0.20.1 and partyserver@0.5.10
29
+ * (2026-08-19); an SDK upgrade that changes the cross-stub set fails that
30
+ * test and demands re-derivation, which is the intended failure.
31
+ */
32
+ /**
33
+ * Every name RPC can resolve on `target`: own property names of every
34
+ * prototype up to (and excluding) Object.prototype, minus `constructor` and
35
+ * minus anything `target` already carries as an own property — own
36
+ * properties are not RPC-reachable, so they need no seal. The single
37
+ * definition {@link sealRpcSurface} and its tests both work from.
38
+ */
39
+ export function rpcReachableNames(target) {
40
+ const names = new Set();
41
+ const own = new Set(Object.getOwnPropertyNames(target));
42
+ let proto = Object.getPrototypeOf(target);
43
+ while (proto !== null && proto !== Object.prototype) {
44
+ for (const name of Object.getOwnPropertyNames(proto)) {
45
+ if (name === 'constructor' || own.has(name))
46
+ continue;
47
+ names.add(name);
48
+ }
49
+ proto = Object.getPrototypeOf(proto);
50
+ }
51
+ return [...names].sort();
52
+ }
53
+ function inheritedDescriptor(target, name) {
54
+ let proto = Object.getPrototypeOf(target);
55
+ while (proto !== null && proto !== Object.prototype) {
56
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
57
+ if (descriptor)
58
+ return descriptor;
59
+ proto = Object.getPrototypeOf(proto);
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Shadow every RPC-reachable member not on `surface` with a non-enumerable
65
+ * own property carrying the SAME descriptor — the same function object, the
66
+ * same accessor pair — so in-process behaviour cannot change while the name
67
+ * stops resolving over RPC. Surface names the class lacks are ignored: a
68
+ * surface is a ceiling, and the runtime already denies what does not exist.
69
+ */
70
+ export function sealRpcSurface(instance, surface) {
71
+ const allowed = new Set(surface);
72
+ for (const name of rpcReachableNames(instance)) {
73
+ if (allowed.has(name))
74
+ continue;
75
+ const descriptor = inheritedDescriptor(instance, name);
76
+ if (descriptor)
77
+ Object.defineProperty(instance, name, { ...descriptor, enumerable: false });
78
+ }
79
+ }
80
+ /**
81
+ * The platform half of any surface built on partyserver's `Server` (and
82
+ * therefore the Agents SDK's `Agent`): what the RUNTIME and the SDK's own
83
+ * routing must still reach after sealing. From the consumer's verified list
84
+ * (`rpc-surface.ts:63-86`): `setName` is called by `getServerByName` before
85
+ * the stub is returned; `_initAndFetch` is `setName` plus `fetch`, so it
86
+ * exposes nothing new; the WebSocket handlers' arguments cannot cross an
87
+ * RPC boundary anyway.
88
+ */
89
+ export const PLATFORM_RPC_SURFACE = [
90
+ 'fetch',
91
+ 'setName',
92
+ '_initAndFetch',
93
+ 'alarm',
94
+ 'webSocketMessage',
95
+ 'webSocketClose',
96
+ 'webSocketError',
97
+ ];
98
+ /**
99
+ * The Agents SDK's own cross-stub facet protocol at agents@0.20.1: every
100
+ * `_cf_` method the SDK calls on a receiver that is not `this`, derived
101
+ * from `agents/dist` (the SDK's ACTUAL cross-stub surface, not a prefix
102
+ * rule — Agent's prototype defines 58 `_cf_` methods and only these are
103
+ * cross-called). An Agent that hosts SDK facets must keep these; one that
104
+ * does not (Proteus's UserDO) should not carry them at all.
105
+ */
106
+ export const AGENTS_FACET_RPC_SURFACE = [
107
+ '_cf_acquireFacetKeepAlive',
108
+ '_cf_broadcastToSubAgent',
109
+ '_cf_cancelScheduleForFacet',
110
+ '_cf_checkRunFibersForFacet',
111
+ '_cf_cleanupFacetPrefix',
112
+ '_cf_closeSubAgentConnection',
113
+ '_cf_destroyDescendantFacet',
114
+ '_cf_dispatchScheduledCallback',
115
+ '_cf_getScheduleForFacet',
116
+ '_cf_handleSubAgentWebSocketClose',
117
+ '_cf_handleSubAgentWebSocketConnect',
118
+ '_cf_handleSubAgentWebSocketMessage',
119
+ '_cf_initAsFacet',
120
+ '_cf_listSchedulesForFacet',
121
+ '_cf_registerFacetRun',
122
+ '_cf_releaseFacetKeepAlive',
123
+ '_cf_scheduleEveryForFacet',
124
+ '_cf_scheduleForFacet',
125
+ '_cf_sendToSubAgentConnection',
126
+ '_cf_setSubAgentConnectionState',
127
+ '_cf_subAgentConnectionMetas',
128
+ '_cf_unregisterFacetRun',
129
+ ];
130
+ /**
131
+ * Cross-stub `_cf_` members deliberately absent from EVERY surface: each
132
+ * takes a method NAME and calls it on the receiver, which would re-open
133
+ * everything sealing closes. Sealing them fail-closes the SDK features
134
+ * built on them (`parentAgent()` proxies, workflow-to-agent bridges) — the
135
+ * consumer accepts exactly that cost, and fail-closed is the right default
136
+ * for a bridge that dispatches arbitrary names. (`_cf_invokeStubMethod` is
137
+ * only ever self-called in the pinned dist, but it is prototype-defined, so
138
+ * it is named here and sealed.)
139
+ */
140
+ export const AGENTS_INVOKE_BRIDGES = [
141
+ '_cf_invokeAgentPath',
142
+ '_cf_invokeStubMethod',
143
+ '_cf_invokeSubAgent',
144
+ '_cf_invokeSubAgentPath',
145
+ ];
@@ -0,0 +1,138 @@
1
+ /**
2
+ * timers.ts — Durable Object alarm multiplexing, persisted across
3
+ * hibernation.
4
+ *
5
+ * A Durable Object has ONE alarm, and a second `setAlarm()` silently
6
+ * overwrites the first — so every alarm-driven subsystem coordinates through
7
+ * a single reason→deadline map and one dispatcher. Reasons are plain strings
8
+ * registered by the embedder: `timers(host, ctx).schedule` arms one, and
9
+ * `timers(host, ctx).dispatch` runs the embedder-supplied handler for every
10
+ * reason whose deadline has passed.
11
+ */
12
+ /**
13
+ * The storage the timer map lives in. `setAlarm` is optional because
14
+ * `wrangler dev` serves a storage without it, which is the whole reason
15
+ * scheduling degrades to a no-op instead of throwing.
16
+ */
17
+ export interface TimerStorage {
18
+ get(key: string): Promise<unknown>;
19
+ put(key: string, value: unknown): Promise<void>;
20
+ delete(key: string): Promise<boolean>;
21
+ setAlarm?(scheduledTime: number): Promise<void>;
22
+ }
23
+ /** The hosting actor's context, as the timer coordination reads it. */
24
+ export interface TimerContext {
25
+ storage: TimerStorage;
26
+ }
27
+ /**
28
+ * Multi-reason timer coordination map.
29
+ *
30
+ * JSON-serialised `Record<reason, deadlineMsEpoch>` where keys are the
31
+ * embedder's canonical reason strings (e.g. 'w9-flush', 'log-janitor'). The
32
+ * alarm() dispatcher reads this on fire, dispatches every reason whose
33
+ * deadline has passed, and re-arms `ctx.storage.setAlarm` at the earliest
34
+ * remaining deadline.
35
+ *
36
+ * Why a map (not a single nextAlarmAt + reason): two subsystems can have
37
+ * distinct deadlines. Without the map, the later setAlarm() call would
38
+ * overwrite the earlier reason silently, breaking whichever subsystem
39
+ * expected its deadline.
40
+ *
41
+ * Forward-compat: the dispatcher silently drops unknown reasons so a
42
+ * rollback from a future deploy that added new reasons doesn't leave the
43
+ * alarm stuck.
44
+ *
45
+ * The VALUE is live production DO storage ('w1_next_alarm_reasons', from the
46
+ * workstream that introduced it) and must never change — renaming a storage
47
+ * key is a migration, and orphaned rows are the least of what it breaks.
48
+ */
49
+ export declare const TIMER_REASONS_KEY = "w1_next_alarm_reasons";
50
+ /**
51
+ * The host instance carrying the per-instance timer chain. The field lives on
52
+ * the embedder's DO instance so one chain serializes every timer-map
53
+ * read-modify-write for that instance (see {@link Timers.schedule}).
54
+ */
55
+ export interface TimerHost {
56
+ _timerChain?: Promise<unknown>;
57
+ }
58
+ /**
59
+ * What one timer handler may return: nothing, or a deadline this reason
60
+ * re-arms itself at. Re-arming through the return value keeps the map's
61
+ * read-modify-write inside the dispatcher, where it is serialized.
62
+ */
63
+ export type TimerHandlerResult = void | {
64
+ rearmAt: number;
65
+ };
66
+ /**
67
+ * The platform's alarm-invocation report, forwarded to every handler: the
68
+ * platform retries a failed alarm() with backoff and abandons it after its
69
+ * retry budget, and `isRetry`/`retryCount` are the only way a handler can
70
+ * tell how close it is to that abandonment. Structurally identical to
71
+ * workers-types' AlarmInvocationInfo; declared here so the module stays
72
+ * usable without the ambient types.
73
+ */
74
+ export interface TimerAlarmInfo {
75
+ readonly isRetry: boolean;
76
+ readonly retryCount: number;
77
+ readonly scheduledTime: number;
78
+ }
79
+ /** The embedder's reasons, each with the handler that answers it. */
80
+ export type TimerHandlers = Record<string, (now: number, info?: TimerAlarmInfo) => TimerHandlerResult | Promise<TimerHandlerResult>>;
81
+ /**
82
+ * One actor's timers: the reason map over its ONE platform alarm.
83
+ *
84
+ * A cheap accessor over `(host, ctx)` — the chain that serializes the map's
85
+ * read-modify-write lives on the host instance, so every `timers()` call for
86
+ * one instance coordinates through the same chain.
87
+ */
88
+ export declare function timers(host: TimerHost, ctx: TimerContext): Timers;
89
+ export declare class Timers {
90
+ private readonly host;
91
+ private readonly ctx;
92
+ constructor(host: TimerHost, ctx: TimerContext);
93
+ /**
94
+ * Schedule (or re-schedule) a timer reason. Coordinated via a single map in
95
+ * DO storage so multiple subsystems don't clobber each other's `setAlarm()`
96
+ * calls.
97
+ *
98
+ * Semantics:
99
+ * - Reads the existing reasons map.
100
+ * - Sets `map[reason] = whenMs` IF `whenMs` is sooner than the
101
+ * currently-pending deadline for that reason (or no entry exists).
102
+ * Later-than-pending requests are silently ignored — the existing
103
+ * alarm will fire and re-arm anyway.
104
+ * - Writes the map back and calls `ctx.storage.setAlarm(min(deadlines))`.
105
+ *
106
+ * Cost: 1 storage read + 1 storage write + 1 setAlarm per call. setAlarm
107
+ * itself is billed as 1 row written per DO pricing. At a 60s janitor
108
+ * cadence, this is ~$0.05/mo/session at scale — dwarfed by the
109
+ * hibernation duration savings.
110
+ *
111
+ * Fail-soft: any throw is swallowed with a warn. On older runtimes /
112
+ * wrangler-dev where setAlarm is unavailable, this is a no-op (the
113
+ * subsystem's in-isolate setTimeout fallback continues to work).
114
+ */
115
+ schedule(reason: string, whenMs: number): Promise<boolean>;
116
+ /**
117
+ * Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
118
+ * with the embedder's handler map.
119
+ *
120
+ * For each pending reason whose deadline has passed, run its handler.
121
+ * Handlers are awaited in place: the alarm invocation is the fresh turn a
122
+ * re-entering subsystem asked for, and it has to stay the one paying for the
123
+ * work it just released.
124
+ *
125
+ * After running fireable reasons, re-arms `ctx.storage.setAlarm` at the
126
+ * earliest remaining deadline. If no reasons remain, deletes the map key and
127
+ * does NOT call setAlarm — the DO becomes hibernation-eligible after the 10s
128
+ * idle window.
129
+ *
130
+ * Forward/back-compat: unknown reasons silently dropped. `onLegacyAlarm`
131
+ * covers an alarm that fires with no map at all — a deploy from before the
132
+ * map existed left a bare `setAlarm` behind, and the embedder decides what
133
+ * that one-time fire means (one dispatch later the map is populated by the
134
+ * next schedule call).
135
+ */
136
+ dispatch(handlers: TimerHandlers, onLegacyAlarm?: () => void, alarmInfo?: TimerAlarmInfo): Promise<void>;
137
+ }
138
+ //# sourceMappingURL=timers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timers.d.ts","sourceRoot":"","sources":["../src/timers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED,uEAAuE;AACvE,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,iBAAiB,0BAA0B,CAAC;AAEzD;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;CAChC;AAmBD;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED,qEAAqE;AACrE,MAAM,MAAM,aAAa,GAAG,MAAM,CAChC,MAAM,EACN,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,cAAc,KAAK,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CACzF,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,YAAY,GAAG,MAAM,CAEjE;AAED,qBAAa,MAAM;IAEf,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,SAAS,EACf,GAAG,EAAE,YAAY;IAGpC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA4C1D;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CACN,QAAQ,EAAE,aAAa,EACvB,aAAa,CAAC,EAAE,MAAM,IAAI,EAC1B,SAAS,CAAC,EAAE,cAAc,GACzB,OAAO,CAAC,IAAI,CAAC;CAWjB"}
package/dist/timers.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * timers.ts — Durable Object alarm multiplexing, persisted across
3
+ * hibernation.
4
+ *
5
+ * A Durable Object has ONE alarm, and a second `setAlarm()` silently
6
+ * overwrites the first — so every alarm-driven subsystem coordinates through
7
+ * a single reason→deadline map and one dispatcher. Reasons are plain strings
8
+ * registered by the embedder: `timers(host, ctx).schedule` arms one, and
9
+ * `timers(host, ctx).dispatch` runs the embedder-supplied handler for every
10
+ * reason whose deadline has passed.
11
+ */
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
14
+ /**
15
+ * Multi-reason timer coordination map.
16
+ *
17
+ * JSON-serialised `Record<reason, deadlineMsEpoch>` where keys are the
18
+ * embedder's canonical reason strings (e.g. 'w9-flush', 'log-janitor'). The
19
+ * alarm() dispatcher reads this on fire, dispatches every reason whose
20
+ * deadline has passed, and re-arms `ctx.storage.setAlarm` at the earliest
21
+ * remaining deadline.
22
+ *
23
+ * Why a map (not a single nextAlarmAt + reason): two subsystems can have
24
+ * distinct deadlines. Without the map, the later setAlarm() call would
25
+ * overwrite the earlier reason silently, breaking whichever subsystem
26
+ * expected its deadline.
27
+ *
28
+ * Forward-compat: the dispatcher silently drops unknown reasons so a
29
+ * rollback from a future deploy that added new reasons doesn't leave the
30
+ * alarm stuck.
31
+ *
32
+ * The VALUE is live production DO storage ('w1_next_alarm_reasons', from the
33
+ * workstream that introduced it) and must never change — renaming a storage
34
+ * key is a migration, and orphaned rows are the least of what it breaks.
35
+ */
36
+ export const TIMER_REASONS_KEY = 'w1_next_alarm_reasons';
37
+ /**
38
+ * The async context of a running dispatch's handlers. A schedule request
39
+ * made inside it lands in the dispatch's arm collection instead of the
40
+ * chain — a handler that AWAITED a chained schedule would be waiting on an
41
+ * entry queued behind the dispatch it is running inside, which is a
42
+ * deadlock. `Outbox.queue` awaits `timers.schedule`, so any handler that
43
+ * queues into an outbox reaches this. The dispatcher folds the collected
44
+ * arms into the reason map before its own re-arm.
45
+ *
46
+ * AsyncLocalStorage, not a host field, so the redirect is scoped to the
47
+ * dispatch's OWN async context: a concurrent turn that schedules while a
48
+ * handler awaits external IO takes the normal chain path and keeps its own
49
+ * turn-gated persistence. Requires the `nodejs_compat` (or `nodejs_als`)
50
+ * compatibility flag on workerd.
51
+ */
52
+ const dispatchArms = new AsyncLocalStorage();
53
+ /**
54
+ * One actor's timers: the reason map over its ONE platform alarm.
55
+ *
56
+ * A cheap accessor over `(host, ctx)` — the chain that serializes the map's
57
+ * read-modify-write lives on the host instance, so every `timers()` call for
58
+ * one instance coordinates through the same chain.
59
+ */
60
+ export function timers(host, ctx) {
61
+ return new Timers(host, ctx);
62
+ }
63
+ export class Timers {
64
+ host;
65
+ ctx;
66
+ constructor(host, ctx) {
67
+ this.host = host;
68
+ this.ctx = ctx;
69
+ }
70
+ /**
71
+ * Schedule (or re-schedule) a timer reason. Coordinated via a single map in
72
+ * DO storage so multiple subsystems don't clobber each other's `setAlarm()`
73
+ * calls.
74
+ *
75
+ * Semantics:
76
+ * - Reads the existing reasons map.
77
+ * - Sets `map[reason] = whenMs` IF `whenMs` is sooner than the
78
+ * currently-pending deadline for that reason (or no entry exists).
79
+ * Later-than-pending requests are silently ignored — the existing
80
+ * alarm will fire and re-arm anyway.
81
+ * - Writes the map back and calls `ctx.storage.setAlarm(min(deadlines))`.
82
+ *
83
+ * Cost: 1 storage read + 1 storage write + 1 setAlarm per call. setAlarm
84
+ * itself is billed as 1 row written per DO pricing. At a 60s janitor
85
+ * cadence, this is ~$0.05/mo/session at scale — dwarfed by the
86
+ * hibernation duration savings.
87
+ *
88
+ * Fail-soft: any throw is swallowed with a warn. On older runtimes /
89
+ * wrangler-dev where setAlarm is unavailable, this is a no-op (the
90
+ * subsystem's in-isolate setTimeout fallback continues to work).
91
+ */
92
+ schedule(reason, whenMs) {
93
+ const { host, ctx } = this;
94
+ // From inside a dispatch handler, hand the arm to the dispatcher
95
+ // instead of the chain (see dispatchArms): the fold keeps EDF
96
+ // semantics, and the dispatch's own write and re-arm carry it. The
97
+ // setAlarm gate matches the chain path's, so both paths refuse alike
98
+ // on a runtime without alarms.
99
+ const arms = dispatchArms.getStore();
100
+ if (arms) {
101
+ if (typeof ctx?.storage?.setAlarm !== 'function')
102
+ return Promise.resolve(false);
103
+ arms.push({ reason, whenMs });
104
+ return Promise.resolve(true);
105
+ }
106
+ // Serialize every read-modify-write of the reasons map through one
107
+ // per-instance chain: two schedulers firing back-to-back from one activity
108
+ // hook would otherwise interleave their get→put cycles and silently drop
109
+ // whichever reason wrote first.
110
+ const run = async () => {
111
+ try {
112
+ const setAlarmFn = ctx?.storage?.setAlarm;
113
+ if (typeof setAlarmFn !== 'function')
114
+ return false;
115
+ const existing = (await ctx.storage.get(TIMER_REASONS_KEY));
116
+ const map = { ...(existing || {}) };
117
+ // Earliest-deadline-first: only update if new request is sooner or
118
+ // this reason has no pending entry.
119
+ if (!(reason in map) || whenMs < map[reason]) {
120
+ map[reason] = whenMs;
121
+ await ctx.storage.put(TIMER_REASONS_KEY, map);
122
+ }
123
+ const earliest = Math.min(...Object.values(map));
124
+ setAlarmFn.call(ctx.storage, earliest);
125
+ return true;
126
+ }
127
+ catch (e) {
128
+ console.warn('[nimbus/W1] timers.schedule threw:', errorText(e));
129
+ return false;
130
+ }
131
+ };
132
+ const chained = (host._timerChain ?? Promise.resolve()).then(run, run);
133
+ host._timerChain = chained;
134
+ return chained;
135
+ }
136
+ /**
137
+ * Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
138
+ * with the embedder's handler map.
139
+ *
140
+ * For each pending reason whose deadline has passed, run its handler.
141
+ * Handlers are awaited in place: the alarm invocation is the fresh turn a
142
+ * re-entering subsystem asked for, and it has to stay the one paying for the
143
+ * work it just released.
144
+ *
145
+ * After running fireable reasons, re-arms `ctx.storage.setAlarm` at the
146
+ * earliest remaining deadline. If no reasons remain, deletes the map key and
147
+ * does NOT call setAlarm — the DO becomes hibernation-eligible after the 10s
148
+ * idle window.
149
+ *
150
+ * Forward/back-compat: unknown reasons silently dropped. `onLegacyAlarm`
151
+ * covers an alarm that fires with no map at all — a deploy from before the
152
+ * map existed left a bare `setAlarm` behind, and the embedder decides what
153
+ * that one-time fire means (one dispatch later the map is populated by the
154
+ * next schedule call).
155
+ */
156
+ dispatch(handlers, onLegacyAlarm, alarmInfo) {
157
+ const { host, ctx } = this;
158
+ // Same serialization as schedule: the dispatcher's read→handlers→write
159
+ // cycle must not interleave with an activity-hook schedule.
160
+ const chained = (host._timerChain ?? Promise.resolve()).then(() => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo), () => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo));
161
+ host._timerChain = chained;
162
+ return chained;
163
+ }
164
+ }
165
+ async function dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo) {
166
+ // Collect schedule requests made inside handler context (see
167
+ // dispatchArms) and fold them into the map below, so an in-dispatch arm
168
+ // neither deadlocks on the chain nor races the write.
169
+ const arms = [];
170
+ try {
171
+ const now = Date.now();
172
+ const existing = (await ctx?.storage?.get?.(TIMER_REASONS_KEY));
173
+ const map = { ...(existing || {}) };
174
+ const hadMap = Object.keys(map).length > 0;
175
+ if (!hadMap) {
176
+ dispatchArms.run(arms, () => onLegacyAlarm?.());
177
+ }
178
+ else {
179
+ // Snapshot fireable reasons BEFORE running any of them, so a
180
+ // handler that schedules itself for the next cycle doesn't get
181
+ // immediately re-fired in the same dispatch.
182
+ const fired = [];
183
+ for (const [reason, when] of Object.entries(map)) {
184
+ if (when <= now)
185
+ fired.push(reason);
186
+ }
187
+ for (const reason of fired) {
188
+ delete map[reason];
189
+ const handler = handlers[reason];
190
+ // Unknown reasons silently dropped (forward-compat).
191
+ if (!handler)
192
+ continue;
193
+ try {
194
+ const result = await dispatchArms.run(arms, () => handler(now, alarmInfo));
195
+ if (result && typeof result.rearmAt === 'number') {
196
+ map[reason] = result.rearmAt;
197
+ }
198
+ }
199
+ catch (e) {
200
+ console.warn(`[nimbus/W1] dispatch ${reason} threw:`, errorText(e));
201
+ }
202
+ }
203
+ }
204
+ // Fold the in-dispatch arms, earliest-deadline-first per reason.
205
+ for (const arm of arms) {
206
+ if (!(arm.reason in map) || arm.whenMs < map[arm.reason]) {
207
+ map[arm.reason] = arm.whenMs;
208
+ }
209
+ }
210
+ // Re-arm or clear.
211
+ const setAlarmFn = ctx?.storage?.setAlarm;
212
+ if (Object.keys(map).length > 0) {
213
+ await ctx.storage.put(TIMER_REASONS_KEY, map);
214
+ const earliest = Math.min(...Object.values(map));
215
+ if (typeof setAlarmFn === 'function') {
216
+ setAlarmFn.call(ctx.storage, earliest);
217
+ }
218
+ }
219
+ else if (hadMap) {
220
+ try {
221
+ await ctx.storage.delete(TIMER_REASONS_KEY);
222
+ }
223
+ catch { }
224
+ // No remaining reasons → no setAlarm call → DO becomes
225
+ // hibernation-eligible after the 10s idle window.
226
+ }
227
+ }
228
+ catch (e) {
229
+ console.warn('[nimbus/W1] timers.dispatch threw:', errorText(e));
230
+ }
231
+ }