Skip to content

Commit 9b3241d

Browse files
matthewpsarah11918florian-lefebvrestyfle
authored
Allow adapters to customize headers for fetch requests (#14543)
* feat: add adapter support for internal fetch headers (Netlify skew protection) Adds a new generic mechanism for adapters to inject headers into Astro's internal fetch calls. This enables features like Netlify's skew protection. Changes: - Add `runtimeConfig.internalFetchHeaders` to AstroAdapter interface - Create `astro:adapter-config/client` virtual module - Update Actions, View Transitions, Server Islands, and Prefetch to use adapter headers - Implement Netlify skew protection with DEPLOY_ID header - Generate `.netlify/v1/skew-protection.json` configuration file - Add comprehensive test fixture for skew protection * fix: resolve TypeScript errors in adapter config implementation - Add type declarations for astro:adapter-config/client virtual module - Add manifest property to SSRResult interface - Fix Object.entries type assertions in prefetch and router - Add explicit return type to Netlify adapter's internalFetchHeaders function * refactor: extract internalFetchHeaders from manifest into SSRResult Follow existing pattern of extracting specific fields from manifest rather than passing the entire manifest object to SSRResult. This matches how other fields like base, trailingSlash, and serverIslandNameMap are handled. * Address PR feedback: clean up tests and update comment - Remove incomplete test stubs for actions and view transitions - Remove unused manifestPath variable - Update prefetch comment to be less strict about dependencies * Fix import sorting in create-vite and router files * Fix skew protection test to check correct manifest file The test was checking ssr.mjs which is just a wrapper, but the actual serialized manifest is in the manifest_*.mjs file in the build directory. * Fix virtual module to return empty headers during SSR This prevents timeouts in integration tests where client-side files importing the virtual module are processed during SSR. * review changes * Update .changeset/netlify-skew-protection.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * fix lint * Update packages/astro/dev-only.d.ts Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> * Add assetQueryParams support for skew protection This adds a new `assetQueryParams` configuration option to the adapter client config that allows adapters to append query parameters to all static asset URLs. This is used for Vercel's skew protection to track deployment versions. Changes: - Extended AstroAdapter.client interface with assetQueryParams property - Modified getAssetsPrefix() to accept and append query parameters - Updated manifest builder to include query params in all asset URLs - Updated SSR element creation functions to support query params - Updated Vercel adapter to provide deployment ID as query param when skew protection is enabled This ensures that all asset requests include the deployment identifier, allowing Vercel to route them to the correct deployment version if there's a version mismatch between client and server. * update imports * update based on review comments * switch to use URLSearchParams * remove unneeded file * align vercel impl with how the other works * focus changesets a bit more * Update .changeset/astro-asset-query-params.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * Update .changeset/astro-asset-query-params.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * explain how to do it yourself too * oops * Update packages/integrations/vercel/src/index.ts Co-authored-by: Steven <steven@ceriously.com> --------- Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> Co-authored-by: Steven <steven@ceriously.com>
1 parent e735d85 commit 9b3241d

File tree

29 files changed

+398
-23
lines changed

29 files changed

+398
-23
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds two new adapter configuration options `assetQueryParams` and `internalFetchHeaders` to the Adapter API.
6+
7+
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.
8+
9+
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).
10+
11+
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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@astrojs/netlify': minor
3+
---
4+
5+
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.
6+
7+
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:
8+
9+
```js
10+
const response = await fetch('/api/endpoint', {
11+
headers: {
12+
'X-Netlify-Deploy-ID': import.meta.env.DEPLOY_ID,
13+
},
14+
});
15+
```

.changeset/vercel-fixes.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astrojs/vercel': minor
3+
---
4+
5+
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.
6+
7+
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.

packages/astro/dev-only.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ declare module 'virtual:astro:assets/fonts/internal' {
1111
export const consumableMap: import('./src/assets/fonts/types.js').ConsumableMap;
1212
}
1313

14+
declare module 'virtual:astro:adapter-config/client' {
15+
export const internalFetchHeaders: Record<string, string>;
16+
}
17+
1418
declare module 'virtual:astro:actions/options' {
1519
export const shouldAppendTrailingSlash: boolean;
1620
}

