@nimbus-sh/worker 0.4.0 → 0.6.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 (95) hide show
  1. package/README.md +46 -43
  2. package/dist/_shared/preview-host.d.ts +33 -2
  3. package/dist/_shared/preview-host.d.ts.map +1 -1
  4. package/dist/_shared/preview-host.js +73 -8
  5. package/dist/_shared/session-router.d.ts +43 -0
  6. package/dist/_shared/session-router.d.ts.map +1 -1
  7. package/dist/_shared/session-router.js +59 -12
  8. package/dist/facets/durable-images.d.ts +59 -0
  9. package/dist/facets/durable-images.d.ts.map +1 -0
  10. package/dist/facets/durable-images.js +94 -0
  11. package/dist/facets/durable-slots.d.ts +45 -0
  12. package/dist/facets/durable-slots.d.ts.map +1 -0
  13. package/dist/facets/durable-slots.js +95 -0
  14. package/dist/facets/esbuild-transform.d.ts +10 -0
  15. package/dist/facets/esbuild-transform.d.ts.map +1 -0
  16. package/dist/facets/esbuild-transform.js +36 -0
  17. package/dist/facets/manager.d.ts +244 -6
  18. package/dist/facets/manager.d.ts.map +1 -1
  19. package/dist/facets/manager.js +901 -117
  20. package/dist/facets/resident-identity.d.ts +23 -0
  21. package/dist/facets/resident-identity.d.ts.map +1 -0
  22. package/dist/facets/resident-identity.js +31 -0
  23. package/dist/git/commands.d.ts +22 -1
  24. package/dist/git/commands.d.ts.map +1 -1
  25. package/dist/git/commands.js +429 -425
  26. package/dist/git-bundle.generated.d.ts +2 -2
  27. package/dist/git-bundle.generated.d.ts.map +1 -1
  28. package/dist/git-bundle.generated.js +3 -3
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -1
  32. package/dist/node-shims-artifact.generated.js +3 -3
  33. package/dist/router/index.d.ts.map +1 -1
  34. package/dist/router/index.js +97 -3
  35. package/dist/router/public-directory-do.d.ts +17 -0
  36. package/dist/router/public-directory-do.d.ts.map +1 -0
  37. package/dist/router/public-directory-do.js +16 -0
  38. package/dist/router/public-directory.d.ts +79 -0
  39. package/dist/router/public-directory.d.ts.map +1 -0
  40. package/dist/router/public-directory.js +122 -0
  41. package/dist/router/remote-api.d.ts +27 -1
  42. package/dist/router/remote-api.d.ts.map +1 -1
  43. package/dist/router/remote-api.js +48 -2
  44. package/dist/runtime/cpython-resident.d.ts.map +1 -1
  45. package/dist/runtime/cpython-resident.js +3 -1
  46. package/dist/runtime/node-shims.d.ts +4 -1
  47. package/dist/runtime/node-shims.d.ts.map +1 -1
  48. package/dist/runtime/node-shims.js +324 -6
  49. package/dist/runtime/package-manager.d.ts +44 -1
  50. package/dist/runtime/package-manager.d.ts.map +1 -1
  51. package/dist/runtime/package-manager.js +168 -3
  52. package/dist/runtime/ruby-resident.d.ts.map +1 -1
  53. package/dist/runtime/ruby-resident.js +2 -0
  54. package/dist/session/agent.d.ts +11 -0
  55. package/dist/session/agent.d.ts.map +1 -1
  56. package/dist/session/agent.js +28 -1
  57. package/dist/session/helpers.js +1 -1
  58. package/dist/session/init-phases.d.ts +17 -23
  59. package/dist/session/init-phases.d.ts.map +1 -1
  60. package/dist/session/init-phases.js +17 -41
  61. package/dist/session/init.d.ts.map +1 -1
  62. package/dist/session/init.js +28 -15
  63. package/dist/session/keys.d.ts +12 -0
  64. package/dist/session/keys.d.ts.map +1 -1
  65. package/dist/session/keys.js +12 -0
  66. package/dist/session/nimbus-session.d.ts +63 -5
  67. package/dist/session/nimbus-session.d.ts.map +1 -1
  68. package/dist/session/nimbus-session.js +131 -17
  69. package/dist/session/port-capability.d.ts +141 -8
  70. package/dist/session/port-capability.d.ts.map +1 -1
  71. package/dist/session/port-capability.js +265 -8
  72. package/dist/session/programmatic.d.ts +145 -3
  73. package/dist/session/programmatic.d.ts.map +1 -1
  74. package/dist/session/programmatic.js +434 -12
  75. package/dist/session/routes.d.ts +26 -0
  76. package/dist/session/routes.d.ts.map +1 -1
  77. package/dist/session/routes.js +117 -25
  78. package/dist/session/rpc.d.ts +24 -1
  79. package/dist/session/rpc.d.ts.map +1 -1
  80. package/dist/session/rpc.js +65 -98
  81. package/dist/session/shell-socket.d.ts +76 -0
  82. package/dist/session/shell-socket.d.ts.map +1 -0
  83. package/dist/session/shell-socket.js +172 -0
  84. package/dist/session/start-real-vite.js +1 -1
  85. package/dist/session/supervisor-op.d.ts +42 -0
  86. package/dist/session/supervisor-op.d.ts.map +1 -0
  87. package/dist/session/supervisor-op.js +96 -0
  88. package/dist/session/supervisor-rpc.d.ts +6 -10
  89. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  90. package/dist/session/supervisor-rpc.js +91 -71
  91. package/dist/session/ws.d.ts.map +1 -1
  92. package/dist/session/ws.js +19 -4
  93. package/package.json +4 -4
  94. package/public/_assets/runtime/{node-shims-fea906843f6d594e.js → node-shims-9e1380d9ee430fcc.js} +320 -5
  95. package/public/s/index.html +101 -5
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # @nimbus-sh/worker
2
2
 
