@nimbus-sh/fabric 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -4,44 +4,35 @@
4
4
  > cloud OS. This README is edited and maintained with Claude (AI) and
5
5
  > presented as-is.
6
6
 
7
- The Cloudflare-specific half of Nimbus: the machinery for running real
8
- programs on Durable Objects, DO facets, and the Worker Loader. Where
9
- [`@nimbus-sh/core`](https://www.npmjs.com/package/@nimbus-sh/core) is the
10
- backend-agnostic OS (filesystem, shell, process contracts), this package is
11
- what that OS stands on when the host is Cloudflare — and it never imports the
12
- OS's policy, only its shared primitives.
13
-
14
- I extracted it because almost none of it is specific to Nimbus. Anyone who
15
- hosts long-lived processes on Durable Objects meets the same platform
16
- behaviors we did: `await put()` resolving before durability, one alarm per
17
- object, a 65,536-facet lifetime budget, a frozen in-DO clock, RPC stubs that
18
- die with their request context. This package is the machinery we built against
19
- those behaviors, with the measured numbers that justified each mechanism
20
- carried in the doc comments — they are the design record, and they travel with
21
- the code on purpose.
22
-
23
- Everything below was measured on deployed production workerd, not on
24
- `wrangler dev` and not inferred from types, between June and August 2026.
25
- Where a specific date matters it is given.
26
-
27
- ## Importing it
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
-
37
- The root export pulls `cloudflare:workers`, so `import ... from
38
- '@nimbus-sh/fabric'` resolves only inside a Worker. Outside workerd (unit
39
- tests, tooling) import the subpath modules directly —
40
- `@nimbus-sh/fabric/timers.js`, `@nimbus-sh/fabric/fenced-work.js`, and so
41
- on. Most of the package is structurally typed against plain objects precisely
42
- so it can be tested in bun or node.
43
-
44
- An embedder states its composition once, in its composition root:
7
+ Run long-lived programs on Cloudflare Durable Objects.
8
+
9
+ A Durable Object gives you one alarm, one 128 MiB isolate, a 30-second CPU
10
+ turn, and storage that can reset under you. This package turns those into
11
+ things you can build on: many timers on the one alarm, work that survives a
12
+ reset, CPU work that spans turns, and real processes in their own isolates.
13
+
14
+ Use it if you host something that outlives a request. A dev server, a build,
15
+ an agent, a terminal session.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @nimbus-sh/fabric
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ Set `compatibility_flags: ["nodejs_compat"]` in your Worker. The timer
26
+ dispatcher needs `AsyncLocalStorage`, which workerd ships only under that
27
+ flag. Without it the module fails to load at deploy time.
28
+
29
+ Import the root inside a Worker. Outside workerd, import subpaths such as
30
+ `@nimbus-sh/fabric/timers.js`, which are typed against plain objects and run
31
+ in bun or node.
32
+
33
+ ## Setup
34
+
35
+ Declare your composition once, in your Worker's entry module.
45
36
 
46
37
  ```ts
47
38
  import { composeFabric, adoptCtxExports } from '@nimbus-sh/fabric';
@@ -60,21 +51,12 @@ composeFabric({
60
51
  adoptCtxExports(ctx.exports);
61
52
  ```
62
53
 
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.
54
+ Both calls take the first value they are given.
72
55
 
73
- ## One alarm, many reasons
56
+ ## Timers
74
57
 
75
- A Durable Object has ONE alarm, and a second `setAlarm()` silently overwrites
76
- the first. Every alarm-driven subsystem therefore coordinates through a single
77
- reason→deadline map in storage, with one dispatcher:
58
+ A Durable Object has one alarm, and a second `setAlarm()` overwrites the
59
+ first. Route every timer through one dispatcher instead.
78
60
 
79
61
  ```ts
80
62
  import { DurableObject } from 'cloudflare:workers';
@@ -99,23 +81,16 @@ export class MySession extends DurableObject {
99
81
  }
100
82
  ```
101
83
 
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
104
- before running any handler (so a handler that re-schedules itself is not
105
- re-fired in the same dispatch), silently drops unknown reasons (a rollback
106
- from a deploy that added reasons must not wedge the alarm), and when no
107
- reasons remain it deletes the map and does not re-arm — which is what lets the
108
- object hibernate.
84
+ `schedule` stores a deadline per reason and arms the alarm at the earliest
85
+ one. `dispatch` runs the reasons that are due, ignores reasons it does not
86
+ know, and stops re-arming when none are left, which lets the object
87
+ hibernate. A handler re-arms itself by returning `{ rearmAt }`.
109
88
 
110
- ## Knowing which incarnation you are
89
+ ## Generations and reset detection
111
90
 
112
- Workerd recycles isolates freely: cold starts, hibernation wakes, and resets
113
- all hand you a fresh module scope over the same storage. The isolate
114
- generation is a persisted counter that increments once per fresh isolate, and
115
- process IDs derive from it (`PID_GEN_STRIDE` = 1,000,000 in core's process
116
- table), which yields the one reset predicate everything else builds on: **a
117
- pid at or below the current generation's base was allocated by a previous
118
- incarnation.**
91
+ Workerd hands you a fresh isolate on cold starts, hibernation wakes, and
92
+ resets. The generation counter tells you which incarnation you are in, and
93
+ process IDs derive from it.
119
94
 
120
95
  ```ts
121
96
  import { adoptGeneration, generation } from '@nimbus-sh/fabric';
@@ -128,20 +103,18 @@ export class MySession extends DurableObject {
128
103
  }
129
104
  ```
130
105
 
131
- The ordering inside is deliberate: adopt the persisted value first, bump only
132
- after the `put` resolves. An unpersisted bump would be re-read by the next
133
- boot and re-issued — two instances sharing one generation is exactly the pid
134
- aliasing the counter exists to prevent. Note that `await put()` returning is
135
- not durability; the output gate is what keeps a pid from generation N from
136
- escaping before N is on disk.
106
+ This gives you one reliable test for stale state: **an ID at or below the
107
+ current generation's base came from a previous incarnation.**
137
108
 
138
- ## The launch journal: surviving resets
109
+ Adopt the persisted value before bumping it. `await put()` returning is not
110
+ durability, so a bump that has not landed can be re-issued to the next boot,
111
+ and two instances would share a generation.
139
112
 
140
- The platform resets a Durable Object over what one turn has outstanding in
141
- storage, and the reset destroys every write that turn had in flight. A
142
- long-running launch holds everything in memory, so the process it is building
143
- dies silently with the instance. The journal is what a later instance reads to
144
- know that happened:
113
+ ## Fenced work
114
+
115
+ A reset destroys whatever the current turn had in flight, including a
116
+ half-built process. Journal the work first, and a later instance can finish
117
+ it.
145
118
 
146
119
  ```ts
147
120
  import { FencedWork, type FencedWorkRecord } from '@nimbus-sh/fabric';
@@ -164,33 +137,18 @@ await journal.release(pid);
164
137
  await journal.recoverInterrupted();
165
138
  ```
166
139
 
167
- Two details here cost us real incidents before they were mechanisms:
168
-
169
- - **`put` then `sync()`.** `await storage.put()` resolves before durability.
170
- Measured live: a launch killed in its first chunks left NO row for the
171
- replacement instance to find, which is how the recovery this feeds sat inert
172
- while its own test stayed green. `sync()` is the storage layer's durability
173
- barrier; the journal writes through it on the way in and on the way out
174
- (delete-then-sync, so a reset moments after release cannot resurrect a
175
- process the user watched end).
176
- - **The row lives for the process's lifetime, not the launch's.** Measured on
177
- staging, 2026-08-13: every observed reset struck seconds AFTER the launch
178
- settled. A launch-scoped row would already have been deleted when recovery
179
- went looking.
180
-
181
- Recovery applies the generation predicate (`pid <= generationBase()`), deletes
182
- each stale row, and re-drives once per record (`FENCED_WORK_MAX_ATTEMPT` =
183
- 1) — a reset that recurs is not the transient kind.
184
-
185
- ## Pacing big work across turns
186
-
187
- One DO turn has a CPU budget of about 30 s (we were killed with `exceededCpu`
188
- at 31.8 s and 32.5 s), and yielding inside an invocation buys nothing — CPU
189
- accrues to the invocation, and only genuinely re-entering the object resets
190
- it. Worse, a long turn pins the actor's only thread, so the terminal WebSocket
191
- dies even when the work succeeds. And progress cannot be measured in
192
- milliseconds, because the in-DO clock does not advance without I/O (0 ms
193
- across 200,000 consecutive reads). So the pacer accounts **bytes**:
140
+ Write the row before the work starts and release it when the process ends,
141
+ not when the launch ends. Resets usually arrive after a launch settles, so a
142
+ launch-scoped row is already gone when recovery looks for it.
143
+
144
+ The journal writes through `ctx.storage.sync()`, because `await put()`
145
+ resolves before the write is durable. Recovery re-drives each stale row once.
146
+
147
+ ## Turn pacing
148
+
149
+ One turn gets about 30 seconds of CPU. Yielding inside a turn does not help,
150
+ because CPU accrues to the invocation. Only re-entering the object resets the
151
+ budget, and a long turn also blocks the actor's thread and drops WebSockets.
194
152
 
195
153
  ```ts
196
154
  import { TurnBudget, PacedWork, onColdStart, timers } from '@nimbus-sh/fabric';
@@ -208,20 +166,13 @@ await budget.spend(bytesJustProcessed); // suspends every TURN_CHUNK_MAX_BYTES
208
166
  'launch-turn': () => pump.pump(),
209
167
  ```
210
168
 
211
- The pump awaits each resumed chunk, so the invocation that granted the turn is
212
- the invocation that pays for the work — nothing runs detached in a handler's
213
- microtask drain. A past-deadline alarm is delivered as soon as the object is
214
- free, which makes `schedule(..., Date.now())` a genuine "re-enter now"
215
- primitive. Without an alarm-capable host the pump degrades to a same-context
216
- timer: the single-turn behaviour this path always had, minus the
217
- responsiveness.
169
+ Account in bytes, not milliseconds: the in-DO clock does not advance without
170
+ I/O. `spend()` suspends every 2 MB and resumes on a fresh turn. Scheduling a
171
+ past deadline re-enters the object immediately.
218
172
 
219
- ## Running programs: the isolate pool
173
+ ## Isolate pool
220
174
 
221
- `IsolatePool` runs plain functions in warm dynamic-worker isolates over
222
- `env.LOADER`. Functions are serialized with `fn.toString()`, so they must be
223
- self-contained: no captured variables, no `this` (rejected at dispatch), and
224
- their last parameter receives the forwarded bindings.
175
+ Run plain functions in warm dynamic-worker isolates.
225
176
 
226
177
  ```ts
227
178
  import { IsolatePool } from '@nimbus-sh/fabric';
@@ -241,46 +192,32 @@ try {
241
192
  }
242
193
  ```
243
194
 
244
- Slots are stable (`slot = index % concurrency`) so a batch of 67 tarball
245
- extractions reuses 4 warm isolates instead of paying 67 cold starts. Wasm
246
- rides the loader's modules map as `{ wasm: ArrayBuffer }` — the only path that
247
- works, since request-time `WebAssembly.compile` is CSP-blocked, RPC of a
248
- compiled `Module` is refused by structured clone, and inlining bytes into the
249
- module source OOMs the supervisor.
250
-
251
- The cache key folds the function hash, the preamble hash, a wasm fingerprint,
252
- and **the first 12 characters of the owning DO's id**. That last term is a
253
- security lesson, not an optimization: without it, session B's pool reused
254
- session A's warm isolate — which still carried A's `env.SUPERVISOR` binding —
255
- and B's writes landed silently in A's filesystem while B's install reported
256
- success. Warm isolates are scoped to one session unless a pool explicitly opts
257
- into `cacheScope: 'global'`, which is reserved for stateless compute pools
258
- that take no supervisor binding and retain no user state.
259
-
260
- `Fanout` is the tier above: a single DO method can drive at most 4
261
- concurrent Worker Loader fetches, so batches of fewer than 5 tasks run in the
262
- coordinator through an `IsolatePool` and wider batches shard deterministically
263
- across sibling DOs (up to 32, dispatched in phases of 4 to bound simultaneous
264
- cold starts). Transient peer resets retry on a 250/750/1500 ms schedule; an
265
- overloaded peer gets the 1/3/6 s one.
266
-
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
269
- gotten — each permanently holds one of the ~5–6 dynamic-worker slots, because
270
- a keyed `loader.get(id)` is never released — plus live and peak concurrent
271
- Loader fetches, read via `loaderLedgerStats(ctx)`. A "Too many concurrent
272
- dynamic workers" refusal classifies as `dynamic_worker_cap` and is annotated
273
- with the ids actually holding slots. Measurement and honest failure naming
274
- only — no admission control, because the cap is the platform's and
275
- approximate, and a gate on an approximate number would refuse work the
276
- platform would have run.
277
-
278
- ## Running processes: the resident fabric
279
-
280
- A resident process — a dev server, a socket runner, an attached TUI — is a DO
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:
195
+ Functions are serialized with `fn.toString()`, so they must be
196
+ self-contained: no captured variables, no `this`. Bindings arrive as the last
197
+ parameter. Slots are stable, so a batch of 67 tasks reuses 4 warm isolates
198
+ instead of paying 67 cold starts.
199
+
200
+ Ship wasm through the loader's modules map as `{ wasm: ArrayBuffer }`. It is
201
+ the only path that works. Request-time `WebAssembly.compile` is blocked by
202
+ CSP, structured clone refuses a compiled `Module`, and inlining bytes into
203
+ the source exhausts the supervisor's memory.
204
+
205
+ Warm isolates are scoped to one session. A pool may opt into
206
+ `cacheScope: 'global'` only if it takes no supervisor binding and keeps no
207
+ user state.
208
+
209
+ `Fanout` handles wider batches. One DO method can drive at most 4 concurrent
210
+ loader fetches, so batches under 5 run in the coordinator and larger ones
211
+ shard across up to 32 sibling objects, 4 at a time.
212
+
213
+ Each keyed `loader.get(id)` permanently holds one of roughly 5–6
214
+ dynamic-worker slots. `loaderLedgerStats(ctx)` reports what you have
215
+ consumed, and a cap refusal names the IDs holding slots.
216
+
217
+ ## Process fabric
218
+
219
+ A resident process is a Durable Object facet running a class from a dynamic
220
+ worker.
284
221
 
285
222
  ```ts
286
223
  import { ProcessFabric, createProcessHost } from '@nimbus-sh/fabric';
@@ -305,130 +242,81 @@ handle.kill();
305
242
  await handle.done;
306
243
  ```
307
244
 
308
- The dynamic worker must export a Durable Object class named `NimbusProcess`
309
- (`RESIDENT_PROCESS_CLASS`) with `startProcess(args)` and
310
- `handleHttpRequest(request)`. Its `startProcess` declares one of two contracts:
311
- `'lifetime'` (the call is held open for the process's whole life and settles at
312
- exit — an attached TUI) or `'boot'` (the call returns a payload once the
313
- process is up and the facet stays resident — a server).
314
-
315
- Pieces worth knowing about, each earned the hard way:
316
-
317
- - **The slot book.** A Durable Object admits 65,536 facets over its LIFETIME —
318
- the IDs are append-only and never reclaimed, so the bound is on facets ever
319
- created. Naming facets after pids burned one ID per spawn with no way back.
320
- Reusing a NAME costs no new ID, so facet names come from a per-DO free list
321
- (`proc-slot-<n>`, lowest reused first), and a slot is released only after
322
- `facets.abort` + `facets.delete` — a slot handed out during teardown would
323
- put two processes on one name. The names the book does mint are counted
324
- durably — `facetIdBudget(ctx)` reports `{ consumed, budget }`, first uses
325
- only, adopted across resets — and a creation failure with the budget
326
- consumed names the budget and the count instead of repeating the platform's
327
- opaque message. Exhaustion is permanent for the object, so it is the one
328
- failure worth naming precisely.
329
- - **At-most-once start.** The facet's start callback re-running would
330
- re-execute the user's program, answering a request from a process the user
331
- never started. Both re-entry cases (released, lost) throw instead.
332
- - **Boot specs name large members by VFS path.** A whole structured-clone RPC
333
- value caps at 32 MiB, and one node snapshot alone serialized to 44,252,709
334
- bytes. `vfsWasmModules` and `vfsTextModules` send paths; the hosting actor
335
- reads the bytes through the `ResidentDiskReader` it was given, inside the
336
- loader's cache-miss callback, so they exist only for the duration of the
337
- load. Text images are verified against the digest their own path claims —
338
- a truncated image would otherwise boot as silently-wrong code.
339
- - **The substrate is one deployment-wide value** (`createProcessHost`'s mode,
340
- `'facet'` or `'peer'`), never per-spawn. No program name, mode, or payload
341
- size reaches the choice.
342
-
343
- What each substrate costs, measured on the production shape:
245
+ The worker exports a Durable Object class named `NimbusProcess` with
246
+ `startProcess(args)` and `handleHttpRequest(request)`. `startProcess`
247
+ declares one of two contracts. Use `'lifetime'` when the call should stay
248
+ open for the process's life, as an attached terminal does. Use `'boot'` when
249
+ it should return once the process is up and leave the facet resident, as a
250
+ server does.
251
+
252
+ **Facet names come from a free list.** A Durable Object allows 65,536 facets
253
+ over its lifetime, and IDs are never reclaimed, so the limit counts facets
254
+ ever created. Reusing a name costs no new ID. `facetIdBudget(ctx)` reports
255
+ `{ consumed, budget }`.
256
+
257
+ **Large boot members travel as VFS paths.** A structured-clone RPC value caps
258
+ at 32 MiB. Use `vfsWasmModules` and `vfsTextModules`; the host reads the
259
+ bytes during the load and verifies each image against the digest its path
260
+ claims.
261
+
262
+ **The substrate is one deployment-wide setting**, `'facet'` or `'peer'`,
263
+ never a per-spawn choice.
344
264
 
345
265
  | | spawn | memory | CPU | SQLite |
346
266
  |---|---|---|---|---|
347
- | facet | 8–16 ms | independent (~208 MiB each) | SHARED | own |
267
+ | facet | 8–16 ms | independent (~208 MiB each) | shared | own |
348
268
  | peer | 242–359 ms | independent | independent | own |
349
269
 
350
- Facet CPU is shared because facets are separate isolates inside one actor
351
- thread: awaiting I/O yields it completely, but a deliberate 9,956 ms CPU burn
352
- stalled a sibling for 9,966 ms. A peer pays roughly 20× the spawn cost to buy
353
- that back, and verifies its placement rather than assuming it — a module-scope
354
- UUID token is compared across the hop, up to 4 sibling names tried, because a
355
- peer that co-located shares the CPU it was chosen to escape.
356
-
357
- The substrates also differ in image delivery, stated in the
358
- `ProcessImageDelivery` contract rather than smoothed over: a facet shares its
359
- session's Durable Object, so the session's store is reachable by
360
- copy-on-write (`ctx.facets.clone`: 18–31 ms for a 45.73 MB corpus, 34–54 ms
361
- for 1 GB — flat, because nothing is copied) but also shares the session's
362
- ~10 GiB storage budget. A peer brings its own budget and no reflink: clone is
363
- same-object-only and workerd exposes no `VACUUM INTO`, `ATTACH`, or
364
- `sqlite3_backup` across objects. And a clone hazard we measured rather than
365
- assumed: ANY unresolvable `src` — a typo, a name not created yet — silently
366
- EMPTIES the destination and reports success. `cloneStorage` is the one
367
- way the fabric calls clone: it takes the caller's `populated(name)` probe and
368
- asserts it positively on the source before the clone and on the destination
369
- after, so a typo is refused before the platform call and a wiped destination
370
- is never reported as success. An emptied facet still shows a 4,096-byte
371
- database — one page — which is why the probe must find the caller's own data,
372
- not a non-zero size.
373
-
374
- ## The image store
375
-
376
- `ImageStore` materializes generated boot images into a content-addressed
377
- store (`var/lib/nimbus/facet-images/<sha256>.js`) through a small
378
- `ImageBlobStore` port — the embedder owns the disk, the store owns the
379
- protocol:
380
-
381
- - **Root before the first byte.** The whole root set is registered
382
- synchronously before any byte lands, so the sweep can never observe a
383
- written-but-unclaimed image, however many turns the write spans.
384
- - **Sliced writes.** One transaction takes `FACET_IMAGE_WRITE_SLICE_BYTES`
385
- (a whole number of VFS chunks under the 1 MiB transaction bound — a slice
386
- ending mid-chunk forces a read-back, and an oversize write falls back to
387
- copy-on-write, which is quadratic). A 22.9 MB map written in one turn took
388
- the session down with it about 25% of the time; sliced and paced, it
389
- doesn't.
390
- - **Size equality is completeness.** A write only ever grows the file from
391
- offset zero, so an interrupted write leaves a strictly shorter file; the
392
- reader verifies the digest before the loader sees the bytes.
393
- - **The sweep roots off the process table.** An image is live for exactly as
394
- long as a process boots from it. No TTL, no eviction heuristic; after a
395
- reset the table is empty and every orphan goes.
396
-
397
- ## Binding shims for inner workers
270
+ Facets are separate isolates in one actor thread, so they scale memory but
271
+ not CPU. Awaiting I/O yields the thread; a 9,956 ms CPU burn stalled a
272
+ sibling for 9,966 ms. A peer costs about 20× the spawn time and buys real CPU
273
+ isolation, and it verifies it did not co-locate.
274
+
275
+ A facet shares its session's object, so it can copy the session's store by
276
+ reflink (`ctx.facets.clone`, 18–31 ms for 45.73 MB, 34–54 ms for 1 GB) and
277
+ shares the session's ~10 GiB budget. A peer brings its own storage and cannot
278
+ reflink.
279
+
280
+ Call clone through `cloneStorage`. An unresolvable `src` empties the
281
+ destination and reports success, so the wrapper checks the source before the
282
+ call and the destination after. An emptied facet still shows a 4,096-byte
283
+ database, so check for your own data rather than a non-zero size.
284
+
285
+ ## Image store
286
+
287
+ `ImageStore` writes generated boot images into a content-addressed store at
288
+ `var/lib/nimbus/facet-images/<sha256>.js`, through an `ImageBlobStore` port
289
+ you implement.
290
+
291
+ It registers the whole root set before writing any byte, so a sweep never
292
+ sees an unclaimed image. Writes are sliced to stay inside the 1 MiB
293
+ transaction bound; a 22.9 MB map written in one turn reset the session about
294
+ a quarter of the time. Because a write only grows the file from offset zero,
295
+ matching size means a complete write, and the reader verifies the digest
296
+ before the loader sees the bytes. Images stay live while a process boots from
297
+ them, rooted in the process table, with no TTL.
298
+
299
+ ## Binding shims
398
300
 
399
301
  `NimbusLoaderRPC`, `NimbusLoadedWorker`, `NimbusLoadedEntrypoint`,
400
302
  `NimbusAssetsRPC`, `NimbusDurableObjectNamespace`, and `NimbusDOStub` give a
401
- dynamically-loaded inner Worker working `env` bindings. They exist because of
402
- three platform behaviors, each of which cost a debugging session:
403
-
404
- - **`WorkerStub` does not serialize**, so each hop a caller makes
405
- (`load → getEntrypoint → fetch`) is its own `WorkerEntrypoint` class.
406
- - **Stubs are I/O objects bound to the request that minted them** ("Cannot
407
- perform I/O on behalf of a different request"), so the shims store CODE,
408
- never stubs, and re-resolve through `LOADER.get(id, cb)` in the current
409
- context — workerd caches by id, so repeated loads are close to free. The
410
- code map is a hard-capped LRU of 32 entries: `wrangler dev`'s
411
- rebuild-on-save loop once grew it without bound to a 128 MiB isolate crash.
412
- - **An RPC stub's method is a wildcard property**: `method.call(ep, request)`
413
- builds the pipelined path `method.call` and serializes `ep` as an argument,
414
- which workerd refuses ("Entrypoints to dynamically-loaded workers cannot be
415
- transferred"). Calls must be written `ep.method(request)`.
416
-
417
- Nesting is capped at depth 4 (`NIMBUS_INNER_LOADER_DEPTH` raises it) —
418
- Nimbus-in-Nimbus is fine, five levels is a runaway.
419
-
420
- ## The platform, measured
421
-
422
- These tables are the part of this package I most wanted to publish. They are
423
- enforced by the code above where code can enforce them; the rest is here so
424
- the next person does not have to measure them again. All figures are from
425
- production workerd, June–August 2026.
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.
303
+ dynamically-loaded inner Worker working `env` bindings.
304
+
305
+ Three platform rules shape them. A `WorkerStub` does not serialize, so each
306
+ hop is its own `WorkerEntrypoint` class. A stub belongs to the request that
307
+ minted it, so the shims store code rather than stubs and re-resolve through
308
+ `LOADER.get(id, cb)`; workerd caches by ID, and the code map is capped at 32
309
+ entries. An RPC method is a wildcard property, so call `ep.method(request)`
310
+ and never `method.call(ep, request)`, which workerd refuses.
311
+
312
+ Nesting is capped at depth 4. Raise it with `NIMBUS_INNER_LOADER_DEPTH`.
313
+
314
+ ## Measured platform limits
315
+
316
+ Figures below come from production workerd, June to August 2026. The code
317
+ above enforces them where it can. [PLATFORM.md](PLATFORM.md) is the full
318
+ catalog: every entry dated, graded by evidence, and marked as enforced here
319
+ or left to you.
432
320
 
433
321
  ### Durable Object storage
434
322
 
@@ -498,18 +386,16 @@ handle it yourself.
498
386
  | `exceededMemory` and `exceededCpu` are both uncatchable inside the dying isolate, observable only across an RPC boundary | absence of the error is not evidence of its absence |
499
387
  | `process.memoryUsage()` returns 0 in DO context | any heap estimate is a lower bound; say so |
500
388
 
501
- ## Relation to the other packages
389
+ ## Related packages
502
390
 
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.
391
+ [`@nimbus-sh/core`](https://www.npmjs.com/package/@nimbus-sh/core) is the
392
+ backend-agnostic OS: filesystem, shell, process contracts.
393
+ [`@nimbus-sh/platform`](https://www.npmjs.com/package/@nimbus-sh/platform)
394
+ holds the limits tables, the error taxonomy, RPC disposal, and the supervisor
395
+ budget machinery.
507
396
  [`@nimbus-sh/worker`](https://www.npmjs.com/package/@nimbus-sh/worker) is the
508
- canonical embedder: it supplies the seams above, the supervisor entrypoint,
509
- the session protocol, and everything user-facing. If you want the full hosted
510
- product shape, start from `npx create-nimbus-app`; if you are building your
511
- own thing on Durable Objects, this package and its doc comments are the part
512
- of Nimbus you can take without taking Nimbus.
397
+ reference embedder, with the supervisor entrypoint and the session protocol.
398
+ For the full hosted product, start from `npx create-nimbus-app`.
513
399
 
514
400
  ## License
515
401