@nimbus-sh/fabric 0.1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +487 -0
  3. package/dist/alarms.d.ts +134 -0
  4. package/dist/alarms.d.ts.map +1 -0
  5. package/dist/alarms.js +214 -0
  6. package/dist/bindings.d.ts +316 -0
  7. package/dist/bindings.d.ts.map +1 -0
  8. package/dist/bindings.js +678 -0
  9. package/dist/ctx-exports.d.ts +47 -0
  10. package/dist/ctx-exports.d.ts.map +1 -0
  11. package/dist/ctx-exports.js +54 -0
  12. package/dist/facet-image-store.d.ts +112 -0
  13. package/dist/facet-image-store.d.ts.map +1 -0
  14. package/dist/facet-image-store.js +181 -0
  15. package/dist/fanout-pool.d.ts +223 -0
  16. package/dist/fanout-pool.d.ts.map +1 -0
  17. package/dist/fanout-pool.js +368 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25 -0
  21. package/dist/inner-do-registry.d.ts +41 -0
  22. package/dist/inner-do-registry.d.ts.map +1 -0
  23. package/dist/inner-do-registry.js +51 -0
  24. package/dist/launch-journal.d.ts +170 -0
  25. package/dist/launch-journal.d.ts.map +1 -0
  26. package/dist/launch-journal.js +154 -0
  27. package/dist/launch-pacer.d.ts +173 -0
  28. package/dist/launch-pacer.d.ts.map +1 -0
  29. package/dist/launch-pacer.js +193 -0
  30. package/dist/loader-ledger.d.ts +57 -0
  31. package/dist/loader-ledger.d.ts.map +1 -0
  32. package/dist/loader-ledger.js +91 -0
  33. package/dist/loader-pool.d.ts +315 -0
  34. package/dist/loader-pool.d.ts.map +1 -0
  35. package/dist/loader-pool.js +666 -0
  36. package/dist/process-fabric.d.ts +524 -0
  37. package/dist/process-fabric.d.ts.map +1 -0
  38. package/dist/process-fabric.js +388 -0
  39. package/dist/process-host.d.ts +132 -0
  40. package/dist/process-host.d.ts.map +1 -0
  41. package/dist/process-host.js +444 -0
  42. package/dist/vendor/errors.d.ts +24 -0
  43. package/dist/vendor/errors.d.ts.map +1 -0
  44. package/dist/vendor/errors.js +46 -0
  45. package/dist/vendor/serialize.d.ts +3 -0
  46. package/dist/vendor/serialize.d.ts.map +1 -0
  47. package/dist/vendor/serialize.js +25 -0
  48. package/dist/vendor/types.d.ts +69 -0
  49. package/dist/vendor/types.d.ts.map +1 -0
  50. package/dist/vendor/types.js +4 -0
  51. package/dist/workerd-facet-host.d.ts +207 -0
  52. package/dist/workerd-facet-host.d.ts.map +1 -0
  53. package/dist/workerd-facet-host.js +508 -0
  54. package/dist/ws-hibernation-config.d.ts +73 -0
  55. package/dist/ws-hibernation-config.d.ts.map +1 -0
  56. package/dist/ws-hibernation-config.js +93 -0
  57. package/package.json +62 -0
  58. package/src/alarms.ts +275 -0
  59. package/src/bindings.ts +871 -0
  60. package/src/ctx-exports.ts +77 -0
  61. package/src/facet-image-store.ts +196 -0
  62. package/src/fanout-pool.ts +503 -0
  63. package/src/index.ts +26 -0
  64. package/src/inner-do-registry.ts +58 -0
  65. package/src/launch-journal.ts +229 -0
  66. package/src/launch-pacer.ts +231 -0
  67. package/src/loader-ledger.ts +112 -0
  68. package/src/loader-pool.ts +984 -0
  69. package/src/process-fabric.ts +729 -0
  70. package/src/process-host.ts +566 -0
  71. package/src/vendor/errors.ts +56 -0
  72. package/src/vendor/serialize.ts +37 -0
  73. package/src/vendor/types.ts +75 -0
  74. package/src/workerd-facet-host.ts +694 -0
  75. package/src/ws-hibernation-config.ts +123 -0
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nimbus-sh/fabric",
3
+ "version": "0.1.0",
4
+ "description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
5
+ "keywords": [
6
+ "cloudflare",
7
+ "workers",
8
+ "durable-objects",
9
+ "facets",
10
+ "worker-loader",
11
+ "sandbox"
12
+ ],
13
+ "homepage": "https://github.com/AshishKumar4/Nimbus",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/AshishKumar4/Nimbus.git",
17
+ "directory": "packages/fabric"
18
+ },
19
+ "bugs": "https://github.com/AshishKumar4/Nimbus/issues",
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "workspace": "./src/index.ts",
28
+ "bun": "./src/index.ts",
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./*.js": {
33
+ "workspace": "./src/*.ts",
34
+ "bun": "./src/*.ts",
35
+ "types": "./dist/*.d.ts",
36
+ "import": "./dist/*.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json --noCheck --noEmit false --declaration true --declarationMap true --outDir dist --rootDir src",
48
+ "prepack": "npm run build",
49
+ "typecheck": "tsc --noEmit"
50
+ },
51
+ "dependencies": {
52
+ "@nimbus-sh/core": "^0.5.0",
53
+ "zod": "^4.4.3"
54
+ },
55
+ "devDependencies": {
56
+ "@cloudflare/workers-types": "^4.20250327.0",
57
+ "typescript": "^5.7.0"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }
package/src/alarms.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * alarms.ts — Durable Object alarm multiplexing + isolate-generation
3
+ * machinery, persisted across hibernation.
4
+ *
5
+ * Workerd hibernates Durable Objects between requests to free memory. On
6
+ * wake, the new isolate must rebuild its in-memory state from SQL — but it
7
+ * also needs to know "is this the same lifecycle as before, or did workerd
8
+ * recycle me?" That distinction matters for recovery (warmJoin vs cold init)
9
+ * and is captured by the isolate generation, a counter persisted across
10
+ * hibernations.
11
+ *
12
+ * A Durable Object has ONE alarm, and a second `setAlarm()` silently
13
+ * overwrites the first — so every alarm-driven subsystem coordinates through
14
+ * a single reason→deadline map and one dispatcher. Reasons are plain strings
15
+ * registered by the embedder: `scheduleAlarm` arms one, and `dispatchAlarm`
16
+ * runs the embedder-supplied handler for every reason whose deadline has
17
+ * passed.
18
+ */
19
+
20
+ import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
21
+
22
+ /**
23
+ * The storage the alarm map lives in. `setAlarm` is optional because
24
+ * `wrangler dev` serves a storage without it, which is the whole reason
25
+ * scheduling degrades to a no-op instead of throwing.
26
+ */
27
+ export interface AlarmStorage {
28
+ get(key: string): Promise<unknown>;
29
+ put(key: string, value: unknown): Promise<void>;
30
+ delete(key: string): Promise<boolean>;
31
+ setAlarm?(scheduledTime: number): Promise<void>;
32
+ }
33
+
34
+ /** The hosting actor's context, as the alarm coordination reads it. */
35
+ export interface AlarmContext {
36
+ storage: AlarmStorage;
37
+ }
38
+
39
+ /**
40
+ * Multi-reason alarm coordination map.
41
+ *
42
+ * JSON-serialised `Record<reason, deadlineMsEpoch>` where keys are the
43
+ * embedder's canonical reason strings (e.g. 'w9-flush', 'log-janitor'). The
44
+ * alarm() dispatcher reads this on fire, dispatches every reason whose
45
+ * deadline has passed, and re-arms `ctx.storage.setAlarm` at the earliest
46
+ * remaining deadline.
47
+ *
48
+ * Why a map (not a single nextAlarmAt + reason): two subsystems can have
49
+ * distinct deadlines. Without the map, the later setAlarm() call would
50
+ * overwrite the earlier reason silently, breaking whichever subsystem
51
+ * expected its deadline.
52
+ *
53
+ * Forward-compat: the dispatcher silently drops unknown reasons so a
54
+ * rollback from a future deploy that added new reasons doesn't leave the
55
+ * alarm stuck.
56
+ *
57
+ * The VALUE is live production DO storage ('w1_next_alarm_reasons', from the
58
+ * workstream that introduced it) and must never change — renaming a storage
59
+ * key is a migration, and orphaned rows are the least of what it breaks.
60
+ */
61
+ export const ALARM_REASONS_KEY = 'w1_next_alarm_reasons';
62
+
63
+ /**
64
+ * Storage key for the isolate-generation counter (cold-start +
65
+ * post-hibernation wake; one increment per fresh isolate).
66
+ *
67
+ * The VALUE is live production DO storage ('w9_isolate_gen') and must never
68
+ * change, same contract as {@link ALARM_REASONS_KEY}.
69
+ */
70
+ export const ISOLATE_GEN_KEY = 'w9_isolate_gen';
71
+
72
+ /**
73
+ * The host instance carrying the per-instance alarm chain. The field lives on
74
+ * the embedder's DO instance so one chain serializes every alarm-map
75
+ * read-modify-write for that instance (see {@link scheduleAlarm}).
76
+ */
77
+ export interface AlarmHost {
78
+ _alarmChain?: Promise<unknown>;
79
+ }
80
+
81
+ /** The host instance carrying the isolate-generation state. */
82
+ export interface IsolateGenHost {
83
+ _isolateGen: number;
84
+ _isolateGenPersisted: boolean;
85
+ }
86
+
87
+ /**
88
+ * Schedule (or re-schedule) an alarm reason. Coordinated via a single map in
89
+ * DO storage so multiple subsystems don't clobber each other's `setAlarm()`
90
+ * calls.
91
+ *
92
+ * Semantics:
93
+ * - Reads the existing reasons map.
94
+ * - Sets `map[reason] = whenMs` IF `whenMs` is sooner than the
95
+ * currently-pending deadline for that reason (or no entry exists).
96
+ * Later-than-pending requests are silently ignored — the existing
97
+ * alarm will fire and re-arm anyway.
98
+ * - Writes the map back and calls `ctx.storage.setAlarm(min(deadlines))`.
99
+ *
100
+ * Cost: 1 storage read + 1 storage write + 1 setAlarm per call. setAlarm
101
+ * itself is billed as 1 row written per DO pricing. At a 60s janitor
102
+ * cadence, this is ~$0.05/mo/session at scale — dwarfed by the
103
+ * hibernation duration savings.
104
+ *
105
+ * Fail-soft: any throw is swallowed with a warn. On older runtimes /
106
+ * wrangler-dev where setAlarm is unavailable, this is a no-op (the
107
+ * subsystem's in-isolate setTimeout fallback continues to work).
108
+ */
109
+ export function scheduleAlarm(
110
+ host: AlarmHost,
111
+ ctx: AlarmContext,
112
+ reason: string,
113
+ whenMs: number,
114
+ ): Promise<boolean> {
115
+ // Serialize every read-modify-write of the reasons map through one
116
+ // per-instance chain: two schedulers firing back-to-back from one activity
117
+ // hook would otherwise interleave their get→put cycles and silently drop
118
+ // whichever reason wrote first.
119
+ const run = async (): Promise<boolean> => {
120
+ try {
121
+ const setAlarmFn = ctx?.storage?.setAlarm;
122
+ if (typeof setAlarmFn !== 'function') return false;
123
+ const existing = (await ctx.storage.get(ALARM_REASONS_KEY)) as
124
+ | Record<string, number>
125
+ | undefined;
126
+ const map: Record<string, number> = { ...(existing || {}) };
127
+ // Earliest-deadline-first: only update if new request is sooner or
128
+ // this reason has no pending entry.
129
+ if (!(reason in map) || whenMs < map[reason]) {
130
+ map[reason] = whenMs;
131
+ await ctx.storage.put(ALARM_REASONS_KEY, map);
132
+ }
133
+ const earliest = Math.min(...Object.values(map));
134
+ setAlarmFn.call(ctx.storage, earliest);
135
+ return true;
136
+ } catch (e) {
137
+ console.warn('[nimbus/W1] scheduleAlarm threw:', errorText(e));
138
+ return false;
139
+ }
140
+ };
141
+ const chained = (host._alarmChain ?? Promise.resolve()).then(run, run);
142
+ host._alarmChain = chained;
143
+ return chained;
144
+ }
145
+
146
+ /**
147
+ * What one alarm handler may return: nothing, or a deadline this reason
148
+ * re-arms itself at. Re-arming through the return value keeps the map's
149
+ * read-modify-write inside the dispatcher, where it is serialized.
150
+ */
151
+ export type AlarmHandlerResult = void | { rearmAt: number };
152
+
153
+ /** The embedder's reasons, each with the handler that answers it. */
154
+ export type AlarmHandlers = Record<
155
+ string,
156
+ (now: number) => AlarmHandlerResult | Promise<AlarmHandlerResult>
157
+ >;
158
+
159
+ /**
160
+ * Multi-reason alarm dispatcher. Called from the DO's `alarm()` handler with
161
+ * the embedder's handler map.
162
+ *
163
+ * For each pending reason whose deadline has passed, run its handler.
164
+ * Handlers are awaited in place: the alarm invocation is the fresh turn a
165
+ * re-entering subsystem asked for, and it has to stay the one paying for the
166
+ * work it just released.
167
+ *
168
+ * After running fireable reasons, re-arms `ctx.storage.setAlarm` at the
169
+ * earliest remaining deadline. If no reasons remain, deletes the map key and
170
+ * does NOT call setAlarm — the DO becomes hibernation-eligible after the 10s
171
+ * idle window.
172
+ *
173
+ * Forward/back-compat: unknown reasons silently dropped. `onLegacyAlarm`
174
+ * covers an alarm that fires with no map at all — a deploy from before the
175
+ * map existed left a bare `setAlarm` behind, and the embedder decides what
176
+ * that one-time fire means (one dispatch later the map is populated by the
177
+ * next scheduleAlarm call).
178
+ */
179
+ export function dispatchAlarm(
180
+ host: AlarmHost,
181
+ ctx: AlarmContext,
182
+ handlers: AlarmHandlers,
183
+ onLegacyAlarm?: () => void,
184
+ ): Promise<void> {
185
+ // Same serialization as scheduleAlarm: the dispatcher's read→handlers→write
186
+ // cycle must not interleave with an activity-hook scheduleAlarm.
187
+ const chained = (host._alarmChain ?? Promise.resolve()).then(
188
+ () => dispatchAlarmBody(ctx, handlers, onLegacyAlarm),
189
+ () => dispatchAlarmBody(ctx, handlers, onLegacyAlarm),
190
+ );
191
+ host._alarmChain = chained;
192
+ return chained;
193
+ }
194
+
195
+ async function dispatchAlarmBody(
196
+ ctx: AlarmContext,
197
+ handlers: AlarmHandlers,
198
+ onLegacyAlarm?: () => void,
199
+ ): Promise<void> {
200
+ try {
201
+ const now = Date.now();
202
+ const existing = (await ctx?.storage?.get?.(ALARM_REASONS_KEY)) as
203
+ | Record<string, number>
204
+ | undefined;
205
+ if (!existing || Object.keys(existing).length === 0) {
206
+ onLegacyAlarm?.();
207
+ return;
208
+ }
209
+ const map: Record<string, number> = { ...existing };
210
+ // Snapshot fireable reasons BEFORE running any of them, so a
211
+ // handler that schedules itself for the next cycle doesn't get
212
+ // immediately re-fired in the same dispatch.
213
+ const fired: string[] = [];
214
+ for (const [reason, when] of Object.entries(map)) {
215
+ if (when <= now) fired.push(reason);
216
+ }
217
+ for (const reason of fired) {
218
+ delete map[reason];
219
+ const handler = handlers[reason];
220
+ // Unknown reasons silently dropped (forward-compat).
221
+ if (!handler) continue;
222
+ try {
223
+ const result = await handler(now);
224
+ if (result && typeof result.rearmAt === 'number') {
225
+ map[reason] = result.rearmAt;
226
+ }
227
+ } catch (e) {
228
+ console.warn(`[nimbus/W1] dispatch ${reason} threw:`, errorText(e));
229
+ }
230
+ }
231
+ // Re-arm or clear.
232
+ const setAlarmFn = ctx?.storage?.setAlarm;
233
+ if (Object.keys(map).length > 0) {
234
+ await ctx.storage.put(ALARM_REASONS_KEY, map);
235
+ const earliest = Math.min(...Object.values(map));
236
+ if (typeof setAlarmFn === 'function') {
237
+ setAlarmFn.call(ctx.storage, earliest);
238
+ }
239
+ } else {
240
+ try { await ctx.storage.delete(ALARM_REASONS_KEY); } catch {}
241
+ // No remaining reasons → no setAlarm call → DO becomes
242
+ // hibernation-eligible after the 10s idle window.
243
+ }
244
+ } catch (e) {
245
+ console.warn('[nimbus/W1] dispatchAlarm threw:', errorText(e));
246
+ }
247
+ }
248
+
249
+ /** Increment + persist the isolate-gen counter once per fresh isolate. */
250
+ export async function maybeBumpIsolateGen(host: IsolateGenHost, ctx: AlarmContext): Promise<void> {
251
+ if (host._isolateGenPersisted) return;
252
+ host._isolateGenPersisted = true;
253
+ try {
254
+ const prev = (await ctx.storage.get(ISOLATE_GEN_KEY)) as number | undefined;
255
+ // Adopt the persisted truth first, and adopt the bump only after the
256
+ // put resolves. An unpersisted `next` would be re-read as `prev` by the
257
+ // NEXT boot and re-issued — two instances sharing one generation is
258
+ // exactly the pid-aliasing this counter exists to prevent. Running on
259
+ // the previous persisted generation is the lesser lapse, and the
260
+ // put-failure case is replica-only in practice (replicas never spawn).
261
+ //
262
+ // What holds the guarantee is the output gate, not this await: measured,
263
+ // the block body resolves in 0 ms even with a confirmed put, because
264
+ // `await storage.put()` returns before durability. The gate is what
265
+ // keeps a pid from generation N from escaping before N is durable, which
266
+ // is why marking this put `allowUnconfirmed` is not a free speedup — see
267
+ // scratchpad/coldstart-s1.md.
268
+ host._isolateGen = typeof prev === 'number' ? prev : 0;
269
+ const next = host._isolateGen + 1;
270
+ await ctx.storage.put(ISOLATE_GEN_KEY, next);
271
+ host._isolateGen = next;
272
+ } catch (e) {
273
+ console.warn('[nimbus/W9] isolate-gen bump failed:', errorText(e));
274
+ }
275
+ }