@nimbus-sh/fabric 0.8.0 → 0.9.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.
@@ -133,6 +133,14 @@ export const ResidentCodeSpecSchema = z.object({
133
133
  * image store below and the spec names it.
134
134
  */
135
135
  vfsTextModules: z.record(z.string(), z.string()).optional(),
136
+ /**
137
+ * VFS paths of generated CommonJS PACKS (encodeCommonJsPack): each image
138
+ * carries many `{ cjs }` modules, and the map gains every one of them at
139
+ * load. A node process's module cells travel this way — one module per
140
+ * file, so the guest's registry compiles only what the program requires,
141
+ * and one image per launch, so the boot spec names a path and not thousands.
142
+ */
143
+ vfsCommonJsPacks: z.array(z.string()).optional(),
136
144
  /**
137
145
  * The isolate's exact `env`: one entry per binding the embedder minted,
138
146
  * carried by reference so loopback stubs survive untouched. Defined —
@@ -265,7 +273,7 @@ export async function residentLoaderConfig(
265
273
  spec: ResidentCodeSpec,
266
274
  disk: ResidentDiskReader,
267
275
  ): Promise<Record<string, unknown>> {
268
- const resolved: Record<string, string | { wasm: ArrayBuffer }> = {};
276
+ const resolved: Record<string, string | { wasm: ArrayBuffer } | { cjs: string }> = {};
269
277
  for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
270
278
  const bytes = await disk.readFile(path);
271
279
  // The read's own buffer when it fits exactly, and only otherwise a copy.
@@ -283,6 +291,9 @@ export async function residentLoaderConfig(
283
291
  for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
284
292
  resolved[moduleName] = await readFacetImage(disk, path);
285
293
  }
294
+ for (const path of spec.vfsCommonJsPacks ?? []) {
295
+ Object.assign(resolved, decodeCommonJsPack(await readFacetImage(disk, path)));
296
+ }
286
297
  return {
287
298
  compatibilityDate: spec.compatibilityDate,
288
299
  compatibilityFlags: spec.compatibilityFlags,
@@ -293,6 +304,40 @@ export async function residentLoaderConfig(
293
304
  };
294
305
  }
295
306
 
307
+ /**
308
+ * One image holding many `{ cjs }` modules: a JSON index of `[name, length]`
309
+ * rows, a newline, and the module texts back to back. Lengths are UTF-16 code
310
+ * units, the unit the decoded text is sliced in, so decoding copies nothing:
311
+ * each module is a slice of the one string read.
312
+ *
313
+ * Encoded as its parts, in order, never joined: the image store encodes them
314
+ * straight into the image's bytes, and a joined copy would be a second full
315
+ * copy of the program's code on the coordinator.
316
+ */
317
+ export function encodeCommonJsPack(modules: Record<string, string>): string[] {
318
+ const index: [string, number][] = [];
319
+ const parts: string[] = [''];
320
+ for (const [name, text] of Object.entries(modules)) {
321
+ index.push([name, text.length]);
322
+ parts.push(text);
323
+ }
324
+ parts[0] = JSON.stringify(index) + '\n';
325
+ return parts;
326
+ }
327
+
328
+ export function decodeCommonJsPack(pack: string): Record<string, { cjs: string }> {
329
+ const newline = pack.indexOf('\n');
330
+ const index = z.array(z.tuple([z.string(), z.number().int().nonnegative()])).parse(JSON.parse(pack.slice(0, newline)));
331
+ const modules: Record<string, { cjs: string }> = {};
332
+ let offset = newline + 1;
333
+ for (const [name, length] of index) {
334
+ modules[name] = { cjs: pack.slice(offset, offset + length) };
335
+ offset += length;
336
+ }
337
+ if (offset !== pack.length) throw new Error(`Nimbus: CommonJS pack holds ${pack.length - offset} bytes its index does not name`);
338
+ return modules;
339
+ }
340
+
296
341
  /**
297
342
  * Read one content-addressed facet image and verify it against the digest its
298
343
  * path claims. Content addressing is only a guarantee if the bytes are checked
@@ -428,17 +473,14 @@ export interface ProcessImageDelivery {
428
473
  * across one. A peer-hosted process can only ever receive an image
429
474
  * through `moduleCeilingBytes` below, or by streaming it.
430
475
  *
431
- * Reachable in PRODUCTION but not from a type checker or `wrangler dev`, and
432
- * the difference is worth stating precisely because inferring one from the
433
- * other is how a wrong claim gets written down. `@cloudflare/workers-types`
434
- * 4.20260605.1 declares `get`/`abort`/`delete` and no `clone`, and the pinned
435
- * workerd is 1.20260603.1 — but the deployed runtime is Cloudflare's, not the
436
- * one wrangler bundles, and there it is present and works: enumerating the
437
- * binding on a live Worker at this repo's own compatibility_date returns
438
- * `["abort","clone","constructor","delete","get"]`, and a clone into a
439
- * destination of a DIFFERENT class had all 500 seeded files readable from the
440
- * destination's CONSTRUCTOR. No compat-date gate. So calling it is a
441
- * lockfile-and-types problem, not a platform one.
476
+ * Present in production, and since the pins moved to
477
+ * `@cloudflare/workers-types` 5.20260928.1 and workerd 1.20260926.1 also in
478
+ * the type checker and `wrangler dev` (DurableObjectFacets.clone;
479
+ * src/workerd/api/actor-state.h). Before that the types declared no `clone`
480
+ * and the pinned workerd 1.20260603.1 lacked it, while on a live Worker the
481
+ * binding enumerated `["abort","clone","constructor","delete","get"]` and a
482
+ * clone into a destination of a DIFFERENT class had all 500 seeded files
483
+ * readable from the destination's CONSTRUCTOR. No compat-date gate.
442
484
  *
443
485
  * The hazard that comes with it, measured rather than assumed: ANY `src`
444
486
  * that does not resolve to a populated facet — a typo, a name not created
@@ -488,7 +530,7 @@ export interface OneShotCodeSpec {
488
530
  compatibilityDate: string;
489
531
  compatibilityFlags: string[];
490
532
  mainModule: string;
491
- modules: Record<string, string | { wasm: ArrayBuffer }>;
533
+ modules: Record<string, string | { wasm: ArrayBuffer } | { cjs: string }>;
492
534
  }
493
535
 
494
536
  /**
package/src/timers.ts CHANGED
@@ -58,9 +58,12 @@ export const TIMER_REASONS_KEY = 'w1_next_alarm_reasons';
58
58
  * The host instance carrying the per-instance timer chain. The field lives on
59
59
  * the embedder's DO instance so one chain serializes every timer-map
60
60
  * read-modify-write for that instance (see {@link Timers.schedule}).
61
+ * `_timerEpoch` counts {@link Timers.reset}s: a schedule or dispatch writes
62
+ * only while the epoch it was requested in is still current.
61
63
  */
62
64
  export interface TimerHost {
63
65
  _timerChain?: Promise<unknown>;
66
+ _timerEpoch?: number;
64
67
  }
65
68
 
66
69
  /**
@@ -159,6 +162,7 @@ export class Timers {
159
162
  arms.push({ reason, whenMs });
160
163
  return Promise.resolve(true);
161
164
  }
165
+ const epoch = host._timerEpoch ?? 0;
162
166
  // Serialize every read-modify-write of the reasons map through one
163
167
  // per-instance chain: two schedulers firing back-to-back from one activity
164
168
  // hook would otherwise interleave their get→put cycles and silently drop
@@ -170,15 +174,20 @@ export class Timers {
170
174
  const existing = (await ctx.storage.get(TIMER_REASONS_KEY)) as
171
175
  | Record<string, number>
172
176
  | undefined;
177
+ // Requested before a reset: it belongs to the timers the reset voided.
178
+ if ((host._timerEpoch ?? 0) !== epoch) return false;
173
179
  const map: Record<string, number> = { ...(existing || {}) };
174
180
  // Earliest-deadline-first: only update if new request is sooner or
175
181
  // this reason has no pending entry.
182
+ let written: Promise<unknown> | undefined;
176
183
  if (!(reason in map) || whenMs < map[reason]) {
177
184
  map[reason] = whenMs;
178
- await ctx.storage.put(TIMER_REASONS_KEY, map);
185
+ written = ctx.storage.put(TIMER_REASONS_KEY, map);
179
186
  }
180
- const earliest = Math.min(...Object.values(map));
181
- setAlarmFn.call(ctx.storage, earliest);
187
+ // Issued in the turn the epoch was checked in: a reset after this
188
+ // point wipes and disarms after these, never before.
189
+ setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
190
+ await written;
182
191
  return true;
183
192
  } catch (e) {
184
193
  console.warn('[nimbus/W1] timers.schedule threw:', errorText(e));
@@ -190,6 +199,18 @@ export class Timers {
190
199
  return chained;
191
200
  }
192
201
 
202
+ /**
203
+ * Void every timer of this instance: a schedule or dispatch already
204
+ * requested — still queued on the chain, or a dispatch whose handlers are
205
+ * running — writes no reason and arms no alarm from here on. For a
206
+ * deliberate end of the actor's state (a session destroy): call it before
207
+ * wiping storage and deleting the alarm, so nothing in flight writes the
208
+ * map back or re-arms after the wipe. Requests made after it proceed.
209
+ */
210
+ reset(): void {
211
+ this.host._timerEpoch = (this.host._timerEpoch ?? 0) + 1;
212
+ }
213
+
193
214
  /**
194
215
  * Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
195
216
  * with the embedder's handler map.
@@ -216,19 +237,28 @@ export class Timers {
216
237
  alarmInfo?: TimerAlarmInfo,
217
238
  ): Promise<void> {
218
239
  const { host, ctx } = this;
240
+ const epoch = host._timerEpoch ?? 0;
241
+ const current = (): boolean => (host._timerEpoch ?? 0) === epoch;
219
242
  // Same serialization as schedule: the dispatcher's read→handlers→write
220
243
  // cycle must not interleave with an activity-hook schedule.
221
244
  const chained = (host._timerChain ?? Promise.resolve()).then(
222
- () => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
223
- () => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
245
+ () => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
246
+ () => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
224
247
  );
225
248
  host._timerChain = chained;
226
249
  return chained;
227
250
  }
228
251
  }
229
252
 
253
+ /**
254
+ * One dispatch. `current` answers whether the epoch it was requested in still
255
+ * stands: once a reset voids it, no further handler runs and nothing is
256
+ * written or armed — the reset's caller is wiping this state, and a write
257
+ * after the wipe would outlive it.
258
+ */
230
259
  async function dispatchBody(
231
260
  ctx: TimerContext,
261
+ current: () => boolean,
232
262
  handlers: TimerHandlers,
233
263
  onLegacyAlarm?: () => void,
234
264
  alarmInfo?: TimerAlarmInfo,
@@ -242,6 +272,7 @@ async function dispatchBody(
242
272
  const existing = (await ctx?.storage?.get?.(TIMER_REASONS_KEY)) as
243
273
  | Record<string, number>
244
274
  | undefined;
275
+ if (!current()) return;
245
276
  const map: Record<string, number> = { ...(existing || {}) };
246
277
  const hadMap = Object.keys(map).length > 0;
247
278
  if (!hadMap) {
@@ -255,6 +286,7 @@ async function dispatchBody(
255
286
  if (when <= now) fired.push(reason);
256
287
  }
257
288
  for (const reason of fired) {
289
+ if (!current()) return;
258
290
  delete map[reason];
259
291
  const handler = handlers[reason];
260
292
  // Unknown reasons silently dropped (forward-compat).
@@ -269,20 +301,24 @@ async function dispatchBody(
269
301
  }
270
302
  }
271
303
  }
304
+ // A reset while the handlers ran: the map read above is gone, and
305
+ // writing it back — or arming for it — would revive what was ended.
306
+ if (!current()) return;
272
307
  // Fold the in-dispatch arms, earliest-deadline-first per reason.
273
308
  for (const arm of arms) {
274
309
  if (!(arm.reason in map) || arm.whenMs < map[arm.reason]) {
275
310
  map[arm.reason] = arm.whenMs;
276
311
  }
277
312
  }
278
- // Re-arm or clear.
313
+ // Re-arm or clear, issued in the turn the epoch was checked in: a reset
314
+ // after this point wipes and disarms after these, never before.
279
315
  const setAlarmFn = ctx?.storage?.setAlarm;
280
316
  if (Object.keys(map).length > 0) {
281
- await ctx.storage.put(TIMER_REASONS_KEY, map);
282
- const earliest = Math.min(...Object.values(map));
317
+ const written = ctx.storage.put(TIMER_REASONS_KEY, map);
283
318
  if (typeof setAlarmFn === 'function') {
284
- setAlarmFn.call(ctx.storage, earliest);
319
+ setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
285
320
  }
321
+ await written;
286
322
  } else if (hadMap) {
287
323
  try { await ctx.storage.delete(TIMER_REASONS_KEY); } catch {}
288
324
  // No remaining reasons → no setAlarm call → DO becomes
@@ -9,12 +9,18 @@ export interface TextModule { text: string }
9
9
  export interface DataModule { data: ArrayBuffer }
10
10
  export interface JsonModule { json: unknown }
11
11
  /**
12
- * A compiled WebAssembly module, importable from a facet by module name.
13
- * Nimbus ships these on every path — sql.js for node:sqlite, the interpreter
14
- * images for python and ruby — so the omission here was the type lagging the
15
- * API, not a kind the loader lacks.
12
+ * A WebAssembly module, importable from a facet by module name. Nimbus ships
13
+ * these on every path — sql.js for node:sqlite, the interpreter images for
14
+ * python and ruby — so the omission here was the type lagging the API, not a
15
+ * kind the loader lacks. The loader takes either the bytes, which it
16
+ * compiles, or a module the caller holds compiled already, whose compiled
17
+ * code the dynamic worker then shares (workerd src/workerd/api/
18
+ * worker-loader.c++, extractWasmModuleContent). A module handed over this
19
+ * way should be described with describeHostWasm (host-wasm.ts): the
20
+ * code-size budget and the loader cache key cannot read a Module's size or
21
+ * identity from JS.
16
22
  */
17
- export interface WasmModule { wasm: ArrayBuffer }
23
+ export interface WasmModule { wasm: ArrayBuffer | WebAssembly.Module }
18
24
 
19
25
  /** Plain string = type inferred from file extension (.js or .py). */
20
26
  export type ModuleContent =
@@ -28,7 +28,6 @@ import {
28
28
  facetNameCount,
29
29
  facetNameCountDurable,
30
30
  recordFacetNameMinted,
31
- recordLoaderId,
32
31
  withDynamicWorkerCapNamed,
33
32
  withFacetBudgetNamed,
34
33
  } from './budgets.js';
@@ -121,9 +120,10 @@ interface FacetContainer {
121
120
  abort(name: string, reason?: unknown): void;
122
121
  delete(name: string): void;
123
122
  /**
124
- * Present on deployed Cloudflare workerd, absent from the pinned
125
- * `@cloudflare/workers-types` and from local workerd ≤ 1.20260603.1 — see
126
- * {@link cloneStorage}, the one way the fabric calls it.
123
+ * Declared by `@cloudflare/workers-types` 5 and present in workerd
124
+ * ≥ 1.20260926.1 and in production; an embedder's older local workerd
125
+ * (≤ 1.20260603.1) lacks it — see {@link cloneStorage}, the one way the
126
+ * fabric calls it.
127
127
  */
128
128
  clone?(src: string, dst: string): void;
129
129
  }
@@ -212,7 +212,7 @@ export async function cloneStorage(
212
212
  if (typeof facets.clone !== 'function') {
213
213
  throw new Error(
214
214
  'Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
215
- + 'path needs deployed Cloudflare workerd (local workerd <= 1.20260603.1 lacks it)',
215
+ + 'path needs workerd 1.20260926.1 or later, or deployed Cloudflare workerd',
216
216
  );
217
217
  }
218
218
  const { src, dst } = clone;
@@ -471,10 +471,13 @@ function spawnResident(
471
471
  );
472
472
  }
473
473
  evaluated = true;
474
- return { class: residentProcessClass(ctx, env, disk, supervisor, params) };
474
+ return { class: residentProcessClass(env, disk, supervisor, params, loaderKey) };
475
475
  };
476
476
  const book = slotBook(ctx);
477
477
  const ledger = sessionLedger(ctx);
478
+ // A warm worker keeps the SUPERVISOR binding it was built with, and the
479
+ // loader outlives this instance.
480
+ const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
478
481
  let facet: ResidentFacetStub;
479
482
  try {
480
483
  // N18: the fill is admitted, and recorded under the facet's name, before
@@ -490,6 +493,12 @@ function spawnResident(
490
493
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
491
494
  }
492
495
  if (explicit) book.live.add(name);
496
+ // The facet's worker is one Dynamic Worker in flight for as long as the
497
+ // process is resident, not only while a call is open: its WebSockets and
498
+ // streamed responses outlive the calls the ledger could bracket, and a
499
+ // request can reach it at any moment. Held from here to `release`, so no
500
+ // fan-out spends the slot a running process needs.
501
+ const endResidency = beginLoaderFetch(ctx, loaderKey);
493
502
  facetOfPid(ctx).set(params.pid, name);
494
503
 
495
504
  let disposed = false;
@@ -498,6 +507,7 @@ function spawnResident(
498
507
  disposed = true;
499
508
  released = true;
500
509
  facetOfPid(ctx).delete(params.pid);
510
+ endResidency();
501
511
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
502
512
  if (explicit) book.live.delete(name);
503
513
  // The two release classes: an ephemeral facet's SQLite is slot-reuse
@@ -564,11 +574,11 @@ function spawnResident(
564
574
  * the hosting DO's heap.
565
575
  */
566
576
  function residentProcessClass(
567
- ctx: DurableObjectState,
568
577
  env: ResidentFacetEnv,
569
578
  disk: () => ResidentDiskReader,
570
579
  supervisor: ResidentSupervisorProps,
571
580
  params: ProcessHostParams,
581
+ loaderKey: string,
572
582
  ): unknown {
573
583
  const loader = env.LOADER;
574
584
  if (!loader || typeof loader.get !== 'function') {
@@ -577,18 +587,9 @@ function residentProcessClass(
577
587
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
578
588
  );
579
589
  }
580
- // A warm worker keeps the SUPERVISOR binding it was built with, and the
581
- // loader outlives this instance.
582
- const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
583
- try {
584
- const worker = loader
585
- .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
586
- .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
587
- recordLoaderId(ctx, loaderKey);
588
- return worker;
589
- } catch (error) {
590
- throw withDynamicWorkerCapNamed(ctx, error);
591
- }
590
+ return loader
591
+ .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
592
+ .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
592
593
  }
593
594
 
594
595
  async function runOneShot<T>(
@@ -638,22 +639,22 @@ async function runOneShot<T>(
638
639
  throw new Error('Nimbus: one-shot runtime entrypoint has no fetch method');
639
640
  }
640
641
  params.onLoaded?.();
641
- // The unkeyed worker is a live dynamic worker for exactly this call, so
642
- // the run is a Loader fetch on the hosting actor's ledger — bracketed,
643
- // never wrapped: see beginLoaderFetch for the measured DO-poisoning
644
- // hazard, and the pipelined-`fetch.call` note above for its sibling.
645
- const endFetch = beginLoaderFetch(ctx);
646
- let response: Response;
642
+ // The unkeyed worker is one distinct dynamic worker in flight until its
643
+ // response is consumed (the body streams from it), keyed by this run's
644
+ // writer id — bracketed, never wrapped: see beginLoaderFetch for the
645
+ // measured DO-poisoning hazard, and the pipelined-`fetch.call` note above
646
+ // for its sibling.
647
+ const endFetch = beginLoaderFetch(ctx, `one-shot:${params.writerId}`);
647
648
  try {
648
- response = await ep.fetch(params.request);
649
+ const response = await ep.fetch(params.request);
650
+ try {
651
+ return await consume(response);
652
+ } finally {
653
+ disposeRpcResource(response);
654
+ }
649
655
  } finally {
650
656
  endFetch();
651
657
  }
652
- try {
653
- return await consume(response);
654
- } finally {
655
- disposeRpcResource(response);
656
- }
657
658
  } catch (error) {
658
659
  throw withDynamicWorkerCapNamed(ctx, error);
659
660
  } finally {