@nimbus-sh/core 0.3.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/dist/_shared/error-text.d.ts +15 -0
  4. package/dist/_shared/error-text.d.ts.map +1 -0
  5. package/dist/_shared/error-text.js +21 -0
  6. package/dist/_shared/exports-resolver.d.ts +19 -7
  7. package/dist/_shared/exports-resolver.d.ts.map +1 -1
  8. package/dist/_shared/exports-resolver.js +1 -1
  9. package/dist/_shared/retry.d.ts.map +1 -1
  10. package/dist/_shared/retry.js +3 -1
  11. package/dist/_shared/tarball-stream.d.ts +93 -0
  12. package/dist/_shared/tarball-stream.d.ts.map +1 -0
  13. package/dist/_shared/tarball-stream.js +235 -0
  14. package/dist/_shared/tarball.d.ts +17 -0
  15. package/dist/_shared/tarball.d.ts.map +1 -0
  16. package/dist/_shared/tarball.js +39 -0
  17. package/dist/_shared/w7-frame.js +2 -1
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +30 -16
  20. package/dist/observability/oom-classify.d.ts +1 -1
  21. package/dist/observability/oom-classify.d.ts.map +1 -1
  22. package/dist/observability/oom-classify.js +7 -0
  23. package/dist/observability/oom-discriminator.d.ts +1 -1
  24. package/dist/runtime/clang-runner.d.ts +38 -0
  25. package/dist/runtime/clang-runner.d.ts.map +1 -0
  26. package/dist/runtime/clang-runner.js +866 -0
  27. package/dist/runtime/esbuild-service.d.ts +4 -3
  28. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  29. package/dist/runtime/esbuild-service.js +9 -4
  30. package/dist/runtime/facet-host.d.ts +12 -0
  31. package/dist/runtime/facet-host.d.ts.map +1 -1
  32. package/dist/runtime/installed-runtimes.d.ts +4 -3
  33. package/dist/runtime/installed-runtimes.d.ts.map +1 -1
  34. package/dist/runtime/local-facet-host.d.ts.map +1 -1
  35. package/dist/runtime/local-facet-host.js +29 -9
  36. package/dist/runtime/long-running-handle.d.ts +3 -3
  37. package/dist/runtime/long-running-handle.d.ts.map +1 -1
  38. package/dist/runtime/os-contracts.d.ts +7 -5
  39. package/dist/runtime/os-contracts.d.ts.map +1 -1
  40. package/dist/runtime/port-registry.d.ts +28 -0
  41. package/dist/runtime/port-registry.d.ts.map +1 -1
  42. package/dist/runtime/port-registry.js +79 -9
  43. package/dist/runtime/process-logs.d.ts +9 -1
  44. package/dist/runtime/process-logs.d.ts.map +1 -1
  45. package/dist/runtime/project-detect.d.ts +6 -1
  46. package/dist/runtime/project-detect.d.ts.map +1 -1
  47. package/dist/runtime/require-resolver.d.ts.map +1 -1
  48. package/dist/runtime/require-resolver.js +3 -4
  49. package/dist/runtime/ruby-gems.d.ts +30 -0
  50. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  51. package/dist/runtime/ruby-gems.js +636 -0
  52. package/dist/runtime/ruby-runner.d.ts +127 -0
  53. package/dist/runtime/ruby-runner.d.ts.map +1 -0
  54. package/dist/runtime/ruby-runner.js +1357 -0
  55. package/dist/runtime/runtime-registry.d.ts +4 -3
  56. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  57. package/dist/runtime/runtime-registry.js +2 -1
  58. package/dist/runtime/session-process-supervisor.d.ts +15 -0
  59. package/dist/runtime/session-process-supervisor.d.ts.map +1 -1
  60. package/dist/runtime/session-process-supervisor.js +30 -0
  61. package/dist/runtime/sqlite-runtime-fs-bridge.js +1 -5
  62. package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -1
  63. package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
  64. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -1
  65. package/dist/runtime/virtual-socket-kernel.generated.js +2 -2
  66. package/dist/runtime/virtual-socket-kernel.js +10 -5
  67. package/dist/runtime/wasm-runner.d.ts.map +1 -1
  68. package/dist/runtime/wasm-runner.js +22 -14
  69. package/dist/shell/features.d.ts.map +1 -1
  70. package/dist/shell/features.js +5 -4
  71. package/dist/shell/unix-commands.d.ts +14 -1
  72. package/dist/shell/unix-commands.d.ts.map +1 -1
  73. package/dist/shell/unix-commands.js +115 -69
  74. package/dist/substrate/lifo/commands/net/ip.d.ts.map +1 -1
  75. package/dist/substrate/lifo/commands/system/ps.d.ts +2 -1
  76. package/dist/substrate/lifo/commands/system/ps.d.ts.map +1 -1
  77. package/dist/substrate/lifo/kernel/ServiceManager.d.ts.map +1 -1
  78. package/dist/substrate/lifo/kernel/ServiceManager.js +0 -2
  79. package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts +22 -11
  80. package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts.map +1 -1
  81. package/dist/substrate/lifo/kernel/network/NetworkStack.js +0 -2
  82. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts +2 -1
  83. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts.map +1 -1
  84. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.js +3 -4
  85. package/dist/substrate/lifo/kernel/vfs/VFS.d.ts +17 -0
  86. package/dist/substrate/lifo/kernel/vfs/VFS.d.ts.map +1 -1
  87. package/dist/substrate/lifo/kernel/vfs/VFS.js +33 -0
  88. package/dist/substrate/lifo/node-compat/esbuild.d.ts.map +1 -1
  89. package/dist/substrate/lifo/node-compat/esbuild.js +2 -4
  90. package/dist/substrate/lifo/node-compat/index.d.ts.map +1 -1
  91. package/dist/substrate/lifo/node-compat/index.js +3 -2
  92. package/dist/substrate/lifo/node-compat/loose-equality.d.ts +19 -0
  93. package/dist/substrate/lifo/node-compat/loose-equality.d.ts.map +1 -0
  94. package/dist/substrate/lifo/node-compat/loose-equality.js +91 -0
  95. package/dist/substrate/lifo/node-compat/process.d.ts.map +1 -1
  96. package/dist/substrate/lifo/node-compat/process.js +6 -3
  97. package/dist/substrate/lifo/shell/Shell.d.ts +19 -0
  98. package/dist/substrate/lifo/shell/Shell.d.ts.map +1 -1
  99. package/dist/substrate/lifo/shell/Shell.js +86 -75
  100. package/dist/substrate/lifo/shell/lexer.d.ts +9 -0
  101. package/dist/substrate/lifo/shell/lexer.d.ts.map +1 -1
  102. package/dist/substrate/lifo/shell/lexer.js +40 -0
  103. package/dist/vfs/seed-project.d.ts.map +1 -1
  104. package/dist/vfs/seed-project.js +8 -2
  105. package/dist/vfs/sqlite-vfs.d.ts +61 -1
  106. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  107. package/dist/vfs/sqlite-vfs.js +199 -35
  108. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  109. package/dist/workspace/nimbus-workspace.js +9 -2
  110. package/package.json +4 -2
  111. package/src/_shared/error-text.ts +21 -0
  112. package/src/_shared/exports-resolver.ts +24 -4
  113. package/src/_shared/retry.ts +4 -2
  114. package/src/_shared/tarball-stream.ts +263 -0
  115. package/src/_shared/tarball.ts +46 -0
  116. package/src/_shared/w7-frame.ts +2 -1
  117. package/src/constants.ts +30 -16
  118. package/src/observability/oom-classify.ts +7 -0
  119. package/src/observability/oom-discriminator.ts +1 -1
  120. package/src/runtime/clang-runner.ts +924 -0
  121. package/src/runtime/esbuild-service.ts +16 -11
  122. package/src/runtime/facet-host.ts +12 -0
  123. package/src/runtime/installed-runtimes.ts +4 -3
  124. package/src/runtime/local-facet-host.ts +28 -8
  125. package/src/runtime/long-running-handle.ts +3 -3
  126. package/src/runtime/os-contracts.ts +7 -5
  127. package/src/runtime/port-registry.ts +97 -9
  128. package/src/runtime/process-logs.ts +12 -3
  129. package/src/runtime/project-detect.ts +7 -1
  130. package/src/runtime/require-resolver.ts +7 -7
  131. package/src/runtime/ruby-gems.ts +682 -0
  132. package/src/runtime/ruby-runner.ts +1484 -0
  133. package/src/runtime/runtime-registry.ts +9 -7
  134. package/src/runtime/session-process-supervisor.ts +27 -0
  135. package/src/runtime/sqlite-runtime-fs-bridge.ts +9 -6
  136. package/src/runtime/virtual-socket-kernel.generated.ts +2 -2
  137. package/src/runtime/virtual-socket-kernel.ts +10 -5
  138. package/src/runtime/wasm-runner.ts +55 -29
  139. package/src/shell/features.ts +5 -4
  140. package/src/shell/unix-commands.ts +273 -128
  141. package/src/substrate/lifo/commands/net/ip.ts +15 -15
  142. package/src/substrate/lifo/commands/system/ps.ts +2 -1
  143. package/src/substrate/lifo/kernel/ServiceManager.ts +0 -2
  144. package/src/substrate/lifo/kernel/network/NetworkStack.ts +24 -17
  145. package/src/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.ts +37 -12
  146. package/src/substrate/lifo/kernel/vfs/VFS.ts +34 -0
  147. package/src/substrate/lifo/node-compat/esbuild.ts +16 -8
  148. package/src/substrate/lifo/node-compat/index.ts +3 -2
  149. package/src/substrate/lifo/node-compat/loose-equality.ts +81 -0
  150. package/src/substrate/lifo/node-compat/process.ts +5 -2
  151. package/src/substrate/lifo/shell/Shell.ts +92 -70
  152. package/src/substrate/lifo/shell/lexer.ts +38 -0
  153. package/src/vfs/seed-project.ts +10 -3
  154. package/src/vfs/sqlite-vfs.ts +209 -36
  155. package/src/workspace/nimbus-workspace.ts +10 -1
