diff --git a/.changeset/fix-inter-chunk-skew-protection.md b/.changeset/fix-inter-chunk-skew-protection.md new file mode 100644 index 000000000000..52ff3961ddc9 --- /dev/null +++ b/.changeset/fix-inter-chunk-skew-protection.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes skew protection query parameters not being appended to inter-chunk JavaScript imports in client bundles, which could cause version mismatches during rolling deployments on Vercel diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index 29efc0df788a..e48f93106596 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -11,6 +11,7 @@ import { pluginMiddleware } from './plugin-middleware.js'; import { pluginPrerender } from './plugin-prerender.js'; import { pluginScripts } from './plugin-scripts.js'; import { pluginSSR } from './plugin-ssr.js'; +import { pluginChunkImports } from './plugin-chunk-imports.js'; import { pluginNoop } from './plugin-noop.js'; import { vitePluginSSRAssets } from '../vite-plugin-ssr-assets.js'; @@ -31,5 +32,6 @@ export function getAllBuildPlugins( ...pluginSSR(options, internals), pluginNoop(), vitePluginSSRAssets(internals), + pluginChunkImports(options), ].filter(Boolean); } diff --git a/packages/astro/src/core/build/plugins/plugin-chunk-imports.ts b/packages/astro/src/core/build/plugins/plugin-chunk-imports.ts new file mode 100644 index 000000000000..eb7a8790dc9a --- /dev/null +++ b/packages/astro/src/core/build/plugins/plugin-chunk-imports.ts @@ -0,0 +1,58 @@ +import { init, parse } from 'es-module-lexer'; +import type { Plugin as VitePlugin } from 'vite'; +import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js'; +import type { StaticBuildOptions } from '../types.js'; + +/** + * Appends assetQueryParams (e.g., ?dpl=) to relative + * JS import paths inside client chunks. Without this, inter-chunk imports + * bypass the HTML rendering pipeline and miss skew protection query params. + * + * Uses es-module-lexer to reliably parse both static and dynamic imports. + */ +export function pluginChunkImports(options: StaticBuildOptions): VitePlugin | undefined { + const assetQueryParams = options.settings.adapter?.client?.assetQueryParams; + if (!assetQueryParams || assetQueryParams.toString() === '') { + return undefined; + } + const queryString = assetQueryParams.toString(); + + return { + name: '@astro/plugin-chunk-imports', + enforce: 'post', + + applyToEnvironment(environment) { + return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client; + }, + + async renderChunk(code, _chunk) { + if (!code.includes('./')) { + return null; + } + + await init; + const [imports] = parse(code); + + // Filter to relative JS imports only + const relativeImports = imports.filter( + (imp) => imp.n && /^\.\.?\//.test(imp.n) && /\.(?:js|mjs)$/.test(imp.n), + ); + + if (relativeImports.length === 0) { + return null; + } + + // Build new code by replacing specifiers from end to start + // (reverse order preserves earlier offsets) + let rewritten = code; + for (let i = relativeImports.length - 1; i >= 0; i--) { + const imp = relativeImports[i]; + // imp.s and imp.e are the start/end offsets of the module specifier (without quotes) + rewritten = + rewritten.slice(0, imp.e) + '?' + queryString + rewritten.slice(imp.e); + } + + return { code: rewritten, map: null }; + }, + }; +} diff --git a/packages/astro/test/asset-query-params.test.js b/packages/astro/test/asset-query-params.test.js index c86ae200bc85..641c21c7b750 100644 --- a/packages/astro/test/asset-query-params.test.js +++ b/packages/astro/test/asset-query-params.test.js @@ -145,6 +145,61 @@ describe('Asset Query Parameters with Islands', () => { }); }); +describe('Asset Query Parameters in Inter-Chunk JS Imports', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/asset-query-params-chunks/', + output: 'server', + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }), + }, + }, + }), + }); + await fixture.build(); + }); + + it('appends assetQueryParams to relative imports inside client JS chunks', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const scripts = $('script[src]'); + assert.ok(scripts.length > 0, 'Should have at least one external script'); + + let foundRelativeImport = false; + // Read all client JS files and check inter-chunk imports have query params + const jsFiles = await fixture.glob('client/**/*.js'); + for (const file of jsFiles) { + const code = await fixture.readFile(`/${file}`); + // Match relative imports: from"./chunk.js", from "./chunk.js", import("./chunk.js") + const allImports = [ + ...code.matchAll(/(from\s*["'])(\.\.?\/[^"']+\.(?:js|mjs)(?:\?[^"']*)?)(["'])/g), + ...code.matchAll(/(import\s*\(\s*["'])(\.\.?\/[^"']+\.(?:js|mjs)(?:\?[^"']*)?)(["'])/g), + ]; + for (const match of allImports) { + foundRelativeImport = true; + const importPath = match[2]; + assert.match( + importPath, + /\?dpl=test-deploy-id/, + `Inter-chunk import should include assetQueryParams: ${match[0]}`, + ); + } + } + assert.ok( + foundRelativeImport, + 'Expected at least one relative inter-chunk import in client JS files', + ); + }); +}); + describe('Asset Query Parameters with Islands and assetsPrefix map', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/fixtures/asset-query-params-chunks/package.json b/packages/astro/test/fixtures/asset-query-params-chunks/package.json new file mode 100644 index 000000000000..4317ab6c522e --- /dev/null +++ b/packages/astro/test/fixtures/asset-query-params-chunks/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/asset-query-params-chunks", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterA.astro b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterA.astro new file mode 100644 index 000000000000..067b4dae1f0c --- /dev/null +++ b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterA.astro @@ -0,0 +1,6 @@ +
Counter A
+ diff --git a/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterB.astro b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterB.astro new file mode 100644 index 000000000000..7d51fc183f80 --- /dev/null +++ b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/CounterB.astro @@ -0,0 +1,6 @@ +
Counter B
+ diff --git a/packages/astro/test/fixtures/asset-query-params-chunks/src/components/shared.js b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/shared.js new file mode 100644 index 000000000000..2523fa87daeb --- /dev/null +++ b/packages/astro/test/fixtures/asset-query-params-chunks/src/components/shared.js @@ -0,0 +1,18 @@ +// Shared module that will be extracted into a separate chunk +// when imported by multiple client-side scripts +export function greet(name) { + return `Hello, ${name}!`; +} + +export function farewell(name) { + return `Goodbye, ${name}!`; +} + +// Add enough code to prevent inlining +export const MESSAGES = { + welcome: 'Welcome to the app', + loading: 'Loading...', + error: 'Something went wrong', + success: 'Operation successful', + notFound: 'Page not found', +}; diff --git a/packages/astro/test/fixtures/asset-query-params-chunks/src/pages/index.astro b/packages/astro/test/fixtures/asset-query-params-chunks/src/pages/index.astro new file mode 100644 index 000000000000..53c0d49f70c0 --- /dev/null +++ b/packages/astro/test/fixtures/asset-query-params-chunks/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +import CounterA from '../components/CounterA.astro'; +import CounterB from '../components/CounterB.astro'; +--- + + Chunk Imports Test + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59117aa01bf4..d5b541c97d7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1937,6 +1937,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/asset-query-params-chunks: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/asset-url-base: dependencies: astro: