| 1 | # @alchemy.run/frontend-frameworks/waku |
| 2 | |
| 3 | Wrangler-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 |
| 6 | dependency and no wrangler.json/toml read or written**, and with the deploy |
| 7 | platform decoupled behind a **deploy target** passed as a value. Cloudflare |
| 8 | Workers 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 | |
| 13 | The package separates two concerns (see |
| 14 | `packages/frontend-frameworks/src/core/README.md` → "Architecture: frameworks × deploy |
| 15 | targets" 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 | |
| 24 | A future platform (e.g. AWS Lambda) is a new target module implementing the |
| 25 | same `WakuTarget` hooks — zero changes to the framework half or to |
| 26 | framework-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 |
| 35 | interface 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 | |
| 44 | Why 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 | |
| 61 | Default export: `(config?: CloudflareVitePluginOptions) => WakuTarget` — the |
| 62 | factory 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 | |
| 83 | The target's `config` is the cloudflare vite plugin's options: worker |
| 84 | name/bindings/assets behavior, `compatibilityDate` / `compatibilityFlags` |
| 85 | (defaulted to include `nodejs_als` — required by waku core — unless the |
| 86 | user's flags already provide AsyncLocalStorage via `nodejs_als`, |
| 87 | `nodejs_compat`, or `nodejs_compat_v2`; full `nodejs_compat` only if user |
| 88 | dependencies need it), and (from the alchemy source provider) the in-memory |
| 89 | dev worker wiring. `main` and `viteEnvironments` are pinned and cannot be |
| 90 | overridden. |
| 91 | |
| 92 | ## How `build` works |
| 93 | |
| 94 | Replicates waku's `runBuild` with zero CLI involvement: |
| 95 | |
| 96 | 1. Load the _project's_ `vite`, `waku/internals`, `waku/vite-plugins` |
| 97 | (`loadProjectModule` — the project's installed copies are the ones |
| 98 | driven, never ours). |
| 99 | 2. 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"`. |
| 105 | 3. `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. |
| 118 | 4. 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. |
| 126 | 5. 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 | |
| 130 | If the target defines a wholesale `build` hook, the framework delegates the |
| 131 | entire production build to it instead (the OpenNext-style takeover; no waku |
| 132 | orchestration runs). |
| 133 | |
| 134 | ## How `dev` works |
| 135 | |
| 136 | Replicates waku's `runDev`: `vite.createServer({ configFile: false, plugins: |
| 137 | [unstable_combinedPlugins(config)] })` with the target's plugins inside |
| 138 | waku'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 |
| 140 | request bridge middleware never fires because the workerd proxy middleware |
| 141 | registers ahead of it. A `port` passed to `dev()` is strict; the |
| 142 | options-level `port` is a non-strict default. |
| 143 | |
| 144 | ## Options |
| 145 | |
| 146 | ```ts |
| 147 | import wakuFramework, { make } from "@alchemy.run/frontend-frameworks/waku"; |
| 148 | |
| 149 | make({ |
| 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 | |
| 167 | Target forms accepted by `target` (resolved per operation with |
| 168 | framework-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 |
| 190 | import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options"; |
| 191 | |
| 192 | export 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 | |
| 209 | Or the typed factory form for waku-specific options: |
| 210 | |
| 211 | ```ts |
| 212 | import wakuFramework from "@alchemy.run/frontend-frameworks/waku"; |
| 213 | |
| 214 | framework: (options) => wakuFramework({ ...options, port: 3101, waku: { srcDir: "app" } }), |
| 215 | ``` |
| 216 | |
| 217 | The deprecated top-level `vite` / `miniflare` fields keep working as aliases |
| 218 | for `target.cloudflare.worker` / `target.cloudflare.preview`. |
| 219 | |
| 220 | ## SSG runs inside workerd |
| 221 | |
| 222 | The SSG step of `buildApp` boots a `vite.preview` server over the **same** |
| 223 | resolved config as the build, and the cloudflare vite plugin's |
| 224 | `configurePreviewServer` hook serves the freshly built worker through |
| 225 | workerd (`cloudflare-runtime`'s `Runtime.start` over the built server |
| 226 | modules + 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 | |
| 239 | This 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 | |
| 257 | Everything this package touches in waku is `unstable_`-prefixed |
| 258 | (`unstable_combinedPlugins`, `unstable_resolveConfig`, |
| 259 | `unstable_createServerEntryAdapter`, `unstable_startPreviewServer`, the |
| 260 | adapter 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 |
| 262 | the adapter fork against upstream's `packages/waku/src/adapters/cloudflare.ts`, |
| 263 | the 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) |