env-runner 0.1.0 → 0.1.2

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.
@@ -1,25 +1,212 @@
1
1
  import { t as BaseEnvRunner } from "./base-runner.mjs";
2
+ import { createRequire } from "node:module";
2
3
  import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, resolve } from "node:path";
6
+ //#region src/runners/miniflare/wrapper.ts
7
+ const IPC_PATH$1 = "/__env_runner_ipc";
8
+ /**
9
+ * Generates a wrapper module that imports the user entry and adds IPC glue.
10
+ *
11
+ * The user module is expected to export `fetch` and optionally `ipc`.
12
+ * The wrapper uses a persistent WebSocket pair for bidirectional IPC:
13
+ * - Init: `fetch` with `upgrade: websocket` creates a WebSocketPair
14
+ * - Messages: JSON over the WebSocket (no per-message `dispatchFetch`)
15
+ * - Reload: `{ type: "reload" }` triggers cache-busted re-import
16
+ * - Shutdown: `{ type: "shutdown" }` calls `ipc.onClose()`
17
+ *
18
+ * Passed as an in-memory `script` to Miniflare (no temp files needed).
19
+ */
20
+ function generateWrapper(entryPath, opts) {
21
+ const staticReExport = opts?.dynamicOnly ? "" : `export * from ${JSON.stringify(entryPath)};`;
22
+ const explicitExports = opts?.dynamicOnly && opts.exports?.length ? opts.exports.map((name) => `export { ${name} } from ${JSON.stringify(entryPath)};`).join("\n") : "";
23
+ const fetchBody = opts?.captureErrors ?? true ? `try {
24
+ return await entryFetch(request, env, ctx);
25
+ } catch (e) {
26
+ const error = e instanceof Error ? e : new Error(String(e));
27
+ const body = JSON.stringify({
28
+ error: error.message,
29
+ stack: error.stack,
30
+ name: error.constructor?.name || "Error",
31
+ });
32
+ return new Response(body, {
33
+ status: 500,
34
+ headers: { "Content-Type": "application/json", "X-Env-Runner-Error": "1" },
35
+ });
36
+ }` : `return entryFetch(request, env, ctx);`;
37
+ return `import __process from "node:process";
38
+ if (!globalThis.process) { globalThis.process = __process; }
39
+ ${staticReExport}
40
+ ${explicitExports}
41
+
42
+ const __IPC_PATH = "${IPC_PATH$1}";
43
+ const __entryPath = ${JSON.stringify(entryPath)};
44
+ let __userEntry;
45
+ let __ipcInitialized = false;
46
+ let __serverWs;
47
+
48
+ async function __loadEntry(env, path) {
49
+ globalThis.__ENV_RUNNER_UNSAFE_EVAL__ = env.__ENV_RUNNER_UNSAFE_EVAL__;
50
+ const importFn = env.__ENV_RUNNER_UNSAFE_EVAL__.newAsyncFunction(
51
+ "return await import(path)",
52
+ "loadEntry",
53
+ "path"
54
+ );
55
+ const mod = await importFn(path);
56
+ return mod.default || mod;
57
+ }
58
+
59
+ function __sendMessage(message) {
60
+ if (__serverWs) {
61
+ __serverWs.send(JSON.stringify(message));
62
+ }
63
+ }
64
+
65
+ async function __handleWsMessage(env, data) {
66
+ let msg;
67
+ try { msg = JSON.parse(data); } catch { return; }
68
+
69
+ if (msg.type === "message") {
70
+ if (__userEntry?.ipc?.onMessage) {
71
+ __userEntry.ipc.onMessage(msg.data);
72
+ }
73
+ return;
74
+ }
75
+
76
+ if (msg.type === "reload" && env.__ENV_RUNNER_UNSAFE_EVAL__) {
77
+ const version = msg.version || 0;
78
+ try {
79
+ const newEntry = await __loadEntry(env, __entryPath + "?t=" + version);
80
+ if (__userEntry?.ipc?.onClose) {
81
+ await __userEntry.ipc.onClose();
82
+ }
83
+ __userEntry = newEntry;
84
+ __ipcInitialized = false;
85
+ if (__userEntry.ipc?.onOpen) {
86
+ __ipcInitialized = true;
87
+ await __userEntry.ipc.onOpen({ sendMessage: __sendMessage });
88
+ }
89
+ __sendMessage({ event: "module-reloaded" });
90
+ } catch (e) {
91
+ __sendMessage({ event: "module-reloaded", error: String(e) });
92
+ }
93
+ return;
94
+ }
95
+
96
+ if (msg.type === "shutdown") {
97
+ if (__userEntry?.ipc?.onClose) {
98
+ await __userEntry.ipc.onClose();
99
+ }
100
+ return;
101
+ }
102
+ }
103
+
104
+ export default {
105
+ async fetch(request, env, ctx) {
106
+ const url = new URL(request.url);
107
+
108
+ // WebSocket IPC handshake
109
+ if (url.pathname === __IPC_PATH && request.headers.get("upgrade") === "websocket") {
110
+ try {
111
+ if (!__userEntry) {
112
+ __userEntry = await __loadEntry(env, __entryPath);
113
+ }
114
+ } catch (e) {
115
+ return new Response("Failed to load entry: " + String(e), { status: 500 });
116
+ }
117
+
118
+ const pair = new WebSocketPair();
119
+ const client = pair[0];
120
+ const server = pair[1];
121
+ server.accept();
122
+ __serverWs = server;
123
+
124
+ server.addEventListener("message", (event) => {
125
+ __handleWsMessage(env, event.data);
126
+ });
127
+
128
+ // Initialize IPC hooks
129
+ if (!__ipcInitialized && __userEntry.ipc) {
130
+ __ipcInitialized = true;
131
+ if (__userEntry.ipc.onOpen) {
132
+ await __userEntry.ipc.onOpen({ sendMessage: __sendMessage });
133
+ }
134
+ }
135
+
136
+ return new Response(null, { status: 101, webSocket: client });
137
+ }
138
+
139
+ if (!__userEntry) {
140
+ return new Response("Worker not initialized", { status: 503 });
141
+ }
142
+ const entryFetch = __userEntry.fetch;
143
+ if (!entryFetch) {
144
+ return new Response("No fetch handler exported", { status: 500 });
145
+ }
146
+ ${fetchBody}
147
+ }
148
+ };
149
+ `;
150
+ }
151
+ //#endregion
3
152
  //#region src/runners/miniflare/runner.ts
