Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f957f03
feat: add adapter support for internal fetch headers (Netlify skew pr…
matthewp Oct 8, 2025
c2a0d56
fix: resolve TypeScript errors in adapter config implementation
matthewp Oct 14, 2025
f3c03f6
refactor: extract internalFetchHeaders from manifest into SSRResult
matthewp Oct 14, 2025
abaa04f
Address PR feedback: clean up tests and update comment
matthewp Oct 14, 2025
ea52496
Fix import sorting in create-vite and router files
matthewp Oct 14, 2025
152298a
Fix skew protection test to check correct manifest file
matthewp Oct 14, 2025
a33869a
Fix virtual module to return empty headers during SSR
matthewp Oct 14, 2025
4e36b0d
Merge branch 'main' into netlify-skew-protection
matthewp Oct 14, 2025
454e770
review changes
matthewp Oct 17, 2025
61a1910
Merge branch 'main' into netlify-skew-protection
matthewp Oct 17, 2025
b5d57a4
Update .changeset/netlify-skew-protection.md
matthewp Oct 17, 2025
ae1d9e1
fix lint
matthewp Oct 17, 2025
1476102
Merge branch 'netlify-skew-protection' of github.com:withastro/astro …
matthewp Oct 17, 2025
b6d9627
Update packages/astro/dev-only.d.ts
matthewp Oct 17, 2025
8a2efec
Add assetQueryParams support for skew protection
matthewp Oct 17, 2025
2717a6d
Merge branch 'netlify-skew-protection' of github.com:withastro/astro …
matthewp Oct 17, 2025
693b5de
update imports
matthewp Oct 17, 2025
d85846a
update based on review comments
matthewp Oct 17, 2025
805cecc
switch to use URLSearchParams
matthewp Oct 17, 2025
f131229
remove unneeded file
matthewp Oct 20, 2025
24cbfe7
align vercel impl with how the other works
matthewp Oct 20, 2025
2c623ba
focus changesets a bit more
matthewp Oct 20, 2025
f515ff0
Update .changeset/astro-asset-query-params.md
matthewp Oct 20, 2025
da67fea
Update .changeset/astro-asset-query-params.md
matthewp Oct 20, 2025
f77ee2b
explain how to do it yourself too
matthewp Oct 20, 2025
4639d13
Merge branch 'netlify-skew-protection' of github.com:withastro/astro …
matthewp Oct 20, 2025
8490920
oops
matthewp Oct 20, 2025
aa361ef
Update packages/integrations/vercel/src/index.ts
matthewp Oct 20, 2025
a0424b6
Merge branch 'main' into netlify-skew-protection
matthewp Oct 21, 2025
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
11 changes: 11 additions & 0 deletions .changeset/astro-asset-query-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'astro': minor
---

Adds two new adapter configuration options `assetQueryParams` and `internalFetchHeaders` to the Adapter API.

Official and community-built adapters can now use `client.assetQueryParams` to specify query parameters that should be appended to asset URLs (CSS, JavaScript, images, fonts, etc.). The query parameters are automatically appended to all generated asset URLs during the build process.

Adapters can also use `client.internalFetchHeaders` to specify headers that should be included in Astro's internal fetch calls (Actions, View Transitions, Server Islands, Prefetch).

This enables features like Netlify's skew protection, which requires the deploy ID to be sent with both internal requests and asset URLs to ensure client and server versions match during deployments.
15 changes: 15 additions & 0 deletions .changeset/netlify-assetqueryparams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@astrojs/netlify': minor
---

Enables Netlify's skew protection feature for Astro sites deployed on Netlify. Skew protection ensures that your site's client and server versions stay synchronized during deployments, preventing issues where users might load assets from a newer deployment while the server is still running the older version.

When you deploy to Netlify, the deployment ID is now automatically included in both asset requests and API calls, allowing Netlify to serve the correct version to every user. These are set for built-in features (Actions, View Transitions, Server Islands, Prefetch). If you are making your own fetch requests to your site, you can include the header manually using the `DEPLOY_ID` environment variable:

```js
const response = await fetch('/api/endpoint', {
headers: {
'X-Netlify-Deploy-ID': import.meta.env.DEPLOY_ID,
},
});
```
7 changes: 7 additions & 0 deletions .changeset/vercel-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/vercel': minor
---

Enables skew protection for Astro sites deployed on Vercel. Skew protection ensures that your site's client and server versions stay synchronized during deployments, preventing issues where users might load assets from a newer deployment while the server is still running the older version.

Skew protection is automatically enabled on Vercel deployments when the `VERCEL_SKEW_PROTECTION_ENABLED` environment variable is set to `1`. The deployment ID is automatically included in both asset requests and API calls, allowing Vercel to serve the correct version to every user.
4 changes: 4 additions & 0 deletions packages/astro/dev-only.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ declare module 'virtual:astro:assets/fonts/internal' {
export const consumableMap: import('./src/assets/fonts/types.js').ConsumableMap;
}

declare module 'virtual:astro:adapter-config/client' {
export const internalFetchHeaders: Record<string, string>;
}

declare module 'virtual:astro:actions/options' {
export const shouldAppendTrailingSlash: boolean;
}
Expand Down
23 changes: 15 additions & 8 deletions packages/astro/src/assets/utils/getAssetsPrefix.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import type { AssetsPrefix } from '../../core/app/types.js';

export function getAssetsPrefix(fileExtension: string, assetsPrefix?: AssetsPrefix): string {
if (!assetsPrefix) return '';
if (typeof assetsPrefix === 'string') return assetsPrefix;
// we assume the file extension has a leading '.' and we remove it
const dotLessFileExtension = fileExtension.slice(1);
if (assetsPrefix[dotLessFileExtension]) {
return assetsPrefix[dotLessFileExtension];
export function getAssetsPrefix(
fileExtension: string,
assetsPrefix?: AssetsPrefix,
): string {
let prefix = '';
if (!assetsPrefix) {
prefix = '';
} else if (typeof assetsPrefix === 'string') {
prefix = assetsPrefix;
} else {
// we assume the file extension has a leading '.' and we remove it
const dotLessFileExtension = fileExtension.slice(1);
prefix = assetsPrefix[dotLessFileExtension] || assetsPrefix.fallback;
}
return assetsPrefix.fallback;

return prefix;
}
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export type SSRManifest = {
buildClientDir: string | URL;
buildServerDir: string | URL;
csp: SSRManifestCSP | undefined;
internalFetchHeaders?: Record<string, string>;
};

export type SSRActions = {
Expand Down
25 changes: 23 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,21 @@ async function buildManifest(
staticFiles.push(entryModules[PAGE_SCRIPT_ID]);
}

const assetQueryParams = settings.adapter?.client?.assetQueryParams;
const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined;

const prefixAssetPath = (pth: string) => {
let result = '';
if (settings.config.build.assetsPrefix) {
const pf = getAssetsPrefix(fileExtension(pth), settings.config.build.assetsPrefix);
return joinPaths(pf, pth);
result = joinPaths(pf, pth);
} else {
return prependForwardSlash(joinPaths(settings.config.base, pth));
result = prependForwardSlash(joinPaths(settings.config.base, pth));
}
if (assetQueryString) {
result += '?' + assetQueryString;
}
return result;
};

// Default components follow a special flow during build. We prevent their processing earlier
Expand Down Expand Up @@ -341,6 +349,18 @@ async function buildManifest(
};
}

// Get internal fetch headers from adapter config
let internalFetchHeaders: Record<string, string> | undefined = undefined;
if (settings.adapter?.client?.internalFetchHeaders) {
const headers =
typeof settings.adapter.client.internalFetchHeaders === 'function'
? settings.adapter.client.internalFetchHeaders()
: settings.adapter.client.internalFetchHeaders;
if (Object.keys(headers).length > 0) {
internalFetchHeaders = headers;
}
}

return {
hrefRoot: opts.settings.config.root.toString(),
cacheDir: opts.settings.config.cacheDir.toString(),
Expand Down Expand Up @@ -372,5 +392,6 @@ async function buildManifest(
key: encodedKey,
sessionConfig: settings.config.session,
csp,
internalFetchHeaders,
};
}
2 changes: 2 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import astroPrefetch from '../prefetch/vite-plugin-prefetch.js';
import astroDevToolbar from '../toolbar/vite-plugin-dev-toolbar.js';
import astroTransitions from '../transitions/vite-plugin-transitions.js';
import type { AstroSettings, RoutesList } from '../types/astro.js';
import { vitePluginAdapterConfig } from '../vite-plugin-adapter-config/index.js';
import astroVitePlugin from '../vite-plugin-astro/index.js';
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
Expand Down Expand Up @@ -156,6 +157,7 @@ export async function createVite(
command === 'dev' && vitePluginAstroServer({ settings, logger, fs, routesList, manifest }), // manifest is only required in dev mode, where it gets created before a Vite instance is created, and get passed to this function
importMetaEnv({ envLoader }),
astroEnv({ settings, sync, envLoader }),
vitePluginAdapterConfig(settings),
markdownVitePlugin({ settings, logger }),
htmlVitePlugin(),
astroPostprocessVitePlugin(),
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ export class RenderContext {
styleResources: manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [],
directives: manifest.csp?.directives ? [...manifest.csp.directives] : [],
isStrictDynamic: manifest.csp?.isStrictDynamic ?? false,
internalFetchHeaders: manifest.internalFetchHeaders,
};

return result;
Expand Down
30 changes: 22 additions & 8 deletions packages/astro/src/core/render/ssr-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,32 @@ import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core
import type { SSRElement } from '../../types/public/internal.js';
import type { AssetsPrefix, StylesheetAsset } from '../app/types.js';

export function createAssetLink(href: string, base?: string, assetsPrefix?: AssetsPrefix): string {
export function createAssetLink(
href: string,
base?: string,
assetsPrefix?: AssetsPrefix,
queryParams?: URLSearchParams,
): string {
let url = '';
if (assetsPrefix) {
const pf = getAssetsPrefix(fileExtension(href), assetsPrefix);
return joinPaths(pf, slash(href));
url = joinPaths(pf, slash(href));
} else if (base) {
return prependForwardSlash(joinPaths(base, slash(href)));
url = prependForwardSlash(joinPaths(base, slash(href)));
} else {
return href;
url = href;
}
if (queryParams) {
url += '?' + queryParams.toString();
}
return url;
}

function createStylesheetElement(
stylesheet: StylesheetAsset,
base?: string,
assetsPrefix?: AssetsPrefix,
queryParams?: URLSearchParams,
): SSRElement {
if (stylesheet.type === 'inline') {
return {
Expand All @@ -28,7 +39,7 @@ function createStylesheetElement(
return {
props: {
rel: 'stylesheet',
href: createAssetLink(stylesheet.src, base, assetsPrefix),
href: createAssetLink(stylesheet.src, base, assetsPrefix, queryParams),
},
children: '',
};
Expand All @@ -39,17 +50,19 @@ export function createStylesheetElementSet(
stylesheets: StylesheetAsset[],
base?: string,
assetsPrefix?: AssetsPrefix,
queryParams?: URLSearchParams,
): Set<SSRElement> {
return new Set(stylesheets.map((s) => createStylesheetElement(s, base, assetsPrefix)));
return new Set(stylesheets.map((s) => createStylesheetElement(s, base, assetsPrefix, queryParams)));
}

export function createModuleScriptElement(
script: { type: 'inline' | 'external'; value: string },
base?: string,
assetsPrefix?: AssetsPrefix,
queryParams?: URLSearchParams,
): SSRElement {
if (script.type === 'external') {
return createModuleScriptElementWithSrc(script.value, base, assetsPrefix);
return createModuleScriptElementWithSrc(script.value, base, assetsPrefix, queryParams);
} else {
return {
props: {
Expand All @@ -64,11 +77,12 @@ function createModuleScriptElementWithSrc(
src: string,
base?: string,
assetsPrefix?: AssetsPrefix,
queryParams?: URLSearchParams,
): SSRElement {
return {
props: {
type: 'module',
src: createAssetLink(src, base, assetsPrefix),
src: createAssetLink(src, base, assetsPrefix, queryParams),
},
children: '',
};
Expand Down
11 changes: 9 additions & 2 deletions packages/astro/src/prefetch/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/*
NOTE: Do not add any dependencies or imports in this file so that it can load quickly in dev.
NOTE: Be careful about adding dependencies or imports in this file so that it can load quickly in dev.
*/

import { internalFetchHeaders } from 'virtual:astro:adapter-config/client';

const debug = import.meta.env.DEV ? console.debug : undefined;
const inBrowser = import.meta.env.SSR === false;
// Track prefetched URLs so we don't prefetch twice
Expand Down Expand Up @@ -249,7 +251,12 @@ export function prefetch(url: string, opts?: PrefetchOptions) {
// Otherwise, fallback prefetch with fetch
else {
debug?.(`[astro] Prefetching ${url} with fetch`);
fetch(url, { priority: 'low' });
// Apply adapter-specific headers for internal fetches
const headers = new Headers();
for (const [key, value] of Object.entries(internalFetchHeaders) as [string, string][]) {
headers.set(key, value);
}
fetch(url, { priority: 'low', headers });
}
}

Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/runtime/server/render/server-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,25 @@ export class ServerIslandComponent {
);
}

// Get adapter headers for inline script
const adapterHeaders = this.result.internalFetchHeaders || {};
const headersJson = safeJsonStringify(adapterHeaders);

const method = useGETRequest
? // GET request
`let response = await fetch('${serverIslandUrl}');`
`const headers = new Headers(${headersJson});
let response = await fetch('${serverIslandUrl}', { headers });`
: // POST request
`let data = {
componentExport: ${safeJsonStringify(componentExport)},
encryptedProps: ${safeJsonStringify(propsEncrypted)},
slots: ${safeJsonStringify(renderedSlots)},
};
const headers = new Headers({ 'Content-Type': 'application/json', ...${headersJson} });
let response = await fetch('${serverIslandUrl}', {
method: 'POST',
body: JSON.stringify(data),
headers,
});`;

this.islandContent = `${method}replaceServerIsland('${hostId}', response);`;
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/transitions/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { internalFetchHeaders } from 'virtual:astro:adapter-config/client';
import type { TransitionBeforePreparationEvent } from './events.js';
import { doPreparation, doSwap, TRANSITION_AFTER_SWAP } from './events.js';
import { detectScriptExecuted } from './swap-functions.js';
Expand Down Expand Up @@ -99,7 +100,12 @@ async function fetchHTML(
init?: RequestInit,
): Promise<null | { html: string; redirected?: string; mediaType: DOMParserSupportedType }> {
try {
const res = await fetch(href, init);
// Apply adapter-specific headers for internal fetches
const headers = new Headers(init?.headers);
for (const [key, value] of Object.entries(internalFetchHeaders) as [string, string][]) {
headers.set(key, value);
}
const res = await fetch(href, { ...init, headers });
const contentType = res.headers.get('content-type') ?? '';
// drop potential charset (+ other name/value pairs) as parser needs the mediaType
const mediaType = contentType.split(';', 1)[0].trim();
Expand Down
15 changes: 15 additions & 0 deletions packages/astro/src/types/public/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ export interface AstroAdapter {
* If the adapter is not able to handle certain configurations, Astro will throw an error.
*/
supportedAstroFeatures: AstroAdapterFeatureMap;
/**
* Configuration for Astro's client-side code.
*/
client?: {
/**
* Headers to inject into Astro's internal fetch calls (Actions, View Transitions, Server Islands, Prefetch).
* Can be an object of headers or a function that returns headers.
*/
internalFetchHeaders?: Record<string, string> | (() => Record<string, string>);
/**
* Query parameters to append to all asset URLs (images, stylesheets, scripts, etc.).
* Useful for adapters that need to track deployment versions or other metadata.
*/
assetQueryParams?: URLSearchParams;
};
}

export type AstroAdapterFeatureMap = {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/types/public/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export interface SSRResult {
styleResources: SSRManifestCSP['styleResources'];
directives: SSRManifestCSP['directives'];
isStrictDynamic: SSRManifestCSP['isStrictDynamic'];
internalFetchHeaders?: Record<string, string>;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/virtual-modules/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { shouldAppendTrailingSlash } from 'virtual:astro:actions/options';
import { internalFetchHeaders } from 'virtual:astro:adapter-config/client';
import type { ActionClient, SafeResult } from '../actions/runtime/server.js';
import {
ACTION_QUERY_PARAMS,
Expand Down Expand Up @@ -94,6 +95,10 @@ async function handleAction(
// When running client-side, make a fetch request to the action path.
const headers = new Headers();
headers.set('Accept', 'application/json');
// Apply adapter-specific headers for internal fetches
for (const [key, value] of Object.entries(internalFetchHeaders)) {
headers.set(key, value);
}
let body = param;
if (!(body instanceof FormData)) {
try {
Expand Down
41 changes: 41 additions & 0 deletions packages/astro/src/vite-plugin-adapter-config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Plugin as VitePlugin } from 'vite';
import type { AstroSettings } from '../types/astro.js';

const VIRTUAL_CLIENT_ID = 'virtual:astro:adapter-config/client';
const RESOLVED_VIRTUAL_CLIENT_ID = '\0' + VIRTUAL_CLIENT_ID;

export function vitePluginAdapterConfig(settings: AstroSettings): VitePlugin {
return {
name: 'astro:adapter-config',
resolveId(id) {
if (id === VIRTUAL_CLIENT_ID) {
return RESOLVED_VIRTUAL_CLIENT_ID;
}
},
load(id, options) {
if (id === RESOLVED_VIRTUAL_CLIENT_ID) {
// During SSR, return empty headers to avoid any runtime issues
if (options?.ssr) {
return {
code: `export const internalFetchHeaders = {};`,
};
}

const adapter = settings.adapter;
const clientConfig = adapter?.client || {};

let internalFetchHeaders = {};
if (clientConfig.internalFetchHeaders) {
internalFetchHeaders =
typeof clientConfig.internalFetchHeaders === 'function'
? clientConfig.internalFetchHeaders()
: clientConfig.internalFetchHeaders;
}

return {
code: `export const internalFetchHeaders = ${JSON.stringify(internalFetchHeaders)};`,
};
}
},
};
}
Loading
Loading