1import * as FrameworkCore from "../core/index.ts";
2import type { AstroInlineConfig, AstroIntegration } from "astro";
3import type * as AstroNamespace from "astro";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import type * as ViteModule from "vite";
9import { NODE_ENVIRONMENTS } from "./environments.ts";
10import {
11 DEFAULT_TARGET_SPECIFIER,
12 isAstroTarget,
13 type AstroTarget,
14 type AstroTargetInput,
15} from "./Target.ts";
16
17type AstroModule = typeof AstroNamespace;
18
19/**
20 * Options for the Astro `Framework` integration. The deploy target is passed
21 * as a value (`target`), keeping this module platform-neutral: all
22 * platform-specific behavior (adapter/integration selection, bundler plugin
23 * injection, dev SSR execution) lives in the target module.
24 */
25export interface AstroFrameworkOptions<TargetConfig = unknown> {
26 /**
27 * The deploy target: an `AstroTarget` value, a `(config) => AstroTarget`
28 * factory, or a module specifier resolved from the project's own
29 * `node_modules`. Defaults to this package's Cloudflare target module
30 * (`"@alchemy.run/frontend-frameworks/astro/cloudflare"`). Factories and specifiers are
31 * applied to {@link targetConfig}.
32 */
33 readonly target?: AstroTargetInput<TargetConfig> | undefined;
34 /**
35 * Target configuration passed to a target factory or specifier-loaded
36 * target module. Opaque to this module — its shape belongs to the target
37 * (Cloudflare: `{ worker?: CloudflareVitePluginOptions,
38 * sessionKVBindingName?: string }`). Unused when {@link target} is already
39 * a target *value*.
40 */
41 readonly targetConfig?: TargetConfig | undefined;
42 /**
43 * Extra Astro config merged into the in-memory `AstroInlineConfig`
44 * (`site`, `base`, `integrations`, `devToolbar`, `vite`, ...). The
45 * project's own `astro.config.*` file loads natively; this inline config
46 * is merged OVER it by astro (arrays like `integrations`/`vite.plugins`
47 * concatenate, scalars override). `root` is pinned; `output` is only set
48 * when passed here (so a config file's `output` — or astro's `"static"`
49 * default — is honored). Do not pass `adapter` — the deploy target
50 * provides it, and a user-declared adapter fails the build with an
51 * actionable error.
52 */
53 readonly astro?: AstroInlineConfig | undefined;
54 /**
55 * Path to an alternate Astro config file, resolved against {@link root}
56 * when relative (astro itself resolves a relative `configFile` against
57 * the process cwd, so this module anchors it to the project root before
58 * handing it over). Defaults to astro's own config discovery
59 * (`astro.config.*` in the project root).
60 */
61 readonly config?: string | undefined;
62 /** Project root. Defaults to the process working directory. */
63 readonly root?: string | undefined;
64}
65
66/** Inputs for {@link makeAstroInlineConfig} (exported for testing). */
67export interface AstroConfigInputs {
68 /** Absolute project root. */
69 readonly root: string;
70 /**
71 * The deploy target's integration. Injected via `integrations` (after the
72 * user's) rather than `adapter`: it registers itself as the adapter at
73 * `astro:config:done`, where it also rejects a user-declared adapter with
74 * an actionable error.
75 */
76 readonly integration: AstroIntegration;
77 /** User Astro config merged in (its `vite.plugins` are preserved). */
78 readonly userConfig?: AstroInlineConfig | undefined;
79 /** Dev-server port (merged into `server.port`). */
80 readonly port?: number | undefined;
81 /** Dev-server host (merged into `server.host`). */
82 readonly host?: string | undefined;
83 /**
84 * Extra Vite plugins appended after the user's (e.g. the build-output
85 * collector).
86 */
87 readonly extraVitePlugins?: ReadonlyArray<ViteModule.Plugin> | undefined;
88}
89
90/**
91 * Build the in-memory `AstroInlineConfig` — the overlay astro merges OVER
92 * the project's own `astro.config.*` (which loads natively; `configFile`
93 * is left undiscovered-default unless the user overlay carries one, so a
94 * project without a config file falls back to a purely programmatic
95 * config). Astro's `resolveConfig` merge
96 * gives this inline config precedence: arrays (`integrations`,
97 * `vite.plugins`) concatenate after the file's, scalars override.
98 *
99 * - The deploy target's integration is appended to `integrations` (it
100 * registers itself as the adapter at `astro:config:done` and fails the
101 * build if the user config already declares an `adapter`).
102 * - `output` is NOT set unless the caller passes it explicitly: the inline
103 * config merges OVER the project's `astro.config.*`, so a default here
104 * would clobber the file's `output` (e.g. an explicit `"static"`). With
105 * neither source setting it, astro's own default (`"static"`, with
106 * per-route `prerender = false` opt-outs enabled by the adapter) applies.
107 * - User `vite.plugins` are preserved ahead of the collector.
108 */
109export const makeAstroInlineConfig = (
110 inputs: AstroConfigInputs,
111): AstroInlineConfig => {
112 const user = inputs.userConfig;
113 const serverOverrides = {
114 ...(inputs.port !== undefined ? { port: inputs.port } : {}),
115 ...(inputs.host !== undefined ? { host: inputs.host } : {}),
116 };
117 const server: AstroInlineConfig["server"] =
118 Object.keys(serverOverrides).length === 0
119 ? user?.server
120 : typeof user?.server === "function"
121 ? (options) => ({
122 ...(user.server as (options: unknown) => object)(options),
123 ...serverOverrides,
124 })
125 : { ...user?.server, ...serverOverrides };
126 return {
127 logLevel: "warn",
128 ...user,
129 root: inputs.root,
130 integrations: [...(user?.integrations ?? []), inputs.integration],
131 server,
132 vite: {
133 ...user?.vite,
134 plugins: [
135 ...(user?.vite?.plugins ?? []),
136 ...(inputs.extraVitePlugins ?? []),
137 ],
138 },
139 };
140};
141
142/**
143 * The Astro implementation of framework-core's `Framework` service.
144 *
145 * - `build` resolves the deploy target, then either delegates wholesale to
146 * `target.build` (when the target defines one) or runs astro's programmatic
147 * `build(AstroInlineConfig)` with the target's adapter integration and the
148 * shared build-output collector on the `ssr` environment (skipping astro's
149 * node-side `astro`/`prerender` environments), finishing with
150 * `applyDeployTargetFinish`. `serverModules[0]` is the ssr entry chunk
151 * (`server/entry.mjs`); `clientDirectory` is `dist/client`, captured as a
152 * path so prerendered HTML written after the Vite build rides along.
153 * - `dev` runs astro's `dev()` with the target's adapter integration; with
154 * the Cloudflare target the `ssr` environment executes inside workerd via
155 * the cloudflare-vite-plugin module runner, with in-memory bindings — no
156 * wrangler config anywhere. Closing the Scope stops the server.
157 *
158 * Returns the service implementation as an Effect (the alchemy-side
159 * `FrameworkModule` contract); use {@link layer} for the `Layer` form the
160 * e2e harness consumes.
161 */
162export const make = <TargetConfig = unknown>(
163 options?: AstroFrameworkOptions<TargetConfig>,
164): Effect.Effect<
165 FrameworkCore.Framework["Service"],
166 never,
167 FileSystem.FileSystem | Path.Path
168> =>
169 Effect.gen(function* () {
170 const fs = yield* FileSystem.FileSystem;
171 const path = yield* Path.Path;
172
173 const fail = (message: string) => (cause: unknown) =>
174 new FrameworkCore.FrameworkError({
175 framework: "astro",
176 message,
177 cause,
178 });
179
180 const failTarget = (error: FrameworkCore.DeployTargetError) =>
181 new FrameworkCore.FrameworkError({
182 framework: "astro",
183 message: error.message,
184 cause: error.cause ?? error,
185 });
186
187 // Astro's dev()/build() record telemetry events by default.
188 yield* Effect.sync(() => {
189 process.env.ASTRO_TELEMETRY_DISABLED ??= "1";
190 });
191
192 const resolveRoot = (override: string | undefined) =>
193 Effect.sync(() =>
194 path.resolve(override ?? options?.root ?? process.cwd()),
195 );
196
197 /**
198 * The user's inline Astro overlay with `config` resolved into
199 * `configFile`, anchored to the project root (astro resolves a
200 * relative `configFile` against the process cwd, not `root`).
201 */
202 const resolveUserAstro = (root: string): AstroInlineConfig | undefined =>
203 options?.config === undefined
204 ? options?.astro
205 : { ...options?.astro, configFile: path.resolve(root, options.config) };
206
207 const resolveTarget = (
208 root: string,
209 ): Effect.Effect<AstroTarget, FrameworkCore.FrameworkError> =>
210 FrameworkCore.resolveDeployTarget<AstroTarget, TargetConfig | undefined>(
211 root,
212 (options?.target ??
213 DEFAULT_TARGET_SPECIFIER) as FrameworkCore.DeployTargetInput<
214 AstroTarget,
215 TargetConfig | undefined
216 >,
217 options?.targetConfig,
218 ).pipe(
219 Effect.mapError(failTarget),
220 Effect.flatMap((target) =>
221 isAstroTarget(target)
222 ? Effect.succeed(target)
223 : Effect.fail(
224 fail(
225 "The resolved deploy target is not an AstroTarget: it must provide an " +
226 "`integration: () => AstroIntegration` hook",
227 )(undefined),
228 ),
229 ),
230 );
231
232 const loadAstro = (
233 root: string,
234 ): Effect.Effect<AstroModule, FrameworkCore.FrameworkError> =>
235 FrameworkCore.loadProjectModule<AstroModule>(root, "astro").pipe(
236 Effect.mapError(fail("Failed to load the project's astro")),
237 );
238
239 const makeConfig = (
240 root: string,
241 target: AstroTarget,
242 overrides?: Pick<AstroConfigInputs, "port" | "host" | "extraVitePlugins">,
243 ): AstroInlineConfig =>
244 makeAstroInlineConfig({
245 root,
246 integration: target.integration(),
247 userConfig: resolveUserAstro(root),
248 ...overrides,
249 });
250
251 return FrameworkCore.Framework.of({
252 build: Effect.fn(function* (buildOptions) {
253 const root = yield* resolveRoot(buildOptions?.root);
254 const target = yield* resolveTarget(root);
255 if (target.build !== undefined) {
256 // Wholesale build takeover: the target owns the entire
257 // production build (the OpenNext-style case). The inline Astro
258 // overlay rides along (with `config` already resolved into an
259 // absolute `configFile`) so a child-process build can
260 // reconstruct the framework with the same options.
261 return yield* target
262 .build({
263 root,
264 framework: "astro",
265 env: buildOptions?.env,
266 astro: resolveUserAstro(root),
267 })
268 .pipe(
269 Effect.provideService(FileSystem.FileSystem, fs),
270 Effect.provideService(Path.Path, path),
271 Effect.mapError(failTarget),
272 );
273 }
274 const astro = yield* loadAstro(root);
275 const collector = yield* FrameworkCore.makeBuildOutputCollector({
276 entryEnvironment: "ssr",
277 skipEnvironments: NODE_ENVIRONMENTS,
278 selectEntry: target.selectServerEntry,
279 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
280 const config = makeConfig(root, target, {
281 extraVitePlugins: [collector.plugin],
282 });
283 yield* Effect.tryPromise({
284 try: async () => await astro.build(config),
285 catch: fail("Failed to build"),
286 });
287 // Disk re-read: astro injects the serialized SSR manifest into the
288 // entry chunk on disk *after* the bundler finishes (the in-memory
289 // capture still contains the `@@ASTRO_MANIFEST_REPLACE@@`
290 // placeholder), and prunes the prerender-only chunks.
291 const output = yield* collector
292 .collect({ fromDisk: true })
293 .pipe(Effect.mapError((error) => fail(error.message)(error.cause)));
294 // The astro build is delivered in-memory (no on-disk entry handed
295 // over), so the finish context carries no `entry`.
296 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
297 root,
298 framework: "astro",
299 }).pipe(
300 Effect.provideService(FileSystem.FileSystem, fs),
301 Effect.provideService(Path.Path, path),
302 Effect.mapError(failTarget),
303 );
304 }),
305 dev: Effect.fn(function* (devOptions) {
306 const root = yield* resolveRoot(devOptions?.root);
307 const target = yield* resolveTarget(root);
308 const astro = yield* loadAstro(root);
309 // `port: 0` (true OS-assigned) when the Vite under Astro is
310 // >= 8.2.1, probed ephemeral port otherwise — see
311 // `resolveViteDevPort`. Astro's dev server runs on Astro's own
312 // Vite dependency, so the version is resolved from the astro
313 // package's directory, not the project root.
314 const viteVersion = yield* FrameworkCore.resolveProjectPackageDirectory(
315 root,
316 "astro",
317 ).pipe(
318 Effect.flatMap((astroDirectory) =>
319 FrameworkCore.resolveInstalledPackageVersion(
320 astroDirectory,
321 "vite",
322 ),
323 ),
324 Effect.orElseSucceed(() => undefined),
325 );
326 const port = yield* FrameworkCore.resolveViteDevPort(
327 viteVersion,
328 devOptions?.port,
329 );
330 const config = makeConfig(root, target, {
331 port,
332 host: devOptions?.host,
333 });
334 const server = yield* Effect.acquireRelease(
335 Effect.tryPromise({
336 try: async () => await astro.dev(config),
337 catch: fail("Failed to start the astro dev server"),
338 }),
339 (server) => Effect.promise(async () => await server.stop()),
340 );
341 const url =
342 server.resolvedUrls?.local[0] ??
343 (typeof server.address.port === "number"
344 ? `http://localhost:${server.address.port}/`
345 : undefined);
346 if (url === undefined) {
347 return yield* Effect.fail(
348 fail("Could not determine the URL of the astro dev server")(
349 undefined,
350 ),
351 );
352 }
353 return { url };
354 }),
355 });
356 });
357
358/**
359 * The `Layer` form of {@link make} — the shape the e2e harness's framework
360 * factory contract expects (`(options) => Layer<Framework>`).
361 */
362export const layer = <TargetConfig = unknown>(
363 options?: AstroFrameworkOptions<TargetConfig>,
364): Layer.Layer<
365 FrameworkCore.Framework,
366 never,
367 FileSystem.FileSystem | Path.Path
368> => Layer.effect(FrameworkCore.Framework, make(options));