@nimbus-sh/fabric 0.1.0 → 0.3.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 (104) hide show
  1. package/README.md +208 -293
  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 +3 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +2 -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} +58 -17
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/fenced-work.js +241 -0
  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 +33 -15
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +25 -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 +19 -11
  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} +24 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +24 -12
  60. package/dist/workerd-facet-host.d.ts +67 -70
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +129 -181
  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 +16 -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} +129 -42
  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 +43 -34
  82. package/src/process-host.ts +22 -20
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
  86. package/src/workerd-facet-host.ts +159 -208
  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-journal.js +0 -154
  97. package/dist/launch-pacer.d.ts.map +0 -1
  98. package/dist/loader-ledger.d.ts +0 -57
  99. package/dist/loader-ledger.d.ts.map +0 -1
  100. package/dist/loader-ledger.js +0 -91
  101. package/dist/loader-pool.d.ts.map +0 -1
  102. package/src/alarms.ts +0 -275
  103. package/src/ctx-exports.ts +0 -77
  104. package/src/loader-ledger.ts +0 -112
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"}
@@ -0,0 +1,248 @@
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
+ import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
28
+ const ledgers = new WeakMap();
29
+ function ledger(ctx) {
30
+ let entry = ledgers.get(ctx);
31
+ if (!entry) {
32
+ entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
33
+ ledgers.set(ctx, entry);
34
+ }
35
+ return entry;
36
+ }
37
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
38
+ export function recordLoaderId(ctx, id) {
39
+ ledger(ctx).ids.add(id);
40
+ }
41
+ /**
42
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
43
+ * function ends it (idempotently), from the caller's own `finally`.
44
+ *
45
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
46
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
47
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
48
+ * Durable Object poisoned after every pooled dispatch — the next fabric
49
+ * activity hung the object or reset the instance outright (pid base jumped,
50
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
51
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
52
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
53
+ * workers: an RPC stub call must stay a direct property call awaited by the
54
+ * frame that made it, so the ledger only brackets it.
55
+ */
56
+ export function beginLoaderFetch(ctx) {
57
+ const entry = ledger(ctx);
58
+ entry.liveFetches++;
59
+ entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
60
+ let ended = false;
61
+ return () => {
62
+ if (ended)
63
+ return;
64
+ ended = true;
65
+ entry.liveFetches--;
66
+ };
67
+ }
68
+ /** Snapshot for the diag surface. Pure read; no I/O. */
69
+ export function loaderLedgerStats(ctx) {
70
+ const entry = ledger(ctx);
71
+ return {
72
+ idsEverGotten: [...entry.ids],
73
+ liveFetches: entry.liveFetches,
74
+ peakLiveFetches: entry.peakLiveFetches,
75
+ };
76
+ }
77
+ /**
78
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
79
+ * failure; hand every other error back untouched. The platform's message
80
+ * says only that the cap was hit — which ids hold the slots, and that a
81
+ * keyed id can never give one back, is what the operator needs to know to
82
+ * shrink anything.
83
+ */
84
+ export function withDynamicWorkerCapNamed(ctx, error) {
85
+ if (classifyError(error) !== 'dynamic_worker_cap')
86
+ return error;
87
+ const entry = ledger(ctx);
88
+ const platform = error instanceof Error ? error.message : String(error);
89
+ return new Error(`${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
90
+ + `holding dynamic-worker slots (a loader.get id is never released): `
91
+ + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
92
+ + `peak ${entry.peakLiveFetches}`, { cause: error });
93
+ }
94
+ // ── Dynamic-worker module-map ceiling ───────────────────────────────────────
95
+ /**
96
+ * Total bytes a dynamic Worker's module map may carry, across every member of
97
+ * it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
98
+ * refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
99
+ * size of 67108864 bytes", confirmed at five sizes with two trials each. The
100
+ * budget is shared, so a ruby process is already 34.3 MiB down before its disk
101
+ * is counted.
102
+ */
103
+ export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
104
+ /**
105
+ * Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
106
+ * the largest members. The platform's own refusal reports one number for a
107
+ * budget shared across every member of the map, which tells the operator
108
+ * nothing about WHAT to shrink — so every fabric seam that assembles a map
109
+ * runs this before the loader sees it.
110
+ *
111
+ * Costed to its two paths. Under the ceiling: one length read per member —
112
+ * UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
113
+ * text the generators emit and undercount otherwise; the platform's own
114
+ * refusal still backstops the exotic case, because this check exists to name
115
+ * members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
116
+ * then, sorted so the biggest lever is first.
117
+ */
118
+ export function assertModuleMapWithinCodeLimit(modules) {
119
+ let estimate = 0;
120
+ for (const content of Object.values(modules)) {
121
+ estimate += memberBytes(content, null);
122
+ }
123
+ if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES)
124
+ return;
125
+ const encoder = new TextEncoder();
126
+ const sized = Object.entries(modules)
127
+ .map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
128
+ .sort((a, b) => b.bytes - a.bytes);
129
+ const total = sized.reduce((sum, member) => sum + member.bytes, 0);
130
+ const top = sized.slice(0, 5)
131
+ .map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
132
+ .join(', ');
133
+ throw new Error(`Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
134
+ + `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
135
+ + `every member. Largest members: ${top}`);
136
+ }
137
+ /**
138
+ * Bytes one module-map member carries, across the loader's content kinds
139
+ * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
140
+ * encoder, text is measured exactly; without one, by code-unit length.
141
+ */
142
+ function memberBytes(content, encoder) {
143
+ const textBytes = (text) => encoder ? encoder.encode(text).byteLength : text.length;
144
+ if (typeof content === 'string')
145
+ return textBytes(content);
146
+ if (content !== null && typeof content === 'object') {
147
+ for (const value of Object.values(content)) {
148
+ if (typeof value === 'string')
149
+ return textBytes(value);
150
+ if (value instanceof ArrayBuffer)
151
+ return value.byteLength;
152
+ if (ArrayBuffer.isView(value))
153
+ return value.byteLength;
154
+ }
155
+ }
156
+ return 0;
157
+ }
158
+ // ── Facet-ID lifetime budget ────────────────────────────────────────────────
159
+ /**
160
+ * Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
161
+ * never reclaimed, so crossing it is unrecoverable for the object — which is
162
+ * why the ledger below counts consumption durably instead of leaving the
163
+ * bound as prose the slot book merely respects.
164
+ */
165
+ export const FACET_ID_LIFETIME_BUDGET = 65_536;
166
+ /** Where the ledger persists the count of facet names ever minted. */
167
+ export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
168
+ const facetNameLedgers = new WeakMap();
169
+ function facetNameLedger(ctx) {
170
+ let ledger = facetNameLedgers.get(ctx);
171
+ if (!ledger) {
172
+ const created = { chain: Promise.resolve(0), known: 0, minted: 0 };
173
+ created.chain = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
174
+ .then((value) => (typeof value === 'number' ? value : 0))
175
+ .catch(() => 0)
176
+ .then((adopted) => {
177
+ created.known = Math.max(created.known, adopted);
178
+ return adopted;
179
+ });
180
+ ledger = created;
181
+ facetNameLedgers.set(ctx, ledger);
182
+ }
183
+ return ledger;
184
+ }
185
+ /**
186
+ * Advance the durable ledger to this incarnation's name count, if it is a new
187
+ * lifetime high. Chained behind adoption so the comparison is always against
188
+ * the real persisted value; a failed write leaves the old link's count and the
189
+ * next mint tries again — the ledger may transiently undercount, never over.
190
+ */
191
+ export function recordFacetNameMinted(ctx, count) {
192
+ const ledger = facetNameLedger(ctx);
193
+ ledger.minted = Math.max(ledger.minted, count);
194
+ ledger.chain = ledger.chain.then(async (durable) => {
195
+ if (count <= durable)
196
+ return durable;
197
+ try {
198
+ await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
199
+ }
200
+ catch {
201
+ return durable;
202
+ }
203
+ ledger.known = Math.max(ledger.known, count);
204
+ return count;
205
+ });
206
+ }
207
+ /** The best count available without awaiting storage: minted or adopted. */
208
+ export function facetNameCount(ctx) {
209
+ const ledger = facetNameLedger(ctx);
210
+ return Math.max(ledger.known, ledger.minted);
211
+ }
212
+ /** The count with adoption awaited, for a first failure on a fresh boot. */
213
+ export async function facetNameCountDurable(ctx) {
214
+ const ledger = facetNameLedger(ctx);
215
+ const durable = await ledger.chain;
216
+ return Math.max(durable, ledger.minted);
217
+ }
218
+ /**
219
+ * The lifetime facet-ID ledger: how many facet names this fabric has ever
220
+ * minted on the Durable Object, against the 65,536 the platform will ever
221
+ * grant it. `consumed` only ever counts FIRST uses — a reused name, in this
222
+ * incarnation or any earlier one, cost no new ID, which is the slot book's
223
+ * whole reason to exist. Surfaced so an operator can see proximity to a wall
224
+ * whose crossing is unrecoverable, instead of discovering it from the
225
+ * platform's opaque failure.
226
+ */
227
+ export async function facetIdBudget(ctx) {
228
+ return {
229
+ consumed: await facetNameCountDurable(ctx),
230
+ budget: FACET_ID_LIFETIME_BUDGET,
231
+ };
232
+ }
233
+ /**
234
+ * Name the facet-ID budget on a creation failure at the wall; below it, hand
235
+ * the error back untouched. Exhaustion is the one failure here the platform
236
+ * reports opaquely AND that no teardown, retry or reset can undo, so the
237
+ * ledger — the only witness to the real cause — does the naming. Not a
238
+ * threshold: the comparison is against the budget itself.
239
+ */
240
+ export function withFacetBudgetNamed(consumed, error) {
241
+ if (consumed < FACET_ID_LIFETIME_BUDGET)
242
+ return error;
243
+ const platform = error instanceof Error ? error.message : String(error);
244
+ return new Error(`Nimbus: facet creation failed with this Durable Object's `
245
+ + `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
246
+ + `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
247
+ + `so this failure is permanent for the object: ${platform}`, { cause: error });
248
+ }
@@ -0,0 +1,3 @@
1
+ export { adoptCtxExports, composeFabric, getCtxExports, stagedBootAssembler, supervisorEntrypoint, supervisorEntrypointName, } from '@nimbus-sh/platform/composition.js';
2
+ export type { CtxExports, EntrypointLoopbackFactory, FabricComposition, StagedBootAssembler, } from '@nimbus-sh/platform/composition.js';
3
+ //# sourceMappingURL=composition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composition.d.ts","sourceRoot":"","sources":["../src/composition.ts"],"names":[],"mappings":"AACA,OAAO,EACL,eAAe,EACf,aAAa,EACb,aAAa,EACb,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,oCAAoC,CAAC;AAE5C,YAAY,EACV,UAAU,EACV,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,oCAAoC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // Core and fabric share one holder without introducing a dependency cycle.
2
+ export { adoptCtxExports, composeFabric, getCtxExports, stagedBootAssembler, supervisorEntrypoint, supervisorEntrypointName, } from '@nimbus-sh/platform/composition.js';
@@ -0,0 +1,81 @@
1
+ /**
2
+ * connections.ts — typed, validated, hibernation-durable per-connection
3
+ * state over the WebSocket attachment.
4
+ *
5
+ * Specified from Proteus's DeviceSocketHub (`cf-backend/src/user/device-hub.ts`)
6
+ * and CLI rpc gate (`cf-backend/src/cli/rpc-gate.ts`), which split the
7
+ * pattern into its two halves:
8
+ * - a TAG is the immutable-at-accept lookup key and authorization — it
9
+ * rides the hibernation state, which is why the rpc gate persists auth
10
+ * scopes as a tag: "an in-memory allowlist would silently widen to full
11
+ * access on wake". That is a security property, and it is why this
12
+ * module keeps NO in-memory mirror: `ctx.getWebSockets` is the only
13
+ * source of truth for liveness, re-derived on every read.
14
+ * - the ATTACHMENT is the mutable per-connection payload. It outlives the
15
+ * code that wrote it — it survives deploys — so what a previous version
16
+ * wrote is untrusted input, and every read is schema-validated, never
17
+ * cast (device-hub.ts:49-53).
18
+ *
19
+ * Two platform facts the wrapper enforces where the consumer only documents:
20
+ * - attachments are STRUCTURED-CLONED, not JSON-encoded: a Set survives as
21
+ * a Set and silently fails an array schema on read (proven by Proteus's
22
+ * workerd test, do-socket-attachment.test.ts). Validating on WRITE turns
23
+ * that silent read-side null into a loud write-side error.
24
+ * - the bound is {@link WS_ATTACHMENT_LIMIT_BYTES} (16,384) on the
25
+ * SERIALIZED bytes — workerd re-serializes on every set to check it
26
+ * (web-socket.h, MAX_ATTACHMENT_SIZE). A JSON-length measurement is an
27
+ * approximation, so this module never pre-refuses on it; it lets the
28
+ * platform be the ceiling and NAMES the failure honestly when it trips.
29
+ *
30
+ * Reconnect replaces: accepting a socket under a key closes every open
31
+ * socket already holding that key (1000, 'replaced by a new connection'),
32
+ * exactly as the device hub does.
33
+ */
34
+ import { z } from 'zod/v4';
35
+ /** A hibernatable WebSocket, as this module drives it. */
36
+ export interface ConnectionSocket {
37
+ readyState: number;
38
+ close(code?: number, reason?: string): void;
39
+ serializeAttachment(value: unknown): void;
40
+ deserializeAttachment(): unknown;
41
+ }
42
+ /** The hosting actor's hibernation surface. */
43
+ export interface ConnectionsContext {
44
+ acceptWebSocket(ws: ConnectionSocket, tags?: string[]): void;
45
+ getWebSockets(tag?: string): ConnectionSocket[];
46
+ getTags(ws: ConnectionSocket): string[];
47
+ }
48
+ /** The per-connection hub of one hosting actor. Cheap accessor; it holds no
49
+ * state of its own, which is the point. */
50
+ export declare function connections<T>(ctx: ConnectionsContext, schema: z.ZodType<T>): Connections<T>;
51
+ export declare class Connections<T> {
52
+ private readonly ctx;
53
+ private readonly schema;
54
+ constructor(ctx: ConnectionsContext, schema: z.ZodType<T>);
55
+ /**
56
+ * Accept a socket under an identity key, replacing whatever already holds
57
+ * it. The attachment validates BEFORE anything else happens, so a rejected
58
+ * newcomer cannot evict the live connection it failed to replace.
59
+ */
60
+ accept(ws: ConnectionSocket, opts: {
61
+ key: string;
62
+ tags?: string[];
63
+ attachment: T;
64
+ }): void;
65
+ /** The open socket holding a tag, re-derived from the platform. */
66
+ get(tag: string): ConnectionSocket | null;
67
+ /** Every open socket (optionally: holding a tag). */
68
+ list(tag?: string): ConnectionSocket[];
69
+ /** A socket's tags — identity key first, then whatever accept added. */
70
+ tags(ws: ConnectionSocket): string[];
71
+ /**
72
+ * The attachment, validated. Null when it does not parse — an attachment
73
+ * written by a previous deploy is untrusted input, and a null is honest
74
+ * where a cast would be a lie.
75
+ */
76
+ read(ws: ConnectionSocket): T | null;
77
+ /** Replace the attachment, validated on the way in. */
78
+ write(ws: ConnectionSocket, attachment: T): void;
79
+ private writeSerialized;
80
+ }
81
+ //# sourceMappingURL=connections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../src/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAC;AAK3B,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1C,qBAAqB,IAAI,OAAO,CAAC;CAClC;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,eAAe,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC7D,aAAa,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAAC;IAChD,OAAO,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE,CAAC;CACzC;AAED;4CAC4C;AAC5C,wBAAgB,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAE5F;AAED,qBAAa,WAAW,CAAC,CAAC;IAEtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAGvC;;;;OAIG;IACH,MAAM,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,CAAC,CAAA;KAAE,GAAG,IAAI;IASzF,mEAAmE;IACnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI;IAOzC,qDAAqD;IACrD,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,EAAE;IAItC,wEAAwE;IACxE,IAAI,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE;IAIpC;;;;OAIG;IACH,IAAI,CAAC,EAAE,EAAE,gBAAgB,GAAG,CAAC,GAAG,IAAI;IAKpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,EAAE,gBAAgB,EAAE,UAAU,EAAE,CAAC,GAAG,IAAI;IAIhD,OAAO,CAAC,eAAe;CAcxB"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * connections.ts — typed, validated, hibernation-durable per-connection
3
+ * state over the WebSocket attachment.
4
+ *
5
+ * Specified from Proteus's DeviceSocketHub (`cf-backend/src/user/device-hub.ts`)
6
+ * and CLI rpc gate (`cf-backend/src/cli/rpc-gate.ts`), which split the
7
+ * pattern into its two halves:
8
+ * - a TAG is the immutable-at-accept lookup key and authorization — it
9
+ * rides the hibernation state, which is why the rpc gate persists auth
10
+ * scopes as a tag: "an in-memory allowlist would silently widen to full
11
+ * access on wake". That is a security property, and it is why this
12
+ * module keeps NO in-memory mirror: `ctx.getWebSockets` is the only
13
+ * source of truth for liveness, re-derived on every read.
14
+ * - the ATTACHMENT is the mutable per-connection payload. It outlives the
15
+ * code that wrote it — it survives deploys — so what a previous version
16
+ * wrote is untrusted input, and every read is schema-validated, never
17
+ * cast (device-hub.ts:49-53).
18
+ *
19
+ * Two platform facts the wrapper enforces where the consumer only documents:
20
+ * - attachments are STRUCTURED-CLONED, not JSON-encoded: a Set survives as
21
+ * a Set and silently fails an array schema on read (proven by Proteus's
22
+ * workerd test, do-socket-attachment.test.ts). Validating on WRITE turns
23
+ * that silent read-side null into a loud write-side error.
24
+ * - the bound is {@link WS_ATTACHMENT_LIMIT_BYTES} (16,384) on the
25
+ * SERIALIZED bytes — workerd re-serializes on every set to check it
26
+ * (web-socket.h, MAX_ATTACHMENT_SIZE). A JSON-length measurement is an
27
+ * approximation, so this module never pre-refuses on it; it lets the
28
+ * platform be the ceiling and NAMES the failure honestly when it trips.
29
+ *
30
+ * Reconnect replaces: accepting a socket under a key closes every open
31
+ * socket already holding that key (1000, 'replaced by a new connection'),
32
+ * exactly as the device hub does.
33
+ */
34
+ import { WS_ATTACHMENT_LIMIT_BYTES } from '@nimbus-sh/platform/limits.js';
35
+ const WS_OPEN = 1;
36
+ /** The per-connection hub of one hosting actor. Cheap accessor; it holds no
37
+ * state of its own, which is the point. */
38
+ export function connections(ctx, schema) {
39
+ return new Connections(ctx, schema);
40
+ }
41
+ export class Connections {
42
+ ctx;
43
+ schema;
44
+ constructor(ctx, schema) {
45
+ this.ctx = ctx;
46
+ this.schema = schema;
47
+ }
48
+ /**
49
+ * Accept a socket under an identity key, replacing whatever already holds
50
+ * it. The attachment validates BEFORE anything else happens, so a rejected
51
+ * newcomer cannot evict the live connection it failed to replace.
52
+ */
53
+ accept(ws, opts) {
54
+ const validated = this.schema.parse(opts.attachment);
55
+ for (const old of this.ctx.getWebSockets(opts.key)) {
56
+ if (old.readyState === WS_OPEN)
57
+ old.close(1000, 'replaced by a new connection');
58
+ }
59
+ this.ctx.acceptWebSocket(ws, [opts.key, ...(opts.tags ?? [])]);
60
+ this.writeSerialized(ws, validated);
61
+ }
62
+ /** The open socket holding a tag, re-derived from the platform. */
63
+ get(tag) {
64
+ for (const ws of this.ctx.getWebSockets(tag)) {
65
+ if (ws.readyState === WS_OPEN)
66
+ return ws;
67
+ }
68
+ return null;
69
+ }
70
+ /** Every open socket (optionally: holding a tag). */
71
+ list(tag) {
72
+ return this.ctx.getWebSockets(tag).filter((ws) => ws.readyState === WS_OPEN);
73
+ }
74
+ /** A socket's tags — identity key first, then whatever accept added. */
75
+ tags(ws) {
76
+ return this.ctx.getTags(ws);
77
+ }
78
+ /**
79
+ * The attachment, validated. Null when it does not parse — an attachment
80
+ * written by a previous deploy is untrusted input, and a null is honest
81
+ * where a cast would be a lie.
82
+ */
83
+ read(ws) {
84
+ const parsed = this.schema.safeParse(ws.deserializeAttachment());
85
+ return parsed.success ? parsed.data : null;
86
+ }
87
+ /** Replace the attachment, validated on the way in. */
88
+ write(ws, attachment) {
89
+ this.writeSerialized(ws, this.schema.parse(attachment));
90
+ }
91
+ writeSerialized(ws, validated) {
92
+ try {
93
+ ws.serializeAttachment(validated);
94
+ }
95
+ catch (e) {
96
+ const approx = jsonLength(validated);
97
+ throw new Error(`fabric: WebSocket attachment refused by the platform — the bound is `
98
+ + `${WS_ATTACHMENT_LIMIT_BYTES.toLocaleString('en-US')} bytes of SERIALIZED attachment `
99
+ + `(workerd MAX_ATTACHMENT_SIZE), and this value is ~${approx.toLocaleString('en-US')} bytes `
100
+ + `as JSON text (an approximation; the serialized form is what counts): ${errorText(e)}`, { cause: e });
101
+ }
102
+ }
103
+ }
104
+ function jsonLength(value) {
105
+ try {
106
+ return JSON.stringify(value)?.length ?? 0;
107
+ }
108
+ catch {
109
+ return 0;
110
+ }
111
+ }
112
+ function errorText(error) {
113
+ return error instanceof Error ? error.message : String(error);
114
+ }