Skip to content

Commit dbc9cbb

Browse files
committed
feat!: require explicit runtime deps
1 parent 7fef0a6 commit dbc9cbb

29 files changed

Lines changed: 1128 additions & 217 deletions

‎.agents/ARCHITECTURE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ Detailed per-file notes for the shared/core modules. For the directory map see t
66
- **`src/types.ts`** — Core interfaces: `EnvRunner`, `WorkerAddress`, `WorkerHooks`, `RunnerRPCHooks`, `RPCOptions`
77
- **`src/common/base-runner.ts`** — `BaseEnvRunner` abstract class + `EnvRunnerData`: shared logic for all runners (fetch proxy with exponential backoff, upgrade, message dispatch, socket cleanup). Exposes a read-only `address` getter (the live `WorkerAddress`, used by the WebSocket bridge to target the worker). `upgrade()` is the Node raw-socket passthrough (httpxy `proxyUpgrade`); it now awaits `waitForReady()` (bounded) before proxying instead of silently dropping an upgrade that arrives while the worker is still (re)starting, and destroys the raw client socket on the give-up path (worker never became ready) so it isn't leaked. `_resolveVirtualData()` resolves factory-valued `data.virtual` sources to strings on the host before spawn (returns a promise only when a factory is present, else `undefined` for the synchronous spawn path); `_initWithVirtualData(init)` wraps it for subclass constructors — runs `init` synchronously when no factory is present, defers it otherwise, and routes a throwing/rejecting factory to `close(error)` (no unhandled rejection). `_handleMessage()` treats a worker `{ event: "init-error", error }` message (sent by built-in workers when virtual-module registration or the entry import throws) as fatal before ready: it closes the runner with the error as cause, so callers see the actionable message instead of a bare "process exited with code 1". `waitForReady()` also rejects promptly ("Runner closed before becoming ready") when the runner is/becomes closed before the address arrives. `rpc()`, `reloadModule()`, and `invalidateModule()` all go through the protected `_request(message, { match, timeout, timeoutError, send? })` helper (one send-and-await-ack implementation; `send` overrides the transport, used by the miniflare runner's WebSocket reload): it rejects on timeout, on an `error`-carrying response, and **promptly when the runner closes mid-wait** (pending rejectors in `_pendingRequests`, fired by `close()` with the close cause) instead of letting callers wait out the timeout on a dead worker. `invalidateModule(specifier, timeout?)` re-runs a factory-valued virtual source on the host (`_refreshVirtualSource()`, using the original sources kept in `_virtualSources` by `_resolveVirtualData()`), sends `{ event: "invalidate-module", specifier, source? }` to the worker, and awaits the `{ event: "module-invalidated", specifier, error? }` ack — rejects for non-virtual specifiers. `_refreshVirtualSource()` first awaits `_virtualResolved` (the pending factory resolution): before it settles, `_data.virtual` still aliases the original factory-valued map, and writing a resolved string into it would permanently replace the factory (and mutate the caller's options). Re-exports `VirtualModules`/`VirtualModuleSource`
88
- **`src/common/worker-utils.ts`** — Shared utilities for built-in workers: `AppEntry` interface (with optional `websocket`, `upgrade`, and `ipc` hooks), `AppEntryIPC`/`AppEntryIPCContext` types, `resolveEntry()` to dynamically import user entry (a real path **or** a virtual/bare specifier resolved by registered ESM hooks), `isVirtualSpecifier()` to check whether a specifier is a key of the `data.virtual` map, `parseServerAddress()` to extract host/port from srvx server, `reloadEntryModule()` for cache-busted re-import with IPC teardown/re-init. Workers pass `isVirtualSpecifier(data.entry, data.virtual)` as the `virtual` flag through `resolveEntry()`/`reloadEntryModule()`: a virtual entry skips all filesystem path handling (no `file://` conversion, no `existsSync`/`data:`-URL re-read), so a virtual key **overrides** a real file with the same path. Without the flag, `_importFresh()` re-reads real files via a `data:` URL and cache-busts bare specifiers with a `?__envRunnerReload=<n>` query (detected by `existsSync` on the path part). For virtual specifiers, `_importFresh()` first tries `refreshVirtualModule()` (Bun backend: re-register to bust the cache, then plain re-import) and falls back to the `?__envRunnerReload` query (registerHooks backend)
9+
- **`src/common/runtime-deps.ts`** — Shared resolver for the runtime dependencies `env-runner` deliberately does not depend on. `RuntimeDep<T> = T | string | URL | false` is the single shape every such option accepts: the imported module, a specifier, or `false` to opt out. `resolveRuntimeDep({ name, option, value, expect?, from?, required?, hint? })` resolves it — `false` → `undefined`; a module → validated against `expect` (a named export that must be a function) and returned as-is; a specifier → `resolveSpecifier()` (URLs and URL-like strings pass through, bare names go through exsolve's `resolveModulePath(..., { from: cwd, try: true })` → `file://` href, unresolvable ones pass through verbatim) then `import()`, wrapping a failure in an actionable `TypeError` with the original as `cause`; omitted → the same optional import of `name`, yielding `undefined` when the package isn't installed unless `required` is set. `resolveRuntimeDepSpecifier(value, option, from?)` is the boundary-crossing variant for options whose package must be imported _inside_ a worker: it normalizes a specifier the same way but throws for a module instance. Used by `MiniflareEnvRunner`'s `miniflare` (`required`), `loadWranglerConfig()`'s `wranglerModule`, `registerVercelQueueConsumer()`'s `sdk`, and `NetlifyEnvRunner`'s `netlifyRuntime` (host: `resolveRuntimeDepSpecifier()`; worker: `resolveRuntimeDep()`). Exported publicly from `src/index.ts` as `resolveRuntimeDep`/`RuntimeDep`
910
- **`src/common/ws-proxy.ts`** — `createRunnerWSProxyPlugin(getRunner)`: builds a runtime-native WebSocket reverse-proxy srvx plugin for the public-facing server, picking the strategy by **host** runtime. On Node it attaches the `http.Server` `"upgrade"` event (after `server.ready()`) and forwards the raw socket through `runner.upgrade()` — transparent, single-framed, end-to-end subprotocol/extension negotiation. On Bun/Deno (which serve natively and expose no Node upgrade socket) it terminates the client with crossws (`crossws/server/{bun,deno}` + `createWebSocketProxy`) and bridges to the worker over crossws's own `WebSocket` client (`resolveWSProxyTarget()` builds a `ws://host:port` or `ws+unix://<socket>:<path>` target; crossws dials both uniformly on Node/Bun/Deno, Deno needing `--unstable-net` for Unix sockets), forwarding the subprotocol and awaiting runner readiness before resolving the target. `getRunner` is read lazily so the plugin survives hot-reloads. Internal — surfaced publicly only as `RunnerManager.wsSrvxPlugin()`
1011
- **`src/virtual-loader.ts`** + **`src/common/virtual-modules.ts`** — Virtual-module ESM hooks/registration. See [`VIRTUAL-MODULES.md`](VIRTUAL-MODULES.md)
1112
- **`src/runners/<name>/`** — Per-runner `runner.ts` (+ `worker.ts`) implementations. See the runner reference docs: [`NODE-RUNNERS.md`](NODE-RUNNERS.md) (node-worker, node-process, bun-process, deno-process, self), [`MINIFLARE.md`](MINIFLARE.md) (miniflare + wrangler), [`VERCEL.md`](VERCEL.md), [`NETLIFY.md`](NETLIFY.md)
12-
- **`src/loader.ts`** — `loadRunner(name, opts)`: dynamic loader that imports a runner by name (`node-worker` | `node-process` | `bun-process` | `deno-process` | `self` | `miniflare` | `vercel` | `netlify`) and returns an `EnvRunner` instance
13+
- **`src/loader.ts`** — `loadRunner(name, opts)`: dynamic loader that imports a runner by name (`node-worker` | `node-process` | `bun-process` | `deno-process` | `self` | `miniflare` | `vercel` | `netlify`) and returns an `EnvRunner` instance. `LoadRunnerOptions`' index signature carries runner-specific options (e.g. the `miniflare` module) through untyped, which is why the `miniflare` entry casts to `RunnerConstructor`
1314
- **`src/manager.ts`** — `RunnerManager`: proxy manager for hot-reload, message queueing, and listener forwarding across runner swaps. The `fetch` field delegates to a protected `_fetch()` method so subclasses can hook request handling (used by `EnvServer` for auto-start). `reload(runner?)`'s argument is optional: with no runner it calls the protected `_createRunner()` factory, which throws on the base class and is overridden by `EnvServer`. `invalidateModule()` sets a dirty flag (`_moduleInvalidated`) and the next `fetch()` lazily flushes it with one `reloadModule()` before serving (`_flushInvalidation()` — concurrent fetches share the reload promise; a failed reload keeps the flag set so the next fetch retries); an explicit `reloadModule()` or a runner swap via `reload()` clears the flag
14-
- **`src/server.ts`** — `EnvServer` extends `RunnerManager`: high-level API combining runner loading, watch mode (`fs.watch` with 100ms debounce), and auto-reload on file changes. Supports `watch` and `watchPaths` options. The `runner` option is optional and defaults to `"node-worker"`. `start()` is idempotent (a shared `_startPromise`; a failed start resets it so a later call retries) and optional: the first `fetch()` auto-starts via the `_fetch()` override (skipped once closed — fetch after `close()` stays a 503, never respawns)
15+
- **`src/server.ts`** — `EnvServer` extends `RunnerManager`: high-level API combining runner loading, watch mode (`fs.watch` with 100ms debounce), and auto-reload on file changes. Supports `watch` and `watchPaths` options. The `runner` option is optional and defaults to `"node-worker"`. `runnerOptions` (a plain record) is spread into the runner constructor ahead of the derived `name`/`hooks`/`data`/`execArgv`, carrying runner-specific dependencies such as `{ miniflare }` (the CLI passes none — the miniflare runner's own optional import covers `--runner miniflare`). `start()` is idempotent (a shared `_startPromise`; a failed start resets it so a later call retries) and optional: the first `fetch()` auto-starts via the `_fetch()` override (skipped once closed — fetch after `close()` stays a 503, never respawns)
1516
- **`src/cli.ts`** — CLI entry point: `env-runner <entry> [--runner] [--port] [--host] [-w/--watch]`
1617
- **`src/index.ts`** — Public API: re-exports types, `BaseEnvRunner`, concrete runners, `SelfEnvRunner`, `RunnerManager`, `EnvServer`, and `loadRunner`
1718

@@ -58,4 +59,5 @@ High-level API extending `RunnerManager` with runner loading and file watching:
5859
- `close()` — Stops watchers and closes the runner
5960
- `watch: true` — Watches the entry file using `fs.watch()` with 100ms debounce; on change, creates a new runner and calls `reload()`
6061
- `watchPaths` — Additional directories/files to watch (supports `recursive: true`)
62+
- `runnerOptions` — Extra runner constructor options spread into `_createRunner()`'s `loadRunner()` call (e.g. `{ miniflare }` for `runner: "miniflare"`)
6163
- `onReload(listener)`/`offReload(listener)` — Multi-listener reload events (Set-based)

0 commit comments

Comments
 (0)