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 { 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 {
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 { App } from "../App.ts";
23import { Bucket } from "../Bucket.ts";
24import { Certificate } from "../Certificate.ts";
25import { IpAssignment } from "../IpAssignment.ts";
26import type { Providers } from "../Providers.ts";
27import { Service, type ServiceProps } from "../Service.ts";
28import { AssetDeployment } from "./AssetDeployment.ts";
29
30/**
31 * A resource-valued prop: the resource itself, or an Effect that produces
32 * it (so `yield* App(...)` and `App(...)` both type-check).
33 */
34export type Ref<T> = T | Effect.Effect<T, never, Providers>;
35
36export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling };
37export { staticConfigFromAssets };
38
39/**
40 * Props shared by every Fly framework website composite.
41 */
42export interface FrameworkSiteProps {
43 /** Deployment strategy forwarded to the hosted Fly Service. */
44 deploy?: ServiceProps["deploy"];
45 /** Process shutdown policy. The framework server must handle the signal. */
46 shutdown?: ServiceProps["shutdown"];
47 /** Named Machine readiness checks. */
48 checks?: ServiceProps["checks"];
49 /** Override proxy services and their routing health checks. */
50 services?: ServiceProps["services"];
51 /**
52 * Project root directory (the directory containing `package.json`).
53 * @default "."
54 */
55 rootDir?: string;
56 /**
57 * Controls which files are hashed to decide whether the build re-runs.
58 * @default true
59 */
60 memo?: MemoOptions | boolean;
61 /**
62 * Parent Fly App. Accepts a `Fly.App` or an Effect that produces one.
63 * When omitted, a `Fly.App` is created under this site's namespace.
64 */
65 app?: Ref<App>;
66 /**
67 * Process environment for the hosted server. Not Cloudflare Worker
68 * bindings — values become Machine env vars. Accepts `Output`s
69 * (e.g. `VITE_API_URL: api.url`).
70 */
71 env?: Record<
72 string,
73 string | Redacted.Redacted<string> | Output.Output<string | undefined>
74 >;
75 /**
76 * Static-asset routing (`notFoundHandling`, `htmlHandling`). Hashed
77 * client files are uploaded to Tigris regardless of this bag.
78 */
79 assets?: WebsiteAssetsProps;
80 /**
81 * Options for the local dev server that runs this site under
82 * `alchemy dev`.
83 */
84 dev?: ServerDevProps;
85 /**
86 * Optional custom hostname. Requests ACME (`Fly.Certificate`) on the
87 * App. `url` becomes `https://<domain>` (existing DNS only for v1).
88 */
89 domain?: string;
90 /**
91 * User-defined tags. Accepted for API parity; Fly Services do not
92 * surface resource tags.
93 */
94 tags?: Record<string, string>;
95}
96
97/** Per-framework wiring for {@link makeFrameworkSite}. */
98export interface FrameworkSiteConfig {
99 /** Display name used in error messages (e.g. `"SvelteKit"`). */
100 name: string;
101 /** Framework-integration module specifier. */
102 framework: string;
103 /** Node deploy-target module specifier. */
104 target: string;
105 /**
106 * Framework-specific build options forwarded to the integration (e.g.
107 * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable.
108 */
109 options?: Record<string, unknown> | undefined;
110 /**
111 * Assets-only routing forwarded to the Node target (Vite SPA fallback).
112 * `spa` also publishes client files to Tigris at `/`.
113 */
114 static?: { spa?: boolean; errorPage?: string } | undefined;
115 /**
116 * Vocs/Waku: serve `about/index.html` at `/about`.
117 * @default "none"
118 */
119 htmlHandling?: "none" | "drop-trailing-slash";
120 /**
121 * Packages installed into the Machine image with `npm install` instead
122 * of bundling (Next.js needs `next`).
123 */
124 install?: string[] | undefined;
125 /**
126 * Skip baking `clientDirectory` at `/app/dist`. Next.js serves `.next`
127 * from the image root instead.
128 */
129 skipClientAssets?: boolean | undefined;
130}
131
132export interface FrameworkSite {
133 /**
134 * Public site URL. Local framework URL under `alchemy dev`;
135 * `https://{app}.fly.dev` (or `https://{domain}`) on deploy.
136 */
137 url: string | Output.Output<string | undefined> | undefined;
138 /** Parent Fly App. `undefined` during `alchemy dev`. */
139 app: App | undefined;
140 /** Hosted Fly Service. `undefined` during `alchemy dev`. */
141 service: Service | undefined;
142 /** Shared Anycast IPv4 so `{app}.fly.dev` answers. */
143 ip: IpAssignment | undefined;
144 /** ACME certificate when {@link FrameworkSiteProps.domain} is set. */
145 certificate: Certificate | undefined;
146}
147
148export class FrameworkSiteError extends Data.TaggedError("FrameworkSiteError")<{
149 readonly framework: string;
150 readonly message: string;
151 readonly cause?: unknown;
152}> {}
153
154const DEFAULT_PORT = 3000;
155
156const resolveRef = <T>(ref: Ref<T>): Effect.Effect<T, never, Providers> =>
157 Effect.isEffect(ref) ? ref : Effect.succeed(ref);
158
159const envRecord = (
160 env:
161 | Record<
162 string,
163 string | Redacted.Redacted<string> | Output.Output<string | undefined>
164 >
165 | undefined,
166): Record<string, string | Output.Output<string | undefined>> | undefined => {
167 if (env === undefined) return undefined;
168 return Object.fromEntries(
169 Object.entries(env).map(([key, value]) => [
170 key,
171 Redacted.isRedacted(value) ? Redacted.value(value) : value,
172 ]),
173 );
174};
175
176const runFrameworkSite = Effect.fn("Fly.Website.FrameworkSite")(function* (
177 _id: string,
178 props: FrameworkSiteProps,
179 config: FrameworkSiteConfig,
180) {
181 const ctx = yield* AlchemyContext;
182 const remoted = yield* ProviderModePolicy;
183 const isLocal = ctx.dev && remoted !== true;
184 const path = yield* Path.Path;
185 const fs = yield* FileSystem.FileSystem;
186
187 const build = yield* FrameworkServer("Build", {
188 framework: config.framework,
189 target: config.target,
190 root: props.rootDir,
191 env: envRecord(props.env),
192 options: config.options,
193 memo: props.memo,
194 dev: props.dev,
195 });
196
197 if (isLocal) {
198 return {
199 url: build.url,
200 app: undefined,
201 service: undefined,
202 ip: undefined,
203 certificate: undefined,
204 } satisfies FrameworkSite;
205 }
206
207 // The build runs at APPLY time (`Website.Server` is a resource), so its
208 // attributes are Outputs here — derive every deploy input lazily and let
209 // the engine resolve them once the build has produced real paths. Reading
210 // `build.distDir` eagerly in the composite body would observe an Output
211 // proxy, not a string.
212 const buildOut = Output.mapEffect(
213 ([serverEntry, distDir]: [string | undefined, string | undefined]) =>
214 Effect.gen(function* () {
215 if (serverEntry === undefined || distDir === undefined) {
216 return yield* Effect.die(
217 new FrameworkSiteError({
218 framework: config.framework,
219 message: `The ${config.name} build produced no Node serve entry (serverModules[0]). The Node deploy target should write serve-node.mjs.`,
220 }),
221 );
222 }
223 const main = path.resolve(initialCwd, serverEntry);
224 if (!(yield* fs.exists(main).pipe(Effect.orElseSucceed(() => false)))) {
225 return yield* Effect.die(
226 new FrameworkSiteError({
227 framework: config.framework,
228 message: `The ${config.name} build produced no server entry at ${main}`,
229 }),
230 );
231 }
232 return { distDir: path.resolve(initialCwd, distDir), main };
233 }),
234 )(
235 Output.all(
236 build.serverEntry as unknown as Output.Output<string | undefined>,
237 build.distDir as unknown as Output.Output<string | undefined>,
238 ) as unknown as Output.Output<[string | undefined, string | undefined]>,
239 );
240 const main = Output.map(buildOut, (out) => out.main);
241
242 const extraFiles = Output.mapEffect(
243 (out: { distDir: string; main: string }) =>
244 packSiteExtraFiles(
245 out.distDir,
246 config.skipClientAssets === true ? "next" : "client",
247 ),
248 )(buildOut);
249
250 const app =
251 props.app !== undefined ? yield* resolveRef(props.app) : yield* App("App");
252
253 const ip = yield* IpAssignment("Shared", {
254 app,
255 type: "shared_v4",
256 });
257
258 // SPA: hashed `/assets` at Tigris. HTML and unknown paths stay on
259 // origin (NodeServe `notFoundHandling: "spa"`). Intercepting `/` with
260 // Tigris hangs SPA fallbacks — Fly does not rewrite `/counter/42` to
261 // `index.html`.
262 let statics:
263 | Output.Output<
264 | Array<{
265 guestPath: string;
266 urlPrefix: string;
267 tigrisBucket?: string;
268 indexDocument?: string;
269 }>
270 | undefined
271 >
272 | undefined;
273 if (config.static?.spa === true) {
274 const clientDir = Output.map(
275 build.clientDir as unknown as Output.Output<string | undefined>,
276 (dir) => {
277 if (dir === undefined) {
278 throw new FrameworkSiteError({
279 framework: config.framework,
280 message: `The ${config.name} build produced no client assets directory`,
281 });
282 }
283 return path.resolve(initialCwd, dir);
284 },
285 );
286 const bucket = yield* Bucket("Assets", { public: true });
287 yield* AssetDeployment("Files", {
288 bucket,
289 sourcePath: clientDir as unknown as string,
290 purge: true,
291 });
292 statics = Output.mapEffect(([clientDir, bucketName]: [string, string]) =>
293 Effect.gen(function* () {
294 const assetsDir = path.join(clientDir, "assets");
295 const exists = yield* fs
296 .exists(assetsDir)
297 .pipe(Effect.orElseSucceed(() => false));
298 return exists
299 ? [
300 {
301 guestPath: "/assets",
302 urlPrefix: "/assets",
303 tigrisBucket: bucketName,
304 },
305 ]
306 : undefined;
307 }),
308 )(
309 Output.all(
310 clientDir,
311 bucket.name as unknown as Output.Output<string>,
312 ) as unknown as Output.Output<[string, string]>,
313 );
314 }
315
316 const service = yield* Service("Service", {
317 app,
318 deploy: props.deploy,
319 shutdown: props.shutdown,
320 checks: props.checks,
321 services: props.services,
322 main: main as unknown as string,
323 port: DEFAULT_PORT,
324 // Node + nitro SSR needs more than the Machine default 256MB.
325 guest: { memoryMb: 512 },
326 isExternal: true,
327 env: props.env,
328 extraFiles: extraFiles as unknown as Array<{
329 source: string;
330 dest: string;
331 }>,
332 statics: statics as unknown as
333 | Array<{
334 guestPath: string;
335 urlPrefix: string;
336 tigrisBucket?: string;
337 indexDocument?: string;
338 }>
339 | undefined,
340 build:
341 config.install !== undefined && config.install.length > 0
342 ? { install: config.install }
343 : undefined,
344 });
345
346 const certificate =
347 props.domain !== undefined
348 ? yield* Certificate("Certificate", {
349 app,
350 hostname: props.domain,
351 kind: "acme",
352 })
353 : undefined;
354
355 const url = props.domain !== undefined ? `https://${props.domain}` : app.url;
356
357 return { url, app, service, ip, certificate };
358});
359
360/**
361 * Shared implementation behind the Fly framework website composites:
362 * `Website.Server` runs the framework toolchain (dev sidecar / production
363 * build), then a live deploy hosts `serve-node.mjs` on a Fly.Service.
364 *
365 * Callers pipe `Namespace.push(id)` themselves (the composites do).
366 *
367 * Composite-level tagged errors (`FrameworkSiteError`, filesystem) are
368 * defects — `Alchemy.Stack` only admits `ConfigError` on the user
369 * effect, same as Cloudflare/AWS Website composites.
370 */
371export const makeFrameworkSite = (
372 id: string,
373 props: FrameworkSiteProps,
374 config: FrameworkSiteConfig,
375) => runFrameworkSite(id, props, config).pipe(Effect.orDie);
376
377/** Push {@link id} then run {@link makeFrameworkSite}. */
378export const frameworkSite = (
379 id: string,
380 props: FrameworkSiteProps,
381 config: FrameworkSiteConfig,
382) => makeFrameworkSite(id, props, config).pipe(Namespace.push(id));