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 */
29import { removeTrailingForwardSlash } from "@astrojs/internal-helpers/path";
30import {
31 createRedirectsFromAstroRoutes,
32 printAsRedirects,
33} from "@astrojs/underscore-redirects";
34import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite";
35import cloudflareVitePlugin from "@alchemy.run/cloudflare-runtime/vite";
36import { KvNamespace } from "@alchemy.run/cloudflare-runtime/core/bindings";
37import type {
38 AstroConfig,
39 AstroIntegration,
40 IntegrationResolvedRoute,
41} from "astro";
42import { passthroughImageService, sessionDrivers } from "astro/config";
43import {
44 appendFile,
45 readFile,
46 rename,
47 unlink,
48 writeFile,
49} from "node:fs/promises";
50import { fileURLToPath } from "node:url";
51import type * as vite from "vite";
52import { createConfigPlugin } from "./config-plugin.ts";
53import { NODE_ENVIRONMENTS } from "./environments.ts";
54import { buildAssetsHeadersContent } from "./headers.ts";
55import { createWorkerdPrerenderEnvironmentPlugin } from "./prerender-environment.ts";
56import { createNodePrerenderPlugin } from "./prerender-middleware.ts";
57
58/** The Worker entry module (the vendored `@astrojs/cloudflare` server entrypoint). */
59export const SERVER_ENTRYPOINT =
60 "@alchemy.run/frontend-frameworks/astro/entrypoints/server";
61
62/** The production endpoint of the passthrough image service. */
63export const IMAGE_PASSTHROUGH_ENDPOINT = fileURLToPath(
64 import.meta
65 .resolve("@alchemy.run/frontend-frameworks/astro/image-passthrough-endpoint"),
66);
67
68export 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 */
146const 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. */
168const 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 */
188const CLOUDFLARE_KV_SESSION_DRIVER_ENTRYPOINT =
189 sessionDrivers.cloudflareKVBinding().entrypoint;
190
191type SessionConfigLike =
192 | {
193 driver?:
194 | string
195 | { entrypoint: string | { toString(): string } }
196 | undefined;
197 cookie?: unknown;
198 ttl?: unknown;
199 }
200 | undefined;
201
202export 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 */
231export 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 */
258export 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 */
285export 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
313export 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}