1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import type * as Redacted from "effect/Redacted";
5import { AlchemyContext } from "../../AlchemyContext.ts";
6import * as Command from "../../Command/index.ts";
7import type { MemoOptions } from "../../Command/Memo.ts";
8import * as Namespace from "../../Namespace.ts";
9import * as Output from "../../Output.ts";
10import { ProviderModePolicy } from "../../ProviderMode.ts";
11import { initialCwd } from "../../Util/Node.ts";
12import { Service } from "../Service.ts";
13import {
14 bindWebsiteDomain,
15 DEFAULT_WEBSITE_PORT,
16 resolveWebsiteServer,
17 unwrapEnv,
18 websiteUrl,
19 type FrameworkSiteProps,
20 type Website,
21} from "./FrameworkSite.ts";
22import { loadFrontendCore } from "../../Website/FrontendCore.ts";
23
24export interface StaticSiteProps extends Pick<
25 FrameworkSiteProps,
26 "domain" | "zone" | "server" | "tags" | "env"
27> {
28 /**
29 * Shell command that produces the site (e.g. `hugo --minify`).
30 */
31 command: string;
32 /**
33 * Directory the command writes, relative to {@link cwd}.
34 */
35 outdir: string;
36 /**
37 * Working directory for {@link command}.
38 * @default process.cwd()
39 */
40 cwd?: string;
41 /**
42 * Controls which files are hashed to decide whether the build re-runs.
43 * @default true
44 */
45 memo?: MemoOptions | boolean;
46 /**
47 * Answer misses with the index page (200) instead of a 404 so
48 * client-side routes deep-link correctly. Mutually exclusive with
49 * {@link errorPage}.
50 */
51 spa?: boolean;
52 /**
53 * Serve this page with a real `404` status for requests that match no
54 * file. Mutually exclusive with {@link spa}.
55 */
56 errorPage?: string;
57 /**
58 * Local dev configuration. When `alchemy dev` runs, the build command
59 * is skipped and `command` is spawned as a long-lived child. Without
60 * `dev`, the site is still built and served locally (no cloud Service).
61 */
62 dev?: {
63 /**
64 * Shell command to run as the local dev server (e.g. `npm run dev`).
65 */
66 command: string;
67 /**
68 * Working directory for {@link command}. Defaults to
69 * {@link StaticSiteProps.cwd}.
70 */
71 cwd?: string;
72 /**
73 * Environment variables for {@link command}, merged on top of
74 * `process.env`.
75 */
76 env?: Record<string, string | Redacted.Redacted<string>>;
77 /**
78 * Override for the `url` output if alchemy fails to detect it from
79 * stdout of the dev command.
80 */
81 url?: string;
82 };
83}
84
85/**
86 * A Hetzner Service that serves static assets built by a shell command.
87 *
88 * `StaticSite` runs a build command (e.g. `npm run build`), packs the
89 * output directory into the unit archive, and deploys a tiny static-file
90 * server (`GET` assets, `/health`, optional SPA / 404-page). Use this
91 * when your site has its own build step — Hugo, Zola, Eleventy, or any
92 * custom pipeline.
93 *
94 * For Vite-based projects, prefer {@link Vite | Hetzner.Website.Vite}.
95 *
96 * `Dev` and `Build` carry constant logical ids, so they are namespaced
97 * under `id`. The Service stays in the caller's namespace (same as
98 * Cloudflare.Website.StaticSite).
99 *
100 *
101 * ### Basic Usage
102 * **Example:** Deploying a Hugo site
103 * ```typescript
104 * const site = yield* Hetzner.Website.StaticSite("Blog", {
105 * command: "hugo --minify",
106 * outdir: "public",
107 * });
108 * ```
109 *
110 * **Example:** SPA-style routing
111 * ```typescript
112 * const site = yield* Hetzner.Website.StaticSite("App", {
113 * command: "npm run build",
114 * outdir: "dist",
115 * spa: true,
116 * });
117 * ```
118 *
119 * ### Building from a Subdirectory
120 * **Example:** Building a frontend in a monorepo
121 * ```typescript
122 * const site = yield* Hetzner.Website.StaticSite("Web", {
123 * cwd: "apps/web",
124 * command: "npm run build",
125 * outdir: "dist",
126 * });
127 * ```
128 *
129 * ### Local Development
130 * **Example:** External Dev Server
131 * ```typescript
132 * const site = yield* Hetzner.Website.StaticSite("App", {
133 * command: "npm run build",
134 * outdir: "dist",
135 * dev: { command: "npm run dev" },
136 * });
137 * ```
138 *
139 * @resource
140 * @product Website
141 */
142export const StaticSite = (id: string, props: StaticSiteProps) =>
143 Effect.gen(function* () {
144 const ctx = yield* AlchemyContext;
145 const remoted = yield* ProviderModePolicy;
146 const isLocal = ctx.dev && remoted !== true;
147 const port = DEFAULT_WEBSITE_PORT;
148
149 if (props.spa && props.errorPage) {
150 return yield* Effect.die(
151 `Cannot provide both "spa" and "errorPage". A SPA answers misses with the index page (200); "errorPage" answers them with a real 404.`,
152 );
153 }
154 if (props.domain !== undefined && props.zone === undefined) {
155 return yield* Effect.die(
156 `Hetzner.Website.StaticSite: "domain" requires "zone" (an existing Hetzner.Zone).`,
157 );
158 }
159
160 if (isLocal && props.dev) {
161 const dev = yield* Command.Dev("Dev", {
162 command: props.dev.command,
163 cwd: props.dev.cwd ?? props.cwd,
164 env: props.dev.env ?? props.env,
165 }).pipe(Namespace.push(id));
166 const url = Output.map(
167 (detected: string | undefined) => detected ?? props.dev?.url,
168 )(dev.url);
169 return {
170 url,
171 server: undefined,
172 service: undefined,
173 } satisfies Website;
174 }
175
176 const build = yield* Command.Build("Build", {
177 command: props.command,
178 cwd: props.cwd,
179 memo: props.memo,
180 outdir: props.outdir,
181 env: props.env,
182 }).pipe(Namespace.push(id));
183
184 const fs = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186 const cwd = path.resolve(initialCwd, props.cwd ?? ".");
187 const outdir = path.resolve(cwd, props.outdir);
188 const {
189 NODE_SERVE_ENTRY_FILE_NAME,
190 relativeClientDirExpression,
191 writeNodeServeEntry,
192 } = yield* loadFrontendCore;
193 const servePath = path.join(
194 path.dirname(outdir),
195 NODE_SERVE_ENTRY_FILE_NAME,
196 );
197 yield* writeNodeServeEntry({
198 output: {
199 clientDirectory: outdir,
200 serverModules: [],
201 externalWorkspaces: new Set<string>(),
202 },
203 servePath,
204 serveModuleName: NODE_SERVE_ENTRY_FILE_NAME,
205 clientDirExpression: relativeClientDirExpression(servePath, outdir),
206 notFoundHandling:
207 props.errorPage !== undefined
208 ? "404-page"
209 : props.spa === true
210 ? "spa"
211 : "none",
212 printUrl: isLocal,
213 defaultPort: port,
214 platform: "node",
215 });
216
217 if (isLocal) {
218 const bun = yield* Effect.sync(() => process.execPath);
219 const dev = yield* Command.Dev("Dev", {
220 command: `${bun} ${servePath}`,
221 cwd: path.dirname(servePath),
222 env: {
223 ...unwrapEnv(props.env),
224 ALCHEMY_BUILD_HASH: build.hash.output as unknown as string,
225 PORT: "0",
226 HOST: "127.0.0.1",
227 },
228 }).pipe(Namespace.push(id));
229 return {
230 url: dev.url,
231 server: undefined,
232 service: undefined,
233 } satisfies Website;
234 }
235
236 const server =
237 props.server !== undefined
238 ? yield* resolveWebsiteServer(props)
239 : yield* resolveWebsiteServer(props).pipe(Namespace.push(id));
240 const service = yield* Service(id, {
241 server,
242 main: servePath,
243 extraFiles: [
244 {
245 source: outdir,
246 destination: path.basename(outdir),
247 },
248 ],
249 port,
250 env: {
251 ...unwrapEnv(props.env),
252 PORT: String(port),
253 },
254 // Generated static-file server is a complete bun/node program.
255 isExternal: true,
256 });
257
258 if (props.domain !== undefined && props.zone !== undefined) {
259 yield* bindWebsiteDomain({
260 domain: props.domain,
261 zone: props.zone,
262 server,
263 tags: props.tags,
264 });
265 }
266
267 return {
268 url: websiteUrl({ domain: props.domain, service, port }),
269 server,
270 service,
271 } satisfies Website;
272 }).pipe(Effect.orDie);