Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-rooms-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixed a bug where a cascade of reloads would cause the page to crash during the first visit when building or developing with Cloudflare SSR in Astro v6 due to dependency loading issues.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import type { DepOptimizationConfig } from 'vite';

const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
Expand All @@ -16,6 +17,8 @@ function replaceTopLevelReturns(code: string): string {
});
}

const ASTRO_FRONTMATTER_NAMESPACE = 'astro-frontmatter';

// Not exposed as a type from Vite, so need to grab this way.
type ESBuildPlugin = NonNullable<
NonNullable<DepOptimizationConfig['esbuildOptions']>['plugins']
Expand All @@ -25,47 +28,61 @@ type ESBuildPlugin = NonNullable<
* An esbuild plugin that extracts frontmatter from .astro files during
* dependency optimization scanning. This allows Vite to discover imports
* in the server-side frontmatter code.
*
* This plugin uses an `onResolve` handler to intercept `.astro` files before
* Vite's built-in `vite:dep-scan` plugin routes them to the `html` namespace.
* Without this, Vite's scanner only extracts `<script>` tags from `.astro`
* files, completely missing frontmatter imports (which is where SSR-side
* dependencies like `zod`, `nanostores`, `astro:transitions`, etc. live).
*/
export function astroFrontmatterScanPlugin(): ESBuildPlugin {
return {
name: 'astro-frontmatter-scan',
setup(build) {
// Scope to the "file" namespace so that .astro files resolved into the
// "html" namespace (e.g. when a .ts file default-imports a component)
// fall through to Vite's built-in html-type handler, which appends
// `export default {}` and avoids "No matching export" errors.
build.onLoad({ filter: /\.astro$/, namespace: 'file' }, async (args) => {
try {
const code = await readFile(args.path, 'utf-8');
// Intercept .astro file resolution to route them through our namespace
// before Vite's `vite:dep-scan` plugin puts them in the `html` namespace.
// In esbuild, plugins are processed in order and the first `onResolve`
// match wins. Since user plugins (including this one) are registered before
// Vite's scanner plugin, this handler takes priority.
build.onResolve({ filter: /\.astro$/ }, (args) => {
// Only intercept files in the default namespace. Files already in a
// custom namespace are either already claimed or are re-entry points
// that should not be processed again.
if (args.namespace !== 'file' && args.namespace !== '' && args.namespace !== undefined) {
return undefined;
}
const resolvedPath = isAbsolute(args.path)
? args.path
: resolve(args.resolveDir, args.path);
return { path: resolvedPath, namespace: ASTRO_FRONTMATTER_NAMESPACE };
});

// Extract frontmatter content between --- markers
const frontmatterMatch = FRONTMATTER_RE.exec(code);
if (frontmatterMatch) {
// Replace `return` with `throw` to avoid esbuild's "Top-level return" error during scanning.
// This aligns with Astro's core compiler logic for frontmatter error handling.
// See: packages/astro/src/vite-plugin-astro/compile.ts
const contents = replaceTopLevelReturns(frontmatterMatch[1]);
build.onLoad(
{ filter: /\.astro$/, namespace: ASTRO_FRONTMATTER_NAMESPACE },
async (args) => {
try {
const code = await readFile(args.path, 'utf-8');

// Append `export default {}` so that default imports of .astro files
// (e.g. `import MyComponent from './MyComponent.astro'`) resolve correctly
// during the dep scan. Without this, .astro files loaded in the `html`
// namespace (when imported from .ts files) would have no default export,
// causing esbuild to fail with "No matching export for import 'default'".
return {
contents: contents + '\nexport default {}',
loader: 'ts',
};
const frontmatterMatch = FRONTMATTER_RE.exec(code);
if (frontmatterMatch) {
const contents = replaceTopLevelReturns(frontmatterMatch[1]);
return {
contents: contents + '\nexport default {}',
loader: 'ts',
resolveDir: dirname(args.path),
};
}
} catch {
// Ignore read errors
}
} catch {
// Ignore read errors
}

// No frontmatter or read error, return empty with a default export
return {
contents: 'export default {}',
loader: 'ts',
};
});
return {
contents: 'export default {}',
loader: 'ts',
resolveDir: dirname(args.path),
};
},
);
},
};
}
6 changes: 6 additions & 0 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ export default function createIntegration({
'astro/jsx-runtime',
'astro/app/entrypoint/dev',
'astro/virtual-modules/middleware.js',
'astro/virtual-modules/transitions.js',
'astro/virtual-modules/transitions-router.js',
'astro/virtual-modules/transitions-types.js',
'astro/virtual-modules/transitions-events.js',
'astro/virtual-modules/transitions-swap-functions.js',
...(isAstroPrismPackageInstalled ? prismFiles : []),
],
exclude: [
Expand All @@ -296,6 +301,7 @@ export default function createIntegration({
'virtual:@astrojs/*',
'@astrojs/starlight',
],
ignoreOutdatedRequests: true,
esbuildOptions: {
// Suppress Vite's `createRequire(import.meta.url)` banner to work around
// https://github.com/vitejs/vite/issues/22004 — Vite's SSR transform
Expand Down
145 changes: 145 additions & 0 deletions packages/integrations/cloudflare/test/dev-ssr-optimization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as assert from 'node:assert/strict';
import { Writable } from 'node:stream';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { AstroLogger } from '../../../astro/dist/core/logger/core.js';
import cloudflare from '../dist/index.js';
import { astroFrontmatterScanPlugin } from '../dist/esbuild-plugin-astro-frontmatter.js';
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';

describe('Cloudflare SSR Optimization', () => {
// Verifies that the Cloudflare integration's SSR optimization plugin
// correctly pre-registers critical virtual modules and configures Vite to handle cascading re-optimizations gracefully,
// preventing crashes during development when using features like View Transitions.
async function captureEnvironmentPlugin(): Promise<any> {
const integration = cloudflare({});
const capturedPlugins: any[] = [];

await integration.hooks['astro:config:setup']?.({
command: 'dev',
isRestart: false,
config: {
root: new URL('file:///tmp/'),
srcDir: new URL('file:///tmp/src/'),
session: { driver: 'memory' },
} as any,
updateConfig: (cfg: any) => {
const plugins = [cfg?.vite?.plugins ?? []].flat(Number.POSITIVE_INFINITY);
capturedPlugins.push(...plugins.filter(Boolean));
},
addWatchFile: () => {},
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any,
} as any);

return capturedPlugins.find((p: any) => p?.name === '@astrojs/cloudflare:environment') ?? null;
}

// Note: These tests are designed to run in a Node environment and may not be compatible with browser-based testing frameworks.
describe('Astro Frontmatter Scanner', () => {
it('registers an onResolve handler to prioritize .astro file interception and dependency discovery', () => {
const plugin = astroFrontmatterScanPlugin();
const resolvers: Array<{ filter: RegExp }> = [];

void plugin.setup({
onResolve(options: { filter: RegExp }, _callback: (...args: unknown[]) => unknown) {
resolvers.push(options);
},
onLoad(_options: any, _callback: (...args: unknown[]) => unknown) {},
} as any);

const hasAstroResolver = resolvers.some((r) => r.filter.test('Component.astro'));
assert.equal(hasAstroResolver, true);
});
});

const TRANSITIONS_MODULES = [
'astro/virtual-modules/transitions.js',
'astro/virtual-modules/transitions-router.js',
'astro/virtual-modules/transitions-types.js',
'astro/virtual-modules/transitions-events.js',
'astro/virtual-modules/transitions-swap-functions.js',
] as const;

// SSR environment in which critical virtual modules are pre-registered and reload resilience is configured
// to ensure they are correctly injected.
describe('Vite Environment Configuration', () => {
let envPlugin: any;

before(async () => {
envPlugin = await captureEnvironmentPlugin();
});

for (const envName of ['astro', 'ssr', 'prerender'] as const) {
it(`explicitly includes View Transitions virtual modules in "${envName}" environment to prevent runtime discovery`, () => {
const config = envPlugin?.configEnvironment(envName, {});
const include: string[] = config?.optimizeDeps?.include ?? [];
const missing = TRANSITIONS_MODULES.filter((m) => !include.includes(m));
assert.deepEqual(missing, [], `Missing virtual modules in "${envName}": ${missing.join(', ')}`);
});

it(`enables ignoreOutdatedRequests for "${envName}" environment to prevent crashes during cascading re-optimization`, () => {
const config = envPlugin?.configEnvironment(envName, {});
assert.equal(config?.optimizeDeps?.ignoreOutdatedRequests, true);
});
}
});

// Verify that the dev server remains stable and correctly handles SSR optimizations without crashing
// even when features like View Transitions trigger multiple optimization cycles.
describe('Dev Server Runtime Behavior', () => {
let fixture: Fixture;
let devServer: DevServer;
const logs: string[] = [];

before(async () => {
fixture = await loadFixture({
root: './fixtures/dev-ssr-optimization/',
});

const logger = new AstroLogger({
level: 'warn',
destination: new Writable({
objectMode: true,
write(event, _, callback) {
logs.push(event.message);
callback();
},
}),
});

devServer = await fixture.startDevServer({
// @ts-expect-error: logger is @internal
logger,
});

// initial request to trigger optimization and potential re-optimization cycles
await fixture.fetch('/');
});

after(async () => {
await devServer.stop();
});

it('successfully serves the initial request without triggering fatal optimization errors', async () => {
const res = await fixture.fetch('/');
assert.equal(res.status, 200);
});

it('maintains stability across multiple requests and handles in-place restarts', async () => {
const res = await fixture.fetch('/');
assert.equal(res.status, 200);
});

it('suppresses "The file does not exist" errors by handling stale chunk references gracefully', () => {
const staleChunkErrors = logs.filter((msg) => msg.includes('The file does not exist'));
assert.equal(staleChunkErrors.length, 0);
});

it('ensures correct server-side rendering of pages utilizing View Transitions', async () => {
const res = await fixture.fetch('/');
const html = await res.text();
const $ = cheerio.load(html);
assert.equal($('#content').text(), 'OK');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
adapter: cloudflare({
platformProxy: {
enabled: true,
},
}),
output: 'server',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@test/cloudflare-dev-ssr-optimization",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
},
"devDependencies": {
"wrangler": "^4.83.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
let count = $state(0);
</script>

<button onclick={() => count++}>Count: {count}</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dev SSR Optimization Test</title>
<ClientRouter />
</head>
<body>
<div id="content">OK</div>
</body>
</html>
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading