@madenowhere/phaze-cloudflare
v0.0.7
Published
Greenfield Cloudflare Workers + Pages adapter for Phaze apps. File-system routing, default Worker entry, typed bindings, SSR via @madenowhere/phaze-render-to-string, no Astro layer.
Maintainers
Readme
@madenowhere/phaze-cloudflare
Direct Phaze + Cloudflare Workers deployment. No Astro layer.
File-system routing, typed Worker bindings, SSR via @madenowhere/phaze-render-to-string, and a built-in RPC-style action runtime that mirrors Astro Actions but ships a smaller wire format, better cancellation semantics, and a few feature additions Astro doesn't have.
pnpm add @madenowhere/phaze-cloudflare @madenowhere/phaze// vite.config.ts
import { defineConfig } from 'vite'
import cloudflare from '@madenowhere/phaze-cloudflare/vite'
export default defineConfig({
plugins: [cloudflare({ pages: 'src/pages' })],
})Size comparison vs astro-cloudflare
Same TodoList feature set (form + per-item checkbox + delete + KV-backed actions + SSR loader), measured from the production build of each example in this repo. Brotli quality 11, the same level Cloudflare's edge serves.
| Metric | phaze-cloudflare | astro-cloudflare | Margin |
|---|---|---|---|
| phaze chunk (brotli) | 2,681 B | 2,931 B | -250 B (no Astro client.ts ClientRouter hook) |
| Entry chunk (brotli, with prefetch + router enabled) | 4,315 B | 4,994 B (TodoList + page + 2× hydration shims) | -679 B |
| Client JS shipped to browser (brotli) | 6,996 B | 7,925 B | -929 B (-12%) |
| Worker bundle (brotli) | 99,743 B | 194,396 B | -94,653 B (-49%) |
| HTTP chunks on first paint | 2 JS + 1 CSS = 3 | 5 JS = 5 (CSS inlined in HTML) | 2 fewer JS requests |
These numbers cover the full Phase 1 + Phase 2 feature set:
- Phase 1 (server-side): cookies, endpoints, middleware, typed env
- Phase 2 (client-UX): streaming SSR, viewport prefetch, client-side router with view transitions
The headline client-bundle margin tightened with the <For> inversion landing in @madenowhere/phaze-compile — Astro now benefits from the same zero-shipped-bytes default for lists, so the previous ~25 % margin (which counted Astro's then-needed runtime For against phaze-cloudflare's already-inverted list) narrows to ~12 %. The worker-bundle margin is structural — Astro's worker carries the page renderer, session driver, image service, server-island manifest, and action dispatcher; phaze-cloudflare's worker is @madenowhere/phaze-render-to-string + linkedom + your action handlers.
phaze budget — 2,681 B brotli (sub-3 KB)
The headline runtime chunk (@madenowhere/phaze core only — no adapter layer) sits below the 3 KB shipped-byte contract. Verify directly:
brotli -q 11 -c examples/phaze-cloudflare/dist/client/assets/phaze-*.js | wc -c
# 2681<For> runtime bytes (flow/for + dom/lis + dom/move, ~900 B brotli) only land in phaze when a <For phaze> somewhere in your app opts in to the keyed runtime. The default <For for:item={items}> compile-strips to {() => items().map(...)} and the For import drops at Program.exit — zero shipped bytes from the For runtime in apps using the default form.
New features land in phaze-cloudflare's own client surface (entry chunk or opt-in sibling chunks) — never in phaze. Any future change that would touch the headline runtime number requires explicit sign-off.
Why phaze-cloudflare is smaller in every metric:
- No Astro adapter layer.
phaze-astro/client(ClientRouterastro:after-swaphook + per-island hydrator) doesn't ship — that's ~280 B brotli saved on the client. - No Astro page chunk. Astro's
page.*.js(~925 B brotli — prefetch runtime, link-listener, view-transitions scaffold) has no phaze-cloudflare equivalent. Phaze-cloudflare's single client entry handles route matching directly. - No per-island hydration shims. Astro emits a small
client.*.jsper island (here: 96 B + 66 B brotli) to bridgeclient:onlydirectives into phaze's renderer. Phaze-cloudflare mounts the whole tree from one entry. - No
devaluein the wire format. Astro Actions serialise responses withdevalueforDate/Map/Temporalsupport (~3 KB brotli of client runtime).phaze:actionsuses plainJSONby default. - Half-sized worker bundle. Astro's worker includes the page renderer, ClientRouter middleware, image service, session driver, server-island manifest, and Astro's action dispatcher. Phaze-cloudflare's worker is just
phaze-render-to-string+ linkedom + your action handlers.
All metrics computed directly from the dist artifacts in examples/phaze-cloudflare/dist/ and examples/astro-cloudflare/dist/. Re-run with:
brotli -q 11 -c <dist-file> | wc -cPlugin options
cloudflare({
// Where the file-system router scans for pages. Default: 'src/pages'.
pages: 'src/pages',
// Where static assets live (copied to dist/client). Default: 'public'.
publicDir: 'public',
// Build output dir. Default: 'dist'.
outDir: 'dist',
// Server-actions module. Default: 'src/actions.ts'. Pass `false` to opt
// out (no `phaze:actions` virtual emitted; no dispatcher wired). When
// left as the default, missing-file is fail-soft.
actions: 'src/actions.ts',
// CSS routing. 'auto' (default) inlines if < inlineStylesheetsLimit,
// links otherwise. 'always' / 'never' force one path.
inlineStylesheets: 'auto',
inlineStylesheetsLimit: 4096,
// `phazeChunks` flags — control how phaze-owned modules split or fold
// into the consumer bundle. Defaults below fold every opt-in helper
// into the chunk that uses it; matches neuralkit-web-claude's setup,
// ~0.36 KB brotli saved vs splitting into dedicated phaze-* chunks
// (brotli compresses helper + consumer together better than each
// isolated chunk's ESM wrapper + lost dedup across chunk boundaries).
//
// Flip a flag to `true` to split that surface into its own chunk when
// 3+ pages share it and dedicated caching wins. Pass `chunks: false`
// to disable phaze-aware chunking entirely.
chunks: {
chunkDirectives: false, // `autofocus`, `inView`, etc. → folded
chunkActions: false, // `useAction` from /actions → folded
chunkSubpaths: false, // `/store`, `/portal`, `/catch`, `/time`,
// `/match`, `/list` → folded
},
// Paths to statically prerender at build time. Each path is rendered
// through the user's page module + SSR pipeline and written to
// `dist/client/<path>/index.html`. Cloudflare's static-assets
// handler serves the file directly — zero per-request Worker cost,
// edge-cached. See the "Static prerendering" section below.
// Default: `[]` (no prerender pass).
prerender: ['/', '/about'],
})The phaze chunk (the phaze runtime — typically < 3 KB brotli) stays its own dedicated chunk regardless of these flags; only the opt-in helpers above are affected.
| Flag | false (default) | true (split) |
|---|---|---|
| chunkDirectives | autofocus, inView, etc. fold into the component that uses them | each gets a dedicated phaze-directives.js chunk |
| chunkActions | useAction folds into the action-using component | dedicated phaze-actions.js chunk |
| chunkSubpaths | /store, /portal, /catch, /time, /match, /list fold into consumers | each gets its own phaze-store.js, phaze-list.js, … |
The SSR worker bundle always inlines everything into a single dist/server/index.js regardless of these flags — Workers don't benefit from chunk splitting (no caching across pages, no parallel parsing) and wrangler dev's no_bundle: true mode requires the entry to be self-contained.
phaze:actions — typed server actions over fetch
A direct port of Astro Actions's developer experience, optimised for the phaze-cloudflare runtime. Server handlers go in src/actions.ts; client code imports the typed proxy from the virtual phaze:actions module. The wire is plain JSON, the dispatcher is built into the Worker entry, no extra Vite plugins needed.
60-second tour
// src/actions.ts
import { defineAction, ActionError } from '@madenowhere/phaze-cloudflare/actions'
import { z } from 'zod'
export interface Todo { id: string; text: string; done: boolean }
export const server = {
add: defineAction({
input: z.object({ text: z.string().trim().min(1).max(200) }),
handler: async ({ text }, { env }) => {
const todos = (await env.TODOS.get<Todo[]>('todos', 'json')) ?? []
todos.unshift({ id: crypto.randomUUID(), text, done: false })
await env.TODOS.put('todos', JSON.stringify(todos))
return { todos }
},
}),
remove: defineAction({
input: z.object({ id: z.string() }),
handler: async ({ id }, { env }) => {
const todos = (await env.TODOS.get<Todo[]>('todos', 'json')) ?? []
const next = todos.filter((t) => t.id !== id)
await env.TODOS.put('todos', JSON.stringify(next))
return { todos: next }
},
}),
}// src/pages/todos.tsx
import { actions } from 'phaze:actions'
import { useAction } from '@madenowhere/phaze-cloudflare/actions'
import type { Todo } from '../actions'
export default function Todos() {
const addAction = useAction(actions.add)
const onSubmit = async (e: Event) => {
e.preventDefault()
const text = /* read input */
const { data, error } = await addAction.execute({ text })
if (error) console.warn(error.code, error.message)
}
return (
<form on:submit={onSubmit}>
<input class:opacity-50={addAction.pending}/>
<button phaze:disabled={addAction.pending}>add</button>
</form>
)
}The actions proxy is fully typed against the server export — every handler's input/output shape flows through to autocomplete on actions.<name>(input).
Side-by-side: astro:actions vs phaze:actions
The surface is intentionally parallel. Here's the same TodoList action set written for both, so you can see the diff is exactly what the migration guide promises.
Server actions module
import { defineAction, ActionError } from 'astro:actions'
import { z } from 'astro:schema'
import { env } from 'cloudflare:workers'
export interface Todo {
id: string
text: string
done: boolean
}
const load = async (): Promise<Todo[]> =>
(await env.TODOS.get<Todo[]>('todos', 'json')) ?? []
export const server = {
add: defineAction({
input: z.object({
text: z.string().trim().min(1).max(200),
}),
handler: async ({ text }) => {
const todos = await load()
todos.unshift({
id: crypto.randomUUID(),
text,
done: false,
})
await env.TODOS.put('todos', JSON.stringify(todos))
return { todos }
},
}),
}import { defineAction, ActionError } from '@madenowhere/phaze-cloudflare/actions'
import { z } from 'zod'
export interface Todo {
id: string
text: string
done: boolean
}
const load = async (env: Env): Promise<Todo[]> =>
(await env.TODOS.get<Todo[]>('todos', 'json')) ?? []
export const server = {
add: defineAction({
input: z.object({
text: z.string().trim().min(1).max(200),
}),
handler: async ({ text }, { env }) => {
const todos = await load(env as Env)
todos.unshift({
id: crypto.randomUUID(),
text,
done: false,
})
await env.TODOS.put('todos', JSON.stringify(todos))
return { todos }
},
}),
}The only material differences: imports change (astro:actions → @madenowhere/phaze-cloudflare/actions, astro:schema → zod), and env reaches the handler via ctx.env (second-arg destructure) instead of the Astro cloudflare:workers module-level export. Everything else — defineAction, input schema shape, ActionError, the server export convention — is identical.
Client island
import { s } from '@madenowhere/phaze/dsl'
import { store } from '@madenowhere/phaze/store'
import { For } from '@madenowhere/phaze'
import { actions } from 'astro:actions'
import { useAction } from '@madenowhere/phaze-astro/actions'
import type { Todo } from '../actions'
export default function TodoList({ initial }: { initial: Todo[] }) {
const todos = s<Todo[]>(initial.map(store))
const addAction = useAction(actions.add)
const submit = async (e: Event) => {
e.preventDefault()
const { data } = await addAction.execute({ text })
// …
}
return (
<ul>
<For for:todo={todos}>
<TodoItem key={todo.id} todo={todo}/>
</For>
</ul>
)
}import { s } from '@madenowhere/phaze/dsl'
import { store } from '@madenowhere/phaze/store'
import { For } from '@madenowhere/phaze'
import { actions } from 'phaze:actions'
import { useAction } from '@madenowhere/phaze-cloudflare/actions'
import type { Todo } from '../actions'
export default function TodoList({ data: initial }: { data: Todo[] }) {
const todos = s<Todo[]>(initial.map(store))
const addAction = useAction(actions.add)
const submit = async (e: Event) => {
e.preventDefault()
const { data } = await addAction.execute({ text })
// …
}
return (
<ul>
<For for:todo={todos}>
<TodoItem key={todo.id} todo={todo}/>
</For>
</ul>
)
}Two import lines change; the component body is byte-for-byte identical. The prop shape shifts from { initial } (Astro passes through Astro.props) to { data: initial } (phaze-cloudflare passes the loader() result as data).
What you get for free that Astro doesn't have
The improvements below are all additive — they don't require you to change any of the code above. The useAction returned object gains new fields (phase, abort); execute() gains an optional second arg; ActionError gains an optional payload field. Pre-existing call sites still work.
Improvements over astro:actions
The full delta — what phaze:actions does that astro:actions doesn't, with the client-bundle cost column reflecting the compile-stripped reality. Every entry below is implemented as a phaze-compile AST transform, not as a runtime helper. useAction(actions.X) is rewritten per call-site into an inline state machine that emits only the fields the consumer actually reads; actions.X(input) becomes a direct inline fetch arrow; defineAction({...}) collapses to just the object literal; throw new ActionError({...}) becomes throw { type: 'PhazeActionError', ... }. Result: 0 bytes shipped from the action surface in the compiled common case. Phaze-compile is mandatory infrastructure — there is no runtime fallback.
| Improvement | What Astro Has | What phaze:actions adds | Status |
|---|---|---|---|
| AbortSignal on execute | nothing — concurrent submits all complete | execute(input, { signal }) + auto-abort on new execute() by default (configurable: 'cancel-prior' | 'parallel'). Compile-emitted per useAction call site; AbortController code only appears when execute(_, { signal }) is actually called or dedupe !== 'parallel'. | ✅ shipped |
| Phase state-signal | pending: Signal<boolean> only | phase: MatchSignal<'idle' \| 'pending' \| 'success' \| 'error'> from /match, predicates .is('pending') / .not('idle'). Compile pass walks the useAction(...) return's references — only emits the match-signal allocation if the call site reads .phase. pending keeps as a derived alias. | ✅ shipped |
| Structured error payload | error is opaque (just code / message) | throw new ActionError({ code, message, payload }) — payload round-trips through the wire envelope; consumer destructures err.payload. Compile rewrites the throw to a plain object literal; the ActionError class never reaches the bundle. | ✅ shipped |
| Middleware | no concept — auth / rate-limit live inside each handler body | defineAction({ use: [requireAuth, rateLimit], handler }) — cross-cutting concerns declared per-action. The phaze-cloudflare plugin emits per-action server dispatchers that inline the middleware chain; no shared dispatcher runtime. | ✅ shipped |
| Edge-signal handoff | actions run from cold context; SSR-established values aren't reachable | ctx.edge: ReadonlyArray<unknown> — handlers read the SSR pass's edgeSignal() values without re-deriving them. Compile pass emits the X-Phaze-Edge header build only when the consumer page also reads edge signals; per-action dispatcher decodes the header only when ctx.edge is referenced in the handler. | ✅ shipped |
| JSON-only wire by default | ships devalue in the client bundle (~3 KB brotli of runtime) for Date / Map / Temporal deserialization | Default to plain JSON.stringify / JSON.parse — opt-in transform: 'devalue' planned for richer types | ✅ shipped |
| Action abort() method | none | useAction(...).abort() cancels the in-flight call imperatively. Compile emits the abort closure only when the call site references .abort. | ✅ shipped |
| Cancellation error envelope | n/a | Aborted calls resolve with { error: { code: 'CANCELED', message: 'Aborted' } } — never reject, so call sites never need try/catch around execute(). Compile-emitted as part of the inline fetch arrow's catch branch. | ✅ shipped |
| Streaming actions (SSE) | request-response only | defineStreamingAction({ handler: async function*(input, ctx) { yield ... } }) — server-sent progress to long-running mutations (uploads, AI inference, multi-step migrations) | 🛠 planned |
| transform: 'devalue' opt-in | always-on devalue | Per-action or global flag to swap JSON for devalue when actions return Date / Map / Temporal / circular refs | 🛠 planned |
Client net (what the browser downloads): 0 B from the action surface (everything compile-stripped — defineAction, useAction, actions.X(...), throw new ActionError(...) all rewritten by phaze-compile to inline AST), – 3,000 B saved by dropping devalue. Verified empirically: phaze-cloudflare's TodoList ships 6,996 B brotli of JS total vs astro-cloudflare's 7,925 B for the same feature set (-929 B / -12%). The action-surface compile-strip is structural; the recent <For> inversion landing on the Astro side narrowed the remaining margin (Astro no longer pays for the runtime For when its lists are inversion-eligible).
Per-field DCE on useAction is the extra optimization that drives the entry chunk below the no-DCE baseline. The compile pass walks each const X = useAction(actions.Y, opts?) binding's references to enumerate which fields the consumer reads — .pending, .phase, .error, .data, .execute, .abort. When the read set fits the "lean" shape ({ pending, execute } or a subset), the pass emits a ~25-line IIFE instead of the full ~60-line state machine: no phase signal, no error / data signals, no AbortController setup, no setPhase helper. The TodoList example reads .pending from one useAction and just .execute from the other two — all three get the lean form. Saves ~150-200 B brotli on this app; more on apps with heavier useAction usage.
How to verify: grep 'cancel-prior' dist/client/assets/*.js and grep 'new Proxy(Object.create(null)' dist/client/assets/*.js both return zero matches — the shared useAction state machine and the actions Proxy have been compile-stripped. Per-action endpoint URLs (/_phaze/action/add, /_phaze/action/toggle, /_phaze/action/remove) appear inline at each call site instead.
Why zero-shipped is enforceable, not aspirational: every API on the action surface has a deterministic compile-time rewrite. defineAction({ ... }) → just the object literal. actions.X(input) → inline fetch arrow. useAction(actions.X) → per-field-DCE inline state machine. throw new ActionError({ ... }) → plain throw { type: 'PhazeActionError', ... }. No runtime body exists in the package — the compiler is the implementation. Apps that bypass phaze-compile get a build-time error from the missing module, not a working runtime fallback. (This matches how /match's is(sig, val) already ships 0 bytes through the same mandatory-compile pipeline.)
API reference
defineAction({ input?, use?, handler })
Identity helper that preserves Input/Output inference for the client proxy. Runtime cost: zero.
defineAction({
input: zodSchema, // optional; anything `.parse(raw)`-shaped
use: [requireAuth, rateLimit({ rpm: 60 })], // optional middleware chain
handler: async (input, ctx) => { … },
})ctx is ActionContext<Bindings>:
| Field | Type | What |
|---|---|---|
| env | Bindings | Workers env (KV, D1, R2, DO) — typed by you via wrangler types |
| request | Request | Raw request — for headers, cookies, body if input isn't set |
| ctx | ExecutionContext | waitUntil / passThroughOnException |
| edge | ReadonlyArray<unknown> | SSR-captured edge-signal values from the page that issued this call |
ActionError({ code, message?, payload? })
Throw from inside a handler (or middleware) to return a typed error response. HTTP status is derived from code.
throw new ActionError({
code: 'CONFLICT',
message: 'Already exists',
payload: { conflictingId: existing.id },
})| code | Status |
|---|---|
| BAD_REQUEST | 400 |
| UNAUTHORIZED | 401 |
| FORBIDDEN | 403 |
| NOT_FOUND | 404 |
| METHOD_NOT_ALLOWED | 405 |
| CONFLICT | 409 |
| PAYLOAD_TOO_LARGE | 413 |
| TOO_MANY_REQUESTS | 429 |
| INTERNAL_SERVER_ERROR | 500 |
| CANCELED | 499 (client-only — AbortSignal triggered) |
useAction(action, options?)
Reactive wrapper. Returns:
{
pending: Signal<boolean> // alias for phase.is('pending')
phase: MatchSignal<'idle' | 'pending' | 'success' | 'error'> // named-state-signal
error: Signal<SerializedActionError | null>
data: Signal<Output | undefined>
execute: (input, { signal? }?) => Promise<{ data, error }>
abort: () => void
}options.dedupe:
'cancel-prior'(default) — newexecute()aborts the in-flight one'parallel'— let both fly; last resolution wins
phaze:actions virtual module
The plugin-generated client proxy. Lazily synthesises (input, options?) => Promise<{ data, error }> per accessed name. Typed against typeof import('src/actions').server.
Direct calls (without useAction) are equally valid:
import { actions } from 'phaze:actions'
const { data, error } = await actions.add({ text: '…' })
if (error?.code === 'CONFLICT') { /* … */ }Wire format
Single endpoint per Worker, prefix /_phaze/action/:
POST /_phaze/action/<name> HTTP/1.1
content-type: application/json
X-Phaze-Edge: <optional base64-JSON of edge values>
<JSON.stringify(input)>Response:
HTTP/1.1 200 OK
content-type: application/json
{ "data": <handler return> }or:
HTTP/1.1 4xx | 5xx
content-type: application/json
{ "error": { "type": "PhazeActionError", "code": "…", "message": "…", "payload"?: … } }Empty bodies are legal on both sides (input defaulting to {}, data defaulting to undefined).
Migration from astro:actions
The shape is intentionally compatible. For most consumers, only the import sources change:
- import { defineAction, ActionError } from 'astro:actions'
- import { z } from 'astro:schema'
+ import { defineAction, ActionError } from '@madenowhere/phaze-cloudflare/actions'
+ import { z } from 'zod'
- import { actions } from 'astro:actions'
- import { useAction } from '@madenowhere/phaze-astro/actions'
+ import { actions } from 'phaze:actions'
+ import { useAction } from '@madenowhere/phaze-cloudflare/actions'Inside handlers, ctx.locals.runtime.env becomes plain ctx.env:
- handler: async ({ text }, { locals }) => {
- const env = locals.runtime.env
+ handler: async ({ text }, { env }) => {
const todos = await env.TODOS.get(…)useAction consumers keep .pending / .error / .data / .execute unchanged; the new .phase, .abort(), and the execute(input, { signal }) overload are additive.
Server-side features
Four conventions cover the bulk of what an app needs beyond pages + actions: per-request cookies, HTTP-method endpoints, request-wrapping middleware, and typed env access. All four are server-only by design — zero impact on the client bundle. Combined Worker cost: ~1.5 KB brotli for all four.
Cookies
ctx.cookies is available on every PageContext, EndpointContext, ActionContext, and MiddlewareContext. One Cookies instance per request, shared across the entire dispatch chain — a cookie set() in middleware survives through to the final response merge.
// src/pages/login.tsx (any handler / loader / action)
export const loader = async ({ env, cookies, request }) => {
if (cookies.get('session')) return { user: await env.DB.lookupSession(...) }
return { user: null }
}
// src/actions.ts
export const server = {
login: defineAction({
input: z.object({ email: z.string().email(), password: z.string() }),
handler: async ({ email, password }, { env, cookies }) => {
const session = await env.DB.createSession({ email, password })
cookies.set('session', session.id, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
})
return { ok: true }
},
}),
}API:
cookies.get(name: string): string | undefined // lazy — parses 'Cookie' header on first call
cookies.has(name: string): boolean
cookies.set(name: string, value: string, options?: CookieSetOptions): void
cookies.delete(name: string, options?: { path?, domain? }): voidCookieSetOptions: domain, path, maxAge, expires, httpOnly, secure, sameSite ('Lax' | 'Strict' | 'None'), partitioned, encode. Sensible defaults — no path needed for root-scoped cookies, no manual toUTCString().
Cold-start cost: zero. The Cookies instance is allocated per-request; the inbound Cookie header is parsed lazily on first .get() so requests that never touch cookies pay no parsing cost. Module-load is a class declaration only.
Endpoints
Any non-.tsx/.jsx file in src/pages/** is an endpoint module — exports GET / POST / PUT / DELETE / PATCH / HEAD / OPTIONS named handlers, each returning a Response. Routes use the same [id] / [...path] segment conventions as pages.
// src/pages/api/health.ts → GET /api/health
import type { EndpointHandler } from '@madenowhere/phaze-cloudflare'
export const GET: EndpointHandler<Env> = ({ env, cookies }) => {
return Response.json({
ok: true,
visits: cookies.get('visits') ?? '0',
})
}
// src/pages/api/users/[id].ts → GET / DELETE /api/users/:id
export const GET: EndpointHandler<Env> = async ({ env, params }) => {
const user = await env.DB.first('SELECT * FROM users WHERE id = ?', params.id)
if (!user) return new Response('Not found', { status: 404 })
return Response.json(user)
}
export const DELETE: EndpointHandler<Env> = async ({ env, params }) => {
await env.DB.run('DELETE FROM users WHERE id = ?', params.id)
return new Response(null, { status: 204 })
}Unsupported method → automatic 405 with Allow: header listing the verbs the endpoint does support. The dispatcher is just one entry per route in the same route table pages use — no separate runtime; the file-extension check at build time decides page vs endpoint.
Middleware
src/middleware.ts exports onRequest — a single function that wraps every page render, endpoint call, AND action dispatch. Set cookies / headers before delegating; observe / mutate / replace the response after.
// src/middleware.ts
import type { MiddlewareHandler } from '@madenowhere/phaze-cloudflare'
export const onRequest: MiddlewareHandler<Env> = async (ctx, next) => {
// Pre-request work — auth gate, request id, timing start.
const start = Date.now()
const session = ctx.cookies.get('session')
if (ctx.url.pathname.startsWith('/admin') && !session) {
return Response.redirect('/login', 302)
}
// Delegate to the page / endpoint / action handler.
const response = await next()
// Post-response work — security headers, timing, observability.
response.headers.set('x-served-ms', String(Date.now() - start))
response.headers.set('x-content-type-options', 'nosniff')
return response
}MiddlewareContext carries { env, request, ctx, cookies, url } — the parsed URL is cached so middleware doesn't re-parse on every read. The shared cookies instance lets a middleware set() propagate to the final response automatically (merged once at the outermost wrap).
Cold-start cost when absent: zero — the generated worker entry passes middleware: null and handleRequest skips the wrap. When present: one async function call per request.
Typed env — phaze:env/server & phaze:env/client
Author a schema in src/env.ts, get typed lazy-validated env on the server and typed inlined constants on the client.
// src/env.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),
TODOS: z.custom<KVNamespace>((v) => v != null && typeof (v as KVNamespace).get === 'function'),
},
public: {
PUBLIC_SITE_NAME: z.string().default('My App'),
PUBLIC_GA_ID: z.string().optional(),
},
})Server-side — worker only, lazy zod validation on first access:
// any worker-side file (loader, action handler, middleware, endpoint)
import { env } from 'phaze:env/server'
const todos = await env.TODOS.get('todos', 'json') // typed; validated on first read; cachedThe Proxy throws a loud error if a var is accessed that isn't declared in src/env.ts's server schema (catches typos that would otherwise return undefined). Validation runs once per var per worker instance — subsequent reads return the cached parsed value.
Client-side — typed access to PUBLIC_* values from .env / .env.local, inlined as constants at build time:
// src/pages/index.tsx
import { env as publicEnv } from 'phaze:env/client'
export default function Home() {
return <h1>{publicEnv.PUBLIC_SITE_NAME}</h1>
}The generated phaze:env/client module emits { PUBLIC_SITE_NAME: import.meta.env.PUBLIC_SITE_NAME, ... } — Vite replaces each import.meta.env.PUBLIC_X with the literal value at build time. Zero validator runtime on the client; zero zod in the client bundle. Type safety comes from the auto-generated .phaze/types.d.ts ambient declaration aliasing env to EnvFor<typeof __envSchema['public']>.
Validation asymmetry rationale: the worker can absorb zod's ~12 KB brotli without affecting the cold-start budget (Workers tolerate up to 1 MB compressed; phaze-cloudflare's worker is ~95 KB). The client bundle's < 3 KB phaze budget cannot — so client gets type-only validation. Runtime PUBLIC_* validation is the user's responsibility on the client side (a one-line zod parse in their component, or none at all).
.env file convention: Vite auto-loads .env, .env.local, .env.production, .env.development from the project root. The plugin sets envPrefix: ['VITE_', 'PUBLIC_'] so PUBLIC_-prefixed vars reach client code (matching Astro's convention).
Worker bindings (KV, D1, R2, DO) flow through cloudflare:workers's module-level env export — phaze:env/server's Proxy wraps it. So env.TODOS in phaze:env/server accesses the same KV namespace wrangler.jsonc declared, validated against your schema.
Streaming SSR
Page responses are streamed by default — the worker emits Transfer-Encoding: chunked and pushes the response in three stages so the browser starts parsing + downloading CSS/JS before the page body finishes rendering.
stage 1 — <doctype + head + opening body> after head() resolves (typically synchronous)
stage 2 — SSR'd page body after loader() + renderToString
stage 3 — </div> + hydration <script> tags after stage 2head() and loader() fire in parallel. Stage 1 awaits only head(); the loader continues in the background while the browser parses the head + starts downloading CSS and the JS bundle (<link rel="modulepreload"> is in stage 1). TTFB drops by however long the loader took to do its DB / KV round-trip.
Endpoints + actions stay buffered — those return discrete Response objects (typically JSON), no streaming benefit. Streaming applies only to page renders.
Middleware compatibility — middleware's pre-next() work (cookie writes, header munging) runs synchronously before the stream starts; the Response stays mutable until middleware returns. The Workers runtime begins pulling the stream body only after handleRequest resolves. Post-next() work can still mutate the Response headers and they'll be on the wire with the first chunk.
Default behavior — no opt-in flag, no separate runtime. Cost: ~250 B brotli added to the worker bundle (the ReadableStream + TextEncoder + three-stage closure). Client bundle unaffected.
Prefetch & client-side router
Two opt-in client-side features for SPA-class navigation perf. Both are gated on plugin config (default off — zero bytes shipped when unused); both live in phaze-cloudflare's client entry, never in phaze.
cloudflare({
prefetch: true, // viewport prefetch on <a> tags
router: true, // client-side router with view transitions
})Prefetch
Once enabled, the client entry hooks every internal <a> tag via IntersectionObserver. When a link enters the viewport (with a 50% margin), a low-priority fetch(href) warms the browser's HTTP cache. By the time the user clicks, the response is already in cache — the navigation serves near-instantly.
Per-link opt-out: <a href="/other" data-no-prefetch>.... The runtime also watches for <a> tags inserted dynamically (via MutationObserver), so signal-driven UI that adds links post-hydration still gets prefetched.
Cost: ~125 B brotli in the client entry chunk when enabled.
Client-side router with view transitions
Intercepts internal <a> clicks (ignoring modifier-key clicks, middle-click, external URLs), fetches the target page's HTML, extracts the new __PHAZE_CF__ payload + body content, swaps #__phaze_root__ via document.startViewTransition(), re-hydrates against the new payload, and updates browser history (pushState). Browsers without startViewTransition (Safari today) fall through to a plain swap — same JS-level navigation, no animated transition.
<!-- Just write normal anchors. The router intercepts. -->
<a href="/about">About</a>The router's fetch includes a phaze-router: 1 header — middleware can detect router-driven requests if it wants to (e.g., to skip rendering the full HTML envelope and return a fragment). v0 doesn't optimise this; full HTML round-trip per navigation.
Pairs naturally with prefetch: true — prefetched pages are already in the HTTP cache, so the router's fetch() resolves from cache and navigation is essentially instant.
Cost: ~390 B brotli in the client entry chunk when enabled. Combined with prefetch: ~515 B brotli for both.
Comparison vs astro-cloudflare equivalents
| Feature | Astro (@astrojs/cloudflare + Astro prefetch / <ClientRouter />) | phaze-cloudflare | Savings |
|---|---|---|---|
| Prefetch runtime (brotli) | ~600 B | ~125 B | -79% |
| Client router runtime (brotli) | ~1,500 B | ~390 B | -74% |
| Combined when both enabled (brotli) | ~2,100 B | ~515 B | -75% |
Smaller through tighter scope: phaze-cloudflare's router doesn't ship view-transition polyfill (browser-native), doesn't ship a route-prefetch policy engine (single 'viewport' strategy by default), doesn't ship the per-island shim plumbing Astro needs to re-execute partial-hydration islands. The <a> interception + fetch + DOMParser + startViewTransition + startClient (existing, reused) sequence is the whole runtime.
Content collections (phaze:content)
Astro-shaped content collections — typed glob loader + frontmatter validation. Define collections in src/content.config.ts, drop markdown files alongside, read them via getCollection(name) / getEntry(name, id).
// src/content.config.ts
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([]),
}),
}),
}<!-- src/content/posts/launch.md -->
---
title: We launched
pubDate: 2026-05-23
draft: false
tags:
- launch
- phaze
---
This is the body of the post in markdown.// src/pages/blog.tsx
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 }:
id— filename without extension ('launch').slug— same as id (v2 may add custom slugify).collection— the key incollections.data— validated frontmatter (typed against the user's schema).body— raw markdown body (everything after the closing---).
No markdown renderer. The framework intentionally does NOT include a markdown-to-HTML transformer — pipe body through marked / markdown-it / unified if you need HTML, or use MDX directly. Keeping it out saves 30-100 KB of build deps for the majority case (structured content like FAQs, case studies, listings) that doesn't need markdown rendering at all.
Frontmatter parser. A tiny YAML subset (~50 LOC, inline in content-runtime.ts) handles the 90% case: top-level key: scalar pairs, quoted/unquoted strings, numbers, booleans, null, dates (as raw strings — let z.coerce.date() parse them), arrays of scalars. Not supported: nested objects, multiline strings, anchors/aliases, inline arrays/objects. Users with richer frontmatter needs can pipe through js-yaml themselves in a custom loader.
Server-side only. getCollection / getEntry throw if called on the client — they're for loader() / page-module top-level / prerender paths. The client bundle ships only a stub (~80 B brotli); markdown content never reaches the browser. Pair with prerender: ['/blog'] for zero-runtime-cost blog pages, or call from a live SSR loader if the content needs per-request filtering.
How the plugin wires it up.
- Reads
src/content.config.tsat build time; extracts each collection'spatternliteral via regex. - Globs the filesystem against each pattern (the plugin's own ~30-LOC mini-glob —
**,*, literal segments). - Emits one explicit
import … from 'src/content/posts/launch.md?raw'per matched file in thephaze:contentvirtual. - Wraps the user's
collections.<name>.schemaaround each parsed frontmatter; caches per-collection results in a Map.
Worker bundle cost: ~2.3 KB brotli for the content runtime + collection contents (varies with content volume). Client bundle cost: ~80 B brotli for the SSR-only stub. phaze unchanged.
Cloudflare-specific consideration: for very large collections (1000+ entries), the worker bundle gets heavy. Workers have a 10 MB code limit; the framework doesn't enforce per-collection size limits. If your content scales past blog scale, move to D1 or R2 and write a custom loader.
<Image> — Cloudflare Image Transformations
A typed JSX helper that renders a plain <img> with sensible defaults and, when given a widths array, emits a srcset of Cloudflare Image Transformations URLs (/cdn-cgi/image/width=W,format=auto/<src>). Format negotiation (AVIF / WebP / JPEG) happens at the edge based on the request's Accept header. No sharp dependency at build time, no runtime image service in the worker.
import { Image } from '@madenowhere/phaze-cloudflare/image'
// Responsive image with srcset via CF Polish:
<Image
src="/hero.png"
alt="Hero banner"
width={1200}
height={600}
widths={[400, 800, 1600, 2400]}
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
// Plain <img> (no CDN prefix) when you just want defaults:
<Image src="/logo.svg" alt="Logo" width={120} height={32} />Renders:
<!-- With widths: -->
<img
src="/cdn-cgi/image/format=auto/hero.png"
srcset="/cdn-cgi/image/width=400,format=auto/hero.png 400w,
/cdn-cgi/image/width=800,format=auto/hero.png 800w,
/cdn-cgi/image/width=1600,format=auto/hero.png 1600w,
/cdn-cgi/image/width=2400,format=auto/hero.png 2400w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero banner"
width="1200" height="600"
loading="eager" decoding="async" fetchpriority="high">
<!-- Without widths: -->
<img src="/logo.svg" alt="Logo" width="120" height="32"
loading="lazy" decoding="async">Defaults that prevent common mistakes:
loading="lazy"unlesspriorityis set (theneager+fetchpriority="high").decoding="async"to keep image decoding off the main render thread.- Required
width+heightprops prevent CLS (browsers can reserve space before the image lands). - Required
alt(usealt=""explicitly for purely decorative images).
priority use case: above-the-fold hero / LCP images. Skips lazy loading and tells the browser to prioritise the request — measurable LCP improvement on first-paint-critical images.
Requirements:
- Cloudflare Image Transformations enabled on the zone (Pro+ plan) — needed for the
/cdn-cgi/image/...URL prefix to work. - Without the
widthsprop,<Image>falls back to a plain<img>— works on any plan / origin.
Why no build-time variant generation?
Astro's <Image> runs sharp at build time, generating hashed .avif / .webp / scaled variants in dist/_astro/. Trade-offs:
- Adds ~30 MB of native deps + slow first build.
- Works on any host (no CF lock-in).
- Static assets, cacheable, predictable.
phaze-cloudflare takes the opposite trade-off: ship zero build-time image deps; let Cloudflare's edge do the transformation on-demand; cache at the edge after the first hit. Faster builds, simpler dependency footprint, locks you to CF's image service.
Runtime cost: ~500 B brotli in the consuming chunk (function body + conditional branches). Folds into the consumer's chunk via phazeChunks — phaze is unaffected.
Static prerendering
For routes that don't depend on per-request data — marketing pages, docs, "/about", landing-page sections, statically-known blog posts — the SSR pipeline can run at build time instead of per request. The generated HTML lands in dist/client/<path>/index.html; Cloudflare's static-assets handler serves it directly without invoking the Worker.
Effects:
- Zero Worker cost per request for prerendered paths.
- Edge-cached with no extra config — same as any other asset under
dist/client/. - Same hydration contract as SSR — the prerendered HTML carries
<script>window.__PHAZE_CF__=…</script>exactly as a live SSR response would, so client hydration works identically. Reactive interactions on the page resume on hydration; the SPA router (router: true) can navigate away to a non-prerendered page and back. - Excluded from
_routes.json— each prerendered path is added to theexcludelist so the Worker never sees the request.
Enable per-path:
cloudflare({
prerender: ['/', '/about', '/pricing', '/blog/launch'],
})Paths must be concrete (no :param segments). Dynamic routes can still be prerendered by listing each instance explicitly. The render pass uses an empty env stub ({}) — pages that read phaze:env/server will see undefined for every var; pages that read user bindings (KV, D1, R2) will fail at build time. Use prerender for routes that don't depend on per-request data.
What gets inlined:
head()output (title, description, raw tags) — runs at build time, baked into the HTML.loader()output — runs once at build, serialized into the inlined__PHAZE_CF__.data.edgeSignal()initial values — captured into__PHAZE_CF__.edge[]the same as SSR.phaze:env/clientPUBLIC_* values — substituted from.env/.env.productionvia Vite'sloadEnv.
What doesn't work in prerendered pages:
- Reading
ctx.env.<binding>(KV, D1, R2, DO, Queues) — bindings are empty at build time. - Reading
phaze:env/serverfor declared server vars — Proxy returns undefined. - Cookies on the request (no incoming
Cookieheader at build). - Anything else that needs a live request context.
Implementation: the prerender pass runs in a transient Vite SSR loader inside closeBundle, gated on dist/server/index.js existing (so it only fires after the SSR build, not after the client build). The render helper is loaded via ssrLoadModule so the phaze runtime's import.meta.env.DEV references go through Vite's transform — none of the phaze code runs raw under Node.
Cost: 0 bytes added to either client or worker bundles. Prerender is a build-time-only feature; the Worker is unchanged whether prerender is set or empty.
SSR & page routing
The same Worker entry handles both action dispatches and SSR. Action routes (/_phaze/action/*) are dispatched first; everything else falls through to file-system routing against src/pages/. Per-page loader() runs at SSR time and its result is JSON-encoded into window.__PHAZE_CF__.data for the hydration pass.
See examples/phaze-cloudflare for a full deployment-ready example with action persistence via Workers KV.
