| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { cast } from "effect/Function"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 5 | import * as Command from "../../Command/index.ts"; |
| 6 | import type { Input, InputProps } from "../../Input.ts"; |
| 7 | import * as Namespace from "../../Namespace.ts"; |
| 8 | import * as Output from "../../Output.ts"; |
| 9 | import { renamedFrom } from "../../Rename.ts"; |
| 10 | import { |
| 11 | effectClass, |
| 12 | isYieldableEffectLike, |
| 13 | type YieldableEffectLike, |
| 14 | } from "../../Util/effect.ts"; |
| 15 | import { asEffect } from "../../Util/types.ts"; |
| 16 | import type { Providers } from "../Providers.ts"; |
| 17 | import type { AssetsConfig } from "../Workers/Assets.ts"; |
| 18 | import { |
| 19 | Worker, |
| 20 | type NormalizedBindings, |
| 21 | type WorkerAssetsConfig, |
| 22 | type WorkerBindingProps, |
| 23 | type WorkerProps, |
| 24 | } from "../Workers/Worker.ts"; |
| 25 | import { isContainerDecl } from "../Workers/WorkerAsyncBindings.ts"; |
| 26 | |
| 27 | export interface StaticSiteProps<Bindings extends WorkerBindingProps = {}> |
| 28 | extends |
| 29 | Omit<WorkerProps<Bindings, WorkerAssetsConfig>, "assets" | "dev">, |
| 30 | Omit<Command.BuildProps, "env"> { |
| 31 | /** |
| 32 | * Optional configuration for static asset routing behavior. |
| 33 | * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc. |
| 34 | */ |
| 35 | |
| 36 | assets?: AssetsConfig; |
| 37 | /** |
| 38 | * Local dev configuration. When `alchemy dev` runs, the build command is |
| 39 | * skipped and `command` is spawned as a long-lived child process tied to |
| 40 | * the stack's scope. Alchemy does not proxy or interpret the process — |
| 41 | * the dev server's own URL (e.g. `http://localhost:5173`) is what you |
| 42 | * open in the browser. |
| 43 | * |
| 44 | * @example |
| 45 | * ```typescript |
| 46 | * Cloudflare.Website.StaticSite("App", { |
| 47 | * command: "npm run build", |
| 48 | * outdir: "dist", |
| 49 | * main: "./src/worker.ts", |
| 50 | * dev: { command: "npm run dev" }, |
| 51 | * }); |
| 52 | * ``` |
| 53 | */ |
| 54 | dev?: { |
| 55 | /** |
| 56 | * Shell command to run as the local dev server (e.g. `npm run dev`). |
| 57 | */ |
| 58 | command: string; |
| 59 | /** |
| 60 | * Working directory for {@link command}. Defaults to |
| 61 | * {@link Command.BuildProps.cwd} (the build command's `cwd`), or |
| 62 | * `process.cwd()` if neither is set. |
| 63 | */ |
| 64 | cwd?: string; |
| 65 | /** |
| 66 | * Environment variables for {@link command}, merged on top of |
| 67 | * `process.env`. When set, these replace the top-level `env` for the |
| 68 | * dev process; otherwise the top-level `env` is passed through. |
| 69 | * `Redacted` values stay out of logs and state, so put secrets here |
| 70 | * rather than interpolating them into {@link command}. |
| 71 | */ |
| 72 | env?: Record<string, string | Redacted.Redacted<string>>; |
| 73 | /** |
| 74 | * Override for the `url` output if alchemy fails to detect it from the stdout of the dev command |
| 75 | */ |
| 76 | url?: string; |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | type StaticSiteWorker<Bindings extends WorkerBindingProps> = Worker<{ |
| 81 | [ |
| 82 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 83 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 84 | }>; |
| 85 | |
| 86 | /** |
| 87 | * A Cloudflare Worker that serves static assets built by a shell command. |
| 88 | * |
| 89 | * `StaticSite` runs a build command (e.g. `npm run build`), content-hashes |
| 90 | * the output directory, and deploys the result as a Cloudflare Worker with |
| 91 | * static assets. Use this when your site has its own build step that |
| 92 | * produces a directory of files — Hugo, Zola, Eleventy, or any custom |
| 93 | * pipeline. |
| 94 | * |
| 95 | * For Vite-based projects, prefer `Cloudflare.Website.Vite` which handles |
| 96 | * building automatically. |
| 97 | * |
| 98 | * |
| 99 | * ### Basic Usage |
| 100 | * Point `command` at your build script and `outdir` at where it writes |
| 101 | * output. Alchemy runs the command, hashes the output, and deploys it as |
| 102 | * an assets-only Worker — no Worker code is uploaded, and Cloudflare's |
| 103 | * asset layer serves every request itself. |
| 104 | * |
| 105 | * **Example:** Deploying a Hugo site |
| 106 | * ```typescript |
| 107 | * const site = yield* Cloudflare.Website.StaticSite("Blog", { |
| 108 | * command: "hugo --minify", |
| 109 | * outdir: "public", |
| 110 | * }); |
| 111 | * ``` |
| 112 | * |
| 113 | * Provide `main` to put your own Worker in front of the assets instead. |
| 114 | * The Worker receives an `ASSETS` binding it can delegate to: |
| 115 | * |
| 116 | * ```typescript |
| 117 | * // src/worker.ts |
| 118 | * export default { |
| 119 | * fetch: (request: Request, env: { ASSETS: Fetcher }) => |
| 120 | * env.ASSETS.fetch(request), |
| 121 | * }; |
| 122 | * ``` |
| 123 | * |
| 124 | * **Example:** Custom Worker in front of the assets |
| 125 | * ```typescript |
| 126 | * const site = yield* Cloudflare.Website.StaticSite("Blog", { |
| 127 | * command: "hugo --minify", |
| 128 | * outdir: "public", |
| 129 | * main: "./src/worker.ts", |
| 130 | * }); |
| 131 | * ``` |
| 132 | * |
| 133 | * ### Asset Configuration |
| 134 | * Use `assets` to control how Cloudflare handles routing for |
| 135 | * your static files — HTML handling, not-found behavior, etc. |
| 136 | * |
| 137 | * **Example:** SPA-style routing |
| 138 | * ```typescript |
| 139 | * const site = yield* Cloudflare.Website.StaticSite("App", { |
| 140 | * command: "npm run build", |
| 141 | * outdir: "dist", |
| 142 | * main: "./src/worker.ts", |
| 143 | * assets: { |
| 144 | * htmlHandling: "auto-trailing-slash", |
| 145 | * notFoundHandling: "single-page-application", |
| 146 | * }, |
| 147 | * }); |
| 148 | * ``` |
| 149 | * |
| 150 | * ### Building from a Subdirectory |
| 151 | * Set `cwd` to run the build command in a subdirectory (e.g. a |
| 152 | * monorepo package). `outdir` is resolved relative to `cwd`. |
| 153 | * |
| 154 | * **Example:** Building a frontend in a monorepo |
| 155 | * ```typescript |
| 156 | * const site = yield* Cloudflare.Website.StaticSite("Web", { |
| 157 | * cwd: "apps/web", |
| 158 | * command: "npm run build", |
| 159 | * outdir: "dist", |
| 160 | * main: "apps/web/worker.ts", |
| 161 | * }); |
| 162 | * ``` |
| 163 | * |
| 164 | * ### Custom Rebuild Scope |
| 165 | * By default, all non-gitignored files are hashed to decide whether |
| 166 | * the build should re-run. Use `memo` to narrow the scope. |
| 167 | * |
| 168 | * **Example:** Narrowing the memo scope |
| 169 | * ```typescript |
| 170 | * const site = yield* Cloudflare.Website.StaticSite("Docs", { |
| 171 | * command: "npm run build", |
| 172 | * outdir: "dist", |
| 173 | * main: "./src/worker.ts", |
| 174 | * memo: { |
| 175 | * include: ["content/**", "templates/**", "config.toml"], |
| 176 | * }, |
| 177 | * }); |
| 178 | * ``` |
| 179 | * |
| 180 | * **Example:** Rebuilding when a sibling workspace package changes |
| 181 | * The default scope only hashes files under `cwd` (plus the nearest |
| 182 | * lockfile), so edits to a sibling workspace package the app imports do |
| 183 | * not retrigger the build on their own. Add the sibling's sources with a |
| 184 | * `../` include glob — and keep `lockfile: true`, since providing |
| 185 | * `include` otherwise drops the lockfile from the hash: |
| 186 | * ```typescript |
| 187 | * const site = yield* Cloudflare.Website.StaticSite("Web", { |
| 188 | * cwd: "apps/web", |
| 189 | * command: "npm run build", |
| 190 | * outdir: "dist", |
| 191 | * main: "./src/worker.ts", |
| 192 | * memo: { |
| 193 | * include: ["**\/*", "../../packages/env/src/**"], |
| 194 | * lockfile: true, |
| 195 | * }, |
| 196 | * }); |
| 197 | * ``` |
| 198 | * |
| 199 | * ### Class Form |
| 200 | * Calling `StaticSite` with no arguments returns a constructor you can |
| 201 | * `extend` to declare the Worker as a named class. The class is both |
| 202 | * an `Effect` you can `yield*` to deploy and a type you can reference |
| 203 | * elsewhere — useful when other resources need to bind to this Worker. |
| 204 | * |
| 205 | * **Example:** Declaring a Worker class |
| 206 | * ```typescript |
| 207 | * class Blog extends Cloudflare.Website.StaticSite<Blog>()("Blog", { |
| 208 | * command: "hugo --minify", |
| 209 | * outdir: "public", |
| 210 | * main: "./src/worker.ts", |
| 211 | * }) {} |
| 212 | * |
| 213 | * const site = yield* Blog; |
| 214 | * ``` |
| 215 | * |
| 216 | * @resource |
| 217 | * @product Website |
| 218 | * @category Workers & Compute |
| 219 | */ |
| 220 | export const StaticSite: { |
| 221 | <Self>(): { |
| 222 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 223 | id: string, |
| 224 | propsEff: |
| 225 | | InputProps<StaticSiteProps<Bindings>, "dev"> |
| 226 | | Effect.Effect< |
| 227 | InputProps<StaticSiteProps<Bindings>, "dev">, |
| 228 | never, |
| 229 | Req |
| 230 | >, |
| 231 | ): Effect.Effect<Self, never, Req | Providers> & { |
| 232 | new (): StaticSiteWorker<Bindings>; |
| 233 | }; |
| 234 | }; |
| 235 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 236 | id: string, |
| 237 | propsEff: |
| 238 | | InputProps<StaticSiteProps<Bindings>, "dev"> |
| 239 | | Effect.Effect<InputProps<StaticSiteProps<Bindings>, "dev">, never, Req>, |
| 240 | ): Effect.Effect<StaticSiteWorker<Bindings>, never, Req | Providers>; |
| 241 | } = ((id?: any, propsEff?: any) => |
| 242 | id === undefined |
| 243 | ? (id: string, propsEff: any) => effectClass(makeStaticSite(id, propsEff)) |
| 244 | : makeStaticSite(id, propsEff)) as any; |
| 245 | |
| 246 | const makeStaticSite = < |
| 247 | const Bindings extends WorkerBindingProps = {}, |
| 248 | Req = never, |
| 249 | >( |
| 250 | id: string, |
| 251 | propsEff: |
| 252 | | InputProps<StaticSiteProps<Bindings>, "dev"> |
| 253 | | Effect.Effect<InputProps<StaticSiteProps<Bindings>, "dev">, never, Req>, |
| 254 | ) => |
| 255 | Effect.gen(function* () { |
| 256 | const ctx = yield* AlchemyContext; |
| 257 | const props = yield* asEffect(propsEff); |
| 258 | |
| 259 | // `Dev` and `Build` carry constant logical ids, so they are namespaced |
| 260 | // under `id` to keep two sites on one stack from colliding. Nothing |
| 261 | // else is: the site's own `props` — and the resources its `env` |
| 262 | // bindings declare — must resolve in the CALLER's namespace. Pushing |
| 263 | // the namespace around the whole body instead re-declares a shared |
| 264 | // resource referenced from `env` as a second copy under `<id>/`. |
| 265 | // `Vite` already passes `id` straight through to `Worker`. |
| 266 | |
| 267 | // In dev mode with a dev.command, declare a DevCommand resource so |
| 268 | // the sidecar owns the process lifecycle (survives user-code HMR), |
| 269 | // skip the build, and tell Worker not to start a local instance. |
| 270 | const dev = |
| 271 | ctx.dev && props.dev |
| 272 | ? yield* Command.Dev("Dev", { |
| 273 | command: props.dev.command, |
| 274 | cwd: |
| 275 | props.dev.cwd ?? |
| 276 | (typeof props.cwd === "string" ? props.cwd : undefined), |
| 277 | env: yield* serializeEnv(props.dev.env ?? props.env), |
| 278 | }).pipe( |
| 279 | Namespace.push(id), |
| 280 | Effect.map((d) => |
| 281 | Output.map(d.url, (url) => ({ |
| 282 | url: url ?? props.dev?.url, |
| 283 | })), |
| 284 | ), |
| 285 | ) |
| 286 | : undefined; |
| 287 | |
| 288 | const build = dev |
| 289 | ? undefined |
| 290 | : yield* Command.Build("Build", { |
| 291 | command: props.command, |
| 292 | cwd: props.cwd, |
| 293 | memo: props.memo, |
| 294 | outdir: props.outdir, |
| 295 | env: yield* serializeEnv(props.env), |
| 296 | }).pipe(Namespace.push(id)); |
| 297 | |
| 298 | // Pure-static sites (neither `main` nor `script`) deploy as |
| 299 | // assets-only Workers: no script is uploaded and Cloudflare's asset |
| 300 | // layer serves every request itself. |
| 301 | // |
| 302 | // The Worker's FQN was `<id>/Worker` before #1053 flattened it to |
| 303 | // `<id>`; `renamedFrom` migrates the pre-existing state row to the new |
| 304 | // FQN instead of letting the engine plan a create+delete replacement |
| 305 | // (a new physical name, a torn-down workers.dev URL, and a |
| 306 | // custom-domain handover that crashes the deploy). |
| 307 | return yield* Worker<Bindings, WorkerAssetsConfig, Req>(id, { |
| 308 | ...props, |
| 309 | assets: build |
| 310 | ? cast({ |
| 311 | directory: build.outdir, |
| 312 | hash: build.hash.output, |
| 313 | ...props.assets, |
| 314 | }) |
| 315 | : undefined, |
| 316 | // Opt out of the local Worker in dev when the external DevCommand |
| 317 | // is serving the content. The Worker resource still exists in |
| 318 | // state with a stub Attributes shape. |
| 319 | dev: dev ? { mode: "external", url: dev.url } : undefined, |
| 320 | script: props.script, |
| 321 | }).pipe(renamedFrom(`${id}/Worker`)); |
| 322 | }); |
| 323 | |
| 324 | /** |
| 325 | * Serialize the site's `env` for the build/dev subprocess. The same record |
| 326 | * doubles as the Worker's binding props, so entries may be plain strings, |
| 327 | * `Redacted` secrets, `effect/Config` values, `Output` references, or |
| 328 | * binding Effects: |
| 329 | * |
| 330 | * - strings and `Redacted` values pass through unchanged |
| 331 | * - `Config` (and any other runnable Effect) is resolved here, at stack |
| 332 | * construction — passing it through unresolved would hand the subprocess |
| 333 | * the JSON-serialized `Config` object (`{"_id":"Config"}`) instead of its |
| 334 | * value (#796) |
| 335 | * - `Output` references resolve at reconcile; the resolved value is |
| 336 | * serialized the same way inline values are (an `Output<object>` must |
| 337 | * reach the subprocess as JSON, not `[object Object]`) |
| 338 | * - binding Effects (`~alchemy/Kind`-marked, e.g. a `WorkerLoader`) and |
| 339 | * `Cloudflare.Container` declarations have no env-var representation and |
| 340 | * are dropped |
| 341 | * - remaining plain values (`null`, numbers, JSON objects) are stringified |
| 342 | */ |
| 343 | const serializeEnv = Effect.fn(function* ( |
| 344 | env: Input< |
| 345 | | WorkerBindingProps |
| 346 | | Record<string, string | Redacted.Redacted<string>> |
| 347 | | undefined |
| 348 | >, |
| 349 | ) { |
| 350 | const entries: [string, unknown][] = []; |
| 351 | for (const [k, v] of Object.entries(env ?? {})) { |
| 352 | if (v === undefined) continue; |
| 353 | if (typeof v === "string" || Redacted.isRedacted(v)) { |
| 354 | entries.push([k, v]); |
| 355 | } else if (Output.isOutput(v)) { |
| 356 | entries.push([k, Output.map(v, serializeEnvValue)]); |
| 357 | } else if (isContainerDecl(v)) { |
| 358 | // A `Cloudflare.Container` declaration is Effect-shaped but is a |
| 359 | // binding (DO namespace + ContainerApplication) — yielding it would |
| 360 | // resolve the started-instance tag, which only exists inside a |
| 361 | // Durable Object (#997). Deploy-time only, nothing to expose to the |
| 362 | // build subprocess. |
| 363 | continue; |
| 364 | } else if (isYieldableEffectLike(v)) { |
| 365 | const resolved = serializeEnvValue( |
| 366 | yield* asEffect(v as YieldableEffectLike<unknown, unknown, never>).pipe( |
| 367 | Effect.orDie, |
| 368 | ), |
| 369 | ); |
| 370 | if (resolved === undefined) continue; |
| 371 | entries.push([k, resolved]); |
| 372 | } else if (v !== null && typeof v === "object" && "~alchemy/Kind" in v) { |
| 373 | // A binding Effect (e.g. WorkerLoader) — deploy-time only, nothing to |
| 374 | // expose to the build subprocess. |
| 375 | continue; |
| 376 | } else { |
| 377 | entries.push([k, JSON.stringify(v)]); |
| 378 | } |
| 379 | } |
| 380 | return Object.fromEntries(entries) as Record< |
| 381 | string, |
| 382 | string | Redacted.Redacted<string> |
| 383 | >; |
| 384 | }); |
| 385 | |
| 386 | /** |
| 387 | * Serialize one resolved env value for the build/dev subprocess: strings and |
| 388 | * `Redacted` pass through, everything else becomes JSON. |
| 389 | */ |
| 390 | const serializeEnvValue = ( |
| 391 | value: unknown, |
| 392 | ): string | Redacted.Redacted<string> | undefined => |
| 393 | value === undefined |
| 394 | ? undefined |
| 395 | : typeof value === "string" |
| 396 | ? value |
| 397 | : Redacted.isRedacted(value) |
| 398 | ? (value as Redacted.Redacted<string>) |
| 399 | : JSON.stringify(value); |