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/fix-inter-chunk-skew-protection.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/astro/src/core/build/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -31,5 +32,6 @@ export function getAllBuildPlugins(
...pluginSSR(options, internals),
pluginNoop(),
vitePluginSSRAssets(internals),
pluginChunkImports(options),
].filter(Boolean);
}
58 changes: 58 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-chunk-imports.ts
Original file line number Diff line number Diff line change
@@ -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=<VERCEL_DEPLOYMENT_ID>) 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why filter to only relative imports?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Vite only generates relative paths for inter-chunk references in bundled output. But please let me know if there's a case I might be missing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah that's right, ok this is good.

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 };
},
};
}
55 changes: 55 additions & 0 deletions packages/astro/test/asset-query-params.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/asset-query-params-chunks",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div id="counter-a">Counter A</div>
<script>
import { greet, MESSAGES } from './shared.js';
const el = document.getElementById('counter-a');
if (el) el.textContent = greet('A') + ' ' + MESSAGES.welcome;
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div id="counter-b">Counter B</div>
<script>
import { farewell, MESSAGES } from './shared.js';
const el = document.getElementById('counter-b');
if (el) el.textContent = farewell('B') + ' ' + MESSAGES.success;
</script>
Original file line number Diff line number Diff line change
@@ -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',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import CounterA from '../components/CounterA.astro';
import CounterB from '../components/CounterB.astro';
---
<html>
<head><title>Chunk Imports Test</title></head>
<body>
<CounterA />
<CounterB />
</body>
</html>
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

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

Loading