1# @alchemy.run/frontend-frameworks/waku
2
3Wrangler-free [Waku](https://waku.gg) integration. Implements
4`@alchemy.run/frontend-frameworks/core`'s `Framework` service — programmatic
5`build` / `dev` over waku's public `unstable_` API — with **no wrangler
6dependency and no wrangler.json/toml read or written**, and with the deploy
7platform decoupled behind a **deploy target** passed as a value. Cloudflare
8Workers is the built-in (and default) target, shipped at
9`@alchemy.run/frontend-frameworks/waku/cloudflare`.
10
11## Architecture: framework half × deploy-target half
12
13The package separates two concerns (see
14`packages/frontend-frameworks/src/core/README.md` → "Architecture: frameworks × deploy
15targets" for the full doctrine):
16
17| Module | Role | Platform imports |
18| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
19| `.` (`src/waku/index.ts`, `src/waku/Waku.ts`) | **Framework half**: waku config synthesis, programmatic build/dev orchestration, `BuildOutput` collection, deploy-target resolution | **None** (enforced by a unit test) |
20| `./cloudflare` (`src/waku/cloudflare.ts`) | **Cloudflare target**: `WakuTarget` implementation — injects `@alchemy.run/cloudflare-runtime/vite`, selects the `./adapter` fork | `cloudflare-vite-plugin` |
21| `./adapter` (`src/waku/adapter.ts`) | Wrangler-free fork of `waku/adapters/cloudflare`; bundled _into the worker_ by waku's vite pipeline | worker-side only |
22| `./source` (`src/waku/source.ts`) | Alchemy `Cloudflare.Worker` source provider (`{ provider: "@alchemy.run/frontend-frameworks/waku/source" }`) wrapping the framework with the cloudflare target | `cloudflare-runtime` (types) |
23
24A future platform (e.g. AWS Lambda) is a new target module implementing the
25same `WakuTarget` hooks — zero changes to the framework half or to
26framework-core.
27
28### The `WakuTarget` contract
29
30`WakuTarget` extends framework-core's generic `DeployTarget` (opaque
31`config`, `bundle` conditions/externals, optional wholesale `build`,
32`finish`, `serve`) with the two hooks waku's toolchain needs:
33
34```ts
35interface WakuTarget<Config = unknown> extends DeployTarget<Config> {
36 /** Absolute path of the adapter module selected via `unstable_adapter`. */
37 adapter(context: WakuTargetContext): Effect<string, DeployTargetError>;
38 /** Vite plugins injected FIRST inside waku's `vite.plugins` (dev + build). */
39 vitePlugins(context: WakuTargetContext): Effect<PluginOption[], DeployTargetError>;
40}
41// WakuTargetContext = { root, wakuDirectory, phase: "build" | "dev" }
42```
43
44Why exactly these two seams:
45
46- **`adapter` is mandatory.** If `unstable_adapter` is left unset (and no
47 `CLOUDFLARE`/`WORKERS_CI` env var is set), waku silently selects
48 `waku/adapters/node`, which cannot run on a non-Node runtime — every
49 request 500s opaquely. The framework half always pins the target's
50 adapter.
51- **`vitePlugins` injection position is load-bearing.** The target's plugins
52 go _inside_ waku's `config.vite.plugins` (waku's `extraPlugins` places
53 them first, ahead of waku's own environments plugin) — the position
54 upstream documents for `@cloudflare/vite-plugin`, and the only one where
55 the target's request-proxy middleware registers before waku's Node request
56 bridge (which assumes a runnable Node environment and breaks against a
57 workerd-backed one).
58
59### The cloudflare target (`@alchemy.run/frontend-frameworks/waku/cloudflare`)
60
61Default export: `(config?: CloudflareVitePluginOptions) => WakuTarget` — the
62factory shape `resolveDeployTarget` expects from a target module. It:
63
64- resolves `adapter` to this package's `./adapter` fork — a ~200-line fork of
65 `waku/adapters/cloudflare` (built entirely on public
66 `waku/adapter-builders` + `waku/internals` exports) whose single functional
67 change is `buildEnhancers: []`, dropping
68 `waku/adapters/cloudflare-build-enhancer` — the **sole wrangler-file
69 writer in waku** (it scrapes/writes `wrangler.jsonc`,
70 `dist/server/wrangler.json`, `.wrangler/deploy/config.json`). Everything
71 else (Hono app, `cloudflare:workers` env access, SSG via the preview
72 server) is behavior-identical to upstream.
73- produces `@alchemy.run/cloudflare-runtime/vite` with `main` pinned to
74 waku's rsc worker entry (`waku/dist/lib/vite-entries/entry.server.js`,
75 resolved from the _project's_ waku install — the deep path is not in
76 waku's exports map) and the `viteEnvironments: { entry: "rsc", children:
77["ssr"] }` topology. Pinning `main` is mandatory: waku's rsc environment
78 declares **two** rolldown inputs (`index` + `build`) while the plugin
79 asserts exactly one entry.
80- declares `bundle: { conditions: ["workerd", "worker", "module",
81"browser"], external: ["cloudflare:"] }`.
82
83The target's `config` is the cloudflare vite plugin's options: worker
84name/bindings/assets behavior, `compatibilityDate` / `compatibilityFlags`
85(defaulted to include `nodejs_als` — required by waku core — unless the
86user's flags already provide AsyncLocalStorage via `nodejs_als`,
87`nodejs_compat`, or `nodejs_compat_v2`; full `nodejs_compat` only if user
88dependencies need it), and (from the alchemy source provider) the in-memory
89dev worker wiring. `main` and `viteEnvironments` are pinned and cannot be
90overridden.
91
92## How `build` works
93
94Replicates waku's `runBuild` with zero CLI involvement:
95
961. Load the _project's_ `vite`, `waku/internals`, `waku/vite-plugins`
97 (`loadProjectModule` — the project's installed copies are the ones
98 driven, never ours).
992. Resolve the deploy target, run its hooks, and synthesize waku's `Config`
100 in memory (`unstable_resolveConfig`): the target's adapter as
101 `unstable_adapter`, the target's plugins first in `vite.plugins`,
102 `waku`/`hono` deduped (so the adapter fork — which lives outside the
103 project — resolves the project's copies), the documented rsc/ssr
104 `optimizeDeps.include` entries, and `platform: "neutral"`.
1053. `vite.createBuilder({ configFile: false, plugins:
106[unstable_combinedPlugins(config), collectorPlugin] }).buildApp()`, with
107 `process.env.NODE_ENV` set first (waku's environmentsPlugin bakes it into
108 `define`) and `globalThis.__WAKU_START_PREVIEW_SERVER__` set — the SSG
109 step of `buildApp` calls `unstable_startPreviewServer`, which throws
110 without it. The preview server resolves the **same** waku config as the
111 build (upstream parity: waku's CLI reuses the loaded config's plugin
112 instances for both `createBuilder` and `vite.preview`), so the cloudflare
113 target's `configurePreviewServer` hook serves the freshly built worker
114 through workerd and **SSG renders inside workerd with real bindings** —
115 a top-level `import { env } from "cloudflare:workers"` in a page module
116 is fine. Waku's adapter registers its Node fallback middleware behind the
117 proxy; it only fires for targets without a preview mode.
1184. Collect the `BuildOutput` with framework-core's collector, using a
119 **post-`buildApp` disk re-read**: waku writes
120 `__waku_build_metadata.js` and prunes static-only server chunks _after_
121 the bundler's `writeBundle` (in-memory captures alone are non-bootable —
122 the worker imports the metadata module). Entry selection is pinned to
123 waku's `server/index.js` (the adapter's `ExportedHandler`), because the
124 rsc environment emits multiple entry chunks (`index`, `build`, and any
125 wrapped worker entry) and last-entry-wins picking is nondeterministic.
1265. Apply the target's `finish` pass, if it defines one (the cloudflare
127 target does not), and return the `BuildOutput` **in-memory only** —
128 persistence (`dist/build.json`) is the e2e harness's concern.
129
130If the target defines a wholesale `build` hook, the framework delegates the
131entire production build to it instead (the OpenNext-style takeover; no waku
132orchestration runs).
133
134## How `dev` works
135
136Replicates waku's `runDev`: `vite.createServer({ configFile: false, plugins:
137[unstable_combinedPlugins(config)] })` with the target's plugins inside
138waku's config. With the cloudflare target, the rsc environment runs in
139**workerd** (module-runner) with in-memory bindings and HMR; waku's own Node
140request bridge middleware never fires because the workerd proxy middleware
141registers ahead of it. A `port` passed to `dev()` is strict; the
142options-level `port` is a non-strict default.
143
144## Options
145
146```ts
147import wakuFramework, { make } from "@alchemy.run/frontend-frameworks/waku";
148
149make({
150 /** Deploy target: value | (config) => target | module specifier |
151 * harness carriage. Default: "@alchemy.run/frontend-frameworks/waku/cloudflare",
152 * loaded from the PROJECT's node_modules. */
153 target: cloudflareTarget({ compatibilityDate: "2026-03-10" }),
154 /** @deprecated — the pre-target alias for the target's config
155 * (the harness's top-level `vite` field). */
156 vite: undefined,
157 /** Extra waku config merged into the in-memory resolveConfig input
158 * (srcDir, distDir, basePath, vite, ...). */
159 waku: { srcDir: "src" },
160 /** Project root (default: process.cwd()). */
161 root: undefined,
162 /** Non-strict default dev port. */
163 port: 3101,
164});
165```
166
167Target forms accepted by `target` (resolved per operation with
168framework-core's `resolveDeployTarget`):
169
170- a **target value** — full type safety over the config; build it yourself:
171
172 ```ts
173 import cloudflareTarget from "@alchemy.run/frontend-frameworks/waku/cloudflare";
174 make({ target: cloudflareTarget({ compatibilityDate: "2026-03-10", worker: { name: "app" } }) });
175 ```
176
177- a **factory** `(config) => WakuTarget` — applied to the carried config;
178- a **module specifier string** — loaded from the _project's_
179 `node_modules`; the module default-exports (or names `target`) a value or
180 factory;
181- the **e2e harness carriage** `{ name?, cloudflare?: { worker } }` — the
182 shape the harness's options spread hands the framework factory; selects
183 the default target module with `target.cloudflare.worker ?? vite` as its
184 config.
185
186## Usage (e2e harness)
187
188```ts
189// e2e.config.ts
190import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options";
191
192export default Options.make({
193 framework: "@alchemy.run/frontend-frameworks/waku",
194 target: {
195 cloudflare: {
196 worker: {
197 compatibilityDate: "2026-03-10",
198 compatibilityFlags: ["nodejs_als"],
199 worker: { name: "my-waku-app", bindings: [] },
200 },
201 preview: {
202 /* miniflare options for `e2e preview` / Playwright "live" */
203 },
204 },
205 },
206});
207```
208
209Or the typed factory form for waku-specific options:
210
211```ts
212import wakuFramework from "@alchemy.run/frontend-frameworks/waku";
213
214framework: (options) => wakuFramework({ ...options, port: 3101, waku: { srcDir: "app" } }),
215```
216
217The deprecated top-level `vite` / `miniflare` fields keep working as aliases
218for `target.cloudflare.worker` / `target.cloudflare.preview`.
219
220## SSG runs inside workerd
221
222The SSG step of `buildApp` boots a `vite.preview` server over the **same**
223resolved config as the build, and the cloudflare vite plugin's
224`configurePreviewServer` hook serves the freshly built worker through
225workerd (`cloudflare-runtime`'s `Runtime.start` over the built server
226modules + the client assets directory). Consequences:
227
228- A **top-level** `import { env } from "cloudflare:workers"` in a page
229 module works — waku prerenders every static page through the worker, not
230 the Node process, so the `cloudflare:` scheme resolves natively. (In a
231 build without a platform vite plugin, upstream renders SSG through the
232 adapter's Node fallback middleware and the same import breaks with
233 `ERR_UNSUPPORTED_ESM_URL_SCHEME`.)
234- Static pages that read Cloudflare bindings at build time render **with**
235 them — the preview worker is configured from the target's plugin options
236 (bindings, compatibility date/flags, assets), so the emitted HTML bakes in
237 real binding values.
238
239This matches what upstream's `@cloudflare/vite-plugin` provides via its own
240`configurePreviewServer` (miniflare over the built output).
241
242## Limitations
243
244- **Durable Objects cannot be defined in a waku app** (upstream
245 limitation) — use service bindings to a separate worker.
246- **Waku assumes `process.cwd()` is the project root** in places (html-shell
247 input, relative rolldown inputs). The alchemy source provider compensates
248 by running build/dev under a cwd lock; the e2e harness runs from the
249 fixture directory anyway.
250- **`nodejs_als` is required** (AsyncLocalStorage); waku core needs nothing
251 more from nodejs_compat. The cloudflare target defaults
252 `compatibilityFlags` to include `nodejs_als` when the user's flags provide
253 no ALS (`nodejs_als`, `nodejs_compat`, or `nodejs_compat_v2`).
254
255## Version pinning
256
257Everything this package touches in waku is `unstable_`-prefixed
258(`unstable_combinedPlugins`, `unstable_resolveConfig`,
259`unstable_createServerEntryAdapter`, `unstable_startPreviewServer`, the
260adapter system itself) and waku is in beta — `waku` is pinned exactly
261(`1.0.0-beta.7`). Treat version bumps as deliberate migrations: re-verify
262the adapter fork against upstream's `packages/waku/src/adapters/cloudflare.ts`,
263the two-input rsc environment merge, and the preview-server global.
264
265## Upstream references
266
267- Waku: [`39e868f2638c27de9635c68ebf965e14a4112e9b`](https://github.com/wakujs/waku/tree/39e868f2638c27de9635c68ebf965e14a4112e9b)