1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Equal from "effect/Equal";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as NodeNet from "node:net";
7import { hashDirectory, type MemoOptions } from "../Command/Memo.ts";
8import { havePropsChanged, isResolved } from "../Diff.ts";
9import * as LocalProvider from "../Local/LocalProvider.ts";
10import * as ProviderLayer from "../Local/ProviderLayer.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { initialCwd, moduleExtension } from "../Util/Node.ts";
14import { sha256Object } from "../Util/sha256.ts";
15
16/**
17 * The structural slice of a framework-integration module
18 * (`@alchemy.run/frontend-frameworks/nuxt`, `@alchemy.run/frontend-frameworks/astro`, ...) this resource
19 * drives. Typed structurally so alchemy carries no dependency on
20 * `@distilled.cloud/framework-core` — the *project's* install is always the
21 * one loaded. Requirement channels are erased (the module is loaded
22 * dynamically, so its effects are typed post-hoc): `build` is
23 * requirement-free and `dev` runs in the caller's ambient Scope.
24 */
25interface FrameworkModule {
26 readonly make: (options: Record<string, unknown>) => Effect.Effect<
27 {
28 readonly build: (options?: {
29 readonly root?: string;
30 readonly env?: Record<string, string>;
31 }) => Effect.Effect<FrameworkBuildOutputSlice, unknown>;
32 readonly dev: (options?: {
33 readonly root?: string;
34 readonly port?: number;
35 readonly host?: string;
36 }) => Effect.Effect<{ readonly url: string }, unknown>;
37 },
38 unknown,
39 FileSystem.FileSystem | Path.Path
40 >;
41}
42
43/** The structural slice of framework-core's `BuildOutput` this resource reads. */
44interface FrameworkBuildOutputSlice {
45 readonly distDirectory?: string | undefined;
46 readonly clientDirectory: string | undefined;
47 readonly serverModules: Array<{ readonly name: string }> | undefined;
48}
49
50export class FrameworkServerError extends Data.TaggedError(
51 "FrameworkServerError",
52)<{
53 readonly framework: string;
54 readonly message: string;
55 readonly cause?: unknown;
56}> {}
57
58/** Drops `undefined`-valued entries from {@link ServerProps.env}. */
59const definedEnv = (
60 env: Record<string, string | undefined> | undefined,
61): Record<string, string> | undefined =>
62 env === undefined
63 ? undefined
64 : Object.fromEntries(
65 Object.entries(env).filter(
66 (entry): entry is [string, string] => entry[1] !== undefined,
67 ),
68 );
69
70/**
71 * Options for the local dev server that runs a framework site under
72 * `alchemy dev`.
73 *
74 * Use `{ mode: "external" }` to skip starting a dev server entirely —
75 * useful when an external dev server (e.g. one you run yourself in
76 * another terminal) is serving the site instead.
77 */
78export type ServerDevProps =
79 | {
80 /**
81 * Run the framework's own dev server locally (the default).
82 * @default "server"
83 */
84 mode?: "server";
85 /**
86 * Host the dev server binds to. Defaults to the framework's own
87 * choice (localhost).
88 */
89 host?: string;
90 /**
91 * Preferred port for the dev server. Defaults to an ephemeral port.
92 * If the port is unavailable, the next free port is used unless
93 * {@link strictPort} is `true`.
94 */
95 port?: number;
96 /**
97 * When `true`, fail instead of falling back to another port if
98 * {@link port} is already in use.
99 * @default false
100 */
101 strictPort?: boolean;
102 }
103 | {
104 /**
105 * Don't start a dev server; an external dev server is running instead.
106 */
107 mode: "external";
108 /**
109 * URL the external dev server is reachable at, if applicable.
110 * This will be returned as the `url` attribute of the Server resource.
111 */
112 url?: string;
113 };
114
115export interface ServerProps {
116 /**
117 * Module specifier of the framework-integration package that implements
118 * the build and dev server (e.g. `"@alchemy.run/frontend-frameworks/nuxt"`). Must be
119 * installed in your project — it is loaded dynamically at deploy time and
120 * drives your project's own framework toolchain.
121 */
122 framework: string;
123 /**
124 * Project root directory (the directory containing the framework config),
125 * relative to the process working directory.
126 * @default "."
127 */
128 root?: string;
129 /**
130 * The deploy target the build is produced for: a module specifier
131 * resolved from your project's `node_modules`
132 * (e.g. `"@alchemy.run/frontend-frameworks/nuxt/aws"`).
133 */
134 target: string;
135 /**
136 * Framework-integration options forwarded to the module's `make()`
137 * (e.g. `{ nuxt: { ... } }` config overrides). Must be JSON-serializable —
138 * the value participates in the memo hash and is persisted in state.
139 */
140 options?: Record<string, unknown>;
141 /**
142 * Options for the local dev server that runs this site under
143 * `alchemy dev`. See {@link ServerDevProps}.
144 */
145 dev?: ServerDevProps;
146 /**
147 * Environment variables for the framework server. On deploy the
148 * composite sets these on the Lambda; during `alchemy dev` they are
149 * applied to the dev server's process environment (the dev sidecar and
150 * any child it spawns) so server code reads the same values in both
151 * modes. Changing a value restarts the dev server.
152 *
153 * Entries whose value is `undefined` are dropped — this lets website
154 * composites forward optional values (e.g. an `Output` service URL that
155 * resolves to `undefined`) without filtering first.
156 */
157 env?: Record<string, string | undefined>;
158 /**
159 * Controls which files are hashed to decide whether the build should
160 * re-run. By default every non-gitignored file in the root is hashed,
161 * plus the nearest lockfile. Set `false` to rebuild on every deploy.
162 * @default true
163 */
164 memo?: MemoOptions | boolean;
165}
166
167export interface Server extends Resource<
168 "Website.Server",
169 ServerProps,
170 {
171 /**
172 * Root output directory of the build (e.g. `.output`), relative to the
173 * process's initial working directory. `undefined` in dev mode (no
174 * production build runs).
175 */
176 distDir: string | undefined;
177 /**
178 * Static-assets directory of the build (prerendered pages included),
179 * relative to the initial working directory. `undefined` when the build
180 * produced no client assets, and in dev mode.
181 */
182 clientDir: string | undefined;
183 /**
184 * The server entry module on disk (e.g. `.output/server/index.mjs`),
185 * relative to the initial working directory. `undefined` for
186 * assets-only builds, and in dev mode.
187 */
188 serverEntry: string | undefined;
189 /**
190 * The framework dev server's local URL (e.g. `http://localhost:3000`)
191 * during `alchemy dev`. `undefined` on deploys.
192 */
193 url: string | undefined;
194 hash: {
195 /**
196 * Hash of the input files (plus framework/target/options) that
197 * produced this build. `undefined` in dev mode.
198 */
199 input: string | undefined;
200 /**
201 * Hash of the build output files. `undefined` in dev mode.
202 */
203 output: string | undefined;
204 };
205 }
206> {}
207
208/**
209 * The framework toolchain half of a website: on `alchemy deploy` it
210 * runs the framework's production build with a platform deploy target and
211 * tracks the on-disk output in state; on `alchemy dev` it runs the
212 * framework's OWN dev server (native HMR via the framework's kit) in the
213 * dev sidecar and exposes its local URL. Shared by AWS, Fly, Hetzner, and
214 * Railway website composites.
215 *
216 * The framework package and the target module are loaded from *your*
217 * project's `node_modules`, so your project's framework version drives the
218 * build. Build inputs are content-hashed so an unchanged project skips the
219 * rebuild entirely.
220 *
221 * ### Building Frameworks
222 * **Example:** Nuxt For AWS Lambda
223 * ```typescript
224 * const server = yield* Server("Server", {
225 * framework: "@alchemy.run/frontend-frameworks/nuxt",
226 * target: "@alchemy.run/frontend-frameworks/nuxt/aws",
227 * root: "./app",
228 * });
229 * // deploy: server.serverEntry -> .output/server/index.mjs (Lambda handler)
230 * // server.clientDir -> .output/public (static assets for the CDN)
231 * // dev: server.url -> http://localhost:<port> (framework HMR server)
232 * ```
233 *
234 * @resource
235 */
236export const Server = Resource<Server>("Website.Server", {
237 aliases: ["AWS.Website.Server", "AWS.Website.FrameworkBuild"],
238});
239
240const importFrameworkModule = (specifier: string) =>
241 Effect.tryPromise({
242 try: () => import(specifier) as Promise<Partial<FrameworkModule>>,
243 catch: (cause) =>
244 new FrameworkServerError({
245 framework: specifier,
246 message:
247 `Failed to import the framework integration "${specifier}". ` +
248 "It must be installed in your project (it is loaded dynamically at deploy time).",
249 cause,
250 }),
251 }).pipe(
252 Effect.flatMap((module_) =>
253 typeof module_.make === "function"
254 ? Effect.succeed(module_ as FrameworkModule)
255 : Effect.fail(
256 new FrameworkServerError({
257 framework: specifier,
258 message: `"${specifier}" does not export the framework-integration contract (a "make" function)`,
259 }),
260 ),
261 ),
262 );
263
264const makeFramework = (props: ServerProps, root: string) =>
265 importFrameworkModule(props.framework).pipe(
266 Effect.flatMap((module_) =>
267 Effect.mapError(
268 module_.make({
269 ...props.options,
270 root,
271 target: props.target,
272 }),
273 (cause) =>
274 new FrameworkServerError({
275 framework: props.framework,
276 message: "Failed to initialize the framework integration",
277 cause,
278 }),
279 ),
280 ),
281 );
282
283export const ServerProvider = () =>
284 ProviderLayer.dual(Server, {
285 live: ServerProviderLive,
286 local: ServerProviderLocal,
287 });
288
289export const ServerProviderLive = () =>
290 Provider.effect(
291 Server,
292 Effect.gen(function* () {
293 const path = yield* Path.Path;
294 const fs = yield* FileSystem.FileSystem;
295
296 const runBuild = Effect.fn(function* (props: ServerProps) {
297 const root = path.resolve(initialCwd, props.root ?? ".");
298 const service = yield* makeFramework(props, root);
299 return yield* Effect.mapError(
300 service.build({ root, env: definedEnv(props.env) }),
301 (cause) =>
302 new FrameworkServerError({
303 framework: props.framework,
304 message: `The ${props.framework} build failed`,
305 cause,
306 }),
307 );
308 });
309
310 const hashInput = (props: ServerProps, root: string) =>
311 hashDirectory({
312 cwd: root,
313 memo:
314 props.memo === true ||
315 props.memo === undefined ||
316 props.memo === false
317 ? {}
318 : props.memo,
319 }).pipe(
320 Effect.flatMap((files) =>
321 sha256Object({
322 files,
323 framework: props.framework,
324 target: props.target,
325 options: props.options,
326 }),
327 ),
328 );
329
330 const hashOutput = (props: ServerProps, distDir: string) =>
331 hashDirectory({
332 cwd: distDir,
333 memo: {
334 // Next.js serves from its project root, not a dedicated output directory.
335 exclude:
336 path.resolve(distDir) ===
337 path.resolve(initialCwd, props.root ?? ".")
338 ? [
339 "**/node_modules/**",
340 "**/.git/**",
341 "**/.alchemy/**",
342 ".next/cache/**",
343 ]
344 : [],
345 lockfile: false,
346 },
347 });
348
349 const makeOutput = Effect.fn(function* (
350 props: ServerProps,
351 built: FrameworkBuildOutputSlice,
352 ) {
353 const root = path.resolve(initialCwd, props.root ?? ".");
354 const distDir = built.distDirectory ?? path.join(root, "dist");
355 if (!(yield* fs.exists(distDir))) {
356 return yield* Effect.fail(
357 new FrameworkServerError({
358 framework: props.framework,
359 message: `The build produced no output directory at ${distDir}`,
360 }),
361 );
362 }
363 const entryName = built.serverModules?.[0]?.name;
364 return {
365 distDir: path.relative(initialCwd, distDir),
366 clientDir:
367 built.clientDirectory !== undefined
368 ? path.relative(initialCwd, built.clientDirectory)
369 : undefined,
370 serverEntry:
371 entryName !== undefined
372 ? path.relative(initialCwd, path.join(distDir, entryName))
373 : undefined,
374 url: undefined,
375 hash:
376 props.memo === false
377 ? { input: undefined, output: undefined }
378 : yield* Effect.all(
379 {
380 input: hashInput(props, root),
381 output: hashOutput(props, distDir),
382 },
383 { concurrency: "unbounded" },
384 ),
385 };
386 });
387
388 return {
389 list: () => Effect.succeed([]),
390 diff: Effect.fn(function* ({ olds, news, output }) {
391 if (!output || !isResolved(news)) return undefined;
392 if (news.memo === false || !output.hash.input || !output.hash.output)
393 return { action: "update" };
394 if (havePropsChanged(olds, news)) return { action: "update" };
395 const root = path.resolve(initialCwd, news.root ?? ".");
396 // Cheap check: same inputs + output still on disk with the same
397 // content hash -> noop without re-running the build.
398 const input = yield* hashInput(news, root);
399 if (input !== output.hash.input) return { action: "update" };
400 if (output.distDir === undefined) return { action: "update" };
401 const distDir = path.resolve(initialCwd, output.distDir);
402 if (!(yield* fs.exists(distDir))) return { action: "update" };
403 const outHash = yield* hashOutput(news, distDir);
404 return {
405 action: Equal.equals(outHash, output.hash.output)
406 ? "noop"
407 : "update",
408 };
409 }),
410 reconcile: Effect.fn(function* ({ news }) {
411 const built = yield* runBuild(news);
412 return yield* makeOutput(news, built);
413 }),
414 delete: Effect.fn(function* ({ olds, output }) {
415 if (output.distDir === undefined) return;
416 const root = path.resolve(initialCwd, olds.root ?? ".");
417 const distDir = path.resolve(initialCwd, output.distDir);
418 if (!(yield* fs.exists(root)) || !(yield* fs.exists(distDir))) return;
419 // Some frameworks (Next.js) serve from the project root itself.
420 // Only dedicated output directories inside that root are disposable.
421 // Canonical paths also protect roots reached through a symlink.
422 const relative = path.relative(
423 yield* fs.realPath(root),
424 yield* fs.realPath(distDir),
425 );
426 if (
427 relative === "" ||
428 relative === ".." ||
429 relative.startsWith(`..${path.sep}`) ||
430 path.isAbsolute(relative)
431 )
432 return;
433 yield* fs.remove(distDir, { recursive: true });
434 }),
435 };
436 }),
437 );
438
439/**
440 * Try to bind `port` on `host`; resolves `true` when the port is free.
441 * The listener is closed immediately — the port is only observed
442 * available, not reserved, so the caller should bind promptly and the
443 * framework still handles the (tiny) race window itself.
444 */
445const isPortFree = (port: number, host: string) =>
446 Effect.callback<boolean>((resume) => {
447 const server = NodeNet.createServer();
448 server.unref();
449 server.once("error", () => resume(Effect.succeed(false)));
450 server.listen(port, host, () => {
451 server.close(() => resume(Effect.succeed(true)));
452 });
453 });
454
455/**
456 * Resolve the dev server's port from the `dev` props: probe the preferred
457 * port and either fail (`strictPort`) or walk forward to the next free
458 * port when it is taken.
459 */
460const resolveDevPort = Effect.fn(function* (options: {
461 readonly framework: string;
462 readonly port: number;
463 readonly host: string;
464 readonly strictPort: boolean;
465}) {
466 const { framework, port, host, strictPort } = options;
467 if (yield* isPortFree(port, host)) return port;
468 if (strictPort) {
469 return yield* Effect.fail(
470 new FrameworkServerError({
471 framework,
472 message: `Port ${port} is already in use and \`dev.strictPort\` is set`,
473 }),
474 );
475 }
476 for (let candidate = port + 1; candidate <= port + 100; candidate++) {
477 if (yield* isPortFree(candidate, host)) return candidate;
478 }
479 return yield* Effect.fail(
480 new FrameworkServerError({
481 framework,
482 message: `No free port found between ${port} and ${port + 100}`,
483 }),
484 );
485});
486
487/**
488 * A framework dev server may advertise its *bind* address — nuxt echoes the
489 * host it was told to listen on, so a Router-attached site (which binds
490 * `0.0.0.0` to be reachable from the emulator container) reports
491 * `http://0.0.0.0:{port}/`. The unspecified address is not a connectable
492 * host: normalize it to `localhost` for the `url` attribute (browser links,
493 * Router dev routing, the emulated CloudFront edge dialing the origin). The
494 * server itself still listens on every interface.
495 */
496const normalizeAdvertisedUrl = (url: string) =>
497 url.replace(
498 /^(https?:\/\/)(?:0\.0\.0\.0|\[::\]|\[0+(?::0+){7}\])(?=[:/]|$)/,
499 "$1localhost",
500 );
501
502/**
503 * The `alchemy dev` variant: runs the framework's own dev server (native
504 * HMR through the framework's kit — nuxt, astro, ...) inside the dev
505 * sidecar, so it survives user-code hot reloads. Restarts when the
506 * framework/target/root/options config changes.
507 */
508export const ServerProviderLocal = () =>
509 LocalProvider.make(
510 Server,
511 import.meta.resolve(
512 `./ServerLocal${moduleExtension(import.meta.url)}`,
513 import.meta.url,
514 ),
515 Effect.gen(function* () {
516 const path = yield* Path.Path;
517 const fs = yield* FileSystem.FileSystem;
518 return {
519 start: Effect.fn(function* ({ news: props }) {
520 // External mode: an external dev server (run out-of-band by the
521 // user) serves the site — don't import the framework or spawn
522 // anything, just surface the external URL as the attribute.
523 if (props.dev?.mode === "external") {
524 return {
525 distDir: undefined,
526 clientDir: undefined,
527 serverEntry: undefined,
528 url: props.dev.url,
529 hash: { input: undefined, output: undefined },
530 };
531 }
532 const dev = props.dev;
533 const root = path.resolve(initialCwd, props.root ?? ".");
534 // Dev/live env parity: the composite sets these on the Lambda on
535 // deploy; in dev the framework server runs inside the sidecar
536 // (or as its child — `next dev`), so the sidecar's process env
537 // is what SSR code reads. `env` is part of the restart surface,
538 // so a changed value restarts the dev server with the new
539 // environment. Values are not unset on stop: sibling dev servers
540 // share the process, so clearing could clobber their keys.
541 if (props.env !== undefined) {
542 yield* Effect.sync(() => {
543 for (const [key, value] of Object.entries(props.env!)) {
544 if (value === undefined) continue;
545 process.env[key] = String(value);
546 }
547 });
548 }
549 const service = yield* makeFramework(props, root).pipe(
550 Effect.provideService(FileSystem.FileSystem, fs),
551 Effect.provideService(Path.Path, path),
552 );
553 // Resolve the port BEFORE handing off to the framework: probe
554 // the preferred port, fail on `strictPort`, otherwise walk
555 // forward to the next free port. Without a preferred port the
556 // framework picks its own (ephemeral) port.
557 const port =
558 dev?.port !== undefined
559 ? yield* resolveDevPort({
560 framework: props.framework,
561 port: dev.port,
562 host: dev.host ?? "127.0.0.1",
563 strictPort: dev.strictPort ?? false,
564 })
565 : undefined;
566 // `dev` is scoped: the server lives in the instance scope the
567 // LocalProvider helper provides and is torn down on
568 // restart/delete. It resolves at readiness with the local URL.
569 const { url } = yield* Effect.mapError(
570 service.dev({ root, port, host: dev?.host }),
571 (cause) =>
572 new FrameworkServerError({
573 framework: props.framework,
574 message: `The ${props.framework} dev server failed to start`,
575 cause,
576 }),
577 );
578 return {
579 distDir: undefined,
580 clientDir: undefined,
581 serverEntry: undefined,
582 url: normalizeAdvertisedUrl(url),
583 hash: { input: undefined, output: undefined },
584 };
585 }),
586 } satisfies LocalProvider.LocalProviderSpec<Server>;
587 }),
588 );