Skip to content

Waku

Cloudflare.Website.Waku deploys a Waku app as a Cloudflare Worker. It builds the project programmatically: the React Server Components server bundle deploys as the Worker script, and the client output — including SSG-prerendered pages — deploys as static assets. There is no waku.config.ts to edit, no Wrangler file, and no build command to run.

The build integration is not bundled with alchemy — the resource dynamically imports @distilled.cloud/waku from your project at deploy time, so it must be installed alongside your framework. It is only used at build time, so a dev dependency is enough:

Terminal window
bun add -d @distilled.cloud/waku

There is no framework config to write: the resource builds the project programmatically, so a fresh Waku project deploys as-is — your waku.config.ts stays untouched.

Declare the site as a module-level const (rather than inline in the Stack) and derive the typed shape of its bindings from it:

alchemy.run.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Website = Cloudflare.Website.Waku("Website");
export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;

WebsiteEnv is the typed shape of the Worker’s bindings, derived from the class — you’ll import it into your Waku code in Read bindings in server code.

Yield the class from your Stack and return its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyWakuSite",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

Waku’s server runtime uses AsyncLocalStorage, so the nodejs_als compatibility flag is added automatically — you don’t need to pass it. SSG pages are served at their extensionless URLs (/about) via the default drop-trailing-slash asset handling.

During alchemy dev, Waku’s dev server runs with SSR executing in workerd, so server code sees the same runtime and the same real bindings it will have in production.

See examples/cloudflare-website-waku for the checked-in example.

The resource returns a plain Worker, so env accepts the full binding vocabulary — KV namespaces, R2 buckets, Durable Objects, secrets:

alchemy.run.ts
import * as Config from "effect/Config";
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export const Website = Cloudflare.Website.Waku("Website", {
env: {
UPLOADS: Uploads,
API_KEY: Config.redacted("API_KEY"),
},
});

Uploads is a description, not a deploy — Alchemy provisions the real bucket because the Website binds it. Config.redacted reads API_KEY from your environment at deploy time and binds it as a Worker secret — see Secrets & env.

Server components and API routes read bindings from the cloudflare:workers env at request time. Use a guarded dynamic import — Waku’s SSG step renders static pages in Node, where a top-level import { env } from "cloudflare:workers" cannot resolve — and type the result with the inferred env type:

src/lib/env.ts
import type { WebsiteEnv } from "../../alchemy.run.ts";
export const getEnv = async (): Promise<WebsiteEnv> => {
const { env } = await import("cloudflare:workers");
return env as WebsiteEnv;
};

Server code calls it per request:

src/pages/api/upload.ts
import { getEnv } from "../../lib/env.ts";
export const POST = async (request: Request) => {
const env = await getEnv();
await env.UPLOADS.put("hello.txt", await request.text());
return Response.json({ ok: true });
};

env.UPLOADS is typed as an R2 bucket and env.API_KEY as a string — renaming a binding in alchemy.run.ts is a type error in your server code.

By default the deployed Worker entry is Waku’s own RSC server entry. When the Worker must export more than Waku’s fetch handler — Durable Object classes, additional handlers — point main at your own module that wraps Waku’s handler and re-exports the extras:

src/worker-entry.ts
import wakuHandler from "virtual:waku/server-entry";
export class Counter extends DurableObject {}
export default {
fetch: (request, env, ctx) => wakuHandler.fetch(request, env, ctx),
};
export const Website = Cloudflare.Website.Waku("Website", {
main: "src/worker-entry.ts",
env: {
COUNTER: Cloudflare.DurableObject("Counter", {
className: "Counter",
}),
},
});