Skip to content

feat(cloudflare/vite): runner-mode dev with HMR via Bun.serve front-proxy#145

Draft
Mkassabov wants to merge 2 commits into
fix/filter-bun-watch-warningsfrom
vite-dev-hmr
Draft

feat(cloudflare/vite): runner-mode dev with HMR via Bun.serve front-proxy#145
Mkassabov wants to merge 2 commits into
fix/filter-bun-watch-warningsfrom
vite-dev-hmr

Conversation

@Mkassabov
Copy link
Copy Markdown
Contributor

Wire Cloudflare.Vite into bun alchemy dev so SPA projects get a working dev loop with Vite HMR. SSR projects (cloudflare-vite-plugin virtual entry) detected at runtime and gated until the workerd-side runner pieces are wired through serveVite.

Before: bun alchemy dev crashed with BundleError: Failed to find real path for bundle: undefined for any Cloudflare.Vite resource because LocalWorkerProvider had no Vite branch.

After:

import * as Cloudflare from "alchemy/Cloudflare";
const Web = Cloudflare.Vite("Web", {
  rootDir: "./web",
  compatibility: { flags: ["nodejs_compat"] },
  assets: {
    config: {
      htmlHandling: "auto-trailing-slash",
      notFoundHandling: "single-page-application",
    },
  },
});
$ bun alchemy dev --profile <profile>
✓ Web (Cloudflare.Worker) updated
{ webUrl: "http://web-...localhost:1337" }

Browser loads the SPA via the workerd LocalProxy → Bun.serve front-proxy → Vite middleware. Vite's HMR runs on a dedicated port the browser connects to directly. Editing web/src/main.ts hot-updates without a page refresh. The HMR port is persisted across bun --watch reloads so the browser auto-reconnects when the dev process restarts.

A small auto-accept Vite plugin appends if (import.meta.hot) import.meta.hot.accept() to user .ts/.js/.tsx/.jsx files outside node_modules that don't already mention import.meta.hot — boilerplate-free hot-update for plain SPAs.

Architecture

  • Cloudflare/Local/Vite/ViteDev.ts — spawns Vite in middleware mode with a dedicated HMR port; control HTTP server for SSR module-graph snapshots.
  • Cloudflare/Local/Vite/FrontProxy.ts — Bun.serve listener that drives Vite's connect middleware against a real Writable shim (so sirv's createReadStream(file).pipe(res) for /@fs/ works) and falls through to workerd for SSR.
  • Cloudflare/Local/Vite/AutoAcceptPlugin.ts — auto-injects import.meta.hot.accept().
  • Cloudflare/Local/Vite/HostWorker.ts + ModuleSnapshot.ts — workerd-side runner-mode pieces (worker_loader bootstrap + SSR module-graph walk). Type-clean but currently gated; activation needs Runtime/Bindings exposed from cloudflare-runtime/RuntimeServices.
  • Cloudflare/Local/Sidecar.ts + SidecarHandlers.ts — new serveVite RPC. Hash-keyed reuse map keeps the same Vite scope alive across bun --watch reloads on POSIX.
  • Cloudflare/Workers/LocalWorkerProvider.ts — branches on props.vite.

Windows fixes that came out of testing

  • Sidecar/Lock.tsDate.now()new Date() on win32 (Bun fs.utimes EINVAL on millis); bounded retry on lock conflict to absorb the bun --watch reload race.
  • SidecarServer.ts — exit silently on LockError(Conflict) instead of dumping a stack trace.
  • Sidecar/RpcClient.ts — drop detached: true on win32. DETACHED_PROCESS allocates a console window regardless of windowsHide. Trade-off: sidecar dies with parent on Windows (Vite restarts on alchemy.run.ts edits), but the persisted HMR port lets the browser auto-reconnect.
  • Util/PlatformServices.ts — patch child_process.spawn/etc. and Bun.spawn/spawnSync to default windowsHide: true on win32.
  • bin/exec.ts — propagate --profile to subprocesses via ALCHEMY_PROFILE env. ConfigProvider override only applied to the local Effect runtime; the sidecar saw "default" profile and prompted for Cloudflare auth.
  • Vite/ViteDev.tscacheDir set to <root>/.alchemy/vite (absolute via path.resolve). Vite's default cache lives under node_modules/.vite/deps, which sits inside a bun link-ed alchemy symlink — bun --watch follows the symlink and reloads the dev process every time Vite re-optimizes deps.

Vite 8 compatibility