@@ -19,8 +19,9 @@
19
19
 
20
20
  import type { CredentialedVfs, SqliteVFS } from '../vfs/sqlite-vfs.js';
21
21
  import { CRED_KERNEL } from './os-contracts.js';
22
- import { resolvePackageEntry, resolveExports } from '../_shared/exports-resolver.js';
22
+ import { resolvePackageEntry, resolveExports, type ResolvablePackageJson } from '../_shared/exports-resolver.js';
23
23
  import { normalizeVfsPath, stripLeadingSlashes } from '../vfs/path.js';
24
+ import { errorText } from '../_shared/error-text.js';
24
25
 
25
26
  /**
26
27
  * Bundler version tag. BUMP THIS whenever bundling semantics change —
@@ -175,7 +176,6 @@ export function hasTopLevelAwait(src: string): boolean {
175
176
  // inside `(...)`; a `{` only opens a function body when a `function`/
176
177
  // `=>` is pending AND we're at parenDepth 0 (past the param list).
177
178
  const re = /\b(await|function|class)\b|=>|\{|\}|\(|\)/g;
178
- let m: RegExpExecArray | null;
179
179
  const fnEntryDepths: number[] = [];
180
180
  let depth = 0;
181
181
  let parenDepth = 0;
@@ -189,7 +189,7 @@ export function hasTopLevelAwait(src: string): boolean {
189
189
  // `x => ({ ... })` (object-returning expression body, `{` at parenDepth
190
190
  // 1) does not leak a body slot onto a later top-level block.
191
191
  let arrowPending = false;
192
- while ((m = re.exec(stripped)) !== null) {
192
+ for (let m = re.exec(stripped); m !== null; m = re.exec(stripped)) {
193
193
  const tok = m[0];
194
194
  if (arrowPending && tok !== '{') arrowPending = false;
195
195
  if (tok === '(') { parenDepth++; }
@@ -803,7 +803,7 @@ async function loadEsbuild(): Promise<typeof esbuild> {
803
803
  export interface TransformResult {
804
804
  code: string;
805
805
  map: string;
806
- warnings: { text: string; location?: any }[];
806
+ warnings: { text: string; location?: esbuild.Location | null }[];
807
807
  }
808
808
 
809
809
  export interface BuildOutputFile {
@@ -813,8 +813,8 @@ export interface BuildOutputFile {
813
813
 
814
814
  export interface BuildResult {
815
815
  outputFiles: BuildOutputFile[];
816
- errors: { text: string; location?: any }[];
817
- warnings: { text: string; location?: any }[];
816
+ errors: { text: string; location?: esbuild.Location | null }[];
817
+ warnings: { text: string; location?: esbuild.Location | null }[];
818
818
  }
819
819
 
820
820
  // ── EsbuildService ──────────────────────────────────────────────────────
@@ -876,7 +876,11 @@ export class EsbuildService {
876
876
  let initTimeout: ReturnType<typeof setTimeout> | null = null;
877
877
  await Promise.race([
878
878
  esb.initialize({
879
- wasmModule: esbuildWasmUrl as any,
879
+ // wrangler resolves this static `.wasm` import to a compiled
880
+ // module at bundle time; the asset stub for a `.wasm` module can
881
+ // only declare its default export as a string, and the guard
882
+ // above is what checks the resolution actually happened.
883
+ wasmModule: esbuildWasmUrl as unknown as WebAssembly.Module,
880
884
  worker: false,
881
885
  }),
882
886
  new Promise<never>((_, reject) => {
@@ -890,14 +894,15 @@ export class EsbuildService {
890
894
  }),
891
895
  ]).finally(() => { if (initTimeout) clearTimeout(initTimeout); });
892
896
  this.initialized = true;
893
- } catch (e: any) {
897
+ } catch (e) {
898
+ const message = errorText(e);
894
899
  // "Cannot call initialize more than once" means it's already ready
895
- if (e?.message?.includes('more than once')) {
900
+ if (message.includes('more than once')) {
896
901
  this.initialized = true;
897
902
  return;
898
903
  }
899
904
  this.initPromise = null;
900
- throw new Error('esbuild init failed: ' + (e?.message || e));
905
+ throw new Error('esbuild init failed: ' + message);
901
906
  }
902
907
  })();
903
908
 
@@ -1315,7 +1320,7 @@ export class EsbuildService {
1315
1320
  if (vfs.exists(strip(nmDir)) && vfs.isDirectory(strip(nmDir))) {
1316
1321
  // Read package.json so we can consult the exports field.
1317
1322
  const pkgJsonPath = nmDir + '/package.json';
1318
- let pkgJson: any = null;
1323
+ let pkgJson: ResolvablePackageJson | null = null;
1319
1324
  if (vfs.exists(strip(pkgJsonPath))) {
1320
1325
  try { pkgJson = JSON.parse(vfs.readFileString(strip(pkgJsonPath))); } catch {}
1321
1326
  }
@@ -79,6 +79,18 @@ export interface FacetSpec {
79
79
  syscalls?: FacetSyscalls;
80
80
  /** Facets the host may keep warm for this spec. Default 1. */
