Cloudflare: Build & dev
Build configuration & adapter layering
Section titled “Build configuration & adapter layering”| Layer | Astro | phaze-cloudflare |
|---|---|---|
| Framework | Astro core + integrations (phaze-astro) | Vite + cloudflare() plugin |
| Adapter | @astrojs/cloudflare (separate package) | single plugin — @madenowhere/phaze-cloudflare/vite covers route discovery, virtual modules, env type-gen, server-entry codegen, ambient .d.ts |
| Dev server | Astro dev + island reloader (Astro’s adapter mounts @cloudflare/vite-plugin for in-process workerd) | two single-process modes — pnpm dev (watch-build + in-process Miniflare) and pnpm dev:hmr (Vite dev server + cf-plugin module runner). See Section 16’s Two dev modes below |
| Output | adapter writes _worker.js/_routes.json | plugin writes a single dist/server/index.js Worker + dist/client/ assets |
| Wrangler bundling | adapter wires wrangler.toml for you | user provides wrangler.toml; plugin emits a self-contained entry that no_bundle: true can serve |
import { defineConfig } from 'vite'import cloudflare from '@madenowhere/phaze-cloudflare/vite'
export default defineConfig({ plugins: [cloudflare({ pages: 'src/pages', api: 'src/api', prefetch: true, router: true })],})Fewer packages, fewer config surfaces. One plugin instead of integration + adapter + extra Vite plugins.
vite build --app runs two Vite 7 environments in one process: client (→ dist/client/, hashed assets + a .vite/manifest.json) and ssr (→ dist/server/index.js, a single self-contained worker via inlineDynamicImports: true + noExternal: true). The SSR step reads the client manifest to bake the real hashed entry + CSS URLs into the worker. Optional prerender: [...] paths render to static HTML in closeBundle (production builds only; watch mode skips it since the worker SSRs live).
Type checking
Section titled “Type checking”Astro: astro check + Astro’s own diagnostic layer (frontmatter narrowing, slot validation, etc.).
phaze-cloudflare: phaze-check — a .phaze-aware tsc. Plain tsc --noEmit silently skips every .phaze file (the extension isn’t in TypeScript’s supportedTSExtensions), so CI would never see type errors in pages written as .phaze. phaze-check wraps the project’s real tsc with the same Volar LanguagePlugin the editor uses (phaze-language-tools), so .phaze files are checked exactly like the .tsx they compile to — every tsc flag passes through (--noEmit / --watch / --project). Same pattern as vue-tsc / svelte-check / astro check. See phaze-check.
Pair the tools for full CI coverage: phaze-check --noEmit catches type errors; pnpm build catches .phaze format errors (fence-shape / parse) via the Vite plugin’s code-frame; wrangler types regenerates worker-configuration.d.ts for binding types; in-editor, phaze-tsplugin suppresses the use:-directive false-positive TS6133.
Type parity for page / action / env types holds. Layout slots are typed Slot (() => JSXChild) and hand-written in the layout’s prop signature (panel?: Slot) — the same shape whether a caller (<Fragment slot>) or a URL (@name/) fills them; there’s no auto-inferred slot interface (the one spot Astro’s astro check validates that phaze leaves to the type).
Dev experience
Section titled “Dev experience”| Aspect | phaze-cloudflare |
|---|---|
| Dev modes | two — pnpm dev (watch-build + in-process Miniflare, full reload) and pnpm dev:hmr (Vite dev server + workerd module runner, live HMR). See Two dev modes below. |
| HMR | Tier-1 no-reload HMR in dev:hmr — the client entry is the accept boundary: App-subtree edits re-render the root, page edits swap the active page reactively. pnpm dev does a fast full reload per rebuild. |
| Error overlay | Vite’s overlay |
| Type-check | phaze-check --noEmit — a .phaze-aware tsc (plain tsc silently skips .phaze) |
wrangler types | run wrangler types; the plugin reads worker-configuration.d.ts |
| Dev toolbar | none, by design |
No dev toolbar — for a framework that exists to be small and predictable, not building one is the right call.
Two dev modes
Section titled “Two dev modes”phaze-cloudflare ships two dev loops, both single-process and both running the worker in real workerd with real bindings. They trade reload granularity against build-time fidelity:
{ "scripts": { "dev": "vite build --app --watch", "dev:hmr": "vite" }}pnpm dev | pnpm dev:hmr | |
|---|---|---|
| Entry | vite build --app --watch + in-process Miniflare | vite (Vite dev server) composing @cloudflare/vite-plugin |
| Worker runs in | workerd (Miniflare), script hot-swapped per rebuild | workerd (Miniflare) via Vite 7’s Environment-API module runner |
| Reload model | full reload per rebuild (~1–2 s); no HMR | true client HMR + Tier-1 no-reload phaze HMR |
| Output fidelity | build-grade: real CSS <link>s, tree-shaken WGSL, subset fonts, size report | dev-server: FOUC unless devStylesheets is set, non-subset fonts, build-only shaping skipped |
| Prerender | skipped in watch (worker SSRs live) | n/a (dev server SSRs live) |
Pick pnpm dev when you want output that mirrors production (CSS links, shaped assets) and don’t mind a full reload; pick pnpm dev:hmr when you want a tight no-reload edit loop and can tolerate dev-server-inherent rough edges. Both replace the old two-process vite build --watch + wrangler dev workflow.
Mode 1 — pnpm dev: single-process watch-build + in-process Miniflare
Section titled “Mode 1 — pnpm dev: single-process watch-build + in-process Miniflare”vite build --app --watch rebuilds the client + worker on every save; the plugin’s closeBundle then starts (or hot-swaps) an in-process Miniflare that serves every route through real workerd. One process — no concurrently, no wait-on, no separate wrangler dev subprocess. One log stream:
[vite] vite v7.3.2 building client environment for production...[vite] vite v7.3.2 building ssr environment for production...[vite] building Phaze client (vite)[vite] building edge worker (workerd[Cloudflare])[vite] prerendering static routes[vite] ✓ Completed in 78ms.
phaze dev http://127.0.0.1:8788How it works
Section titled “How it works”The plugin’s closeBundle hook fires after every Vite rebuild. When --watch is in play, it:
- Reads
wrangler.jsoncviawrangler.unstable_readConfig. - Translates the config to Miniflare options via
wrangler.unstable_getMiniflareWorkerOptions— handles all the binding-shape mapping (kv_namespaces→kvNamespaces,d1_databases→d1Databases, etc.). - On first invocation, constructs
new Miniflare({ workers: [{ ...workerOptions, scriptPath: 'dist/server/index.js', modules: true }] }). Awaitsmf.ready, printsphaze dev <url>. - On subsequent rebuilds, calls
mf.setOptions(opts)— workerd hot-swaps the script in-place (sub-100ms typical). No process restart.
Process exit signals (SIGINT/SIGTERM/beforeExit) trigger mf.dispose() to terminate workerd cleanly. No orphan subprocesses.
Why this matters
Section titled “Why this matters”Before this design, phaze-cloudflare ran two unaware processes — vite build --app --watch (continuously rebuilds the bundle) and wrangler dev (watches the bundle, restarts workerd on change). The patterns broke in predictable ways:
- Cold-start race — wrangler-dev would read
dist/server/index.jsmid-write during the very first build. We patched this with a.build-completesentinel +wait-onchain in the dev script; it worked but stacked complexity. - Manifest read race —
vite build --app --watch’s SSR env occasionally resolved__phaze/serverbefore the client env’scloseBundlehad writtendist/client/.vite/manifest.json. Result: a Vite dev-mode URL (/@id/__x00____phaze/client) baked into the produced worker bundle on the first build. We patched this with a 1s retry loop inreadClientManifest; it worked but exposed how fragile the dual-watch coupling was. - Orphan workerd subprocesses — when one side of the
concurrentlypair died, the other could leak workerd children parented to launchd (PID 1), pinning ports until reboot.
Miniflare-in-Vite eliminates the entire class. Single process, single watch loop, no filesystem handoff, no port races. The .build-complete sentinel is kept as a compatibility marker for external tooling that watches it (CI, IDE tasks), but consumer dev scripts no longer need it.
Mode 2 — pnpm dev:hmr: Vite dev server with live HMR
Section titled “Mode 2 — pnpm dev:hmr: Vite dev server with live HMR”pnpm dev:hmr runs a plain vite dev server. In serve mode the cloudflare() plugin composes @cloudflare/vite-plugin into its chain so the worker runs inside Miniflare via Vite 7’s Environment-API module runner — real workerd, real bindings, and live module updates. The client gets true HMR (import.meta.hot); the worker picks up source edits without a bundle rebuild.
On top of Vite’s client HMR, phaze-cloudflare adds Tier-1 no-reload HMR for the page tree. Because the whole page hydrates eagerly as one tree (no island sub-boundaries), the generated client entry is the HMR boundary: it statically imports src/app.tsx (if present) plus every page and accepts updates for each.
- App-subtree edits (App or any descendant — Header/Navigation) bubble to the App accept → the root re-renders with the fresh App.
- Page-subtree edits bubble to the pages accept → the active page swaps (reactively in Phaze App mode, re-render in direct-mount mode).
The virtual __phaze/server is overridden as the worker main (extension-less, so cf-plugin’s maybeResolveMain hands it to Vite’s resolver). phaze-vite’s per-.tsx replace() HMR — a per-component model where each island is its own mount boundary — is deliberately not in this chain; whole-page eager hydration has no per-component boundary for it to target, so the page-level accept boundaries above are used instead.
The tradeoffs are dev-server-inherent: a Vite dev server skips the build-time passes, so it shows FOUC (CSS is JS-injected by Vite — mitigated by the devStylesheets option below), non-subset fonts, and any build-only asset shaping (e.g. WGSL). A Tier-1 re-mount also resets component-local signal state and re-initialises imperative / 3rd-party widgets. When you need build-grade output fidelity, use pnpm dev.
devStylesheets — kill dev-server FOUC
Section titled “devStylesheets — kill dev-server FOUC”A Vite dev server serves CSS as JS-injected <style> tags, applied only after the client module loads — so the SSR’d HTML paints unstyled until then. The devStylesheets plugin option emits the listed root-relative CSS URLs as blocking <link rel="stylesheet"> in the dev SSR <head>, the same way a production build links the hashed manifest CSS:
cloudflare({ devStylesheets: ['/src/styles/global.css'],})Dev-only — it has no effect on builds, where the real styles come from the client manifest. Vite serves these paths as compiled CSS (Tailwind etc. processed) for a direct stylesheet request.
Real workerd in dev — including cloudflare:sockets
Section titled “Real workerd in dev — including cloudflare:sockets”Both dev modes embed workerd, so code that imports workerd-only modules (cloudflare:sockets, cloudflare:workers, cloudflare:email) works in dev exactly as in production. The OTP flow that uses worker-mailer (which imports cloudflare:sockets for raw SMTP) sends real email — no shim, no mock, no Node fallback.
Bindings (env.KV, env.DB, env.R2, secrets from .dev.vars) are real Miniflare-emulated bindings, the same emulators wrangler dev would have used. pnpm dev’s Miniflare shares wrangler dev’s .wrangler/state/v3 persistence dir, so applied D1 migrations and wrangler kv writes are visible in dev.
pnpm dev vs pnpm dev:hmr
Section titled “pnpm dev vs pnpm dev:hmr”pnpm dev:hmr composes @cloudflare/vite-plugin’s in-workerd ModuleRunner — it fetches transformed modules over a WebSocket from the Vite dev server, giving per-module HMR — then layers the page-level accept boundaries above for Tier-1 phaze HMR. pnpm dev is the lighter alternative: it rebuilds the worker bundle on change and uses mf.setOptions to hot-swap (typically 200–1500 ms), trading HMR granularity for build-grade output and one fewer moving part.
Opting out
Section titled “Opting out”If you need to run a separate wrangler dev for any reason (e.g., reproducing a production-specific edge bug, attaching wrangler’s inspector), set PHAZE_CF_NO_DEV_SERVER=1 to disable pnpm dev’s in-process Miniflare path; the plugin falls back to the old vite build --app --watch + external wrangler dev workflow.