| 1 | /** |
| 2 | * `@alchemy.run/frontend-frameworks/astro/node` — the Node container deploy |
| 3 | * target for the Astro integration. |
| 4 | * |
| 5 | * Astro's Node story is a plain Node SSR bundle behind a long-running HTTP |
| 6 | * server: the adapter integration pins this package's `node-server` |
| 7 | * entrypoint (which exports `App.render` as a fetch handler) as the build's |
| 8 | * server entry, and forces the server build to be **self-contained** |
| 9 | * (`vite.ssr.noExternal: true`). The finishing pass writes a Node HTTP |
| 10 | * program that serves `clientDirectory` first, then falls through to that |
| 11 | * handler on `PORT` (default 3000), and answers `GET /health`. |
| 12 | * |
| 13 | * - **`integration`** — the adapter integration: registers itself via |
| 14 | * `setAdapter` at `astro:config:done` (rejecting a user-declared adapter |
| 15 | * with an actionable error) and pins the `node-server` entrypoint. |
| 16 | * - **`finish`** — a fully-static build (`output: "static"`) writes the |
| 17 | * same Node static serve entry Vite uses. Otherwise writes the HTTP |
| 18 | * serve entry (static files then the Astro fetch handler) and pins it |
| 19 | * as `serverModules[0]`. |
| 20 | * - **`selectServerEntry`** — pins the adapter's entry chunk as |
| 21 | * `serverModules[0]` before the finishing pass wraps it. |
| 22 | * - **`bundle`** — Node resolve conditions (no `workerd`, no `@aws-sdk/`). |
| 23 | */ |
| 24 | import type { AstroInlineConfig, AstroIntegration } from "astro"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Path from "effect/Path"; |
| 27 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 28 | import { |
| 29 | NODE_BUNDLE_CONDITIONS, |
| 30 | NODE_SERVE_ENTRY_FILE_NAME, |
| 31 | relativeClientDirExpression, |
| 32 | writeNodeServeEntry, |
| 33 | type NodeServeHtmlHandling, |
| 34 | type NodeServeNotFoundHandling, |
| 35 | } from "../core/NodeServe.ts"; |
| 36 | import { |
| 37 | DeployTargetError, |
| 38 | makeDeployTarget, |
| 39 | type ServerEntryChunk, |
| 40 | } from "../core/index.ts"; |
| 41 | import { make } from "./Astro.ts"; |
| 42 | import type { AstroTarget, AstroTargetBuildContext } from "./Target.ts"; |
| 43 | |
| 44 | /** |
| 45 | * The importable specifier of this package's Node server entrypoint — the |
| 46 | * module the adapter pins as `serverEntrypoint`. It exports a fetch |
| 47 | * `handler`. |
| 48 | */ |
| 49 | export const SERVER_ENTRYPOINT = |
| 50 | "@alchemy.run/frontend-frameworks/astro/entrypoints/node-server"; |
| 51 | |
| 52 | /** Node-specific target configuration. */ |
| 53 | export interface AstroNodeConfig { |
| 54 | readonly htmlHandling?: NodeServeHtmlHandling; |
| 55 | readonly notFoundHandling?: NodeServeNotFoundHandling; |
| 56 | } |
| 57 | |
| 58 | export interface AstroNodeTarget extends AstroTarget<AstroNodeConfig> {} |
| 59 | |
| 60 | /** Options for {@link distilledNode} (the adapter integration). */ |
| 61 | export interface DistilledNodeOptions { |
| 62 | /** |
| 63 | * Reports the resolved build output mode (`astro:config:done`'s |
| 64 | * `buildOutput`): `"static"` when every route is prerendered, `"server"` |
| 65 | * when any route renders on demand. The target's `finish` pass uses it to |
| 66 | * strip `serverModules` from a fully-static build so the deploy is |
| 67 | * assets-only. |
| 68 | * @internal |
| 69 | */ |
| 70 | readonly onBuildOutput?: |
| 71 | | ((buildOutput: "static" | "server") => void) |
| 72 | | undefined; |
| 73 | /** |
| 74 | * Reports the resolved server-entry file name (`config.build.serverEntry`, |
| 75 | * `entry.mjs` by default) so the entry chunk can be pinned as |
| 76 | * `serverModules[0]` before the serve wrap. |
| 77 | * @internal |
| 78 | */ |
| 79 | readonly onServerEntryName?: ((name: string) => void) | undefined; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * The Node adapter integration for Astro. Injected via `integrations` |
| 84 | * (after the user's) rather than `adapter`: it registers itself as the |
| 85 | * adapter at `astro:config:done`, where it also rejects a user-declared |
| 86 | * adapter with an actionable error. |
| 87 | */ |
| 88 | export const distilledNode = ( |
| 89 | options: DistilledNodeOptions = {}, |
| 90 | ): AstroIntegration => { |
| 91 | let injectedAdapterMarker = false; |
| 92 | return { |
| 93 | name: "@alchemy.run/frontend-frameworks/astro-node", |
| 94 | hooks: { |
| 95 | "astro:config:setup": ({ config, updateConfig }) => { |
| 96 | if (config.adapter === undefined) { |
| 97 | injectedAdapterMarker = true; |
| 98 | updateConfig({ |
| 99 | adapter: { |
| 100 | name: "@alchemy.run/frontend-frameworks/astro-node", |
| 101 | hooks: {}, |
| 102 | }, |
| 103 | }); |
| 104 | } |
| 105 | }, |
| 106 | "astro:config:done": ({ setAdapter, config, buildOutput }) => { |
| 107 | if (config.adapter !== undefined && !injectedAdapterMarker) { |
| 108 | throw new Error( |
| 109 | `@alchemy.run/frontend-frameworks/astro/node: the Astro config declares the adapter "${config.adapter.name}", ` + |
| 110 | "but the deploy target already provides the Node adapter. " + |
| 111 | "Remove `adapter` from your astro.config file — user integrations " + |
| 112 | "(react, mdx, tailwind, ...) are honored, and the adapter is injected by the toolchain.", |
| 113 | ); |
| 114 | } |
| 115 | options.onBuildOutput?.(buildOutput); |
| 116 | options.onServerEntryName?.(config.build.serverEntry); |
| 117 | setAdapter({ |
| 118 | name: "@alchemy.run/frontend-frameworks/astro-node", |
| 119 | entrypointResolution: "auto", |
| 120 | serverEntrypoint: SERVER_ENTRYPOINT, |
| 121 | adapterFeatures: { |
| 122 | buildOutput, |
| 123 | middlewareMode: "classic", |
| 124 | preserveBuildClientDir: true, |
| 125 | preserveBuildServerDir: true, |
| 126 | }, |
| 127 | supportedAstroFeatures: { |
| 128 | serverOutput: "stable", |
| 129 | hybridOutput: "stable", |
| 130 | staticOutput: "stable", |
| 131 | i18nDomains: "experimental", |
| 132 | envGetSecret: "stable", |
| 133 | sharpImageService: { |
| 134 | support: "limited", |
| 135 | message: |
| 136 | "sharp is not shipped with the Node server bundle; install it as a project dependency to optimize images at build time", |
| 137 | }, |
| 138 | }, |
| 139 | }); |
| 140 | }, |
| 141 | "astro:build:setup": ({ vite: viteConfig, target }) => { |
| 142 | if (target === "server") { |
| 143 | viteConfig.ssr ||= {}; |
| 144 | viteConfig.ssr.noExternal = true; |
| 145 | } |
| 146 | }, |
| 147 | }, |
| 148 | }; |
| 149 | }; |
| 150 | |
| 151 | const fail = (message: string, cause?: unknown) => |
| 152 | new DeployTargetError({ platform: "node", message, cause }); |
| 153 | |
| 154 | const makeNodeAdapterTarget = ( |
| 155 | config: AstroNodeConfig = {}, |
| 156 | ): AstroNodeTarget => { |
| 157 | let buildOutput: "static" | "server" | undefined; |
| 158 | let serverEntryName: string | undefined; |
| 159 | return makeDeployTarget({ |
| 160 | platform: "node", |
| 161 | config, |
| 162 | bundle: { |
| 163 | conditions: [...NODE_BUNDLE_CONDITIONS], |
| 164 | }, |
| 165 | integration: () => |
| 166 | distilledNode({ |
| 167 | onBuildOutput: (mode) => { |
| 168 | buildOutput = mode; |
| 169 | }, |
| 170 | onServerEntryName: (name) => { |
| 171 | serverEntryName = name; |
| 172 | }, |
| 173 | }), |
| 174 | selectServerEntry: (chunk: ServerEntryChunk): boolean => |
| 175 | chunk.fileName === (serverEntryName ?? "entry.mjs") || |
| 176 | (chunk.facadeModuleId !== null && |
| 177 | /\/entrypoints\/node-server\.(?:ts|js|mjs)$/.test( |
| 178 | chunk.facadeModuleId.replaceAll("\\", "/"), |
| 179 | )), |
| 180 | finish: (output) => |
| 181 | Effect.gen(function* () { |
| 182 | const path = yield* Path.Path; |
| 183 | if (buildOutput === "static") { |
| 184 | if (output.clientDirectory === undefined) { |
| 185 | return yield* Effect.fail( |
| 186 | fail( |
| 187 | "The Astro static build produced no client directory for the Node serve entry", |
| 188 | ), |
| 189 | ); |
| 190 | } |
| 191 | const servePath = path.join( |
| 192 | output.clientDirectory, |
| 193 | NODE_SERVE_ENTRY_FILE_NAME, |
| 194 | ); |
| 195 | return yield* writeNodeServeEntry({ |
| 196 | output: { ...output, distDirectory: output.clientDirectory }, |
| 197 | servePath, |
| 198 | serveModuleName: NODE_SERVE_ENTRY_FILE_NAME, |
| 199 | clientDirExpression: `fileURLToPath(new URL("./", import.meta.url))`, |
| 200 | notFoundHandling: config.notFoundHandling ?? "spa", |
| 201 | htmlHandling: config.htmlHandling, |
| 202 | platform: "node", |
| 203 | }); |
| 204 | } |
| 205 | if ( |
| 206 | output.distDirectory === undefined || |
| 207 | output.clientDirectory === undefined |
| 208 | ) { |
| 209 | return yield* Effect.fail( |
| 210 | fail( |
| 211 | "The Astro build produced no dist/client directories for the Node serve entry", |
| 212 | ), |
| 213 | ); |
| 214 | } |
| 215 | const entryName = serverEntryName ?? "entry.mjs"; |
| 216 | const servePath = path.join( |
| 217 | output.distDirectory, |
| 218 | "server", |
| 219 | NODE_SERVE_ENTRY_FILE_NAME, |
| 220 | ); |
| 221 | return yield* writeNodeServeEntry({ |
| 222 | output, |
| 223 | servePath, |
| 224 | serveModuleName: path |
| 225 | .join("server", NODE_SERVE_ENTRY_FILE_NAME) |
| 226 | .replaceAll("\\", "/"), |
| 227 | clientDirExpression: relativeClientDirExpression( |
| 228 | servePath, |
| 229 | output.clientDirectory, |
| 230 | ), |
| 231 | handler: { |
| 232 | kind: "fetch", |
| 233 | imports: `import { handler } from ${JSON.stringify(`./${entryName}`)};`, |
| 234 | expr: "handler", |
| 235 | }, |
| 236 | notFoundHandling: config.notFoundHandling, |
| 237 | htmlHandling: config.htmlHandling, |
| 238 | platform: "node", |
| 239 | }); |
| 240 | }), |
| 241 | }); |
| 242 | }; |
| 243 | |
| 244 | export interface AstroNodeBuildChildConfig { |
| 245 | readonly rootDir: string; |
| 246 | readonly config: AstroNodeConfig; |
| 247 | readonly astro: AstroInlineConfig | undefined; |
| 248 | } |
| 249 | |
| 250 | export const buildInChild = (config: AstroNodeBuildChildConfig) => |
| 251 | Effect.gen(function* () { |
| 252 | const framework = yield* make({ |
| 253 | root: config.rootDir, |
| 254 | target: makeNodeAdapterTarget(config.config), |
| 255 | astro: config.astro, |
| 256 | }); |
| 257 | return yield* framework.build({ root: config.rootDir }); |
| 258 | }); |
| 259 | |
| 260 | /** |
| 261 | * Build the Node {@link AstroTarget}. See the module doc for the seams. |
| 262 | */ |
| 263 | export const makeNodeTarget = ( |
| 264 | config: AstroNodeConfig = {}, |
| 265 | ): AstroNodeTarget => ({ |
| 266 | ...makeNodeAdapterTarget(config), |
| 267 | build: (context: AstroTargetBuildContext) => |
| 268 | runBuildChild({ |
| 269 | module: import.meta.url, |
| 270 | rootDir: context.root, |
| 271 | env: context.env, |
| 272 | framework: "astro", |
| 273 | config: { |
| 274 | rootDir: context.root, |
| 275 | config, |
| 276 | astro: context.astro, |
| 277 | } satisfies AstroNodeBuildChildConfig, |
| 278 | }).pipe(Effect.mapError((error) => fail(error.message, error.cause))), |
| 279 | }); |
| 280 | |
| 281 | export const target = makeNodeTarget; |
| 282 | |
| 283 | export default makeNodeTarget; |