Skip to content

Commit b2bd27b

Browse files
OliverSpeirsarah11918matthewp
authored
Feat(cloudflare): configure prerender environment (#15711)
* feat(cloudflare): add prerenderEnvironment config option * feat(cloudflare): dev server prerender middleware * chore: add changeset * fix changeset * Update .changeset/cloudflare-prerender-environment.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * fix(cloudflare): register prerender middleware after Vite security checks * feat(cloudflare): route dev prerender handling through Astro core middleware * fix: remove additional astro environment * adds tests * update lockfile * fix: add todo comment back * move dev prerender environment from core to adapter * fix: remove unnecessary astro environment invalidation in routes plugin * Remove unused settingsSymbol * fix: add astro changeset for dev prerender core handling * Don't skip dots * Update .changeset/astro-dev-prerender-core-fixes.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --------- Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Matthew Phillips <matthewphillips@cloudflare.com> Co-authored-by: Matthew Phillips <matthew@matthewphillips.info>
1 parent d1ac58e commit b2bd27b

14 files changed

Lines changed: 381 additions & 74 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
astro: patch
3+
---
4+
5+
Improves Astro core's dev environment handling for prerendered routes by ensuring route/CSS updates and prerender middleware behavior work correctly across both SSR and prerender environments.
6+
7+
This enables integrations that use Astro's prerender dev environment (such as Cloudflare with `prerenderEnvironment: 'node'`) to get consistent route matching and HMR behavior during development.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@astrojs/cloudflare': minor
3+
---
4+
5+
Adds a `prerenderEnvironment` option to the Cloudflare adapter.
6+
7+
By default, Cloudflare uses its workerd runtime for prerendering static pages. Set `prerenderEnvironment` to `'node'` to use Astro's built-in Node.js prerender environment instead, giving prerendered pages access to the full Node.js ecosystem during both build and dev. This is useful when your prerendered pages depend on Node.js-specific APIs or NPM packages that aren't compatible with workerd.
8+
9+
```js
10+
// astro.config.mjs
11+
import cloudflare from '@astrojs/cloudflare';
12+
import { defineConfig } from 'astro/config';
13+
14+
export default defineConfig({
15+
adapter: cloudflare({
16+
prerenderEnvironment: 'node',
17+
}),
18+
});
19+
```

packages/astro/src/core/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export const originPathnameSymbol = Symbol.for('astro.originPathname');
8181
*/
8282
export const pipelineSymbol = Symbol.for('astro.pipeline');
8383

84+
/**
85+
* Use this symbol to opt into handling prerender routes in Astro core dev middleware.
86+
*/
87+
export const devPrerenderMiddlewareSymbol = Symbol.for('astro.devPrerenderMiddleware');
88+
8489
/**
8590
* The symbol used as a field on the request object to store a cleanup callback associated with aborting the request when the underlying socket closes.
8691
*/

packages/astro/src/vite-plugin-astro-server/plugin.ts

Lines changed: 113 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { isRunnableDevEnvironment, type RunnableDevEnvironment } from 'vite';
55
import { toFallbackType } from '../core/app/common.js';
66
import { toRoutingStrategy } from '../core/app/entrypoints/index.js';
77
import type { SSRManifest, SSRManifestCSP, SSRManifestI18n } from '../core/app/types.js';
8-
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
8+
import {
9+
ASTRO_VITE_ENVIRONMENT_NAMES,
10+
devPrerenderMiddlewareSymbol,
11+
} from '../core/constants.js';
912
import {
1013
getAlgorithm,
1114
getDirectives,
@@ -22,6 +25,7 @@ import { AstroError, AstroErrorData } from '../core/errors/index.js';
2225
import type { Logger } from '../core/logger/core.js';
2326
import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js';
2427
import { createViteLoader } from '../core/module-loader/index.js';
28+
import { matchAllRoutes } from '../core/routing/match.js';
2529
import { resolveMiddlewareMode } from '../integrations/adapter-utils.js';
2630
import { SERIALIZED_MANIFEST_ID } from '../manifest/serialized.js';
2731
import type { AstroSettings } from '../types/astro.js';
@@ -50,24 +54,41 @@ export default function createVitePluginAstroServer({
5054
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
5155
},
5256
async configureServer(viteServer) {
53-
// Cloudflare handles its own requests
57+
const ssrEnvironment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
58+
const prerenderEnvironment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.prerender];
59+
60+
const runnableSsrEnvironment = isRunnableDevEnvironment(ssrEnvironment)
61+
? (ssrEnvironment as RunnableDevEnvironment)
62+
: undefined;
63+
const runnablePrerenderEnvironment = isRunnableDevEnvironment(prerenderEnvironment)
64+
? (prerenderEnvironment as RunnableDevEnvironment)
65+
: undefined;
66+
5467
// TODO: let this handle non-runnable environments that don't intercept requests
55-
if (!isRunnableDevEnvironment(viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) {
68+
if (!runnableSsrEnvironment && !runnablePrerenderEnvironment) {
5669
return;
5770
}
58-
const environment = viteServer.environments[
59-
ASTRO_VITE_ENVIRONMENT_NAMES.ssr
60-
] as RunnableDevEnvironment;
61-
const loader = createViteLoader(viteServer, environment);
62-
const { default: createAstroServerApp } =
63-
await environment.runner.import<
64-
typeof import('../vite-plugin-app/createAstroServerApp.js')
65-
>(ASTRO_DEV_SERVER_APP_ID);
66-
const controller = createController({ loader });
67-
const { handler } = await createAstroServerApp(controller, settings, loader, logger);
68-
const { manifest } = await environment.runner.import<{
69-
manifest: SSRManifest;
70-
}>(SERIALIZED_MANIFEST_ID);
71+
72+
async function createHandler(environment: RunnableDevEnvironment) {
73+
const loader = createViteLoader(viteServer, environment);
74+
const { default: createAstroServerApp } =
75+
await environment.runner.import<
76+
typeof import('../vite-plugin-app/createAstroServerApp.js')
77+
>(ASTRO_DEV_SERVER_APP_ID);
78+
const controller = createController({ loader });
79+
const { handler } = await createAstroServerApp(controller, settings, loader, logger);
80+
const { manifest } = await environment.runner.import<{
81+
manifest: SSRManifest;
82+
}>(SERIALIZED_MANIFEST_ID);
83+
return { controller, handler, loader, manifest, environment };
84+
}
85+
86+
const ssrHandler = runnableSsrEnvironment
87+
? await createHandler(runnableSsrEnvironment)
88+
: undefined;
89+
const prerenderHandler = runnablePrerenderEnvironment
90+
? await createHandler(runnablePrerenderEnvironment)
91+
: undefined;
7192
const localStorage = new AsyncLocalStorage();
7293

7394
function handleUnhandledRejection(rejection: any) {
@@ -78,14 +99,25 @@ export default function createVitePluginAstroServer({
7899
message: AstroErrorData.UnhandledRejection.message(rejection?.stack || rejection),
79100
});
80101
const store = localStorage.getStore();
81-
if (store instanceof IncomingMessage) {
82-
setRouteError(controller.state, store.url!, error);
102+
const handlers = [];
103+
if (ssrHandler) handlers.push(ssrHandler);
104+
if (prerenderHandler) handlers.push(prerenderHandler);
105+
for (const currentHandler of handlers) {
106+
if (store instanceof IncomingMessage) {
107+
setRouteError(currentHandler.controller.state, store.url!, error);
108+
}
109+
const { errorWithMetadata } = recordServerError(
110+
currentHandler.loader,
111+
currentHandler.manifest,
112+
logger,
113+
error,
114+
);
115+
setTimeout(
116+
async () =>
117+
currentHandler.loader.webSocketSend(await getViteErrorPayload(errorWithMetadata)),
118+
200,
119+
);
83120
}
84-
const { errorWithMetadata } = recordServerError(loader, manifest, logger, error);
85-
setTimeout(
86-
async () => loader.webSocketSend(await getViteErrorPayload(errorWithMetadata)),
87-
200,
88-
);
89121
}
90122

91123
process.on('unhandledRejection', handleUnhandledRejection);
@@ -94,6 +126,14 @@ export default function createVitePluginAstroServer({
94126
});
95127

96128
return () => {
129+
const shouldHandlePrerenderInCore = Boolean(
130+
(viteServer as any)[devPrerenderMiddlewareSymbol],
131+
);
132+
133+
if (!ssrHandler && !(prerenderHandler && shouldHandlePrerenderInCore)) {
134+
return;
135+
}
136+
97137
// Push this middleware to the front of the stack so that it can intercept responses.
98138
// fix(#6067): always inject this to ensure zombie base handling is killed after restarts
99139
viteServer.middlewares.stack.unshift({
@@ -115,18 +155,58 @@ export default function createVitePluginAstroServer({
115155
handle: secFetchMiddleware(logger, settings.config.security?.allowedDomains),
116156
});
117157

118-
// Note that this function has a name so other middleware can find it.
119-
viteServer.middlewares.use(async function astroDevHandler(request, response) {
120-
if (request.url === undefined || !request.method) {
121-
response.writeHead(500, 'Incomplete request');
122-
response.end();
123-
return;
124-
}
158+
if (prerenderHandler && shouldHandlePrerenderInCore) {
159+
viteServer.middlewares.use(
160+
async function astroDevPrerenderHandler(request, response, next) {
161+
if (request.url === undefined || !request.method) {
162+
response.writeHead(500, 'Incomplete request');
163+
response.end();
164+
return;
165+
}
166+
167+
if (request.url.startsWith('/@') || request.url.startsWith('/__')) {
168+
return next();
169+
}
170+
171+
if (request.url.includes('/node_modules/')) {
172+
return next();
173+
}
125174

126-
localStorage.run(request, () => {
127-
handler(request, response);
175+
try {
176+
const pathname = decodeURI(new URL(request.url, 'http://localhost').pathname);
177+
const { routes } =
178+
await prerenderHandler.environment.runner.import('virtual:astro:routes');
179+
const routesList = { routes: routes.map((r: any) => r.routeData) };
180+
const matches = matchAllRoutes(pathname, routesList);
181+
182+
if (!matches.some((route) => route.prerender)) {
183+
return next();
184+
}
185+
186+
localStorage.run(request, () => {
187+
prerenderHandler.handler(request, response);
188+
});
189+
} catch (err) {
190+
next(err);
191+
}
192+
},
193+
);
194+
}
195+
196+
if (ssrHandler) {
197+
// Note that this function has a name so other middleware can find it.
198+
viteServer.middlewares.use(async function astroDevHandler(request, response) {
199+
if (request.url === undefined || !request.method) {
200+
response.writeHead(500, 'Incomplete request');
201+
response.end();
202+
return;
203+
}
204+
205+
localStorage.run(request, () => {
206+
ssrHandler.handler(request, response);
207+
});
128208
});
129-
});
209+
}
130210
};
131211
},
132212
};

packages/astro/src/vite-plugin-css/index.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,21 +92,26 @@ function* collectCSSWithOrder(
9292
* @param routesList
9393
*/
9494
export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOptions): Plugin[] {
95-
let ssrEnvironment: undefined | DevEnvironment = undefined;
95+
let server: vite.ViteDevServer | undefined;
9696
// Cache CSS content by module ID to avoid re-reading
9797
const cssContentCache = new Map<string, string>();
9898

99+
function getCurrentEnvironment(pluginEnv?: DevEnvironment): DevEnvironment | undefined {
100+
return pluginEnv ?? server?.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr] as DevEnvironment | undefined;
101+
}
102+
99103
return [
100104
{
101105
name: MODULE_DEV_CSS,
102106

103-
async configureServer(server) {
104-
ssrEnvironment = server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
107+
async configureServer(viteServer) {
108+
server = viteServer;
105109
},
106110
applyToEnvironment(env) {
107111
return (
108112
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
109-
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client
113+
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
114+
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
110115
);
111116
},
112117

@@ -144,9 +149,11 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
144149
// The virtual module name for this page, like virtual:astro:dev-css:index@_@astro
145150
const componentPageId = getVirtualModulePageNameForComponent(componentPath);
146151

152+
const env = getCurrentEnvironment(this.environment as DevEnvironment);
153+
147154
// Ensure the page module is loaded. This will populate the graph and allow us to walk through.
148-
await ssrEnvironment?.fetchModule(componentPageId);
149-
const resolved = await ssrEnvironment?.pluginContainer.resolveId(componentPageId);
155+
await env?.fetchModule(componentPageId);
156+
const resolved = await env?.pluginContainer.resolveId(componentPageId);
150157

151158
if (!resolved?.id) {
152159
return {
@@ -155,7 +162,7 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
155162
}
156163

157164
// the vite.EnvironmentModuleNode has all of the info we need
158-
const mod = ssrEnvironment?.moduleGraph.getModuleById(resolved.id);
165+
const mod = env?.moduleGraph.getModuleById(resolved.id);
159166

160167
if (!mod) {
161168
return {
@@ -164,7 +171,7 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
164171
}
165172

166173
// Walk through the graph depth-first
167-
for (const collected of collectCSSWithOrder(componentPageId, mod!)) {
174+
for (const collected of collectCSSWithOrder(componentPageId, mod)) {
168175
// Use the CSS file ID as the key to deduplicate while keeping best ordering
169176
if (!cssWithOrder.has(collected.idKey)) {
170177
// Look up actual content from cache if available
@@ -200,7 +207,8 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
200207
}
201208

202209
// Cache CSS content as we see it
203-
const mod = ssrEnvironment?.moduleGraph.getModuleById(id);
210+
const env = getCurrentEnvironment(this.environment as DevEnvironment);
211+
const mod = env?.moduleGraph.getModuleById(id);
204212
if (mod) {
205213
cssContentCache.set(id, code);
206214
}
@@ -210,11 +218,10 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
210218
{
211219
name: MODULE_DEV_CSS_ALL,
212220
applyToEnvironment(env) {
213-
// This should only run in dev mode so `prerender` is excluded.
214221
return (
215222
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
216223
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
217-
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro
224+
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
218225
);
219226
},
220227
resolveId: {

packages/astro/src/vite-plugin-routes/index.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,26 @@ export default async function astroPluginRoutes({
109109
routeData: serializeRouteData(r, settings.config.trailingSlash),
110110
};
111111
});
112-
let environment = server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
113-
const virtualMod = environment.moduleGraph.getModuleById(ASTRO_ROUTES_MODULE_ID_RESOLVED);
114-
if (!virtualMod) return;
112+
const environmentsToInvalidate = [];
113+
for (const name of [
114+
ASTRO_VITE_ENVIRONMENT_NAMES.ssr,
115+
ASTRO_VITE_ENVIRONMENT_NAMES.prerender,
116+
] as const) {
117+
const environment = server.environments[name];
118+
if (environment) {
119+
environmentsToInvalidate.push(environment);
120+
}
121+
}
115122

116-
environment.moduleGraph.invalidateModule(virtualMod);
123+
for (const environment of environmentsToInvalidate) {
124+
const virtualMod = environment.moduleGraph.getModuleById(ASTRO_ROUTES_MODULE_ID_RESOLVED);
125+
if (!virtualMod) continue;
117126

118-
// Signal that routes have changed so running apps can update
119-
// NOTE: Consider adding debouncing here if rapid file changes cause performance issues
120-
environment.hot.send('astro:routes-updated', {});
127+
environment.moduleGraph.invalidateModule(virtualMod);
128+
// Signal that routes have changed so running apps can update
129+
// NOTE: Consider adding debouncing here if rapid file changes cause performance issues
130+
environment.hot.send('astro:routes-updated', {});
131+
}
121132
}
122133
}
123134
return {

0 commit comments

Comments
 (0)