@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.
- package/README.md +84 -55
- package/dist/bindings.js +5 -5
- package/dist/budgets.d.ts +132 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +248 -0
- package/dist/composition.d.ts +87 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +76 -0
- package/dist/connections.d.ts +81 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +114 -0
- package/dist/derived.d.ts +65 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +95 -0
- package/dist/do-calls.d.ts +94 -0
- package/dist/do-calls.d.ts.map +1 -0
- package/dist/do-calls.js +111 -0
- package/dist/facet-pool.d.ts +90 -0
- package/dist/facet-pool.d.ts.map +1 -0
- package/dist/facet-pool.js +113 -0
- package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
- package/dist/fanout.d.ts.map +1 -0
- package/dist/{fanout-pool.js → fanout.js} +20 -20
- package/dist/{launch-journal.d.ts → fenced-work.d.ts} +25 -13
- package/dist/fenced-work.d.ts.map +1 -0
- package/dist/{launch-journal.js → fenced-work.js} +47 -13
- package/dist/generation.d.ts +69 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +118 -0
- package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
- package/dist/image-store.d.ts.map +1 -0
- package/dist/{facet-image-store.js → image-store.js} +4 -4
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -8
- package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
- package/dist/isolate-pool.d.ts.map +1 -0
- package/dist/{loader-pool.js → isolate-pool.js} +20 -20
- package/dist/journal.d.ts +111 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +177 -0
- package/dist/outbox.d.ts +249 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/process-fabric.d.ts +2 -14
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +6 -15
- package/dist/process-host.d.ts +1 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +10 -9
- package/dist/sealed.d.ts +78 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +145 -0
- package/dist/timers.d.ts +138 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +231 -0
- package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +19 -21
- package/dist/turn-budget.d.ts.map +1 -0
- package/dist/{launch-pacer.js → turn-budget.js} +22 -11
- package/dist/workerd-facet-host.d.ts +28 -67
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +49 -171
- package/examples/agent-core-adapter.ts +191 -0
- package/package.json +4 -2
- package/src/bindings.ts +6 -6
- package/src/budgets.ts +308 -0
- package/src/composition.ts +127 -0
- package/src/connections.ts +140 -0
- package/src/derived.ts +135 -0
- package/src/do-calls.ts +156 -0
- package/src/facet-pool.ts +157 -0
- package/src/{fanout-pool.ts → fanout.ts} +35 -35
- package/src/{launch-journal.ts → fenced-work.ts} +58 -22
- package/src/generation.ts +144 -0
- package/src/{facet-image-store.ts → image-store.ts} +9 -9
- package/src/index.ts +16 -8
- package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
- package/src/journal.ts +242 -0
- package/src/node-async-hooks.d.ts +14 -0
- package/src/outbox.ts +520 -0
- package/src/process-fabric.ts +6 -33
- package/src/process-host.ts +10 -15
- package/src/sealed.ts +150 -0
- package/src/timers.ts +294 -0
- package/src/{launch-pacer.ts → turn-budget.ts} +30 -24
- package/src/workerd-facet-host.ts +67 -193
- package/dist/alarms.d.ts +0 -134
- package/dist/alarms.d.ts.map +0 -1
- package/dist/alarms.js +0 -214
- package/dist/ctx-exports.d.ts +0 -47
- package/dist/ctx-exports.d.ts.map +0 -1
- package/dist/ctx-exports.js +0 -54
- package/dist/facet-image-store.d.ts.map +0 -1
- package/dist/fanout-pool.d.ts.map +0 -1
- package/dist/launch-journal.d.ts.map +0 -1
- package/dist/launch-pacer.d.ts.map +0 -1
- package/dist/loader-ledger.d.ts +0 -57
- package/dist/loader-ledger.d.ts.map +0 -1
- package/dist/loader-ledger.js +0 -91
- package/dist/loader-pool.d.ts.map +0 -1
- package/src/alarms.ts +0 -275
- package/src/ctx-exports.ts +0 -77
- 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/
|
|
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
|
|
44
|
+
An embedder states its composition once, in its composition root:
|
|
37
45
|
|
|
38
46
|
```ts
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
// Only if you use 'staged' boot specs; 'code' boots need no assembler.
|
|
47
|
-
|
|
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 {
|
|
81
|
+
import { timers } from '@nimbus-sh/fabric';
|
|
59
82
|
|
|
60
83
|
export class MySession extends DurableObject {
|
|
61
|
-
|
|
84
|
+
_timerChain?: Promise<unknown>; // serializes the map's read-modify-write
|
|
62
85
|
|
|
63
86
|
async fetch(request: Request): Promise<Response> {
|
|
64
|
-
await
|
|
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
|
|
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
|
-
`
|
|
80
|
-
at the minimum across all of them. `
|
|
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 {
|
|
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
|
|
106
|
-
// this.
|
|
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 {
|
|
147
|
+
import { FencedWork, type FencedWorkRecord } from '@nimbus-sh/fabric';
|
|
128
148
|
|
|
129
|
-
interface MyLaunch extends
|
|
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
|
|
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 (`
|
|
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 {
|
|
196
|
+
import { TurnBudget, PacedWork, onColdStart, timers } from '@nimbus-sh/fabric';
|
|
177
197
|
|
|
178
|
-
const pump = new
|
|
179
|
-
requestTurn: () => { void
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
|
219
|
+
## Running programs: the isolate pool
|
|
199
220
|
|
|
200
|
-
`
|
|
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 {
|
|
227
|
+
import { IsolatePool } from '@nimbus-sh/fabric';
|
|
207
228
|
|
|
208
|
-
const pool = new
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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. `
|
|
260
|
-
way such a process comes into existence; `ProcessFabric` is the
|
|
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. `
|
|
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
|
-
`
|
|
376
|
+
`ImageStore` materializes generated boot images into a content-addressed
|
|
355
377
|
store (`var/lib/nimbus/facet-images/<sha256>.js`) through a small
|
|
356
|
-
`
|
|
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 `
|
|
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 `
|
|
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 | `
|
|
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 — `
|
|
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
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
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/
|
|
28
|
-
import { supervisorEntrypoint, supervisorEntrypointName } from './
|
|
29
|
-
import {
|
|
30
|
-
import { assertModuleMapWithinCodeLimit } from './
|
|
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
|
|
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"}
|