@nimbus-sh/worker 0.4.0 → 0.5.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 (83) hide show
  1. package/README.md +46 -43
  2. package/dist/_shared/preview-host.d.ts +14 -1
  3. package/dist/_shared/preview-host.d.ts.map +1 -1
  4. package/dist/_shared/preview-host.js +28 -3
  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 +50 -0
  9. package/dist/facets/durable-images.d.ts.map +1 -0
  10. package/dist/facets/durable-images.js +75 -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 +94 -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 +160 -5
  18. package/dist/facets/manager.d.ts.map +1 -1
  19. package/dist/facets/manager.js +554 -97
  20. package/dist/git/commands.d.ts +22 -0
  21. package/dist/git/commands.d.ts.map +1 -1
  22. package/dist/git/commands.js +431 -424
  23. package/dist/git-bundle.generated.d.ts +2 -2
  24. package/dist/git-bundle.generated.d.ts.map +1 -1
  25. package/dist/git-bundle.generated.js +3 -3
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -1
  29. package/dist/node-shims-artifact.generated.js +3 -3
  30. package/dist/router/index.d.ts.map +1 -1
  31. package/dist/router/index.js +59 -2
  32. package/dist/router/public-directory-do.d.ts +17 -0
  33. package/dist/router/public-directory-do.d.ts.map +1 -0
  34. package/dist/router/public-directory-do.js +16 -0
  35. package/dist/router/public-directory.d.ts +75 -0
  36. package/dist/router/public-directory.d.ts.map +1 -0
  37. package/dist/router/public-directory.js +115 -0
  38. package/dist/router/remote-api.d.ts +9 -1
  39. package/dist/router/remote-api.d.ts.map +1 -1
  40. package/dist/router/remote-api.js +19 -2
  41. package/dist/runtime/node-shims.d.ts +4 -1
  42. package/dist/runtime/node-shims.d.ts.map +1 -1
  43. package/dist/runtime/node-shims.js +324 -6
  44. package/dist/session/agent.d.ts +11 -0
  45. package/dist/session/agent.d.ts.map +1 -1
  46. package/dist/session/helpers.js +1 -1
  47. package/dist/session/init-phases.d.ts +17 -23
  48. package/dist/session/init-phases.d.ts.map +1 -1
  49. package/dist/session/init-phases.js +17 -41
  50. package/dist/session/init.d.ts.map +1 -1
  51. package/dist/session/init.js +19 -15
  52. package/dist/session/keys.d.ts +8 -0
  53. package/dist/session/keys.d.ts.map +1 -1
  54. package/dist/session/keys.js +8 -0
  55. package/dist/session/nimbus-session.d.ts +29 -4
  56. package/dist/session/nimbus-session.d.ts.map +1 -1
  57. package/dist/session/nimbus-session.js +62 -9
  58. package/dist/session/port-capability.d.ts +95 -8
  59. package/dist/session/port-capability.d.ts.map +1 -1
  60. package/dist/session/port-capability.js +165 -8
  61. package/dist/session/programmatic.d.ts +50 -2
  62. package/dist/session/programmatic.d.ts.map +1 -1
  63. package/dist/session/programmatic.js +104 -6
  64. package/dist/session/routes.d.ts +17 -0
  65. package/dist/session/routes.d.ts.map +1 -1
  66. package/dist/session/routes.js +92 -25
  67. package/dist/session/rpc.d.ts.map +1 -1
  68. package/dist/session/rpc.js +14 -4
  69. package/dist/session/shell-socket.d.ts +76 -0
  70. package/dist/session/shell-socket.d.ts.map +1 -0
  71. package/dist/session/shell-socket.js +172 -0
  72. package/dist/session/start-real-vite.js +1 -1
  73. package/dist/session/supervisor-op.d.ts +5 -0
  74. package/dist/session/supervisor-op.d.ts.map +1 -0
  75. package/dist/session/supervisor-op.js +74 -0
  76. package/dist/session/supervisor-rpc.d.ts +6 -10
  77. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  78. package/dist/session/supervisor-rpc.js +91 -70
  79. package/dist/session/ws.d.ts.map +1 -1
  80. package/dist/session/ws.js +19 -4
  81. package/package.json +4 -4
  82. package/public/_assets/runtime/{node-shims-fea906843f6d594e.js → node-shims-9e1380d9ee430fcc.js} +320 -5
  83. package/public/s/index.html +101 -5
@@ -22,22 +22,26 @@ import { FACET_RESIDENT_STORE_SOURCE } from '../vfs/facet-resident-store.js';
22
22
  import { VFS_CURSOR_SEED_SOURCE, serializeFacetVfsCursor, } from '@nimbus-sh/core/_shared/facet-vfs-cursor.js';
23
23
  import { VFS_WRITE_LEDGER_SOURCE } from '@nimbus-sh/core/_shared/vfs-write-ledger.js';
24
24
  import { vfsPathExtension } from '@nimbus-sh/core/vfs/path.js';
25
- import { clearPortCapability } from '../session/port-capability.js';
25
+ import { clearPortCapability, readPortReservation, releasePortReservation, restoreReservedPortCapability } from '../session/port-capability.js';
26
+ import { PORT_CAPABILITY_KEY_PREFIX } from '../session/keys.js';
27
+ import { unbindPublicPortCapability } from '../router/public-directory.js';
26
28
  import { prefetchForRequire } from '@nimbus-sh/core/runtime/require-resolver.js';
27
29
  import { hasTopLevelModuleSyntax } from '@nimbus-sh/core/runtime/javascript-ast.js';
28
30
  import { bindImportMetaResolve, importMetaDefines } from '@nimbus-sh/core/runtime/import-meta-transform.js';
29
31
  import { recordFailure, getLastRpcFrame, getLastFacetId } from '@nimbus-sh/platform/oom-discriminator.js';
30
32
  import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
31
- import { TurnBudget, PacedWork, turnChunkMaxBytes } from '@nimbus-sh/fabric/turn-budget.js';
33
+ import { TurnBudget, PacedWork, turnChunkMaxBytes, withResolvers } from '@nimbus-sh/fabric/turn-budget.js';
32
34
  import { onColdStart } from '@nimbus-sh/fabric/generation.js';
33
- import { FencedWork, } from '@nimbus-sh/fabric/fenced-work.js';
34
- import { EsbuildService } from '@nimbus-sh/core/runtime/esbuild-service.js';
35
+ import { FencedWork, FENCED_WORK_KEY_PREFIX, } from '@nimbus-sh/fabric/fenced-work.js';
36
+ import { EsbuildService, rewriteBundledEsmToCjs, } from '@nimbus-sh/core/runtime/esbuild-service.js';
35
37
  import { isExecDiagEnabled, recordExecTelemetry } from './exec-telemetry.js';
36
38
  import { disposeRpcResource, disposeRpcResources } from '@nimbus-sh/platform/rpc-dispose.js';
37
39
  import { sqliteWasmModuleEntry } from './opencode-staging.js';
38
40
  import { ImageStore, } from '@nimbus-sh/fabric/image-store.js';
39
41
  import { ProcessFabric, } from '@nimbus-sh/fabric/process-fabric.js';
40
- import { createLoadedWorkerEntrypoint, getNimbusCtxExports, } from '@nimbus-sh/fabric/workerd-facet-host.js';
42
+ import { createLoadedWorkerEntrypoint, getNimbusCtxExports, deleteFacetStorage, } from '@nimbus-sh/fabric/workerd-facet-host.js';
43
+ import { acquireDurableFacetSlot, freeDurableFacetSlot, } from './durable-slots.js';
44
+ import { persistDurableWorkerImage, } from './durable-images.js';
41
45
  import { SQLITE_WASM_MODULE_NAME, } from '../runtime/opencode-facet-runner.js';
42
46
  import { parsePortFromArgv, resolveLongRunningPort } from '@nimbus-sh/core/runtime/long-running-handle.js';
43
47
  import { DEFAULT_FACET_BUNDLE_PROFILE, } from '@nimbus-sh/core/runtime/bundle-profile.js';
