1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import { AlchemyContext } from "../../AlchemyContext.ts";
4import type { MemoOptions } from "../../Command/Memo.ts";
5import type { Input, InputProps } from "../../Input.ts";
6import * as Output from "../../Output.ts";
7import { ProviderModePolicy } from "../../ProviderMode.ts";
8import {
9 Function as LambdaFunction,
10 type FunctionProps,
11} from "../Lambda/Function.ts";
12import { asRouterDomain, registerDevRouterRoute } from "./DevRouterRoute.ts";
13import { Server, type ServerDevProps } from "../../Website/Server.ts";
14import { makeKvSite, type StaticSiteProps } from "./StaticSite.ts";
15import {
16 normalizeWebsiteDomain,
17 type WebsiteAssetsConfig,
18 type WebsiteDomainProps,
19 type WebsiteEdgeProps,
20 type WebsiteInvalidationProps,
21} from "./shared.ts";
22
23/**
24 * Props shared by every framework website composite (SvelteKit, Nuxt,
25 * Waku, Octane, Astro). Each composite extends this with its
26 * framework-specific configuration, kept FLAT — the composite is the
27 * framework, so framework keys need no namespace.
28 */
29export interface FrameworkSiteProps {
30 /**
31 * Project root directory (the directory containing `package.json`).
32 * @default "."
33 */
34 rootDir?: string;
35 /**
36 * Controls which files are hashed to decide whether the build re-runs.
37 * @default true
38 */
39 memo?: MemoOptions | boolean;
40 /**
41 * Environment variables for the SSR server (Lambda environment).
42 * Values accept `Output`s (e.g. `API_URL: api.url`).
43 */
44 env?: Record<string, any>;
45 /**
46 * Memory allocated to the server function, in MB.
47 * @default 1024
48 */
49 memorySize?: number;
50 /**
51 * Maximum server request duration.
52 * @default 30 seconds
53 */
54 timeout?: Duration.Duration;
55 /**
56 * Server instruction set architecture.
57 * @default "x86_64"
58 */
59 architecture?: "x86_64" | "arm64";
60 /**
61 * Lambda runtime for the server function.
62 * @default "nodejs24.x"
63 */
64 runtime?: FunctionProps["runtime"];
65 /**
66 * Static asset upload configuration.
67 */
68 assets?: WebsiteAssetsConfig;
69 /**
70 * Options for the local dev server that runs this site under
71 * `alchemy dev`.
72 */
73 dev?: ServerDevProps;
74 /**
75 * Optional custom domain. A string is shorthand for `{ name }`; `null`
76 * explicitly clears a previously set domain. Set `domain.router` to
77 * serve the site through an existing `AWS.Website.Router` instead of a
78 * standalone CloudFront distribution.
79 */
80 domain?: string | WebsiteDomainProps | null;
81 /**
82 * Serve the site at its CloudFront default domain
83 * (`https://dxxxx.cloudfront.net`). `false` 301s default-domain requests
84 * to `https://<domain.name>` at the edge and excludes the default domain
85 * from the `urls` output. Requires `domain`; not applicable when
86 * `domain.router` is set.
87 * @default true
88 */
89 cloudfrontUrl?: boolean;
90 /**
91 * Additional CloudFront Function customizations.
92 */
93 edge?: WebsiteEdgeProps;
94 /**
95 * Optional deterministic S3 bucket name for the asset bucket.
96 */
97 bucketName?: string;
98 /**
99 * Whether to delete uploaded objects when the bucket is destroyed.
100 * @default false
101 */
102 forceDestroy?: boolean;
103 /**
104 * CloudFront invalidation behavior.
105 * @default { paths: "all", wait: false }
106 */
107 invalidation?: false | WebsiteInvalidationProps;
108 /**
109 * User-defined tags applied to created resources.
110 */
111 tags?: Record<string, string>;
112}
113
114/** Per-framework wiring for {@link makeFrameworkSite}. */
115export interface FrameworkSiteConfig {
116 /** Display name used in error messages (e.g. `"SvelteKit"`). */
117 name: string;
118 /** Framework-integration module specifier. */
119 framework: string;
120 /** AWS deploy-target module specifier. */
121 target: string;
122 /**
123 * Framework-specific build options forwarded to the integration (e.g.
124 * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable.
125 */
126 options?: Record<string, unknown> | undefined;
127 /**
128 * Assets-only mode: every page was prerendered at build time, so no
129 * server function is created and misses resolve to the error page (or
130 * the index page for SPAs). Set by composites whose framework supports
131 * a fully static output (Astro's `output: "static"`).
132 */
133 static?: { spa?: boolean; errorPage?: string } | undefined;
134}
135
136/**
137 * The shared implementation behind the framework website composites:
138 * build the framework through its AWS deploy target, then deploy the
139 * server output on a streaming Lambda Function URL with static assets in
140 * S3 behind a CloudFront distribution (or attach to a shared Router).
141 *
142 * During `alchemy dev` the site is the framework's own dev server (native
143 * HMR) and no cloud resources are declared; `Alchemy.remote()` opts back
144 * into the full live deployment.
145 *
146 * Callers pipe `Namespace.push(id)` themselves (the composites do), so
147 * resource FQNs are identical to the previous per-framework
148 * implementations.
149 */
150export const makeFrameworkSite = Effect.fn("AWS.Website.FrameworkSite")(
151 function* (
152 id: string,
153 propsIn: InputProps<FrameworkSiteProps>,
154 config: FrameworkSiteConfig,
155 ) {
156 // Props accept `Input<T>` throughout (matching the Cloudflare
157 // composites); values flow into the resources below, which resolve
158 // them at reconcile time. The plan-time reads in this function
159 // (domain-shape branching, dev wiring, the build's rootDir/memo)
160 // need concrete values — passing an `Output` for those specific
161 // fields is unsupported, same as on the Cloudflare side.
162 const props = propsIn as FrameworkSiteProps;
163 const ctx = yield* AlchemyContext;
164 const remoted = yield* ProviderModePolicy;
165 const isLocal = ctx.dev && remoted !== true;
166
167 const routerDomain = asRouterDomain(normalizeWebsiteDomain(props.domain));
168
169 // A Router-attached dev server is an ORIGIN for the emulated CloudFront
170 // edge, which runs in a container and reaches the host through its
171 // gateway address — it cannot open a connection to the host's loopback.
172 // Frameworks bind loopback by default (Vite picks `[::1]`), so the edge
173 // would get a refused connection and answer 502. Bind all interfaces
174 // unless the caller asked for a specific host. Standalone dev sites, and
175 // `mode: "external"` (we start nothing), keep the framework's default.
176 const dev: ServerDevProps | undefined =
177 isLocal && routerDomain && props.dev?.mode !== "external"
178 ? { ...props.dev, host: props.dev?.host ?? "0.0.0.0" }
179 : props.dev;
180
181 const build = yield* Server("Build", {
182 framework: config.framework,
183 target: config.target,
184 root: props.rootDir,
185 env: props.env,
186 options: config.options,
187 memo: props.memo,
188 dev,
189 });
190
191 if (isLocal) {
192 // Router-attached sites register with the Router in dev exactly as they
193 // do live — same resource types and ids — with the framework's dev
194 // server standing in for the S3 + Lambda origins.
195 const kvNamespace = routerDomain
196 ? yield* registerDevRouterRoute(routerDomain, build.url)
197 : undefined;
198 return {
199 bucket: undefined,
200 build,
201 files: undefined,
202 distribution: undefined,
203 invalidation: undefined,
204 kvNamespace,
205 server: undefined,
206 serverUrl: undefined,
207 url: build.url,
208 urls: [build.url],
209 };
210 }
211
212 const siteProps: StaticSiteProps = {
213 path: build.clientDir as unknown as string,
214 assets: props.assets,
215 domain: props.domain,
216 cloudfrontUrl: props.cloudfrontUrl,
217 edge: props.edge,
218 bucketName: props.bucketName,
219 forceDestroy: props.forceDestroy,
220 invalidation: props.invalidation,
221 tags: props.tags,
222 };
223
224 if (config.static) {
225 const site = yield* makeKvSite(id, {
226 ...siteProps,
227 errorPage: config.static.errorPage,
228 spa: config.static.spa,
229 });
230 return {
231 ...site,
232 build,
233 server: undefined,
234 serverUrl: undefined,
235 };
236 }
237
238 const server = yield* LambdaFunction("Server", {
239 main: build.serverEntry as unknown as string,
240 handler: "handler",
241 isExternal: true,
242 // The AWS deploy target's finishing pass writes the server directory
243 // as a complete Node deployment unit (entry + chunks) — ship it
244 // as-is.
245 bundle: false,
246 runtime: props.runtime ?? "nodejs24.x",
247 architecture: props.architecture,
248 memorySize: props.memorySize ?? 1024,
249 timeout: props.timeout ?? Duration.seconds(30),
250 env: props.env,
251 functionUrl: {
252 authType: "NONE",
253 invokeMode: "RESPONSE_STREAM",
254 },
255 });
256
257 const serverHost = Output.map((url: string | undefined) => {
258 if (!url) {
259 throw new Error(
260 `The ${config.name} server function did not produce a Function URL.`,
261 );
262 }
263 return new URL(url).hostname;
264 })(server.functionUrl as any) as Input<string>;
265
266 const site = yield* makeKvSite(id, siteProps, { serverHost });
267
268 return {
269 ...site,
270 build,
271 server,
272 serverUrl: server.functionUrl,
273 };
274 },
275);