env-runner 0.1.1 → 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.
package/README.md CHANGED
@@ -141,24 +141,33 @@ const response = await runner.fetch("http://localhost/api");
141
141
  // Proxy WebSocket upgrades
142
142
  runner.upgrade?.({ node: { req, socket, head } });
143
143
 
144
+ // Wait for runner to be ready
145
+ await runner.waitForReady();
146
+
144
147
  // Bidirectional messaging
145
148
  runner.sendMessage({ type: "ping" });
146
149
  runner.onMessage((msg) => console.log(msg));
147
150
 
151
+ // Request-response RPC
152
+ const result = await runner.rpc<string>("transformHTML", "<html>...</html>");
153
+
154
+ // Hot-reload entry module without restarting the worker
155
+ await runner.reloadModule();
156
+
148
157
  // Graceful shutdown
149
158
  await runner.close();
150
159
  ```
151
160
 
152
161
  **Available runners:**
153
162
 
154
- | Runner | Isolation | IPC mechanism |
155
- | ---------------------- | ------------------------------ | ----------------------------------- |
156
- | `NodeWorkerEnvRunner` | Worker thread | `workerData` / `parentPort` |
157
- | `NodeProcessEnvRunner` | Child process (`fork`) | `ENV_RUNNER_DATA` / `process.send` |
158
- | `BunProcessEnvRunner` | Bun or Node.js process | `Bun.spawn` IPC or `fork()` |
159
- | `DenoProcessEnvRunner` | Deno process | `deno run` with IPC channel |
160
- | `SelfEnvRunner` | In-process | In-memory channel |
161
- | `MiniflareEnvRunner` | Cloudflare Workers (miniflare) | `serviceBindings` + `dispatchFetch` |
163
+ | Runner | Isolation | IPC mechanism |
164
+ | ---------------------- | ------------------------------ | ---------------------------------- |
165
+ | `NodeWorkerEnvRunner` | Worker thread | `workerData` / `parentPort` |
166
+ | `NodeProcessEnvRunner` | Child process (`fork`) | `ENV_RUNNER_DATA` / `process.send` |
167
+ | `BunProcessEnvRunner` | Bun or Node.js process | `Bun.spawn` IPC or `fork()` |
168
+ | `DenoProcessEnvRunner` | Deno process | `deno run` with IPC channel |
169
+ | `SelfEnvRunner` | In-process | In-memory channel |
170
+ | `MiniflareEnvRunner` | Cloudflare Workers (miniflare) | WebSocket pair via `dispatchFetch` |
162
171
 
163
172
  #### Miniflare Runner
164
173
 
@@ -186,6 +195,165 @@ await runner.close();
186
195
 
187
196
  The `miniflareOptions` object is passed directly to the [Miniflare constructor](https://developers.cloudflare.com/workers/testing/miniflare/) — you can configure bindings, KV, D1, Durable Objects, and any other Miniflare option.
188
197
 
198
+ #### Module Transform Pipeline
199
+
200
+ Pass a `transformRequest` callback to route module resolution through Vite's (or any) transform pipeline. This enables TS, JSX, and other non-JS formats to be compiled on-the-fly inside the Workers runtime without pre-bundling:
201
+
202
+ ```ts
203
+ import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
204
+
205
+ const runner = new MiniflareEnvRunner({
206
+ name: "my-worker",
207
+ data: { entry: "./worker.ts" },
208
+ // Route module resolution through Vite's transform pipeline
209
+ transformRequest: (id) => viteDevEnvironment.transformRequest(id),
210
+ });
211
+ ```
212
+
213
+ When `transformRequest` is provided:
214
+
215
+ - The `unsafeModuleFallbackService` calls it with the resolved file path before falling back to raw disk reads
216
+ - Module rules for `.ts`, `.tsx`, `.jsx`, and `.mts` are added automatically
217
+ - Static `export *` re-exports are skipped in the wrapper to avoid miniflare's ModuleLocator pre-walking the import tree
218
+
219
+ The callback should return `{ code: string }` for transformed modules, or `null`/`undefined` to fall back to the default raw file read.
220
+
221
+ #### Auto-detected Exports
222
+
223
+ `MiniflareEnvRunner` automatically scans the entry file for `export class` declarations and wires them as Durable Object bindings (binding name = class name). This means you don't need to manually configure `miniflareOptions.durableObjects` for simple cases:
224
+
225
+ ```ts
226
+ // worker.ts
227
+ export class Counter {
228
+ /* ... Durable Object implementation ... */
229
+ }
230
+
231
+ export default {
232
+ async fetch(request, env) {
233
+ // env.Counter is auto-wired — no manual config needed
234
+ const id = env.Counter.idFromName("test");
235
+ const stub = env.Counter.get(id);
236
+ return stub.fetch(request);
237
+ },
238
+ };
239
+ ```
240
+
241
+ To explicitly declare exports or override auto-detection:
242
+
243
+ ```ts
244
+ const runner = new MiniflareEnvRunner({
245
+ name: "my-worker",
246
+ data: { entry: "./worker.ts" },
247
+ // Explicit exports (merged with auto-detected ones)
248
+ exports: { Counter: { type: "DurableObject" } },
249
+ });
250
+ ```
251
+
252
+ Set `exports: false` to disable auto-detection entirely.
253
+
254
+ #### Error Capture
255
+
256
+ By default, the runner wraps the user's `fetch` handler in a try/catch that returns structured JSON error responses with preserved stack traces:
257
+
258
+ ```json
259
+ {
260
+ "error": "Cannot read properties of undefined",
261
+ "stack": "Error: Cannot read properties...\n at fetch (worker.ts:10:5)",
262
+ "name": "TypeError"
263
+ }
264
+ ```
265
+
266
+ Error responses include `Content-Type: application/json` and `X-Env-Runner-Error: 1` headers. Disable with `captureErrors: false`.
267
+
268
+ #### Persistent Miniflare
269
+
270
+ By default, `close()` disposes the Miniflare instance. With `persistent: true`, the Miniflare instance is cached and reused across runner swaps — only the IPC connection is re-established:
271
+
272
+ ```ts
273
+ const runner1 = new MiniflareEnvRunner({
274
+ name: "my-worker",
275
+ data: { entry: "./worker.ts" },
276
+ persistent: true,
277
+ });
278
+
279
+ // Later, after close() + creating a new runner with the same config,
280
+ // the Miniflare instance is reused (faster startup)
281
+ await runner1.close();
282
+
283
+ const runner2 = new MiniflareEnvRunner({
284
+ name: "my-worker",
285
+ data: { entry: "./worker.ts" },
286
+ persistent: true,
287
+ });
288
+
289
+ // Fully destroy: runner.dispose() or MiniflareEnvRunner.disposeAll()
290
+ ```
291
+
292
+ ### Vite Environment API
293
+
294
+ env-runner provides helpers for integrating with Vite's [Environment API](https://vite.dev/guide/api-environment-runtimes.html):
295
+
296
+ ```ts
297
+ import { createViteHotChannel, createViteTransport } from "env-runner/vite";
298
+ ```
299
+
300
+ **Host side** — create a Vite `HotChannel` from any runner's messaging hooks:
301
+
302
+ ```ts
303
+ import { createViteHotChannel } from "env-runner/vite";
304
+
305
+ // Bridge env-runner IPC → Vite's DevEnvironment transport
306
+ const transport = createViteHotChannel(runner, "ssr");
307
+ const env = new DevEnvironment("ssr", config, { hot: true, transport });
308
+ ```
309
+
310
+ **Worker side** — create a `ModuleRunner` transport:
311
+
312
+ ```ts
313
+ import { createViteTransport } from "env-runner/vite";
314
+
315
+ const transport = createViteTransport(sendMessage, onMessage, "ssr");
316
+ const runner = new ModuleRunner({ transport, sourcemapInterceptor: "prepareStackTrace" });
317
+ ```
318
+
319
+ Messages are namespaced by environment name, so multiple Vite environments can share a single runner's IPC channel.
320
+
321
+ **Miniflare + Vite** — combine `MiniflareEnvRunner.transformRequest` with Vite helpers for a full Cloudflare Workers dev environment with HMR and on-the-fly transforms:
322
+
323
+ ```ts
324
+ import { MiniflareEnvRunner } from "env-runner/runners/miniflare";
325
+ import { createViteHotChannel } from "env-runner/vite";
326
+
327
+ const runner = new MiniflareEnvRunner({
328
+ name: "worker",
329
+ data: { entry: "./src/worker.ts" },
330
+ transformRequest: (id) => devEnvironment.transformRequest(id),
331
+ });
332
+
333
+ const hotChannel = createViteHotChannel(runner, "worker");
334
+ ```
335
+
336
+ ### RPC
337
+
338
+ Send request-response messages over IPC with automatic ID generation, timeout, and error propagation:
339
+
340
+ ```ts
341
+ // Host side
342
+ const html = await runner.rpc<string>("transformHTML", rawHtml, { timeout: 5000 });
343
+
344
+ // Worker side (in entry's ipc.onMessage)
345
+ onMessage(msg) {
346
+ if (msg?.__rpc === "transformHTML") {
347
+ const result = await transform(msg.data);
348
+ sendMessage({ __rpc_id: msg.__rpc_id, data: result });
349
+ }
350
+ }
351
+ ```
352
+
353
+ Errors can be propagated back by sending `{ __rpc_id, error: "message" }`.
354
+
355
+ ### Dynamic Runner Loading
356
+
189
357
  You can also use `loadRunner()` to dynamically load a runner by name:
190
358
 
191
359
  ```ts