81
81
  concurrency?: number;
82
+ /**
83
+ * Who a warm facet may be reused FOR. Default `session`.
84
+ *
85
+ * A scope keeps whatever the last call left in it, so by default it may only
86
+ * ever answer for the session that opened it. `global` is a claim the
87
+ * RUNTIME makes about itself and one only a pure function can make: no
88
+ * syscalls binding, nothing retained between calls, output decided entirely
89
+ * by the arguments. The clang toolchain is that — a compile is the same
90
+ * compile for every tenant — and one warm isolate holding its 31 MiB of
91
+ * compiled compiler serves all of them.
92
+ */
93
+ reuse?: 'session' | 'global';
82
94
  }
83
95
 
84
96
  /** The session a facet's syscalls reach, and who they reach it as. */
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import type { CredentialedVfs, SqliteVFS } from '../vfs/sqlite-vfs.js';
20
+ import type { Command } from '../substrate/lifo/commands/types.js';
20
21
  import {
21
22
  CRED_KERNEL,
22
23
  NIMBUS_ABI_TARGET,
@@ -32,9 +33,9 @@ import {
32
33
 
33
34
  /** Minimal shell-registry shape we depend on. */
34
35
  export interface MinShellRegistry {
35
- register(name: string, handler: (ctx: any) => Promise<number>): void;
36
+ register(name: string, handler: Command): void;
36
37
  unregister?(name: string): void;
37
- resolve?(name: string): any;
38
+ resolve?(name: string): Promise<Command | null | undefined> | Command | null | undefined;
38
39
  }
39
40
 
40
41
  /** Runner-factory contract. Each registered runner produces a shell-
@@ -46,7 +47,7 @@ export type RunnerFactory = (
46
47
  installRoot: string,
47
48
  binName: string,
48
49
  binKind: string | undefined,
49
- ) => (ctx: any) => Promise<number>;
50
+ ) => Command;
50
51
 
51
52
  /**
52
53
  * How a manifest entrypoint's `runner` key is resolved to code.
@@ -113,12 +113,21 @@ export function localFacetHost(): FacetHost {
113
113
  root: string,
114
114
  options?: FacetFilesystemOptions,
115
115
  ): FacetFilesystemSeed | { error: string } {
116
- return snapshotVfs(vfs, root, {
116
+ const seeded = snapshotVfs(vfs, root, {
117
117
  extraRoots: options?.extraRoots,
118
118
  skipSubdirs: [],
119
119
  maxBytes: LOCAL_SEED_MAX_BYTES,
120
120
  maxFiles: LOCAL_SEED_MAX_FILES,
121
121
  });
122
+ if ('error' in seeded) return seeded;
123
+ // The seed is also the WHOLE of what this guest has, which is a stronger
124
+ // claim than the walk's: `snapshotVfs` reports the roots it listed
125
+ // exhaustively, and this says every path outside them is absent too. It
126
+ // is — a miss out there could only be answered by suspending the guest
127
+ // mid-syscall, which this host cannot do, so the alternative to "absent"
128
+ // is not "fetched" but a read the guest can never receive. Ruby's VM
129
+ // startup stats dozens of prefixes it was never given.
130
+ return { ...seeded, snapshot: { ...seeded.snapshot, enumeratedRoots: [''] } };
122
131
  },
123
132
  open: (spec) => new LocalFacet(spec),
124
133
  };
@@ -152,8 +161,7 @@ class LocalFacet implements Facet {
152
161
 
153
162
  private async call<A, R>(fn: FacetFn<A, R>, args: A, options?: FacetSubmitOptions): Promise<R> {
154
163
  if (this.disposed) throw new Error(`Nimbus: facet '${this.spec.tag}' is disposed`);
155
- const scope = await this.scope();
156
- await this.addModules(options?.wasmModules);
164
+ const scope = await this.scope(options?.wasmModules);
157
165
  let scoped = this.scoped.get(fn as FacetFn<never, unknown>);
158
166
  if (!scoped) {
159
167
  const value = scope(`(${fn.toString()})`);
@@ -172,17 +180,29 @@ class LocalFacet implements Facet {
172
180
  * The returned closure's `eval` is a DIRECT eval inside the body the preamble
173
181
  * was evaluated in, which is what puts the preamble's top-level declarations
174
182
  * in scope for every function submitted afterwards.
183
+ *
184
+ * Both wasm tables are filled BEFORE that body runs, per-call images merged
185
+ * over the spec's. A preamble may boot its runtime as it is evaluated — Ruby
186
+ * instantiates the interpreter right there — so it reads the table at that
187
+ * moment and an image added afterwards would arrive to a facet that had
188
+ * already given up on it. workerd has the same ordering for the same reason:
189
+ * per-call images ride in the module map the inner worker is built from.
175
190
  */
176
- private async scope(): Promise<(source: string) => unknown> {
177
- if (this.evaluate) return this.evaluate;
178
- await this.addModules(this.spec.wasmModules);
191
+ private async scope(
192
+ callModules: Record<string, ArrayBuffer> | undefined,
193
+ ): Promise<(source: string) => unknown> {
194
+ const built = this.evaluate;
195
+ if (!built) await this.addModules(this.spec.wasmModules);
196
+ await this.addModules(callModules);
197
+ if (built) return built;
179
198
  const globals = { __NIMBUS_WASM: this.wasmTable };
180
199
  const build = new AsyncFunction(
181
200
  'globalThis',
182
201
  `${this.spec.preamble ?? ''}\nreturn (source) => eval(source);`,
183
202
  );
184
- this.evaluate = await build.call(globals, globals) as (source: string) => unknown;
185
- return this.evaluate;
203
+ const evaluate = await build.call(globals, globals) as (source: string) => unknown;
204
+ this.evaluate = evaluate;
205
+ return evaluate;
186
206
  }
187
207
 
188
208
  private async addModules(modules: Record<string, ArrayBuffer> | undefined): Promise<void> {
@@ -107,7 +107,7 @@ export function parsePortFromArgv(argv: string[]): number | null {
107
107
  */
108
108
  export function resolveLongRunningPort(opts: {
109
109
  argv?: string[];
110
- env?: Record<string, any> | undefined;
110
+ env?: Record<string, string> | undefined;
111
111
  configPort?: number | undefined;
112
112
  fallback: number;
113
113
  }): number {
@@ -152,7 +152,7 @@ export function resolveLongRunningPort(opts: {
152
152
  */
153
153
  export function expandShellDefaults(
154
154
  token: string,
155
- env: Record<string, any> | undefined,
155
+ env: Record<string, string> | undefined,
156
156
  ): string {
157
157
  if (!token || token.indexOf('${') < 0) return token;
158
158
  const lookup = (name: string): string | undefined => {
@@ -179,7 +179,7 @@ export function expandShellDefaults(
179
179
  */
180
180
  export function expandArgvShellDefaults(
181
181
  argv: ReadonlyArray<string>,
182
- env: Record<string, any> | undefined,
182
+ env: Record<string, string> | undefined,
183
183
  ): string[] {
184
184
  return argv.map((t) => expandShellDefaults(t, env));
185
185
  }
@@ -312,11 +312,12 @@ export interface RuntimeTtyOptions {
312
312
  /**
313
313
  * Where a registered port's traffic goes: one process, reached by HTTP.
314
314
  *
315
- * The whole of what a listening process exposes, deliberately. A resident
316
- * process never receives a WebSocket every inbound socket Nimbus serves
317
- * terminates on the session and reaches the process, if at all, as events on a
318
- * poll so a target that could return a 101 would describe a case no runner
319
- * produces.
315
+ * Ordinary HTTP may cross Workers RPC as Request/Response values. A WebSocket
316
+ * upgrade may not: its 101 Response owns a live socket, and RPC's
317
+ * Request/Response transport reconstructs the value rather than handing over
318
+ * the socket. So a target that can serve an upgrade exposes a separate
319
+ * fetch-semantic entrypoint, and every hop of an upgrade stays on the HTTP
320
+ * service-binding path. A target that omits it serves HTTP only.
320
321
  *
321
322
  * Lives here rather than beside the registry because both ends need it and
322
323
  * neither owns it: the port registry stores these, and the process fabric
@@ -325,6 +326,7 @@ export interface RuntimeTtyOptions {
325
326
  */
326
327
  export interface RouteableFacetTarget {
327
328
  handleHttpRequest(request: Request): Promise<Response>;
329
+ handleWebSocketRequest?(request: Request): Promise<Response>;
328
330
  }
329
331
 
330
332
  export interface RuntimePortBridge {
@@ -40,6 +40,17 @@ export interface PortEntry {
40
40
  */
41
41
  facetStub: RouteableFacetTarget | null;
42
42
  registeredAt: number;
43
+ /**
44
+ * Unguessable token for the lifetime of THIS registration, so an embedder
45
+ * can hand out one port's traffic without handing out the session. A fresh
46
+ * one per `register`, which is what makes an unexposed port stay unexposed.
47
+ */
48
+ capability: string;
49
+ }
50
+
51
+ function createPortCapability(): string {
52
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
53
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
43
54
  }
44
55
 
45
56
  export class PortRegistry {
@@ -64,7 +75,13 @@ export class PortRegistry {
64
75
  */
65
76
  register(port: number, pid: number): void {
66
77
  const target = this.facetStubsByPid.get(pid) ?? null;
67
- this.ports.set(port, { port, pid, facetStub: target, registeredAt: Date.now() });
78
+ this.ports.set(port, {
79
+ port,
80
+ pid,
81
+ facetStub: target,
82
+ registeredAt: Date.now(),
83
+ capability: createPortCapability(),
84
+ });
68
85
  this.notifyPortWaiters(pid);
69
86
  }
70
87
 
@@ -126,6 +143,24 @@ export class PortRegistry {
126
143
  return [...this.ports.values()];
127
144
  }
128
145
 
146
+ /** Answer only whether this capability matches, never what is registered. */
147
+ hasCapability(port: number, capability: string): boolean {
148
+ return this.ports.get(port)?.capability === capability;
149
+ }
150
+
151
+ /**
152
+ * Re-adopt a capability the embedder was already handed, after the supervisor
153
+ * was rebuilt. A restored dev server is a NEW registration with a new token,
154
+ * which would silently invalidate every preview URL already in circulation
155
+ * across an eviction — so the durable value wins over the fresh one.
156
+ */
157
+ restoreCapability(port: number, capability: string): boolean {
158
+ const entry = this.ports.get(port);
159
+ if (!entry || !/^[a-f0-9]{24}$/.test(capability)) return false;
160
+ entry.capability = capability;
161
+ return true;
162
+ }
163
+
129
164
  /**
130
165
  * Forward an HTTP request to the facet owning a port.
131
166
  *
@@ -148,6 +183,36 @@ export class PortRegistry {
148
183
  * client receives.
149
184
  */
150
185
  async routeRequest(port: number, request: Request, pathname: string): Promise<Response | null> {
186
+ return this.routeRequestInternal(port, request, pathname, false);
187
+ }
188
+
189
+ /**
190
+ * Route a request a trusted embedder has already authenticated against the
191
+ * port's capability and stripped its own credentials from.
192
+ *
193
+ * Unlike the generic route, this one PRESERVES `Authorization`. The generic
194
+ * route strips it because the header it sees is Nimbus's own, and handing a
195
+ * session credential to untrusted code is the thing that must never happen.
196
+ * Here the embedder has removed that credential already and what remains
197
+ * belongs to the guest application, which needs it to authenticate its own
198
+ * users.
199
+ */
200
+ async routeCapabilityRequest(
201
+ port: number,
202
+ capability: string,
203
+ request: Request,
204
+ pathname: string,
205
+ ): Promise<Response | null> {
206
+ if (!this.hasCapability(port, capability)) return null;
207
+ return this.routeRequestInternal(port, request, pathname, true);
208
+ }
209
+
210
+ private async routeRequestInternal(
211
+ port: number,
212
+ request: Request,
213
+ pathname: string,
214
+ preserveAuthorization: boolean,
215
+ ): Promise<Response | null> {
151
216
  const entry = this.ports.get(port);
152
217
  // Honour the documented contract: no entry at all → null, so callers
153
218
  // report "no process listening". (Pre-fix this fell into the 501 below,
@@ -189,7 +254,12 @@ export class PortRegistry {
189
254
  // if a body is supplied on those methods.
190
255
  const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
191
256
  const headers = new Headers(request.headers);
257
+ // The strip below removes Nimbus's own credentials. On a capability
258
+ // route the embedder has already removed those, so what is left belongs
259
+ // to the guest application and has to survive the hop.
260
+ const authorization = preserveAuthorization ? headers.get('authorization') : null;
192
261
  sanitizeUntrustedHeaders(headers);
262
+ if (authorization) headers.set('authorization', authorization);
193
263
 
194
264
  headers.set('X-Nimbus-Port', String(port));
195
265
  // `duplex: 'half'` is required by workerd when body is a
@@ -205,11 +275,19 @@ export class PortRegistry {
205
275
  if (hasBody) init.duplex = 'half';
206
276
  const forwarded = new Request(innerUrl.toString(), init);
207
277
 
208
- // RPC: the facet receives the Request and returns a Response.
209
- // Both cross the isolate boundary via Workers RPC's native
210
- // Request/Response transport bytes are streamed with
211
- // flow-control, never materialised.
212
- const response: Response = await entry.facetStub.handleHttpRequest(forwarded);
278
+ // HTTP crosses Workers RPC as Request/Response values, streamed with
279
+ // flow control and never materialised. A WebSocket upgrade cannot: its
280
+ // 101 owns a live socket, so it takes the fetch-semantic entrypoint,
281
+ // which every hop preserves. A target with no such entrypoint serves
282
+ // HTTP only, and says so rather than dropping the socket.
283
+ const isWebSocket = request.headers.get('upgrade')?.toLowerCase() === 'websocket';
284
+ const handler = isWebSocket
285
+ ? entry.facetStub.handleWebSocketRequest
286
+ : entry.facetStub.handleHttpRequest;
287
+ if (!handler) {
288
+ return new Response('Port target does not expose a WebSocket fetch route', { status: 501 });
289
+ }
290
+ const response: Response = await handler(forwarded);
213
291
 
214
292
  if (!(response instanceof Response)) {
215
293
  // Defensive: if a facet ever returns something else (JSON
@@ -286,10 +364,20 @@ export class PortRegistry {
286
364
 
287
365
  function routeableFacetTarget(value: unknown): RouteableFacetTarget | null {
288
366
  if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return null;
289
- const method = Reflect.get(value, 'fetch') || Reflect.get(value, 'handleHttpRequest');
290
- if (typeof method !== 'function') return null;
367
+ // A `fetch` target already has fetch semantics, so it serves both.
368
+ const fetchMethod = Reflect.get(value, 'fetch');
369
+ if (typeof fetchMethod === 'function') {
370
+ const fetch = fetchMethod.bind(value) as (request: Request) => Promise<Response>;
371
+ return { handleHttpRequest: fetch, handleWebSocketRequest: fetch };
372
+ }
373
+ const httpMethod = Reflect.get(value, 'handleHttpRequest');
374
+ if (typeof httpMethod !== 'function') return null;
375
+ const webSocketMethod = Reflect.get(value, 'handleWebSocketRequest');
291
376
  return {
292
- handleHttpRequest: method.bind(value),
377
+ handleHttpRequest: httpMethod.bind(value),
378
+ ...(typeof webSocketMethod === 'function'
379
+ ? { handleWebSocketRequest: webSocketMethod.bind(value) }
380
+ : {}),
293
381
  };
294
382
  }
295
383
 
@@ -61,6 +61,15 @@ export interface SequencedLogChunk extends LogChunk {
61
61
  seq: number;
62
62
  }
63
63
 
64
+ /**
65
+ * A chunk as a persist adapter hands it back on load. `seq` is the sequence
66
+ * number the adapter stored alongside it; hydration falls back to array
67
+ * position for a row that carries none.
68
+ */
69
+ export interface PersistedLogChunk extends LogChunk {
70
+ seq?: number;
71
+ }
72
+
64
73
  export interface ProcessLogReadOptions {
65
74
  cursor?: number;
66
75
  lines?: number;
@@ -130,7 +139,7 @@ interface PidState {
130
139
  * against the SQLite engine); KV is not used by W9.
131
140
  */
132
141
  export interface PersistAdapter {
133
- load(pid: number): { chunks: LogChunk[]; exit: ProcessExitInfo | null } | null;
142
+ load(pid: number): { chunks: PersistedLogChunk[]; exit: ProcessExitInfo | null } | null;
134
143
  persistChunks(pid: number, rows: { seq: number; chunk: LogChunk }[]): void;
135
144
  persistExit(pid: number, info: ProcessExitInfo): void;
136
145
  dropPid(pid: number): void;
@@ -710,7 +719,7 @@ export class ProcessLogStore {
710
719
  if (state.hydrated) return;
711
720
  state.hydrated = true;
712
721
  if (!this._persist) return;
713
- let loaded: { chunks: LogChunk[]; exit: ProcessExitInfo | null } | null;
722
+ let loaded: { chunks: PersistedLogChunk[]; exit: ProcessExitInfo | null } | null;
714
723
  try {
715
724
  loaded = this._persist.load(pid);
716
725
  } catch {
@@ -736,7 +745,7 @@ export class ProcessLogStore {
736
745
  let highestSeq = -1;
737
746
  for (let i = loaded.chunks.length - 1; i >= 0; i--) {
738
747
  const c = loaded.chunks[i];
739
- const seq = (c as any).seq ?? i;
748
+ const seq = c.seq ?? i;
740
749
  if (seq > highestSeq) highestSeq = seq;
741
750
  const size = c.data.length;
742
751
  if (bytes + size > this.perPidBytes && newest.length > 0) {
@@ -10,6 +10,12 @@
10
10
  * can add detectVite, detectNext, etc. here.
11
11
  */
12
12
 
13
+ /** The filesystem reads a project probe needs. */
14
+ export interface ProjectProbeFs {
15
+ exists(path: string): boolean;
16
+ readFileString(path: string): string;
17
+ }
18
+
13
19
  /**
14
20
  * W10: detect whether the project at `<root>` is a Cloudflare Workers
15
21
  * project. Returns true if any of the standard markers are present:
@@ -18,7 +24,7 @@
18
24
  * - <root>/wrangler.toml
19
25
  * - <root>/package.json with `wrangler` in deps or devDeps
20
26
  */
21
- export function detectCloudflareWorkersProject(vfs: any, root: string): boolean {
27
+ export function detectCloudflareWorkersProject(vfs: ProjectProbeFs, root: string): boolean {
22
28
  const r = String(root).replace(/^\/+/, '').replace(/\/+$/, '');
23
29
  for (const f of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
24
30
  try {
@@ -33,6 +33,7 @@ import {
33
33
  resolveExports as sharedResolveExports,
34
34
  DEFAULT_CJS_CONDITIONS,
35
35
  DEFAULT_ESM_CONDITIONS,
36
+ type ResolvablePackageJson,
36
37
  } from '../_shared/exports-resolver.js';
37
38
  import {
38
39
  TYPESCRIPT_INDEX_CANDIDATES,
@@ -146,7 +147,7 @@ function resolveFile(vfs: CredentialedVfs, base: string, sink?: PkgJsonSink): st
146
147
  const baseTrim = base.replace(/\/+$/, '');
147
148
  const pkgJsonPath = normalizePath(baseTrim + '/package.json');
148
149
  if (vfs.exists(pkgJsonPath) && !vfs.isDirectory(pkgJsonPath)) {
149
- let pkg: any = null;
150
+ let pkg: ResolvablePackageJson | null = null;
150
151
  try { pkg = JSON.parse(vfs.readFileString(pkgJsonPath)); } catch { /* fall through */ }
151
152
  if (pkg && typeof pkg.main === 'string' && pkg.main.length > 0) {
152
153
  // Record this package.json so the bundle carries the content the
@@ -257,7 +258,7 @@ function resolvePkgSubpathEx(vfs: CredentialedVfs, pkgDir: string, subpath: stri
257
258
  // fallback (consistent behaviour across the no-pkgjson branch).
258
259
  return tryLegacyDirectorySubpath(vfs, pkgDir, subpath, sink);
259
260
  }
260
- let pkg: { exports?: any; module?: string; main?: string };
261
+ let pkg: ResolvablePackageJson;
261
262
  try { pkg = JSON.parse(vfs.readFileString(pkgJsonPath)); }
262
263
  catch {
263
264
  const r = resolveFile(vfs, pkgDir + '/index', sink);
@@ -477,7 +478,7 @@ function resolveImportsField(
477
478
  while (true) {
478
479
  const pkgJsonPath = (dir ? dir + '/' : '') + 'package.json';
479
480
  if (vfs.exists(pkgJsonPath) && !vfs.isDirectory(pkgJsonPath)) {
480
- let pkg: any = null;
481
+ let pkg: ResolvablePackageJson | null = null;
481
482
  try { pkg = JSON.parse(vfs.readFileString(pkgJsonPath)); } catch { /* malformed */ }
482
483
  // First package.json wins (Node spec), even if no imports field.
483
484
  if (pkg && pkg.imports) {
@@ -618,8 +619,7 @@ export function prefetchForRequire(
618
619
  const stripped = stripCommentsForImports(code);
619
620
 
620
621
  REQUIRE_RE.lastIndex = 0;
621
- let match;
622
- while ((match = REQUIRE_RE.exec(stripped)) !== null) {
622
+ for (let match = REQUIRE_RE.exec(stripped); match !== null; match = REQUIRE_RE.exec(stripped)) {
623
623
  const specifier = match[2];
624
624
  if (isFacetProvided(specifier)) continue;
625
625
  const r = resolveRequireEx(vfs, specifier, fromDir, addPkgJson);
@@ -635,7 +635,7 @@ export function prefetchForRequire(
635
635
  // runtime W3.5 Fix B's CJS rewrite calls require('./x') which then
636
636
  // fails because `x` was never added.
637
637
  IMPORT_RE.lastIndex = 0;
638
- while ((match = IMPORT_RE.exec(stripped)) !== null) {
638
+ for (let match = IMPORT_RE.exec(stripped); match !== null; match = IMPORT_RE.exec(stripped)) {
639
639
  const specifier = match[2];
640
640
  if (isFacetProvided(specifier)) continue;
641
641
  const r = resolveRequireEx(vfs, specifier, fromDir, addPkgJson);
@@ -647,7 +647,7 @@ export function prefetchForRequire(
647
647
  // Follow static-string dynamic imports so a CLI entry that defers to
648
648
  // import('./dist/index.js') has that subtree's content prefetched.
649
649
  DYNIMPORT_RE.lastIndex = 0;
650
- while ((match = DYNIMPORT_RE.exec(stripped)) !== null) {
650
+ for (let match = DYNIMPORT_RE.exec(stripped); match !== null; match = DYNIMPORT_RE.exec(stripped)) {
651
651
  const specifier = match[2];
652
652
  if (isFacetProvided(specifier)) continue;
653
653
  const r = resolveRequireEx(vfs, specifier, fromDir, addPkgJson);