1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Redacted from "effect/Redacted";
5import { AlchemyContext } from "../../AlchemyContext.ts";
6import * as Command from "../../Command/index.ts";
7import * as Namespace from "../../Namespace.ts";
8import type * as Output from "../../Output.ts";
9import { ProviderModePolicy } from "../../ProviderMode.ts";
10import { initialCwd } from "../../Util/Node.ts";
11import { CustomDomain } from "../CustomDomain.ts";
12import { Project } from "../Project.ts";
13import { Service } from "../Service.ts";
14import {
15 WEBSITE_PORT,
16 type FrameworkSiteProps,
17 type Website,
18} from "./FrameworkSite.ts";
19import { loadFrontendCore } from "../../Website/FrontendCore.ts";
20
21// `env` comes from FrameworkSiteProps (which additionally accepts `Output`
22// values, e.g. `VITE_API_URL: api.url`) — it must be omitted from the
23// BuildProps side because interfaces cannot extend two parents whose `env`
24// members differ.
25export interface StaticSiteProps
26 extends
27 Omit<Command.BuildProps, "env">,
28 Pick<
29 FrameworkSiteProps,
30 "project" | "environment" | "domain" | "tags" | "env"
31 > {
32 /**
33 * Local dev configuration. When `alchemy dev` runs with `dev.command`,
34 * the build is skipped and `command` is spawned as a long-lived child
35 * (`Command.Dev`). Without `dev.command`, the site still builds and a
36 * local static server serves `outdir` — no Railway Service is created.
37 */
38 dev?: {
39 /**
40 * Shell command to run as the local dev server (e.g. `npm run dev`).
41 */
42 command: string;
43 /**
44 * Working directory for {@link command}. Defaults to
45 * {@link Command.BuildProps.cwd}.
46 */
47 cwd?: string;
48 /**
49 * Environment variables for {@link command}, merged on top of
50 * `process.env`.
51 */
52 env?: Record<string, string | Redacted.Redacted<string>>;
53 /**
54 * Override for the `url` output if alchemy fails to detect it from
55 * the stdout of the dev command.
56 */
57 url?: string;
58 };
59 /**
60 * Answer misses with the index page (200) instead of a 404 so
61 * client-side routes deep-link. Mutually exclusive with {@link errorPage}.
62 */
63 spa?: boolean;
64 /**
65 * Serve this page (e.g. `404.html`) with status 404 when no file
66 * matches. Mutually exclusive with {@link spa}.
67 */
68 errorPage?: string;
69}
70
71const envRecord = (
72 env:
73 | Record<
74 string,
75 string | Redacted.Redacted<string> | Output.Output<string | undefined>
76 >
77 | undefined,
78): Record<string, string | Output.Output<string | undefined>> | undefined => {
79 if (env === undefined) return undefined;
80 return Object.fromEntries(
81 Object.entries(env).map(([key, value]) => [
82 key,
83 Redacted.isRedacted(value) ? Redacted.value(value) : value,
84 ]),
85 );
86};
87
88/**
89 * A Railway Service that serves static assets built by a shell command.
90 *
91 * `StaticSite` runs a build command (e.g. `npm run build`), content-hashes
92 * the output directory, and deploys the result as a Node static-file
93 * server on one `Railway.Service`. Use this when your site has its own
94 * build step that produces a directory of files — Hugo, Zola, Eleventy, or
95 * any custom pipeline.
96 *
97 * For Vite-based projects, prefer {@link Vite | Railway.Website.Vite}
98 * which handles building automatically.
99 *
100 * Cloudflare DX: top-level `command` + `outdir` (`Command.BuildProps`).
101 * Only `Command.Build("Build")` / `Command.Dev("Dev")` are pushed under
102 * the site id; the Service stays in the caller namespace.
103 *
104 * During `alchemy dev`, `dev.command` skips the build and is the site.
105 * Without `dev.command`, the site still builds and a local static server
106 * serves `outdir` — no Project or Service is created.
107 *
108 * ### Basic Usage
109 * **Example:** Deploying a Hugo site
110 * ```typescript
111 * const site = yield* Railway.Website.StaticSite("Blog", {
112 * command: "hugo --minify",
113 * outdir: "public",
114 * });
115 * ```
116 *
117 * **Example:** SPA-style routing
118 * ```typescript
119 * const site = yield* Railway.Website.StaticSite("App", {
120 * command: "npm run build",
121 * outdir: "dist",
122 * spa: true,
123 * });
124 * ```
125 *
126 * ### Building from a Subdirectory
127 * **Example:** Building a frontend in a monorepo
128 * ```typescript
129 * const site = yield* Railway.Website.StaticSite("Web", {
130 * cwd: "apps/web",
131 * command: "npm run build",
132 * outdir: "dist",
133 * });
134 * ```
135 *
136 * ### Local Development
137 * **Example:** External dev command
138 * ```typescript
139 * const site = yield* Railway.Website.StaticSite("App", {
140 * command: "npm run build",
141 * outdir: "dist",
142 * dev: { command: "npm run dev" },
143 * });
144 * ```
145 *
146 * @resource
147 * @product Website
148 */
149export const StaticSite = (id: string, props: StaticSiteProps) =>
150 Effect.gen(function* () {
151 const ctx = yield* AlchemyContext;
152 const remoted = yield* ProviderModePolicy;
153 const isLocal = ctx.dev && remoted !== true;
154 const fs = yield* FileSystem.FileSystem;
155 const path = yield* Path.Path;
156
157 if (isLocal && props.dev !== undefined) {
158 const dev = yield* Command.Dev("Dev", {
159 command: props.dev.command,
160 cwd: props.dev.cwd ?? props.cwd,
161 env: props.dev.env ?? props.env,
162 }).pipe(Namespace.push(id));
163 return {
164 url: props.dev.url ?? dev.url,
165 service: undefined,
166 project: undefined,
167 } satisfies Website;
168 }
169
170 yield* Command.Build("Build", {
171 command: props.command,
172 cwd: props.cwd,
173 memo: props.memo,
174 outdir: props.outdir,
175 env: props.env,
176 shell: props.shell,
177 timeout: props.timeout,
178 }).pipe(Namespace.push(id));
179
180 const cwd = path.resolve(initialCwd, props.cwd ?? ".");
181 const clientAbs = path.resolve(cwd, props.outdir);
182
183 const {
184 NODE_SERVE_ENTRY_FILE_NAME,
185 relativeClientDirExpression,
186 writeNodeServeEntry,
187 } = yield* loadFrontendCore;
188 const servePath = path.join(
189 path.dirname(clientAbs),
190 NODE_SERVE_ENTRY_FILE_NAME,
191 );
192 yield* writeNodeServeEntry({
193 output: {
194 clientDirectory: clientAbs,
195 serverModules: [],
196 externalWorkspaces: new Set<string>(),
197 },
198 servePath,
199 serveModuleName: NODE_SERVE_ENTRY_FILE_NAME,
200 clientDirExpression: relativeClientDirExpression(servePath, clientAbs),
201 notFoundHandling:
202 props.errorPage !== undefined
203 ? "404-page"
204 : props.spa === true
205 ? "spa"
206 : "none",
207 printUrl: isLocal,
208 platform: "node",
209 });
210
211 if (isLocal) {
212 const runtime = yield* Effect.sync(() => process.execPath);
213 const dev = yield* Command.Dev("Dev", {
214 command: `${runtime} ${servePath}`,
215 cwd: path.dirname(servePath),
216 env: {
217 ...props.env,
218 PORT: "0",
219 HOST: "127.0.0.1",
220 },
221 }).pipe(Namespace.push(id));
222 return {
223 url: dev.url,
224 service: undefined,
225 project: undefined,
226 } satisfies Website;
227 }
228
229 const project = Effect.isEffect(props.project)
230 ? yield* props.project
231 : (props.project ?? (yield* Project("Project").pipe(Namespace.push(id))));
232 const environment = Effect.isEffect(props.environment)
233 ? yield* props.environment
234 : (props.environment ?? project);
235 const service = yield* Service(id, {
236 project,
237 environment,
238 main: servePath,
239 port: WEBSITE_PORT,
240 healthcheck: "/health",
241 isExternal: true,
242 env: envRecord(props.env),
243 extraFiles: [{ source: clientAbs, dest: path.basename(clientAbs) }],
244 });
245
246 if (props.domain !== undefined && props.domain.length > 0) {
247 yield* CustomDomain("Domain", {
248 service,
249 environment,
250 domain: props.domain,
251 targetPort: WEBSITE_PORT,
252 }).pipe(Namespace.push(id));
253 return {
254 url: `https://${props.domain}`,
255 service,
256 project,
257 } satisfies Website;
258 }
259
260 return {
261 url: service.url,
262 service,
263 project,
264 } satisfies Website;
265 }).pipe(Effect.orDie);