@@ -46,6 +50,7 @@ import { MAX_RPC_SAFE_PAYLOAD_BYTES } from '@nimbus-sh/platform/limits.js';
46
50
  import { CRED_KERNEL } from '@nimbus-sh/core/runtime/os-contracts.js';
47
51
  import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
48
52
  import { prefetchBundleStart, prefetchBundleEnd, setPrefetchCacheBytes, } from '@nimbus-sh/platform/diag-counters.js';
53
+ const ISOLATED_ESM_TRANSFORM_MIN_BYTES = 512 * 1024;
49
54
  /**
50
55
  * Detect & restore a Uint8Array that's been JSON-mangled to a
51
56
  * {"0":n,"1":n,...} object during the result-envelope round-trip.
@@ -120,6 +125,7 @@ function parseFacetManagerEnv(env) {
120
125
  LOADER: loader,
121
126
  ASSETS: assets,
122
127
  NIMBUS_LAUNCH_CHUNK_BYTES: typeof chunkBytes === 'string' ? chunkBytes : undefined,
128
+ rawEnv: env,
123
129
  };
124
130
  }
125
131
  /**
@@ -408,11 +414,11 @@ function __mkCompiledFn(code) {
408
414
  code = __nl >= 0 ? code.slice(__nl + 1) : "";
409
415
  }
410
416
  function renameIfDeclared(name) {
411
- const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "\\\\b", "m");
417
+ const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "(?![$\\\\w])", "m");
412
418
  return re.test(code) ? name + "__nimbus_unused" : name;
413
419
  }
414
420
  const baseParams = [
415
- "exports", "require", "module",
421
+ "exports", renameIfDeclared("require"), "module",
416
422
  renameIfDeclared("__filename"),
417
423
  renameIfDeclared("__dirname"),
418
424
  ];
@@ -433,23 +439,10 @@ const __MODULE_VFS_MANIFEST = ${safeManifest};
433
439
  const __MODULE_VFS_METADATA = ${safeMetadata};
434
440
  const __compiledModules = new Map();
435
441
  const __compileFailures = new Map();
436
- for (const [__p, __c] of Object.entries(__MODULE_VFS_BUNDLE)) {
437
- // Precompile JS modules AND extensionless CJS entries (bin scripts and
438
- // shims). workerd forbids new Function at request time, so anything not
439
- // precompiled here surfaces the misleading "file was not pre-bundled".
440
- // .json is data; skip it (it loads via JSON.parse, not as a function).
441
- const __base = __p.slice(__p.lastIndexOf("/") + 1);
442
- const __dot = __base.lastIndexOf(".");
443
- const __ext = __dot > 0 ? __base.slice(__dot) : "";
444
- if (__ext === ".js" || __ext === ".mjs" || __ext === ".cjs" || __ext === "") {
445
- if (typeof __c !== "string") continue;
446
- try {
447
- __compiledModules.set(__p, __mkCompiledFn(__c));
448
- } catch (__e) {
449
- __compileFailures.set(__p, __e && __e.message ? __e.message : String(__e));
450
- }
451
- }
452
- }
442
+ // Precompile JS modules AND extensionless CJS entries (bin scripts and
443
+ // shims). workerd forbids new Function at request time, so anything not
444
+ // precompiled here surfaces the misleading "file was not pre-bundled".
445
+ ${BUNDLE_PRECOMPILE_LOOP}
453
446
 
454
447
  class __ProcessExit extends Error {
455
448
  constructor(code) { super("process.exit(" + code + ")"); this.code = code; }
@@ -687,11 +680,11 @@ const __NimbusHostResponse = globalThis.Response;
687
680
 
688
681
  function __mkCompiledFn(code) {
689
682
  function renameIfDeclared(name) {
690
- const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "\\\\b", "m");
683
+ const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "(?![$\\\\w])", "m");
691
684
  return re.test(code) ? name + "__nimbus_unused" : name;
692
685
  }
693
686
  const baseParams = [
694
- "exports", "require", "module",
687
+ "exports", renameIfDeclared("require"), "module",
695
688
  renameIfDeclared("__filename"),
696
689
  renameIfDeclared("__dirname"),
697
690
  ];
@@ -719,15 +712,7 @@ const __compileFailures = new Map();
719
712
  // \`new Function\` throws "Code generation from strings disallowed" in the DO
720
713
  // constructor and at request time — so the require closure must be compiled
721
714
  // here, off the module map. That is why code cannot come from the store.
722
- for (const [__p, __c] of Object.entries(__MODULE_VFS_BUNDLE)) {
723
- if (__p.endsWith(".js") || __p.endsWith(".mjs") || __p.endsWith(".cjs")) {
724
- try {
725
- __compiledModules.set(__p, __mkCompiledFn(__c));
726
- } catch (__e) {
727
- __compileFailures.set(__p, __e && __e.message ? __e.message : String(__e));
728
- }
729
- }
730
- }
715
+ ${BUNDLE_PRECOMPILE_LOOP}
731
716
 
732
717
  ${FACET_RESIDENT_STORE_SOURCE}
733
718
 
@@ -1342,8 +1327,10 @@ export function encodedBundleSize(bundle, manifest) {
1342
1327
  function describeBundleCells(cells, limit = 8) {
1343
1328
  const shown = [...cells].sort((a, b) => b[1] - a[1]).slice(0, limit);
1344
1329
  const rest = cells.length - shown.length;
1345
- return shown.map(([path, bytes]) => `${path} (${bytes} bytes)`).join(', ')
1346
- + (rest > 0 ? `, +${rest} more` : '');
1330
+ return shown.map(([path, bytes]) => {
1331
+ const source = compiledCellPath(path);
1332
+ return source === null ? `${path} (${bytes} bytes)` : `${source} [compiled] (${bytes} bytes)`;
1333
+ }).join(', ') + (rest > 0 ? `, +${rest} more` : '');
1347
1334
  }
1348
1335
  /**
1349
1336
  * hardening-r5: emit a JS expression that revives binary cells from base64
@@ -1729,7 +1716,7 @@ function buildManifest(vfs, cwd, scriptPath) {
1729
1716
  return manifest;
1730
1717
  }
1731
1718
  function buildVfsMetadata(vfs, manifest, bundle) {
1732
- const paths = new Set(Object.keys(bundle));
1719
+ const paths = new Set(Object.keys(bundle).filter((path) => compiledCellPath(path) === null));
1733
1720
  for (const [directory, children] of Object.entries(manifest)) {
1734
1721
  paths.add(directory);
1735
1722
  for (const child of children) {
@@ -1803,6 +1790,16 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
1803
1790
  try {
1804
1791
  if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
1805
1792
  return false;
1793
+ // This is a guess at what a program might require, and the same
1794
+ // per-file ceiling the entry-package walk applies bounds it: a
1795
+ // multi-MiB main entry is an alternative bundle (typescript's 8.69 MiB
1796
+ // `lib/typescript.js` beside the `lib/tsc.js` that actually runs), and
1797
+ // one guess must not spend a third of the budget — and a third of the
1798
+ // supervisor's headroom — on every invocation that never reads it.
1799
+ // Anything the program really requires arrives through the closure,
1800
+ // which is uncapped.
1801
+ if (vfs.lstat(stripped).size > BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES)
1802
+ return false;
1806
1803
  // hardening-r5: preserve binary content as Uint8Array.
1807
1804
  const content = _readBundleCell(vfs, stripped);
1808
1805
  const cellLen = _bundleCellLength(content);
@@ -2530,18 +2527,22 @@ const RUNTIME_PACKAGE_EXCLUDED_FILE_SUFFIXES = [
2530
2527
  '.webm',
2531
2528
  ];
2532
2529
  /**
2533
- * Per-file ceiling for the speculative entry-package walk.
2530
+ * Per-file ceiling for the speculative passes over installed packages: the
2531
+ * entry-package walk (`addBinTargetSiblings`) and the main-entry oversample
2532
+ * (`greedyAddMainEntries`).
2534
2533
  *
2535
2534
  * Everything the entry package needs in order to *run* arrives through the
2536
- * require closure, which is uncapped and never evicted. This walk exists only
2537
- * to catch data files the static walker cannot see, and data files are small.
2538
- * Multi-MiB cells in a package tree are overwhelmingly alternative bundles —
2539
- * typescript ships an 8.69 MiB `lib/typescript.js` that is never read — rather
2540
- * than data.
2535
+ * require closure, which is uncapped and never evicted. These passes exist
2536
+ * only to catch what the static walker cannot see — data files, and modules
2537
+ * reached by a computed require — and those are small. Multi-MiB cells in a
2538
+ * package tree are overwhelmingly alternative bundles — typescript ships an
2539
+ * 8.69 MiB `lib/typescript.js` that `tsc` never reads — rather than data.
2541
2540
  *
2542
2541
  * So one speculative guess must not spend the budget every later invocation
2543
2542
  * then carries: the same reasoning as `CWD_SNAPSHOT_MAX_FILE_BYTES`, applied
2544
- * to the package tree.
2543
+ * to the package tree. A miss this causes is loud and self-repairing: the
2544
+ * facet reports the unstaged read and the next build stages it from the
2545
+ * residency ledger, which has no per-file rule.
2545
2546
  */
