@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/src/bindings.ts CHANGED
@@ -25,11 +25,11 @@
25
25
 
26
26
  import { WorkerEntrypoint } from 'cloudflare:workers';
27
27
  import { z } from 'zod/v4';
28
- import { disposeRpcResource, useRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
29
- import { supervisorEntrypoint, supervisorEntrypointName } from './ctx-exports.js';
30
- import { requireStagedBootAssembler } from './process-fabric.js';
31
- import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
32
- import type { EntrypointLoopbackFactory } from './ctx-exports.js';
28
+ import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
29
+ import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
30
+ import { stagedBootAssembler } from './composition.js';
31
+ import { assertModuleMapWithinCodeLimit } from './budgets.js';
32
+ import type { EntrypointLoopbackFactory } from './composition.js';
33
33
  import type { WorkerCode } from './vendor/types.js';
34
34
 
35
35
  /**
@@ -578,7 +578,7 @@ export class NimbusLoadedEntrypoint extends WorkerEntrypoint<NimbusLoaderShimEnv
578
578
  // alive.
579
579
  const stage = props.stage;
580
580
  outerStub = outerLoader.get(props.key, async () => {
581
- const assembled = await requireStagedBootAssembler()(this.env, stage);
581
+ const assembled = await stagedBootAssembler()(this.env, stage);
582
582
  assertModuleMapWithinCodeLimit(
583
583
  (assembled as { modules?: Record<string, unknown> }).modules ?? {},
584
584
  );
package/src/budgets.ts ADDED
@@ -0,0 +1,308 @@
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
+
28
+ import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
29
+
30
+ interface LoaderLedger {
31
+ /** Distinct loader ids ever gotten — each one a permanently consumed slot. */
32
+ ids: Set<string>;
33
+ /** In-flight calls into dynamic workers, right now. */
34
+ liveFetches: number;
35
+ /** The most that were ever in flight at once. */
36
+ peakLiveFetches: number;
37
+ }
38
+
39
+ const ledgers = new WeakMap<object, LoaderLedger>();
40
+
41
+ function ledger(ctx: object): LoaderLedger {
42
+ let entry = ledgers.get(ctx);
43
+ if (!entry) {
44
+ entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
45
+ ledgers.set(ctx, entry);
46
+ }
47
+ return entry;
48
+ }
49
+
50
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
51
+ export function recordLoaderId(ctx: object, id: string): void {
52
+ ledger(ctx).ids.add(id);
53
+ }
54
+
55
+ /**
56
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
57
+ * function ends it (idempotently), from the caller's own `finally`.
58
+ *
59
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
60
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
61
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
62
+ * Durable Object poisoned after every pooled dispatch — the next fabric
63
+ * activity hung the object or reset the instance outright (pid base jumped,
64
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
65
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
66
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
67
+ * workers: an RPC stub call must stay a direct property call awaited by the
68
+ * frame that made it, so the ledger only brackets it.
69
+ */
70
+ export function beginLoaderFetch(ctx: object): () => void {
71
+ const entry = ledger(ctx);
72
+ entry.liveFetches++;
73
+ entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
74
+ let ended = false;
75
+ return () => {
76
+ if (ended) return;
77
+ ended = true;
78
+ entry.liveFetches--;
79
+ };
80
+ }
81
+
82
+ /** Snapshot for the diag surface. Pure read; no I/O. */
83
+ export function loaderLedgerStats(ctx: object): {
84
+ idsEverGotten: string[];
85
+ liveFetches: number;
86
+ peakLiveFetches: number;
87
+ } {
88
+ const entry = ledger(ctx);
89
+ return {
90
+ idsEverGotten: [...entry.ids],
91
+ liveFetches: entry.liveFetches,
92
+ peakLiveFetches: entry.peakLiveFetches,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
98
+ * failure; hand every other error back untouched. The platform's message
99
+ * says only that the cap was hit — which ids hold the slots, and that a
100
+ * keyed id can never give one back, is what the operator needs to know to
101
+ * shrink anything.
102
+ */
103
+ export function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error {
104
+ if (classifyError(error) !== 'dynamic_worker_cap') return error;
105
+ const entry = ledger(ctx);
106
+ const platform = error instanceof Error ? error.message : String(error);
107
+ return new Error(
108
+ `${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
109
+ + `holding dynamic-worker slots (a loader.get id is never released): `
110
+ + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
111
+ + `peak ${entry.peakLiveFetches}`,
112
+ { cause: error },
113
+ );
114
+ }
115
+
116
+ // ── Dynamic-worker module-map ceiling ───────────────────────────────────────
117
+
118
+ /**
119
+ * Total bytes a dynamic Worker's module map may carry, across every member of
120
+ * it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
121
+ * refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
122
+ * size of 67108864 bytes", confirmed at five sizes with two trials each. The
123
+ * budget is shared, so a ruby process is already 34.3 MiB down before its disk
124
+ * is counted.
125
+ */
126
+ export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
127
+
128
+ /**
129
+ * Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
130
+ * the largest members. The platform's own refusal reports one number for a
131
+ * budget shared across every member of the map, which tells the operator
132
+ * nothing about WHAT to shrink — so every fabric seam that assembles a map
133
+ * runs this before the loader sees it.
134
+ *
135
+ * Costed to its two paths. Under the ceiling: one length read per member —
136
+ * UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
137
+ * text the generators emit and undercount otherwise; the platform's own
138
+ * refusal still backstops the exotic case, because this check exists to name
139
+ * members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
140
+ * then, sorted so the biggest lever is first.
141
+ */
142
+ export function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>): void {
143
+ let estimate = 0;
144
+ for (const content of Object.values(modules)) {
145
+ estimate += memberBytes(content, null);
146
+ }
147
+ if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES) return;
148
+
149
+ const encoder = new TextEncoder();
150
+ const sized = Object.entries(modules)
151
+ .map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
152
+ .sort((a, b) => b.bytes - a.bytes);
153
+ const total = sized.reduce((sum, member) => sum + member.bytes, 0);
154
+ const top = sized.slice(0, 5)
155
+ .map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
156
+ .join(', ');
157
+ throw new Error(
158
+ `Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
159
+ + `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
160
+ + `every member. Largest members: ${top}`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Bytes one module-map member carries, across the loader's content kinds
166
+ * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
167
+ * encoder, text is measured exactly; without one, by code-unit length.
168
+ */
169
+ function memberBytes(content: unknown, encoder: TextEncoder | null): number {
170
+ const textBytes = (text: string): number =>
171
+ encoder ? encoder.encode(text).byteLength : text.length;
172
+ if (typeof content === 'string') return textBytes(content);
173
+ if (content !== null && typeof content === 'object') {
174
+ for (const value of Object.values(content)) {
175
+ if (typeof value === 'string') return textBytes(value);
176
+ if (value instanceof ArrayBuffer) return value.byteLength;
177
+ if (ArrayBuffer.isView(value)) return value.byteLength;
178
+ }
179
+ }
180
+ return 0;
181
+ }
182
+
183
+ // ── Facet-ID lifetime budget ────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
187
+ * never reclaimed, so crossing it is unrecoverable for the object — which is
188
+ * why the ledger below counts consumption durably instead of leaving the
189
+ * bound as prose the slot book merely respects.
190
+ */
191
+ export const FACET_ID_LIFETIME_BUDGET = 65_536;
192
+
193
+ /** Where the ledger persists the count of facet names ever minted. */
194
+ export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
195
+
196
+ /** The slice of storage the facet-name ledger persists through. */
197
+ interface FacetNameLedgerStorage {
198
+ storage: {
199
+ get(key: string): Promise<unknown> | unknown;
200
+ put(key: string, value: unknown): Promise<void>;
201
+ };
202
+ }
203
+
204
+ /**
205
+ * One hosting actor's durable facet-name count, as an adopt-then-advance
206
+ * chain. It starts as the read of {@link FACET_NAME_HIGH_WATER_KEY} and
207
+ * every later link writes only a LARGER count — a fresh incarnation restarts
208
+ * its slot cursor at zero, and a write that had not adopted first would
209
+ * clobber the lifetime count down to this incarnation's. The chain never
210
+ * rejects.
211
+ */
212
+ interface FacetNameLedger {
213
+ chain: Promise<number>;
214
+ /** The largest count the chain has adopted or durably written. */
215
+ known: number;
216
+ /** The largest count ever recorded this incarnation, durable or not. */
217
+ minted: number;
218
+ }
219
+
220
+ const facetNameLedgers = new WeakMap<object, FacetNameLedger>();
221
+
222
+ function facetNameLedger(ctx: FacetNameLedgerStorage): FacetNameLedger {
223
+ let ledger = facetNameLedgers.get(ctx);
224
+ if (!ledger) {
225
+ const created: FacetNameLedger = { chain: Promise.resolve(0), known: 0, minted: 0 };
226
+ created.chain = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
227
+ .then((value) => (typeof value === 'number' ? value : 0))
228
+ .catch(() => 0)
229
+ .then((adopted) => {
230
+ created.known = Math.max(created.known, adopted);
231
+ return adopted;
232
+ });
233
+ ledger = created;
234
+ facetNameLedgers.set(ctx, ledger);
235
+ }
236
+ return ledger;
237
+ }
238
+
239
+ /**
240
+ * Advance the durable ledger to this incarnation's name count, if it is a new
241
+ * lifetime high. Chained behind adoption so the comparison is always against
242
+ * the real persisted value; a failed write leaves the old link's count and the
243
+ * next mint tries again — the ledger may transiently undercount, never over.
244
+ */
245
+ export function recordFacetNameMinted(ctx: FacetNameLedgerStorage, count: number): void {
246
+ const ledger = facetNameLedger(ctx);
247
+ ledger.minted = Math.max(ledger.minted, count);
248
+ ledger.chain = ledger.chain.then(async (durable) => {
249
+ if (count <= durable) return durable;
250
+ try {
251
+ await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
252
+ } catch {
253
+ return durable;
254
+ }
255
+ ledger.known = Math.max(ledger.known, count);
256
+ return count;
257
+ });
258
+ }
259
+
260
+ /** The best count available without awaiting storage: minted or adopted. */
261
+ export function facetNameCount(ctx: FacetNameLedgerStorage): number {
262
+ const ledger = facetNameLedger(ctx);
263
+ return Math.max(ledger.known, ledger.minted);
264
+ }
265
+
266
+ /** The count with adoption awaited, for a first failure on a fresh boot. */
267
+ export async function facetNameCountDurable(ctx: FacetNameLedgerStorage): Promise<number> {
268
+ const ledger = facetNameLedger(ctx);
269
+ const durable = await ledger.chain;
270
+ return Math.max(durable, ledger.minted);
271
+ }
272
+
273
+ /**
274
+ * The lifetime facet-ID ledger: how many facet names this fabric has ever
275
+ * minted on the Durable Object, against the 65,536 the platform will ever
276
+ * grant it. `consumed` only ever counts FIRST uses — a reused name, in this
277
+ * incarnation or any earlier one, cost no new ID, which is the slot book's
278
+ * whole reason to exist. Surfaced so an operator can see proximity to a wall
279
+ * whose crossing is unrecoverable, instead of discovering it from the
280
+ * platform's opaque failure.
281
+ */
282
+ export async function facetIdBudget(
283
+ ctx: FacetNameLedgerStorage,
284
+ ): Promise<{ consumed: number; budget: number }> {
285
+ return {
286
+ consumed: await facetNameCountDurable(ctx),
287
+ budget: FACET_ID_LIFETIME_BUDGET,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Name the facet-ID budget on a creation failure at the wall; below it, hand
293
+ * the error back untouched. Exhaustion is the one failure here the platform
294
+ * reports opaquely AND that no teardown, retry or reset can undo, so the
295
+ * ledger — the only witness to the real cause — does the naming. Not a
296
+ * threshold: the comparison is against the budget itself.
297
+ */
298
+ export function withFacetBudgetNamed(consumed: number, error: unknown): unknown {
299
+ if (consumed < FACET_ID_LIFETIME_BUDGET) return error;
300
+ const platform = error instanceof Error ? error.message : String(error);
301
+ return new Error(
302
+ `Nimbus: facet creation failed with this Durable Object's `
303
+ + `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
304
+ + `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
305
+ + `so this failure is permanent for the object: ${platform}`,
306
+ { cause: error },
307
+ );
308
+ }
@@ -0,0 +1,16 @@
1
+ // Core and fabric share one holder without introducing a dependency cycle.
2
+ export {
3
+ adoptCtxExports,
4
+ composeFabric,
5
+ getCtxExports,
6
+ stagedBootAssembler,
7
+ supervisorEntrypoint,
8
+ supervisorEntrypointName,
9
+ } from '@nimbus-sh/platform/composition.js';
10
+
11
+ export type {
12
+ CtxExports,
13
+ EntrypointLoopbackFactory,
14
+ FabricComposition,
15
+ StagedBootAssembler,
16
+ } from '@nimbus-sh/platform/composition.js';
@@ -0,0 +1,140 @@
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
+
35
+ import { z } from 'zod/v4';
36
+ import { WS_ATTACHMENT_LIMIT_BYTES } from '@nimbus-sh/platform/limits.js';
37
+
38
+ const WS_OPEN = 1;
39
+
40
+ /** A hibernatable WebSocket, as this module drives it. */
41
+ export interface ConnectionSocket {
42
+ readyState: number;
43
+ close(code?: number, reason?: string): void;
44
+ serializeAttachment(value: unknown): void;
45
+ deserializeAttachment(): unknown;
46
+ }
47
+
48
+ /** The hosting actor's hibernation surface. */
49
+ export interface ConnectionsContext {
50
+ acceptWebSocket(ws: ConnectionSocket, tags?: string[]): void;
51
+ getWebSockets(tag?: string): ConnectionSocket[];
52
+ getTags(ws: ConnectionSocket): string[];
53
+ }
54
+
55
+ /** The per-connection hub of one hosting actor. Cheap accessor; it holds no
56
+ * state of its own, which is the point. */
57
+ export function connections<T>(ctx: ConnectionsContext, schema: z.ZodType<T>): Connections<T> {
58
+ return new Connections(ctx, schema);
59
+ }
60
+
61
+ export class Connections<T> {
62
+ constructor(
63
+ private readonly ctx: ConnectionsContext,
64
+ private readonly schema: z.ZodType<T>,
65
+ ) {}
66
+
67
+ /**
68
+ * Accept a socket under an identity key, replacing whatever already holds
69
+ * it. The attachment validates BEFORE anything else happens, so a rejected
70
+ * newcomer cannot evict the live connection it failed to replace.
71
+ */
72
+ accept(ws: ConnectionSocket, opts: { key: string; tags?: string[]; attachment: T }): void {
73
+ const validated = this.schema.parse(opts.attachment);
74
+ for (const old of this.ctx.getWebSockets(opts.key)) {
75
+ if (old.readyState === WS_OPEN) old.close(1000, 'replaced by a new connection');
76
+ }
77
+ this.ctx.acceptWebSocket(ws, [opts.key, ...(opts.tags ?? [])]);
78
+ this.writeSerialized(ws, validated);
79
+ }
80
+
81
+ /** The open socket holding a tag, re-derived from the platform. */
82
+ get(tag: string): ConnectionSocket | null {
83
+ for (const ws of this.ctx.getWebSockets(tag)) {
84
+ if (ws.readyState === WS_OPEN) return ws;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Every open socket (optionally: holding a tag). */
90
+ list(tag?: string): ConnectionSocket[] {
91
+ return this.ctx.getWebSockets(tag).filter((ws) => ws.readyState === WS_OPEN);
92
+ }
93
+
94
+ /** A socket's tags — identity key first, then whatever accept added. */
95
+ tags(ws: ConnectionSocket): string[] {
96
+ return this.ctx.getTags(ws);
97
+ }
98
+
99
+ /**
100
+ * The attachment, validated. Null when it does not parse — an attachment
101
+ * written by a previous deploy is untrusted input, and a null is honest
102
+ * where a cast would be a lie.
103
+ */
104
+ read(ws: ConnectionSocket): T | null {
105
+ const parsed = this.schema.safeParse(ws.deserializeAttachment());
106
+ return parsed.success ? parsed.data : null;
107
+ }
108
+
109
+ /** Replace the attachment, validated on the way in. */
110
+ write(ws: ConnectionSocket, attachment: T): void {
111
+ this.writeSerialized(ws, this.schema.parse(attachment));
112
+ }
113
+
114
+ private writeSerialized(ws: ConnectionSocket, validated: T): void {
115
+ try {
116
+ ws.serializeAttachment(validated);
117
+ } catch (e) {
118
+ const approx = jsonLength(validated);
119
+ throw new Error(
120
+ `fabric: WebSocket attachment refused by the platform — the bound is `
121
+ + `${WS_ATTACHMENT_LIMIT_BYTES.toLocaleString('en-US')} bytes of SERIALIZED attachment `
122
+ + `(workerd MAX_ATTACHMENT_SIZE), and this value is ~${approx.toLocaleString('en-US')} bytes `
123
+ + `as JSON text (an approximation; the serialized form is what counts): ${errorText(e)}`,
124
+ { cause: e },
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ function jsonLength(value: unknown): number {
131
+ try {
132
+ return JSON.stringify(value)?.length ?? 0;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ }
137
+
138
+ function errorText(error: unknown): string {
139
+ return error instanceof Error ? error.message : String(error);
140
+ }
package/src/derived.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * derived.ts — a watermark memo: derive a cheap key, compare, rebuild only
3
+ * on change.
4
+ *
5
+ * Proteus's ActorAgent hand-rolls this pair four times — cached value plus
6
+ * cached key, compared and rebuilt inline: the system prompt (key composed
7
+ * from soul text, executors, model, tools, stance), the tool set (keyed
8
+ * partly on `_craftCacheKey()`, two synchronous SQLite aggregates), the MCP
9
+ * tool surface (keyed on UserDO's `mcp_updated_at`), and the SOUL text
10
+ * (no key at all — push-invalidated by `setSoul`). The consumer port moved
11
+ * the prompt and tool-set pairs onto `derived` cleanly; the MCP pair needs
12
+ * the per-call context and the hooks; the SOUL pair cannot move at all.
13
+ *
14
+ * The SOUL pair names this module's limit: a value LOADED asynchronously
15
+ * (an awaited file read at turn start) but READ synchronously (the prompt
16
+ * builder consults what is already in memory). Neither variant expresses
17
+ * that split — `derived` cannot await the load, `derivedAsync` cannot serve
18
+ * a synchronous read — so an async-load/sync-read snapshot stays a
19
+ * hand-rolled field with a push invalidation.
20
+ *
21
+ * `derived` is synchronous end to end, because its consumers are: Think
22
+ * calls `getSystemPrompt(): string` synchronously, and nothing synchronous
23
+ * may await — the init-gate rule. `derivedAsync` exists because ONE consumer
24
+ * call site needs it: the MCP surface awaits a cross-DO RPC for both the
25
+ * watermark and the build, and its proven failure policy is stale-on-error —
26
+ * a watermark or build failure serves the last good value without touching
27
+ * the stored key, and only surfaces when there is no value to serve.
28
+ *
29
+ * `invalidate()` is the push half the SOUL memo proved: an out-of-band write
30
+ * (`setSoul`) clears the memo so the next read rebuilds under an unchanged
31
+ * watermark.
32
+ */
33
+
34
+ export interface Derived<T, C = void> {
35
+ /**
36
+ * `context` reaches the watermark and the build of THIS call — the seam
37
+ * for per-call state (a stub, a caller identity, a work mode). The memo
38
+ * stores one value; the watermark must cover everything the build reads,
39
+ * context included, or a context change serves another context's value.
40
+ */
41
+ get(context: C): T;
42
+ /** Force the next get to rebuild, watermark unchanged. */
43
+ invalidate(): void;
44
+ }
45
+
46
+ /**
47
+ * The consumer's logging seams. Both MCP logs the port could not express:
48
+ * `onRebuild` fires after a build stores (the "rebuilt @ wm=N" line), and
49
+ * `onStale` — async only — fires when a failure serves the stale value,
50
+ * the one path where the error is otherwise absorbed. A surfaced error
51
+ * (nothing stale to serve) reports itself.
52
+ */
53
+ export interface DerivedHooks {
54
+ /** After a build stores. `previousKey` is undefined on the first build. */
55
+ onRebuild?(previousKey: string | number | undefined, nextKey: string | number): void;
56
+ }
57
+
58
+ export interface DerivedAsyncHooks extends DerivedHooks {
59
+ /** A watermark or build failure just served the stale value. */
60
+ onStale?(error: unknown): void;
61
+ }
62
+
63
+ export function derived<T, C = void>(
64
+ watermark: (context: C) => string | number,
65
+ build: (context: C, key: string | number) => T,
66
+ hooks: DerivedHooks = {},
67
+ ): Derived<T, C> {
68
+ let key: string | number | undefined;
69
+ let value: T | undefined;
70
+ let has = false;
71
+ return {
72
+ get(context: C): T {
73
+ const next = watermark(context);
74
+ if (has && next === key) return value as T;
75
+ value = build(context, next);
76
+ hooks.onRebuild?.(key, next);
77
+ key = next;
78
+ has = true;
79
+ return value;
80
+ },
81
+ invalidate(): void {
82
+ has = false;
83
+ value = undefined;
84
+ },
85
+ };
86
+ }
87
+
88
+ export interface DerivedAsync<T, C = void> {
89
+ get(context: C): Promise<T>;
90
+ invalidate(): void;
91
+ }
92
+
93
+ export function derivedAsync<T, C = void>(
94
+ watermark: (context: C) => Promise<string | number>,
95
+ build: (context: C, key: string | number) => Promise<T>,
96
+ hooks: DerivedAsyncHooks = {},
97
+ ): DerivedAsync<T, C> {
98
+ let key: string | number | undefined;
99
+ let value: T | undefined;
100
+ let has = false;
101
+ return {
102
+ async get(context: C): Promise<T> {
103
+ let next: string | number;
104
+ try {
105
+ next = await watermark(context);
106
+ } catch (e) {
107
+ if (has) {
108
+ hooks.onStale?.(e);
109
+ return value as T;
110
+ }
111
+ throw e;
112
+ }
113
+ if (has && next === key) return value as T;
114
+ let built: T;
115
+ try {
116
+ built = await build(context, next);
117
+ } catch (e) {
118
+ if (has) {
119
+ hooks.onStale?.(e);
120
+ return value as T;
121
+ }
122
+ throw e;
123
+ }
124
+ value = built;
125
+ hooks.onRebuild?.(key, next);
126
+ key = next;
127
+ has = true;
128
+ return built;
129
+ },
130
+ invalidate(): void {
131
+ has = false;
132
+ value = undefined;
133
+ },
134
+ };
135
+ }