FrontProxy.ts shim is a real Writable and implements the full ServerResponse surface Vite 8 + sirv exercise: setHeader/getHeader/removeHeader/appendHeader/getHeaders/getHeaderNames/hasHeader/writeHead/flushHeaders plus stream _write. appendHeader is required for Vite 8's Vary: Sec-Fetch-Dest and stream-piping is required for sirv to serve env.mjs etc. with the right MIME type.

Bun emits a hard-coded `warn: File <path> is not in the project directory
and will not be watched` line for every transpiled file outside its
auto-detected project root. In this monorepo that fires hundreds of
times on every `alchemy <cmd>` invocation and there is no bun flag to
silence it.

`foreground-child` upstream hardcodes `stdio = [0, 1, 2]`, so we vendor
a minimal copy (`bin/_foreground-child.js`) that adds an optional
`stderrFilter` option — pipe stderr, line-buffer, drop matches, forward
the rest. Watchdog, signal proxying, IPC bridging, and exit-code
forwarding are preserved verbatim from upstream (ISC, attribution at
top of file). Drops the `foreground-child` dep in favor of a direct
`signal-exit` dep (already a transitive).

Also harden the `uncaughtException` handler for Windows: on Ctrl-C the
watchdog exits with `STATUS_CONTROL_C_EXIT` (3221225786) and
`signal: null` rather than `SIGINT`, so the original guard missed it.
…roxy

Wire `Cloudflare.Vite` into `bun alchemy dev` so SPA projects get a
working dev loop with HMR, falling-through to workerd when an SSR
entry exists.

Architecture: ViteDev runs Vite in middleware mode + a small control
HTTP server. FrontProxy is a Bun.serve listener that drives Vite's
connect middleware against a real `Writable` shim (so sirv's
`createReadStream(...).pipe(res)` for `/@fs/` static files works) and
proxies anything Vite doesn't handle to the workerd HTTP socket. HMR
runs on a dedicated port the browser connects to directly, bypassing
the workerd subdomain proxy. HostWorker.ts + ModuleSnapshot.ts are
the workerd-side runner pieces (worker_loader + Vite module-graph
snapshot) — present and type-clean but the SSR provisioning path is
gated until Runtime/Bindings get exposed from cloudflare-runtime.

LocalWorkerProvider branches on `props.vite` and routes through a new
`serveVite` RPC. The RPC handler spawns ViteDev + FrontProxy,
registers the front-proxy with LocalProxy, and falls back to the raw
front-proxy URL when LocalProxy is unavailable.

Windows fixes that came out of testing:
- `Sidecar/Lock.ts`: Bun on Windows EINVAL-rejects ms-since-epoch
  numbers from `fs.utimes`, so pass `new Date()` on win32 only. Lock
  acquire also retries (~3s, 150ms spacing) on Conflict to absorb
  the bun --watch reload race where a new sidecar starts before the
  old one has finalized.
- `SidecarServer.ts`: catch Lock-Conflict and exit silently — the
  parent's RPC retry will connect to the canonical sidecar.
- `Sidecar/RpcClient.ts`: drop `detached: true` on win32. DETACHED
  process flag allocates a new console window on Windows even with
  windowsHide, so the sidecar popped a terminal every dev run.
- `Util/PlatformServices.ts`: monkey-patch `child_process.spawn`
  AND `Bun.spawn` to default `windowsHide: true` on win32, so all
  Effect-spawned children (Effect's BunChildProcessSpawner reaches
  Bun.spawn directly) inherit it.
- `bin/exec.ts`: propagate `--profile` to spawned subprocesses via
  `ALCHEMY_PROFILE` env. The ConfigProvider override only applies to
  the local Effect runtime, not to children — without this the
  sidecar defaulted to the "default" profile and prompted for
  Cloudflare auth in dev.
- `Vite/ViteDev.ts`: `cacheDir` set to `<root>/.alchemy/vite`. Vite's
  default dep-optimizer cache lives in `node_modules/.vite/deps`,
  which sits inside a `bun link`-ed alchemy symlink — bun --watch
  follows the symlink and reloads the dev process every time Vite
  re-optimizes deps.

Catalog refs for `@distilled.cloud/cloudflare-runtime` and
`@distilled.cloud/cloudflare-vite-plugin` temporarily point at
local link: paths because the runner-mode work depends on a
`globalOutbound: { name: "internet" }` change to LocalProxy in
cloudflare-runtime that hasn't been published yet (without it the
local-proxy worker can't fetch loopback addresses on Windows —
WSARecv #64). Revert the catalog before merging once that ships.
@Mkassabov Mkassabov force-pushed the fix/filter-bun-watch-warnings branch from 32b5158 to 875c6e4 Compare May 14, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant