Skip to content

Commit de669f0

Browse files
authored
fix(core): append assetQueryParams to inter-chunk JS imports (#15964) (#16110)
* fix(core): append assetQueryParams to inter-chunk JS imports (#15964) * Add changeset for inter-chunk skew protection fix
1 parent 358f826 commit de669f0

10 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
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

packages/astro/src/core/build/plugins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { pluginMiddleware } from './plugin-middleware.js';
1111
import { pluginPrerender } from './plugin-prerender.js';
1212
import { pluginScripts } from './plugin-scripts.js';
1313
import { pluginSSR } from './plugin-ssr.js';
14+
import { pluginChunkImports } from './plugin-chunk-imports.js';
1415
import { pluginNoop } from './plugin-noop.js';
1516
import { vitePluginSSRAssets } from '../vite-plugin-ssr-assets.js';
1617

@@ -31,5 +32,6 @@ export function getAllBuildPlugins(
3132
...pluginSSR(options, internals),
3233
pluginNoop(),
3334
vitePluginSSRAssets(internals),
35+
pluginChunkImports(options),
3436
].filter(Boolean);
3537
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { init, parse } from 'es-module-lexer';
2+
import type { Plugin as VitePlugin } from 'vite';
3+
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
4+
import type { StaticBuildOptions } from '../types.js';
5+
6+
/**
7+
* Appends assetQueryParams (e.g., ?dpl=<VERCEL_DEPLOYMENT_ID>) to relative
8+
* JS import paths inside client chunks. Without this, inter-chunk imports
9+
* bypass the HTML rendering pipeline and miss skew protection query params.
10+
*
11+
* Uses es-module-lexer to reliably parse both static and dynamic imports.
12+
*/
13+
export function pluginChunkImports(options: StaticBuildOptions): VitePlugin | undefined {
14+
const assetQueryParams = options.settings.adapter?.client?.assetQueryParams;
15+
if (!assetQueryParams || assetQueryParams.toString() === '') {
16+
return undefined;
17+
}
18+
const queryString = assetQueryParams.toString();
19+
20+
return {
21+
name: '@astro/plugin-chunk-imports',
22+
enforce: 'post',
23+
24+
applyToEnvironment(environment) {
25+
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client;
26+
},
27+
28+
async renderChunk(code, _chunk) {
29+
if (!code.includes('./')) {
30+
return null;
31+
}
32+
33+
await init;
34+
const [imports] = parse(code);
35+
36+
// Filter to relative JS imports only
37+
const relativeImports = imports.filter(
38+
(imp) => imp.n && /^\.\.?\//.test(imp.n) && /\.(?:js|mjs)$/.test(imp.n),
39+
);
40+
41+
if (relativeImports.length === 0) {
42+
return null;
43+
}
44+
45+
// Build new code by replacing specifiers from end to start
46+
// (reverse order preserves earlier offsets)
47+
let rewritten = code;
48+
for (let i = relativeImports.length - 1; i >= 0; i--) {
49+
const imp = relativeImports[i];
50+
// imp.s and imp.e are the start/end offsets of the module specifier (without quotes)
51+
rewritten =
52+
rewritten.slice(0, imp.e) + '?' + queryString + rewritten.slice(imp.e);
53+
}
54+
55+
return { code: rewritten, map: null };
56+
},
57+
};
58+
}

packages/astro/test/asset-query-params.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,61 @@ describe('Asset Query Parameters with Islands', () => {
145145
});
146146
});
147147

148+
describe('Asset Query Parameters in Inter-Chunk JS Imports', () => {
149+
/** @type {import('./test-utils').Fixture} */
150+
let fixture;
151+
152+
before(async () => {
153+
fixture = await loadFixture({
154+
root: './fixtures/asset-query-params-chunks/',
155+
output: 'server',
156+
adapter: testAdapter({
157+
extendAdapter: {
158+
client: {
159+
assetQueryParams: new URLSearchParams({ dpl: 'test-deploy-id' }),
160+
},
161+
},
162+
}),
163+
});
164+
await fixture.build();
165+
});
166+
167+
it('appends assetQueryParams to relative imports inside client JS chunks', async () => {
168+
const app = await fixture.loadTestAdapterApp();
169+
const response = await app.render(new Request('http://example.com/'));
170+
assert.equal(response.status, 200);
171+
const html = await response.text();
172+
const $ = cheerio.load(html);
173+
const scripts = $('script[src]');
174+
assert.ok(scripts.length > 0, 'Should have at least one external script');
175+
176+
let foundRelativeImport = false;
177+
// Read all client JS files and check inter-chunk imports have query params
178+
const jsFiles = await fixture.glob('client/**/*.js');
179+
for (const file of jsFiles) {
180+
const code = await fixture.readFile(`/${file}`);
181+
// Match relative imports: from"./chunk.js", from "./chunk.js", import("./chunk.js")
182+
const allImports = [
183+
...code.matchAll(/(from\s*["'])(\.\.?\/[^"']+\.(?:js|mjs)(?:\?[^"']*)?)(["'])/g),
184+
...code.matchAll(/(import\s*\(\s*["'])(\.\.?\/[^"']+\.(?:js|mjs)(?:\?[^"']*)?)(["'])/g),
185+
];
186+
for (const match of allImports) {
187+
foundRelativeImport = true;
188+
const importPath = match[2];
189+
assert.match(
190+
importPath,
191+
/\?dpl=test-deploy-id/,
192+
`Inter-chunk import should include assetQueryParams: ${match[0]}`,
193+
);
194+
}
195+
}
196+
assert.ok(
197+
foundRelativeImport,
198+
'Expected at least one relative inter-chunk import in client JS files',
199+
);
200+
});
201+
});
202+
148203
describe('Asset Query Parameters with Islands and assetsPrefix map', () => {
149204
/** @type {import('./test-utils').Fixture} */
150205
let fixture;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/asset-query-params-chunks",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
}
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div id="counter-a">Counter A</div>
2+
<script>
3+
import { greet, MESSAGES } from './shared.js';
4+
const el = document.getElementById('counter-a');
5+
if (el) el.textContent = greet('A') + ' ' + MESSAGES.welcome;
6+
</script>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div id="counter-b">Counter B</div>
2+
<script>
3+
import { farewell, MESSAGES } from './shared.js';
4+
const el = document.getElementById('counter-b');
5+
if (el) el.textContent = farewell('B') + ' ' + MESSAGES.success;
6+
</script>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Shared module that will be extracted into a separate chunk
2+
// when imported by multiple client-side scripts
3+
export function greet(name) {
4+
return `Hello, ${name}!`;
5+
}
6+
7+
export function farewell(name) {
8+
return `Goodbye, ${name}!`;
9+
}
10+
11+
// Add enough code to prevent inlining
12+
export const MESSAGES = {
13+
welcome: 'Welcome to the app',
14+
loading: 'Loading...',
15+
error: 'Something went wrong',
16+
success: 'Operation successful',
17+
notFound: 'Page not found',
18+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
import CounterA from '../components/CounterA.astro';
3+
import CounterB from '../components/CounterB.astro';
4+
---
5+
<html>
6+
<head><title>Chunk Imports Test</title></head>
7+
<body>
8+
<CounterA />
9+
<CounterB />
10+
</body>
11+
</html>

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)