4
- const IPC_HEADER = "x-env-runner-ipc";
153
+ const IPC_PATH = "/__env_runner_ipc";
154
+ const _miniflareCache = /* @__PURE__ */ new Map();
5
155
  var MiniflareEnvRunner = class extends BaseEnvRunner {
6
156
  #miniflare;
7
157
  #miniflareOptions;
158
+ #transformRequest;
159
+ #reloadCounter = 0;
160
+ #ws;
161
+ #persistent;
162
+ #cacheKey;
163
+ #exports;
164
+ #captureErrors;
8
165
  constructor(opts) {
9
166
  super({
10
167
  ...opts,
11
168
  workerEntry: ""
12
169
  });
13
170
  this.#miniflareOptions = opts.miniflareOptions || {};
171
+ this.#transformRequest = opts.transformRequest;
172
+ this.#persistent = opts.persistent ?? false;
173
+ this.#exports = opts.exports ?? {};
174
+ this.#captureErrors = opts.captureErrors ?? true;
14
175
  this.#init();
15
176
  }
177
+ /** Dispose all persistent Miniflare instances from the cache. */
178
+ static async disposeAll() {
179
+ const entries = [..._miniflareCache.values()];
180
+ _miniflareCache.clear();
181
+ for (const entry of entries) await entry.mf.dispose().catch(() => {});
182
+ }
183
+ /** Fully dispose the Miniflare instance (even if persistent). */
184
+ async dispose() {
185
+ if (this.#miniflare) {
186
+ if (this.#ws) {
187
+ this.#ws.send(JSON.stringify({ type: "shutdown" }));
188
+ this.#ws.close();
189
+ this.#ws = void 0;
190
+ }
191
+ if (this.#cacheKey) _miniflareCache.delete(this.#cacheKey);
192
+ await this.#miniflare.dispose();
193
+ this.#miniflare = void 0;
194
+ }
195
+ if (!this.closed) await this.close();
196
+ }
16
197
  async fetch(input, init) {
17
198
  if (!this.#miniflare || this.closed) return new Response("miniflare env runner is unavailable", { status: 503 });
18
199
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
19
- return this.#miniflare.dispatchFetch(url, init);
200
+ const res = await this.#miniflare.dispatchFetch(url, init);
201
+ if (res instanceof Response) return res;
202
+ return new Response(res.body, {
203
+ status: res.status,
204
+ statusText: res.statusText,
205
+ headers: res.headers
206
+ });
20
207
  }
21
208
  sendMessage(message) {
22
- if (!this.#miniflare) throw new Error("Miniflare env runner should be initialized before sending messages.");
209
+ if (!this.#ws) throw new Error("Miniflare env runner should be initialized before sending messages.");
23
210
  if (message?.type === "ping") {
24
211
  queueMicrotask(() => this._handleMessage({
25
212
  type: "pong",
@@ -27,11 +214,45 @@ var MiniflareEnvRunner = class extends BaseEnvRunner {
27
214
  }));
28
215
  return;
29
216
  }
30
- this.#miniflare.dispatchFetch("http://localhost/__env_runner_ipc", {
31
- method: "POST",
32
- headers: { [IPC_HEADER]: "message" },
33
- body: JSON.stringify(message)
34
- }).catch(() => {});
217
+ this.#ws.send(JSON.stringify({
218
+ type: "message",
219
+ data: message
220
+ }));
221
+ }
222
+ /**
223
+ * Hot-reload the user entry module without recreating the Miniflare instance.
224
+ *
225
+ * Sends `reload-module` event over the WebSocket. The worker wrapper uses
226
+ * `unsafeEvalBinding` to re-import the entry with a cache-busting query string
227
+ * and responds with `module-reloaded` when done.
228
+ */
229
+ async reloadModule(timeout = 5e3) {
230
+ if (!this.#ws) throw new Error("Miniflare env runner should be initialized before reloading.");
231
+ if (!this._data?.entry) return;
232
+ this.#reloadCounter++;
233
+ const version = this.#reloadCounter;
234
+ return new Promise((resolve, reject) => {
235
+ const timer = setTimeout(() => {
236
+ cleanup();
237
+ reject(/* @__PURE__ */ new Error("Module reload timed out"));
238
+ }, timeout);
239
+ const listener = (msg) => {
240
+ if (msg?.event === "module-reloaded") {
241
+ cleanup();
242
+ if (msg.error) reject(typeof msg.error === "string" ? new Error(msg.error) : msg.error);
243
+ else resolve();
244
+ }
245
+ };
246
+ const cleanup = () => {
247
+ clearTimeout(timer);
248
+ this.offMessage(listener);
249
+ };
250
+ this.onMessage(listener);
251
+ this.#ws.send(JSON.stringify({
252
+ type: "reload",
253
+ version
254
+ }));
255
+ });
35
256
  }
