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.tracing.enterSpan() API. No tracer option needed; just enable tracing in wrangler.jsonc:12345678// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
123456GET /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:123456789// 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).1234wrangler 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.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.<canvas>, fonts won't load from other origins, and fetch() from another domain can't read the response.public/_headers file in your project:12/* 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.wrangler.jsonc and control it with standard Cache-Control headers on your responses.1234// 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:1234567891011import { 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> })
12345678910111213export 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.1response.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:12345678910111213import { 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 |