1import * as Effect from "effect/Effect";
2import type { MemoOptions } from "../../Command/Memo.ts";
3import type { InputProps } from "../../Input.ts";
4import { effectClass } from "../../Util/effect.ts";
5import type { Providers } from "../Providers.ts";
6import type { AssetsConfig } from "../Workers/Assets.ts";
7import {
8 Worker,
9 type NormalizedBindings,
10 type WorkerAssetsConfig,
11 type WorkerBindingProps,
12 type WorkerProps,
13} from "../Workers/Worker.ts";
14
15/**
16 * The specifier of the Waku source-provider module. The package must be
17 * installed in the user's project — `loadSource` fails with a
18 * `SourceProviderError` naming it otherwise.
19 */
20const WAKU_SOURCE_PROVIDER = "@alchemy.run/frontend-frameworks/waku/source";
21
22export interface WakuProps<
23 Bindings extends WorkerBindingProps = {},
24> extends Omit<
25 WorkerProps<Bindings>,
26 "vite" | "main" | "assets" | "source" | "script" | "bundle"
27> {
28 /**
29 * Overrides the module that becomes the deployed Worker entry. Relative
30 * paths resolve from {@link rootDir}.
31 *
32 * By default Waku's own RSC server entry is deployed. Point `main` at a
33 * custom module when the deployed Worker must export more than Waku's
34 * fetch handler — e.g. Durable Object classes or additional handlers.
35 * The custom entry wraps Waku's handler by importing it from
36 * `virtual:waku/server-entry` and re-exports the extras:
37 *
38 * ```typescript
39 * // src/worker-entry.ts
40 * import wakuHandler from "virtual:waku/server-entry";
41 * export class Counter extends DurableObject {}
42 * export default {
43 * fetch: (request, env, ctx) => wakuHandler.fetch(request, env, ctx),
44 * };
45 * ```
46 */
47 main?: string;
48 /**
49 * Root directory of the Waku project. Defaults to the process working
50 * directory.
51 */
52 rootDir?: string;
53 /**
54 * Controls which files feed the content hash that decides whether a
55 * rebuild is needed. By default every non-gitignored file under
56 * {@link rootDir} is hashed, plus the nearest package-manager lockfile.
57 * Provide `include`/`exclude` globs to narrow the scope.
58 */
59 memo?: MemoOptions;
60 /**
61 * Optional configuration for static asset routing behavior.
62 * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc.
63 *
64 * Waku links SSG pages without trailing slashes (`/about`), so
65 * `htmlHandling` defaults to `"drop-trailing-slash"` — the prerendered
66 * `about/index.html` serves directly at `/about` instead of 307-redirecting
67 * to `/about/`. Set `htmlHandling` explicitly to override.
68 *
69 * @default { htmlHandling: "drop-trailing-slash" }
70 */
71 assets?: AssetsConfig;
72 /**
73 * Waku config overrides merged over the project's `waku.config.*`
74 * (per-key; deploy-time values win). For values that vary by stage or
75 * come from other resources — static config belongs in `waku.config.*`.
76 */
77 waku?: {
78 /**
79 * Waku `srcDir` (relative to {@link rootDir}).
80 * @default "src"
81 */
82 srcDir?: string;
83 /**
84 * Waku `distDir` (relative to {@link rootDir}). If `waku.config.*`
85 * customizes `distDir`, mirror it here (or add it to `memo.exclude`)
86 * so the build output stays excluded from the rebuild-deciding input
87 * hash.
88 * @default "dist"
89 */
90 distDir?: string;
91 /**
92 * Waku `basePath`.
93 * @default "/"
94 */
95 basePath?: string;
96 };
97}
98
99// These options are inspected while constructing the Worker. Resolve them in
100// the outer props Effect; pass-through properties can remain deferred Inputs.
101type WakuInput<Bindings extends WorkerBindingProps> = InputProps<
102 WakuProps<Bindings>,
103 "compatibility" | "assets" | "waku"
104>;
105
106/**
107 * A Cloudflare Worker deployed from a [Waku](https://waku.gg) project.
108 *
109 * `Waku` builds the project programmatically — no `waku.config.ts` edits,
110 * no Wrangler configuration, and no build command required. A project's
111 * `waku.config.*` loads natively (same as waku's CLI) and is where all
112 * Waku configuration (`srcDir`, `distDir`, `basePath`, ...) lives; the
113 * `waku` prop overrides it per key at deploy time.
114 * `unstable_adapter` is owned by Alchemy and must not be set. Note that a
115 * standalone `vite.config.ts` is NOT loaded (same as waku's CLI) — Vite
116 * config belongs in `waku.config.*`'s `vite` field. The RSC server
117 * bundle deploys as the Worker script and the client output (including
118 * SSG-prerendered pages) deploys as static assets.
119 *
120 * Requires the `@alchemy.run/frontend-frameworks` package to be installed in
121 * your project; the integration is loaded from its `/waku` export. Input files
122 * are content-hashed
123 * (respecting `.gitignore` by default) so unchanged projects skip the
124 * build and deploy entirely.
125 *
126 * Waku's server runtime uses `AsyncLocalStorage`, so the `nodejs_als`
127 * compatibility flag is enabled automatically when your compatibility
128 * flags include neither `nodejs_als` nor `nodejs_compat`. SSG pages are
129 * served at their extensionless URLs (`/about`) via the default
130 * `drop-trailing-slash` asset handling.
131 *
132 *
133 * ### Deploying a Waku Site
134 * A single call builds the project and deploys the RSC server bundle plus
135 * the client assets — no configuration required.
136 *
137 * **Example:** Waku site
138 * ```typescript
139 * const site = yield* Cloudflare.Website.Waku("Site");
140 * ```
141 *
142 * **Example:** Waku project in a subdirectory
143 * ```typescript
144 * const site = yield* Cloudflare.Website.Waku("Site", {
145 * rootDir: "apps/web",
146 * });
147 * ```
148 *
149 * ### Waku Config Overrides
150 * Waku configuration lives in your `waku.config.*`. The `waku` prop
151 * overrides it per key at deploy time — for values that vary by stage
152 * or come from other resources.
153 *
154 * **Example:** Stage-dependent base path
155 * ```typescript
156 * const site = yield* Cloudflare.Website.Waku("Site", {
157 * waku: {
158 * basePath: "/docs/",
159 * },
160 * });
161 * ```
162 *
163 * ### Bindings
164 * Pass resources through `env` like any other Worker. Server components
165 * and API routes read them from the `cloudflare:workers` env at request
166 * time. Prefer a guarded dynamic import in page modules — Waku's SSG step
167 * renders static pages in Node, where a top-level
168 * `import { env } from "cloudflare:workers"` cannot resolve.
169 *
170 * **Example:** Binding an R2 bucket
171 * ```typescript
172 * const bucket = yield* Cloudflare.R2.Bucket("Uploads");
173 *
174 * const site = yield* Cloudflare.Website.Waku("Site", {
175 * env: {
176 * UPLOADS: bucket,
177 * },
178 * });
179 * ```
180 *
181 * ### Custom Worker Entry
182 * By default the deployed Worker entry is Waku's own RSC server entry.
183 * When the Worker must export more than Waku's fetch handler — Durable
184 * Object classes, additional handlers — point `main` at your own module
185 * that wraps Waku's handler (imported from `virtual:waku/server-entry`)
186 * and re-exports the extras.
187 *
188 * **Example:** Custom entry hosting a Durable Object
189 * ```typescript
190 * // src/worker-entry.ts
191 * // import wakuHandler from "virtual:waku/server-entry";
192 * // export class Counter extends DurableObject { ... }
193 * // export default { fetch: (req, env, ctx) => wakuHandler.fetch(req, env, ctx) };
194 *
195 * const site = yield* Cloudflare.Website.Waku("Site", {
196 * main: "src/worker-entry.ts",
197 * env: {
198 * COUNTER: Cloudflare.DurableObject("Counter", {
199 * className: "Counter",
200 * }),
201 * },
202 * });
203 * ```
204 *
205 * ### Custom Rebuild Scope
206 * By default, every non-gitignored file is hashed to decide whether a
207 * rebuild is needed. Use `memo` to narrow the scope when your project has
208 * large directories that don't affect the build output.
209 *
210 * **Example:** Narrowing the memo scope
211 * ```typescript
212 * const site = yield* Cloudflare.Website.Waku("Site", {
213 * memo: {
214 * include: ["src/**", "public/**", "package.json"],
215 * },
216 * });
217 * ```
218 *
219 * ### Class Form
220 * Calling `Waku` with no arguments returns a constructor you can `extend`
221 * to declare the Worker as a named class. The class is both an `Effect`
222 * you can `yield*` to deploy and a type you can reference elsewhere —
223 * useful when other resources need to bind to this Worker.
224 *
225 * **Example:** Declaring a Waku Worker class
226 * ```typescript
227 * class Site extends Cloudflare.Website.Waku<Site>()("Site") {}
228 *
229 * const site = yield* Site;
230 * ```
231 *
232 * @resource
233 * @product Website
234 * @category Workers & Compute
235 */
236export const Waku: {
237 <Self>(): {
238 <const Bindings extends WorkerBindingProps = {}, Req = never>(
239 id: string,
240 propsEff?:
241 | WakuInput<Bindings>
242 | Effect.Effect<WakuInput<Bindings>, never, Req>,
243 ): Effect.Effect<Self, never, Req | Providers> & {
244 new (): Worker<{
245 [
246 binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig>
247 ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding];
248 }>;
249 };
250 };
251 <const Bindings extends WorkerBindingProps = {}, Req = never>(
252 id: string,
253 propsEff?:
254 | WakuInput<Bindings>
255 | Effect.Effect<WakuInput<Bindings>, never, Req>,
256 ): Effect.Effect<
257 Worker<{
258 [
259 binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig>
260 ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding];
261 }>,
262 never,
263 Req | Providers
264 >;
265} = (<const Bindings extends WorkerBindingProps = {}, Req = never>(
266 id?: string,
267 propsEff?:
268 | WakuInput<Bindings>
269 | Effect.Effect<WakuInput<Bindings>, never, Req>,
270) =>
271 id === undefined
272 ? <const Bindings extends WorkerBindingProps = {}, Req = never>(
273 id: string,
274 propsEff?:
275 | WakuInput<Bindings>
276 | Effect.Effect<WakuInput<Bindings>, never, Req>,
277 ) => effectClass(Waku(id, propsEff))
278 : Worker(
279 id,
280 Effect.map(
281 Effect.isEffect(propsEff) ? propsEff : Effect.succeed(propsEff),
282 (props) => ({
283 ...props,
284 compatibility: {
285 ...props?.compatibility,
286 // Waku's server runtime needs AsyncLocalStorage — default to
287 // nodejs_als when the user hasn't enabled it (or the broader
288 // nodejs_compat) themselves.
289 flags:
290 props?.compatibility?.flags?.includes("nodejs_als") ||
291 props?.compatibility?.flags?.includes("nodejs_compat")
292 ? props.compatibility!.flags
293 : [...(props?.compatibility?.flags ?? []), "nodejs_als"],
294 },
295 // Waku links SSG pages without trailing slashes; serve
296 // `about/index.html` at `/about` directly instead of redirecting.
297 assets: {
298 htmlHandling: "drop-trailing-slash" as const,
299 ...props?.assets,
300 },
301 main: undefined!,
302 source: {
303 provider: WAKU_SOURCE_PROVIDER,
304 devMode: "server",
305 rootDir: props?.rootDir,
306 options: {
307 rootDir: props?.rootDir,
308 // Custom worker entry (wraps waku's handler via
309 // `virtual:waku/server-entry`); resolved against `rootDir`
310 // by the source provider.
311 main: props?.main,
312 // Deploy-time waku config overrides, merged over the
313 // project's `waku.config.*` by the source provider.
314 srcDir: props?.waku?.srcDir,
315 distDir: props?.waku?.distDir,
316 basePath: props?.waku?.basePath,
317 memo: props?.memo,
318 },
319 },
320 }),
321 ),
322 )) as any;