@@ -207,6 +375,10 @@ export default {
207
375
  fetch(request: Request) {
208
376
  return new Response("Hello!");
209
377
  },
378
+ upgrade(context) {
379
+ // Optional: handle WebSocket upgrade requests (Node.js only)
380
+ // context.node gives { req, socket, head }
381
+ },
210
382
  middleware: [], // Optional srvx middleware
211
383
  plugins: [], // Optional srvx plugins
212
384
  ipc: {
@@ -1,67 +1,7 @@
1
+ import { a as RunnerMessageListener, l as WorkerAddress, t as EnvRunner, u as WorkerHooks } from "./types.mjs";
1
2
  import { IncomingMessage } from "node:http";
2
3
  import { Socket } from "node:net";
3
4
 
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
- /** Core runner interface combining lifecycle hooks, RPC, and request proxying. */
52
- interface EnvRunner extends WorkerHooks, RunnerRPCHooks {
53
- /** Whether the worker is ready to accept requests. */
54
- readonly ready: boolean;
55
- /** Whether the runner has been closed. */
56
- readonly closed: boolean;
57
- /** Proxy an HTTP request to the worker. */
58
- fetch: FetchHandler;
59
- /** Proxy a WebSocket upgrade request to the worker. */
60
- upgrade?: UpgradeHandler;
61
- /** Gracefully shut down the worker. */
62
- close(): Promise<void>;
63
- }
64
- //#endregion
65
5
  //#region src/common/base-runner.d.ts
66
6
  interface EnvRunnerData {
67
7
  name?: string;
@@ -93,6 +33,11 @@ declare abstract class BaseEnvRunner implements EnvRunner {
93
33
  abstract sendMessage(message: unknown): void;
94
34
  onMessage(listener: RunnerMessageListener): void;
95
35
  offMessage(listener: RunnerMessageListener): void;
36
+ waitForReady(timeout?: number): Promise<void>;
37
+ rpc<T = unknown>(name: string, data?: unknown, opts?: {
38
+ timeout?: number;
39
+ }): Promise<T>;
40
+ reloadModule(timeout?: number): Promise<void>;
96
41
  close(cause?: unknown): Promise<void>;
97
42
  protected _handleMessage(message: any): void;
98
43
  protected _closeSocket(): Promise<void>;
@@ -102,4 +47,4 @@ declare abstract class BaseEnvRunner implements EnvRunner {
102
47
  protected abstract _runtimeType(): string;
103
48
  }
104
49
  //#endregion
105
- export { NodeUpgradeContext as a, UpgradeContext as c, WorkerHooks as d, FetchHandler as i, UpgradeHandler as l, EnvRunnerData as n, RunnerMessageListener as o, EnvRunner as r, RunnerRPCHooks as s, BaseEnvRunner as t, WorkerAddress as u };
50
+ export { EnvRunnerData as n, BaseEnvRunner as t };
@@ -35,6 +35,71 @@ var BaseEnvRunner = class {
35
35
  offMessage(listener) {
36
36
  this._messageListeners.delete(listener);
37
37
  }
38
+ waitForReady(timeout = 5e3) {
39
+ if (this.ready) return Promise.resolve();
40
+ return new Promise((resolve, reject) => {
41
+ const timer = setTimeout(() => {
42
+ this._messageListeners.delete(listener);
43
+ reject(/* @__PURE__ */ new Error("Runner did not become ready in time"));
44
+ }, timeout);
45
+ const listener = () => {
46
+ if (this.ready) {
47
+ clearTimeout(timer);
48
+ this._messageListeners.delete(listener);
49
+ resolve();
50
+ }
51
+ };
52
+ this._messageListeners.add(listener);
53
+ });
54
+ }
55
+ rpc(name, data, opts) {
56
+ const id = Math.random().toString(36).slice(2);
57
+ const timeout = opts?.timeout ?? 3e3;
58
+ return new Promise((resolve, reject) => {
59
+ const timer = setTimeout(() => {
60
+ cleanup();
61
+ reject(/* @__PURE__ */ new Error(`RPC "${name}" timed out`));
62
+ }, timeout);
63
+ const listener = (msg) => {
64
+ if (msg?.__rpc_id === id) {
65
+ cleanup();
66
+ if (msg.error) reject(typeof msg.error === "string" ? new Error(msg.error) : msg.error);
67
+ else resolve(msg.data);
68
+ }
69
+ };
70
+ const cleanup = () => {
71
+ clearTimeout(timer);
72
+ this.offMessage(listener);
73
+ };
74
+ this.onMessage(listener);
75
+ this.sendMessage({
76
+ __rpc: name,
77
+ __rpc_id: id,
78
+ data
79
+ });
80
+ });
81
+ }
82
+ async reloadModule(timeout = 5e3) {
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ cleanup();
86
+ reject(/* @__PURE__ */ new Error("Module reload timed out"));
87
+ }, timeout);
88
+ const listener = (msg) => {
89
+ if (msg?.event === "module-reloaded") {
90
+ cleanup();
91
+ if (msg.error) reject(typeof msg.error === "string" ? new Error(msg.error) : msg.error);
92
+ else resolve();
93
+ }
94
+ };
95
+ const cleanup = () => {
96
+ clearTimeout(timer);
97
+ this.offMessage(listener);
98
+ };
99
+ this.onMessage(listener);
100
+ this.sendMessage({ event: "reload-module" });
101
+ });
102
+ }
38
103
  async close(cause) {
39
104
  if (this.closed) return;
40
105
  this.closed = true;
@@ -1,4 +1,4 @@
1
- //#region node_modules/.deno/std-env@4.0.0-rc.1/node_modules/std-env/dist/index.mjs
1
+ //#region node_modules/.pnpm/std-env@4.0.0-rc.1/node_modules/std-env/dist/index.mjs
2
2
  const e = globalThis.process?.env || Object.create(null), t = globalThis.process || { env: e }, n = t !== void 0 && t.env && t.env.NODE_ENV || void 0, r = [
3
3
  [`claude`, [`CLAUDECODE`, `CLAUDE_CODE`]],
4
4
  [`replit`, [`REPL_ID`]],
@@ -1,4 +1,5 @@
1
- import { d as WorkerHooks, n as EnvRunnerData, t as BaseEnvRunner } from "./base-runner.mjs";
1
+ import { u as WorkerHooks } from "./types.mjs";
2
+ import { n as EnvRunnerData, t as BaseEnvRunner } from "./base-runner.mjs";
2
3
 
3
4
  //#region src/runners/deno-process/runner.d.ts
4
5
  declare class DenoProcessEnvRunner extends BaseEnvRunner {
@@ -1,21 +1,70 @@
1
- import { d as WorkerHooks, n as EnvRunnerData, t as BaseEnvRunner } from "./base-runner.mjs";
1
+ import { u as WorkerHooks } from "./types.mjs";
2
+ import { n as EnvRunnerData, t as BaseEnvRunner } from "./base-runner.mjs";
2
3
 
3
4
  //#region src/runners/miniflare/runner.d.ts
5
+ /** Result from a module transform (compatible with Vite's `TransformResult`). */
6
+ interface TransformResult {
7
+ code: string;
8
+ }
9
+ /** Detected or declared export for auto-wiring Durable Object / Entrypoint bindings. */
10
+ interface MiniflareExportInfo {
11
+ type?: "DurableObject" | "WorkerEntrypoint" | "class";
12
+ }
4
13
  interface MiniflareEnvRunnerOptions {
5
14
  name: string;
6
15
  hooks?: WorkerHooks;
7
16
  data?: EnvRunnerData;
8
17
  /** Options passed directly to the Miniflare constructor. */
9
18
  miniflareOptions?: Record<string, unknown>;
19
+ /**
20
+ * Optional module transform callback. When provided, the module fallback
21
+ * service calls this instead of reading raw files from disk.
22
+ *
23
+ * This enables integration with Vite's transform pipeline — pass
24
+ * `environment.transformRequest` to get TS/JSX/etc. compiled on the fly.
25
+ *
26
+ * @param id - Absolute file path of the module to transform
27
+ * @returns Transformed code, or null/undefined to fall back to raw disk read
28
+ */
29
+ transformRequest?: (id: string) => Promise<TransformResult | null | undefined>;
30
+ /**
31
+ * Declare named exports (Durable Objects, WorkerEntrypoints) to auto-wire
32
+ * bindings and generate re-exports in the wrapper module.
33
+ *
34
+ * When set to `true`, `export class` declarations are auto-detected from
35
+ * the entry file. When set to a record, the listed exports are used
36
+ * (merged with auto-detected ones). Disabled by default.
37
+ */
38
+ exports?: Record<string, MiniflareExportInfo> | boolean;
39
+ /**
40
+ * When `true`, the Miniflare instance is cached and reused across runner
41
+ * swaps (e.g. via `RunnerManager.reload()`). `close()` tears down IPC but
42
+ * keeps Miniflare alive. Call `dispose()` to fully destroy it.
43
+ */
44
+ persistent?: boolean;
45
+ /** Wrap the user's `fetch` in a try/catch that returns structured JSON error responses. Default: `true`. */
46
+ captureErrors?: boolean;
10
47
  }
11
48
  declare class MiniflareEnvRunner extends BaseEnvRunner {
12
49
  #private;
13
50
  constructor(opts: MiniflareEnvRunnerOptions);
51
+ /** Dispose all persistent Miniflare instances from the cache. */
52
+ static disposeAll(): Promise<void>;
53
+ /** Fully dispose the Miniflare instance (even if persistent). */
54
+ dispose(): Promise<void>;
14
55
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
15
56
  sendMessage(message: unknown): void;
57
+ /**
58
+ * Hot-reload the user entry module without recreating the Miniflare instance.
59
+ *
60
+ * Sends `reload-module` event over the WebSocket. The worker wrapper uses
61
+ * `unsafeEvalBinding` to re-import the entry with a cache-busting query string
62
+ * and responds with `module-reloaded` when done.
63
+ */
64
+ reloadModule(timeout?: number): Promise<void>;
16
65
  protected _hasRuntime(): boolean;
17
66
  protected _runtimeType(): string;
18
67
  protected _closeRuntime(): Promise<void>;
19
68
  }
20
69
  //#endregion
21
- export { MiniflareEnvRunnerOptions as n, MiniflareEnvRunner as t };
70
+ export { TransformResult as i, MiniflareEnvRunnerOptions as n, MiniflareExportInfo as r, MiniflareEnvRunner as t };