| 1 | // Alchemy modifications are licensed under Apache-2.0. |
| 2 | // This file includes third-party code; see /THIRD_PARTY_LICENSES.md. |
| 3 | /** |
| 4 | * Wrangler-free fork of `@astrojs/cloudflare`'s `createIntegration` |
| 5 | * (v14.1.3, `src/index.ts`) with: |
| 6 | * |
| 7 | * - `@cloudflare/vite-plugin` swapped for `@alchemy.run/cloudflare-runtime/vite` |
| 8 | * (main = this package's vendored server entrypoint, entry env `ssr`, |
| 9 | * Astro's node-side `astro`/`prerender` environments in `skipEnvironments`). |
| 10 | * - `loadWranglerEnv`, wrangler config watchers, `previewEntrypoint`, and the |
| 11 | * output-wrangler.json patch dropped. |
| 12 | * (The `astro:build:done` wrangler.json assets-directory patch is replaced |
| 13 | * by the cloudflare target's `finish` pass over `BuildOutput` — see |
| 14 | * `cloudflare.ts` — and the vestigial parse of a pre-existing `_redirects` |
| 15 | * file, whose result upstream never uses, is dropped.) |
| 16 | * - `prerenderEnvironment: 'workerd'` by default like upstream, but the |
| 17 | * prerenderer serves the built `prerender` environment through |
| 18 | * `cloudflare-runtime` instead of a Vite preview server wrapped around |
| 19 | * `@cloudflare/vite-plugin` (see `prerenderer.ts`); `'node'` falls back to |
| 20 | * Astro's stock node prerenderer. |
| 21 | * - Zero-config sessions mirrored from upstream: when `config.session.driver` |
| 22 | * is unset, the `cloudflareKVBinding` driver is configured against the |
| 23 | * `sessionKVBindingName` KV binding; in dev, a local in-memory KV namespace |
| 24 | * (`KvNamespace.local`) is injected into the worker bindings. Opt out with |
| 25 | * `sessions: false`. |
| 26 | * - The image service is `passthrough` (workerd cannot run sharp, and the |
| 27 | * IMAGES binding is remote-only in our runtime). |
| 28 | */ |
| 29 | import { removeTrailingForwardSlash } from "@astrojs/internal-helpers/path"; |
| 30 | import { |
| 31 | createRedirectsFromAstroRoutes, |
| 32 | printAsRedirects, |
| 33 | } from "@astrojs/underscore-redirects"; |
| 34 | import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite"; |
| 35 | import cloudflareVitePlugin from "@alchemy.run/cloudflare-runtime/vite"; |
| 36 | import { KvNamespace } from "@alchemy.run/cloudflare-runtime/core/bindings"; |
| 37 | import type { |
| 38 | AstroConfig, |
| 39 | AstroIntegration, |
| 40 | IntegrationResolvedRoute, |
| 41 | } from "astro"; |
| 42 | import { passthroughImageService, sessionDrivers } from "astro/config"; |
| 43 | import { |
| 44 | appendFile, |
| 45 | readFile, |
| 46 | rename, |
| 47 | unlink, |
| 48 | writeFile, |
| 49 | } from "node:fs/promises"; |
| 50 | import { fileURLToPath } from "node:url"; |
| 51 | import type * as vite from "vite"; |
| 52 | import { createConfigPlugin } from "./config-plugin.ts"; |
| 53 | import { NODE_ENVIRONMENTS } from "./environments.ts"; |
| 54 | import { buildAssetsHeadersContent } from "./headers.ts"; |
| 55 | import { createWorkerdPrerenderEnvironmentPlugin } from "./prerender-environment.ts"; |
| 56 | import { createNodePrerenderPlugin } from "./prerender-middleware.ts"; |
| 57 | |
| 58 | /** The Worker entry module (the vendored `@astrojs/cloudflare` server entrypoint). */ |
| 59 | export const SERVER_ENTRYPOINT = |
| 60 | "@alchemy.run/frontend-frameworks/astro/entrypoints/server"; |
| 61 | |
| 62 | /** The production endpoint of the passthrough image service. */ |
| 63 | export const IMAGE_PASSTHROUGH_ENDPOINT = fileURLToPath( |
| 64 | import.meta |
| 65 | .resolve("@alchemy.run/frontend-frameworks/astro/image-passthrough-endpoint"), |
| 66 | ); |
| 67 | |
| 68 | export interface DistilledCloudflareOptions { |
| 69 | /** |
| 70 | * Options forwarded to `@alchemy.run/cloudflare-runtime/vite` |
| 71 | * (compatibility date/flags, worker name/bindings/assets, runtime context). |
| 72 | * `main`, `viteEnvironments`, and the Astro node environments in |
| 73 | * `skipEnvironments` are managed by the integration. |
| 74 | */ |
| 75 | readonly vite?: CloudflareVitePluginOptions | undefined; |
| 76 | /** |
| 77 | * Which runtime prerenders static pages at build time. |
| 78 | * |
| 79 | * - `'workerd'` (default, matching upstream): the `prerender` environment |
| 80 | * is built as a Worker (same treatment as the `ssr` environment) and |
| 81 | * served from workerd via `cloudflare-runtime`, so prerendered pages run |
| 82 | * in the production runtime — top-level `cloudflare:*` imports and the |
| 83 | * configured worker bindings included. |
| 84 | * - `'node'`: Astro's stock node prerender environment. Fallback for pages |
| 85 | * that depend on Node-only APIs at build time. |
| 86 | * @default "workerd" |
| 87 | */ |
| 88 | readonly prerenderEnvironment?: "workerd" | "node" | undefined; |
| 89 | /** |
| 90 | * The name of the KV binding injected into Astro's session config when |
| 91 | * present on the Worker env. |
| 92 | * @default "SESSION" |
| 93 | */ |
| 94 | readonly sessionKVBindingName?: string | undefined; |
| 95 | /** |
| 96 | * Zero-config sessions (mirrors upstream `@astrojs/cloudflare`): when no |
| 97 | * `session.driver` is configured, Astro's `cloudflareKVBinding` session |
| 98 | * driver is configured against the {@link sessionKVBindingName} KV |
| 99 | * binding. Set to `false` to leave the session config untouched (sessions |
| 100 | * stay off unless you configure a driver yourself). |
| 101 | * @default true |
| 102 | */ |
| 103 | readonly sessions?: boolean | undefined; |
| 104 | /** |
| 105 | * Automatically add an in-memory local KV namespace |
| 106 | * (`KvNamespace.local({ binding: sessionKVBindingName })`) so zero-config |
| 107 | * sessions work without any setup: in dev it is appended to the dev worker |
| 108 | * bindings; at build time (workerd prerendering) it is appended to the |
| 109 | * prerender environment worker's bindings so session-touching pages can |
| 110 | * prerender (the deployed Worker still uses its real KV binding — the |
| 111 | * prerender KV is ephemeral, matching upstream's local preview bindings). |
| 112 | * Disable when the bindings already carry a KV binding with that name |
| 113 | * (binding names must be unique) — e.g. when an outer toolchain provisions |
| 114 | * the session namespace itself and passes it through |
| 115 | * `vite.worker.bindings`. |
| 116 | * @default true |
| 117 | */ |
| 118 | readonly sessionDevKV?: boolean | undefined; |
| 119 | /** |
| 120 | * Reports the absolute path of the *original* client build directory |
| 121 | * (`config.build.client` before the `base !== "/"` remap nests it). The |
| 122 | * cloudflare target's `finish` pass uses it to point |
| 123 | * `BuildOutput.clientDirectory` back at the directory that should be |
| 124 | * served at the URL root. |
| 125 | * @internal |
| 126 | */ |
| 127 | readonly onOriginalClientDir?: ((dir: string) => void) | undefined; |
| 128 | /** |
| 129 | * Reports the resolved build output mode (`astro:config:done`'s |
| 130 | * `buildOutput`): `"static"` when every route is prerendered, `"server"` |
| 131 | * when any route renders on demand. The cloudflare target's `finish` pass |
| 132 | * uses it to strip `serverModules` from a fully-static build so the deploy |
| 133 | * is assets-only (no worker). |
| 134 | * @internal |
| 135 | */ |
| 136 | readonly onBuildOutput?: |
| 137 | | ((buildOutput: "static" | "server") => void) |
| 138 | | undefined; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Upstream `@astrojs/cloudflare` adapter options this fork deliberately does |
| 143 | * not support, with the reason / migration. Passing one raises immediately |
| 144 | * instead of being silently ignored. |
| 145 | */ |
| 146 | const UNSUPPORTED_UPSTREAM_OPTIONS: Record<string, string> = { |
| 147 | imageService: |
| 148 | "the image service is hardwired to `passthrough` (workerd cannot run sharp, and the IMAGES binding is remote-only in our runtime)", |
| 149 | imagesBindingName: |
| 150 | "the Cloudflare Images binding image service is not supported (the IMAGES binding is remote-only in our runtime)", |
| 151 | platformProxy: |
| 152 | "there is no wrangler platform proxy; dev bindings are passed via `vite.worker.bindings`", |
| 153 | configPath: |
| 154 | "there is no wrangler config file; pass worker options via `vite`", |
| 155 | persistState: "workerd state is in-memory in this fork's dev runtime", |
| 156 | auxiliaryWorkers: |
| 157 | "auxiliary workers are not supported; compose bindings via `vite.worker.bindings`", |
| 158 | inspectorPort: |
| 159 | "the workerd inspector is not exposed by this fork's dev runtime", |
| 160 | viteEnvironment: |
| 161 | "the worker environment is pinned to Astro's `ssr` environment", |
| 162 | cloudflareModules: |
| 163 | "`.wasm`/`.bin`/`.txt` worker modules are handled by `@alchemy.run/cloudflare-runtime/vite`", |
| 164 | experimental: "upstream experimental options are not supported by this fork", |
| 165 | }; |
| 166 | |
| 167 | /** Reject upstream adapter options we would otherwise silently ignore. */ |
| 168 | const assertSupportedOptions = (options: DistilledCloudflareOptions): void => { |
| 169 | const offending = Object.keys(options).filter( |
| 170 | (key) => key in UNSUPPORTED_UPSTREAM_OPTIONS, |
| 171 | ); |
| 172 | if (offending.length > 0) { |
| 173 | throw new Error( |
| 174 | "@alchemy.run/frontend-frameworks/astro: unsupported @astrojs/cloudflare option(s):\n" + |
| 175 | offending |
| 176 | .map((key) => ` - \`${key}\`: ${UNSUPPORTED_UPSTREAM_OPTIONS[key]}`) |
| 177 | .join("\n") + |
| 178 | "\nSupported options: `vite`, `prerenderEnvironment`, `sessionKVBindingName`, `sessions`, `sessionDevKV`.", |
| 179 | ); |
| 180 | } |
| 181 | }; |
| 182 | |
| 183 | /** |
| 184 | * Whether the (possibly zero-config-defaulted) session config uses Astro's |
| 185 | * `cloudflareKVBinding` driver — vendored from upstream |
| 186 | * (`usesCloudflareKVSessionDriver`). |
| 187 | */ |
| 188 | const CLOUDFLARE_KV_SESSION_DRIVER_ENTRYPOINT = |
| 189 | sessionDrivers.cloudflareKVBinding().entrypoint; |
| 190 | |
| 191 | type SessionConfigLike = |
| 192 | | { |
| 193 | driver?: |
| 194 | | string |
| 195 | | { entrypoint: string | { toString(): string } } |
| 196 | | undefined; |
| 197 | cookie?: unknown; |
| 198 | ttl?: unknown; |
| 199 | } |
| 200 | | undefined; |
| 201 | |
| 202 | export const usesCloudflareKVSessionDriver = ( |
| 203 | session: SessionConfigLike, |
| 204 | ): boolean => { |
| 205 | const driver = session?.driver; |
| 206 | if (!driver) { |
| 207 | return false; |
| 208 | } |
| 209 | if (typeof driver === "string") { |
| 210 | return ( |
| 211 | driver === "cloudflareKVBinding" || driver === "cloudflare-kv-binding" |
| 212 | ); |
| 213 | } |
| 214 | const entrypoint = |
| 215 | typeof driver.entrypoint === "string" |
| 216 | ? driver.entrypoint |
| 217 | : driver.entrypoint.toString(); |
| 218 | return ( |
| 219 | entrypoint === CLOUDFLARE_KV_SESSION_DRIVER_ENTRYPOINT || |
| 220 | entrypoint.endsWith("cloudflare-kv-binding") |
| 221 | ); |
| 222 | }; |
| 223 | |
| 224 | /** |
| 225 | * Append the in-memory local session KV namespace to the dev worker |
| 226 | * bindings (exported for testing). Binding names must be unique per worker: |
| 227 | * if the dev bindings already carry a `sessionKVBindingName` KV binding, |
| 228 | * disable this injection with `sessionDevKV: false` (or rename via |
| 229 | * `sessionKVBindingName`). |
| 230 | */ |
| 231 | export const withDevSessionKv = ( |
| 232 | viteOptions: CloudflareVitePluginOptions | undefined, |
| 233 | binding: string, |
| 234 | ): CloudflareVitePluginOptions => ({ |
| 235 | ...viteOptions, |
| 236 | // A bindings-only worker is fine: the dev server generates a worker name |
| 237 | // when none is configured. |
| 238 | worker: { |
| 239 | ...viteOptions?.worker, |
| 240 | bindings: [ |
| 241 | ...(viteOptions?.worker?.bindings ?? []), |
| 242 | KvNamespace.local({ binding }), |
| 243 | ], |
| 244 | } as CloudflareVitePluginOptions["worker"], |
| 245 | }); |
| 246 | |
| 247 | /** |
| 248 | * The workerd prerenderer's vite-plugin options (exported for testing): when |
| 249 | * zero-config sessions resolved to the Cloudflare KV driver, the same |
| 250 | * in-memory local KV namespace that dev injects is appended to the prerender |
| 251 | * worker's bindings — session-touching pages can then prerender without the |
| 252 | * deployed Worker's real KV namespace existing at build time (upstream's |
| 253 | * prerender worker receives the local preview bindings the same way). The |
| 254 | * KV is ephemeral: session state written during prerendering is discarded |
| 255 | * with the prerender worker. `sessionDevKV: false` opts out (e.g. when the |
| 256 | * caller's own `vite.worker.bindings` already carry the session binding). |
| 257 | */ |
| 258 | export const withPrerenderSessionKv = ( |
| 259 | viteOptions: CloudflareVitePluginOptions | undefined, |
| 260 | options: { |
| 261 | readonly needsSessionKVBinding: boolean; |
| 262 | readonly sessionDevKV: boolean; |
| 263 | readonly binding: string; |
| 264 | }, |
| 265 | ): CloudflareVitePluginOptions | undefined => |
| 266 | options.needsSessionKVBinding && options.sessionDevKV |
| 267 | ? withDevSessionKv(viteOptions, options.binding) |
| 268 | : viteOptions; |
| 269 | |
| 270 | /** |
| 271 | * The `@alchemy.run/cloudflare-runtime/vite` options for an Astro project: |
| 272 | * user options with `main` pinned to the vendored server entrypoint, the |
| 273 | * worker pinned to Astro's `ssr` environment, and Astro's node-side |
| 274 | * environments merged into `skipEnvironments` (exported for testing). |
| 275 | * |
| 276 | * During a `build` with `prerenderEnvironment: "workerd"`, the `prerender` |
| 277 | * environment stops being a skipped node environment and becomes a child |
| 278 | * worker environment instead: it gets the full worker treatment (workerd |
| 279 | * resolve conditions, unenv polyfills, `cloudflare:` externals) so its |
| 280 | * output can be loaded into workerd by the prerenderer. In dev, `prerender` |
| 281 | * stays skipped in both modes — workerd mode has no prerender dev |
| 282 | * environment at all (prerenderable routes are served through the entry |
| 283 | * worker's dev-match path). |
| 284 | */ |
| 285 | export const makeIntegrationPluginOptions = ( |
| 286 | viteOptions: CloudflareVitePluginOptions = {}, |
| 287 | { |
| 288 | prerenderEnvironment = "node", |
| 289 | command = "dev", |
| 290 | }: { |
| 291 | prerenderEnvironment?: "workerd" | "node"; |
| 292 | command?: "dev" | "build" | "preview" | "sync"; |
| 293 | } = {}, |
| 294 | ): CloudflareVitePluginOptions => { |
| 295 | const workerdPrerender = |
| 296 | prerenderEnvironment === "workerd" && command === "build"; |
| 297 | return { |
| 298 | ...viteOptions, |
| 299 | main: SERVER_ENTRYPOINT, |
| 300 | viteEnvironments: workerdPrerender |
| 301 | ? { entry: "ssr", children: ["prerender"] } |
| 302 | : { entry: "ssr" }, |
| 303 | skipEnvironments: [ |
| 304 | ...new Set( |
| 305 | [...NODE_ENVIRONMENTS, ...(viteOptions.skipEnvironments ?? [])].filter( |
| 306 | (name) => !(workerdPrerender && name === "prerender"), |
| 307 | ), |
| 308 | ), |
| 309 | ], |
| 310 | }; |
| 311 | }; |
| 312 | |
| 313 | export function distilledCloudflare( |
| 314 | options: DistilledCloudflareOptions = {}, |
| 315 | ): AstroIntegration { |
| 316 | assertSupportedOptions(options); |
| 317 | const prerenderEnvironment = options.prerenderEnvironment ?? "workerd"; |
| 318 | const sessionKVBindingName = options.sessionKVBindingName ?? "SESSION"; |
| 319 | const sessions = options.sessions ?? true; |
| 320 | const sessionDevKV = options.sessionDevKV ?? true; |
| 321 | let _config: AstroConfig; |
| 322 | let _buildOutput: "static" | "server"; |
| 323 | let _originalClientDir: URL; |
| 324 | let _routes: Array<IntegrationResolvedRoute> = []; |
| 325 | // Whether the (possibly zero-config-defaulted) session config resolved to |
| 326 | // the Cloudflare KV driver — set at `astro:config:setup`, read at |
| 327 | // `astro:build:start` to give the workerd prerender worker a local |
| 328 | // session KV. |
| 329 | let _needsSessionKVBinding = false; |
| 330 | // Whether WE satisfied `config.adapter`. The user's astro.config.* loads |
| 331 | // natively and this integration is injected via `integrations`, but |
| 332 | // astro's build refuses server output unless `config.adapter` is set — so |
| 333 | // `astro:config:setup` injects a hookless marker when the config declares |
| 334 | // no adapter (the real adapter registration is `setAdapter` at |
| 335 | // `astro:config:done`). When the flag is still false at |
| 336 | // `astro:config:done`, the user declared their own adapter — a conflict. |
| 337 | let _injectedAdapterMarker = false; |
| 338 | return { |
| 339 | name: "@alchemy.run/frontend-frameworks/astro", |
| 340 | hooks: { |
| 341 | "astro:config:setup": ({ command, config, updateConfig, logger }) => { |
| 342 | const isTypeGenPhase = command === "build" || command === "sync"; |
| 343 | const userOptimizeDeps = config.vite?.optimizeDeps; |
| 344 | |
| 345 | // Zero-config sessions (mirrors upstream): default the session |
| 346 | // driver to Cloudflare KV against `sessionKVBindingName` when the |
| 347 | // user hasn't configured one. |
| 348 | // `session: false` is the user explicitly disabling sessions — |
| 349 | // respect it and skip the zero-config defaulting. |
| 350 | let session = config.session; |
| 351 | if (sessions && session !== false && !session?.driver) { |
| 352 | logger.info( |
| 353 | `Enabling sessions with Cloudflare KV with the "${sessionKVBindingName}" KV binding.`, |
| 354 | ); |
| 355 | session = { |
| 356 | driver: sessionDrivers.cloudflareKVBinding({ |
| 357 | binding: sessionKVBindingName, |
| 358 | }), |
| 359 | cookie: session?.cookie, |
| 360 | ttl: session?.ttl, |
| 361 | } as typeof config.session; |
| 362 | } |
| 363 | const needsSessionKVBinding = |
| 364 | sessions && |
| 365 | session !== false && |
| 366 | usesCloudflareKVSessionDriver(session); |
| 367 | _needsSessionKVBinding = needsSessionKVBinding; |
| 368 | |
| 369 | // In dev, satisfy the session KV binding with an in-memory local KV |
| 370 | // namespace so zero-config sessions work without any setup. |
| 371 | const viteOptions = |
| 372 | command === "dev" && needsSessionKVBinding && sessionDevKV |
| 373 | ? withDevSessionKv(options.vite, sessionKVBindingName) |
| 374 | : options.vite; |
| 375 | |
| 376 | const cloudflarePlugins = cloudflareVitePlugin( |
| 377 | makeIntegrationPluginOptions(viteOptions, { |
| 378 | prerenderEnvironment, |
| 379 | command, |
| 380 | }), |
| 381 | ) as Array<vite.Plugin>; |
| 382 | |
| 383 | // Same trick as upstream (astro#16332): `build`/`sync` run type |
| 384 | // generation, which creates a temporary Vite server and fires |
| 385 | // `configureServer` — that would boot workerd mid-build. Stripping |
| 386 | // the hook degrades our dev environments to runnable stubs, a |
| 387 | // supported contract of the dev plugin. |
| 388 | if (isTypeGenPhase) { |
| 389 | for (const plugin of cloudflarePlugins) { |
| 390 | plugin.configureServer = undefined; |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | if (config.adapter === undefined) { |
| 395 | // Satisfy astro's `config.adapter` existence check (the build |
| 396 | // refuses server output without it). Hookless on purpose: the |
| 397 | // marker is inert even if a later `runHookConfigSetup` pass |
| 398 | // unshifts it into `integrations`; the real adapter is registered |
| 399 | // via `setAdapter` at `astro:config:done`. |
| 400 | _injectedAdapterMarker = true; |
| 401 | updateConfig({ |
| 402 | adapter: { |
| 403 | name: "@alchemy.run/frontend-frameworks/astro", |
| 404 | hooks: {}, |
| 405 | }, |
| 406 | }); |
| 407 | } |
| 408 | |
| 409 | updateConfig({ |
| 410 | build: { redirects: false }, |
| 411 | session, |
| 412 | vite: { |
| 413 | plugins: [ |
| 414 | // Node mode only: in workerd mode dev has no prerender |
| 415 | // environment — prerenderable routes are served through the |
| 416 | // entry worker's dev-match path, same as upstream. |
| 417 | ...(command === "dev" && prerenderEnvironment === "node" |
| 418 | ? [createNodePrerenderPlugin()] |
| 419 | : []), |
| 420 | ...(prerenderEnvironment === "workerd" |
| 421 | ? [createWorkerdPrerenderEnvironmentPlugin(SERVER_ENTRYPOINT)] |
| 422 | : []), |
| 423 | cloudflarePlugins, |
| 424 | { |
| 425 | name: "@alchemy.run/frontend-frameworks/astro:cf-imports", |
| 426 | enforce: "pre", |
| 427 | resolveId: { |
| 428 | filter: { id: /^cloudflare:/ }, |
| 429 | handler(id) { |
| 430 | return { id, external: true }; |
| 431 | }, |
| 432 | }, |
| 433 | } satisfies vite.Plugin, |
| 434 | { |
| 435 | name: "@alchemy.run/frontend-frameworks/astro:environment", |
| 436 | configEnvironment(environmentName, environmentOptions) { |
| 437 | if (isTypeGenPhase) { |
| 438 | return { optimizeDeps: { noDiscovery: true, include: [] } }; |
| 439 | } |
| 440 | const isServerEnvironment = [ |
| 441 | "astro", |
| 442 | "ssr", |
| 443 | "prerender", |
| 444 | ].includes(environmentName); |
| 445 | if ( |
| 446 | isServerEnvironment && |
| 447 | !environmentOptions.optimizeDeps?.noDiscovery |
| 448 | ) { |
| 449 | return { |
| 450 | optimizeDeps: { |
| 451 | include: [ |
| 452 | SERVER_ENTRYPOINT, |
| 453 | "astro", |
| 454 | "astro/runtime/**", |
| 455 | "astro > html-escaper", |
| 456 | "astro > mrmime", |
| 457 | "astro > zod/v4", |
| 458 | "astro > zod/v4/core", |
| 459 | "astro > clsx", |
| 460 | "astro > cookie", |
| 461 | "astro > devalue", |
| 462 | "astro > @oslojs/encoding", |
| 463 | "astro > es-module-lexer", |
| 464 | "astro > unstorage", |
| 465 | "astro > neotraverse/modern", |
| 466 | "astro > piccolore", |
| 467 | "astro > picomatch", |
| 468 | "astro/app", |
| 469 | "astro/app/fetch/default-handler", |
| 470 | "astro/fetch", |
| 471 | "astro/assets", |
| 472 | "astro/assets/runtime", |
| 473 | "astro/assets/utils/inferRemoteSize.js", |
| 474 | "astro/assets/fonts/runtime.js", |
| 475 | "astro/compiler-runtime", |
| 476 | "astro/jsx-runtime", |
| 477 | "astro/app/entrypoint/dev", |
| 478 | "astro/virtual-modules/middleware.js", |
| 479 | "astro/virtual-modules/transitions.js", |
| 480 | "astro/virtual-modules/transitions-events.js", |
| 481 | "astro/virtual-modules/transitions-router.js", |
| 482 | "astro/virtual-modules/transitions-swap-functions.js", |
| 483 | "astro/virtual-modules/transitions-types.js", |
| 484 | "astro/components", |
| 485 | ...(Array.isArray(userOptimizeDeps?.include) |
| 486 | ? userOptimizeDeps.include |
| 487 | : []), |
| 488 | ], |
| 489 | exclude: [ |
| 490 | "unstorage/drivers/cloudflare-kv-binding", |
| 491 | "astro:*", |
| 492 | "virtual:astro:*", |
| 493 | "virtual:astro-cloudflare:*", |
| 494 | "virtual:@astrojs/*", |
| 495 | "@astrojs/starlight", |
| 496 | ...(Array.isArray(userOptimizeDeps?.exclude) |
| 497 | ? userOptimizeDeps.exclude |
| 498 | : []), |
| 499 | ], |
| 500 | }, |
| 501 | }; |
| 502 | } else if (environmentName === "client") { |
| 503 | return { |
| 504 | optimizeDeps: { |
| 505 | include: [ |
| 506 | "astro/runtime/client/dev-toolbar/entrypoint.js", |
| 507 | ], |
| 508 | ignoreOutdatedRequests: true, |
| 509 | }, |
| 510 | }; |
| 511 | } |
| 512 | return undefined; |
| 513 | }, |
| 514 | } satisfies vite.Plugin, |
| 515 | { |
| 516 | enforce: "post", |
| 517 | name: "@alchemy.run/frontend-frameworks/astro:cf-externals", |
| 518 | applyToEnvironment: (environment) => |
| 519 | environment.name === "ssr" || |
| 520 | environment.name === "prerender", |
| 521 | config(conf) { |
| 522 | if (conf.ssr) { |
| 523 | // Cloudflare does not support externalizing modules in server environments |
| 524 | conf.ssr.external = undefined; |
| 525 | } |
| 526 | }, |
| 527 | } satisfies vite.Plugin, |
| 528 | createConfigPlugin({ |
| 529 | sessionKVBindingName, |
| 530 | compileImageConfig: null, |
| 531 | cacheProviderEnabled: false, |
| 532 | }), |
| 533 | ], |
| 534 | }, |
| 535 | image: { |
| 536 | ...config.image, |
| 537 | service: passthroughImageService(), |
| 538 | endpoint: (command === "dev" |
| 539 | ? { entrypoint: "astro/assets/endpoint/generic" } |
| 540 | : { entrypoint: IMAGE_PASSTHROUGH_ENDPOINT }) as never, |
| 541 | }, |
| 542 | }); |
| 543 | }, |
| 544 | "astro:routes:resolved": ({ routes }) => { |
| 545 | _routes = routes; |
| 546 | }, |
| 547 | "astro:config:done": ({ |
| 548 | setAdapter, |
| 549 | config, |
| 550 | injectTypes, |
| 551 | buildOutput, |
| 552 | }) => { |
| 553 | // The user's astro.config.* loads natively and this integration is |
| 554 | // merged over it, so a user-declared adapter (e.g. upstream |
| 555 | // @astrojs/cloudflare) would collide with the one the deploy target |
| 556 | // provides. Fail with an actionable error instead of letting the two |
| 557 | // adapters race. |
| 558 | if (config.adapter !== undefined && !_injectedAdapterMarker) { |
| 559 | throw new Error( |
| 560 | `@alchemy.run/frontend-frameworks/astro: the Astro config declares the adapter "${config.adapter.name}", ` + |
| 561 | "but the deploy target already provides the Cloudflare adapter. " + |
| 562 | "Remove `adapter` from your astro.config file — user integrations " + |
| 563 | "(react, mdx, tailwind, ...) are honored, and the adapter is injected by the toolchain.", |
| 564 | ); |
| 565 | } |
| 566 | _config = config; |
| 567 | _buildOutput = buildOutput; |
| 568 | options.onBuildOutput?.(buildOutput); |
| 569 | _originalClientDir = new URL(config.build.client.href); |
| 570 | if (config.base !== "/") { |
| 571 | // Nest the client build under the base so the assets directory can |
| 572 | // be served at the URL root (`/base/foo.css` → |
| 573 | // `dist/client/base/foo.css`). `astro:build:done` moves the special |
| 574 | // files back up, and the cloudflare target's `finish` pass points |
| 575 | // `BuildOutput.clientDirectory` back at the original directory. |
| 576 | config.build.client = new URL( |
| 577 | "." + config.base + "/", |
| 578 | config.build.client, |
| 579 | ); |
| 580 | } |
| 581 | options.onOriginalClientDir?.(fileURLToPath(_originalClientDir)); |
| 582 | injectTypes({ |
| 583 | filename: "cloudflare.d.ts", |
| 584 | content: |
| 585 | '/// <reference types="@alchemy.run/frontend-frameworks/astro/types.d.ts" />', |
| 586 | }); |
| 587 | setAdapter({ |
| 588 | name: "@alchemy.run/frontend-frameworks/astro", |
| 589 | adapterFeatures: { |
| 590 | buildOutput, |
| 591 | middlewareMode: "classic", |
| 592 | preserveBuildClientDir: true, |
| 593 | preserveBuildServerDir: true, |
| 594 | }, |
| 595 | entrypointResolution: "auto", |
| 596 | supportedAstroFeatures: { |
| 597 | serverOutput: "stable", |
| 598 | hybridOutput: "stable", |
| 599 | staticOutput: "stable", |
| 600 | i18nDomains: "experimental", |
| 601 | sharpImageService: { |
| 602 | support: "limited", |
| 603 | message: "workerd cannot run sharp", |
| 604 | }, |
| 605 | envGetSecret: "stable", |
| 606 | }, |
| 607 | }); |
| 608 | }, |
| 609 | "astro:build:start": async ({ setPrerenderer }) => { |
| 610 | if (prerenderEnvironment !== "workerd") { |
| 611 | return; |
| 612 | } |
| 613 | // Lazy: the prerenderer pulls Effect + cloudflare-runtime, which |
| 614 | // `astro dev` and node-mode builds never need. |
| 615 | const { createWorkerdPrerenderer } = await import("./prerenderer.ts"); |
| 616 | setPrerenderer( |
| 617 | createWorkerdPrerenderer({ |
| 618 | serverDir: _config.build.server, |
| 619 | // With `base !== "/"` the client build is nested under the base |
| 620 | // (`astro:config:done` above); assets are served from the |
| 621 | // original client root, so that is the prerender worker's assets |
| 622 | // directory too. |
| 623 | clientDir: _originalClientDir, |
| 624 | trailingSlash: _config.trailingSlash, |
| 625 | // Zero-config sessions: the prerender worker gets an ephemeral |
| 626 | // in-memory SESSION KV (the deployed Worker's real namespace |
| 627 | // need not exist at build time). See `withPrerenderSessionKv`. |
| 628 | vite: withPrerenderSessionKv(options.vite, { |
| 629 | needsSessionKVBinding: _needsSessionKVBinding, |
| 630 | sessionDevKV, |
| 631 | binding: sessionKVBindingName, |
| 632 | }), |
| 633 | }), |
| 634 | ); |
| 635 | }, |
| 636 | "astro:build:setup": ({ vite: viteConfig, target }) => { |
| 637 | if (target === "server") { |
| 638 | viteConfig.resolve ||= {}; |
| 639 | viteConfig.resolve.alias ||= {}; |
| 640 | viteConfig.ssr ||= {}; |
| 641 | viteConfig.ssr.noExternal = true; |
| 642 | |
| 643 | viteConfig.build ||= {}; |
| 644 | const build = viteConfig.build as Record<string, any>; |
| 645 | build.rolldownOptions ||= {}; |
| 646 | build.rolldownOptions.output ||= {}; |
| 647 | build.rolldownOptions.external = ["sharp"]; |
| 648 | build.rolldownOptions.output.banner ||= |
| 649 | "globalThis.process ??= {}; globalThis.process.env ??= {};"; |
| 650 | |
| 651 | viteConfig.define = { |
| 652 | "process.env": "process.env", |
| 653 | "globalThis.__ASTRO_IMAGES_BINDING_NAME": JSON.stringify("IMAGES"), |
| 654 | ...viteConfig.define, |
| 655 | }; |
| 656 | } |
| 657 | }, |
| 658 | "astro:build:done": async ({ dir, logger, assets }) => { |
| 659 | // base !== "/": the client build was nested under the base |
| 660 | // (`astro:config:done` above); the asset-server special files must |
| 661 | // live at the served directory's root, so move them back up. |
| 662 | if (_config.base !== "/") { |
| 663 | for (const file of [".assetsignore", "_headers", "_redirects"]) { |
| 664 | try { |
| 665 | await rename( |
| 666 | new URL(`./${file}`, _config.build.client), |
| 667 | new URL(`./${file}`, _originalClientDir), |
| 668 | ); |
| 669 | } catch { |
| 670 | // The file was not emitted for this build. |
| 671 | } |
| 672 | } |
| 673 | // Upstream also patches the emitted wrangler.json's |
| 674 | // `assets.directory` here; this build has no wrangler.json — the |
| 675 | // cloudflare target's `finish` pass rewrites |
| 676 | // `BuildOutput.clientDirectory` instead (see `cloudflare.ts`). |
| 677 | } |
| 678 | |
| 679 | // Immutable Cache-Control for the content-hashed assets directory. |
| 680 | if (_config.build.assetsPrefix) { |
| 681 | logger.debug( |
| 682 | "Skipping Cache-Control injection for assets — `build.assetsPrefix` is set, so assets are served from a different origin.", |
| 683 | ); |
| 684 | } else { |
| 685 | const headersPath = new URL("./_headers", _originalClientDir); |
| 686 | const result = await buildAssetsHeadersContent( |
| 687 | { |
| 688 | assetsDir: _config.build.assets, |
| 689 | basePrefix: removeTrailingForwardSlash(_config.base), |
| 690 | headersPath, |
| 691 | }, |
| 692 | (path) => readFile(path, "utf-8"), |
| 693 | ); |
| 694 | if (result === null) { |
| 695 | logger.debug( |
| 696 | "Skipping Cache-Control injection — _headers already sets Cache-Control on a matching rule.", |
| 697 | ); |
| 698 | } else { |
| 699 | const tempPath = new URL("./_headers.tmp", _originalClientDir); |
| 700 | try { |
| 701 | await writeFile(tempPath, result.content); |
| 702 | await rename(tempPath, headersPath); |
| 703 | } catch (err) { |
| 704 | await unlink(tempPath).catch(() => {}); |
| 705 | throw err; |
| 706 | } |
| 707 | logger.info( |
| 708 | `Injected immutable Cache-Control for ${result.assetsPattern} into _headers.`, |
| 709 | ); |
| 710 | } |
| 711 | } |
| 712 | |
| 713 | // Emit `_redirects` entries for Astro's redirect routes (config |
| 714 | // `redirects` + `Astro.redirect` route files) so prerendered/static |
| 715 | // redirects are handled by the asset server ahead of the Worker. |
| 716 | const trueRedirects = createRedirectsFromAstroRoutes({ |
| 717 | config: _config, |
| 718 | routeToDynamicTargetMap: new Map( |
| 719 | Array.from( |
| 720 | _routes |
| 721 | .filter((route) => route.type === "redirect") |
| 722 | .map((route) => [route, ""]), |
| 723 | ), |
| 724 | ), |
| 725 | dir, |
| 726 | buildOutput: _buildOutput, |
| 727 | assets, |
| 728 | }); |
| 729 | if (!trueRedirects.empty()) { |
| 730 | try { |
| 731 | await appendFile( |
| 732 | new URL("./_redirects", _originalClientDir), |
| 733 | printAsRedirects(trueRedirects), |
| 734 | ); |
| 735 | } catch { |
| 736 | logger.error("Failed to write _redirects file"); |
| 737 | } |
| 738 | } |
| 739 | }, |
| 740 | }, |
| 741 | }; |
| 742 | } |