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.1234// wrangler.jsonc { "main": "./src/main.tsx", }
123456789101112131415161718// 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'], }, }), ], })
1234567891011121314// 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:123456{ "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:12345# 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.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.1234567891011121314151617181920import { 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:12345678910111213141516171819202122232425262728293031import { 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:12345678910111213141516171819202122import { 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.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657import { 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.headersCache middleware)Cache-Control: s-maxage=600. The headersCache middleware bridges this gap using the Cache API (caches.default), letting you control edge caching with standard headers.123456789101112131415import { Spiceflow } from 'spiceflow' import { headersCache } from 'spiceflow/cloudflare' export const app = new Spiceflow() .use(headersCache()) .page('/', async ({ response }) => { response.headers.set('Cache-Control', 's-maxage=600, max-age=60') return <div>Cached at the edge</div> }) export default { fetch(request: Request) { return app.handle(request) }, }
s-maxage (or public, max-age) in its Cache-Control header will be stored in the edge cache. On cache hits the Worker still executes, but downstream handlers are skipped — the middleware returns the cached response immediately before reaching your route handlers.response context object to set headers from page handlers or layouts:12345678910111213141516import { Spiceflow } from 'spiceflow' import { headersCache } from 'spiceflow/cloudflare' export const app = new Spiceflow() .use(headersCache()) .page('/', async ({ response }) => { // Cache at edge for 10 min, browser for 1 min response.headers.set('Cache-Control', 's-maxage=600, max-age=60') return <div>Cached page</div> }) export default { fetch(request: Request) { return app.handle(request) }, }
1234567891011121314151617181920212223import { Spiceflow } from 'spiceflow' import { headersCache } from 'spiceflow/cloudflare' export const app = new Spiceflow() .use(headersCache()) .route({ method: 'GET', path: '/api/data', handler() { return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 's-maxage=300, max-age=60', }, }) }, }) export default { fetch(request: Request) { return app.handle(request) }, }
Vary header on your response to cache different variants per request header. The Cache API respects Vary on stored responses automatically — no middleware option needed.1response.headers.set('Vary', 'Accept, Accept-Language')
X-Cache: HIT — served from edge cacheX-Cache: MISS — generated by the Worker and stored in cacheX-Cache: BYPASS — not cacheable (non-200, private, no-store, POST, etc.)1234567891011headersCache({ // Custom cache eligibility check (default: GET 200 with cacheable headers) 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() }, })
Edge Cache (headersCache) | KV Cache (above) | |
| Storage | CDN edge, per-colo | KV, globally replicated |
| Durability | Ephemeral, can be evicted | Persistent until TTL |
| Latency | Fastest (same colo) | ~10-50ms |
| Consistency | Per-colo | Eventually consistent (~60s) |
| Best for | High-traffic pages, API responses | Pages that must survive cache eviction |
| Setup | Just add middleware | Requires KV binding in wrangler.jsonc |