@nimbus-sh/worker 0.2.1 → 0.2.3

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 (45) hide show
  1. package/README.md +5 -2
  2. package/dist/facets/manager.d.ts +24 -0
  3. package/dist/facets/manager.d.ts.map +1 -1
  4. package/dist/facets/manager.js +73 -14
  5. package/dist/loaders/generated-workers.d.ts +2 -2
  6. package/dist/loaders/generated-workers.d.ts.map +1 -1
  7. package/dist/loaders/generated-workers.js +3 -3
  8. package/dist/runtime/facet-loader-host.d.ts +3 -2
  9. package/dist/runtime/facet-loader-host.d.ts.map +1 -1
  10. package/dist/runtime/facet-loader-host.js +1 -0
  11. package/dist/runtime/ruby-repl.js +1 -1
  12. package/dist/runtime/ruby-resident.d.ts +20 -0
  13. package/dist/runtime/ruby-resident.d.ts.map +1 -0
  14. package/dist/runtime/ruby-resident.js +278 -0
  15. package/dist/session/init.d.ts +16 -7
  16. package/dist/session/init.d.ts.map +1 -1
  17. package/dist/session/init.js +192 -208
  18. package/dist/session/keys.d.ts +11 -9
  19. package/dist/session/keys.d.ts.map +1 -1
  20. package/dist/session/keys.js +11 -9
  21. package/dist/session/nimbus-session.d.ts +11 -1
  22. package/dist/session/nimbus-session.d.ts.map +1 -1
  23. package/dist/session/nimbus-session.js +13 -57
  24. package/dist/session/programmatic.d.ts +1 -1
  25. package/dist/session/programmatic.d.ts.map +1 -1
  26. package/dist/session/programmatic.js +1 -1
  27. package/dist/session/routes.js +1 -1
  28. package/package.json +2 -2
  29. package/scripts/bundle-facet-workers.mjs +4 -3
  30. package/scripts/bundle-runtime.mjs +279 -44
  31. package/dist/npm/tarball-stream.d.ts +0 -93
  32. package/dist/npm/tarball-stream.d.ts.map +0 -1
  33. package/dist/npm/tarball-stream.js +0 -235
  34. package/dist/npm/tarball.d.ts +0 -34
  35. package/dist/npm/tarball.d.ts.map +0 -1
  36. package/dist/npm/tarball.js +0 -97
  37. package/dist/runtime/clang-runner.d.ts +0 -37
  38. package/dist/runtime/clang-runner.d.ts.map +0 -1
  39. package/dist/runtime/clang-runner.js +0 -865
  40. package/dist/runtime/ruby-gems.d.ts +0 -30
  41. package/dist/runtime/ruby-gems.d.ts.map +0 -1
  42. package/dist/runtime/ruby-gems.js +0 -636
  43. package/dist/runtime/ruby-runner.d.ts +0 -75
  44. package/dist/runtime/ruby-runner.d.ts.map +0 -1
  45. package/dist/runtime/ruby-runner.js +0 -1577
