Phaze + Cloudflare
@madenowhere/phaze-cloudflare is a direct Phaze + Cloudflare Workers deployment — no Astro layer. One Vite plugin handles route discovery, virtual modules, env type-gen, server-entry codegen, and ambient .d.ts emission. The runtime ships half the worker bytes and ~12 % less client JS than the equivalent Astro Cloudflare setup, while keeping the surface — actions, middleware, cookies, endpoints, typed env, streaming SSR, prefetch, view transitions — at near-full parity.
This page walks the surface area side by side with Astro 6.2 + @astrojs/cloudflare 13.3. Each section calls out where the two are at parity, where phaze-cloudflare wins, and where it doesn’t — so the trade-offs are visible up front.
1. File-system routing
Section titled “1. File-system routing”| Astro | phaze-cloudflare | |
|---|---|---|
| Routes scanned from | src/pages/** | src/pages/** |
| Page extension | .astro / .tsx / .md / .mdx | .tsx / .jsx only |
| Endpoint extension | .ts / .js (same files, endpoint when no default export) | .ts / .js — discriminated by extension at discovery time |
| Dynamic segments | [id], [...rest] | [id], [...rest] (same syntax) |
| Specificity sort | yes | yes |
Page + endpoint collision (users/[id].tsx + api/users/[id].ts) | n/a (.astro vs .ts differ) | hash-suffixed module IDs prevent collision |
Advantage: extension-discriminated routing keeps the page/endpoint distinction unambiguous at build time (no need to inspect exports), and the dispatcher branches on a precomputed kind field rather than re-checking module shape per request.
2. Component model
Section titled “2. Component model”| Astro | phaze-cloudflare | |
|---|---|---|
| Top-level page | .astro template with fenced frontmatter | TSX function component, default export |
| Reactive islands | <Component client:load/> / client:idle / client:visible / client:only="phaze" | Whole page is one reactive tree; no island gating |
| Shared layout | Layout .astro files + <slot/> + named <slot name="X"/> | Component imported + children + <Fragment slot="X">…</Fragment> extracted by phaze-compile |
| Slot lazy contract | eagerly serialised into the parent’s HTML | phaze-compile wraps named-slot children in () => … arrows — layout decides when to evaluate; lets layouts ship without computed |
| Multi-child JSX wrap | n/a | emits arrayExpression instead of JSXFragment when wrapping reactive multi-child — keeps the Fragment symbol out of phaze |
<For> runtime | n/a (lists use .map() in islands) | default <For for:item={items}> compile-strips to {() => items().map(...)} — zero shipped For bytes. phaze flag opts into the runtime For (~900 B) when row identity has to survive reorders. See <For> reference. |
Advantage: the island / page split disappears. Astro emits a small per-island hydration shim (~96 B + ~66 B brotli per client:only directive); phaze-cloudflare’s whole page hydrates from one entry. The trade-off is no static-only zones inside a page — the entire page is reactive. For form-heavy and CRUD-style apps this is the correct trade.
3. Server-side request lifecycle
Section titled “3. Server-side request lifecycle” Astro (Cloudflare adapter) phaze-cloudflare ─────────────────────────── ─────────────────request → Astro app entry generated worker entry ↓ ↓ middleware (src/middleware.ts) middleware.onRequest (same shape) ↓ ↓ Astro router (regex table) matchRoute (regex table) ↓ ↓ page renderer (Astro components) endpoint OR phaze SSR (renderToString + streaming) ↓ ↓ session + image + island manifest — nothing else — ↓ ↓response ← bundled astro/runtime/server.js handleRequest returns Response (stream-first)The 50 % worker delta is mostly: (a) no devalue (action wire format), (b) no session driver, (c) no image service, (d) no per-island manifest.
4. Server actions
Section titled “4. Server actions”Astro (astro:actions) | phaze-cloudflare (phaze:actions) | |
|---|---|---|
| Authoring | src/actions/index.ts + defineAction | src/actions.ts + defineAction (identical surface) |
| Client surface | import { actions } from 'astro:actions' | import { actions } from 'phaze:actions' |
| Wire format | devalue (handles Date, Map, Temporal) — ~3 KB brotli of client runtime | plain JSON — 0 B; actions.X(input) per-call-site compile-inlines a fetch arrow |
defineAction runtime cost | wrapper function is callable runtime code | compile-stripped to the bare object literal |
ActionError runtime cost | class instance + serialiser | compile-stripped to plain throw { type: 'PhazeActionError', code, ... } — dispatcher matches by err.type |
| Per-action middleware | n/a (use the global request middleware) | use: [...] array composing sequentially before the handler |
| Cancellation | not built in | useAction(…).abort() + dedupe: 'cancel-prior' | 'parallel' via AbortController plumbing |
useAction field DCE | n/a — fixed shape | planned — phaze-compile walks references and emits only {pending, execute} (or whichever fields) per call-site |
Why this matters for bundle size: the action surface is types-only at runtime. defineAction is a function call pre-compile, vanishes post-compile. ActionError is a class pre-compile, vanishes post-compile. Removing devalue is the single biggest line item on the client-bundle delta vs Astro’s actions. For form / RPC workloads, JSON is sufficient — Date survives as ISO string, Map is rare in HTTP payloads, Temporal is opt-in if needed.
5. Endpoints (src/pages/api/*.ts)
Section titled “5. Endpoints (src/pages/api/*.ts)”Both expose export const GET = (ctx) => Response. Phaze-cloudflare’s context is slimmer:
interface EndpointContext<Bindings> { params: Record<string, string> env: Bindings request: Request ctx: ExecutionContext cookies: Cookies}vs Astro’s APIContext, which carries locals / currentLocale / redirect / rewrite / session / params / cookies / request. Parity in capability; phaze’s smaller context means no locals-as-magic-bag — env and cookies are top-level fields. For redirect/rewrite, return a plain new Response(null, { status: 302, headers: { location: '/x' } }).
6. Middleware (src/middleware.ts)
Section titled “6. Middleware (src/middleware.ts)”| Astro | phaze-cloudflare | |
|---|---|---|
| Export shape | onRequest(ctx, next) | onRequest(ctx, next) (identical) |
| Discovery | Astro integration scans src/middleware.ts | plugin scans src/middleware.ts |
| Absent middleware cost | Astro inserts a noop middleware | generated entry passes middleware: null; handleRequest short-circuits — zero overhead per request |
| Shared cookies | yes | same Cookies instance flows through middleware, action handler, page loader, endpoint — pre- AND post-next() writes both reach the response |
| URL parsing | re-parsed per access | parsed once on the per-request MiddlewareContext.url |
import type { MiddlewareHandler } from '@madenowhere/phaze-cloudflare'
export const onRequest: MiddlewareHandler<Env> = async (ctx, next) => { if (!ctx.cookies.get('session')) return Response.redirect('/login', 302) const response = await next() response.headers.set('x-served-by', 'phaze') return response}7. Cookies
Section titled “7. Cookies”Identical surface to Astro — .get(name) / .set(name, value, opts) / .delete(name) — with three behavioural improvements:
- Lazy parsing —
.get()only parses the inboundCookieheader on first read. Requests that don’t touch cookies pay zero parse cost. Astro parses eagerly. Partitioned(CHIPS) supported inCookieSetOptions.- RFC 6265 first-instance-wins on duplicate names.
The Cookies instance is shared across middleware → action handler → page loader → endpoint, so cookies set anywhere in the request chain merge into the final response in one pass.
8. Env (typed env vars)
Section titled “8. Env (typed env vars)”Astro (astro:env) | phaze-cloudflare (phaze:env) | |
|---|---|---|
| Schema location | env: { schema: { … } } in astro.config | src/env.ts with defineEnv({ server, public }) — a real source file |
| Validators | envField.string({ context, access }) — Astro’s DSL | anything .parse(raw)-shaped — zod, valibot, ad-hoc validators all fit |
| Public env client cost | bundled astro:env/client virtual + value lookup | inlined as import.meta.env.PUBLIC_X constants — client bundle ships zero validator runtime |
| Server validation timing | eager at request | lazy on first property access via Proxy — cold-start cost is zero until the handler actually reads env.X |
| Type extraction | Astro-generated .astro/types.d.ts | plugin writes .phaze/types.d.ts |
import { defineEnv } from '@madenowhere/phaze-cloudflare/env'import { z } from 'zod'
export default defineEnv({ server: { DATABASE_URL: z.string().url(), API_SECRET: z.string().min(32), }, public: { PUBLIC_SITE_URL: z.string().url(), PUBLIC_GA_ID: z.string().optional(), },})
// src/pages/index.tsximport { env } from 'phaze:env/server' // worker-onlyimport { env as publicEnv } from 'phaze:env/client' // safe everywhere; inlined at build9. SSR & streaming
Section titled “9. SSR & streaming”| Astro | phaze-cloudflare | |
|---|---|---|
| SSR engine | Astro renderer + island hydrators per client:* | @madenowhere/phaze-render-to-string (renderToString + linkedom) |
| Streaming | yes — default in Astro 6.x | yes — three-stage ReadableStream: openShell → SSR body → closeShell + payload |
| TTFB shape | head ships once head() resolves | head() and loader() run in parallel; first chunk lands as soon as head() resolves (loader continues in background) |
| Error mid-stream | Astro catches | catches + emits visible <pre> marker and closes tags so the page doesn’t hang |
| Modulepreload | yes | yes — <link rel="modulepreload"> in <head> + <script type="module"> at end of body |
| Hydration payload | per-island | single window.__PHAZE_CF__ JSON, < neutralised, </style> escaped |
10. Client-side router + view transitions
Section titled “10. Client-side router + view transitions”| Astro | phaze-cloudflare | |
|---|---|---|
| Opt-in | <ClientRouter /> component in layout | plugin option router: true |
| Click intercept | yes | yes |
| HTML fetch | yes | yes, with phaze-router: 1 header so the server can detect/optimise |
| View Transitions | document.startViewTransition | same — falls through to plain swap on browsers without support |
| Re-hydration | per-island | single re-hydrate via startClient() reuse |
| Bundle cost | ~925 B brotli page.*.js (always) + ClientRouter runtime | ~400-500 B brotli, opt-in — folds into entry only when router: true |
transition:persist / transition:name | yes | not yet — view-transition element-level persistence is a known gap |
11. Prefetch
Section titled “11. Prefetch”| Astro | phaze-cloudflare | |
|---|---|---|
| Strategies | tap / hover / viewport / load | viewport only |
| Opt-out per link | data-astro-prefetch="false" | data-no-prefetch |
| Dynamic DOM | MutationObserver | same |
fetch priority | low hint | low hint |
| Bundle cost | bundled into page.*.js always | ~300 B brotli, opt-in |
The hover/tap strategies are a known gap — useful for nav menus where viewport-trigger is too eager. Cheap to add when needed.
12. Head metadata
Section titled “12. Head metadata”Astro: head is part of the .astro template; per-route SEO via prop-driven layout component.
phaze-cloudflare: export const head: HeadFn = (ctx) => ({ title, description, raw }).
export const head: HeadFn<Env> = ({ params }) => ({ title: `User ${params.id} — Acme`, description: 'Profile page', raw: '<link rel="canonical" href="https://acme.com/users/42">',})Advantage: head() runs in parallel with loader() and the first chunk goes out as soon as head resolves — Astro waits for the page render to begin.
13. Build configuration & adapter layering
Section titled “13. 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', 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).
14. Where the surface isn’t (yet) at parity
Section titled “14. Where the surface isn’t (yet) at parity”| Feature | Astro | phaze-cloudflare |
|---|---|---|
Named slots (<Fragment slot="X">) | ✓ | ✓ — landed, with lazy () => wrap |
| Static prerender (per-route) | export const prerender = true | ✓ — landed via cloudflare({ prerender: ['/', '/about'] }). Renders at build time, writes to dist/client/<path>/index.html, adds each path to _routes.json exclude so the Worker never sees it. 0 bytes added to client or worker bundles. |
| Image / Image service | ✓ — sharp at build time, variant files in dist/ | ✓ — <Image> from /image subpath. Emits a CF Image Transformations srcset (/cdn-cgi/image/width=W,format=auto/...) at the edge. Zero build-time image deps; format negotiation happens per-request from the Accept header. ~500 B brotli per consuming chunk. Different trade-off (CF-locked vs portable); see below for details. |
| Content Collections | ✓ — built-in getCollection / getEntry + glob loader | ✓ — phaze:content with the same shape. defineCollection({ pattern, schema }) in src/content.config.ts; getCollection / getEntry from phaze:content. Tiny YAML subset parser (~50 LOC, inline). No markdown renderer in the box — pipe body through your library of choice. Server-side only; client bundle ships a stub. Pairs naturally with prerender: for zero-runtime-cost blog pages. |
| MDX | @astrojs/mdx | not yet |
| i18n | i18n: { defaultLocale, locales } | not yet |
| RSS | @astrojs/rss | buildable via endpoint (src/pages/rss.xml.ts returning a Response) |
| Sitemap | @astrojs/sitemap | same — endpoint route |
The remaining user-facing gaps are MDX (Astro pipes @astrojs/mdx; phaze-cloudflare has .md content collections but no JSX-in-markdown renderer) and i18n (Astro’s i18n.defaultLocale config + routing helpers — no equivalent yet). RSS and sitemap are buildable as plain endpoint routes (src/pages/rss.xml.ts returning a Response); hover/tap prefetch strategies are a known small gap useful for nav menus.
15. Type checking
Section titled “15. Type checking”Astro: astro check + Astro’s own diagnostic layer (frontmatter narrowing, slot validation, etc.).
phaze-cloudflare: standard tsc --noEmit against the ambient .phaze/types.d.ts the plugin emits.
Parity for page / action / env types. Known gap for layout slot signature inference — slots are typed as () => JSXChild by convention; users hand-write the slots interface in their layout (see the named-slots Layout example).
16. Dev experience
Section titled “16. 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 | tsc --noEmit against the plugin-emitted .phaze/types.d.ts |
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.
17. phaze-cloudflare (native) vs Phaze + Astro (Cloudflare) — render + hydrate performance
Section titled “17. phaze-cloudflare (native) vs Phaze + Astro (Cloudflare) — render + hydrate performance”The feature-by-feature comparison above shows surface parity with bundle-size wins. This section is about a structurally different axis: rendering and hydration speed, where the wins come from architecture, not byte trimming.
The framing point — and the user-visible payoff — is that phaze-cloudflare has no server-island machinery. Astro’s island model is what makes “ship mostly-static HTML, hydrate small interactive components” possible; the cost is that every island gets a wrapper custom element, a serialized props envelope, a renderer-URL attribute, a connectedCallback that dyn-imports the renderer, and a slot-stitching protocol. phaze-cloudflare’s whole page is one component tree, hydrated by one entry. The island tax is structural — Astro pays it on every page, including pages with only one island.
The measured baseline
Section titled “The measured baseline”Same TodoList page (KV-backed, two items in the list, live request), captured from each adapter’s production build:
| astro-cloudflare | phaze-cloudflare | Ratio | |
|---|---|---|---|
| SSR’d HTML size | 15,973 B | 1,294 B | 12× smaller |
| Inline JS in HTML | 3,923 chars (custom-element class + props serializer) | 53 chars (window.__PHAZE_CF__={…}) | 74× less |
<astro-island> markers + per-island attrs | 1 wrapper, ~8 attributes, ~200 B of serialized props | 0 | n/a |
<link rel="modulepreload"> | 0 (Astro relies on per-island connectedCallback to dyn-import) | 1 (browser fetches client.js in parallel with HTML parse) | — |
| Client work before first paint | parse 3.9 KB of inline JS to define the <astro-island> custom element | none — client.js modulepreload runs while DOM parses | — |
| Client work before hydration | walk DOM for <astro-island> instances, dyn-import each renderer module, dyn-import each component module, call hydrator | one hydrate(rootComponent, root) call | — |
The HTML payload size and the parse-before-paint inline JS are the headline numbers — they hit every page request, not just the one-off cold start. On a Cloudflare worker serving from edge, the 14 KB delta is roughly 10 ms of transfer time saved per request on a typical 4G connection, and 30+ ms of parse time saved on mid-tier hardware.
Why the gap exists
Section titled “Why the gap exists”Astro’s island machinery is designed for the case where a mostly-static .astro template hosts a few interactive components. Every island gets:
- A
<astro-island>custom element wrapping its SSR’d HTML - An inline custom-element class definition (one per page, ~3.8 KB inlined script)
component-url,component-export,renderer-url,props,client,opts,await-childrenattributes per instance- A devalue-serialized
propspayload (handlesDate/Map/Temporal— pays for it whether the page uses those types or not) - A connectedCallback flow: read attrs → fetch the renderer module → fetch the component module → call the hydrator → stitch slot DOM back in
phaze-cloudflare’s page IS the component. SSR emits the HTML and a single __PHAZE_CF__={…} payload. The client entry is the renderer; client.js loads via modulepreload, picks the page from the route table, calls hydrate() on the root. The page-vs-island duality dissolves.
This is the right trade for form-heavy, CRUD-style, app-like sites — exactly the workload Cloudflare Workers excels at. It’s the wrong trade for content sites that genuinely want most of the page to ship as inert HTML with one widget; that case is what improvement #1 below recovers.
The architecture — five structural wins
Section titled “The architecture — five structural wins”The five changes below are sequenced by impact. They compose — none requires the others — and together they widen the gap from the “12× smaller HTML, 74× less inline JS” measured at the baseline to a comprehensive perf story across the four workload shapes (content-heavy, data-heavy, multi-page SPA, ISR). Each is covered below with its mechanism, measured deltas, and computed() callouts.
1. Static-subtree hoisting — Astro’s “static is free” advantage, taken back
Section titled “1. Static-subtree hoisting — Astro’s “static is free” advantage, taken back”Without it, the whole page hydrates: a page that’s 90 % static layout + a small <TodoList/> still walks the hydration cursor through every node. The DOM doesn’t change, but the JSX construction cost is paid for every static <header>, <nav>, marketing <section>.
Opt in via cloudflare({ staticSubtreeHoist: true }). phaze-compile detects JSX subtrees that contain none of:
- signal reads
on:/phaze:reactive attrsclass:/bind:/use:directivesref={…}/key={…}/ camelCase event props (onClick)- dynamic children (
{expr}with anything other than a literal) - component JSX (capitalised tags)
Each eligible subtree is serialised to an HTML string at build time and replaced with staticSubtree(html) from @madenowhere/phaze/static. The compile pass auto-injects the import. Subtrees without a nested element child are left as JSX (the per-call-site overhead would exceed the saving).
At runtime: staticSubtree(html) returns a handle the JSX runtime recognises in child position. The handle’s resolve(parent) is called at append time (after the parent’s hydration frame is on the stack — which leaf-first JSX evaluation can’t guarantee at construction time). Two branches:
- Hydration with cursor positioned inside
parent—skipNext()advances the cursor past the next SSR’d child and returns it. Identity preserved; no JSX construction; no per-element listener attach.computed()is not used here —staticSubtreeis by definition non-reactive, so no memoization is needed; the value is a string baked at build time. - Otherwise (fresh render, or cursor misaligned because leaf-first eval placed us under a not-yet-adopted ancestor) — parse the HTML string into a
<template>once and return its first child. The SSR’d counterpart, if any, gets pruned by the outer adoption’sexit(). The<template>.innerHTMLparse runs once perstaticSubtree(html)invocation per page — a single browser-native HTML parse vs N individualjsx()constructions.
The fresh-Node sibling case ([fresh, fresh, handle]) needed one new cursor primitive — advanceCursor() — so the static handle’s skipNext() consumes the correct SSR’d slot. Fresh siblings appendChild and bump the cursor without claiming adoption; the SSR’d counterparts get pruned by exit().
Measured (TodoList example, with hoist on):
phaze: 2,681 → 2,825 B brotli (+144 B forskipNext/cursorIs/advanceCursor/staticSubtree)- Entry chunk: 4,315 → 4,395 B brotli (+80 B for inlined HTML strings +
staticSubtreeresolve sites) - Net: +224 B brotli total when enabled. 0 B when disabled — every new primitive tree-shakes.
Where the perf win shows up: content-heavy pages with mostly-static layout (marketing pages, blog index, about pages). The TodoList demo gains little because most of the page is interactive. Hydration walltime drops because the static subtrees:
- skip N
jsx()construction calls per subtree (no object allocation, noapplyPropiteration) - skip N
peekChild+adopt+exitcursor walks - skip N listener attachments — for the about page’s 4-paragraph + nested-
<code>blocks, that’s ~20jsx()calls compressed into one browser-native HTML parse
Limitations of v1:
- A few hydration primitives (
skipNext,cursorIs,advanceCursor) live in core’shydrate.tsfor now rather than the@madenowhere/phaze/staticsubpath — needed direct cursor access that current subpath exports don’t compose into. Marked temporary; the surface will migrate to the subpath once the shape is stable. - The compile pass is opt-in (default
false). Once we have wider coverage of edge cases (custom data attrs, SVG namespacing,style={{…}}objects), the default can flip.
2. Suspense-style SSR via signal.async — server islands without server-island machinery
Section titled “2. Suspense-style SSR via signal.async — server islands without server-island machinery”Without it, the SSR renderer is synchronous: a component that reads signal.async(loader) sees pending=true throughout SSR, the loader’s Promise doesn’t resolve before the response is sent, and the loader fires AGAIN on the client at hydrate-time — doubling the request and showing pending UI until the second fetch returns.
renderToStringAsync(component, options) — a drop-in async variant of renderToString that awaits every in-flight signal.async loader before serialising. Internals:
- SSR-only capture in core’s
signal.ts. When__beginSSRAsyncCapture()is open and the SSR bundle is in scope (import.meta.env.SSR), eachsignal.async(loader)invocation pushes its loader’s Promise into a module-scoped capture list. Client bundles strip the capture path entirely — esbuild constant-foldsimport.meta.env.SSRtofalseand tree-shakes the dead branches. Zero shipped client bytes. - Drain loop in
renderToStringAsync. After the synchronous render returns, drain the captured promises withPromise.allSettled+ a microtask flush; the value/error signals update; the existing reactive bindings update the DOM in place. Loop until the capture list is stable (handles cascading async — a loader that triggers anothersignal.async). renderToStringAsyncis now the default inphaze-cloudflare’s server pipeline. The synchronousrenderToStringstays exported as an escape hatch. Pages that don’t usesignal.asyncsee no behaviour change — the capture list is empty, the await settles instantly.
Where computed() improves perf here: when a component’s render reads multiple derived values off the same async signal (s.value()?.user.name + s.value()?.user.role + …), wrap the derivation in a single computed() and read THAT from JSX. The drain loop re-runs the reactive bindings after promises settle; with computed(), the derivation runs once per signal change and the cached result fans out to all readers. Without computed(), each binding re-derives independently. The doc’s signal.async patterns now consistently use computed() for multi-binding derivations — see Reactive Data / API Fetch for the canonical shape.
Measured (TodoList example, with renderToStringAsync wired into the server):
phaze: unchanged at 2,825 B brotli (with static-hoist) — the SSR capture is fully gated onimport.meta.env.SSRand dead-codes on the client.- Worker bundle: 99,743 → 100,295 B brotli (+552 B for the awaitAsync drain loop in
phaze-render-to-string). - Per-request behaviour for pages with
signal.async: response time ismax(head, loader, async)instead ofloader+ a second client-side fetch.
Not yet — true HTTP-chunk streaming with placeholder swap-in. The current implementation blocks the response until all async promises settle (max(head, loader, …async) wall-time). For pages where one slow async would otherwise hold FCP, a future iteration emits a <template id="phaze-async-N"> placeholder, ships the static parts immediately, then streams swap-in <script> chunks as each async resolves — the signal.ts capture list is already keyed for it; the SSR-side chunk protocol + client-side swap listener are the missing piece.
3. Signal-based router — eliminate re-hydration on SPA nav (opt-in via src/app.tsx)
Section titled “3. Signal-based router — eliminate re-hydration on SPA nav (opt-in via src/app.tsx)”Without it, the router’s swap() does root.innerHTML = newRoot.innerHTML; startClient(routes): the entire scope tears down, every binding re-attaches, every effect re-fires. Layout chrome (nav, footer, ambient theme) re-mounts on every nav even when identical between routes.
An opt-in Phaze App via src/app.tsx. When present, the generated client entry passes the App default export to startClient(routes, App); the SSR pipeline and the prerender pass also wrap the page render in App. The canonical signature accepts a children prop and falls back to a currentRoute()-driven arrow:
import { currentRoute } from '@madenowhere/phaze-cloudflare'
export default function App({ children }: { children?: () => unknown } = {}) { return ( <> {/* persistent layout chrome can go here — header, nav, ambient theme provider, in-app modals — anything that should survive across SPA navigations stays OUTSIDE the dynamic-child arrow */} {children ?? (() => { const r = currentRoute() if (!r) return null const Page = r.module.default return <Page data={r.data}/> })} </> )}The children prop is the SSR/prerender path. Server-side, phaze-cloudflare invokes the App as App({ children: () => Page({ data }) }) — same shape from runtime SSR (server.ts) and the build-time prerender pass (prerender-render.ts). The LHS of children ?? … wins; the page renders into the SSR’d / prerendered HTML directly. An App component that ignores children will produce an empty __phaze_root__ body in SSR + prerender output (the currentRoute signal is client-only — it returns null in the worker / at build time). The example at examples/phaze-cloudflare/src/app.tsx demonstrates the canonical fallback shape.
The currentRoute() arrow is the client path. On the client mount, no children is passed; the RHS arrow becomes the dynamic child. currentRoute() is a signal that the router updates on every SPA nav. When it changes, ONLY the arrow’s body re-runs — everything OUTSIDE the arrow (layout chrome, signals declared at this scope, effects on outer elements) stays mounted.
computed() improves perf here in two ways the canonical pattern uses:
- For derived values off the route —
c(() => currentRoute()?.params.id),c(() => currentRoute()?.data as Profile)— multiple readers across the layout chrome share a single memoised lookup. Withoutcomputed()each binding re-runs the chain on every route change. Astro’s router has no equivalent — its router rebuilds the tree from scratch, so there’s no opportunity for cached derivations to survive. - The route signal itself is a
signal<RouteState>(not acomputed) — it’s set externally by the router, so memoisation doesn’t apply; subscribers fan out from the bare signal.
Mechanism:
- New API surface —
currentRoute()exported from@madenowhere/phaze-cloudflare. Returns null when no Phaze App is mounted (direct-mount mode). - New plugin discovery —
src/app.tsxis probed at the project root; absent file means no Phaze App wrapper is generated, andstartClient(routes)is called as before. - Router’s
swap()branches on__hasAppShell(): Phaze App mode →__setCurrentRoute({ pathname, params, data, module })and let the reactive subtree handle it; direct mode → the legacy innerHTML-swap + re-hydrate. - The Phaze App mounts ONCE at page load via the existing hydrate path. The dynamic-child arrow’s M3 hydrate logic adopts the SSR’d page subtree on first run; subsequent route changes go through the non-hydrating effect path (fresh page subtree mounted in place; old subtree’s effects dispose via orphan-scope teardown).
Measured (TodoList example with Phaze App wired in):
phaze: 2,825 → 2,828 B brotli (+3 B for thesignalimport in client.ts; the rest is folded intophazealready)- Client chunk: 4,395 → 4,617 B brotli (+222 B for the Phaze App wiring, route signal infrastructure, and
app.tsxJSX compile output) - Net: +225 B brotli total when the Phaze App is in play. Apps without
src/app.tsxsee no change — the route-signal infrastructure tree-shakes via__hasAppShell()’s null check.
Performance win vs the previous re-hydrate path — for a hypothetical 10-route site with shared <Layout> chrome:
- Re-hydrate model: every nav tears down the entire JSX tree + N event listeners + Layout’s effects, then rebuilds.
- Phaze App model: only the page subtree rebuilds; Layout’s effects (theme toggle, scroll listener, MutationObserver, etc.) stay attached.
The persistence is most visible for layouts that own browser-native state — a focused search input in the header survives nav; an in-flight CSS transition on a sidebar continues; <video> in a sticky player keeps playing.
4. Per-route dynamic imports — smaller initial JS for multi-page apps
Section titled “4. Per-route dynamic imports — smaller initial JS for multi-page apps”Without it, the generated client entry statically imports every page module: a 10-page site downloads JS for all 10 pages on every cold visit, even if the visitor only views /.
The plugin emits load: () => import('./Page.js') thunks in the route table instead of module: Page static references. Rollup code-splits each page into its own chunk. The initial bundle ships:
phaze(core runtime + static-hoist helpers)client(the route table +startClient+ optionalstartPrefetch/startRouter)app(the Phaze App, ifsrc/app.tsxexists)- The chunk for the initial page being viewed (Vite emits
<link rel="modulepreload">for it from the SSR HTML head, so the fetch starts in parallel with HTML parse)
Pages the visitor doesn’t navigate to are never fetched.
startClient is now async — it awaits the initial page’s load() thunk before hydrating. The SSR’d HTML stays visible during the wait (no FOUC); when the chunk resolves, hydrate adopts the same DOM. Module-resolution cache (a WeakMap<ClientRoute, PageModule> keyed by route) means re-visits don’t re-fetch.
The router (startRouter) calls __resolveRouteModule(route) on every nav — same cache, same lazy load. When prefetch: true is also enabled, the viewport-fetch already warms the HTML response and the page chunks (via <link rel="modulepreload"> in the prefetched HTML’s head), so by click-time the chunk is in browser cache and the import resolves synchronously.
computed() doesn’t appear in this layer — the route signal updates are coarse (whole-page-swap on nav), so a derived computed() would just gate on the same change. Where computed() shows up downstream is in user code reading currentRoute() for derived per-route state (see #3).
Measured (TodoList example with all four flags on):
| Chunk (brotli) | Before #4 | After #4 |
|---|---|---|
phaze | 2,828 B | 2,833 B (+5 B for dyn-import-cache) |
client (entry + every page inlined) | 4,617 B | — |
client (entry only — pages code-split) | — | 1,986 B |
app chunk | (folded into client) | 79 B |
index page chunk (initial-page, code-split) | (folded into client) | 1,880 B |
about page chunk (lazy) | (folded into client) | 1,446 B |
blog page chunk (lazy) | (folded into client) | 652 B |
First-paint total for / | 7,445 B | 6,778 B (-9 %) |
Fetched on visit to /about only | 0 (already loaded) | 1,446 B |
Fetched on visit to /blog only | 0 (already loaded) | 652 B |
For a 20-page real site the saving compounds — only the visited page’s chunk + the entry + the shared runtime gets fetched. The marginal-page cost is the per-route chunk; pages never visited cost zero.
Comparison vs Astro:
- Astro’s per-island model already does some of this — each
<X client:load/>lazy-loads its renderer chunk + component chunk viaastro-island.connectedCallback. The cost is the per-island machinery (~3.8 KB of inline JS to define the custom element). - phaze-cloudflare gets the same code-split granularity for the page-level boundary without the per-island wrapper. The route table holds the thunks; the Phaze App coordinates the swap.
5. ISR via signal.async + withRevalidate — static + fresh data, same primitive
Section titled “5. ISR via signal.async + withRevalidate — static + fresh data, same primitive”import { signal } from '@madenowhere/phaze'import { withRevalidate } from '@madenowhere/phaze/revalidate'
const users = signal.async(() => fetch('/api/users').then(r => r.json()))withRevalidate(users, 60) // refresh every 60 s on the clientMechanism:
- An effect subscribes to
users.pending(). Each time pending falls to false (initial settle OR a revalidation completion), the effect arms asetTimeout(reload, seconds * 1000). - Re-arming chains on settle (not a recurring
setInterval), so a slow loader can’t compound overlapping reloads. - SSR no-op:
typeof window === 'undefined'short-circuits in the worker — no timer arms during the request lifetime. - Pairs with
prerender:natively. The build-time render captures the initial value viasignal.async(with the awaitAsync drain from #2); the client revalidates on the configured interval. Same primitive Astro spellsrevalidateingetStaticPaths, expressed via the signal model.
computed() doesn’t appear in the revalidate machinery itself (the timer/reload chain is structural, not derivational). It DOES show up in user code reading the revalidated value — c(() => users.value()?.length) etc. — for the same reason as #2: shared memoised derivations fan out cheaper than independent re-derives.
Kept on a subpath, not in core’s signal.async. Putting withRevalidate inline in signal.ts would have added ~86 B brotli to phaze even for apps that don’t use ISR. The subpath approach keeps the base signal.async at its current size:
phaze(nowithRevalidateconsumer): unchanged at 2,833 B brotli.- Apps that
import { withRevalidate } from '@madenowhere/phaze/revalidate': chunk grows by ~80 B brotli per consumer chunk that uses it (chunkable separately if you prefer withchunkSubpaths: true). - Worker side (
renderToStringAsyncalready captures the loader’s initial Promise, andtypeof window === 'undefined'skips the timer): no extra cost beyond #2’s already-shipped drain loop.
Summary — where each workload wins
Section titled “Summary — where each workload wins”| Workload | Win | Surface |
|---|---|---|
| Content site (mostly static, one widget) | #1 — static subtrees skip JSX construction at hydrate; the layout pays cursor-adopt cost only. | cloudflare({ staticSubtreeHoist: true }) |
| Data-heavy app (slow loaders) | #2 — signal.async loaders await in SSR; the HTML ships with resolved data, no double-fetch on hydrate. | Automatic in phaze-cloudflare’s SSR pipeline. Use signal.async(fetch) in components. |
| Multi-page SPA | #3 + #4 — layout chrome persists across nav (Phaze App + currentRoute signal); only the visited page’s chunk fetches. | Create src/app.tsx + add prefetch: true, router: true to plugin config. |
| ISR / partial-static | #5 — periodic background reload of signal.async-loaded data. | import { withRevalidate } from '@madenowhere/phaze/revalidate' |
The computed() win pattern across the five — what shows up in canonical code:
- #2/#3/#5: derived values off a signal that fans out to multiple readers should be
computed(() => …)(orc(() => …)via the DSL). Astro’s no-signals architecture has no equivalent — every read re-derives. - #1/#4: structural — no derivational reactivity in the new code paths;
computed()doesn’t appear in these layers.
The throughline: every improvement falls out of phaze’s signal model. There’s no new mental surface for the user — signal.async is already the suspense boundary; effect is already the hydrate hook; currentRoute is just another signal. Astro’s equivalents (server:defer, <ClientRouter/>, ISR via getStaticPaths + revalidate) are separate APIs with separate render contexts. phaze-cloudflare composes what’s already there.
Phaze-vendor stays sub-3 KB. The biggest core touch is #1’s cursorIs / skipNext / advanceCursor cursor primitives (+144 B brotli, tracked in hydrate.ts for migration to a subpath once stable). Everything else (/static, /revalidate, /ssr-internal) is opt-in subpath; apps that don’t use those features see no change. The phaze budget contract holds.
Summary
Section titled “Summary”Ahead of Astro:
- Action surface is 0-byte at runtime (no
devalue,defineAction/ActionErrorcompile-stripped,actions.X(input)inlined as a fetch arrow). - Per-action middleware + built-in AbortController in
useAction. - Lazy cookie parsing + lazy env validation — zero cold-start cost when unread.
- Public env client cost is literally zero — Vite-inlined constants, no virtual module lookup at runtime.
- No per-island hydration shim — single client entry hydrates the whole page.
- Named slots are lazy (
() => …arrows) — layouts ship withoutcomputed. - Multi-child JSX wraps as arrays, not Fragment — keeps Fragment symbol out of
phaze. - Worker bundle is half the size (99 KB vs 194 KB brotli).
head()+loader()parallel, first byte goes out as soon as head resolves.
At parity: file-system routing, dynamic params, middleware shape, cookies API, streaming SSR, endpoint method exports, view transitions, prefetch (viewport strategy), head metadata.
Behind: MDX, i18n, view-transition element-level persistence, hover/tap prefetch strategies, dev toolbar (intentional). <Image> is at functional parity but uses a different transformation pipeline (CF edge vs Astro’s build-time sharp) — same end-user output, different deploy trade-off. Content Collections ship the same defineCollection + getCollection shape; no built-in markdown renderer (pipe body through marked / markdown-it / unified if you need HTML).
<Image> (Section 14a)
Section titled “<Image> (Section 14a)”import { Image } from '@madenowhere/phaze-cloudflare/image'
<Image src="/hero.png" alt="Hero banner" width={1200} height={600} widths={[400, 800, 1600, 2400]} sizes="(max-width: 768px) 100vw, 50vw" priority/>Renders a plain <img> with loading="lazy" (eager + fetchpriority="high" when priority), decoding="async", explicit width/height (CLS-safe), and — when widths is set — a srcset of /cdn-cgi/image/width=W,format=auto/<src> URLs that Cloudflare’s edge transforms on-demand. AVIF / WebP / JPEG negotiation happens per-request from the Accept header.
Trade-off vs Astro’s <Image>: Astro processes images at build time with sharp (portable, adds ~30 MB of native deps, slower first build, static cacheable output). phaze-cloudflare delegates to CF’s edge transformation (CF-locked, zero build deps, cached after first hit, dynamic per-request format selection). Pick the one that fits your hosting story.
Requirements: Cloudflare Image Transformations on the zone (Pro+). Without widths, <Image> renders a plain <img> with no CDN prefix — works on any plan.
Runtime cost: ~500 B brotli in the consuming chunk. phaze unchanged.
Content collections (Section 14b)
Section titled “Content collections (Section 14b)”import { defineCollection } from '@madenowhere/phaze-cloudflare/content'import { z } from 'zod'
export const collections = { posts: defineCollection({ pattern: 'src/content/posts/**/*.md', schema: z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), tags: z.array(z.string()).default([]), }), }),}import { getCollection } from 'phaze:content'
export const loader = async () => { const posts = await getCollection('posts') return posts.filter((p) => !p.data.draft).sort((a, b) => +b.data.pubDate - +a.data.pubDate)}Each entry carries { id, slug, collection, data, body }. The plugin globs the filesystem at build time and emits one import … from '…?raw' per matched file. A tiny YAML subset parser handles the common-case frontmatter (strings, numbers, booleans, dates as strings, arrays of scalars). No markdown renderer in the box — pipe body through marked / markdown-it / unified for HTML, or use MDX directly.
Server-side only. getCollection / getEntry throw on the client; markdown content never reaches the browser. Pair with prerender: for zero-runtime-cost blog pages.
Worker cost: ~2.3 KB brotli + the content itself. Client cost: ~80 B brotli for the SSR-only stub. phaze unchanged.
Static prerendering (Section 14c)
Section titled “Static prerendering (Section 14c)”Prerender list comes through as a plugin option:
cloudflare({ prerender: ['/', '/about', '/pricing'],})Each path is rendered at build time through the same SSR pipeline a live request would use — head(), loader(), edgeSignal(), named slots, phaze:env/client substitution all behave identically. The output lands in dist/client/<path>/index.html, and each path is added to _routes.json’s exclude list so Cloudflare’s static-assets handler serves it directly — the Worker never sees the request.
The pass runs inside closeBundle in a transient Vite SSR loader (gated on dist/server/index.js existing — only fires after the SSR build completes). The phaze runtime loads through ssrLoadModule so import.meta.env.DEV substitution applies; nothing runs raw under Node.
Cost: 0 bytes added to client or worker bundles. Build-time only.
What works in prerendered pages: head metadata, loaders that read no per-request data, edge signals (initial value captured at build), named slots, phaze:env/client (PUBLIC_* values from .env / .env.production).
What doesn’t: reading bindings (KV, D1, R2, DO), phaze:env/server declared vars (proxy sees empty env), cookies on the request, anything that depends on a live request.
The framework is in a strong position. Every feature that exists is leaner than the Astro equivalent; the remaining gaps are enumerated and most are small in scope.