@nimbus-sh/fabric 0.1.0 → 0.2.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 (103) hide show
  1. package/README.md +84 -55
  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 +87 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +76 -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} +25 -13
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/{launch-journal.js → fenced-work.js} +47 -13
  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 +2 -14
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +6 -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 +10 -9
  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} +19 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +22 -11
  60. package/dist/workerd-facet-host.d.ts +28 -67
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +49 -171
  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 +127 -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} +58 -22
  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 +6 -33
  82. package/src/process-host.ts +10 -15
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +30 -24
  86. package/src/workerd-facet-host.ts +67 -193
  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-pacer.d.ts.map +0 -1
  97. package/dist/loader-ledger.d.ts +0 -57
  98. package/dist/loader-ledger.d.ts.map +0 -1
  99. package/dist/loader-ledger.js +0 -91
  100. package/dist/loader-pool.d.ts.map +0 -1
  101. package/src/alarms.ts +0 -275
  102. package/src/ctx-exports.ts +0 -77
  103. package/src/loader-ledger.ts +0 -112
package/README.md CHANGED
@@ -26,27 +26,50 @@ Where a specific date matters it is given.
26
26
 
27
27
  ## Importing it
28
28
 
29
+ Your Worker must set `compatibility_flags: ["nodejs_compat"]`. The timer
30
+ dispatcher imports `AsyncLocalStorage` from `node:async_hooks`, which workerd
31
+ ships only under that flag. Without it the module fails to load, so the
32
+ failure arrives at deploy time, not in production. The dispatcher needs
33
+ async-local state because several Durable Objects from one script can share a
34
+ V8 isolate, and a module-scoped variable would leak the dispatch context
35
+ between them.
36
+
29
37
  The root export pulls `cloudflare:workers`, so `import ... from
30
38
  '@nimbus-sh/fabric'` resolves only inside a Worker. Outside workerd (unit
31
39
  tests, tooling) import the subpath modules directly —
32
- `@nimbus-sh/fabric/alarms.js`, `@nimbus-sh/fabric/launch-journal.js`, and so
40
+ `@nimbus-sh/fabric/timers.js`, `@nimbus-sh/fabric/fenced-work.js`, and so
33
41
  on. Most of the package is structurally typed against plain objects precisely
34
42
  so it can be tested in bun or node.
35
43
 
36
- An embedder wires three seams at composition time, each first-write-wins:
44
+ An embedder states its composition once, in its composition root:
37
45
 
38
46
  ```ts
39
- import { setCtxExports, setSupervisorEntrypointName, setStagedBootAssembler } from '@nimbus-sh/fabric';
40
-
41
- // In the Worker's fetch handler, once:
42
- setCtxExports(ctx.exports);
43
- // The name of your supervisor WorkerEntrypoint export. The fabric mints one
44
- // binding per hosted program from it (env.SUPERVISOR inside the facet).
45
- setSupervisorEntrypointName('MySupervisorRPC');
46
- // Only if you use 'staged' boot specs; 'code' boots need no assembler.
47
- setStagedBootAssembler(async (env, stage) => assembleLoaderConfig(env, stage));
47
+ import { composeFabric, adoptCtxExports } from '@nimbus-sh/fabric';
48
+
49
+ // Module scope of the Worker entry, once per isolate:
50
+ composeFabric({
51
+ // The name of your supervisor WorkerEntrypoint export. The fabric mints one
52
+ // binding per hosted program from it (env.SUPERVISOR inside the facet).
53
+ supervisorEntrypoint: 'MySupervisorRPC',
54
+ // Only if you use 'staged' boot specs; 'code' boots need no assembler.
55
+ stagedBootAssembler: async (env, stage) => assembleLoaderConfig(env, stage),
56
+ });
57
+
58
+ // ctx.exports is runtime state, not composition. Capture it where the
59
+ // platform hands it over — the first fetch, or the DO constructor:
60
+ adoptCtxExports(ctx.exports);
48
61
  ```