packages/astro/src/actions/runtime/virtual.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { shouldAppendTrailingSlash } from 'virtual:astro:actions/options';
2+
import { internalFetchHeaders } from 'virtual:astro:adapter-config/client';
23
import type { APIContext } from '../../types/public/context.js';
34
import type { ActionClient, SafeResult } from './server.js';
45
import {
@@ -94,6 +95,10 @@ async function handleAction(
9495
// When running client-side, make a fetch request to the action path.
9596
const headers = new Headers();
9697
headers.set('Accept', 'application/json');
98+
// Apply adapter-specific headers for internal fetches
99+
for (const [key, value] of Object.entries(internalFetchHeaders)) {
100+
headers.set(key, value);
101+
}
97102
let body = param;
98103
if (!(body instanceof FormData)) {
99104
try {
Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { AssetsPrefix } from '../../core/app/types.js';
22

3-
export function getAssetsPrefix(fileExtension: string, assetsPrefix?: AssetsPrefix): string {
4-
if (!assetsPrefix) return '';
5-
if (typeof assetsPrefix === 'string') return assetsPrefix;
6-
// we assume the file extension has a leading '.' and we remove it
7-
const dotLessFileExtension = fileExtension.slice(1);
8-
if (assetsPrefix[dotLessFileExtension]) {
9-
return assetsPrefix[dotLessFileExtension];
3+
export function getAssetsPrefix(
4+
fileExtension: string,
5+
assetsPrefix?: AssetsPrefix,
6+
): string {
7+
let prefix = '';
8+
if (!assetsPrefix) {
9+
prefix = '';
10+
} else if (typeof assetsPrefix === 'string') {
11+
prefix = assetsPrefix;
12+
} else {
13+
// we assume the file extension has a leading '.' and we remove it
14+
const dotLessFileExtension = fileExtension.slice(1);
15+
prefix = assetsPrefix[dotLessFileExtension] || assetsPrefix.fallback;
1016
}
11-
return assetsPrefix.fallback;
17+
18+
return prefix;
1219
}

packages/astro/src/core/app/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export type SSRManifest = {
9595
buildClientDir: string | URL;
9696
buildServerDir: string | URL;
9797
csp: SSRManifestCSP | undefined;
98+
internalFetchHeaders?: Record<string, string>;
9899
};
99100

100101
export type SSRActions = {

packages/astro/src/core/build/plugins/plugin-manifest.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,13 +202,21 @@ async function buildManifest(
202202
staticFiles.push(entryModules[PAGE_SCRIPT_ID]);
203203
}
204204

205+
const assetQueryParams = settings.adapter?.client?.assetQueryParams;
206+
const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined;
207+
205208
const prefixAssetPath = (pth: string) => {
209+
let result = '';
206210
if (settings.config.build.assetsPrefix) {
207211
const pf = getAssetsPrefix(fileExtension(pth), settings.config.build.assetsPrefix);
208-
return joinPaths(pf, pth);
212+
result = joinPaths(pf, pth);
209213
} else {
210-
return prependForwardSlash(joinPaths(settings.config.base, pth));
214+
result = prependForwardSlash(joinPaths(settings.config.base, pth));
215+
}
216+
if (assetQueryString) {
217+
result += '?' + assetQueryString;
211218
}
219+
return result;
212220
};
213221

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

352+
// Get internal fetch headers from adapter config
353+
let internalFetchHeaders: Record<string, string> | undefined = undefined;
354+
if (settings.adapter?.client?.internalFetchHeaders) {
355+
const headers =
356+
typeof settings.adapter.client.internalFetchHeaders === 'function'
357+
? settings.adapter.client.internalFetchHeaders()
358+
: settings.adapter.client.internalFetchHeaders;
359+
if (Object.keys(headers).length > 0) {
360+
internalFetchHeaders = headers;
361+
}
362+
}
363+
344364
return {
345365
hrefRoot: opts.settings.config.root.toString(),
346366
cacheDir: opts.settings.config.cacheDir.toString(),
@@ -372,5 +392,6 @@ async function buildManifest(
372392
key: encodedKey,
373393
sessionConfig: settings.config.session,
374394
csp,
395+
internalFetchHeaders,
375396
};
376397
}

packages/astro/src/core/create-vite.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import astroPrefetch from '../prefetch/vite-plugin-prefetch.js';
2121
import astroDevToolbar from '../toolbar/vite-plugin-dev-toolbar.js';
2222
import astroTransitions from '../transitions/vite-plugin-transitions.js';
2323
import type { AstroSettings, RoutesList } from '../types/astro.js';
24+
import { vitePluginAdapterConfig } from '../vite-plugin-adapter-config/index.js';
2425
import astroVitePlugin from '../vite-plugin-astro/index.js';
2526
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
2627
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
@@ -156,6 +157,7 @@ export async function createVite(
156157
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
157158
importMetaEnv({ envLoader }),
158159
astroEnv({ settings, sync, envLoader }),
160+
vitePluginAdapterConfig(settings),
159161
markdownVitePlugin({ settings, logger }),
160162
htmlVitePlugin(),
161163
astroPostprocessVitePlugin(),

packages/astro/src/core/render-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ export class RenderContext {
572572
styleResources: manifest.csp?.styleResources ? [...manifest.csp.styleResources] : [],
573573
directives: manifest.csp?.directives ? [...manifest.csp.directives] : [],
574574
isStrictDynamic: manifest.csp?.isStrictDynamic ?? false,
575+
internalFetchHeaders: manifest.internalFetchHeaders,
575576
};
576577

577578
return result;

0 commit comments

Comments
 (0)