| 1 | import * as FrameworkCore from "../core/index.ts"; |
| 2 | import type { AstroInlineConfig, AstroIntegration } from "astro"; |
| 3 | import type * as AstroNamespace from "astro"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import type * as ViteModule from "vite"; |
| 9 | import { NODE_ENVIRONMENTS } from "./environments.ts"; |
| 10 | import { |
| 11 | DEFAULT_TARGET_SPECIFIER, |
| 12 | isAstroTarget, |
| 13 | type AstroTarget, |
| 14 | type AstroTargetInput, |
| 15 | } from "./Target.ts"; |
| 16 | |
| 17 | type 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 | */ |
| 25 | export 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). */ |
| 67 | export 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 | */ |
| 109 | export 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 | */ |
| 162 | export 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 | */ |
| 362 | export 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)); |