49
62
 
63
+ Both calls are first-write-wins.
64
+
65
+ Before a release reaches the registry, consumers link it by packed tarball:
66
+ `npm pack` here, a `file:` path there. One bun behavior to know when you do:
67
+ bun pins a `file:` tarball by the integrity hash in its lockfile and keeps
68
+ serving the extraction it already has, so repacking the tarball at the same
69
+ path changes nothing at the consumer. After a repack, bump the version you
70
+ pack or delete the tarball's lockfile entry; a plain `bun install` is not
71
+ enough.
72
+
50
73
  ## One alarm, many reasons
51
74
 
52
75
  A Durable Object has ONE alarm, and a second `setAlarm()` silently overwrites
@@ -55,18 +78,18 @@ reason→deadline map in storage, with one dispatcher:
55
78
 
56
79
  ```ts
57
80
  import { DurableObject } from 'cloudflare:workers';
58
- import { scheduleAlarm, dispatchAlarm } from '@nimbus-sh/fabric';
81
+ import { timers } from '@nimbus-sh/fabric';
59
82
 
60
83
  export class MySession extends DurableObject {
61
- _alarmChain?: Promise<unknown>; // serializes the map's read-modify-write
84
+ _timerChain?: Promise<unknown>; // serializes the map's read-modify-write
62
85
 
63
86
  async fetch(request: Request): Promise<Response> {
64
- await scheduleAlarm(this, this.ctx, 'janitor', Date.now() + 60_000);
87
+ await timers(this, this.ctx).schedule('janitor', Date.now() + 60_000);
65
88
  return new Response('ok');
66
89
  }
67
90
 
68
91
  async alarm(): Promise<void> {
69
- await dispatchAlarm(this, this.ctx, {
92
+ await timers(this, this.ctx).dispatch({
70
93
  janitor: async (now) => {
71
94
  await this.cleanUp();
72
95
  return { rearmAt: now + 60_000 }; // re-arm through the return value
@@ -76,8 +99,8 @@ export class MySession extends DurableObject {
76
99
  }
77
100
  ```
78
101
 
79
- `scheduleAlarm` keeps the earliest deadline per reason and arms the real alarm
80
- at the minimum across all of them. `dispatchAlarm` snapshots the fireable set
102
+ `schedule` keeps the earliest deadline per reason and arms the real alarm
103
+ at the minimum across all of them. `dispatch` snapshots the fireable set
81
104
  before running any handler (so a handler that re-schedules itself is not
82
105
  re-fired in the same dispatch), silently drops unknown reasons (a rollback
83
106
  from a deploy that added reasons must not wedge the alarm), and when no
@@ -95,15 +118,12 @@ pid at or below the current generation's base was allocated by a previous
95
118
  incarnation.**
96
119
 
97
120
  ```ts
98
- import { maybeBumpIsolateGen } from '@nimbus-sh/fabric';
121
+ import { adoptGeneration, generation } from '@nimbus-sh/fabric';
99
122
 
100
123
  export class MySession extends DurableObject {
101
- _isolateGen = 0;
102
- _isolateGenPersisted = false;
103
-
104
124
  async fetch(request: Request): Promise<Response> {
105
- await maybeBumpIsolateGen(this, this.ctx); // idempotent per instance
106
- // this._isolateGen is now this incarnation's generation
125
+ await adoptGeneration(this.ctx); // idempotent per instance
126
+ // generation(this.ctx) is now this incarnation's generation
107
127
  }
108
128
  }
109
129
  ```
@@ -124,13 +144,13 @@ dies silently with the instance. The journal is what a later instance reads to
124
144
  know that happened:
125
145
 
