1import * as Effect from "effect/Effect";
2import { cast } from "effect/Function";
3import * as Redacted from "effect/Redacted";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import * as Command from "../../Command/index.ts";
6import type { Input, InputProps } from "../../Input.ts";
7import * as Namespace from "../../Namespace.ts";
8import * as Output from "../../Output.ts";
9import { renamedFrom } from "../../Rename.ts";
10import {
11 effectClass,
12 isYieldableEffectLike,
13 type YieldableEffectLike,
14} from "../../Util/effect.ts";
15import { asEffect } from "../../Util/types.ts";
16import type { Providers } from "../Providers.ts";
17import type { AssetsConfig } from "../Workers/Assets.ts";
18import {
19 Worker,
20 type NormalizedBindings,
21 type WorkerAssetsConfig,
22 type WorkerBindingProps,
23 type WorkerProps,
24} from "../Workers/Worker.ts";
25import { isContainerDecl } from "../Workers/WorkerAsyncBindings.ts";
26
27export 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
80type 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 */
220export 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
246const 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 */
343const 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 */
390const 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);