npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 (ClientRouter astro:after-swap hook + 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.*.js per island (here: 96 B + 66 B brotli) to bridge client:only directives into phaze's renderer. Phaze-cloudflare mounts the whole tree from one entry.
  • No devalue in the wire format. Astro Actions serialise responses with devalue for Date / Map / Temporal support (~3 KB brotli of client runtime). phaze:actions uses plain JSON by 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 -c

Plugin 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:schemazod), 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) — new execute() 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? }): void

CookieSetOptions: 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; cached

The 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 exportphaze: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 2

head() 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 in collections.
  • 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.

  1. Reads src/content.config.ts at build time; extracts each collection's pattern literal via regex.
  2. Globs the filesystem against each pattern (the plugin's own ~30-LOC mini-glob — **, *, literal segments).
  3. Emits one explicit import … from 'src/content/posts/launch.md?raw' per matched file in the phaze:content virtual.
  4. Wraps the user's collections.<name>.schema around 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" unless priority is set (then eager + fetchpriority="high").
  • decoding="async" to keep image decoding off the main render thread.
  • Required width + height props prevent CLS (browsers can reserve space before the image lands).
  • Required alt (use alt="" 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 widths prop, <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 phazeChunksphaze 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 the exclude list 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/client PUBLIC_* values — substituted from .env / .env.production via Vite's loadEnv.

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/server for declared server vars — Proxy returns undefined.
  • Cookies on the request (no incoming Cookie header 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.