2546
2547
  const BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES = 4 * 1024 * 1024;
2547
2548
  function shouldIncludeBinPackageFile(pkgRoot, path, bundleProfile) {
@@ -2771,15 +2772,20 @@ function __cacheKey(src) {
2771
2772
  */
2772
2773
  function _markBundleEsmAsFailed(bundle, reason) {
2773
2774
  for (const path of Object.keys(bundle)) {
2775
+ if (compiledCellPath(path) !== null)
2776
+ continue;
2774
2777
  if (!isBundleModuleCandidate(path))
2775
2778
  continue;
2776
2779
  const src = bundle[path];
2777
2780
  if (typeof src !== 'string')
2778
2781
  continue;
2779
- if (!looksLikeEsm(src))
2782
+ // A TypeScript source is never runnable as staged, so it always needs
2783
+ // the emit it cannot get; a JavaScript file only if it is ESM.
2784
+ const typescript = bundleTypescriptLoader(path) !== null;
2785
+ if (!typescript && !looksLikeEsm(src))
2780
2786
  continue;
2781
2787
  const escapedReason = JSON.stringify(`esbuild transform failed for ${path}: ${reason}`);
2782
- bundle[path] =
2788
+ bundle[typescript ? compiledCellKey(path) : path] =
2783
2789
  '// framework-fixes-F4 diagnostic shim — esbuild rejected the ESM transform\n' +
2784
2790
  '(function () { throw new Error(' + escapedReason + '); })();\n';
2785
2791
  }
@@ -2810,13 +2816,89 @@ export function isBundleModuleCandidate(path) {
2810
2816
  * EXTENSION, where `.js` files transform on their content — `looksLikeEsm` is
2811
2817
  * the right question for a file that is already valid JS either way, and the
2812
2818
  * wrong one for a file that is never valid JS.
2819
+ *
2820
+ * A declaration file (`.d.ts`, `.d.mts`, `.d.cts`) is not a source: it has
2821
+ * no runtime form, nothing `require()`s one, and esbuild's output for it is
2822
+ * empty by definition. It is DATA — read by the program that ships it, which
2823
+ * is exactly typescript: `tsc` reads its own `lib/lib.*.d.ts` with
2824
+ * `readFileSync`, and every declaration it type-checks against comes from
2825
+ * those bytes. Transforming them handed the compiler an 811-byte license
2826
+ * comment where `lib.es5.d.ts` (217 KB) had been, and every global type was
2827
+ * gone. So a declaration file is left exactly as it was staged.
2813
2828
  */
2814
2829
  export function bundleTypescriptLoader(path) {
2830
+ if (isTypescriptDeclarationFile(path))
2831
+ return null;
2815
2832
  const ext = vfsPathExtension(path);
2816
2833
  if (ext === '.tsx')
2817
2834
  return 'tsx';
2818
2835
  return ext === '.ts' || ext === '.mts' || ext === '.cts' ? 'ts' : null;
2819
2836
  }
2837
+ /** `name.d.ts` / `name.d.mts` / `name.d.cts`, by TypeScript's own rule. */
2838
+ export function isTypescriptDeclarationFile(path) {
2839
+ const base = path.slice(path.lastIndexOf('/') + 1);
2840
+ return /\.d\.[mc]?ts$/.test(base);
2841
+ }
2842
+ /**
2843
+ * Where a bundle carries the EXECUTABLE form of a cell whose staged bytes are
2844
+ * not JavaScript — a TypeScript source.
2845
+ *
2846
+ * A bundle cell is two things to the facet: what `readFileSync` returns for
2847
+ * the path, and what the startup pre-compile loop turns into the function
2848
+ * `require` runs. For a JavaScript file those are the same bytes, so the
2849
+ * ESM→CJS pass rewrites the cell in place and both readers see JavaScript.
2850
+ * For a TypeScript source they are NOT the same: the bytes a program reads
2851
+ * are the source (tsc compiling its project, a test runner, a linter), and
2852
+ * the bytes `require` needs are esbuild's emit. Rewriting the cell in place
2853
+ * handed tsc esbuild's CommonJS rendering of its own `src/index.ts` — it
2854
+ * compiled that, and reported errors on lines the file never had.
2855
+ *
2856
+ * So the source cell stays exactly as staged, and the compiled form travels
2857
+ * under this key: a NUL-framed prefix no filesystem path can contain, in the
2858
+ * same map, so it is sized, split, cached, released and shipped by every
2859
+ * mechanism the bundle already has. The facet's pre-compile loop takes these
2860
+ * cells OFF the map as it compiles them, keyed by the real path, before any
2861
+ * read can see them.
2862
+ */
2863
+ const COMPILED_CELL_KEY_PREFIX = '\0compiled\0';
2864
+ export function compiledCellKey(path) {
2865
+ return COMPILED_CELL_KEY_PREFIX + path;
2866
+ }
2867
+ /** The source path a compiled-cell key stands for, or null for a plain path. */
2868
+ export function compiledCellPath(key) {
2869
+ return key.startsWith(COMPILED_CELL_KEY_PREFIX) ? key.slice(COMPILED_CELL_KEY_PREFIX.length) : null;
2870
+ }
2871
+ /**
2872
+ * The pre-compile loop both generated facets run at module evaluation, the
2873
+ * only moment workerd lets a string become code. One definition so the two
2874
+ * facets cannot drift on what they compile: every JavaScript-shaped cell
2875
+ * (`.js`, `.mjs`, `.cjs` and the extensionless bin scripts) from its own
2876
+ * bytes, and every TypeScript source from its compiled cell — which is
2877
+ * removed from the bundle here, so a `readFileSync` of the source path
2878
+ * returns the source and a directory listing never shows the key.
2879
+ */
2880
+ export const BUNDLE_PRECOMPILE_LOOP = `
2881
+ const __NIMBUS_COMPILED_PREFIX = ${JSON.stringify(COMPILED_CELL_KEY_PREFIX)};
2882
+ for (const [__p, __c] of Object.entries(__MODULE_VFS_BUNDLE)) {
2883
+ let __modulePath = __p;
2884
+ if (__p.startsWith(__NIMBUS_COMPILED_PREFIX)) {
2885
+ __modulePath = __p.slice(__NIMBUS_COMPILED_PREFIX.length);
2886
+ delete __MODULE_VFS_BUNDLE[__p];
2887
+ } else {
2888
+ // .json is data; skip it (it loads via JSON.parse, not as a function).
2889
+ const __base = __p.slice(__p.lastIndexOf("/") + 1);
2890
+ const __dot = __base.lastIndexOf(".");
2891
+ const __ext = __dot > 0 ? __base.slice(__dot) : "";
2892
+ if (__ext !== ".js" && __ext !== ".mjs" && __ext !== ".cjs" && __ext !== "") continue;
2893
+ }
2894
+ if (typeof __c !== "string") continue;
2895
+ try {
2896
+ __compiledModules.set(__modulePath, __mkCompiledFn(__c));
2897
+ } catch (__e) {
2898
+ __compileFailures.set(__modulePath, __e && __e.message ? __e.message : String(__e));
2899
+ }
2900
+ }
2901
+ `;
2820
2902
  /**
2821
2903
  * Transform every ESM-shaped file in the bundle to CJS via esbuild.
2822
2904
  * Mutates `bundle` in place. Errors are swallowed (the file is left as
@@ -2826,14 +2908,19 @@ export function bundleTypescriptLoader(path) {
2826
2908
  * Candidate set is `isBundleModuleCandidate`; within it, files with no
2827
2909
  * top-level import/export are already CJS-shaped and left alone.
2828
2910
  *
2911
+ * A JavaScript cell is rewritten in place. A TypeScript source keeps its
2912
+ * bytes and gets a compiled cell beside it — see `compiledCellKey`.
2913
+ *
2829
2914
  * Returns the count of files transformed (for diagnostics).
2830
2915
  */
2831
- async function transformEsmInBundle(bundle, esbuild, pacer) {
2916
+ async function transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform) {
2832
2917
  let transformed = 0;
2833
2918
  let failed = 0;
2834
2919
  // Snapshot the keys first — esbuild calls await; never iterate-and-mutate.
2835
2920
  const candidates = [];
2836
2921
  for (const path of Object.keys(bundle)) {
2922
+ if (compiledCellPath(path) !== null)
2923
+ continue;
2837
2924
  if (!isBundleModuleCandidate(path))
2838
2925
  continue;
2839
2926
  const src = bundle[path];
@@ -2841,14 +2928,19 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
2841
2928
  // esbuild.transform expect strings.
2842
2929
  if (typeof src !== 'string')
2843
2930
  continue;
2844
- if (bundleTypescriptLoader(path) === null && !looksLikeEsm(src))
2845
- continue;
2931
+ if (bundleTypescriptLoader(path) === null) {
2932
+ const esm = looksLikeEsm(src);
2933
+ if (!esm)
2934
+ continue;
2935
+ }
2846
2936
  candidates.push(path);
2847
2937
  }
2848
2938
  for (const path of candidates) {
2849
2939
  const src = bundle[path];
2850
2940
  if (typeof src !== 'string')
2851
2941
  continue;
2942
+ // A TypeScript source keeps its bytes; its emit lands beside it.
2943
+ const target = bundleTypescriptLoader(path) === null ? path : compiledCellKey(path);
2852
2944
  // esbuild-wasm runs inside this isolate, so a transform is computation
2853
2945
  // like any other pass — the await above it yields nothing on its own.
2854
2946
  if (pacer)
@@ -2871,19 +2963,25 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
2871
2963
  const key = __cacheKey(src + '\0' + absUrl);
2872
2964
  const cached = __esmTransformCache.get(key);
2873
2965
  if (cached) {
2874
- bundle[path] = cached;
2966
+ bundle[target] = cached;
2875
2967
  transformed++;
2876
2968
  continue;
2877
2969
  }
2878
2970
  try {
2879
- const t = await esbuild.transform(src, {
2971
+ const transformOptions = {
2880
2972
  loader: bundleTypescriptLoader(path) ?? 'js',
2881
2973
  format: 'cjs',
2882
2974
  target: 'esnext',
2883
2975
  define: importMetaDefines(absUrl),
2884
- });
2976
+ };
2977
+ const bounded = transformOptions.loader === 'js' && src.length >= ISOLATED_ESM_TRANSFORM_MIN_BYTES
2978
+ ? rewriteBundledEsmToCjs(src, absUrl)
2979
+ : null;
2980
+ const t = bounded ?? (isolatedTransform && src.length >= ISOLATED_ESM_TRANSFORM_MIN_BYTES
2981
+ ? await isolatedTransform(src, transformOptions)
2982
+ : await esbuild.transform(src, transformOptions));
2885
2983
  const code = bindImportMetaResolve(t.code, absUrl);
2886
- bundle[path] = code;
2984
+ bundle[target] = code;
2887
2985
  __esmTransformCache.set(key, code);
2888
2986
  transformed++;
2889
2987
  }
@@ -2905,7 +3003,6 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
2905
3003
  // __compiledModules, and the failure surfaces at require time
2906
3004
  // with FULL diagnostic context. Net effect for users: the same
2907
3005
  // upstream tool still fails, but they now see WHY.
2908
- //
2909
3006
  // Probe: tests/behavioral/npm-create/new/create-astro-diagnostic.mjs
2910
3007
  // asserts the user-visible error contains "esbuild transform" so
2911
3008
  // future diagnostic regressions are caught.
@@ -2916,7 +3013,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
2916
3013
  // this so repeated submits don't re-run the same transform.
2917
3014
  const diagnosticSrc = '// framework-fixes-F4 diagnostic shim — esbuild rejected the ESM transform\n' +
2918
3015
  '(function () { throw new Error(' + escapedReason + '); })();\n';
2919
- bundle[path] = diagnosticSrc;
3016
+ bundle[target] = diagnosticSrc;
2920
3017
  __esmTransformCache.set(key, diagnosticSrc);
2921
3018
  failed++;
2922
3019
  }
@@ -2939,7 +3036,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
2939
3036
  * behaviour for code paths that don't have esbuild handy).
2940
3037
  *
2941
3038
  */
2942
- export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer) {
3039
+ export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
2943
3040
  // This build accumulates raw VFS contents in the supervisor heap, and did it
2944
3041
  // with nothing watching: the estimator read 9.4 MiB while these bytes were
2945
3042
  // resetting the DO three times. Take the budget the enrichment passes are
@@ -2949,14 +3046,14 @@ export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbui
2949
3046
  const lease = await acquireSupervisorAllocation(VFS_BUNDLE_MAX_BYTES);
2950
3047
  prefetchBundleStart(VFS_BUNDLE_MAX_BYTES);
2951
3048
  try {
2952
- return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer);
3049
+ return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer, isolatedTransform);
2953
3050
  }
2954
3051
  finally {
2955
3052
  prefetchBundleEnd(VFS_BUNDLE_MAX_BYTES);
2956
3053
  lease.release();
2957
3054
  }
2958
3055
  }
2959
- async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer) {
3056
+ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
2960
3057
  // Read the cursor BEFORE the walk: a mutation that lands while the bundle
2961
3058
  // is being assembled must be reported as invalidated, not silently missed.
2962
3059
  const cursor = { epoch: vfs.epoch, rev: vfs.revision() };
@@ -3071,7 +3168,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3071
3168
  // "file was not pre-bundled" at request time.
3072
3169
  if (esbuild) {
3073
3170
  try {
3074
- await transformEsmInBundle(bundle, esbuild, pacer);
3171
+ await transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform);
3075
3172
  // Recount bytes after the transform — CJS rebuilds can be larger
3076
3173
  // OR smaller than the ESM source. We don't try to thread totalBytes
3077
3174
  // through the transform because the eviction loop below recomputes
@@ -3110,8 +3207,9 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3110
3207
  // went are named rather than silently dropped.
3111
3208
  const size = encodedBundleSize(bundle, manifest);
3112
3209
  if (size.bytes > BUNDLE_MAX_ENCODED_BYTES) {
3210
+ // A compiled cell goes with its source: required when the source is.
3113
3211
  const evictable = Object.keys(bundle)
3114
- .filter((path) => !requiredPaths.has(path))
3212
+ .filter((path) => !requiredPaths.has(compiledCellPath(path) ?? path))
3115
3213
  .sort((a, b) => _bundleCellLength(bundle[b]) - _bundleCellLength(bundle[a]));
3116
3214
  const evicted = [];
3117
3215
  for (const k of evictable) {
@@ -3151,6 +3249,16 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3151
3249
  };
3152
3250
  }
3153
3251
  const ROUTEABLE_PORT_ATTACH_TIMEOUT_MS = 1_000;
3252
+ /** A port request may wait this long for a durable app's re-drive to boot. */
3253
+ const DURABLE_ENSURE_BOOT_BUDGET_MS = 12_000;
3254
+ /**
3255
+ * The durable owner a journal row serves: the row's stamped field for
3256
+ * reservation-claimed residents, falling back to the worker recipe's own
3257
+ * owner for rows written before the stamping rule landed.
3258
+ */
3259
+ function residentOwner(record) {
3260
+ return record.owner ?? (record.recipe.kind === 'worker' ? record.recipe.owner : undefined);
3261
+ }
3154
3262
  /** 'running' is the measured common case: the platform's reset strikes
3155
3263
  * seconds after a launch settles, while the resident runs. */
3156
3264
  function residentLaunchDoing(record) {
@@ -3189,7 +3297,7 @@ export class FacetManager {
3189
3297
  * The resident-launch journal (fabric's fenced-work.ts): the durable
3190
3298
  * record of every resident this session owes the user, and its recovery
3191
3299
  * after an instance reset. This manager supplies what a launch IS — the
3192
- * inputs `_spawnResident` re-drives from — and how its loss is reported.
3300
+ * recipe `_redrive` re-drives from — and how its loss is reported.
3193
3301
  */
3194
3302
  launchJournal;
3195
3303
  /**
@@ -3236,12 +3344,13 @@ export class FacetManager {
3236
3344
  * prefetch cache above so a profile can only ever seed the bundle it was
3237
3345
  * measured against.
3238
3346
  *
3239
- * Lifetime is the supervisor incarnation's, same as the cache — a restart
3240
3347
  * costs one more loud failure and then relearns. Persisting it would be a
3241
3348
  * schema and a migration bought with nothing the in-memory form does not
3242
3349
  * already deliver for the case that matters: running the command again.
3243
3350
  */
3244
3351
  residencyProfiles = new Map();
3352
+ /** In-flight request-driven durable-app ensures, single-flight per port. */
3353
+ ensureInflight = new Map();
3245
3354
  static RESIDENCY_PROFILE_MAX_ENTRIES = 16;
3246
3355
  /**
3247
3356
  * A program that reads a directory of data files misses once per file, so
@@ -3271,7 +3380,7 @@ export class FacetManager {
3271
3380
  this.launchJournal = new FencedWork(ctx.storage, {
3272
3381
  generationBase: () => this.processes.pidBase,
3273
3382
  waitUntil: (promise) => this.ctx.waitUntil(promise),
3274
- redrive: (record, attempt) => this._spawnResident(record.code, record.opts, attempt),
3383
+ redrive: (record, attempt) => this._redrive(record, attempt),
3275
3384
  onRedrive: (record) => this.hooks.notify?.('\x1b[2m[nimbus: the session restarted while '
3276
3385
  + `"${record.command}" was ${residentLaunchDoing(record)} — restarting it]\x1b[0m\r\n`),
3277
3386
  onAbandoned: (record) => this.hooks.notify?.('\x1b[2m[nimbus: the session restarted again while '
@@ -3327,6 +3436,17 @@ export class FacetManager {
3327
3436
  unlink: (path) => fs.unlink(path),
3328
3437
  };
3329
3438
  }
3439
+ /**
3440
+ * The kernel-scoped VFS the durable image store reads and writes through —
3441
+ * `.nimbus/images/<sha256>` is session kernel data, not user content.
3442
+ */
3443
+ _imageVfs() {
3444
+ const vfs = this.vfs;
3445
+ if (!vfs) {
3446
+ throw new Error('Nimbus: a durable spawn needs a session filesystem to persist its launch image');
3447
+ }
3448
+ return vfs;
3449
+ }
3330
3450
  /**
3331
3451
  * W3.5 Fix B: hand the FacetManager a pre-warmed EsbuildService for
3332
3452
  * the ESM→CJS bundle pre-pass. NimbusSession already lazy-creates one
@@ -3413,7 +3533,7 @@ export class FacetManager {
3413
3533
  this.prefetchBundleCache.set(key, cached);
3414
3534
  return { ...cached.vfsState, cacheHit: true, cacheRetained: true };
3415
3535
  }
3416
- const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild || undefined, bundleProfile, this.residencyProfiles.get(key));
3536
+ const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild || undefined, bundleProfile, this.residencyProfiles.get(key), undefined, this.hooks.transformLargeEsm);
3417
3537
  vfsState.bundleKey = key;
3418
3538
  vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired);
3419
3539
  vfsState.serializedManifest = JSON.stringify(vfsState.manifest);
@@ -3624,12 +3744,7 @@ export class FacetManager {
3624
3744
  // CJS before they hit the facet's `new Function` pre-compile loop.
3625
3745
  // Lazy-create one if NimbusSession didn't share its own.
3626
3746
  if (this.vfs && !this.esbuild) {
3627
- try {
3628
- this.esbuild = new EsbuildService(this.vfs);
3629
- }
3630
- catch {
3631
- this.esbuild = null;
3632
- }
3747
+ this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
3633
3748
  }
3634
3749
  const diagOn = isExecDiagEnabled();
3635
3750
  const __bundleStart = diagOn ? Date.now() : 0;
@@ -3947,7 +4062,7 @@ export class FacetManager {
3947
4062
  this.imageStore.ensureDir();
3948
4063
  const processVfs = this.vfs?.as(entry.cred);
3949
4064
  const vfsState = processVfs
3950
- ? await buildPrefetchBundle(processVfs, undefined, opts.cwd, '', this.esbuild || undefined)
4065
+ ? await buildPrefetchBundle(processVfs, undefined, opts.cwd, '', this.esbuild || undefined, DEFAULT_FACET_BUNDLE_PROFILE, undefined, undefined, this.hooks.transformLargeEsm)
3951
4066
  : { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
3952
4067
  const vfsBundle = _serializeBundleForFacet(vfsState.bundle);
3953
4068
  assertStagedBundleFitsRpcPayload(vfsBundle, vfsState.bundle);
@@ -4418,6 +4533,39 @@ export class FacetManager {
4418
4533
  clearTimeout(timer);
4419
4534
  }
4420
4535
  }
4536
+ /**
4537
+ * Re-drive a journalled launch after an instance reset. What the journal
4538
+ * row carries is the recipe and nothing else: env and credentials are never
4539
+ * written to storage, so a worker launch's are re-resolved by the embedder
4540
+ * through `hooks.resolveWorkerLaunch`.
4541
+ */
4542
+ async _redrive(record, attempt) {
4543
+ const { recipe } = record;
4544
+ switch (recipe.kind) {
4545
+ case 'node': return this._spawnResident(recipe.code, recipe.opts, attempt);
4546
+ case 'worker': {
4547
+ // The embedder's resolver decides everything; only a self-owned
4548
+ // launch — nobody composed an embedder hook — falls through to the
4549
+ // session's image-store default.
4550
+ const resolve = this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
4551
+ if (!resolve)
4552
+ throw new Error('resident launch journal holds a worker launch but no resolveWorkerLaunch hook is composed');
4553
+ const resolved = await resolve(recipe);
4554
+ if (resolved === null)
4555
+ return undefined;
4556
+ const { 'worker.js': workerCode, ...modules } = resolved.modules;
4557
+ if (workerCode === undefined)
4558
+ throw new Error('resolved worker launch carries no worker.js module');
4559
+ return this._spawnWorker(workerCode, record.command, recipe.cwd, {
4560
+ port: recipe.port > 0 ? recipe.port : undefined, modules, compatibilityDate: recipe.compatibilityDate,
4561
+ compatibilityFlags: recipe.compatibilityFlags, startArgs: recipe.startArgs,
4562
+ env: resolved.env ?? undefined, globalOutbound: resolved.globalOutbound,
4563
+ vfsWasmModules: resolved.vfsWasmModules,
4564
+ durable: { owner: recipe.owner, image: recipe.image },
4565
+ }, attempt);
4566
+ }
4567
+ }
4568
+ }
4421
4569
  /**
4422
4570
  * Spawn a long-running Node process with the same shimmed require/fs/http
4423
4571
  * environment used by foreground `node <script>` execution.
@@ -4503,17 +4651,51 @@ export class FacetManager {
4503
4651
  // sessions kept dying. The row is finally released by the supervisor's
4504
4652
  // terminal hook when the process ends. A re-drive owns its own process,
4505
4653
  // so the caller's pid goes with the instance that had it.
4654
+ //
4655
+ // Durability is a property of the RESERVATION, not the recipe: a resident
4656
+ // that declares a port at spawn claims the durable contract the port's
4657
+ // reservation declares — the owner's durable slot for a store and the
4658
+ // row's port/owner for `ensureDurableAppOnPort`. A resident whose port is
4659
+ // only learned at listen() gets the same journal+capability durability at
4660
+ // registration (see `registerPort`), but keeps its ephemeral facet name —
4661
+ // the store cannot be re-rooted under a running facet.
4662
+ let durableFacetName;
4663
+ let durablePort;
4664
+ let durableOwner;
4665
+ if (opts.port !== undefined && opts.port > 0 && opts.port < 65536) {
4666
+ const reservation = await readPortReservation(this.ctx, opts.port);
4667
+ if (reservation !== null && reservation.owner !== null) {
4668
+ durableOwner = reservation.owner;
4669
+ durablePort = opts.port;
4670
+ durableFacetName = await acquireDurableFacetSlot(this.ctx, durableOwner);
4671
+ }
4672
+ }
4506
4673
  const record = {
4507
4674
  pid: entry.pid,
4508
4675
  command,
4509
- code,
4510
- opts: { ...opts, skipSpawn: undefined, callerPid: undefined },
4511
4676
  attempt,
4512
4677
  phase: 'starting',
4678
+ recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
4679
+ ...(durablePort !== undefined ? { port: durablePort, owner: durableOwner } : {}),
4513
4680
  };
4514
- await this.launchJournal.journal(record);
4515
4681
  try {
4516
- await this._residentLaunchBody(entry, code, command, cwd, opts, pacer);
4682
+ await this.launchJournal.journal(record);
4683
+ }
4684
+ catch (e) {
4685
+ // A resident that cannot be journalled does not start; the failure is
4686
+ // reported the way a boot failure is.
4687
+ pacer.settle();
4688
+ this.processes.exit(entry.pid, 1);
4689
+ const reason = 'long-running node launch failed: ' + errorMessage(e);
4690
+ this._w5RecordTermination(entry.pid, 1, 'facet', reason);
4691
+ try {
4692
+ this.hooks.onExternalExit?.(entry.pid, 1, reason);
4693
+ }
4694
+ catch { }
4695
+ throw e;
4696
+ }
4697
+ try {
4698
+ await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName);
4517
4699
  }
4518
4700
  catch (e) {
4519
4701
  await this.launchJournal.release(entry.pid);
@@ -4530,14 +4712,9 @@ export class FacetManager {
4530
4712
  await this.launchJournal.journal({ ...record, attempt: 0, phase: 'running' });
4531
4713
  }
4532
4714
  }
4533
- async _residentLaunchBody(entry, code, command, cwd, opts, pacer) {
4715
+ async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName) {
4534
4716
  if (this.vfs && !this.esbuild) {
4535
- try {
4536
- this.esbuild = new EsbuildService(this.vfs);
4537
- }
4538
- catch {
4539
- this.esbuild = null;
4540
- }
4717
+ this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
4541
4718
  }
4542
4719
  if (this.vfs)
4543
4720
  this.imageStore.ensureDir();
@@ -4545,7 +4722,7 @@ export class FacetManager {
4545
4722
  const __bundleStart = diagOn ? Date.now() : 0;
4546
4723
  const processVfs = this.vfs?.as(entry.cred);
4547
4724
  const vfsState = processVfs
4548
- ? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer)
4725
+ ? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer, this.hooks.transformLargeEsm)
4549
4726
  : { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
4550
4727
  const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
4551
4728
  const processEnv = opts.attachedTty
@@ -4602,6 +4779,12 @@ export class FacetManager {
4602
4779
  // life; the server/watch runner returns once it is up.
4603
4780
  startContract: opts.attachedTty ? 'lifetime' : 'boot',
4604
4781
  startArgs: { vfsCursor },
4782
+ // A resident whose declared port is reserved binds the owner's
4783
+ // durable slot — the same store a durable worker spawn takes — so the
4784
+ // reservation's durability reaches this process's storage too.
4785
+ ...(durableFacetName !== undefined
4786
+ ? { facet: { name: durableFacetName, durable: true } }
4787
+ : {}),
4605
4788
  boot: {
4606
4789
  kind: 'code',
4607
4790
  code: {
@@ -4675,10 +4858,7 @@ export class FacetManager {
4675
4858
  await handle.booted();
4676
4859
  }
4677
4860
  if (opts.port && opts.port > 0 && opts.port < 65536) {
4678
- // A new process on a port retires the previous occupant's preview
4679
- // capability, so a URL handed out for that one cannot reach this one.
4680
- await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port);
4681
- this.portRegistry.register(opts.port, entry.pid);
4861
+ await this._registerResidentPort(entry.pid, opts.port);
4682
4862
  }
4683
4863
  }
4684
4864
  catch (e) {
@@ -4711,6 +4891,10 @@ export class FacetManager {
4711
4891
  * promise, so nothing polls the port to decide the process is up.
4712
4892
  */
4713
4893
  async spawnWorker(workerCode, command, cwd, opts = {}) {
4894
+ return this._spawnWorker(workerCode, command, cwd, opts, 0);
4895
+ }
4896
+ /** `attempt` is the journal's re-drive budget, as `_spawnResident` carries it. */
4897
+ async _spawnWorker(workerCode, command, cwd, opts, attempt) {
4714
4898
  this.processes.reap();
4715
4899
  const entry = this.processes.spawn(command, [], cwd);
4716
4900
  // Stamp the process-table entry so /api/processes exposes this as a
@@ -4722,35 +4906,130 @@ export class FacetManager {
4722
4906
  this.hooks.onSpawn?.(entry.pid, command, true);
4723
4907
  }
4724
4908
  catch { }
4909
+ const compatibilityDate = opts.compatibilityDate ?? CF_COMPAT_DATE;
4910
+ const compatibilityFlags = opts.compatibilityFlags || ['nodejs_compat'];
4725
4911
  let handle;
4726
4912
  let resourcesTracked = false;
4913
+ let record;
4914
+ let durableFacetName;
4727
4915
  try {
4916
+ if (opts.durable) {
4917
+ // A durable spawn on a declared port starts only when a reservation the
4918
+ // owner already holds is persisted. Validated before the journal row is
4919
+ // written and before the process boots, so a foreign or absent
4920
+ // reservation refuses the launch rather than stealing the exposure.
4921
+ if (opts.port && opts.port > 0 && opts.port < 65536) {
4922
+ const preFlight = await readPortReservation(this.ctx, opts.port);
4923
+ if (preFlight === null || preFlight.owner !== opts.durable.owner) {
4924
+ throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
4925
+ }
4926
+ }
4927
+ // A live outbound binding cannot be journalled — a reset would re-drive
4928
+ // the recipe with nothing to stand in for it. Only the EMBEDDER's
4929
+ // resolveWorkerLaunch can re-mint one: the session's fallback reads
4930
+ // the image store, which never carries a binding, so a durable spawn
4931
+ // carrying globalOutbound is refused where no embedder hook exists.
4932
+ if (opts.globalOutbound !== undefined && opts.globalOutbound !== null
4933
+ && this.hooks.resolveWorkerLaunch === undefined) {
4934
+ throw new Error('Nimbus: a durable spawn carrying a live globalOutbound binding '
4935
+ + 'cannot be journalled without an embedder resolveWorkerLaunch hook');
4936
+ }
4937
+ // Self-owned applications persist their launch inputs as image blobs —
4938
+ // a runner blob for worker.js, an application blob for modules + env —
4939
+ // under .nimbus/images/<sha256>, and the journal row names the digests
4940
+ // of what was written, never placeholder strings. An embedder-owned
4941
+ // spawn is given digests by its own bookkeeping instead.
4942
+ const image = opts.durable.image
4943
+ ?? await persistDurableWorkerImage(this._imageVfs(), workerCode, {
4944
+ modules: opts.modules ?? {},
4945
+ ...(opts.env !== undefined ? { env: opts.env } : {}),
4946
+ vfsWasmModules: opts.vfsWasmModules,
4947
+ });
4948
+ // Journalled before the launch's first byte of work, so a row a
4949
+ // later instance reads proves this process never ended.
4950
+ record = {
4951
+ pid: entry.pid,
4952
+ command,
4953
+ attempt,
4954
+ phase: 'starting',
4955
+ recipe: {
4956
+ kind: 'worker',
4957
+ owner: opts.durable.owner,
4958
+ image,
4959
+ port: opts.port ?? 0,
4960
+ cwd,
4961
+ compatibilityDate,
4962
+ compatibilityFlags,
4963
+ startArgs: opts.startArgs,
4964
+ },
4965
+ // The row-level fields `ensureDurableAppOnPort` reads — same values
4966
+ // the recipe carries, stamped so port/owner lookup never depends on
4967
+ // the recipe's kind.
4968
+ ...(opts.port !== undefined && opts.port > 0
4969
+ ? { port: opts.port, owner: opts.durable.owner }
4970
+ : {}),
4971
+ };
4972
+ // The durable facet name is claimed once, ever, from DO storage — a
4973
+ // re-drive after a reset, an eviction's re-attach and a relaunch all
4974
+ // land on the same `app-slot-<n>`, which is the only thing that keeps
4975
+ // the retained SQLite bound to this application.
4976
+ durableFacetName = await acquireDurableFacetSlot(this.ctx, opts.durable.owner);
4977
+ await this.launchJournal.journal(record);
4978
+ }
4728
4979
  handle = await this._startResidentProcess(entry.pid, {
4729
4980
  // These runners answer startProcess with a boot payload (listening
4730
4981
  // port, or a completed non-server run) and stay resident after it.
4731
4982
  startContract: 'boot',
4732
4983
  startArgs: opts.startArgs,
4984
+ ...(durableFacetName !== undefined
4985
+ ? { facet: { name: durableFacetName, durable: true } }
4986
+ : {}),
4733
4987
  boot: {
4734
4988
  kind: 'code',
4735
4989
  code: {
4736
- compatibilityDate: CF_COMPAT_DATE,
4737
- compatibilityFlags: opts.compatibilityFlags || ['nodejs_compat'],
4990
+ compatibilityDate,
4991
+ compatibilityFlags,
4738
4992
  mainModule: 'worker.js',
4739
4993
  modules: { 'worker.js': workerCode, ...(opts.modules || {}) },
4740
4994
  vfsWasmModules: opts.vfsWasmModules,
4995
+ ...(opts.env !== undefined ? { env: opts.env } : {}),
4996
+ ...(opts.globalOutbound !== undefined ? { globalOutbound: opts.globalOutbound } : {}),
4741
4997
  },
4742
4998
  },
4743
4999
  });
4744
5000
  this.trackProcessRpcResources(entry.pid, [handle]);
4745
5001
  resourcesTracked = true;
4746
5002
  this.portRegistry.bindFacetStub(entry.pid, handle.routeTarget);
5003
+ const boot = await handle.booted();
5004
+ if (opts.durable && this.launchJournal.has(entry.pid) && record) {
5005
+ // Booted and running: the launch proved itself, so the resident
5006
+ // starts its running life with a fresh re-drive budget.
5007
+ await this.launchJournal.journal({ ...record, attempt: 0, phase: 'running' });
5008
+ }
4747
5009
  if (opts.port && opts.port > 0 && opts.port < 65536) {
4748
- // A new process on a port retires the previous occupant's preview
4749
- // capability, so a URL handed out for that one cannot reach this one.
4750
- await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port);
4751
- this.portRegistry.register(opts.port, entry.pid);
5010
+ if (opts.durable) {
5011
+ // Re-read after the boot: a release or reassignment during it wins.
5012
+ // The durable launch registers only under a reservation it still
5013
+ // owns; it never clears or takes a foreign exposure.
5014
+ const reservation = await readPortReservation(this.ctx, opts.port);
5015
+ if (reservation === null || reservation.owner !== opts.durable.owner) {
5016
+ throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
5017
+ }
5018
+ // The owner's hold on the port survives the instance reset that
5019
+ // re-drove this launch, and preview URLs minted against it stay
5020
+ // valid: the durable capability is re-adopted through the
5021
+ // reservation's own path, gated on the stored owner.
5022
+ this.portRegistry.register(opts.port, entry.pid);
5023
+ await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port, opts.durable.owner);
5024
+ }
5025
+ else {
5026
+ // A new process on a port retires the previous occupant's preview
5027
+ // capability, so a URL handed out for that one cannot reach this one.
5028
+ await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port);
5029
+ this.portRegistry.register(opts.port, entry.pid);
5030
+ }
4752
5031
  }
4753
- return { pid: entry.pid, boot: await handle.booted() };
5032
+ return { pid: entry.pid, boot };
4754
5033
  }
4755
5034
  catch (e) {
4756
5035
  this.portRegistry.unregisterByPid(entry.pid);
@@ -4768,10 +5047,44 @@ export class FacetManager {
4768
5047
  throw e;
4769
5048
  }
4770
5049
  }
5050
+ /**
5051
+ * A resident process announcing it bound `port`. When the port is reserved,
5052
+ * the reservation's owner is stamped onto this pid's journal row — the
5053
+ * reservation is what declares which application the port serves, and a
5054
+ * resident that binds it inherits the whole durable contract: the row names
5055
+ * the port `ensureDurableAppOnPort` looks up, the minted capability is
5056
+ * re-adopted rather than retired, and `removeDurableApp` can find the launch
5057
+ * by owner.
5058
+ *
5059
+ * A pid with no journal row — a process outside the resident lifecycle —
5060
+ * registering on a reserved port takes it as today: the stored capability
5061
+ * is retired and a fresh one is minted, the reservation stays with the
5062
+ * owner. An accidental port reuse inside one session therefore cannot
5063
+ * inherit the public capability; same-session processes are one trust
5064
+ * domain, so this is hygiene, not a security boundary.
5065
+ */
5066
+ async _registerResidentPort(pid, port) {
5067
+ const reservation = await readPortReservation(this.ctx, port);
5068
+ const rowKey = `${FENCED_WORK_KEY_PREFIX}${pid}`;
5069
+ const rows = await this.launchJournal.rows();
5070
+ const row = rows.get(rowKey);
5071
+ if (reservation !== null && reservation.owner !== null && row !== undefined) {
5072
+ if (row.port !== port || residentOwner(row) !== reservation.owner) {
5073
+ await this.launchJournal.journal({ ...row, port, owner: reservation.owner });
5074
+ }
5075
+ this.portRegistry.register(port, pid);
5076
+ await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port, reservation.owner);
5077
+ return;
5078
+ }
5079
+ // A new process on an unreserved port — or one the journal does not know —
5080
+ // retires the previous occupant's preview capability, so a URL handed out
5081
+ // for that one cannot reach this one.
5082
+ await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
5083
+ this.portRegistry.register(port, pid);
5084
+ }
4771
5085
  async registerPort(pid, port) {
4772
5086
  if (port > 0 && port < 65536) {
4773
- await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
4774
- this.portRegistry.register(port, pid);
5087
+ await this._registerResidentPort(pid, port);
4775
5088
  }
4776
5089
  }
4777
5090
  waitForRouteablePorts(pid, timeoutMs = ROUTEABLE_PORT_ATTACH_TIMEOUT_MS) {
@@ -4809,5 +5122,149 @@ export class FacetManager {
4809
5122
  this._teardownPairedServeFacet(pid);
4810
5123
  return result;
4811
5124
  }
5125
+ /**
5126
+ * Remove a durable application: the ONLY path that deletes durable facet
5127
+ * storage. Owner-checked by construction — `freeDurableFacetSlot` answers
5128
+ * only a slot the owner actually holds, and the reservation release refuses
5129
+ * a foreign owner's record — so another owner's name cannot be reached.
5130
+ *
5131
+ * One ordered teardown: the live process is killed first (a released
5132
+ * durable facet only aborts, so nothing else ends it), the port reservation
5133
+ * is released, the journal rows for the owner are purged (nothing is owed a
5134
+ * removed application), the facet's SQLite is deleted, and the slot row is
5135
+ * freed last — so a crash mid-removal leaves a name still claimed rather
5136
+ * than a store nobody can re-drive.
5137
+ */
5138
+ async removeDurableApp(owner) {
5139
+ // Kill any live process this owner still has — a durable release aborts
5140
+ // without deleting, so the store outlives the process unless removal
5141
+ // ends it here.
5142
+ const journalRows = await this.ctx.storage.list({ prefix: 'resident-launch:' });
5143
+ const ownedPids = [];
5144
+ const ownedPorts = new Set();
5145
+ for (const [, record] of journalRows) {
5146
+ // Owner-stamped rows cover every kind the reservation claims — a node
5147
+ // resident that bound the port is as much the application as a durable
5148
+ // worker spawn is.
5149
+ if (residentOwner(record) !== owner)
5150
+ continue;
5151
+ ownedPids.push(record.pid);
5152
+ const rowPort = record.port ?? (record.recipe.kind === 'worker' ? record.recipe.port : 0);
5153
+ if (rowPort > 0)
5154
+ ownedPorts.add(rowPort);
5155
+ }
5156
+ for (const pid of ownedPids)
5157
+ this.kill(pid);
5158
+ // The reservation may outlive every journal row (spawn never finished
5159
+ // writing one), so scan the port records for the owner too.
5160
+ const portRows = await this.ctx.storage.list({ prefix: PORT_CAPABILITY_KEY_PREFIX });
5161
+ for (const [key, record] of portRows) {
5162
+ if (record?.owner === owner)
5163
+ ownedPorts.add(Number(key.slice(PORT_CAPABILITY_KEY_PREFIX.length)));
5164
+ // A public capability leaves the routing directory with the row —
5165
+ // before the reservation is released so a mid-removal crash leaves a
5166
+ // dead URL rather than a dangling route to this session.
5167
+ if (record?.owner === owner && record.visibility === 'public' && typeof record.capability === 'string') {
5168
+ await unbindPublicPortCapability(this._publicDirectoryHost(), record.capability);
5169
+ }
5170
+ }
5171
+ for (const port of ownedPorts) {
5172
+ await releasePortReservation(this.ctx, { owner, port });
5173
+ this.portRegistry.unregister(port);
5174
+ }
5175
+ const purged = await this.launchJournal.purgeWhere((record) => residentOwner(record) === owner);
5176
+ const name = await freeDurableFacetSlot(this.ctx, owner);
5177
+ if (name !== null) {
5178
+ try {
5179
+ deleteFacetStorage(this.ctx, name);
5180
+ }
5181
+ catch { /* already gone */ }
5182
+ }
5183
+ return name !== null || purged > 0 || ownedPorts.size > 0;
5184
+ }
5185
+ /** The session-shaped view the public-directory helpers read env from. */
5186
+ _publicDirectoryHost() {
5187
+ return { env: this.env.rawEnv, ctx: this.ctx };
5188
+ }
5189
+ /**
5190
+ * Whether a request addressed to `port` can reach a durable application —
5191
+ * and, when the application is journaled but dead, drive its re-drive and
5192
+ * wait for the boot, bounded.
5193
+ *
5194
+ * The port request is the one surface a reset leaves dark: the alarm pump
5195
+ * re-drives journaled launches eventually, but a URL a user is holding
5196
+ * cannot wait for an alarm that may never fire. 'started' means a live
5197
+ * process owns the port now; 'absent' means nothing durable claims it
5198
+ * (the caller answers 502 as it always has); 'failed' means a re-drive
5199
+ * ran and lost, or outlived its bound — the caller answers 503 and lets
5200
+ * the page re-ask.
5201
+ *
5202
+ * Single-flight per port: parallel requests on a woken page share one
5203
+ * ensure, which shares the journal's per-row drive with recovery — a
5204
+ * request that lands mid-recovery waits on that boot, it never boots a
5205
+ * second process.
5206
+ */
5207
+ ensureDurableAppOnPort(port) {
5208
+ let inflight = this.ensureInflight.get(port);
5209
+ if (inflight === undefined) {
5210
+ inflight = this._ensureDurableAppOnPort(port);
5211
+ this.ensureInflight.set(port, inflight);
5212
+ inflight.finally(() => {
5213
+ if (this.ensureInflight.get(port) === inflight)
5214
+ this.ensureInflight.delete(port);
5215
+ });
5216
+ }
5217
+ return inflight;
5218
+ }
5219
+ async _ensureDurableAppOnPort(port) {
5220
+ if (this.portRegistry.has(port))
5221
+ return 'started';
5222
+ // The reservation says the port means something to someone; the journal
5223
+ // row is what makes it a launch this instance owes. A reservation with
5224
+ // no row (the spawn died before journalling, or the app was removed and
5225
+ // the release already ran) answers absent — nothing here can bring back
5226
+ // an application with no recipe.
5227
+ const reservation = await readPortReservation(this.ctx, port);
5228
+ if (reservation === null || reservation.owner === null)
5229
+ return 'absent';
5230
+ const rows = await this.launchJournal.rows();
5231
+ // The reservation names the owner; the row's stamped port names which
5232
+ // resident serves it — kind-agnostic, so a node resident that bound the
5233
+ // port re-drives the same way a durable worker launch does.
5234
+ const entry = [...rows].find(([, record]) => record.port === port && residentOwner(record) === reservation.owner);
5235
+ if (entry === undefined)
5236
+ return 'absent';
5237
+ const [rowKey, record] = entry;
5238
+ if (record.pid > this.processes.pidBase) {
5239
+ // This instance's own row: the launch is already building — waiting
5240
+ // for its port registration is the entire ask, and driving the row
5241
+ // again would boot a second copy.
5242
+ return (await this._waitForPort(port, DURABLE_ENSURE_BOOT_BUDGET_MS))
5243
+ ? 'started' : 'failed';
5244
+ }
5245
+ const { promise: boundHit, resolve: markBound } = withResolvers();
5246
+ setTimeout(() => markBound(true), DURABLE_ENSURE_BOOT_BUDGET_MS);
5247
+ const failed = await Promise.race([
5248
+ this.launchJournal.drive(rowKey, record),
5249
+ boundHit,
5250
+ ]);
5251
+ if (failed)
5252
+ return 'failed';
5253
+ // A settled drive either owns the port or deliberately answered 'gone':
5254
+ // a resolver's null superseded the row, which is absent, not failed.
5255
+ return this.portRegistry.has(port) ? 'started' : 'absent';
5256
+ }
5257
+ /** Poll for a port registration the in-flight launch has not made yet. */
5258
+ async _waitForPort(port, budgetMs) {
5259
+ const deadline = Date.now() + budgetMs;
5260
+ while (Date.now() < deadline) {
5261
+ if (this.portRegistry.has(port))
5262
+ return true;
5263
+ const { promise: tick, resolve: tickDone } = withResolvers();
5264
+ setTimeout(tickDone, 50);
5265
+ await tick;
5266
+ }
5267
+ return this.portRegistry.has(port);
5268
+ }
4812
5269
  get stats() { return this.processes.stats; }
4813
5270
  }