@@ -0,0 +1,278 @@
1
+ /**
2
+ * ruby-resident.ts — a Ruby program that keeps serving, as a Durable Object
3
+ * facet.
4
+ *
5
+ * The half of the Ruby runtime that names workerd. Everything about running
6
+ * the interpreter is in `@nimbus-sh/core`; this is the substrate a program
7
+ * that outlives its invocation needs — an actor that stays alive between
8
+ * requests, with inbound HTTP routed into the socket the program bound. A
9
+ * host without one supplies no {@link RubyResidentStart}, and such a program
10
+ * is refused rather than quietly run as a one-shot that dies with the call.
11
+ */
12
+ import { z } from 'zod';
13
+ import { buildRubyPreamble, normalizeRubyFacetResult, } from '@nimbus-sh/core/runtime/ruby-runner.js';
14
+ import { VIRTUAL_SOCKET_KERNEL_SRC } from '@nimbus-sh/core/runtime/virtual-socket-kernel.generated.js';
15
+ const RubySocketProcessBootResponseSchema = z.object({
16
+ state: z.string().optional(),
17
+ port: z.number().optional(),
18
+ stdout: z.string().optional(),
19
+ stderr: z.string().optional(),
20
+ result: z.unknown().optional(),
21
+ }).passthrough();
22
+ /**
23
+ * Start a Ruby program that binds a port as its own process, so it outlives
24
+ * the invocation that started it.
25
+ */
26
+ export function rubyResidentStart(facetMgr) {
27
+ return async function startResidentRuby(args) {
28
+ const command = args.command;
29
+ const workerCode = buildRubySocketProcessWorker(buildRubyPreamble());
30
+ const spawned = await facetMgr.spawnWorker(workerCode, command, args.cwd, {
31
+ compatibilityFlags: ['nodejs_compat'],
32
+ // By path, not by value: the image is 34.3 MiB — more than a single RPC
33
+ // value may carry — so whichever host runs this process reads it itself.
34
+ vfsWasmModules: { 'ruby+stdlib.wasm': args.wasmVfsPath },
35
+ startArgs: args.startArgs,
36
+ }).catch(() => null);
37
+ if (!spawned) {
38
+ return { exitCode: 1, stdout: '', stderr: 'ruby process boot failed\n' };
39
+ }
40
+ const parsed = RubySocketProcessBootResponseSchema.safeParse(spawned.boot);
41
+ if (!parsed.success) {
42
+ facetMgr.finishProcess(spawned.pid, 1, 'ruby process boot failed');
43
+ return {
44
+ exitCode: 1,
45
+ stdout: '',
46
+ stderr: 'ruby process boot failed\n',
47
+ };
48
+ }
49
+ const boot = parsed.data;
50
+ if (boot.state === 'listening' && typeof boot.port === 'number' && boot.port > 0) {
51
+ facetMgr.registerPort(spawned.pid, Number(boot.port));
52
+ const routeablePorts = await facetMgr.waitForRouteablePorts(spawned.pid);
53
+ const routeablePort = routeablePorts.includes(Number(boot.port)) ? Number(boot.port) : routeablePorts[0];
54
+ if (!routeablePort) {
55
+ facetMgr.kill(spawned.pid);
56
+ return {
57
+ exitCode: 1,
58
+ stdout: boot.stdout || '',
59
+ stderr: `${boot.stderr || ''}ruby: virtual socket port ${boot.port} failed to attach a route handler\n`,
60
+ };
61
+ }
62
+ return {
63
+ exitCode: 0,
64
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}" port=${routeablePort}]\x1b[0m\n`,
65
+ stderr: boot.stderr || '',
66
+ spawnedPid: spawned.pid,
67
+ port: routeablePort,
68
+ };
69
+ }
70
+ const reservedPorts = await facetMgr.waitForRouteablePorts(spawned.pid);
71
+ if (reservedPorts.length > 0) {
72
+ return {
73
+ exitCode: 0,
74
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}" port=${reservedPorts[0]}]\x1b[0m\n`,
75
+ stderr: boot.stderr || '',
76
+ spawnedPid: spawned.pid,
77
+ port: reservedPorts[0],
78
+ };
79
+ }
80
+ if (boot.state === 'exited') {
81
+ const result = normalizeRubyFacetResult(boot.result) || {
82
+ exitCode: 1,
83
+ stdout: '',
84
+ stderr: 'ruby process returned an invalid exit payload\n',
85
+ };
86
+ facetMgr.finishProcess(spawned.pid, result.exitCode, result.stderr || 'ruby process exited');
87
+ return {
88
+ exitCode: result.exitCode,
89
+ stdout: result.stdout,
90
+ stderr: result.stderr || result.error || '',
91
+ };
92
+ }
93
+ return {
94
+ exitCode: 0,
95
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}"]\x1b[0m\n`,
96
+ stderr: boot.stderr || '',
97
+ spawnedPid: spawned.pid,
98
+ };
99
+ };
100
+ }
101
+ export function buildRubySocketProcessWorker(preamble) {
102
+ return [
103
+ 'import { DurableObject } from "cloudflare:workers";',
104
+ "import __NIMBUS_WASM_ruby_stdlib from './ruby+stdlib.wasm';",
105
+ 'globalThis.__NIMBUS_WASM = globalThis.__NIMBUS_WASM || {};',
106
+ "globalThis.__NIMBUS_WASM['ruby+stdlib.wasm'] = __NIMBUS_WASM_ruby_stdlib;",
107
+ '',
108
+ VIRTUAL_SOCKET_KERNEL_SRC,
109
+ '',
110
+ // Listener lifecycle only. Socket bytes never cross this bridge: Ruby opens
111
+ // both accepted and dialed connections as file descriptors and reads and
112
+ // writes them as ordinary IO.
113
+ 'globalThis.__nimbusRubySockets = {',
114
+ ' listen(port) { return globalThis.__nimbusVirtualSockets.listen(Number(port)); },',
115
+ ' closeListener(port) { globalThis.__nimbusVirtualSockets.closeListener(Number(port)); return true; },',
116
+ ' pending(port) { return globalThis.__nimbusVirtualSockets.pending(Number(port)); },',
117
+ ' acceptNowJson(port) { const conn = globalThis.__nimbusVirtualSockets.acceptNow(Number(port)); return conn ? JSON.stringify(conn) : ""; },',
118
+ '};',
119
+ '',
120
+ // Outbound half of the same loopback the shell's curl and node's patched
121
+ // fetch use: one mechanism, reached here through the supervisor RPC.
122
+ 'globalThis.__nimbusVirtualSocketRouteLoopback = function __nimbusVirtualSocketRouteLoopback(port, request) {',
123
+ ' const supervisor = globalThis.__nimbusRubySupervisor;',
124
+ ' if (!supervisor || typeof supervisor.routeLoopback !== "function") {',
125
+ ' return Promise.reject(new Error("this Ruby process has no supervisor binding for loopback routing"));',
126
+ ' }',
127
+ ' return Promise.resolve(supervisor.routeLoopback(Number(port), request));',
128
+ '};',
129
+ '',
130
+ 'globalThis.__nimbusVirtualPortRegistrationPromises = globalThis.__nimbusVirtualPortRegistrationPromises || [];',
131
+ 'globalThis.__nimbusVirtualSocketDidListen = function __nimbusVirtualSocketDidListen(port) {',
132
+ ' const supervisor = globalThis.__nimbusRubySupervisor;',
133
+ ' if (!supervisor || typeof supervisor.registerPort !== "function") return;',
134
+ ' try {',
135
+ ' const p = supervisor.registerPort(Number(port)).catch((e) => {',
136
+ ' const msg = e && e.message ? e.message : String(e);',
137
+ ' (globalThis.__nimbusRubyStderr || (globalThis.__nimbusRubyStderr = [])).push("[ruby-runner] port registration failed: " + msg + "\\n");',
138
+ ' });',
139
+ ' globalThis.__nimbusVirtualPortRegistrationPromises.push(p);',
140
+ ' } catch (e) {',
141
+ ' const msg = e && e.message ? e.message : String(e);',
142
+ ' (globalThis.__nimbusRubyStderr || (globalThis.__nimbusRubyStderr = [])).push("[ruby-runner] port registration failed: " + msg + "\\n");',
143
+ ' }',
144
+ '};',
145
+ '',
146
+ preamble,
147
+ '',
148
+ // Drive the process when a connection is queued. This is the whole of the
149
+ // runtime's involvement in serving: it knows nothing about what is
150
+ // listening, only that the process should run until it parks.
151
+ //
152
+ // Steps go through the shared resume queue in the preamble, so a handler
153
+ // that parks does not stall other connections and no two drivers can enter
154
+ // a live fiber at once.
155
+ // Timed work the process still owes: the wall-clock moment its earliest
156
+ // sleeper is due. It lives on the global rather than in one driver's
157
+ // closure because no single request may own it. A timer belongs to the
158
+ // request context that created it, and workerd cancels that timer without
159
+ // a word when the request ends - so a driver anchored to the first
160
+ // connection stops dead the moment that connection answers, and every
161
+ // other connection waits out the response timeout instead.
162
+ 'globalThis.__nimbusRubyWakeAt = globalThis.__nimbusRubyWakeAt || null;',
163
+ 'globalThis.__nimbusRubyIdleDrivers = globalThis.__nimbusRubyIdleDrivers || new Set();',
164
+ 'function __nimbusRubyNoteWake(wakeAfter) {',
165
+ ' globalThis.__nimbusRubyWakeAt = (wakeAfter === null || wakeAfter === undefined)',
166
+ ' ? null',
167
+ ' : Date.now() + Math.max(0, wakeAfter) * 1000;',
168
+ ' if (globalThis.__nimbusRubyWakeAt === null) return;',
169
+ ' const waiting = Array.from(globalThis.__nimbusRubyIdleDrivers);',
170
+ ' globalThis.__nimbusRubyIdleDrivers.clear();',
171
+ ' for (const wake of waiting) wake();',
172
+ '}',
173
+ // So instead every live request drives, for as long as it is live, and the
174
+ // moment one of them answers the others are already carrying the work.
175
+ // Steps are serialized by the resume queue, and a driver that wakes to
176
+ // find the deadline already moved knows another one got there first.
177
+ 'async function __nimbusRubyDrive() {',
178
+ ' for (;;) {',
179
+ ' const due = globalThis.__nimbusRubyWakeAt;',
180
+ ' if (due === null) {',
181
+ ' // Nothing is due yet. Stay available anyway: this request holds a',
182
+ ' // live context, and whichever request discovers the next piece of',
183
+ ' // timed work may answer and be gone before that work comes due.',
184
+ ' await new Promise((resolve) => globalThis.__nimbusRubyIdleDrivers.add(resolve));',
185
+ ' continue;',
186
+ ' }',
187
+ ' const delay = due - Date.now();',
188
+ ' if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));',
189
+ ' if (globalThis.__nimbusRubyWakeAt !== due) continue;',
190
+ ' const step = await globalThis.__nimbusRubyStep();',
191
+ ' if (!step.resumed || !step.alive) { globalThis.__nimbusRubyWakeAt = null; return; }',
192
+ ' __nimbusRubyNoteWake(step.wakeAfter);',
193
+ ' }',
194
+ '}',
195
+ 'globalThis.__nimbusVirtualSocketRequestQueued = async function __nimbusVirtualSocketRequestQueued(port) {',
196
+ ' const step = await globalThis.__nimbusRubyStep();',
197
+ ' if (!step.resumed) return false;',
198
+ ' __nimbusRubyNoteWake(step.wakeAfter);',
199
+ ' __nimbusRubyDrive().catch((e) => {',
200
+ ' (globalThis.__nimbusRubyStderr || (globalThis.__nimbusRubyStderr = [])).push(',
201
+ ' "[ruby-runner] driving the process failed: " + ((e && e.message) || e) + "\\n");',
202
+ ' });',
203
+ ' return true;',
204
+ '};',
205
+ '',
206
+ 'async function __nimbusStartRubyProcess(args) {',
207
+ ' if (!globalThis.__nimbusRubyProcessPromise) {',
208
+ ' const stdoutStart = (globalThis.__nimbusRubyStdout || []).length;',
209
+ ' const stderrStart = (globalThis.__nimbusRubyStderr || []).length;',
210
+ ' globalThis.__nimbusRubyProcessOutputStart = { stdoutStart, stderrStart };',
211
+ ' globalThis.__nimbusRubyProcessArgs = {',
212
+ ' userCode: args.userCode,',
213
+ ' rbArgv: args.rbArgv || [],',
214
+ ' userEnv: args.userEnv || {},',
215
+ ' progName: args.progName || "ruby",',
216
+ ' cwd: args.cwd || "/home/user",',
217
+ ' fsSnapshot: args.fsSnapshot,',
218
+ ' };',
219
+ ' globalThis.__nimbusRubyProcessPromise = globalThis.__rubyRun(globalThis.__nimbusRubyProcessArgs).then((result) => {',
220
+ ' globalThis.__nimbusRubyProcessResult = result;',
221
+ ' return result;',
222
+ ' });',
223
+ ' }',
224
+ ' const started = globalThis.__nimbusRubyProcessOutputStart || { stdoutStart: 0, stderrStart: 0 };',
225
+ ' const listen = globalThis.__nimbusVirtualSockets.waitForListen(10_000).then((port) => ({ state: port ? "listening" : "pending", port }));',
226
+ ' const exit = globalThis.__nimbusRubyProcessPromise.then((result) => ({ state: "exited", result }));',
227
+ ' const first = await Promise.race([listen, exit]);',
228
+ ' const registrations = globalThis.__nimbusVirtualPortRegistrationPromises || [];',
229
+ ' if (registrations.length > 0) await Promise.allSettled(registrations.splice(0));',
230
+ ' const stdout = (globalThis.__nimbusRubyStdout || []).slice(started.stdoutStart).join("");',
231
+ ' const stderr = (globalThis.__nimbusRubyStderr || []).slice(started.stderrStart).join("");',
232
+ ' if (first.state === "listening") return { state: "listening", port: first.port, stdout, stderr };',
233
+ ' if (first.state === "exited") return { state: "exited", result: first.result, stdout, stderr };',
234
+ ' const currentPort = globalThis.__nimbusVirtualSockets.firstListeningPort();',
235
+ ' if (currentPort) return { state: "listening", port: currentPort, stdout, stderr };',
236
+ ' return { state: "running", stdout, stderr };',
237
+ '}',
238
+ '',
239
+ // Only adopt a real binding: routed handleHttpRequest/fetch hops resolve
240
+ // the entrypoint without a supervisor, and overwriting with undefined
241
+ // would drop the live stub the process needs for its whole lifetime.
242
+ 'function __nimbusAdoptRubySupervisor(env) {',
243
+ ' const supervisor = env && env.SUPERVISOR;',
244
+ ' if (supervisor) globalThis.__nimbusRubySupervisor = supervisor;',
245
+ // The WASI filesystem takes the same stub. That is what turns the
246
+ // spawn-time seed into a cache over the session VFS, so a server that
247
+ // never exits still persists its writes instead of losing them entirely.
248
+ ' __wasiAdoptSupervisor(supervisor);',
249
+ '}',
250
+ // A resident process parks between requests, and parking is the only
251
+ // moment "durable while running" can be made true: by the time the caller
252
+ // holds a response, everything the request wrote has reached the VFS.
253
+ 'async function __nimbusParkRuby(value) {',
254
+ // Revalidate drains first, then spends ONE round trip on the subtree
255
+ // revision. An unchanged subtree keeps the whole cache; a changed one
256
+ // drops the clean half so the next read sees another process's writes.
257
+ ' await __wasiRevalidateFS();',
258
+ ' return value;',
259
+ '}',
260
+ 'export class NimbusProcess extends DurableObject {',
261
+ ' async startProcess(args) {',
262
+ ' __nimbusAdoptRubySupervisor(this.env);',
263
+ ' return __nimbusParkRuby(await __nimbusStartRubyProcess(args || {}));',
264
+ ' }',
265
+ ' async fetch(request) {',
266
+ ' __nimbusAdoptRubySupervisor(this.env);',
267
+ ' return this.handleHttpRequest(request);',
268
+ ' }',
269
+ ' async handleHttpRequest(request) {',
270
+ ' __nimbusAdoptRubySupervisor(this.env);',
271
+ ' const hinted = Number(request.headers.get("X-Nimbus-Port") || 0);',
272
+ ' const port = hinted || Array.from(globalThis.__nimbusVirtualSockets.listeners.keys())[0];',
273
+ ' if (!port) return new Response("Nimbus Ruby process has no listening virtual socket", { status: 502 });',
274
+ ' return __nimbusParkRuby(await globalThis.__nimbusVirtualSockets.handleHttpRequest(port, request));',
275
+ ' }',
276
+ '}',
277
+ ].join('\n');
278
+ }
@@ -3,12 +3,21 @@
3
3
  *
