Skip to content
sproutboatDocs
Esc
↑↓navigate↵open⌘Jpreview
On this page

The handler

The fetch handler contract, and what JavaScript actually runs in production.

The entry point is src/index.js by default; main can also point to a TypeScript file such as src/index.ts. It must default-export an object literal with a fetch method, as a Cloudflare Worker does. Durable Object classes and helper functions can be declared above it. The CLI bundles the handler and its imports with Bun, which strips TypeScript syntax before Porffor compiles the JavaScript. To see type errors, run a separate type check; Generated types sets it up.

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/health") return new Response("ok");
    return Response.json({ method: request.method, path: url.pathname, greeting: env.GREETING });
  },
} satisfies ExportedHandler<Env>;

This is the standard Workers module syntax, so the same file runs on Cloudflare Workers unchanged. Env is the interface that sproutboat types writes into worker-configuration.d.ts. In plain JavaScript, leave off the satisfies line.

Handler arguments

The arguments depend on compatibility_date in sproutboat.jsonc:

compatibility_date Signature
2026-09-28 or later fetch(request, env, ctx), the Workers signature. scheduled(controller, env, ctx) and queue(batch, env, ctx) also receive env second.
Earlier fetch(request, ctx). env is only a global.

sproutboat init writes 2026-09-28 and a fetch(request, env, ctx) handler, so a new project starts with the Workers signature. Projects created by earlier CLI versions keep the date in their sproutboat.jsonc; set it to 2026-09-28 or later to switch.

Read env from the argument. A global env also exists at runtime, but the generated types do not declare it from 2026-09-28, and Workers has none; import { env } from "cloudflare:workers" is the standard way to reach the bindings outside a handler.

Compatibility dates

Each date below makes one part of the runtime behave like Cloudflare Workers. Before its date a call keeps its older form, so existing deployments do not change under you. A new project should use a recent date, such as 2026-11-18, which has every row.

From What changes
2026-09-28 Handlers receive (request, env, ctx).
2026-10-21 request.body and response.body are ReadableStreams. Before, they are the raw body as a string.
2026-10-22 fetch() and service-binding fetch() return Promises, follow redirects and stream the response.
2026-10-24 D1 calls return Promises (KV 2026-10-25, R2 2026-10-26; see Bindings).
2026-10-27 Request bodies stream. text(), json(), arrayBuffer() and bytes() return Promises, and the body limit is 100 MiB instead of 1 MiB.
2026-10-28, 2026-10-29 Durable Object storage returns Promises (2026-10-28); a class that extends DurableObject is called over RPC, with getByName and ctx.storage.sql (2026-10-29).
2026-10-31 Queues behave like Cloudflare Queues: send() returns a Promise, a message has attempts and retry({ delaySeconds }).

await is allowed on every one of these calls at every date and returns the same value. That is why the samples in these docs always await: the code is correct before and after each date, and it is the same code you would write for Cloudflare Workers. Without await, code only works while the call is still synchronous. From the date that changes it, an unawaited call is a Promise, and one that fails is reported as an unhandled rejection that nothing catches.

Extra handlers

The same default export can also carry scheduled and queue:

interface Job {
  email: string;
}

export default {
  async fetch(request, env, ctx) {
    return new Response("ok");
  },

  // controller.cron, controller.scheduledTime (epoch milliseconds)
  async scheduled(controller, env, ctx) {
    console.log(`cron ${controller.cron} fired for ${new Date(controller.scheduledTime).toISOString()}`);
  },

  // batch.queue, batch.messages, message.body, message.ack(), message.retry()
  async queue(batch, env, ctx) {
    for (const message of batch.messages) {
      console.log("sending to", message.body.email);
      message.ack();
    }
  },
} satisfies ExportedHandler<Env, Job>;

See Cron and Queues.

What works

JavaScript and Web APIs. Standard synchronous JavaScript, plus URL, URLSearchParams, Request, Response, Response.json, Response.redirect, Headers, FormData, TextEncoder and TextDecoder, JSON, Math, regular expressions, Date.now() and new Date().toISOString(), atob and btoa.

Request and response bodies. Read them as on Workers: await request.json(), await request.text(), await request.formData(), and request.body as a ReadableStream. formData() parses URL-encoded and multipart bodies. Before the dates in the table above the same methods answer synchronously, and await returns the same value.

structuredClone copies through JSON, so Map, Set, Date and typed arrays do not survive it.

Crypto. crypto.randomUUID, crypto.getRandomValues, and part of crypto.subtle: digest (SHA-256, SHA-384, SHA-512) and HMAC importKey, sign and verify. That covers HS256 JWTs and signed session cookies. crypto.scryptVerify(password, salt, expected, { N, r, p }) is a Sproutboat extension for checking scrypt hashes created by Node or Bun, for example during a migration.

Cache API. caches.default and caches.open(name) with match, put and delete. await them, as on Workers. Entries are keyed on the URL, only GET requests can be stored, and expiry comes from s-maxage or max-age. Vary is ignored. Store text bodies only.

request.cf. clientIp is the client’s address. When the connection comes from a proxy listed in SB_TRUSTED_PROXIES, clientIp comes from X-Forwarded-For, and httpProtocol, tlsVersion and tlsCipher are filled from the proxy’s headers.

ctx.waitUntil(promise) runs work after the response is sent, in the same process, for up to 25 seconds.

Node built-ins. node:path, node:url and node:querystring work through built-in shims. See Runtime compatibility for tested npm packages.

What does not

Not available Notes
Dynamic import(), require() Static imports only: everything has to resolve at build time.
process, Bun, Deno, Buffer Runtime globals are unavailable in the handler.
Other Node built-ins Only the three shims above.
ctx.passThroughOnException(), ctx.props, URLPattern Not defined yet. ctx has waitUntil.
Streams ReadableStream, WritableStream and TransformStream work in memory. Request bodies stream from 2026-10-27; responses are not streamed to the client, so there are no server-sent events or chunked responses. Poll instead. For large files, use R2’s direct transfer URLs.
Most of crypto.subtle ECDSA, RSA, AES and key wrapping are unavailable, so JWT algorithms other than HS256 and libraries such as better-auth do not work yet.
XMLHttpRequest Not available. Use fetch().
fetch() to a private address Loopback, private networks and the cloud metadata endpoint are refused; see Outbound fetch.
Filesystem No fs. Serve files through static assets or store them in R2.
Parsing date strings new Date("2024-01-02") is unreliable on the current Porffor version. Use timestamps.

Next

Proxy and iterators

CLI 0.17.0 uses Porffor alpha-17 and accepts new Proxy and Proxy.revocable. Native regression checks cover common object traps, callable and constructible proxies, revocation and a frozen-property invariant. URLSearchParams and FormData provide live keys, values and entries iterators. See Runtime compatibility for tested packages and remaining iterator limits.

Was this page helpful?