env-runner 0.1.15 → 0.1.16

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
@@ -73,9 +73,36 @@ await envServer.reload();
73
73
  // Use with any HTTP server
74
74
  const server = serve({
75
75
  fetch: (request) => envServer.fetch(request),
76
+ // Proxy WebSocket upgrades to the worker (see "WebSocket proxying" below)
77
+ plugins: [await envServer.wsSrvxPlugin()],
76
78
  });
77
79
  ```
78
80
 
81
+ #### WebSocket proxying
82
+
83
+ To proxy WebSocket upgrades to the worker, attach the plugin returned by
84
+ `wsSrvxPlugin()` (available on both `RunnerManager` and `EnvServer`) to your
85
+ [srvx](https://srvx.h3.dev) server:
86
+
87
+ ```ts
88
+ const server = serve({
89
+ fetch: (request) => envServer.fetch(request),
90
+ plugins: [await envServer.wsSrvxPlugin()],
91
+ });
92
+ ```
93
+
94
+ The plugin picks the proxy strategy by **host** runtime:
95
+
96
+ - **Node** — proxies the raw upgrade socket to the worker (transparent
97
+ passthrough; subprotocol/extension negotiation stays end-to-end).
98
+ - **Bun/Deno** — those runtimes serve natively and expose no Node upgrade
99
+ socket, so the client WebSocket is terminated with [crossws](https://crossws.h3.dev)
100
+ and bridged to the worker over a standard `WebSocket` client.
101
+
102
+ It reads the active runner lazily, so it keeps working across hot-reloads, and
103
+ waits for the worker to become ready before proxying. Your entry module should
104
+ expose WebSocket hooks via the `websocket` field (see [Workers](#workers)).
105
+
79
106
  ### Manager (`RunnerManager`)
80
107
 
81
108
  Proxy manager for hot-reload with message queueing and listener forwarding:
@@ -147,7 +174,8 @@ await using runner = new NodeProcessEnvRunner({
147
174
  // Relative URLs are resolved against a placeholder origin
148
175
  const response = await runner.fetch("/api");
149
176
 
150
- // Proxy WebSocket upgrades
177
+ // Proxy a raw WebSocket upgrade to the worker (Node host only — low-level;
178
+ // prefer `manager.wsSrvxPlugin()` for cross-runtime proxying)
151
179
  runner.upgrade?.({ node: { req, socket, head } });
152
180
 
153
181
  // Wait for runner to be ready
@@ -50,6 +50,7 @@ declare abstract class BaseEnvRunner implements EnvRunner, AsyncDisposable {
50
50
  data?: EnvRunnerData;
51
51
  });
52
52
  get ready(): boolean;
53
+ get address(): WorkerAddress | undefined;
53
54
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
54
55
  upgrade(context: {
55
56
  node: {
@@ -23,13 +23,20 @@ var BaseEnvRunner = class {
23
23
  get ready() {
24
24
  return Boolean(!this.closed && this._address && this._hasRuntime());
25
25
  }
26
+ get address() {
27
+ return this._address;
28
+ }
26
29
  async fetch(input, init) {
27
30
  for (let i = 0; i < 5 && !this._address && !this.closed; i++) await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i)));
28
31
  if (!this._address) return new Response(`${this._runtimeType()} env runner is unavailable`, { status: 503 });
29
32
  return proxyFetch(this._address, this._resolveFetchInput(input), init);
30
33
  }
31
34
  async upgrade(context) {
32
- if (!this.ready || !this._address) return;
35
+ if (!this.ready) await this.waitForReady().catch(() => {});
36
+ if (!this.ready || !this._address) {
37
+ context.node.socket.destroy();
38
+ return;
39
+ }
33
40
  try {
34
41
  await proxyUpgrade(this._address, context.node.req, context.node.socket, context.node.head);
35
42
  } catch {}
@@ -40,7 +47,7 @@ var BaseEnvRunner = class {
40
47
  offMessage(listener) {
41
48
  this._messageListeners.delete(listener);
42
49
  }
43
- waitForReady(timeout = 5e3) {
50
+ waitForReady(timeout = 15e3) {
44
51
  if (this.ready) return Promise.resolve();
45
52
  if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("Runner closed before becoming ready"));
46
53
  return new Promise((resolve, reject) => {
@@ -1,4 +1,34 @@
1
1
  import { watch } from "node:fs";
2
+ async function createRunnerWSProxyPlugin(getRunner) {
3
+ const isBun = "Bun" in globalThis;
4
+ const isDeno = "Deno" in globalThis;
5
+ if (!isBun && !isDeno) return (server) => {
6
+ server.ready().then(() => {
7
+ (server.node?.server)?.on("upgrade", (req, socket, head) => {
8
+ getRunner()?.upgrade?.({ node: {
9
+ req,
10
+ socket,
11
+ head
12
+ } });
13
+ });
14
+ }).catch(() => {});
15
+ };
16
+ const { createWebSocketProxy } = await import("crossws");
17
+ const { plugin } = isBun ? await import("crossws/server/bun") : await import("crossws/server/deno");
18
+ const proxy = createWebSocketProxy({ target: async (peer) => {
19
+ await getRunner()?.waitForReady?.().catch(() => {});
20
+ return resolveWSProxyTarget(getRunner()?.address, peer.request.url);
21
+ } });
22
+ return plugin({ resolve: () => proxy });
23
+ }
24
+ function resolveWSProxyTarget(address, requestUrl) {
25
+ if (!address) throw new Error("env runner worker is not ready");
26
+ const { pathname, search } = new URL(requestUrl);
27
+ if (address.socketPath) return `ws+unix://${address.socketPath}:${pathname}${search}`;
28
+ if (!address.port) throw new Error("env runner worker is not ready");
29
+ const host = address.host || "127.0.0.1";
30
+ return `ws://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${address.port}${pathname}${search}`;
31
+ }
2
32
  var RunnerManager = class {
3
33
  _runner;
4
34
  _messageQueue = [];
@@ -9,6 +39,7 @@ var RunnerManager = class {
9
39
  _pendingModuleReload;
10
40
  _closeListeners = /* @__PURE__ */ new Set();
11
41
  _readyListeners = /* @__PURE__ */ new Set();
42
+ _readyRejectors = /* @__PURE__ */ new Set();
12
43
  constructor(runner) {
13
44
  if (runner) this._attach(runner);
14
45
  }
@@ -18,6 +49,9 @@ var RunnerManager = class {
18
49
  get closed() {
19
50
  return this._closed;
20
51
  }
52
+ get address() {
53
+ return this._runner?.address;
54
+ }
21
55
  async reload(runner) {
22
56
  this._reloading = true;
23
57
  try {
@@ -49,9 +83,17 @@ var RunnerManager = class {
49
83
  });
50
84
  return this._pendingModuleReload;
51
85
  }
52
- upgrade = (context) => {
53
- this._runner?.upgrade?.(context);
86
+ upgrade = async (context) => {
87
+ const runner = await this._waitForRunner();
88
+ if (!runner?.upgrade) {
89
+ context.node.socket.destroy();
90
+ return;
91
+ }
92
+ await runner.upgrade(context);
54
93
  };
94
+ wsSrvxPlugin() {
95
+ return createRunnerWSProxyPlugin(() => this);
96
+ }
55
97
  sendMessage(message) {
56
98
  if (!this._runner || !this._runner.ready) {
57
99
  this._messageQueue.push(message);
@@ -67,21 +109,31 @@ var RunnerManager = class {
67
109
  this._messageListeners.delete(listener);
68
110
  this._runner?.offMessage(listener);
69
111
  }
70
- waitForReady(timeout = 5e3) {
112
+ waitForReady(timeout = 15e3) {
71
113
  if (this.ready) return Promise.resolve();
114
+ if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("Runner closed before becoming ready"));
72
115
  return new Promise((resolve, reject) => {
116
+ const cleanup = () => {
117
+ clearTimeout(timer);
118
+ this.offMessage(listener);
119
+ this._readyRejectors.delete(onClose);
120
+ };
73
121
  const timer = setTimeout(() => {
74
- this._messageListeners.delete(listener);
122
+ cleanup();
75
123
  reject(/* @__PURE__ */ new Error("Runner did not become ready in time"));
76
124
  }, timeout);
77
125
  const listener = (message) => {
78
126
  if (message?.address || this.ready) {
79
- clearTimeout(timer);
80
- this._messageListeners.delete(listener);
127
+ cleanup();
81
128
  resolve();
82
129
  }
83
130
  };
84
- this._messageListeners.add(listener);
131
+ const onClose = () => {
132
+ cleanup();
133
+ reject(/* @__PURE__ */ new Error("Runner closed before becoming ready"));
134
+ };
135
+ this.onMessage(listener);
136
+ this._readyRejectors.add(onClose);
85
137
  });
86
138
  }
87
139
  rpc(name, data, opts) {
@@ -124,6 +176,8 @@ var RunnerManager = class {
124
176
  async close() {
125
177
  this._closed = true;
126
178
  this._messageQueue.length = 0;
179
+ for (const rejectReady of this._readyRejectors) rejectReady();
180
+ this._readyRejectors.clear();
127
181
  const runner = this._runner;
128
182
  this._detach();
129
183
  if (runner) await runner.close();
@@ -62,6 +62,8 @@ interface EnvRunner extends RunnerRPCHooks, AsyncDisposable {
62
62
  readonly ready: boolean;
63
63
  /** Whether the runner has been closed. */
64
64
  readonly closed: boolean;
65
+ /** Address the worker is listening at, once ready (`undefined` before then). */
66
+ readonly address?: WorkerAddress;
65
67
  /** Proxy an HTTP request to the worker. */
66
68
  fetch: FetchHandler;
67
69
  /** Proxy a WebSocket upgrade request to the worker. */
@@ -44,7 +44,9 @@ function _checkVercelOidcToken(token = process.env.VERCEL_OIDC_TOKEN) {
44
44
  let _defaultEntry;
45
45
  const _podId = Math.random().toString(32).slice(-5);
46
46
  function generateVercelId() {
47
- return `dev1::${_podId}-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
47
+ const timestamp = Date.now().toString(36);
48
+ const random = randomBytes(6).toString("hex");
49
+ return `dev1::${_podId}-${timestamp}-${random}`;
48
50
  }
49
51
  var VercelEnvRunner = class extends NodeWorkerEnvRunner {
50
52
  constructor(opts) {
package/dist/cli.mjs CHANGED
@@ -50,16 +50,10 @@ const server = serve({
50
50
  port: values.port,
51
51
  hostname: values.host,
52
52
  gracefulShutdown: false,
53
- fetch: (request) => envServer.fetch(request)
53
+ fetch: (request) => envServer.fetch(request),
54
+ plugins: [await envServer.wsSrvxPlugin()]
54
55
  });
55
56
  await server.ready();
56
- server.node?.server?.on("upgrade", (req, socket, head) => {
57
- envServer.upgrade({ node: {
58
- req,
59
- socket,
60
- head
61
- } });
62
- });
63
57
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, async () => {
64
58
  console.log(`\n\x1B[2mShutting down...\x1B[0m`);
65
59
  await envServer.close();
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@ import { DenoProcessEnvRunner } from "./_chunks/deno-process-runner.mjs";
4
4
  import { MiniflareEnvRunner, MiniflareEnvRunnerOptions, MiniflareExportInfo, TransformResult } from "./_chunks/miniflare-runner.mjs";
5
5
  import { VercelEnvRunner } from "./_chunks/vercel-runner.mjs";
6
6
  import { NetlifyEnvRunner } from "./_chunks/netlify-runner.mjs";
7
- import { ServerOptions } from "srvx";
7
+ import { ServerOptions, ServerPlugin } from "srvx";
8
8
  import { Hooks } from "crossws";
9
9
  /**
10
10
  * Manages an active `EnvRunner` instance, proxying all calls to it.
@@ -20,9 +20,11 @@ declare class RunnerManager implements EnvRunner, AsyncDisposable {
20
20
  private _pendingModuleReload;
21
21
  private _closeListeners;
22
22
  private _readyListeners;
23
+ private _readyRejectors;
23
24
  constructor(runner?: EnvRunner);
24
25
  get ready(): boolean;
25
26
  get closed(): boolean;
27
+ get address(): WorkerAddress | undefined;
26
28
  /**
27
29
  * Replace the active runner with a new one. Closes the previous runner.
28
30
  *
@@ -41,6 +43,14 @@ declare class RunnerManager implements EnvRunner, AsyncDisposable {
41
43
  */
42
44
  private _flushInvalidation;
43
45
  upgrade: UpgradeHandler;
46
+ /**
47
+ * Create a runtime-native WebSocket reverse-proxy plugin for the public srvx
48
+ * server. Attach it via `serve({ plugins: [await manager.wsSrvxPlugin()] })`:
49
+ * on Node it proxies the raw upgrade socket to the worker, and on Bun/Deno it
50
+ * bridges the WebSocket with crossws. The plugin reads the active runner
51
+ * lazily, so it keeps working across hot-reloads.
52
+ */
53
+ wsSrvxPlugin(): Promise<ServerPlugin>;
44
54
  sendMessage(message: unknown): void;
45
55
  onMessage(listener: RunnerMessageListener): void;
46
56
  offMessage(listener: RunnerMessageListener): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "env-runner",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Generic environment runner for JavaScript runtimes.",
5
5
  "license": "MIT",
6
6
  "repository": "unjs/env-runner",
@@ -43,10 +43,10 @@
43
43
  "typecheck": "tsgo --noEmit --skipLibCheck"
44
44
  },
45
45
  "dependencies": {
46
- "crossws": "^0.4.6",
46
+ "crossws": "^0.4.8",
47
47
  "exsolve": "^1.1.0",
48
- "httpxy": "^0.5.3",
49
- "srvx": "^0.11.17"
48
+ "httpxy": "^0.5.4",
49
+ "srvx": "^0.11.19"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@netlify/runtime": "^4.1.25",