4
4
  * Why this is one big function and not a class:
5
5
  * initSession runs once per /ws upgrade and walks the session
6
- * through Phase R (rehydrate from SQL), Phase B (build kernel +
7
- * shell + register commands), Phase W (attach terminal), and
8
- * (cold-only) Phase O (MOTD + framework hint). The phases share
9
- * lots of locals (vfs, kernel, registry, shell) and ordering
10
- * matters strictly — there's no interesting reuse boundary that a
11
- * class decomposition would expose.
6
+ * through Phase R (rehydrate from SQL), Phase B (compose the
7
+ * workspace + register the session's commands), Phase W (attach
8
+ * terminal), and (cold-only) Phase O (MOTD + framework hint). The
9
+ * phases share lots of locals (vfs, kernel, registry, shell) and
10
+ * ordering matters strictly — there's no interesting reuse boundary
11
+ * that a class decomposition would expose.
12
+ *
13
+ * What it no longer does is COMPOSE the operating system. The kernel,
14
+ * the provider mounts, the command registry, the coreutils, the exec
15
+ * resolver, the default environment and the shell come from
16
+ * `NimbusWorkspace` (@nimbus-sh/core/workspace), which an embedder off
17
+ * Cloudflare builds the same way. What stays here is everything that
18
+ * only makes sense with a socket, a Durable Object and a product behind
19
+ * it: the terminal and its scrollback, the phase machine, the persisted
20
+ * shell state, npm, git, vite, wrangler and the facet-backed runtimes.
12
21
  *
13
22
  * The function is intentionally written so that a reader sees:
14
23
  * 1. setPhase('rehydrate') ...
@@ -38,6 +47,6 @@ type InitHost = SessionInternal & {
38
47
  readonly ctx: any;
39
48
  readonly env: any;
40
49
  };
41
- export declare function initSession(self: InitHost, ws: WebSocket): void;
50
+ export declare function initSession(self: InitHost, ws: WebSocket): Promise<void>;
42
51
  export {};
43
52
  //# sourceMappingURL=init.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/session/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAoFH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,eAAe,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;CAAE,CAAC;AAa3E,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,GAAG,IAAI,CAguF/D"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/session/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AA6EH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,eAAe,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;CAAE,CAAC;AAa3E,wBAAsB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAksF9E"}