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 */
24import type { AstroInlineConfig, AstroIntegration } from "astro";
25import * as Effect from "effect/Effect";
26import * as Path from "effect/Path";
27import { runBuildChild } from "../core/BuildChild.ts";
28import {
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";
36import {
37 DeployTargetError,
38 makeDeployTarget,
39 type ServerEntryChunk,
40} from "../core/index.ts";
41import { make } from "./Astro.ts";
42import 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 */
49export const SERVER_ENTRYPOINT =
50 "@alchemy.run/frontend-frameworks/astro/entrypoints/node-server";
51
52/** Node-specific target configuration. */
53export interface AstroNodeConfig {
54 readonly htmlHandling?: NodeServeHtmlHandling;
55 readonly notFoundHandling?: NodeServeNotFoundHandling;
56}
57
58export interface AstroNodeTarget extends AstroTarget<AstroNodeConfig> {}
59
60/** Options for {@link distilledNode} (the adapter integration). */
61export 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 */
88export 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
151const fail = (message: string, cause?: unknown) =>
152 new DeployTargetError({ platform: "node", message, cause });
153
154const 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
244export interface AstroNodeBuildChildConfig {
245 readonly rootDir: string;
246 readonly config: AstroNodeConfig;
247 readonly astro: AstroInlineConfig | undefined;
248}
249
250export 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 */
263export 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
281export const target = makeNodeTarget;
282
283export default makeNodeTarget;