36
257
  _hasRuntime() {
37
258
  return Boolean(this.#miniflare);
@@ -41,11 +262,21 @@ var MiniflareEnvRunner = class extends BaseEnvRunner {
41
262
  }
42
263
  async _closeRuntime() {
43
264
  if (!this.#miniflare) return;
44
- await this.#miniflare.dispatchFetch("http://localhost/__env_runner_ipc", {
45
- method: "POST",
46
- headers: { [IPC_HEADER]: "shutdown" }
47
- }).catch(() => {});
48
- await this.#miniflare.dispose();
265
+ if (this.#ws) {
266
+ this.#ws.send(JSON.stringify({ type: "shutdown" }));
267
+ this.#ws.close();
268
+ this.#ws = void 0;
269
+ }
270
+ if (this.#persistent && this.#cacheKey) {
271
+ const cached = _miniflareCache.get(this.#cacheKey);
272
+ if (cached) {
273
+ cached.refCount--;
274
+ if (cached.refCount <= 0) {
275
+ _miniflareCache.delete(this.#cacheKey);
276
+ await this.#miniflare.dispose();
277
+ }
278
+ }
279
+ } else await this.#miniflare.dispose();
49
280
  this.#miniflare = void 0;
50
281
  }
51
282
  #init() {
@@ -57,28 +288,150 @@ var MiniflareEnvRunner = class extends BaseEnvRunner {
57
288
  async #initAsync() {
58
289
  const { Miniflare } = await import("miniflare");
59
290
  const entryPath = this._data?.entry;
291
+ const userFlags = this.#miniflareOptions.compatibilityFlags || [];
60
292
  const options = {
293
+ compatibilityDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
61
294
  modules: true,
62
- ...this.#miniflareOptions
295
+ ...this.#miniflareOptions,
296
+ compatibilityFlags: [...new Set(["nodejs_compat", ...userFlags])]
63
297
  };
64
- options.serviceBindings = {
65
- ...options.serviceBindings || {},
66
- __ENV_RUNNER_IPC: async (request) => {
67
- const message = await request.json().catch(() => null);
68
- if (message !== null) this._handleMessage(message);
69
- return new Response(null, { status: 204 });
298
+ if (entryPath && !options.script && !options.scriptPath) {
299
+ const resolvedEntry = resolve(entryPath);
300
+ const entryDir = dirname(resolvedEntry);
301
+ const detectedExports = this.#exports === false || this.#exports === void 0 ? [] : detectExportedClasses(resolvedEntry, typeof this.#exports === "object" ? this.#exports : {});
302
+ if (detectedExports.length > 0 && !options.durableObjects) {
303
+ const autoDOs = { ...this.#miniflareOptions.durableObjects || {} };
304
+ for (const name of detectedExports) {
305
+ const bindingName = toScreamingSnakeCase(name);
306
+ if (!autoDOs[bindingName]) autoDOs[bindingName] = name;
307
+ }
308
+ options.durableObjects = autoDOs;
70
309
  }
71
- };
72
- if (entryPath && !options.script) {
73
- options.script = wrapScript(readFileSync(entryPath, "utf8"));
74
- delete options.scriptPath;
310
+ options.script = generateWrapper(resolvedEntry, {
311
+ dynamicOnly: true,
312
+ captureErrors: this.#captureErrors,
313
+ exports: detectedExports
314
+ });
315
+ options.scriptPath = entryDir + "/__env_runner_wrapper.mjs";
316
+ if (!options.modulesRoot) options.modulesRoot = "/";
317
+ options.unsafeEvalBinding = "__ENV_RUNNER_UNSAFE_EVAL__";
318
+ if (this.#transformRequest && !options.modulesRules) options.modulesRules = [{
319
+ type: "ESModule",
320
+ include: [
321
+ "**/*.ts",
322
+ "**/*.tsx",
323
+ "**/*.jsx",
324
+ "**/*.mts"
325
+ ]
326
+ }];
327
+ if (!options.unsafeModuleFallbackService) {
328
+ const _require = createRequire(resolvedEntry);
329
+ const _transformRequest = this.#transformRequest;
330
+ options.unsafeUseModuleFallbackService = true;
331
+ const modulePathMap = /* @__PURE__ */ new Map();
332
+ options.unsafeModuleFallbackService = async (request) => {
333
+ const url = new URL(request.url);
334
+ const specifier = url.searchParams.get("specifier");
335
+ const rawSpecifier = url.searchParams.get("rawSpecifier");
336
+ const referrer = url.searchParams.get("referrer") || "";
337
+ if (!specifier) return new Response(null, { status: 404 });
338
+ const cleanSpecifier = specifier.split("?")[0] || specifier;
339
+ const cleanRaw = rawSpecifier?.split("?")[0];
340
+ let resolvedPath;
341
+ const fileUrlRaw = cleanRaw || cleanSpecifier;
342
+ if (fileUrlRaw.startsWith("file://")) try {
343
+ resolvedPath = fileURLToPath(fileUrlRaw);
344
+ } catch {
345
+ return new Response(null, { status: 404 });
346
+ }
347
+ else if (cleanRaw && !cleanRaw.startsWith(".") && !cleanRaw.startsWith("/")) {
348
+ const referrerKey = referrer.startsWith("/") ? referrer.slice(1) : referrer;
349
+ const referrerReal = modulePathMap.get(referrerKey);
350
+ const contextRequire = referrerReal ? createRequire(referrerReal) : _require;
351
+ if (cleanRaw.startsWith("node:")) {
352
+ const nodeName = cleanRaw.slice(5);
353
+ try {
354
+ resolvedPath = contextRequire.resolve(`unenv/node/${nodeName}`);
355
+ } catch {
356
+ return new Response(null, { status: 404 });
357
+ }
358
+ } else try {
359
+ resolvedPath = contextRequire.resolve(cleanRaw);
360
+ } catch {
361
+ const name = cleanSpecifier.startsWith("/") ? cleanSpecifier.slice(1) : cleanSpecifier;
362
+ return Response.json({
363
+ name,
364
+ esModule: "export default undefined;"
365
+ });
366
+ }
367
+ } else {
368
+ const referrerKey = referrer.startsWith("/") ? referrer.slice(1) : referrer;
369
+ const referrerDir = dirname(modulePathMap.get(referrerKey) || (referrer.startsWith("/") ? referrer : "/" + referrer));
370
+ const raw = cleanRaw || cleanSpecifier;
371
+ if (raw.startsWith(".")) resolvedPath = resolve(referrerDir, raw);
372
+ else if (cleanSpecifier.startsWith("/")) resolvedPath = cleanSpecifier;
373
+ else try {
374
+ resolvedPath = _require.resolve(raw);
375
+ } catch {
376
+ return new Response(null, { status: 404 });
377
+ }
378
+ }
379
+ const rawQuery = specifier.includes("?") ? specifier.slice(specifier.indexOf("?")) : "";
380
+ const name = (cleanSpecifier.startsWith("/") ? cleanSpecifier.slice(1) : cleanSpecifier) + rawQuery;
381
+ if (_transformRequest) try {
382
+ const result = await _transformRequest(resolvedPath);
383
+ if (result?.code) {
384
+ modulePathMap.set(name, resolvedPath);
385
+ return Response.json({
386
+ name,
387
+ esModule: result.code
388
+ });
389
+ }
390
+ } catch {}
391
+ try {
392
+ const contents = readFileSync(resolvedPath, "utf8");
393
+ modulePathMap.set(name, resolvedPath);
394
+ const isESM = resolvedPath.endsWith(".mjs") || !resolvedPath.endsWith(".cjs") && /\b(import\s|import\(|export\s|export\{|import\.meta\b)/.test(contents);
395
+ return Response.json({
396
+ name,
397
+ ...isESM ? { esModule: contents } : { commonJsModule: contents }
398
+ });
399
+ } catch {
400
+ return new Response(null, { status: 404 });
401
+ }
402
+ };
403
+ }
404
+ }
405
+ if (this.#persistent && entryPath) {
406
+ this.#cacheKey = computeCacheKey(entryPath, this.#miniflareOptions);
407
+ const cached = _miniflareCache.get(this.#cacheKey);
408
+ if (cached) {
409
+ this.#miniflare = cached.mf;
410
+ cached.refCount++;
411
+ }
412
+ }
413
+ if (!this.#miniflare) {
414
+ this.#miniflare = new Miniflare(options);
415
+ await this.#miniflare.ready;
416
+ if (this.#persistent && this.#cacheKey) _miniflareCache.set(this.#cacheKey, {
417
+ mf: this.#miniflare,
418
+ refCount: 1
419
+ });
420
+ }
421
+ const initRes = await this.#miniflare.dispatchFetch("http://localhost" + IPC_PATH, { headers: { upgrade: "websocket" } });
422
+ const ws = initRes.webSocket;
423
+ if (!ws) {
424
+ const body = await initRes.text().catch(() => "");
425
+ throw new Error(`Failed to establish WebSocket IPC channel (${initRes.status}: ${body})`);
75
426
  }
76
- this.#miniflare = new Miniflare(options);
77
- await this.#miniflare.ready;
78
- await this.#miniflare.dispatchFetch("http://localhost/__env_runner_ipc", {
79
- method: "POST",
80
- headers: { [IPC_HEADER]: "init" }
81
- }).catch(() => {});
427
+ ws.accept();
428
+ this.#ws = ws;
429
+ ws.addEventListener("message", (event) => {
430
+ try {
431
+ const parsed = JSON.parse(event.data);
432
+ this._handleMessage(parsed);
433
+ } catch {}
434
+ });
82
435
  this._handleMessage({ address: {
83
436
  host: "127.0.0.1",
84
437
  port: 0
@@ -86,60 +439,28 @@ var MiniflareEnvRunner = class extends BaseEnvRunner {
86
439
  }
87
440
  };
88
441
  /**
89
- * Wraps user module code with IPC glue.
90
- *
91
- * The user module is expected to `export default { fetch, ipc? }`.
92
- * The wrapper renames the user's default export, intercepts IPC requests,
93
- * and bridges the `ipc` hooks via `env.__ENV_RUNNER_IPC` service binding.
442
+ * Detect `export class` declarations in the entry file.
443
+ * Merges with explicitly declared exports from options.
94
444
  */
95
- function wrapScript(userCode) {
96
- return `${userCode.replace(/export\s+default\s+/, "const __userEntry = ")}
97
-
98
- // --- env-runner IPC glue ---
99
- let __ipcInitialized = false;
100
- const __IPC_HEADER = "${IPC_HEADER}";
101
-
102
- export default {
103
- async fetch(request, env, ctx) {
104
- // Handle IPC control messages from runner
105
- const ipcType = request.headers.get(__IPC_HEADER);
106
- if (ipcType) {
107
- if (ipcType === "init") {
108
- if (!__ipcInitialized && __userEntry.ipc && env.__ENV_RUNNER_IPC) {
109
- __ipcInitialized = true;
110
- const sendMessage = (message) => {
111
- env.__ENV_RUNNER_IPC.fetch("http://ipc/", {
112
- method: "POST",
113
- body: JSON.stringify(message),
114
- });
115
- };
116
- if (__userEntry.ipc.onOpen) {
117
- await __userEntry.ipc.onOpen({ sendMessage });
118
- }
119
- }
120
- return new Response(null, { status: 204 });
121
- }
122
- if (ipcType === "message") {
123
- const message = await request.json();
124
- if (__userEntry.ipc?.onMessage) {
125
- __userEntry.ipc.onMessage(message);
126
- }
127
- return new Response(null, { status: 204 });
128
- }
129
- if (ipcType === "shutdown") {
130
- if (__userEntry.ipc?.onClose) {
131
- await __userEntry.ipc.onClose();
132
- }
133
- return new Response(null, { status: 204 });
134
- }
135
- return new Response(null, { status: 400 });
136
- }
137
-
138
- // Delegate to user's fetch handler
139
- return __userEntry.fetch(request, env, ctx);
140
- }
141
- };
142
- `;
445
+ function detectExportedClasses(entryPath, explicit) {
446
+ const names = new Set(Object.keys(explicit));
447
+ try {
448
+ const source = readFileSync(entryPath, "utf8");
449
+ const re = /\bexport\s+class\s+(\w+)/g;
450
+ let match;
451
+ while (match = re.exec(source)) if (match[1]) names.add(match[1]);
452
+ } catch {}
453
+ return [...names];
454
+ }
455
+ /** Convert PascalCase/camelCase to SCREAMING_SNAKE_CASE (e.g. `Counter` → `COUNTER`, `MyDurableObject` → `MY_DURABLE_OBJECT`). */
456
+ function toScreamingSnakeCase(name) {
457
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
458
+ }
459
+ /** Compute a stable cache key for persistent Miniflare instances. */
460
+ function computeCacheKey(entryPath, opts) {
461
+ const serializableOpts = {};
462
+ for (const [k, v] of Object.entries(opts)) if (typeof v !== "function") serializableOpts[k] = v;
463
+ return `${resolve(entryPath)}::${JSON.stringify(serializableOpts)}`;
143
464
  }
144
465
  //#endregion
145
466
  export { MiniflareEnvRunner as t };
@@ -51,6 +51,54 @@ var RunnerManager = class {
51
51
  this._messageListeners.delete(listener);
52
52
  this._runner?.offMessage(listener);
53
53
  }
54
+ waitForReady(timeout = 5e3) {
55
+ if (this.ready) return Promise.resolve();
56
+ return new Promise((resolve, reject) => {
57
+ const timer = setTimeout(() => {
58
+ this._messageListeners.delete(listener);
59
+ reject(/* @__PURE__ */ new Error("Runner did not become ready in time"));
60
+ }, timeout);
61
+ const listener = (message) => {
62
+ if (message?.address || this.ready) {
63
+ clearTimeout(timer);
64
+ this._messageListeners.delete(listener);
65
+ resolve();
66
+ }
67
+ };
68
+ this._messageListeners.add(listener);
69
+ });
70
+ }
71
+ rpc(name, data, opts) {
72
+ const id = Math.random().toString(36).slice(2);
73
+ const timeout = opts?.timeout ?? 3e3;
74
+ return new Promise((resolve, reject) => {
75
+ const timer = setTimeout(() => {
76
+ cleanup();
77
+ reject(/* @__PURE__ */ new Error(`RPC "${name}" timed out`));
78
+ }, timeout);
79
+ const listener = (msg) => {
80
+ if (msg?.__rpc_id === id) {
81
+ cleanup();
82
+ if (msg.error) reject(typeof msg.error === "string" ? new Error(msg.error) : msg.error);
83
+ else resolve(msg.data);
84
+ }
85
+ };
86
+ const cleanup = () => {
87
+ clearTimeout(timer);
88
+ this.offMessage(listener);
89
+ };
90
+ this.onMessage(listener);
91
+ this.sendMessage({
92
+ __rpc: name,
93
+ __rpc_id: id,
94
+ data
95
+ });
96
+ });
97
+ }
98
+ async reloadModule(timeout) {
99
+ if (!this._runner?.reloadModule) throw new Error("Active runner does not support reloadModule()");
100
+ return this._runner.reloadModule(timeout);
101
+ }
54
102
  async close() {
55
103
  this._closed = true;
56
104
  this._messageQueue.length = 0;
@@ -0,0 +1,76 @@
1
+ import { IncomingMessage } from "node:http";
2
+ import { Socket } from "node:net";
3
+
4
+ //#region src/types.d.ts
5
+ /** Handler for proxying HTTP requests to the worker. */
6
+ type FetchHandler = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
7
+ /** Callback for receiving messages from the worker. */
8
+ type RunnerMessageListener = (data: unknown) => void;
9
+ /** Raw Node.js upgrade request context. */
10
+ interface NodeUpgradeContext {
11
+ req: IncomingMessage;
12
+ socket: Socket;
13
+ head: any;
14
+ }
15
+ /** Context passed to the upgrade handler for WebSocket upgrades. */
16
+ interface UpgradeContext {
17
+ node: NodeUpgradeContext;
18
+ }
19
+ /** Handler for proxying WebSocket upgrade requests to the worker. */
20
+ type UpgradeHandler = (context: UpgradeContext) => void;
21
+ /** Bidirectional RPC messaging interface between the runner and worker. */
22
+ interface RunnerRPCHooks {
23
+ /** Send a message to the worker. */
24
+ sendMessage: (message: unknown) => void;
25
+ /** Register a listener for messages from the worker. */
26
+ onMessage: (listener: RunnerMessageListener) => void;
27
+ /** Remove a previously registered message listener. */
28
+ offMessage: (listener: RunnerMessageListener) => void;
29
+ }
30
+ /**
31
+ * Address reported by the worker once it is ready.
32
+ *
33
+ * Either a TCP `host`/`port` pair or a Unix `socketPath`.
34
+ */
35
+ type WorkerAddress = {
36
+ host?: string;
37
+ port: number;
38
+ socketPath?: undefined;
39
+ } | {
40
+ host?: undefined;
41
+ port?: undefined;
42
+ socketPath: string;
43
+ };
44
+ /** Lifecycle hooks for observing runner state changes. */
45
+ interface WorkerHooks {
46
+ /** Called when the worker closes, optionally with the cause. */
47
+ onClose?: (worker: EnvRunner, cause?: unknown) => void;
48
+ /** Called when the worker is ready and listening at the given address. */
49
+ onReady?: (worker: EnvRunner, address?: WorkerAddress) => void;
50
+ }
51
+ /** Options for the `rpc()` method. */
52
+ interface RPCOptions {
53
+ /** Timeout in milliseconds before the RPC call rejects. Default: 3000. */
54
+ timeout?: number;
55
+ }
56
+ /** Core runner interface combining lifecycle hooks, RPC, and request proxying. */
57
+ interface EnvRunner extends WorkerHooks, RunnerRPCHooks {
58
+ /** Whether the worker is ready to accept requests. */
59
+ readonly ready: boolean;
60
+ /** Whether the runner has been closed. */
61
+ readonly closed: boolean;
62
+ /** Proxy an HTTP request to the worker. */
63
+ fetch: FetchHandler;
64
+ /** Proxy a WebSocket upgrade request to the worker. */
65
+ upgrade?: UpgradeHandler;
66
+ /** Returns a promise that resolves when the runner becomes ready. */
67
+ waitForReady(timeout?: number): Promise<void>;
68
+ /** Send an RPC request and wait for the response. */
69
+ rpc<T = unknown>(name: string, data?: unknown, opts?: RPCOptions): Promise<T>;
70
+ /** Re-import the entry module without restarting the worker/process. */
71
+ reloadModule?(timeout?: number): Promise<void>;
72
+ /** Gracefully shut down the worker. */
73
+ close(): Promise<void>;
74
+ }
75
+ //#endregion
76
+ export { RunnerMessageListener as a, UpgradeHandler as c, RPCOptions as i, WorkerAddress as l, FetchHandler as n, RunnerRPCHooks as o, NodeUpgradeContext as r, UpgradeContext as s, EnvRunner as t, WorkerHooks as u };
@@ -12,5 +12,15 @@ function parseServerAddress(server) {
12
12
  port: Number(url.port)
13
13
  };
14
14
  }
15
+ /**
16
+ * Re-import the user entry module with cache busting.
17
+ * Tears down old IPC hooks and re-initializes new ones.
18
+ */
19
+ async function reloadEntryModule(entryPath, currentEntry, sendMessage) {
20
+ await currentEntry.ipc?.onClose?.();
21
+ const newEntry = await resolveEntry(entryPath + "?t=" + Date.now());
22
+ await newEntry.ipc?.onOpen?.({ sendMessage });
23
+ return newEntry;
24
+ }
15
25
  //#endregion
16
- export { resolveEntry as n, parseServerAddress as t };
26
+ export { reloadEntryModule as n, resolveEntry as r, parseServerAddress as t };