3
- The Cloudflare half of Nimbus: the `NimbusSession` Durable Object, router,
4
- static assets, facet machinery, and auth internals. The filesystem, shell,
5
- and WASI runtime layer live in
3
+ Run a Linux-like sandbox on Cloudflare Workers. A session gets a filesystem,
4
+ a shell, real processes, and ports you can reach from a browser, all inside
5
+ one Durable Object.
6
+
7
+ This package is the Cloudflare half: the `NimbusSession` Durable Object, the
8
+ router, the static assets, the facet machinery, and the auth internals. The
9
+ filesystem, shell, and WASI runtime layer live in
6
10
  [`@nimbus-sh/core`](https://www.npmjs.com/package/@nimbus-sh/core), which
7
- this package composes on — core also runs standalone in bun or node.
11
+ this package composes on. Core also runs standalone in bun or node.
8
12
 
9
- Application code should import the deploy-time API through
10
- `@nimbus-sh/sdk/worker`. This package is still installed because it carries
11
- the runtime implementation and static assets used by the SDK entrypoint.
13
+ Import the deploy-time API from `@nimbus-sh/sdk/worker`. Install this package
14
+ as well, because it carries the runtime implementation and the static assets
15
+ that entrypoint serves.
12
16
 
13
17
  ## Install
14
18
 
@@ -123,9 +127,9 @@ Cloudflare Dashboard once for the account, then rerun the setup command.
123
127
  single-tenant demos can set `NIMBUS_LEGACY_PUBLIC=1` and rely on URL
124
128
  possession instead.
125
129
 
126
- ## Composable API
130
+ ## createNimbusHandler
127
131
 
128
- `createNimbusHandler(options)` accepts:
132
+ The handler owns the router, auth, and the session protocol. Its options:
129
133
 
130
134
  ```ts
131
135
  {
@@ -144,8 +148,8 @@ possession instead.
144
148
 
145
149
  ### Custom routes
146
150
 
147
- Routes that return non-`null` short-circuit Nimbus's router. Use them
148
- for a token-mint endpoint, `/healthz`, SDK smoke tests, or backend
151
+ A route that returns anything but `null` short-circuits Nimbus's router. Use
152
+ one for a token-mint endpoint, `/healthz`, SDK smoke tests, or backend
149
153
  sandbox jobs.
150
154
 
151
155
  ```ts
@@ -209,8 +213,7 @@ Applications normally call it through `Nimbus.connect({ endpoint, token,
209
213
  config })`. The route requires a valid Nimbus JWT and `sandbox:use` scope.
210
214
  `box.destroy()` additionally requires `session:destroy` or `session:admin`.
211
215
 
212
- For long-running app servers, start an explicit long-running process and
213
- expose the virtual port:
216
+ For an app server, start the process and expose its virtual port:
214
217
 
215
218
  ```ts
216
219
  await box.files.write('/home/user/app/server.js', serverSource);
@@ -220,19 +223,39 @@ const port = await box.ports.expose(3000);
220
223
  // box.processes.kill(proc.pid) stops it
221
224
  ```
222
225
 
226
+ ### Hooks
227
+
228
+ ```ts
229
+ export default createNimbusHandler({
230
+ hooks: {
231
+ onSessionStart: ({ sessionId, tenantSegment, request }) => {
232
+ console.log(`[${tenantSegment}] session ${sessionId} attached from ${request.headers.get('cf-connecting-ip')}`);
233
+ },
234
+ },
235
+ });
236
+ ```
237
+
238
+ ### Auth modes
239
+
240
+ | Mode | Meaning |
241
+ |---|---|
242
+ | `'auto'` (default) | Verify token when `JWT_SECRET` is set AND `NIMBUS_LEGACY_PUBLIC` is unset. Otherwise legacy-public. |
243
+ | `'enforce'` | Always verify token; fail closed if `JWT_SECRET` is missing. |
244
+ | `'legacy'` | Never verify; all requests route to the single `legacy:public:_` tenant. Use only for single-tenant demos. |
245
+
223
246
  ## Session Agent
224
247
 
225
- The bundled session shell includes an Agent surface inside the editor
226
- workspace. The route lives inside the session Durable Object under
227
- `/api/agent/*`; the stable Cloudflare OAuth callback is
248
+ The bundled session shell carries an agent surface inside the editor
249
+ workspace. Its route lives in the session Durable Object under
250
+ `/api/agent/*`. The stable Cloudflare OAuth callback is
228
251
  `/api/nimbus/oauth/callback`.
229
252
 
230
- The agent can use the same session tools as the SDK: shell exec, files,
231
- runtime installs, long-running processes, logs, and preview ports. Model calls
232
- use the AI SDK with Cloudflare Workers AI's OpenAI-compatible endpoint and an
253
+ The agent uses the same session tools as the SDK: shell exec, files, runtime
254
+ installs, long-running processes, logs, and preview ports. Model calls use
255
+ the AI SDK with Cloudflare Workers AI's OpenAI-compatible endpoint and an
233
256
  optional AI Gateway name.
234
257
 
235
- For user-owned quota, create a Cloudflare OAuth client with response type
258
+ For user-owned quota, create a Cloudflare OAuth client. Use response type
236
259
  `Code`, grant type `Authorization Code`, token authentication method `None`,
237
260
  and redirect URL `https://<your-nimbus-host>/api/nimbus/oauth/callback`.
238
261
  Nimbus uses PKCE and stores user OAuth tokens only in encrypted `HttpOnly`,
@@ -257,26 +280,6 @@ npx wrangler secret put NIMBUS_AGENT_COOKIE_SECRET
257
280
  npx wrangler secret put NIMBUS_CLOUDFLARE_API_TOKEN
258
281
  ```
259
282
 
260
- ### Hooks
261
-
262
- ```ts
263
- export default createNimbusHandler({
264
- hooks: {
265
- onSessionStart: ({ sessionId, tenantSegment, request }) => {
266
- console.log(`[${tenantSegment}] session ${sessionId} attached from ${request.headers.get('cf-connecting-ip')}`);
267
- },
268
- },
269
- });
270
- ```
271
-
272
- ### Auth modes
273
-
274
- | Mode | Meaning |
275
- |---|---|
276
- | `'auto'` (default) | Verify token when `JWT_SECRET` is set AND `NIMBUS_LEGACY_PUBLIC` is unset. Otherwise legacy-public. |
277
- | `'enforce'` | Always verify token; fail closed if `JWT_SECRET` is missing. |
278
- | `'legacy'` | Never verify; all requests route to the single `legacy:public:_` tenant. Use only for single-tenant demos. |
279
-
280
283
  ## Subpath exports
281
284
 
282
285
  | Subpath | What |
@@ -288,7 +291,7 @@ export default createNimbusHandler({
288
291
 
289
292
  ## Required bindings
290
293
 
291
- Every binding is load-bearing — see `apps/hosted-demo/wrangler.jsonc` or
294
+ None of these is optional. See `apps/hosted-demo/wrangler.jsonc` or
292
295
  `@nimbus-sh/config` for the canonical set:
293
296
 
294
297
  - `NIMBUS_SESSION` (Durable Object) — per-session SQLite state
@@ -299,7 +302,7 @@ Every binding is load-bearing — see `apps/hosted-demo/wrangler.jsonc` or
299
302
 
300
303
  ## Status
301
304
 
302
- v0.1 — first public release. SemVer not yet stable; expect breaking
303
- changes through v0.x. Issues + PRs welcome.
305
+ v0.1 is the first public release. SemVer is not stable yet, so expect
306
+ breaking changes through v0.x. Issues + PRs welcome.
304
307
 
305
308
  MIT. © Ashish Kumar Singh + contributors.
@@ -10,13 +10,37 @@
10
10
  * `buildPreviewHost` and `parsePreviewHost` are exact inverses: every
11
11
  * `(sid, port)` has exactly ONE valid origin. Without that bijection a cookie
12
12
  * set on the canonical host is missing from an equivalent-but-different one.
13
+ *
14
+ * The middle label may be a NAME instead of a port — `<name>--<sid>` and
15
+ * `<cap>--<name>--<sid>` — for an application whose reservation carries a
16
+ * name alias. A numeric label is a port; anything else that is a DNS label
17
+ * is a name. The scoped name form is resolved to a port inside the session
18
+ * (its reservation records); the public name form through the directory.
13
19
  */
14
20
  export interface PreviewHost {
15
- port: number;
21
+ /** The port, on the port forms. Absent on a name form — the name resolves to it. */
22
+ port?: number;
23
+ /** The name alias, on the name forms `<name>--<sid>` / `<cap>--<name>--<sid>`. */
24
+ name?: string;
16
25
  sid: string;
26
+ /**
27
+ * Present only on the public capability forms `<cap>--<port>--<sid>` and
28
+ * `<cap>--<name>--<sid>`: the bearer is the capability itself, so the
29
+ * request skips session-attach auth entirely — the session decides by the
30
+ * port's stored visibility.
31
+ */
32
+ capability?: string;
17
33
  }
34
+ /** `<port>--<sid>` or `<name>--<sid>`: the middle label is a port number or a name alias. */
35
+ export declare function buildPreviewHost(sid: string, target: number | string, suffix: string): string;
36
+ /**
37
+ * `<capability>--<port|name>--<sid>.<suffix>` — the unauthenticated sibling
38
+ * of `buildPreviewHost`, for applications whose visibility is `public`. The
39
+ * capability is the bearer: 24 lowercase hex, the same shape the port
40
+ * registry mints.
41
+ */
42
+ export declare function buildPublicPreviewHost(sid: string, target: number | string, capability: string, suffix: string): string;
18
43
  export declare function isPreviewHostSafeSid(sid: string): boolean;
19
- export declare function buildPreviewHost(sid: string, port: number, suffix: string): string;
20
44
  /**
21
45
  * Read the configured preview-host suffix out of a bindings env.
22
46
  *
@@ -26,6 +50,13 @@ export declare function buildPreviewHost(sid: string, port: number, suffix: stri
26
50
  */
27
51
  export declare function readPreviewHostSuffix(env: unknown): string | null;
28
52
  export declare function parsePreviewHost(host: string, suffix: string | undefined | null): PreviewHost | null;
53
+ /**
54
+ * A name label: a DNS label that is neither a port (all digits) nor a
55
+ * capability (24 lowercase hex), and contains no `--` host-label separator.
56
+ * The same rule the session applies when it stores a
57
+ * name on a reservation, so every name it accepts is a host it can parse.
58
+ */
59
+ export declare function isPreviewHostName(label: string): boolean;
29
60
  /**
30
61
  * True when `url` addresses a port preview. Embedders MUST test this BEFORE
31
62
  * their own route table: a preview host serves untrusted user code at the
@@ -1 +1 @@
1
- {"version":3,"file":"preview-host.d.ts","sourceRoot":"","sources":["../../src/_shared/preview-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAElF;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAGjE;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAChC,WAAW,GAAG,IAAI,CAqBpB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAEpE"}
1
+ {"version":3,"file":"preview-host.d.ts","sourceRoot":"","sources":["../../src/_shared/preview-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAkBH,MAAM,WAAW,WAAW;IAC1B,oFAAoF;IACpF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,6FAA6F;AAC7F,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7F;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,GACb,MAAM,CAER;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAGjE;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAChC,WAAW,GAAG,IAAI,CA8CpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAKxD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAEpE"}
@@ -10,18 +10,43 @@
10
10
  * `buildPreviewHost` and `parsePreviewHost` are exact inverses: every
11
11
  * `(sid, port)` has exactly ONE valid origin. Without that bijection a cookie
12
12
  * set on the canonical host is missing from an equivalent-but-different one.
13
+ *
14
+ * The middle label may be a NAME instead of a port — `<name>--<sid>` and
15
+ * `<cap>--<name>--<sid>` — for an application whose reservation carries a
16
+ * name alias. A numeric label is a port; anything else that is a DNS label
17
+ * is a name. The scoped name form is resolved to a port inside the session
18
+ * (its reservation records); the public name form through the directory.
13
19
  */
14
20
  const PREVIEW_HOST_SAFE_SID_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
15
21
  /** Canonical port form only: no leading zeros, so `03000--x` is not a host. */
16
22
  const PREVIEW_HOST_LABEL_RE = /^(0|[1-9]\d*)--(.+)$/;
23
+ /** The scoped name form: a non-numeric DNS label in the port's place. */
24
+ const PREVIEW_NAME_HOST_LABEL_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)--([a-z0-9-]{1,63})$/;
25
+ // The public capability form: the bearer is the capability itself, no
26
+ // attach token and no embedder credential ever crosses this hostname.
27
+ // The label carries everything a request needs — which port, which
28
+ // session, and which token — so it works with no server-side lookup.
29
+ const PREVIEW_CAPABILITY_HOST_LABEL_RE = /^([a-f0-9]{24})--(\d{1,5})--([a-z0-9-]{1,63})$/;
30
+ /** The public name form: the capability names the session in the directory, the name is verified there. */
31
+ const PREVIEW_CAPABILITY_NAME_HOST_LABEL_RE = /^([a-f0-9]{24})--([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)--([a-z0-9-]{1,63})$/;
17
32
  /** Binding that carries the deployment's preview-host suffix. */
18
33
  const PREVIEW_HOST_SUFFIX_BINDING = 'NIMBUS_PREVIEW_HOST_SUFFIX';
34
+ /** `<port>--<sid>` or `<name>--<sid>`: the middle label is a port number or a name alias. */
35
+ export function buildPreviewHost(sid, target, suffix) {
36
+ return `${target}--${sid}.${suffix}`;
37
+ }
38
+ /**
39
+ * `<capability>--<port|name>--<sid>.<suffix>` — the unauthenticated sibling
40
+ * of `buildPreviewHost`, for applications whose visibility is `public`. The
41
+ * capability is the bearer: 24 lowercase hex, the same shape the port
42
+ * registry mints.
43
+ */
44
+ export function buildPublicPreviewHost(sid, target, capability, suffix) {
45
+ return `${capability}--${target}--${sid}.${suffix}`;
46
+ }
19
47
  export function isPreviewHostSafeSid(sid) {
20
48
  return sid.length <= 56 && PREVIEW_HOST_SAFE_SID_RE.test(sid);
21
49
  }
22
- export function buildPreviewHost(sid, port, suffix) {
23
- return `${port}--${sid}.${suffix}`;
24
- }
25
50
  /**
26
51
  * Read the configured preview-host suffix out of a bindings env.
27
52
  *
@@ -46,14 +71,54 @@ export function parsePreviewHost(host, suffix) {
46
71
  const label = normalizedHost.slice(0, -suffixWithDot.length);
47
72
  if (!label || label.includes('.'))
48
73
  return null;
74
+ // The capability form is checked first: its leading 24-hex run would
75
+ // otherwise parse as the port of the legacy form's widest match.
76
+ const capabilityMatch = label.match(PREVIEW_CAPABILITY_HOST_LABEL_RE);
77
+ if (capabilityMatch) {
78
+ const capability = capabilityMatch[1];
79
+ const port = Number(capabilityMatch[2]);
80
+ const sid = capabilityMatch[3];
81
+ if (port < 1 || port > 65535 || !isPreviewHostSafeSid(sid))
82
+ return null;
83
+ return { port, sid, capability };
84
+ }
85
+ const capabilityNameMatch = label.match(PREVIEW_CAPABILITY_NAME_HOST_LABEL_RE);
86
+ if (capabilityNameMatch) {
87
+ const capability = capabilityNameMatch[1];
88
+ const name = capabilityNameMatch[2];
89
+ const sid = capabilityNameMatch[4];
90
+ if (!isPreviewHostName(name) || !isPreviewHostSafeSid(sid))
91
+ return null;
92
+ return { name, sid, capability };
93
+ }
49
94
  const match = label.match(PREVIEW_HOST_LABEL_RE);
50
- if (!match)
95
+ if (match) {
96
+ const port = Number(match[1]);
97
+ const sid = match[2];
98
+ if (port < 1 || port > 65535 || !isPreviewHostSafeSid(sid))
99
+ return null;
100
+ return { port, sid };
101
+ }
102
+ const nameMatch = label.match(PREVIEW_NAME_HOST_LABEL_RE);
103
+ if (!nameMatch)
51
104
  return null;
52
- const port = Number(match[1]);
53
- const sid = match[2];
54
- if (port < 1 || port > 65535 || !isPreviewHostSafeSid(sid))
105
+ const name = nameMatch[1];
106
+ const sid = nameMatch[3];
107
+ if (!isPreviewHostName(name) || !isPreviewHostSafeSid(sid))
55
108
  return null;
56
- return { port, sid };
109
+ return { name, sid };
110
+ }
111
+ /**
112
+ * A name label: a DNS label that is neither a port (all digits) nor a
113
+ * capability (24 lowercase hex), and contains no `--` host-label separator.
114
+ * The same rule the session applies when it stores a
115
+ * name on a reservation, so every name it accepts is a host it can parse.
116
+ */
117
+ export function isPreviewHostName(label) {
118
+ return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(label)
119
+ && !label.includes('--')
120
+ && !/^\d+$/.test(label)
121
+ && !/^[a-f0-9]{24}$/.test(label);
57
122
  }
58
123
  /**
59
124
  * True when `url` addresses a port preview. Embedders MUST test this BEFORE
@@ -51,6 +51,23 @@ export declare const TENANT_HEADER = "X-Nimbus-Tenant";
51
51
  * `Authorization` is preserved — the one route on which that is true.
52
52
  */
53
53
  export declare const PREVIEW_CAPABILITY_HEADER = "x-nimbus-preview-capability";
54
+ /**
55
+ * Header the Worker sets when a request arrived on the public capability
56
+ * host form `<cap>--<port>--<sid>` — the request was never attached to a
57
+ * session, so the capability in `PREVIEW_CAPABILITY_HEADER` is the only
58
+ * credential it carries, and the session may honor it ONLY for a port whose
59
+ * stored visibility is `public`. Any other request answering on that mark
60
+ * is 404.
61
+ */
62
+ export declare const PUBLIC_BEARER_HEADER = "x-nimbus-public-bearer";
63
+ /**
64
+ * Header the Worker sets carrying the caller's verified token scopes, so the
65
+ * DO can gate routes that need more than session attach (e.g. the _diag
66
+ * abort). The header is deleted from the caller's own request before the
67
+ * verified value is set — a forged inbound copy never survives forwarding.
68
+ * Absent/empty in legacy mode, where no scopes were verified.
69
+ */
70
+ export declare const CALLER_SCOPES_HEADER = "x-nimbus-caller-scopes";
54
71
  /**
55
72
  * DO-name segment used when tenant scoping is disabled (legacy-public).
56
73
  * Picked so it cannot collide with a verified token's
@@ -85,6 +102,17 @@ export declare function parseSessionRoute(pathname: string): ParsedSessionRoute
85
102
  export interface ForwardOptions {
86
103
  /** Verified tenant segment for DO naming. */
87
104
  tenantSegment: string;
105
+ /**
106
+ * Router-minted headers the DO must see — the preview capability, the
107
+ * public-bearer mark. Entries arrive only from code that vetted them.
108
+ */
109
+ extraHeaders?: Readonly<Record<string, string>>;
110
+ /**
111
+ * The caller's verified token scopes. Forwarded as
112
+ * {@link CALLER_SCOPES_HEADER} so scope-gated routes inside the DO can
113
+ * check them. Omitted in legacy mode or when the caller was not verified.
114
+ */
115
+ callerScopes?: readonly string[];
88
116
  }
89
117
  /**
90
118
  * Forward a request to the session's DO.
@@ -106,6 +134,21 @@ export interface ForwardOptions {
106
134
  * @param opts Tenant scoping. See {@link ForwardOptions}.
107
135
  */
108
136
  export declare function forwardToSession(request: Request, route: ParsedSessionRoute, env: any, opts: ForwardOptions): Promise<Response>;
137
+ /**
138
+ * The card every session-facing error/status page shares: dark, centered,
139
+ * mono title. `metaRefreshSeconds` opts the page into self-refresh — the
140
+ * "starting" page re-asks on its own timer, the invalid page never does.
141
+ */
142
+ export declare function renderSessionStatusPage(input: {
143
+ title: string;
144
+ heading: string;
145
+ body: string;
146
+ action?: {
147
+ href: string;
148
+ label: string;
149
+ };
150
+ metaRefreshSeconds?: number;
151
+ }): string;
109
152
  /** HTML body for the "invalid session ID" 400 page. Tiny, inline-only. */
110
153
  export declare function renderInvalidSessionHtml(attemptedId: string): string;
111
154
  //# sourceMappingURL=session-router.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-router.d.ts","sourceRoot":"","sources":["../../src/_shared/session-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,gCAAgC,CAAC;AAEvE;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,oBAAoB,CAAC;AAS1D,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI,CAa7E;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,6CAA6C;IAC7C,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,kBAAkB,EACzB,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,QAAQ,CAAC,CAsCnB;AAED,0EAA0E;AAC1E,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CA6BpE"}
1
+ {"version":3,"file":"session-router.d.ts","sourceRoot":"","sources":["../../src/_shared/session-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,gCAAgC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,oBAAoB,CAAC;AAS1D,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI,CAa7E;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,6CAA6C;IAC7C,aAAa,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD;;;;OAIG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AACD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,kBAAkB,EACzB,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,QAAQ,CAAC,CA8CnB;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B,GAAG,MAAM,CA8BT;AAED,0EAA0E;AAC1E,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAapE"}
@@ -52,6 +52,23 @@ export const TENANT_HEADER = 'X-Nimbus-Tenant';
52
52
  * `Authorization` is preserved — the one route on which that is true.
53
53
  */
54
54
  export const PREVIEW_CAPABILITY_HEADER = 'x-nimbus-preview-capability';
55
+ /**
56
+ * Header the Worker sets when a request arrived on the public capability
57
+ * host form `<cap>--<port>--<sid>` — the request was never attached to a
58
+ * session, so the capability in `PREVIEW_CAPABILITY_HEADER` is the only
59
+ * credential it carries, and the session may honor it ONLY for a port whose
60
+ * stored visibility is `public`. Any other request answering on that mark
61
+ * is 404.
62
+ */
63
+ export const PUBLIC_BEARER_HEADER = 'x-nimbus-public-bearer';
64
+ /**
65
+ * Header the Worker sets carrying the caller's verified token scopes, so the
66
+ * DO can gate routes that need more than session attach (e.g. the _diag
67
+ * abort). The header is deleted from the caller's own request before the
68
+ * verified value is set — a forged inbound copy never survives forwarding.
69
+ * Absent/empty in legacy mode, where no scopes were verified.
70
+ */
71
+ export const CALLER_SCOPES_HEADER = 'x-nimbus-caller-scopes';
55
72
  /**
56
73
  * DO-name segment used when tenant scoping is disabled (legacy-public).
57
74
  * Picked so it cannot collide with a verified token's
@@ -115,6 +132,16 @@ export function forwardToSession(request, route, env, opts) {
115
132
  const headers = new Headers(request.headers);
116
133
  headers.set(BASE_PATH_HEADER, route.basePath);
117
134
  headers.set(TENANT_HEADER, opts.tenantSegment);
135
+ // Verified scopes come from the router alone: a caller-supplied copy is
136
+ // deleted before the real value is set, never appended to.
137
+ headers.delete(CALLER_SCOPES_HEADER);
138
+ if (opts.callerScopes !== undefined) {
139
+ headers.set(CALLER_SCOPES_HEADER, opts.callerScopes.join(' '));
140
+ }
141
+ if (opts.extraHeaders) {
142
+ for (const [name, value] of Object.entries(opts.extraHeaders))
143
+ headers.set(name, value);
144
+ }
118
145
  // Load-bearing, not hygiene: the query is a first-class auth channel
119
146
  // (`extractBearerToken` reads it), so anything forwarded with the token
120
147
  // still attached would carry a live credential into inner-DO logs, the
@@ -142,18 +169,23 @@ export function forwardToSession(request, route, env, opts) {
142
169
  const stub = env.NIMBUS_SESSION.get(id);
143
170
  return stub.fetch(inner);
144
171
  }
145
- /** HTML body for the "invalid session ID" 400 page. Tiny, inline-only. */
146
- export function renderInvalidSessionHtml(attemptedId) {
147
- // Escape the attempted ID for display. We don't use innerHTML anywhere,
148
- // but defensive escaping keeps the HTML validator happy and avoids any
149
- // future XSS footguns if someone refactors this to document.write().
150
- const safe = String(attemptedId).replace(/[&<>"']/g, (c) => ({
151
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
152
- }[c]));
172
+ /**
173
+ * The card every session-facing error/status page shares: dark, centered,
174
+ * mono title. `metaRefreshSeconds` opts the page into self-refresh — the
175
+ * "starting" page re-asks on its own timer, the invalid page never does.
176
+ */
177
+ export function renderSessionStatusPage(input) {
178
+ const refresh = input.metaRefreshSeconds !== undefined
179
+ ? `<meta http-equiv="refresh" content="${input.metaRefreshSeconds}">`
180
+ : '';
181
+ const action = input.action !== undefined
182
+ ? `<a class="btn" href="${input.action.href}">${input.action.label}</a>`
183
+ : '';
153
184
  return `<!DOCTYPE html>
154
185
  <html lang="en"><head>
155
186
  <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
156
- <title>Invalid session — Nimbus</title>
187
+ ${refresh}
188
+ <title>${input.title} — Nimbus</title>
157
189
  <style>
158
190
  *{margin:0;padding:0;box-sizing:border-box}
159
191
  html,body{height:100%}
@@ -168,8 +200,23 @@ export function renderInvalidSessionHtml(attemptedId) {
168
200
  a.btn:hover{filter:brightness(1.1)}
169
201
  </style></head>
170
202
  <body><div class="card">
171
- <h1>Invalid session</h1>
172
- <p>The ID <code>${safe}</code> isn&rsquo;t a valid Nimbus session URL.<br>Launch a new one to get started.</p>
173
- <a class="btn" href="/">&larr; Back to Nimbus</a>
203
+ <h1>${input.heading}</h1>
204
+ <p>${input.body}</p>
205
+ ${action}
174
206
  </div></body></html>`;
175
207
  }
208
+ /** HTML body for the "invalid session ID" 400 page. Tiny, inline-only. */
209
+ export function renderInvalidSessionHtml(attemptedId) {
210
+ // Escape the attempted ID for display. We don't use innerHTML anywhere,
211
+ // but defensive escaping keeps the HTML validator happy and avoids any
212
+ // future XSS footguns if someone refactors this to document.write().
213
+ const safe = String(attemptedId).replace(/[&<>"']/g, (c) => ({
214
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
215
+ }[c]));
216
+ return renderSessionStatusPage({
217
+ title: 'Invalid session',
218
+ heading: 'Invalid session',
219
+ body: `The ID <code>${safe}</code> isn&rsquo;t a valid Nimbus session URL.<br>Launch a new one to get started.`,
220
+ action: { href: '/', label: '&larr; Back to Nimbus' },
221
+ });
222
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * facets/durable-images.ts — the durable application's boot-image store.
3
+ *
4
+ * A durable spawn's journal row carries only digests — the launch's code,
5
+ * modules, and environment have to live somewhere a reset can still read
6
+ * them, and that is the kernel-space VFS under `.nimbus/images/<sha256>`,
7
+ * content-addressed so a re-drive with the same recipe reads the same bytes.
8
+ *
9
+ * The store is deliberately separate from the process boot-image sweep in
10
+ * `image-store.ts`: that sweep is rooted at live pids and would collect an
11
+ * application's image the moment its process ends — the precise condition a
12
+ * durable spawn exists to survive. Durable images are kept for the
13
+ * application's life and released only by explicit removal.
14
+ *
15
+ * Two blobs per launch: `runner` is the worker.js source text, `application`
16
+ * is a JSON payload of `{ modules, env, vfsWasmModules }`. The digests in the
17
+ * journal's `recipe.image` are sha256 hashes of those two payloads, and a
18
+ * self-owned spawn mints them at spawn time; an embedder-owned spawn is given
19
+ * them by its own bookkeeping instead.
20
+ */
21
+ import type { SqliteVFS } from '@nimbus-sh/core/vfs/sqlite-vfs.js';
22
+ import type { ResidentCodeSpec } from '@nimbus-sh/fabric/process-fabric.js';
23
+ import type { ResolvedWorkerLaunch, WorkerRecipe } from './manager.js';
24
+ /** The directory every durable application's image blobs live under. */
25
+ export declare const DURABLE_IMAGE_DIR = ".nimbus/images";
26
+ /** Remove only the owner's blobs that no other retained recipe references. */
27
+ export declare function purgeDurableWorkerImages(vfs: SqliteVFS, owned: Iterable<{
28
+ runner: string;
29
+ application: string;
30
+ }>, retained: Iterable<{
31
+ runner: string;
32
+ application: string;
33
+ }>): number;
34
+ /**
35
+ * Persist a launch's image blobs, minting their digests, for a self-owned
36
+ * durable spawn. Reads and writes as CRED_KERNEL: the directory is session
37
+ * kernel data, not user content, and a durable application's images must not
38
+ * be writable — or deletable — by the user process they belong to.
39
+ */
40
+ export declare function persistDurableWorkerImage(vfs: SqliteVFS, workerCode: string, payload: {
41
+ modules: Record<string, string | {
42
+ wasm: ArrayBuffer;
43
+ }>;
44
+ env?: ResidentCodeSpec['env'];
45
+ vfsWasmModules?: Record<string, string>;
46
+ startArgs?: unknown;
47
+ }): Promise<{
48
+ runner: string;
49
+ application: string;
50
+ }>;
51
+ /**
52
+ * The default resolver a self-owned durable spawn answers through: read the
53
+ * two blobs the spawn persisted, restore the env, and hand back the launch a
54
+ * re-drive can boot. Returns null only when an image row has gone missing —
55
+ * which a re-drive treats as 'the application is gone', exactly as an
56
+ * embedder-owned launch whose embedder answers null.
57
+ */
58
+ export declare function resolveDurableWorkerImage(vfs: SqliteVFS, recipe: WorkerRecipe): Promise<ResolvedWorkerLaunch | null>;
59
+ //# sourceMappingURL=durable-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable-images.d.ts","sourceRoot":"","sources":["../../src/facets/durable-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEvE,wEAAwE;AACxE,eAAO,MAAM,iBAAiB,mBAAmB,CAAC;AAIlD,8EAA8E;AAC9E,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,SAAS,EACd,KAAK,EAAE,QAAQ,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,EACxD,QAAQ,EAAE,QAAQ,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,GAC1D,MAAM,CAaR;AAOD;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,SAAS,EACd,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE;IACP,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACxD,GAAG,CAAC,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,GACA,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAclD;AAED;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,SAAS,EACd,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CA0BtC"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * facets/durable-images.ts — the durable application's boot-image store.
3
+ *
4
+ * A durable spawn's journal row carries only digests — the launch's code,
5
+ * modules, and environment have to live somewhere a reset can still read
6
+ * them, and that is the kernel-space VFS under `.nimbus/images/<sha256>`,
7
+ * content-addressed so a re-drive with the same recipe reads the same bytes.
8
+ *
9
+ * The store is deliberately separate from the process boot-image sweep in
10
+ * `image-store.ts`: that sweep is rooted at live pids and would collect an
11
+ * application's image the moment its process ends — the precise condition a
12
+ * durable spawn exists to survive. Durable images are kept for the
13
+ * application's life and released only by explicit removal.
14
+ *
15
+ * Two blobs per launch: `runner` is the worker.js source text, `application`
16
+ * is a JSON payload of `{ modules, env, vfsWasmModules }`. The digests in the
17
+ * journal's `recipe.image` are sha256 hashes of those two payloads, and a
18
+ * self-owned spawn mints them at spawn time; an embedder-owned spawn is given
19
+ * them by its own bookkeeping instead.
20
+ */
21
+ import { CRED_KERNEL } from '@nimbus-sh/core/runtime/os-contracts.js';
22
+ /** The directory every durable application's image blobs live under. */
23
+ export const DURABLE_IMAGE_DIR = '.nimbus/images';
24
+ const imagePath = (digest) => `${DURABLE_IMAGE_DIR}/${digest}`;
25
+ /** Remove only the owner's blobs that no other retained recipe references. */
26
+ export function purgeDurableWorkerImages(vfs, owned, retained) {
27
+ const keep = new Set([...retained].flatMap((image) => [image.runner, image.application]));
28
+ const candidates = new Set([...owned].flatMap((image) => [image.runner, image.application]));
29
+ const kernel = vfs.as(CRED_KERNEL);
30
+ let removed = 0;
31
+ for (const digest of candidates) {
32
+ if (keep.has(digest) || !/^[a-f0-9]{64}$/.test(digest))
33
+ continue;
34
+ const path = imagePath(digest);
35
+ if (!kernel.exists(path))
36
+ continue;
37
+ kernel.unlink(path);
38
+ removed += 1;
39
+ }
40
+ return removed;
41
+ }
42
+ async function sha256Hex(text) {
43
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
44
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
45
+ }
46
+ /**
47
+ * Persist a launch's image blobs, minting their digests, for a self-owned
48
+ * durable spawn. Reads and writes as CRED_KERNEL: the directory is session
49
+ * kernel data, not user content, and a durable application's images must not
50
+ * be writable — or deletable — by the user process they belong to.
51
+ */
52
+ export async function persistDurableWorkerImage(vfs, workerCode, payload) {
53
+ const kernel = vfs.as(CRED_KERNEL);
54
+ kernel.mkdir(DURABLE_IMAGE_DIR, { recursive: true });
55
+ const runner = await sha256Hex(workerCode);
56
+ kernel.writeFile(imagePath(runner), workerCode);
57
+ const applicationPayload = JSON.stringify({
58
+ modules: payload.modules,
59
+ env: payload.env ?? null,
60
+ vfsWasmModules: payload.vfsWasmModules ?? null,
61
+ ...(payload.startArgs !== undefined ? { startArgs: payload.startArgs } : {}),
62
+ });
63
+ const application = await sha256Hex(applicationPayload);
64
+ kernel.writeFile(imagePath(application), applicationPayload);
65
+ return { runner, application };
66
+ }
67
+ /**
68
+ * The default resolver a self-owned durable spawn answers through: read the
69
+ * two blobs the spawn persisted, restore the env, and hand back the launch a
70
+ * re-drive can boot. Returns null only when an image row has gone missing —
71
+ * which a re-drive treats as 'the application is gone', exactly as an
72
+ * embedder-owned launch whose embedder answers null.
73
+ */
74
+ export async function resolveDurableWorkerImage(vfs, recipe) {
75
+ const kernel = vfs.as(CRED_KERNEL);
76
+ let runnerBytes;
77
+ let applicationBytes;
78
+ try {
79
+ runnerBytes = kernel.readFile(imagePath(recipe.image.runner));
80
+ applicationBytes = kernel.readFile(imagePath(recipe.image.application));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ const runner = new TextDecoder().decode(runnerBytes);
86
+ const { modules = {}, env = null, vfsWasmModules = undefined, startArgs } = JSON.parse(new TextDecoder().decode(applicationBytes));
87
+ return {
88
+ env: env ?? null,
89
+ globalOutbound: undefined,
90
+ modules: { 'worker.js': runner, ...modules },
91
+ ...(startArgs !== undefined ? { startArgs } : {}),
92
+ ...(vfsWasmModules !== null ? { vfsWasmModules } : {}),
93
+ };
94
+ }