126
146
  ```ts
127
- import { ResidentLaunchJournal, type ResidentLaunchRecord } from '@nimbus-sh/fabric';
147
+ import { FencedWork, type FencedWorkRecord } from '@nimbus-sh/fabric';
128
148
 
129
- interface MyLaunch extends ResidentLaunchRecord {
149
+ interface MyLaunch extends FencedWorkRecord {
130
150
  argv: string[]; // whatever your redrive needs; the journal never reads it
131
151
  }
132
152
 
133
- const journal = new ResidentLaunchJournal<MyLaunch>(this.ctx.storage, {
153
+ const journal = new FencedWork<MyLaunch>(this.ctx.storage, {
134
154
  generationBase: () => this.pidBase,
135
155
  waitUntil: (p) => this.ctx.waitUntil(p),
136
156
  redrive: (record, attempt) => this.launch(record.argv, attempt),
@@ -159,7 +179,7 @@ Two details here cost us real incidents before they were mechanisms:
159
179
  went looking.
160
180
 
161
181
  Recovery applies the generation predicate (`pid <= generationBase()`), deletes
162
- each stale row, and re-drives once per record (`RESIDENT_LAUNCH_MAX_ATTEMPT` =
182
+ each stale row, and re-drives once per record (`FENCED_WORK_MAX_ATTEMPT` =
163
183
  1) — a reset that recurs is not the transient kind.
164
184
 
165
185
  ## Pacing big work across turns
@@ -173,16 +193,17 @@ milliseconds, because the in-DO clock does not advance without I/O (0 ms
173
193
  across 200,000 consecutive reads). So the pacer accounts **bytes**:
174
194
 
175
195
  ```ts
176
- import { LaunchPacer, LaunchTurnPump, scheduleAlarm } from '@nimbus-sh/fabric';
196
+ import { TurnBudget, PacedWork, onColdStart, timers } from '@nimbus-sh/fabric';
177
197
 
