| 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 | */ |
| 29 | import type { AstroInlineConfig, AstroIntegration } from "astro"; |
| 30 | import * as Effect from "effect/Effect"; |
| 31 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 32 | import { |
| 33 | DeployTargetError, |
| 34 | makeDeployTarget, |
| 35 | type ServerEntryChunk, |
| 36 | } from "../core/index.ts"; |
| 37 | import { make } from "./Astro.ts"; |
| 38 | import 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 | */ |
| 45 | export const SERVER_ENTRYPOINT = |
| 46 | "@alchemy.run/frontend-frameworks/astro/entrypoints/aws-server"; |
| 47 | |
| 48 | /** AWS-specific target configuration. */ |
| 49 | export 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 | |
| 61 | export interface AstroAwsTarget extends AstroTarget<AstroAwsConfig> {} |
| 62 | |
| 63 | /** Options for {@link distilledAws} (the adapter integration). */ |
| 64 | export 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 | */ |
| 93 | export 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 | |
| 178 | const 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 | */ |
| 187 | const 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 | */ |
| 240 | export 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 | |
| 252 | export 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 | */ |
| 268 | export 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 | */ |
| 287 | export default target; |