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 workerd prerenderer: the build-side driver of the
5 * `__astro_*` prerender protocol, ported from `@astrojs/cloudflare` v14.1.3
6 * (`src/prerenderer.ts`).
7 *
8 * Upstream boots a Vite preview server wrapped around
9 * `@cloudflare/vite-plugin` to host the built `prerender` environment. We
10 * skip the preview-server indirection entirely: the built output under
11 * `dist/server/.prerender/` is loaded from disk into workerd via
12 * `cloudflare-runtime`'s `Runtime.start`, with the same bindings, assets
13 * directory, and compatibility settings as the entry worker — so prerendered
14 * pages render in the same runtime that serves them in production
15 * (top-level `cloudflare:*` imports included).
16 *
17 * Protocol (served by the vendored runtime handler when
18 * `virtual:astro-cloudflare:config`'s `isPrerender` is true):
19 * - `POST /__astro_static_paths` → all paths to prerender (serialized routes)
20 * - `POST /__astro_prerender` → renders one page; a failure is signalled via
21 * the `x-astro-prerender-error` response header
22 *
23 * This module is heavyweight (Effect + cloudflare-runtime); the integration
24 * imports it lazily inside the `astro:build:start` hook so `astro dev` and
25 * node-mode builds never load it.
26 */
27import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite";
28import type {
29 BindingHooks,
30 Module,
31} from "@alchemy.run/cloudflare-runtime/core";
32import { DEFAULT_COMPATIBILITY_DATE } from "@alchemy.run/cloudflare-runtime/core/internal/constants";
33import * as Runtime from "@alchemy.run/cloudflare-runtime/core/Runtime";
34import * as RuntimeServices from "@alchemy.run/cloudflare-runtime/core/RuntimeServices";
35import * as Credentials from "@distilled.cloud/cloudflare/Credentials";
36import type { AstroConfig, AstroPrerenderer, PathWithRoute } from "astro";
37import { deserializeRouteData, serializeRouteData } from "astro/app/manifest";
38import type * as Context from "effect/Context";
39import * as Effect from "effect/Effect";
40import * as Exit from "effect/Exit";
41import * as Layer from "effect/Layer";
42import * as Scope from "effect/Scope";
43import * as FetchHttpClient from "effect/http/FetchHttpClient";
44import * as NodeFs from "node:fs/promises";
45import * as NodePath from "node:path";
46import { fileURLToPath } from "node:url";
47import { PlatformServices } from "../Platform.ts";
48import type {
49 PrerenderRequest,
50 StaticPathsResponse,
51} from "./runtime/prerender-types.ts";
52import {
53 PRERENDER_ENDPOINT,
54 STATIC_PATHS_ENDPOINT,
55} from "./runtime/utils/prerender-constants.ts";
56
57/** The subdirectory of the server output dir that hosts the prerender build. */
58const PRERENDER_OUTPUT_SUBDIR = "./.prerender/";
59
60/** Astro core's entry chunk name pattern for the prerender environment. */
61const PRERENDER_ENTRY_PATTERN = /^prerender-entry\..*\.mjs$/;
62
63export interface WorkerdPrerendererOptions {
64 /** `config.build.server` — the prerender build lives in `.prerender/` inside it. */
65 readonly serverDir: URL;
66 /** `config.build.client` — served as the worker's assets directory. */
67 readonly clientDir: URL;
68 /** `config.trailingSlash` — needed to serialize route data for the protocol. */
69 readonly trailingSlash: AstroConfig["trailingSlash"];
70 /**
71 * The integration's `@alchemy.run/cloudflare-runtime/vite` options: the
72 * prerender worker reuses the entry worker's name, bindings, assets
73 * options, compatibility settings, and (optionally) runtime context.
74 */
75 readonly vite?: CloudflareVitePluginOptions | undefined;
76}
77
78/**
79 * Creates the workerd prerenderer registered via `setPrerenderer` in the
80 * `astro:build:start` hook when `prerenderEnvironment` is `"workerd"`.
81 */
82export function createWorkerdPrerenderer(
83 options: WorkerdPrerendererOptions,
84): AstroPrerenderer {
85 const { serverDir, clientDir, trailingSlash, vite } = options;
86 let scope: Scope.Closeable | undefined;
87 let serverUrl: string | undefined;
88
89 const origin = (): string => {
90 if (serverUrl === undefined) {
91 throw new Error(
92 "The workerd prerender server is not running. " +
93 "This is a bug in @alchemy.run/frontend-frameworks/astro — `setup()` must run before the prerender protocol is driven.",
94 );
95 }
96 return serverUrl;
97 };
98
99 return {
100 name: "@alchemy.run/frontend-frameworks/astro:workerd-prerenderer",
101
102 async setup() {
103 const prerenderDir = fileURLToPath(
104 new URL(PRERENDER_OUTPUT_SUBDIR, serverDir),
105 );
106 const clientDirPath = fileURLToPath(clientDir);
107 // The assets manifest is read from the client dir; make sure it exists
108 // even for all-prerendered sites where the client build emitted nothing.
109 await NodeFs.mkdir(clientDirPath, { recursive: true });
110 const modules = await collectOutputModules(prerenderDir);
111
112 scope = Scope.makeUnsafe();
113 const context = vite?.context ?? (await buildDefaultContext(scope));
114 const worker = vite?.worker;
115
116 const program = Effect.gen(function* () {
117 const runtime = yield* Runtime.Runtime;
118 return yield* runtime.start({
119 ...worker,
120 // Suffixed so the prerender instance never collides with a
121 // concurrently running dev server in the local dev registry.
122 name:
123 worker?.name !== undefined
124 ? `${worker.name}-prerender`
125 : "astro-prerender",
126 compatibilityDate:
127 vite?.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
128 compatibilityFlags: vite?.compatibilityFlags ?? [],
129 bindings: (worker?.bindings ?? []) as BindingHooks,
130 modules,
131 assets: {
132 ...worker?.assets,
133 directory: worker?.assets?.directory ?? clientDirPath,
134 },
135 });
136 });
137
138 const url = await program.pipe(
139 Effect.provide(
140 context as Context.Context<RuntimeServices.RuntimeServices>,
141 ),
142 Scope.provide(scope),
143 Effect.runPromise,
144 );
145 serverUrl = url.origin;
146 },
147
148 async getStaticPaths(): Promise<Array<PathWithRoute>> {
149 const response = await fetch(`${origin()}${STATIC_PATHS_ENDPOINT}`, {
150 method: "POST",
151 headers: { "Content-Type": "application/json" },
152 });
153
154 if (!response.ok) {
155 const body = await response.text();
156 const details = body ? `\n${body}` : "";
157 throw new Error(
158 `Failed to get static paths from the workerd prerender server (${response.status}: ${response.statusText}).${details}`,
159 );
160 }
161
162 const data = (await response.json()) as StaticPathsResponse;
163 return data.paths.map(({ pathname, route }) => ({
164 pathname,
165 route: deserializeRouteData(route),
166 }));
167 },
168
169 async render(request, { routeData }) {
170 const body: PrerenderRequest = {
171 url: request.url,
172 routeData: serializeRouteData(routeData, trailingSlash),
173 };
174
175 const response = await fetch(`${origin()}${PRERENDER_ENDPOINT}`, {
176 method: "POST",
177 headers: { "Content-Type": "application/json" },
178 body: JSON.stringify(body),
179 redirect: "manual",
180 });
181
182 // Only the header marks a failure: pages may intentionally return
183 // non-2xx responses while prerendering (e.g. a custom 404 page).
184 const prerenderError = response.headers.get("x-astro-prerender-error");
185 if (prerenderError) {
186 throw new Error(
187 `Failed to prerender ${request.url}: ${prerenderError}`,
188 );
189 }
190
191 return response;
192 },
193
194 async teardown() {
195 serverUrl = undefined;
196 if (scope === undefined) return;
197 const closing = scope;
198 scope = undefined;
199 await Effect.runPromiseExit(
200 Scope.closeUnsafe(closing, Exit.void) ?? Effect.void,
201 );
202 },
203 };
204}
205
206/**
207 * Loads the prerender environment's build output from disk as workerd
208 * modules. Module names are output-relative POSIX paths, so the bundle's
209 * relative chunk imports (`./chunks/*.mjs`) resolve natively in workerd; the
210 * entry chunk (astro core names it `prerender-entry.[hash].mjs`) is listed
211 * first, which makes it the worker's main module.
212 */
213export const collectOutputModules = async (
214 outputDir: string,
215): Promise<Array<Module>> => {
216 let entries: Array<{
217 parentPath: string;
218 name: string;
219 isFile: () => boolean;
220 }>;
221 try {
222 entries = await NodeFs.readdir(outputDir, {
223 recursive: true,
224 withFileTypes: true,
225 });
226 } catch (cause) {
227 throw new Error(
228 `Failed to read the prerender build output at "${outputDir}". ` +
229 "The workerd prerenderer requires the `prerender` environment to have been built first.",
230 { cause },
231 );
232 }
233 const modules: Array<Module> = [];
234 for (const entry of entries) {
235 if (!entry.isFile()) continue;
236 const absolute = NodePath.join(entry.parentPath, entry.name);
237 const relative = NodePath.relative(outputDir, absolute);
238 const module = await moduleForFile(absolute, relative);
239 if (module !== undefined) modules.push(module);
240 }
241 // Deterministic order, entry first.
242 modules.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
243 const entryIndex = modules.findIndex((module) =>
244 PRERENDER_ENTRY_PATTERN.test(module.name),
245 );
246 if (entryIndex === -1) {
247 throw new Error(
248 `No prerender entry chunk (prerender-entry.*.mjs) found in "${outputDir}". ` +
249 "This is a bug in @alchemy.run/frontend-frameworks/astro — the prerender environment should have built the worker server entrypoint.",
250 );
251 }
252 const [entryModule] = modules.splice(entryIndex, 1);
253 modules.unshift(entryModule);
254 return modules;
255};
256
257const TEXT_EXTENSIONS = new Set([".css", ".html", ".svg", ".txt", ".xml"]);
258
259const moduleForFile = async (
260 absolute: string,
261 relative: string,
262): Promise<Module | undefined> => {
263 const extension = NodePath.extname(relative).toLowerCase();
264 if (extension === ".map") return undefined;
265 const name = relative.split(NodePath.sep).join("/");
266 switch (extension) {
267 case ".js":
268 case ".mjs":
269 return {
270 name,
271 type: "ESModule",
272 content: await NodeFs.readFile(absolute, "utf8"),
273 };
274 case ".cjs":
275 return {
276 name,
277 type: "CommonJsModule",
278 content: await NodeFs.readFile(absolute, "utf8"),
279 };
280 case ".json":
281 return {
282 name,
283 type: "Json",
284 content: await NodeFs.readFile(absolute, "utf8"),
285 };
286 case ".wasm":
287 return {
288 name,
289 type: "Wasm",
290 content: new Uint8Array(await NodeFs.readFile(absolute)),
291 };
292 default:
293 return TEXT_EXTENSIONS.has(extension)
294 ? {
295 name,
296 type: "Text",
297 content: await NodeFs.readFile(absolute, "utf8"),
298 }
299 : {
300 name,
301 type: "Data",
302 content: new Uint8Array(await NodeFs.readFile(absolute)),
303 };
304 }
305};
306
307/**
308 * The default runtime context, mirroring the Cloudflare vite plugin's dev
309 * server (`createDefaultContext`): the full `layerRuntime` stack, so local
310 * AND remote bindings behave identically to `astro dev`. Built into the
311 * prerenderer's scope so `teardown()` disposes everything (including the
312 * workerd process).
313 */
314const buildDefaultContext = (
315 scope: Scope.Scope,
316): Promise<Context.Context<RuntimeServices.RuntimeServices>> =>
317 RuntimeServices.layerRuntime({
318 api: {
319 accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
320 },
321 }).pipe(
322 Layer.provideMerge(PlatformServices),
323 Layer.provide(Layer.merge(Credentials.fromEnv(), FetchHttpClient.layer)),
324 Layer.buildWithScope(scope),
325 Effect.runPromise,
326 );