app.handle(request).default export with a fetch handler. Spiceflow does not generate this implicitly. Without it, the Worker has no entry point and requests will fail.// wrangler.jsonc { "main": "./src/main.tsx", }
// vite.config.ts import { cloudflare } from '@cloudflare/vite-plugin' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import spiceflow from 'spiceflow/vite' export default defineConfig({ plugins: [ react(), spiceflow({ entry: './src/main.tsx' }), cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'], }, }), ], })
// src/main.tsx import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().page('/', async () => { return <div>Hello from Cloudflare RSC</div> }) export type App = typeof app export default { fetch(request: Request) { return app.handle(request) }, }
example-cloudflare/ for a complete working example.picocolors and chalk disable colors when they detect no TTY, so terminal output loses all formatting. Set FORCE_COLOR=1 in your dev and build scripts to restore colors:{ "scripts": { "dev": "FORCE_COLOR=1 vite dev", "build": "FORCE_COLOR=1 vite build" } }
wrangler.jsonc, run wrangler types. Wrangler regenerates worker-configuration.d.ts, which provides the global Env type and the typed env export from cloudflare:workers.@cloudflare/vite-plugin resolves and flattens your wrangler.json config at build time and writes it into dist/rsc/wrangler.json. When wrangler deploy runs, it reads this generated config — not your top-level wrangler.json. This means wrangler deploy --env preview alone is not enough if the build was done without specifying the environment.CLOUDFLARE_ENV env var during vite build so the plugin resolves the correct environment section:# Build for preview environment CLOUDFLARE_ENV=preview vite build && wrangler deploy --env preview # Build for production (default, no env var needed) vite build && wrangler deploy
CLOUDFLARE_ENV=preview, the generated dist/rsc/wrangler.json will contain the top-level config (production name, routes, KV namespaces, etc.) and --env preview will be ignored at deploy time.env directly from cloudflare:workers. Run wrangler types after changing wrangler.jsonc so Wrangler regenerates worker-configuration.d.ts — that gives env a type-safe Env shape automatically.import { Spiceflow } from 'spiceflow' import { env } from 'cloudflare:workers' export const app = new Spiceflow() .route({ method: 'GET', path: '/kv/:key', async handler({ params }) { const value = await env.KV.get(params.key) return { key: params.key, value } }, }) .route({ method: 'POST', path: '/queue', async handler({ request }) { const body = await request.json() await env.QUEUE.send(body) return { success: true, message: 'Added to queue' } }, }) export default { fetch(request: Request) { return app.handle(request) }, }
tracing.enterSpan() API. No tracer option needed; just enable tracing in wrangler.jsonc:// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
GET /dashboard [server] ├── middleware - auth ├── loader - /dashboard ← spiceflow span ├── page - /dashboard ← spiceflow span ├── env.MY_KV.get("key") ← automatic CF span └── rsc.serialize ← spiceflow span
context.tracer.startActiveSpan() in your handlers also appear in the trace tree. The span and tracer on the handler context work the same as with an OTel tracer.tracer to the Spiceflow constructor, it takes priority over the automatic Cloudflare tracer.SpiceflowSpan methods natively yet. Spiceflow bridges the gap where possible:span.setStatus() — error statuses are mapped to otel.status_code and otel.status_description attributesspan.recordException() — mapped to exception.type, exception.message, and exception.stacktrace attributesspan.updateName() — no-opspan.spanContext() — returns undefined (CF planned for future)span.end() — no-op (CF auto-ends spans when the callback returns)span.setAttribute() works fully. Error details from recordException and setStatus are visible as span attributes in the Cloudflare dashboard and any OTel export destination.wrangler.jsonc:// wrangler.jsonc { "observability": { "enabled": true, "traces": { "enabled": true } } }
observability.enabled turns on logs (console output, uncaught exceptions, request metadata). observability.traces.enabled turns on traces (span trees for every request).wrangler tail # all logs wrangler tail --status error # errors only wrangler tail --search "TypeError" # filter by text wrangler tail --format json # JSON output for piping to jq
error.type, otel.status_code, exception.message, and exception.stacktrace as span attributes on errors, so they are queryable in the dashboard.waitUntil)waitUntil function in the handler context that allows you to schedule tasks in the background in a cross platform way. It will use the Cloudflare Workers waitUntil if present. It's currently a no-op in Node.js.import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().route({ method: 'POST', path: '/process', async handler({ request, waitUntil }) { const data = await request.json() // Schedule background task waitUntil( fetch('https://analytics.example.com/track', { method: 'POST', body: JSON.stringify({ event: 'data_processed', data }), }), ) // Return response immediately return { success: true, id: Math.random().toString(36) } }, })
waitUntil is automatically detected from the global context:import { Spiceflow } from 'spiceflow' export const app = new Spiceflow().route({ method: 'POST', path: '/webhook', async handler({ request, waitUntil }) { const payload = await request.json() // Process webhook data in background waitUntil( processWebhookData(payload) .then(() => console.log('Webhook processed')) .catch((err) => console.error('Webhook processing failed:', err)), ) // Respond immediately to webhook sender return new Response('OK', { status: 200 }) }, }) async function processWebhookData(payload: any) { // Simulate time-consuming processing await new Promise((resolve) => setTimeout(resolve, 1000)) // Save to database, send notifications, etc. } export default { fetch(request: Request) { return app.handle(request) }, }
waitUntil FunctionwaitUntil implementation:import { Spiceflow } from 'spiceflow' export const app = new Spiceflow({ waitUntil: (promise) => { // Custom implementation for non-Cloudflare environments promise.catch((err) => console.error('Background task failed:', err)) }, }).route({ method: 'GET', path: '/analytics', async handler({ waitUntil }) { // Schedule analytics tracking waitUntil(trackPageView('/analytics')) return { message: 'Analytics page loaded' } }, }) async function trackPageView(path: string) { // Track page view in analytics system console.log(`Page view tracked: ${path}`) }
waitUntil function is provided, the default implementation is a no-op that doesn't wait for the promises to complete.import { env } from 'cloudflare:workers' to access KV bindings directly from anywhere in your code, without threading env through .state(). Run wrangler types whenever the bindings change so env.PAGE_CACHE stays type-safe.import { Spiceflow, getDeploymentId } from 'spiceflow' import { env } from 'cloudflare:workers' export const app = new Spiceflow() .use(async ({ request, waitUntil }, next) => { if (request.method !== 'GET') { return next() } const { pathname, search } = request.parsedUrl const deploymentId = await getDeploymentId() const cacheKey = `${deploymentId}:${pathname}${search}` // IMPORTANT. cache key must always include search to distinguish html and rsc responses const cached = await env.PAGE_CACHE.get(cacheKey) if (cached) { return new Response(cached, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-cache': 'HIT', }, }) } const response = await next() if (!response || response.status !== 200) { return response } const html = await response.text() // Write to KV in the background so the response is not delayed waitUntil( env.PAGE_CACHE.put(cacheKey, html, { expirationTtl: 60 * 60 * 24 * 7, // 7 days }), ) return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'x-cache': 'MISS', }, }) }) .page('/', async () => { return ( <div> <h1>Home</h1> </div> ) }) export default { fetch(request: Request) { return app.handle(request) }, }
getDeploymentId() returns a different value and all cache keys are effectively new. Old entries expire naturally after 7 days.<canvas>, fonts won't load from other origins, and fetch() from another domain can't read the response.public/_headers file in your project:/* Access-Control-Allow-Origin: *
public/ contents into the client build output, which becomes the Cloudflare assets.directory. Cloudflare reads _headers from there and applies the rules to all static asset responses. The _headers file itself is not served as an asset.staticPage)staticPage() routes are rendered once at build time and written to the client output as static assets. Cloudflare serves them before the Worker runs: no Worker CPU, no Worker request, and the first visitor already gets a cached page. To run middleware first, see Middleware on Static Pages.export const app = new Spiceflow() .staticPage('/about', async () => <About />) // prerendered by `vite build` .page('/dashboard', async () => <Dashboard />) // rendered by the Worker per request
vite build ──► vite preview (built Worker in workerd) ──► GET /about, /about.rsc │ dist/client/about.html, about.rsc, _headers ◄──────────────┘
cloudflare:* modules that Node cannot load, so the build starts the built Worker with vite preview and fetches each static route from it. Client navigations load /about.rsc from assets; _headers gives it the Flight content type and the deployment id.og:url, canonical) use the single host in your Worker routes. With several hosts, or none (workers.dev), pass the public origin:spiceflow({ entry: './src/main.tsx', prerenderOrigin: 'https://example.com' })
.page() plus Workers Cache for those..use() middleware is skipped. To run redirects, auth checks, or header changes on static pages too, let the Worker run first and serve the prerendered files with serveAssets:// wrangler.jsonc { "assets": { "binding": "ASSETS", // hashed Vite assets still skip the Worker "run_worker_first": ["/*", "!/assets/*"] } }
import { env } from 'cloudflare:workers' import { Spiceflow } from 'spiceflow' import { serveAssets } from 'spiceflow/cloudflare' export const app = new Spiceflow() .use(({ request, redirect }) => { if (new URL(request.url).pathname === '/about-us') throw redirect('/about') }) .use(serveAssets(env.ASSETS)) // after the middleware that must run first .staticPage('/about', async () => <About />)
GET /about ──► Worker ──► .use() middleware ──► serveAssets ──► env.ASSETS: about.html │ │ redirect / 401 404 ──► render the page in the Worker
serveAssets is the Cloudflare version of serveStatic. It serves about.html for /about, about.rsc for client navigations, and any other file in the client output, like public/ files. If the binding has no file, the request falls through to your routes. The page is never rendered again, so the Worker only pays for the middleware.serveAssets after the middlewares that must run first. Middlewares after it do not run for asset hits.env.ASSETS.fetch() can return an asset. Without serveAssets, static pages render again in the Worker and public/ files return 404."run_worker_first": true, hashed JS and CSS also go through the Worker. Prefer the pattern list above."not_found_handling": "single-page-application": the binding would answer every path with index.html.vite build prerenders the page. That request has no cookies or user headers. If a middleware redirects or rejects it, the build fails.wrangler.jsonc and control it with standard Cache-Control headers on your responses.// wrangler.jsonc { "cache": { "enabled": true } }
Cache-Control headers is automatically cached and served from Cloudflare's edge on subsequent requests.response context object to set headers from page handlers or layouts:import { Spiceflow } from 'spiceflow' export const app = new Spiceflow() .page('/', async ({ response }) => { // Fresh for 5 min; serve stale for up to 1 hour while refreshing response.headers.set( 'Cache-Control', 'public, max-age=300, stale-while-revalidate=3600', ) return <div>Cached at the edge</div> })
export const app = new Spiceflow() .route({ method: 'GET', path: '/api/data', handler() { return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600', }, }) }, })
Vary header on your response to cache different variants per request header (e.g. content type, language). Workers Cache stores a separate cached variant per distinct combination of those header values.response.headers.set('Vary', 'Accept, Accept-Language')
stale-while-revalidate serves the stale response immediately while refreshing in the background, so users never wait for a re-renderCache-Tag header lets you purge specific content programmatically via ctx.cache.purge({ tags: ["product:123"] })exports config lets you cache some entrypoints and not others (e.g. skip cache on a gateway that authenticates, cache the expensive backend)workers.dev, preview URLs, and Workers for PlatformsheadersCache middlewareheadersCache middleware from spiceflow/cloudflare was the only way to cache Worker responses at the edge. It uses the Cache API (caches.default) directly inside your Worker code. This middleware is now deprecated for most use cases. Prefer the native Workers Cache config above.shouldCache predicates:import { headersCache } from 'spiceflow/cloudflare' app.use(headersCache({ // Custom cache eligibility check shouldCache: (request, response) => response.status === 200, // Custom cache key (must be an absolute URL string or Request) cacheKey: (request) => { const url = new URL(request.url) url.search = '' // ignore query params return url.toString() }, }))
stale-while-revalidate supportCache-Tag purging| Workers Cache (wrangler config) | KV Cache (above) | |
| Storage | CDN edge, regionally tiered | KV, globally replicated |
| Durability | Ephemeral, can be evicted | Persistent until TTL |
| Latency | Fastest (Worker doesn't run on hit) | ~10-50ms |
| Consistency | Tiered, upper tier shared globally | Eventually consistent (~60s) |
| Best for | High-traffic pages, API responses | Pages that must survive cache eviction |
| Setup | "cache": { "enabled": true } in wrangler.jsonc | Requires KV binding in wrangler.jsonc |