1/**
2 * `@alchemy.run/frontend-frameworks/astro/aws` — the AWS Lambda deploy target
3 * for the Astro integration.
4 *
5 * Astro's AWS story is a plain Node SSR bundle behind a Lambda Function
6 * URL: the adapter integration pins this package's `aws-server` entrypoint
7 * (which wraps `App.render` with the `@alchemy.run/frontend-frameworks/aws-lambda`
8 * handler adapter) as the build's server entry, and forces the server build
9 * to be **self-contained** (`vite.ssr.noExternal: true`) so `dist/server`
10 * can ship to Lambda as-is (`bundle: false`). What this target owns:
11 *
12 * - **`integration`** — the adapter integration: registers itself via
13 * `setAdapter` at `astro:config:done` (rejecting a user-declared adapter
14 * with an actionable error), pins the `aws-server` entrypoint, and
15 * configures the server build for self-containment.
16 * - **`finish`** — a fully-static build (`output: "static"`: every route
17 * prerendered) deploys ASSETS-ONLY: `serverModules` is dropped so the
18 * composite skips the Lambda entirely.
19 * - **`selectServerEntry`** — pins the adapter's entry chunk as
20 * `serverModules[0]` (astro's page modules are rolldown inputs in the
21 * same environment, so the entry environment emits many entry chunks).
22 * - **`bundle`** — Node resolve conditions and the `@aws-sdk/` externals
23 * (the Lambda Node.js runtime ships SDK v3) for callers that post-process
24 * server code.
25 *
26 * No `devPlatform`: `dev` runs Astro's own Node dev server, which is
27 * already the AWS Lambda programming model (plain Node).
28 */
29import type { AstroInlineConfig, AstroIntegration } from "astro";
30import * as Effect from "effect/Effect";
31import { runBuildChild } from "../core/BuildChild.ts";
32import {
33 DeployTargetError,
34 makeDeployTarget,
35 type ServerEntryChunk,
36} from "../core/index.ts";
37import { make } from "./Astro.ts";
38import type { AstroTarget, AstroTargetBuildContext } from "./Target.ts";
39
40/**
41 * The importable specifier of this package's AWS Lambda server entrypoint —
42 * the module the adapter pins as `serverEntrypoint`. It exports the Lambda
43 * `handler` (streaming by default).
44 */
45export const SERVER_ENTRYPOINT =
46 "@alchemy.run/frontend-frameworks/astro/entrypoints/aws-server";
47
48/** AWS-specific target configuration. */
49export interface AstroAwsConfig {
50 /**
51 * Whether the emitted Lambda handler streams its response
52 * (`awslambda.streamifyResponse`, requires a Function URL with
53 * `invokeMode: RESPONSE_STREAM`). With `false` — or whenever the
54 * `awslambda` global is absent — the buffered handler answers
55 * APIGW/Function-URL events with a complete payload-v2 response object.
56 * @default true
57 */
58 readonly streaming?: boolean | undefined;
59}
60
61export interface AstroAwsTarget extends AstroTarget<AstroAwsConfig> {}
62
63/** Options for {@link distilledAws} (the adapter integration). */
64export interface DistilledAwsOptions {
65 /** See {@link AstroAwsConfig.streaming}. @default true */
66 readonly streaming?: boolean | undefined;
67 /**
68 * Reports the resolved build output mode (`astro:config:done`'s
69 * `buildOutput`): `"static"` when every route is prerendered, `"server"`
70 * when any route renders on demand. The target's `finish` pass uses it to
71 * strip `serverModules` from a fully-static build so the deploy is
72 * assets-only (no Lambda).
73 * @internal
74 */
75 readonly onBuildOutput?:
76 | ((buildOutput: "static" | "server") => void)
77 | undefined;
78 /**
79 * Reports the resolved server-entry file name (`config.build.serverEntry`,
80 * `entry.mjs` by default) so the entry chunk can be pinned as
81 * `serverModules[0]`.
82 * @internal
83 */
84 readonly onServerEntryName?: ((name: string) => void) | undefined;
85}
86
87/**
88 * The AWS Lambda adapter integration for Astro. Injected via
89 * `integrations` (after the user's) rather than `adapter`: it registers
90 * itself as the adapter at `astro:config:done`, where it also rejects a
91 * user-declared adapter with an actionable error.
92 */
93export const distilledAws = (
94 options: DistilledAwsOptions = {},
95): AstroIntegration => {
96 // Whether WE satisfied `config.adapter`. The user's astro.config.* loads
97 // natively and this integration is injected via `integrations`, but
98 // astro's build refuses server output unless `config.adapter` is set — so
99 // `astro:config:setup` injects a hookless marker when the config declares
100 // no adapter (the real adapter registration is `setAdapter` at
101 // `astro:config:done`). When the flag is still false at
102 // `astro:config:done`, the user declared their own adapter — a conflict.
103 let injectedAdapterMarker = false;
104 return {
105 name: "@alchemy.run/frontend-frameworks/astro-aws",
106 hooks: {
107 "astro:config:setup": ({ config, updateConfig }) => {
108 if (config.adapter === undefined) {
109 injectedAdapterMarker = true;
110 updateConfig({
111 adapter: {
112 name: "@alchemy.run/frontend-frameworks/astro-aws",
113 hooks: {},
114 },
115 });
116 }
117 },
118 "astro:config:done": ({ setAdapter, config, buildOutput }) => {
119 if (config.adapter !== undefined && !injectedAdapterMarker) {
120 throw new Error(
121 `@alchemy.run/frontend-frameworks/astro/aws: the Astro config declares the adapter "${config.adapter.name}", ` +
122 "but the deploy target already provides the AWS Lambda adapter. " +
123 "Remove `adapter` from your astro.config file — user integrations " +
124 "(react, mdx, tailwind, ...) are honored, and the adapter is injected by the toolchain.",
125 );
126 }
127 options.onBuildOutput?.(buildOutput);
128 options.onServerEntryName?.(config.build.serverEntry);
129 setAdapter({
130 name: "@alchemy.run/frontend-frameworks/astro-aws",
131 entrypointResolution: "auto",
132 serverEntrypoint: SERVER_ENTRYPOINT,
133 adapterFeatures: {
134 buildOutput,
135 middlewareMode: "classic",
136 preserveBuildClientDir: true,
137 preserveBuildServerDir: true,
138 },
139 supportedAstroFeatures: {
140 serverOutput: "stable",
141 hybridOutput: "stable",
142 staticOutput: "stable",
143 i18nDomains: "experimental",
144 envGetSecret: "stable",
145 sharpImageService: {
146 support: "limited",
147 message:
148 "sharp is not shipped with the Lambda server bundle; install it as a project dependency to optimize images at build time",
149 },
150 },
151 });
152 },
153 "astro:build:setup": ({ vite: viteConfig, target }) => {
154 if (target === "server") {
155 // Self-contained dist/server: Lambda ships the directory as-is
156 // (`bundle: false`), so every server dependency must be bundled
157 // into the emitted chunks. Node builtins stay external
158 // automatically; sharp is a native module that can never bundle,
159 // and the Lambda Node.js runtime provides `@aws-sdk/*` v3.
160 viteConfig.ssr ||= {};
161 viteConfig.ssr.noExternal = true;
162 viteConfig.build ||= {};
163 const build = viteConfig.build as Record<string, any>;
164 build.rolldownOptions ||= {};
165 build.rolldownOptions.external = ["sharp", /^@aws-sdk\//];
166 viteConfig.define = {
167 __ALCHEMY_ASTRO_AWS_STREAMING__: JSON.stringify(
168 options.streaming ?? true,
169 ),
170 ...viteConfig.define,
171 };
172 }
173 },
174 },
175 };
176};
177
178const fail = (message: string, cause?: unknown) =>
179 new DeployTargetError({ platform: "aws", message, cause });
180
181/**
182 * The adapter-driven target — the shape the framework's regular
183 * build/finish pipeline consumes. Used directly in the build child (where
184 * `cwd === root` holds); {@link target} wraps it with the wholesale
185 * `build` hook that spawns the child.
186 */
187const makeAwsAdapterTarget = (config: AstroAwsConfig = {}): AstroAwsTarget => {
188 // The resolved build output mode from `astro:config:done`: `"static"`
189 // means every route is prerendered, so the deploy must be assets-only.
190 let buildOutput: "static" | "server" | undefined;
191 // The resolved server-entry file name (`entry.mjs` by default).
192 let serverEntryName: string | undefined;
193 return makeDeployTarget({
194 platform: "aws",
195 config,
196 bundle: {
197 conditions: ["node", "import", "module"],
198 external: ["@aws-sdk/"],
199 },
200 integration: () =>
201 distilledAws({
202 streaming: config.streaming,
203 onBuildOutput: (mode) => {
204 buildOutput = mode;
205 },
206 onServerEntryName: (name) => {
207 serverEntryName = name;
208 },
209 }),
210 // Astro's page modules are rolldown inputs of the `ssr` environment, so
211 // it emits many entry chunks; pin the adapter's server entry (renamed to
212 // `config.build.serverEntry` by astro, facade = the aws-server module).
213 selectServerEntry: (chunk: ServerEntryChunk): boolean =>
214 chunk.fileName === (serverEntryName ?? "entry.mjs") ||
215 (chunk.facadeModuleId !== null &&
216 /\/entrypoints\/aws-server\.(?:ts|js|mjs)$/.test(
217 chunk.facadeModuleId.replaceAll("\\", "/"),
218 )),
219 // A fully-static build deploys ASSETS-ONLY: the SSR entry astro bundled
220 // for prerendering is dropped from the output and the client directory
221 // carries all prerendered HTML, `404.html` included.
222 finish: (output) =>
223 Effect.succeed(
224 buildOutput === "static"
225 ? { ...output, serverModules: undefined }
226 : output,
227 ),
228 });
229};
230
231/**
232 * The natively-loaded `astro.config.*` executes user plugins/integrations
233 * that may read the cwd, mutate `process.env`, or `process.chdir` — none of
234 * which may touch the engine process (many deploys share one event loop).
235 * This target's wholesale `build` therefore runs the framework in a
236 * disposable child process whose working directory IS the project root (see
237 * `core/BuildChild.ts`). The shared `core/BuildChildRunner` entry imports
238 * this module in the child and calls the exported {@link buildInChild}.
239 */
240export interface AstroAwsBuildChildConfig {
241 readonly rootDir: string;
242 /** The (JSON-serializable) target config the parent was created with. */
243 readonly config: AstroAwsConfig;
244 /**
245 * The inline Astro overlay the framework was constructed with
246 * (`AstroFrameworkOptions.astro`, carried through the build context).
247 * Only JSON-serializable fields cross the process boundary.
248 */
249 readonly astro: AstroInlineConfig | undefined;
250}
251
252export const buildInChild = (config: AstroAwsBuildChildConfig) =>
253 Effect.gen(function* () {
254 const framework = yield* make({
255 root: config.rootDir,
256 // The adapter-only target: no wholesale `build` hook, so the child
257 // runs the regular astro build + finish pipeline (no recursion).
258 target: makeAwsAdapterTarget(config.config),
259 astro: config.astro,
260 });
261 return yield* framework.build({ root: config.rootDir });
262 });
263
264/**
265 * Build the AWS Lambda {@link AstroTarget}. See the module doc for the
266 * seams.
267 */
268export const target = (config: AstroAwsConfig = {}): AstroAwsTarget => ({
269 ...makeAwsAdapterTarget(config),
270 build: (context: AstroTargetBuildContext) =>
271 runBuildChild({
272 module: import.meta.url,
273 rootDir: context.root,
274 framework: "astro",
275 config: {
276 rootDir: context.root,
277 config,
278 astro: context.astro,
279 } satisfies AstroAwsBuildChildConfig,
280 }).pipe(Effect.mapError((error) => fail(error.message, error.cause))),
281});
282
283/**
284 * The deploy-target module contract (`resolveDeployTarget` accepts the
285 * default export — or the named `target` export — as a value or factory).
286 */
287export default target;