1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import type * as Redacted from "effect/Redacted";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import * as Command from "../../Command/index.ts";
6import type { MemoOptions } from "../../Command/Memo.ts";
7import * as Namespace from "../../Namespace.ts";
8import * as Output from "../../Output.ts";
9import { ProviderModePolicy } from "../../ProviderMode.ts";
10import { initialCwd } from "../../Util/Node.ts";
11import { loadFrontendCore } from "../../Website/FrontendCore.ts";
12import { Service } from "../Run/Service.ts";
13import {
14 type FrameworkSite,
15 type WebsiteAssetsProps,
16 type WebsiteServiceProps,
17 staticConfigFromAssets,
18 websiteServiceProps,
19} from "./FrameworkSite.ts";
20
21export interface StaticSiteProps extends WebsiteServiceProps {
22 /**
23 * Path to the local site directory (working directory for
24 * {@link build.command}).
25 * @default "."
26 */
27 path?: string;
28 /**
29 * Build executed before deploy.
30 */
31 build: {
32 /** Shell command that produces the site (e.g. `"hugo --minify"`). */
33 command: string;
34 /** Directory the command writes, relative to {@link path}. */
35 output: string;
36 /** Environment variables for the build command. */
37 env?: Record<string, string | Redacted.Redacted<string>>;
38 };
39 /**
40 * Controls which files are hashed to decide whether the build re-runs.
41 * @default true
42 */
43 memo?: MemoOptions | boolean;
44 /**
45 * Process environment for the hosted static server.
46 */
47 env?: Record<string, string | Redacted.Redacted<string>>;
48 /**
49 * Miss handling for the generated file server.
50 */
51 assets?: WebsiteAssetsProps;
52 /**
53 * Local dev configuration. When `alchemy dev` runs with `dev.command`,
54 * the build is skipped and `command` is spawned as a long-lived child.
55 */
56 dev?: {
57 /**
58 * Shell command to run as the local dev server (e.g. `npm run dev`).
59 */
60 command: string;
61 /**
62 * Working directory for {@link command}. Defaults to {@link path}.
63 */
64 cwd?: string;
65 /**
66 * Environment variables for {@link command}.
67 */
68 env?: Record<string, string | Redacted.Redacted<string>>;
69 /**
70 * Override for the `url` output if alchemy fails to detect it from
71 * stdout of the dev command.
72 */
73 url?: string;
74 };
75}
76
77/**
78 * Deploy a static site built by a shell command to Cloud Run.
79 *
80 * `StaticSite` runs a build command (e.g. `npm run build` / `hugo`),
81 * content-hashes the output directory, and deploys a Cloud Run service
82 * whose image is a tiny Node file server over those files (plus
83 * `/health`). Use this when the site has its own build step — Hugo, Zola,
84 * Eleventy, or any custom pipeline.
85 *
86 * For Vite-based projects, prefer `GCP.Website.Vite`. For a bucket-backed
87 * site behind a CDN, compose `GCP.Storage.Bucket` with a load balancer
88 * instead (see the `gcp-static-site` example).
89 *
90 * `Build` / `Dev` use constant logical ids under `Namespace.push(id)`.
91 * The Cloud Run service stays in the caller namespace.
92 *
93 *
94 * ### Basic Usage
95 * **Example:** Deploying a Hugo site
96 * ```typescript
97 * const site = yield* GCP.Website.StaticSite("Blog", {
98 * build: { command: "hugo --minify", output: "public" },
99 * });
100 * ```
101 *
102 * **Example:** SPA-style routing
103 * ```typescript
104 * const site = yield* GCP.Website.StaticSite("App", {
105 * build: { command: "npm run build", output: "dist" },
106 * assets: { notFoundHandling: "single-page-application" },
107 * });
108 * ```
109 *
110 * ### Building from a Subdirectory
111 * **Example:** Building a frontend in a monorepo
112 * ```typescript
113 * const site = yield* GCP.Website.StaticSite("Web", {
114 * path: "apps/web",
115 * build: { command: "npm run build", output: "dist" },
116 * });
117 * ```
118 *
119 * ### Cloud Run Settings
120 * **Example:** Private site with a warm instance
121 * ```typescript
122 * const site = yield* GCP.Website.StaticSite("Internal", {
123 * build: { command: "npm run build", output: "dist" },
124 * public: false,
125 * scaling: { minInstanceCount: 1 },
126 * });
127 * ```
128 *
129 * ### Local Development
130 * **Example:** External dev command
131 * ```typescript
132 * const site = yield* GCP.Website.StaticSite("App", {
133 * build: { command: "npm run build", output: "dist" },
134 * dev: { command: "npm run dev" },
135 * });
136 * ```
137 *
138 * @resource
139 * @product Website
140 */
141export const StaticSite = (id: string, props: StaticSiteProps) =>
142 Effect.gen(function* () {
143 const ctx = yield* AlchemyContext;
144 const remoted = yield* ProviderModePolicy;
145 const isLocal = ctx.dev && remoted !== true;
146 const path = yield* Path.Path;
147
148 if (isLocal && props.dev) {
149 const dev = yield* Command.Dev("Dev", {
150 command: props.dev.command,
151 cwd: props.dev.cwd ?? props.path,
152 env: props.dev.env ?? props.env,
153 }).pipe(Namespace.push(id));
154 const url = Output.map(dev.url, (value) => value ?? props.dev?.url);
155 return { url, service: undefined } satisfies FrameworkSite;
156 }
157
158 const build = yield* Command.Build("Build", {
159 command: props.build.command,
160 cwd: props.path,
161 memo: props.memo,
162 outdir: props.build.output,
163 env: props.build.env ?? props.env,
164 }).pipe(Namespace.push(id));
165
166 const cwd = path.resolve(initialCwd, props.path ?? ".");
167 const outdir = path.resolve(cwd, props.build.output);
168 const internal = staticConfigFromAssets(props.assets);
169 const notFoundHandling =
170 internal.errorPage !== undefined
171 ? ("404-page" as const)
172 : internal.spa === true
173 ? ("spa" as const)
174 : ("none" as const);
175
176 const {
177 NODE_SERVE_ENTRY_FILE_NAME,
178 relativeClientDirExpression,
179 writeNodeServeEntry,
180 } = yield* loadFrontendCore;
181 const servePath = path.join(
182 path.dirname(outdir),
183 NODE_SERVE_ENTRY_FILE_NAME,
184 );
185 yield* writeNodeServeEntry({
186 output: {
187 clientDirectory: outdir,
188 serverModules: [],
189 externalWorkspaces: new Set<string>(),
190 },
191 servePath,
192 serveModuleName: NODE_SERVE_ENTRY_FILE_NAME,
193 clientDirExpression: relativeClientDirExpression(servePath, outdir),
194 notFoundHandling,
195 printUrl: isLocal,
196 platform: "node",
197 });
198
199 if (isLocal) {
200 const runtime = yield* Effect.sync(() => process.execPath);
201 const dev = yield* Command.Dev("Dev", {
202 command: `${runtime} ${servePath}`,
203 cwd: path.dirname(servePath),
204 env: {
205 ...props.env,
206 PORT: "0",
207 HOST: "127.0.0.1",
208 ALCHEMY_BUILD_HASH: build.hash.output as unknown as string,
209 },
210 }).pipe(Namespace.push(id));
211 return {
212 url: Output.map(dev.url, (value) => value),
213 service: undefined,
214 } satisfies FrameworkSite;
215 }
216
217 const service = yield* Service(
218 id,
219 websiteServiceProps(props, {
220 main: servePath,
221 extraFiles: [
222 {
223 // Keep the build dependency so planning cannot hash the previous artifact.
224 source: Output.map(build.outdir, (dir) =>
225 path.resolve(initialCwd, dir),
226 ) as unknown as string,
227 dest: path.basename(outdir),
228 },
229 ],
230 }),
231 );
232
233 return { url: service.uri, service } satisfies FrameworkSite;
234 }).pipe(Effect.orDie);