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 { App } from "../App.ts";
13import { Certificate } from "../Certificate.ts";
14import { IpAssignment } from "../IpAssignment.ts";
15import { Service, type ServiceProps } from "../Service.ts";
16import {
17 type FrameworkSite,
18 type Ref,
19 type WebsiteAssetsProps,
20 staticConfigFromAssets,
21} from "./FrameworkSite.ts";
22import { loadFrontendCore } from "../../Website/FrontendCore.ts";
23
24const DEFAULT_PORT = 3000;
25
26const resolveRef = <T>(ref: Ref<T>) =>
27 Effect.isEffect(ref) ? ref : Effect.succeed(ref);
28
29export interface StaticSiteProps {
30 /** Deployment strategy forwarded to the hosted Fly Service. */
31 deploy?: ServiceProps["deploy"];
32 /** Process shutdown policy for the generated server. */
33 shutdown?: ServiceProps["shutdown"];
34 /** Named Machine readiness checks. */
35 checks?: ServiceProps["checks"];
36 /** Override proxy services and their routing health checks. */
37 services?: ServiceProps["services"];
38 /**
39 * Path to the local site directory (working directory for
40 * {@link build.command}).
41 * @default "."
42 */
43 path?: string;
44 /**
45 * Build executed before deploy.
46 */
47 build: {
48 /** Shell command that produces the site (e.g. `"hugo --minify"`). */
49 command: string;
50 /** Directory the command writes, relative to {@link path}. */
51 output: string;
52 /** Environment variables for the build command. */
53 env?: Record<string, string | Redacted.Redacted<string>>;
54 };
55 /**
56 * Controls which files are hashed to decide whether the build re-runs.
57 * @default true
58 */
59 memo?: MemoOptions | boolean;
60 /**
61 * Process environment for the hosted static server.
62 */
63 env?: Record<string, string | Redacted.Redacted<string>>;
64 /**
65 * Miss handling for the generated file server.
66 */
67 assets?: WebsiteAssetsProps;
68 /**
69 * Parent Fly App. When omitted, a `Fly.App` is created. The Service
70 * stays in the caller namespace; only `Build` / `Dev` are pushed.
71 */
72 app?: Ref<App>;
73 /**
74 * Optional custom hostname. Requests ACME (`Fly.Certificate`) on the App.
75 */
76 domain?: string;
77 /**
78 * User-defined tags. Accepted for API parity; Fly Services do not
79 * surface resource tags.
80 */
81 tags?: Record<string, string>;
82 /**
83 * Local dev configuration. When `alchemy dev` runs with `dev.command`,
84 * the build is skipped and `command` is spawned as a long-lived child.
85 */
86 dev?: {
87 /**
88 * Shell command to run as the local dev server (e.g. `npm run dev`).
89 */
90 command: string;
91 /**
92 * Working directory for {@link command}. Defaults to {@link cwd}.
93 */
94 cwd?: string;
95 /**
96 * Environment variables for {@link command}.
97 */
98 env?: Record<string, string | Redacted.Redacted<string>>;
99 /**
100 * Override for the `url` output if alchemy fails to detect it from
101 * stdout of the dev command.
102 */
103 url?: string;
104 };
105}
106
107/**
108 * Deploy a static site built by a shell command to Fly.
109 *
110 * `StaticSite` runs a build command (e.g. `npm run build` / `hugo`),
111 * content-hashes the output directory, and deploys a Service that serves
112 * those files (plus `/health`). Use this when the site has its own build
113 * step — Hugo, Zola, Eleventy, or any custom pipeline.
114 *
115 * For Vite-based projects, prefer `Fly.Website.Vite`.
116 *
117 * `Build` / `Dev` use constant logical ids under `Namespace.push(id)`.
118 * The Service stays in the caller namespace (same as
119 * `Cloudflare.Website.StaticSite`).
120 *
121 *
122 * ### Basic Usage
123 * **Example:** Deploying a Hugo site
124 * ```typescript
125 * const site = yield* Fly.Website.StaticSite("Blog", {
126 * build: { command: "hugo --minify", output: "public" },
127 * });
128 * ```
129 *
130 * **Example:** SPA-style routing
131 * ```typescript
132 * const site = yield* Fly.Website.StaticSite("App", {
133 * build: { command: "npm run build", output: "dist" },
134 * assets: { notFoundHandling: "single-page-application" },
135 * });
136 * ```
137 *
138 * ### Building from a Subdirectory
139 * **Example:** Building a frontend in a monorepo
140 * ```typescript
141 * const site = yield* Fly.Website.StaticSite("Web", {
142 * path: "apps/web",
143 * build: { command: "npm run build", output: "dist" },
144 * });
145 * ```
146 *
147 * ### Local Development
148 * **Example:** External dev command
149 * ```typescript
150 * const site = yield* Fly.Website.StaticSite("App", {
151 * build: { command: "npm run build", output: "dist" },
152 * dev: { command: "npm run dev" },
153 * });
154 * ```
155 *
156 * @resource
157 * @product Website
158 */
159export const StaticSite = (id: string, props: StaticSiteProps) =>
160 Effect.gen(function* () {
161 const ctx = yield* AlchemyContext;
162 const remoted = yield* ProviderModePolicy;
163 const isLocal = ctx.dev && remoted !== true;
164 const path = yield* Path.Path;
165
166 const empty = (): FrameworkSite => ({
167 url: undefined,
168 app: undefined,
169 service: undefined,
170 ip: undefined,
171 certificate: undefined,
172 });
173
174 if (isLocal && props.dev) {
175 const dev = yield* Command.Dev("Dev", {
176 command: props.dev.command,
177 cwd: props.dev.cwd ?? props.path,
178 env: props.dev.env ?? props.env,
179 }).pipe(Namespace.push(id));
180 const url = Output.map(dev.url, (value) => value ?? props.dev?.url);
181 return { ...empty(), url };
182 }
183
184 const build = yield* Command.Build("Build", {
185 command: props.build.command,
186 cwd: props.path,
187 memo: props.memo,
188 outdir: props.build.output,
189 env: props.build.env ?? props.env,
190 }).pipe(Namespace.push(id));
191
192 const cwd = path.resolve(initialCwd, props.path ?? ".");
193 const outdir = path.resolve(cwd, props.build.output);
194 const internal = staticConfigFromAssets(props.assets);
195 const notFoundHandling =
196 internal.errorPage !== undefined
197 ? ("404-page" as const)
198 : internal.spa === true
199 ? ("spa" as const)
200 : ("none" as const);
201
202 const {
203 NODE_SERVE_ENTRY_FILE_NAME,
204 relativeClientDirExpression,
205 writeNodeServeEntry,
206 } = yield* loadFrontendCore;
207 const servePath = path.join(
208 path.dirname(outdir),
209 NODE_SERVE_ENTRY_FILE_NAME,
210 );
211 const serveOutput = {
212 clientDirectory: outdir,
213 serverModules: [],
214 externalWorkspaces: new Set<string>(),
215 };
216 yield* writeNodeServeEntry({
217 output: serveOutput,
218 servePath,
219 serveModuleName: NODE_SERVE_ENTRY_FILE_NAME,
220 clientDirExpression: relativeClientDirExpression(servePath, outdir),
221 notFoundHandling,
222 printUrl: isLocal,
223 platform: "node",
224 });
225
226 if (isLocal) {
227 const runtime = yield* Effect.sync(() => process.execPath);
228 const dev = yield* Command.Dev("Dev", {
229 command: `${runtime} ${servePath}`,
230 cwd: path.dirname(servePath),
231 env: {
232 ...props.env,
233 PORT: "0",
234 HOST: "127.0.0.1",
235 ALCHEMY_BUILD_HASH: build.hash.output as unknown as string,
236 },
237 }).pipe(Namespace.push(id));
238 return {
239 ...empty(),
240 url: Output.map(dev.url, (value) => value),
241 };
242 }
243
244 const main = servePath;
245
246 const app =
247 props.app !== undefined
248 ? yield* resolveRef(props.app)
249 : yield* App("App").pipe(Namespace.push(id));
250
251 const ip = yield* IpAssignment("Shared", {
252 app,
253 type: "shared_v4",
254 }).pipe(Namespace.push(id));
255
256 // Serve the built tree from the Machine. Fly Tigris `statics` on
257 // `urlPrefix: "/"` do not rewrite HTML routes and hang GET `/`.
258 // Hashed `/assets` still go to Tigris on FrameworkSite.
259 const service = yield* Service(id, {
260 app,
261 deploy: props.deploy,
262 shutdown: props.shutdown,
263 checks: props.checks,
264 services: props.services,
265 main,
266 port: DEFAULT_PORT,
267 // Generated static-file server is a complete bun/node program.
268 isExternal: true,
269 env: props.env,
270 extraFiles: [
271 {
272 // Keep the build dependency so planning cannot hash the previous artifact.
273 source: Output.map(build.outdir, (dir) =>
274 path.resolve(initialCwd, dir),
275 ),
276 dest: path.basename(outdir),
277 },
278 ],
279 });
280
281 const certificate =
282 props.domain !== undefined
283 ? yield* Certificate("Certificate", {
284 app,
285 hostname: props.domain,
286 kind: "acme",
287 }).pipe(Namespace.push(id))
288 : undefined;
289
290 const url =
291 props.domain !== undefined ? `https://${props.domain}` : app.url;
292
293 return { url, app, service, ip, certificate };
294 }).pipe(Effect.orDie);