| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 3 | import type { InputProps } from "../../Input.ts"; |
| 4 | import { effectClass } from "../../Util/effect.ts"; |
| 5 | import type { Providers } from "../Providers.ts"; |
| 6 | import type { AssetsConfig } from "../Workers/Assets.ts"; |
| 7 | import { |
| 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 | */ |
| 20 | const WAKU_SOURCE_PROVIDER = "@alchemy.run/frontend-frameworks/waku/source"; |
| 21 | |
| 22 | export 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. |
| 101 | type 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 | */ |
| 236 | export 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; |