@nimbus-sh/platform 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.
package/README.md CHANGED
@@ -4,32 +4,166 @@
4
4
  > cloud OS. This README is edited and maintained with Claude (AI) and
5
5
  > presented as-is.
6
6
 
7
- The zero-dependency leaf under
8
- [`@nimbus-sh/core`](https://www.npmjs.com/package/@nimbus-sh/core) and
9
- [`@nimbus-sh/fabric`](https://www.npmjs.com/package/@nimbus-sh/fabric): the
10
- measured Cloudflare platform truths both are built on, separated from the
11
- policy Nimbus layers on top of them.
12
-
13
- - `limits.js` — the measured limits tables: storage-transaction bounds, the
14
- RPC payload envelope, and the supervisor memory budgets. Each constant's doc
15
- comment carries the measurement that set it.
16
- - `oom-classify.js` / `oom-discriminator.js` — the error taxonomy for isolates
17
- that die without throwing: OOM vs CPU reset vs transient DO reset vs
18
- overload, and the failure ring that attributes them after the fact.
7
+ The measured limits of the Cloudflare Workers platform, and a classifier for
8
+ the failures workerd reports only as prose.
9
+
10
+ Some Workers failures give you nothing to branch on. A Durable Object call
11
+ comes back with the words `internal error`. An isolate dies without throwing.
12
+ A write past a cap resets the object instead of failing. This package holds
13
+ the numbers those failures happen at, and the matchers that name a caught
14
+ error. No call site has to string-match its own.
15
+
16
+ Use it if you write Workers or Durable Object code. It answers whether to
17
+ retry a failed call, and what the limit is before you write past it. It has
18
+ no runtime dependencies and imports nothing from `cloudflare:`, so it loads
19
+ in workerd,
20
+ bun, and node.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @nimbus-sh/platform
26
+ ```
27
+
28
+ ## Retry or fail
29
+
30
+ `classifyDoCall` reads a failed Durable Object call and returns one class.
31
+ It walks the cause chain, because a wrapper error drops the runtime's
32
+ `.retryable` property and only the joined prose survives. `isRetryableDoCall`
33
+ says which classes a retry may act on.
34
+
35
+ ```ts
36
+ import { classifyDoCall, isRetryableDoCall, describeError } from '@nimbus-sh/platform';
37
+
38
+ export async function callWithRetry<T>(call: () => Promise<T>, attempts = 3): Promise<T> {
39
+ for (let attempt = 1; ; attempt++) {
40
+ try {
41
+ // Mint a fresh stub inside `call`: a stub that failed once is spent.
42
+ return await call();
43
+ } catch (error) {
44
+ const outcome = classifyDoCall(error);
45
+ if (attempt >= attempts || !isRetryableDoCall(outcome)) {
46
+ throw new Error(`${outcome}: ${describeError(error)}`, { cause: error });
47
+ }
48
+ await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt));
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ | Class | What happened | Retry |
55
+ |---|---|---|
56
+ | `superseded_isolate` | a deploy replaced the isolate mid-call | yes |
57
+ | `connection_lost` | the stub or storage connection dropped | yes |
58
+ | `storage_reset` | storage reset the object: cold start, live write, or timeout | yes |
59
+ | `retryable_flag` | the runtime flagged the error `retryable` itself | yes |
60
+ | `overloaded` | the object shed the call under queue pressure | never |
61
+ | `permanent` | the call ran and the answer is the error | no |
62
+
63
+ `overloaded` is checked first and vetoes the rest, because retrying an
64
+ overloaded object is what overloaded it. Cloudflare's own error-handling
65
+ guidance forbids it.
66
+
67
+ `describeError` gives one line you can log: the error's class, whether the
68
+ text came from a remote isolate, and the condition it classifies as. Reach
69
+ for it wherever you would otherwise write `e?.message ?? String(e)` and get
70
+ back the word `internal error`.
71
+
72
+ ## Naming what died
73
+
74
+ `classifyError` maps an error, or a bare stderr line, to one cause:
75
+ `sqlite_nomem`, `sqlite_full`, `oom`, `cpu_exceeded`, `clone_refused`,
76
+ `rpc_timeout`, `subrequest_cap`, `dynamic_worker_cap`, `condemnation`,
77
+ `hard_evict`, or `unknown`.
78
+
79
+ ```ts
80
+ import { classifyError } from '@nimbus-sh/platform';
81
+
82
+ export function remedy(error: unknown): string {
83
+ switch (classifyError(error)) {
84
+ case 'sqlite_full': return 'drain data; reads and DELETEs still work at the wall';
85
+ case 'sqlite_nomem': return 'retry with a smaller transaction';
86
+ case 'clone_refused': return 'ship the payload in slices under the RPC envelope';
87
+ case 'cpu_exceeded': return 're-enter the object; yielding inside the turn buys nothing';
88
+ default: return 'unclassified: keep the message and widen the rules';
89
+ }
90
+ }
91
+ ```
92
+
93
+ The split between `sqlite_full` and `sqlite_nomem` carries the remedy. At the
94
+ storage wall, reads and deletes keep working and you drain data; on NOMEM you
95
+ ask for a smaller transaction.
96
+
97
+ Both `exceededMemory` and `exceededCpu` are uncatchable inside the isolate
98
+ that dies. Only a caller across an RPC boundary sees the message, and
99
+ isolates also vanish with no message at all. A low `cpu_exceeded` count is
100
+ therefore not evidence that CPU was not the cause.
101
+
102
+ `isTransientDoReset` and `isDoOverloaded` answer those two narrower questions
103
+ on their own.
104
+
105
+ ## Limits
106
+
107
+ Every constant is a fact about the platform: a hard ceiling, or a measured
108
+ envelope proven safe against one.
109
+
110
+ ```ts
111
+ import { MAX_RPC_SAFE_PAYLOAD_BYTES } from '@nimbus-sh/platform';
112
+
113
+ export function* slices(bytes: Uint8Array): Generator<Uint8Array> {
114
+ for (let at = 0; at < bytes.length; at += MAX_RPC_SAFE_PAYLOAD_BYTES) {
115
+ yield bytes.subarray(at, at + MAX_RPC_SAFE_PAYLOAD_BYTES);
116
+ }
117
+ }
118
+ ```
119
+
120
+ | Constant | Value | What it bounds |
121
+ |---|---|---|
122
+ | `CHUNK_SIZE` | 65,536 | the storage and wire quantum every other bound is counted in |
123
+ | `MAX_TX_BLOB_BYTES` | 1 MiB | bytes one storage transaction may hold outstanding; the platform resets the object over the turn, not the total |
124
+ | `MAX_TX_LOGICAL_ROWS` / `MAX_TX_SQL_EXECS` | 256 / 64 | rows and execs in that same transaction |
125
+ | `SQLITE_MAX_ROW_BYTES` | 2,000,000 | bytes per ROW, key length included; budget values against the row bound |
126
+ | `SQLITE_MAX_STATEMENT_BYTES` | 100 KiB | SQL text per exec |
127
+ | `SQLITE_MAX_BOUND_PARAMETERS` | 100 | bound parameters per query; a batched insert breaches this first |
128
+ | `DO_STORAGE_LIMIT_BYTES` | 10,000,000,000 | storage per Durable Object, shared by every facet and clone under it, with no copy-on-write credit |
129
+ | `BLOCK_CONCURRENCY_CANCEL_MS` | 30,000 | how long a `blockConcurrencyWhile()` callback may run before the object is reset |
130
+ | `WS_ATTACHMENT_LIMIT_BYTES` | 16,384 | serialized bytes per hibernatable WebSocket attachment |
131
+ | `MAX_RPC_SAFE_PAYLOAD_BYTES` | 28 MiB | bytes to send in one RPC value, under the platform's 32 MiB |
132
+
133
+ Each constant's doc comment carries the measurement that set it, and says
134
+ whether the value is documented by Cloudflare or probed. `SQLITE_MAX_ROW_BYTES`
135
+ is 2,000,000 against a measured single-value ceiling of 2,199,981 bytes,
136
+ because budgeting a row against the value ceiling would be wrong.
137
+
138
+ The supervisor budget constants (`SUPERVISOR_HEAP_CEILING_BYTES` and the
139
+ allocation budgets beside it) are Nimbus policy derived from the 128 MiB
140
+ isolate ceiling. They are soft admission targets, and the doc comments say so.
141
+
142
+ ## The rest of the surface
143
+
19
144
  - `heap-estimate.js` / `heavy-alloc-coord.js` / `weighted-credit-pool.js` —
20
- the instrumented supervisor heap model and the weighted admission budget
21
- behind it.
22
- - `diag-counters.js` / `install-phase.js` — the deterministic allocation-site
23
- counters that replace `process.memoryUsage()` (which returns 0 in a Durable
24
- Object), and the phase taxonomy they report.
25
- - `rpc-dispose.js` — explicit `Symbol.dispose` handling for RPC resources,
26
- including on error paths.
145
+ an instrumented heap model for a supervisor Durable Object, and the
146
+ weighted admission budget that holds allocations under it.
147
+ `estimateSupervisorHeap` returns a lower bound, and `HEAP_BLIND_SPOTS`
148
+ names the allocation sites it cannot see.
149
+ - `diag-counters.js` / `install-phase.js` — deterministic allocation-site
150
+ counters, because `process.memoryUsage()` returns 0 in a Durable Object,
151
+ plus the phase taxonomy they report against.
152
+ - `oom-discriminator.js` — the failure ring that attributes a death after the
153
+ fact: `recordFailure`, `getFailures`, and a snapshot pair that carries the
154
+ ring across a reset.
155
+ - `rpc-dispose.js` — `disposeRpcResource` and `disposeRpcResources`, explicit
156
+ `Symbol.dispose` for RPC values, including on error paths.
27
157
  - `w7-frame.js` — the incremental typed record format for streamed bulk
28
- filesystem writes, and the batch payload types it encodes.
158
+ filesystem writes, with `encodeWriteBatchStream` and the batch payload
159
+ types it encodes.
160
+
161
+ ## Related packages
29
162
 
30
- No runtime dependencies, no imports from any other Nimbus package. Everything
31
- above it — core's filesystem and shell, fabric's Durable Object machinery, the
32
- worker — consumes this package; nothing here knows about them.
163
+ [`@nimbus-sh/core`](https://www.npmjs.com/package/@nimbus-sh/core) is the
164
+ backend-agnostic OS, and
165
+ [`@nimbus-sh/fabric`](https://www.npmjs.com/package/@nimbus-sh/fabric) is the
166
+ Durable Object machinery. Both are built on the limits and the taxonomy here.
33
167
 
34
168
  ## License
35
169
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * composition.ts — the ONE seam an embedder wires the fabric through.
3
+ *
4
+ * The fabric mints supervisor bindings and assembles staged boots for the
5
+ * programs it hosts, but the entrypoint class that answers those bindings
6
+ * and the artifact sources a stage names both belong to the embedder. The
7
+ * embedder states them once, in its composition root, with one call:
8
+ *
9
+ * composeFabric({
10
+ * supervisorEntrypoint: 'SupervisorRPC',
11
+ * stagedBootAssembler: (env, stage) => assembleConfig(env, stage),
12
+ * });
13
+ *
14
+ * First-write-wins, like every holder in this module: the composition
15
+ * root's module scope runs once per isolate, before any request.
16
+ *
17
+ * `ctx.exports` is runtime state, not composition: workerd mints it per
18
+ * instance, so the embedder captures it where the platform hands it over —
19
+ * the first fetch, or the DO constructor — with {@link adoptCtxExports}.
20
+ *
21
+ * This module stays a leaf (no fabric imports) so helpers (notably
22
+ * isolate-pool.ts) can read `ctx.exports` without transitively importing
23
+ * the Durable Object classes, which is what lets the pool be unit-tested
24
+ * in a plain Node/Bun process.
25
+ */
26
+ export type EntrypointLoopbackFactory = <Stub = unknown>(options: {
27
+ props: object;
28
+ }) => Stub;
29
+ export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
30
+ export type StagedBootAssembler = (env: unknown, stage: unknown) => Promise<object>;
31
+ export interface FabricComposition {
32
+ supervisorEntrypoint: string;
33
+ stagedBootAssembler?: StagedBootAssembler;
34
+ /** Durable Object namespace binding that owns the workspace. */
35
+ hostNamespace?: string;
36
+ /** RPC method the host forwards to workspace.supervisorOp. */
37
+ hostDispatchMethod?: string;
38
+ }
39
+ export declare const DEFAULT_HOST_NAMESPACE = "NIMBUS_SESSION";
40
+ export declare const DEFAULT_HOST_DISPATCH_METHOD = "supervisorOp";
41
+ /** Composition and loopback exports are first-write-wins for this isolate. */
42
+ export declare function composeFabric(value: FabricComposition): void;
43
+ export declare function adoptCtxExports(value: CtxExports): void;
44
+ export declare function getCtxExports(): CtxExports | null;
45
+ export declare function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null;
46
+ export declare function supervisorEntrypointName(): string | null;
47
+ export declare function hostNamespace(): string;
48
+ export declare function hostDispatchMethod(): string;
49
+ export declare function stagedBootAssembler(): StagedBootAssembler;
50
+ //# sourceMappingURL=composition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composition.d.ts","sourceRoot":"","sources":["../src/composition.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,IAAI,GAAG,OAAO,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAC;AAC7F,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,yBAAyB,GAAG,SAAS,CAAC,CAAC;AAC/E,MAAM,MAAM,mBAAmB,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAEpF,MAAM,WAAW,iBAAiB;IAChC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,eAAO,MAAM,sBAAsB,mBAAmB,CAAC;AACvD,eAAO,MAAM,4BAA4B,iBAAiB,CAAC;AAI3D,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAE5D;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAEvD;AAED,wBAAgB,aAAa,IAAI,UAAU,GAAG,IAAI,CAEjD;AAED,wBAAgB,oBAAoB,CAAC,UAAU,CAAC,EAAE,OAAO,GAAG,yBAAyB,GAAG,IAAI,CAO3F;AAED,wBAAgB,wBAAwB,IAAI,MAAM,GAAG,IAAI,CAExD;AAED,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,mBAAmB,IAAI,mBAAmB,CASzD"}
@@ -0,0 +1,43 @@
1
+ export const DEFAULT_HOST_NAMESPACE = 'NIMBUS_SESSION';
2
+ export const DEFAULT_HOST_DISPATCH_METHOD = 'supervisorOp';
3
+ let composition = null;
4
+ let ctxExports = null;
5
+ /** Composition and loopback exports are first-write-wins for this isolate. */
6
+ export function composeFabric(value) {
7
+ if (!composition)
8
+ composition = value;
9
+ }
10
+ export function adoptCtxExports(value) {
11
+ if (!ctxExports)
12
+ ctxExports = value;
13
+ }
14
+ export function getCtxExports() {
15
+ return ctxExports;
16
+ }
17
+ export function supervisorEntrypoint(exportsObj) {
18
+ const exports = exportsObj ?? ctxExports;
19
+ const name = composition?.supervisorEntrypoint;
20
+ if (!name)
21
+ return null;
22
+ if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null)
23
+ return null;
24
+ const factory = exports[name];
25
+ return typeof factory === 'function' ? factory : null;
26
+ }
27
+ export function supervisorEntrypointName() {
28
+ return composition?.supervisorEntrypoint ?? null;
29
+ }
30
+ export function hostNamespace() {
31
+ return composition?.hostNamespace ?? DEFAULT_HOST_NAMESPACE;
32
+ }
33
+ export function hostDispatchMethod() {
34
+ return composition?.hostDispatchMethod ?? DEFAULT_HOST_DISPATCH_METHOD;
35
+ }
36
+ export function stagedBootAssembler() {
37
+ const assembler = composition?.stagedBootAssembler;
38
+ if (!assembler) {
39
+ throw new Error('fabric: no staged-boot assembler composed; a \'staged\' boot spec '
40
+ + 'cannot be assembled without one (composeFabric)');
41
+ }
42
+ return assembler;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-sh/platform",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The measured Cloudflare platform truths Nimbus is built on — error taxonomy, limits tables, budget coordination, and wire-format primitives, with zero runtime dependencies.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * composition.ts — the ONE seam an embedder wires the fabric through.
3
+ *
4
+ * The fabric mints supervisor bindings and assembles staged boots for the
5
+ * programs it hosts, but the entrypoint class that answers those bindings
6
+ * and the artifact sources a stage names both belong to the embedder. The
7
+ * embedder states them once, in its composition root, with one call:
8
+ *
9
+ * composeFabric({
10
+ * supervisorEntrypoint: 'SupervisorRPC',
11
+ * stagedBootAssembler: (env, stage) => assembleConfig(env, stage),
12
+ * });
13
+ *
14
+ * First-write-wins, like every holder in this module: the composition
15
+ * root's module scope runs once per isolate, before any request.
16
+ *
17
+ * `ctx.exports` is runtime state, not composition: workerd mints it per
18
+ * instance, so the embedder captures it where the platform hands it over —
19
+ * the first fetch, or the DO constructor — with {@link adoptCtxExports}.
20
+ *
21
+ * This module stays a leaf (no fabric imports) so helpers (notably
22
+ * isolate-pool.ts) can read `ctx.exports` without transitively importing
23
+ * the Durable Object classes, which is what lets the pool be unit-tested
24
+ * in a plain Node/Bun process.
25
+ */
26
+ export type EntrypointLoopbackFactory = <Stub = unknown>(options: { props: object }) => Stub;
27
+ export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
28
+ export type StagedBootAssembler = (env: unknown, stage: unknown) => Promise<object>;
29
+
30
+ export interface FabricComposition {
31
+ supervisorEntrypoint: string;
32
+ stagedBootAssembler?: StagedBootAssembler;
33
+ /** Durable Object namespace binding that owns the workspace. */
34
+ hostNamespace?: string;
35
+ /** RPC method the host forwards to workspace.supervisorOp. */
36
+ hostDispatchMethod?: string;
37
+ }
38
+
39
+ export const DEFAULT_HOST_NAMESPACE = 'NIMBUS_SESSION';
40
+ export const DEFAULT_HOST_DISPATCH_METHOD = 'supervisorOp';
41
+ let composition: FabricComposition | null = null;
42
+ let ctxExports: CtxExports | null = null;
43
+
44
+ /** Composition and loopback exports are first-write-wins for this isolate. */
45
+ export function composeFabric(value: FabricComposition): void {
46
+ if (!composition) composition = value;
47
+ }
48
+
49
+ export function adoptCtxExports(value: CtxExports): void {
50
+ if (!ctxExports) ctxExports = value;
51
+ }
52
+
53
+ export function getCtxExports(): CtxExports | null {
54
+ return ctxExports;
55
+ }
56
+
57
+ export function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null {
58
+ const exports = exportsObj ?? ctxExports;
59
+ const name = composition?.supervisorEntrypoint;
60
+ if (!name) return null;
61
+ if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null) return null;
62
+ const factory = (exports as Record<string, unknown>)[name];
63
+ return typeof factory === 'function' ? factory as EntrypointLoopbackFactory : null;
64
+ }
65
+
66
+ export function supervisorEntrypointName(): string | null {
67
+ return composition?.supervisorEntrypoint ?? null;
68
+ }
69
+
70
+ export function hostNamespace(): string {
71
+ return composition?.hostNamespace ?? DEFAULT_HOST_NAMESPACE;
72
+ }
73
+
74
+ export function hostDispatchMethod(): string {
75
+ return composition?.hostDispatchMethod ?? DEFAULT_HOST_DISPATCH_METHOD;
76
+ }
77
+
78
+ export function stagedBootAssembler(): StagedBootAssembler {
79
+ const assembler = composition?.stagedBootAssembler;
80
+ if (!assembler) {
81
+ throw new Error(
82
+ 'fabric: no staged-boot assembler composed; a \'staged\' boot spec '
83
+ + 'cannot be assembled without one (composeFabric)',
84
+ );
85
+ }
86
+ return assembler;
87
+ }