1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Redacted from "effect/Redacted";
6import { AlchemyContext } from "../../AlchemyContext.ts";
7import type { PackageInstall } from "../../Bundle/InstalledPackages.ts";
8import type { MemoOptions } from "../../Command/Memo.ts";
9import * as Output from "../../Output.ts";
10import { ProviderModePolicy } from "../../ProviderMode.ts";
11import { initialCwd } from "../../Util/Node.ts";
12import {
13 staticConfigFromAssets,
14 type WebsiteAssetsProps,
15 type WebsiteNotFoundHandling,
16} from "../../Website/assets.ts";
17import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts";
18import {
19 Server as FrameworkServer,
20 type ServerDevProps,
21} from "../../Website/Server.ts";
22import { CustomDomain } from "../CustomDomain.ts";
23import type { ExtraFile } from "../hosted.ts";
24import { Project, type Project as ProjectResource } from "../Project.ts";
25import type { Environment as EnvironmentResource } from "../ProjectEnvironment.ts";
26import type { Providers } from "../Providers.ts";
27import { Service, type Service as ServiceResource } from "../Service.ts";
28import { Cdn } from "./Cdn.ts";
29
30/** Port the generated Node serve entry and Railway Service listen on. */
31export const WEBSITE_PORT = 3000;
32
33/**
34 * A resource-valued prop: the resource itself, or an Effect that produces
35 * it (so `yield* Project(...)` and `Project(...)` both type-check).
36 */
37export type Ref<T> = T | Effect.Effect<T, never, Providers>;
38
39export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling };
40export { staticConfigFromAssets };
41
42/**
43 * Props shared by every Railway framework website composite.
44 */
45export interface FrameworkSiteProps {
46 /**
47 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
48 * that produces one. When omitted, a `Railway.Project("Project")` is
49 * created under this site's namespace.
50 */
51 project?: Ref<ProjectResource>;
52 /**
53 * Environment to deploy the Service into. Accepts a `Railway.Project`
54 * (primary environment), a `Railway.Environment`, or `{ environmentId }`.
55 * Defaults to the project's primary environment.
56 */
57 environment?: Ref<
58 EnvironmentResource | ProjectResource | { readonly environmentId: string }
59 >;
60 /**
61 * Project root directory (the directory containing `package.json`).
62 * @default "."
63 */
64 rootDir?: string;
65 /**
66 * Controls which files are hashed to decide whether the build re-runs.
67 * Forwarded to the framework integration when it honors memo options.
68 * @default true
69 */
70 memo?: MemoOptions | boolean;
71 /**
72 * Process environment for the deployed Service (and, under
73 * `alchemy dev`, the framework dev server). Accepts `Output`s
74 * (e.g. `VITE_API_URL: api.url`).
75 */
76 env?: Record<
77 string,
78 string | Redacted.Redacted<string> | Output.Output<string | undefined>
79 >;
80 /**
81 * Static-asset routing (`notFoundHandling`, `htmlHandling`). Railway
82 * CDN caches hashed files by Content-Type regardless of this bag.
83 */
84 assets?: WebsiteAssetsProps;
85 /**
86 * Options for the local dev server that runs this site under
87 * `alchemy dev`.
88 */
89 dev?: ServerDevProps;
90 /**
91 * Optional custom hostname attached via `Railway.CustomDomain`. A
92 * string is the hostname (`www.example.com`). When set, `url` is
93 * `https://{domain}` instead of the generated `*.up.railway.app`.
94 */
95 domain?: string;
96 /**
97 * User-defined tags. Railway Services do not persist tags; accepted
98 * for API parity with AWS/Cloudflare Website composites.
99 */
100 tags?: Record<string, string>;
101}
102
103/**
104 * Static-asset serving options mapped from Cloudflare `AssetsConfig`
105 * onto the generated Node serve entry.
106 */
107export interface WebsiteStaticConfig {
108 /**
109 * Answer misses with `index.html` (200) so client-side routes
110 * deep-link. Mutually exclusive with {@link errorPage}.
111 */
112 spa?: boolean;
113 /**
114 * Serve this page (e.g. `404.html`) with status 404 when no file
115 * matches. Mutually exclusive with {@link spa}.
116 */
117 errorPage?: string;
118 /**
119 * Vocs/Waku: serve `about/index.html` at `/about`.
120 * @default "none"
121 */
122 htmlHandling?: "none" | "drop-trailing-slash";
123}
124
125/** Per-framework wiring for {@link makeFrameworkSite}. */
126export interface FrameworkSiteConfig {
127 /** Display name used in error messages (e.g. `"SvelteKit"`). */
128 name: string;
129 /** Framework-integration module specifier. */
130 framework: string;
131 /** Node deploy-target module specifier. */
132 target: string;
133 /**
134 * Framework-specific build options forwarded to the integration (e.g.
135 * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable.
136 */
137 options?: Record<string, unknown> | undefined;
138 /**
139 * Assets-only serving: used when the build produced no server modules
140 * (Vite, Foldkit, Vocs, Astro `output: "static"`).
141 */
142 static?: WebsiteStaticConfig | undefined;
143 /**
144 * Native packages to `npm install` into the image instead of bundling
145 * (Next.js needs `next` / `react`).
146 */
147 install?: PackageInstall | undefined;
148 /**
149 * How to bake the build into the image.
150 * - `"client"` (default): `COPY dist /app/dist` from `clientDirectory`
151 * - `"next"`: `COPY .next` + `public` (and `next.config.*`)
152 * @default "client"
153 */
154 bake?: "client" | "next" | undefined;
155}
156
157export interface Website {
158 /**
159 * Public site URL. Under `alchemy dev` this is the framework (or
160 * static) dev server (`http://localhost:<port>`). On deploy it is
161 * `https://{domain}` (`*.up.railway.app`, or the custom hostname).
162 */
163 url: string | Output.Output<string | undefined> | undefined;
164 /**
165 * The Railway Service that serves the site. `undefined` under
166 * `alchemy dev` (no cloud resources are declared).
167 */
168 service: ServiceResource | undefined;
169 /**
170 * The Railway Project the Service belongs to. `undefined` under
171 * `alchemy dev`.
172 */
173 project: ProjectResource | undefined;
174}
175
176export class FrameworkServerError extends Data.TaggedError(
177 "Railway.Website.FrameworkServerError",
178)<{
179 readonly framework: string;
180 readonly message: string;
181 readonly cause?: unknown;
182}> {}
183
184const envRecord = (
185 env:
186 | Record<
187 string,
188 string | Redacted.Redacted<string> | Output.Output<string | undefined>
189 >
190 | undefined,
191): Record<string, string | Output.Output<string | undefined>> | undefined => {
192 if (env === undefined) return undefined;
193 return Object.fromEntries(
194 Object.entries(env).map(([key, value]) => [
195 key,
196 Redacted.isRedacted(value) ? Redacted.value(value) : value,
197 ]),
198 );
199};
200
201/**
202 * Shared implementation behind the Railway framework website composites:
203 * build the framework through its Node deploy target, then deploy one
204 * `Railway.Service` whose image bakes the Node serve entry plus
205 * `clientDirectory`.
206 *
207 * During `alchemy dev` the site is the framework's own dev server (native
208 * HMR) and no cloud resources are declared; `Alchemy.remote()` opts back
209 * into the live Service path.
210 *
211 * Callers pipe `Namespace.push(id)` themselves (the composites do).
212 */
213const runFrameworkSite = Effect.fn("Railway.Website.FrameworkSite")(function* (
214 _id: string,
215 props: FrameworkSiteProps,
216 config: FrameworkSiteConfig,
217) {
218 const ctx = yield* AlchemyContext;
219 const remoted = yield* ProviderModePolicy;
220 const isLocal = ctx.dev && remoted !== true;
221 const path = yield* Path.Path;
222 const fs = yield* FileSystem.FileSystem;
223 const root = path.resolve(initialCwd, props.rootDir ?? ".");
224 const bake = config.bake ?? "client";
225
226 const build = yield* FrameworkServer("Build", {
227 framework: config.framework,
228 target: config.target,
229 root: props.rootDir,
230 env: envRecord(props.env),
231 options: config.options,
232 memo: props.memo,
233 dev: props.dev,
234 });
235
236 if (isLocal) {
237 return {
238 url: build.url,
239 service: undefined,
240 project: undefined,
241 } satisfies Website;
242 }
243
244 // The build runs at APPLY time (`Website.Server` is a resource), so its
245 // attributes are Outputs here — derive every deploy input lazily and let
246 // the engine resolve them once the build has produced real paths. Reading
247 // `build.distDir` eagerly in the composite body would observe an Output
248 // proxy, not a string.
249 const buildOut = Output.mapEffect(
250 ([serverEntry, distDir]: [string | undefined, string | undefined]) =>
251 Effect.gen(function* () {
252 if (serverEntry === undefined || distDir === undefined) {
253 return yield* Effect.die(
254 new FrameworkServerError({
255 framework: config.framework,
256 message: `The ${config.name} build produced no Node serve entry (serverModules[0]). The Node deploy target should write serve-node.mjs.`,
257 }),
258 );
259 }
260 const main = path.resolve(initialCwd, serverEntry);
261 if (!(yield* fs.exists(main).pipe(Effect.orElseSucceed(() => false)))) {
262 return yield* Effect.die(
263 new FrameworkServerError({
264 framework: config.framework,
265 message: `The ${config.name} build produced no server entry at ${main}`,
266 }),
267 );
268 }
269 return { distDir: path.resolve(initialCwd, distDir), main };
270 }),
271 )(
272 Output.all(
273 build.serverEntry as unknown as Output.Output<string | undefined>,
274 build.distDir as unknown as Output.Output<string | undefined>,
275 ) as unknown as Output.Output<[string | undefined, string | undefined]>,
276 );
277 const main = Output.map(buildOut, (out) => out.main);
278
279 // `bake === "next"` ships `.next`/`public`/`next.config.*` from the
280 // project root; every other bake ships the build's dist directory. BOTH
281 // must be derived from `buildOut`: the artifacts only exist once the
282 // build has run at apply — probing the root here (pre-build) would miss
283 // a fresh project's `.next` entirely.
284 const extraFiles = Output.mapEffect(
285 (out: { distDir: string; main: string }) =>
286 packSiteExtraFiles(bake === "next" ? root : out.distDir, bake),
287 )(buildOut);
288
289 const project = Effect.isEffect(props.project)
290 ? yield* props.project
291 : (props.project ?? (yield* Project("Project")));
292 const environment = Effect.isEffect(props.environment)
293 ? yield* props.environment
294 : (props.environment ?? project);
295 const service = yield* Service("Service", {
296 project,
297 environment,
298 main: main as unknown as string,
299 port: WEBSITE_PORT,
300 healthcheck: "/health",
301 isExternal: true,
302 env: envRecord(props.env),
303 extraFiles: extraFiles as unknown as ExtraFile[] | undefined,
304 build:
305 config.install !== undefined ? { install: config.install } : undefined,
306 });
307 yield* Cdn("Cdn", {
308 service,
309 environment,
310 htmlCaching: "AUTO",
311 purgeOnDeploy: "HTML",
312 });
313
314 if (props.domain !== undefined && props.domain.length > 0) {
315 yield* CustomDomain("Domain", {
316 service,
317 environment,
318 domain: props.domain,
319 targetPort: WEBSITE_PORT,
320 });
321 return {
322 url: `https://${props.domain}`,
323 service,
324 project,
325 } satisfies Website;
326 }
327
328 return {
329 url: service.url,
330 service,
331 project,
332 } satisfies Website;
333});
334
335/**
336 * Composite-level tagged errors (`FrameworkServerError`, filesystem)
337 * are defects — `Alchemy.Stack` only admits `ConfigError` on the user
338 * effect.
339 */
340export const makeFrameworkSite = (
341 id: string,
342 props: FrameworkSiteProps,
343 config: FrameworkSiteConfig,
344) => runFrameworkSite(id, props, config).pipe(Effect.orDie);