178
- const pump = new LaunchTurnPump({
179
- requestTurn: () => { void scheduleAlarm(this, this.ctx, 'launch-turn', Date.now()); },
180
- recover: () => journal.recoverInterrupted(),
198
+ const pump = new PacedWork(this.ctx, {
199
+ requestTurn: () => { void timers(this, this.ctx).schedule('launch-turn', Date.now()); },
181
200
  });
182
- const pacer = new LaunchPacer(pump);
201
+ // Deferred reconciliation rides the first pump, off the init gate:
202
+ onColdStart(this.ctx, () => journal.recoverInterrupted());
203
+ const budget = new TurnBudget(pump);
183
204
 
184
205
  // Inside the launch, after each unit of work:
185
- await pacer.spend(bytesJustProcessed); // suspends every LAUNCH_CHUNK_MAX_BYTES (2 MB)
206
+ await budget.spend(bytesJustProcessed); // suspends every TURN_CHUNK_MAX_BYTES (2 MB)
186
207
  // In alarm(), as one of the dispatcher's reasons:
187
208
  'launch-turn': () => pump.pump(),
188
209
  ```
@@ -190,22 +211,22 @@ await pacer.spend(bytesJustProcessed); // suspends every LAUNCH_CHUNK_MAX_BYTE
190
211
  The pump awaits each resumed chunk, so the invocation that granted the turn is
191
212
  the invocation that pays for the work — nothing runs detached in a handler's
192
213
  microtask drain. A past-deadline alarm is delivered as soon as the object is
193
- free, which makes `scheduleAlarm(..., Date.now())` a genuine "re-enter now"
214
+ free, which makes `schedule(..., Date.now())` a genuine "re-enter now"
194
215
  primitive. Without an alarm-capable host the pump degrades to a same-context
195
216
  timer: the single-turn behaviour this path always had, minus the
196
217
  responsiveness.
197
218
 
198
- ## Running programs: the loader pool
219
+ ## Running programs: the isolate pool
199
220
 
200
- `LoaderPool` runs plain functions in warm dynamic-worker isolates over
221
+ `IsolatePool` runs plain functions in warm dynamic-worker isolates over
201
222
  `env.LOADER`. Functions are serialized with `fn.toString()`, so they must be
202
223
  self-contained: no captured variables, no `this` (rejected at dispatch), and
203
224
  their last parameter receives the forwarded bindings.
204
225
 
205
226
  ```ts
206
- import { LoaderPool } from '@nimbus-sh/fabric';
227
+ import { IsolatePool } from '@nimbus-sh/fabric';
207
228
 
208
- const pool = new LoaderPool(env, this.ctx, {
229
+ const pool = new IsolatePool(env, this.ctx, {
209
230
  concurrency: 4,
210
231
  tag: 'checksum',
211
232
  omitSupervisor: true, // this pool needs no callback into the DO
@@ -236,14 +257,15 @@ success. Warm isolates are scoped to one session unless a pool explicitly opts
236
257
  into `cacheScope: 'global'`, which is reserved for stateless compute pools
237
258
  that take no supervisor binding and retain no user state.
238
259
 
239
- `FanoutPool` is the tier above: a single DO method can drive at most 4
260
+ `Fanout` is the tier above: a single DO method can drive at most 4
240
261
  concurrent Worker Loader fetches, so batches of fewer than 5 tasks run in the
241
- coordinator through a `LoaderPool` and wider batches shard deterministically
262
+ coordinator through an `IsolatePool` and wider batches shard deterministically
242
263
  across sibling DOs (up to 32, dispatched in phases of 4 to bound simultaneous
243
264
  cold starts). Transient peer resets retry on a 250/750/1500 ms schedule; an
244
265
  overloaded peer gets the 1/3/6 s one.
245
266
 
246
- Every fabric call into the loader lands on a per-DO ledger: distinct ids ever
267
+ Every fabric call into the loader lands on a per-DO ledger (`budgets.js`,
268
+ which also owns the module-map ceiling and the facet-ID count): distinct ids ever
247
269
  gotten — each permanently holds one of the ~5–6 dynamic-worker slots, because
248
270
  a keyed `loader.get(id)` is never released — plus live and peak concurrent
249
271
  Loader fetches, read via `loaderLedgerStats(ctx)`. A "Too many concurrent
@@ -256,9 +278,9 @@ platform would have run.
256
278
  ## Running processes: the resident fabric
257
279
 
258
280
  A resident process — a dev server, a socket runner, an attached TUI — is a DO
259
- facet whose class comes from a dynamic worker. `openResidentFacet` is the one
260
- way such a process comes into existence; `ProcessFabric` is the lifecycle
261
- around it:
281
+ facet whose class comes from a dynamic worker. `processes(ctx, env).spawn` is
282
+ the one way such a process comes into existence; `ProcessFabric` is the
283
+ lifecycle around it:
262
284
 
263
285
  ```ts
264
286
  import { ProcessFabric, createProcessHost } from '@nimbus-sh/fabric';
@@ -341,7 +363,7 @@ for 1 GB — flat, because nothing is copied) but also shares the session's
341
363
  same-object-only and workerd exposes no `VACUUM INTO`, `ATTACH`, or
342
364
  `sqlite3_backup` across objects. And a clone hazard we measured rather than
343
365
  assumed: ANY unresolvable `src` — a typo, a name not created yet — silently
344
- EMPTIES the destination and reports success. `cloneFacetStorage` is the one
366
+ EMPTIES the destination and reports success. `cloneStorage` is the one
345
367
  way the fabric calls clone: it takes the caller's `populated(name)` probe and
346
368
  asserts it positively on the source before the clone and on the destination
347
369
  after, so a typo is refused before the platform call and a wiped destination
@@ -351,9 +373,9 @@ not a non-zero size.
351
373
 
352
374
  ## The image store
353
375
 
354
- `FacetImageStore` materializes generated boot images into a content-addressed
376
+ `ImageStore` materializes generated boot images into a content-addressed
355
377
  store (`var/lib/nimbus/facet-images/<sha256>.js`) through a small
356
- `FacetImageBlobStore` port — the embedder owns the disk, the store owns the
378
+ `ImageBlobStore` port — the embedder owns the disk, the store owns the
357
379
  protocol:
358
380
 
359
381
  - **Root before the first byte.** The whole root set is registered
@@ -402,6 +424,12 @@ enforced by the code above where code can enforce them; the rest is here so
402
424
  the next person does not have to measure them again. All figures are from
403
425
  production workerd, June–August 2026.
404
426
 
427
+ The tables are the short form. [PLATFORM.md](PLATFORM.md) is the full
428
+ catalog: the same invariants merged with a sibling project's independent
429
+ measurements, every entry graded by evidence (probe / source / production /
430
+ documented), dated, and marked for whether this library enforces it or you
431
+ handle it yourself.
432
+
405
433
  ### Durable Object storage
406
434
 
407
435
  | Invariant | Evidence |
@@ -409,7 +437,7 @@ production workerd, June–August 2026.
409
437
  | `await put()` resolves BEFORE durability; `ctx.storage.sync()` is the barrier; the output gate holds the guarantee | a launch killed in its first chunks left NO journal row (staging, 2026-08-13) |
410
438
  | A reset destroys every write its turn had outstanding; an alarm write rolls back with it and the platform re-delivers the alarm to the replacement instance | the first turn after a reset is a recovery turn, for free |
411
439
  | SQLite value cap is 2 MB per ROW, key length included | single-value ceiling 2,199,981 B with a 12-char key; overflow throws clean, catchable `SQLITE_TOOBIG` |
412
- | One alarm per object; a second `setAlarm()` silently overwrites | why `ALARM_REASONS_KEY` is a map |
440
+ | One alarm per object; a second `setAlarm()` silently overwrites | why `TIMER_REASONS_KEY` is a map |
413
441
  | Input gates stay closed across `get`/`put` | set-if-absent is atomic per DO with no CAS loop |
414
442
  | A facet's own SQLite survives a fresh module scope | 7,141 rows / 45.7 MB intact across recycling — keep provenance in rows, never heap |
415
443
  | ~10 GiB storage budget shared by the DO root and every facet and clone under it, with no copy-on-write credit | N clones of X bytes cost X·(N+1); crossing RESETS the object rather than raising an error |
@@ -418,11 +446,11 @@ production workerd, June–August 2026.
418
446
 
419
447
  | Invariant | Evidence |
420
448
  |---|---|
421
- | No pending alarm ⇒ hibernation-eligible after ~10 s idle | why `dispatchAlarm` deletes the map when nothing remains |
449
+ | No pending alarm ⇒ hibernation-eligible after ~10 s idle | why `timers.dispatch` deletes the map when nothing remains |
422
450
  | One-turn CPU budget ~30 s; yielding inside an invocation buys nothing; only genuine re-entry (an alarm) resets it | killed with `exceededCpu` at 31.8 s and 32.5 s |
423
451
  | A long turn drops the object's WebSockets even when the work succeeds | the launch turn finished `outcome=ok` and the terminal died anyway |
424
452
  | The in-DO clock does not advance without I/O | 0 ms across 200,000 consecutive `Time.now` reads — pace in bytes, hand deadlines to the host |
425
- | Isolate generation increments on EVERY fresh isolate: cold start and hibernation wake, not only resets | `maybeBumpIsolateGen` adopts persisted truth first |
453
+ | Isolate generation increments on EVERY fresh isolate: cold start and hibernation wake, not only resets | `adoptGeneration` adopts persisted truth first |
426
454
  | `pid <= generation base` ⇒ previous generation | THE reset predicate; `PID_GEN_STRIDE` = 1,000,000 |
427
455
  | `setTimeout`/`setInterval` prevent hibernation | one-shot self-nulling timers only |
428
456
 
@@ -437,7 +465,7 @@ production workerd, June–August 2026.
437
465
  | Module scope bans I/O; `new Function` succeeds at module scope and throws at request time | code reaches a facet through the module map or not at all |
438
466
  | The facet start callback fires at most once | re-running it would re-execute the user's program |
439
467
  | ~5–6 concurrent dynamic workers per DO; at most 4 concurrent Loader fetches per DO method; loader-cache entries are never released | `IN_DO_THRESHOLD` = 5 sits under the fetch cap; every `loader.get(id)` permanently consumes a slot — counted per DO by the loader ledger, and a cap refusal names the ids holding them |
440
- | `ctx.facets.clone` is same-object only, absent from `@cloudflare/workers-types` and the pinned workerd, present in production | 18–31 ms / 45.7 MB, 34–54 ms / 1 GB; an unresolvable `src` silently EMPTIES the destination and reports success — `cloneFacetStorage` enforces the both-ends validation |
468
+ | `ctx.facets.clone` is same-object only, absent from `@cloudflare/workers-types` and the pinned workerd, present in production | 18–31 ms / 45.7 MB, 34–54 ms / 1 GB; an unresolvable `src` silently EMPTIES the destination and reports success — `cloneStorage` enforces the both-ends validation |
441
469
  | A DO dies at ~200 MiB of live wasm linear memory; reserved and written pages die at the same ceiling | lazy growth buys nothing; bound guest memory by rewriting the memory section |
442
470
  | A wasm stack suspended (JSPI) in one request cannot resume in another | 3 in-context resumes took 6 ms; the first cross-context one hit a 30 s timeout |
443
471
 
@@ -460,7 +488,7 @@ production workerd, June–August 2026.
460
488
  |---|---|
461
489
  | Without `setWebSocketAutoResponse(ping/pong)`, every idle-tab ping wakes the actor | ~2,880 wakes/day per idle tab; the config survives hibernation |
462
490
  | A hibernatable WS owned by a DO cannot be written from a sibling `WorkerEntrypoint` isolate | sends happen in the DO's own context (relay pattern) |
463
- | A resident process never receives a WebSocket | route targets are `handleHttpRequest` only; every socket terminates on the session DO |
491
+ | A WebSocket upgrade cannot ride the RPC hop a resident's HTTP takes | a 101 owns a live socket and RPC reconstructs values rather than handing sockets over; an upgrade takes the separate fetch-semantic entrypoint and stays on `fetch` for every hop (a facet is fetched directly; a peer fetches its own facet), and a target without that entrypoint answers 501 |
464
492
 
465
493
  ### Sharing an isolate
466
494
 
@@ -472,9 +500,10 @@ production workerd, June–August 2026.
472
500
 
473
501
  ## Relation to the other packages
474
502
 
475
- `@nimbus-sh/core` is the OS this machinery hosts — fabric depends on it for
476
- shared primitives (constants, RPC disposal, error classification) and core
477
- never imports fabric.
503
+ `@nimbus-sh/core` is the OS this machinery hosts; core never imports fabric.
504
+ [`@nimbus-sh/platform`](https://www.npmjs.com/package/@nimbus-sh/platform) is
505
+ the zero-dependency leaf under both: the measured limits tables, the error
506
+ taxonomy, RPC disposal, and the supervisor budget machinery.
478
507
  [`@nimbus-sh/worker`](https://www.npmjs.com/package/@nimbus-sh/worker) is the
479
508
  canonical embedder: it supplies the seams above, the supervisor entrypoint,
480
509
  the session protocol, and everything user-facing. If you want the full hosted
package/dist/bindings.js CHANGED
@@ -24,10 +24,10 @@
24
24
  */
25
25
  import { WorkerEntrypoint } from 'cloudflare:workers';
26
26
  import { z } from 'zod/v4';
27
- import { disposeRpcResource, useRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
28
- import { supervisorEntrypoint, supervisorEntrypointName } from './ctx-exports.js';
29
- import { requireStagedBootAssembler } from './process-fabric.js';
30
- import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
27
+ import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
28
+ import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
29
+ import { stagedBootAssembler } from './composition.js';
30
+ import { assertModuleMapWithinCodeLimit } from './budgets.js';
31
31
  /**
32
32
  * `ctx.exports` — workerd's loopback bag, which the installed
33
33
  * @cloudflare/workers-types does not put on `ExecutionContext`. Probed rather
@@ -448,7 +448,7 @@ export class NimbusLoadedEntrypoint extends WorkerEntrypoint {
448
448
  // alive.
449
449
  const stage = props.stage;
450
450
  outerStub = outerLoader.get(props.key, async () => {
451
- const assembled = await requireStagedBootAssembler()(this.env, stage);
451
+ const assembled = await stagedBootAssembler()(this.env, stage);
452
452
  assertModuleMapWithinCodeLimit(assembled.modules ?? {});
453
453
  const supervisorBinding = await this._supervisorBinding(props);
454
454
  if (!supervisorBinding)
@@ -0,0 +1,132 @@
1
+ /**
2
+ * budgets.ts — per-DO accounting for the platform budgets the fabric spends:
3
+ * the Worker Loader's two caps, the facet-ID lifetime budget, and the
4
+ * dynamic-worker module-map ceiling.
5
+ *
6
+ * Measured on production workerd: a Durable Object admits ~5–6 concurrent
7
+ * dynamic workers before the platform refuses with "Too many concurrent
8
+ * dynamic workers", one DO method can drive at most 4 concurrent Loader
9
+ * fetches, and loader-cache entries are never released — every DISTINCT
10
+ * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
11
+ * the object's lifetime. Nimbus stays under the caps by construction
12
+ * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
13
+ * were counted in prose. This ledger counts them at the fabric's loader call
14
+ * sites instead — the loader pool's slots, a resident process's keyed worker,
15
+ * a one-shot's load — so proximity is measurable and a cap failure can name
16
+ * the ids actually holding slots.
17
+ *
18
+ * Measurement only: no admission control. The caps are the platform's, they
19
+ * are approximate ("~5–6"), and a gate on an approximate number would refuse
20
+ * work the platform would have run.
21
+ *
22
+ * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
23
+ * caps are per Durable Object, and dynamic workers die with the isolate that
24
+ * loaded them, so a ledger that goes away with its host describes nothing
25
+ * that still exists.
26
+ */
27
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
28
+ export declare function recordLoaderId(ctx: object, id: string): void;
29
+ /**
30
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
31
+ * function ends it (idempotently), from the caller's own `finally`.
32
+ *
33
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
34
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
35
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
36
+ * Durable Object poisoned after every pooled dispatch — the next fabric
37
+ * activity hung the object or reset the instance outright (pid base jumped,
38
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
39
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
40
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
41
+ * workers: an RPC stub call must stay a direct property call awaited by the
42
+ * frame that made it, so the ledger only brackets it.
43
+ */
44
+ export declare function beginLoaderFetch(ctx: object): () => void;
45
+ /** Snapshot for the diag surface. Pure read; no I/O. */
46
+ export declare function loaderLedgerStats(ctx: object): {
47
+ idsEverGotten: string[];
48
+ liveFetches: number;
49
+ peakLiveFetches: number;
50
+ };
51
+ /**
52
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
53
+ * failure; hand every other error back untouched. The platform's message
54
+ * says only that the cap was hit — which ids hold the slots, and that a
55
+ * keyed id can never give one back, is what the operator needs to know to
56
+ * shrink anything.
57
+ */
58
+ export declare function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error;
59
+ /**
60
+ * Total bytes a dynamic Worker's module map may carry, across every member of
61
+ * it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
62
+ * refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
63
+ * size of 67108864 bytes", confirmed at five sizes with two trials each. The
64
+ * budget is shared, so a ruby process is already 34.3 MiB down before its disk
65
+ * is counted.
66
+ */
67
+ export declare const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67108864;
68
+ /**
69
+ * Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
70
+ * the largest members. The platform's own refusal reports one number for a
71
+ * budget shared across every member of the map, which tells the operator
72
+ * nothing about WHAT to shrink — so every fabric seam that assembles a map
73
+ * runs this before the loader sees it.
74
+ *
75
+ * Costed to its two paths. Under the ceiling: one length read per member —
76
+ * UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
77
+ * text the generators emit and undercount otherwise; the platform's own
78
+ * refusal still backstops the exotic case, because this check exists to name
79
+ * members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
80
+ * then, sorted so the biggest lever is first.
81
+ */
82
+ export declare function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>): void;
83
+ /**
84
+ * Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
85
+ * never reclaimed, so crossing it is unrecoverable for the object — which is
86
+ * why the ledger below counts consumption durably instead of leaving the
87
+ * bound as prose the slot book merely respects.
88
+ */
89
+ export declare const FACET_ID_LIFETIME_BUDGET = 65536;
90
+ /** Where the ledger persists the count of facet names ever minted. */
91
+ export declare const FACET_NAME_HIGH_WATER_KEY = "fabric_facet_name_high_water";
92
+ /** The slice of storage the facet-name ledger persists through. */
93
+ interface FacetNameLedgerStorage {
94
+ storage: {
95
+ get(key: string): Promise<unknown> | unknown;
96
+ put(key: string, value: unknown): Promise<void>;
97
+ };
98
+ }
99
+ /**
100
+ * Advance the durable ledger to this incarnation's name count, if it is a new
101
+ * lifetime high. Chained behind adoption so the comparison is always against
102
+ * the real persisted value; a failed write leaves the old link's count and the
103
+ * next mint tries again — the ledger may transiently undercount, never over.
104
+ */
105
+ export declare function recordFacetNameMinted(ctx: FacetNameLedgerStorage, count: number): void;
106
+ /** The best count available without awaiting storage: minted or adopted. */
107
+ export declare function facetNameCount(ctx: FacetNameLedgerStorage): number;
108
+ /** The count with adoption awaited, for a first failure on a fresh boot. */
109
+ export declare function facetNameCountDurable(ctx: FacetNameLedgerStorage): Promise<number>;
110
+ /**
111
+ * The lifetime facet-ID ledger: how many facet names this fabric has ever
112
+ * minted on the Durable Object, against the 65,536 the platform will ever
113
+ * grant it. `consumed` only ever counts FIRST uses — a reused name, in this
114
+ * incarnation or any earlier one, cost no new ID, which is the slot book's
115
+ * whole reason to exist. Surfaced so an operator can see proximity to a wall
116
+ * whose crossing is unrecoverable, instead of discovering it from the
117
+ * platform's opaque failure.
118
+ */
119
+ export declare function facetIdBudget(ctx: FacetNameLedgerStorage): Promise<{
120
+ consumed: number;
121
+ budget: number;
122
+ }>;
123
+ /**
124
+ * Name the facet-ID budget on a creation failure at the wall; below it, hand
125
+ * the error back untouched. Exhaustion is the one failure here the platform
126
+ * reports opaquely AND that no teardown, retry or reset can undo, so the
127
+ * ledger — the only witness to the real cause — does the naming. Not a
128
+ * threshold: the comparison is against the budget itself.
129
+ */
130
+ export declare function withFacetBudgetNamed(consumed: number, error: unknown): unknown;
131
+ export {};
132
+ //# sourceMappingURL=budgets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budgets.d.ts","sourceRoot":"","sources":["../src/budgets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAwBH,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,IAAI,CAUxD;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC9C,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB,CAOA;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,CAW7E;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,WAAa,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAoBrF;AAuBD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,sEAAsE;AACtE,eAAO,MAAM,yBAAyB,iCAAiC,CAAC;AAExE,mEAAmE;AACnE,UAAU,sBAAsB;IAC9B,OAAO,EAAE;QACP,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;QAC7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACjD,CAAC;CACH;AAqCD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAatF;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,GAAG,EAAE,sBAAsB,GAAG,MAAM,CAGlE;AAED,4EAA4E;AAC5E,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAIxF;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,sBAAsB,GAC1B,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAK/C;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAU9E"}