diff --git a/.changeset/astro-asset-query-params.md b/.changeset/astro-asset-query-params.md new file mode 100644 index 000000000000..19a77c83c333 --- /dev/null +++ b/.changeset/astro-asset-query-params.md @@ -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. diff --git a/.changeset/netlify-assetqueryparams.md b/.changeset/netlify-assetqueryparams.md new file mode 100644 index 000000000000..961b8a8c7d14 --- /dev/null +++ b/.changeset/netlify-assetqueryparams.md @@ -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, + }, +}); +``` diff --git a/.changeset/vercel-fixes.md b/.changeset/vercel-fixes.md new file mode 100644 index 000000000000..c718980d055a --- /dev/null +++ b/.changeset/vercel-fixes.md @@ -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. diff --git a/packages/astro/dev-only.d.ts b/packages/astro/dev-only.d.ts index 215484d5376b..94f4dbb89cbd 100644 --- a/packages/astro/dev-only.d.ts +++ b/packages/astro/dev-only.d.ts @@ -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; +} + declare module 'virtual:astro:actions/options' { export const shouldAppendTrailingSlash: boolean; } diff --git a/packages/astro/src/actions/runtime/virtual.ts b/packages/astro/src/actions/runtime/virtual.ts index 05e157870982..022f3ee50c8e 100644 --- a/packages/astro/src/actions/runtime/virtual.ts +++ b/packages/astro/src/actions/runtime/virtual.ts @@ -1,4 +1,5 @@ import { shouldAppendTrailingSlash } from 'virtual:astro:actions/options'; +import { internalFetchHeaders } from 'virtual:astro:adapter-config/client'; import type { APIContext } from '../../types/public/context.js'; import type { ActionClient, SafeResult } from './server.js'; import { @@ -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 { diff --git a/packages/astro/src/assets/utils/getAssetsPrefix.ts b/packages/astro/src/assets/utils/getAssetsPrefix.ts index 1a8947b54978..204cf440c04c 100644 --- a/packages/astro/src/assets/utils/getAssetsPrefix.ts +++ b/packages/astro/src/assets/utils/getAssetsPrefix.ts @@ -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; } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 68a7e2ce2ab1..219d377c543f 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -95,6 +95,7 @@ export type SSRManifest = { buildClientDir: string | URL; buildServerDir: string | URL; csp: SSRManifestCSP | undefined; + internalFetchHeaders?: Record; }; export type SSRActions = { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index c7a6a3721253..8d5d6974acc7 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -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 @@ -341,6 +349,18 @@ async function buildManifest( }; } + // Get internal fetch headers from adapter config + let internalFetchHeaders: Record | 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(), @@ -372,5 +392,6 @@ async function buildManifest( key: encodedKey, sessionConfig: settings.config.session, csp, + internalFetchHeaders, }; } diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index a4191306431f..ce762f6aaae8 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -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'; @@ -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(), diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 67f6070b9a75..e9f0d79da5b6 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -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; diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index 074e01851e66..5945a8d35b4a 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -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 { @@ -28,7 +39,7 @@ function createStylesheetElement( return { props: { rel: 'stylesheet', - href: createAssetLink(stylesheet.src, base, assetsPrefix), + href: createAssetLink(stylesheet.src, base, assetsPrefix, queryParams), }, children: '', }; @@ -39,17 +50,19 @@ export function createStylesheetElementSet( stylesheets: StylesheetAsset[], base?: string, assetsPrefix?: AssetsPrefix, + queryParams?: URLSearchParams, ): Set { - 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: { @@ -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: '', }; diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts index 2cfd84940385..117ec51eb371 100644 --- a/packages/astro/src/prefetch/index.ts +++ b/packages/astro/src/prefetch/index.ts @@ -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 @@ -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 }); } } diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts index 25c670dd7522..12cf02c0d786 100644 --- a/packages/astro/src/runtime/server/render/server-islands.ts +++ b/packages/astro/src/runtime/server/render/server-islands.ts @@ -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);`; diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index 16f75f9033d8..1422da8d6fef 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -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'; @@ -99,7 +100,12 @@ async function fetchHTML( init?: RequestInit, ): Promise { 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(); diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts index da08c0bd913f..05cf744b6505 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -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 | (() => Record); + /** + * 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 = { diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index 9f65e4b32b5e..153b5b61f7ef 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -270,6 +270,7 @@ export interface SSRResult { styleResources: SSRManifestCSP['styleResources']; directives: SSRManifestCSP['directives']; isStrictDynamic: SSRManifestCSP['isStrictDynamic']; + internalFetchHeaders?: Record; } /** diff --git a/packages/astro/src/vite-plugin-adapter-config/index.ts b/packages/astro/src/vite-plugin-adapter-config/index.ts new file mode 100644 index 000000000000..41bad297f962 --- /dev/null +++ b/packages/astro/src/vite-plugin-adapter-config/index.ts @@ -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)};`, + }; + } + }, + }; +} diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index 77d24f6edcfb..3fe4cd04e988 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -169,6 +169,26 @@ async function writeNetlifyFrameworkConfig( ); } +async function writeSkewProtectionConfig(config: AstroConfig) { + const deployId = process.env.DEPLOY_ID; + if (!deployId) { + return; // Skip if not deploying to Netlify + } + + const deployConfigDir = new URL('.netlify/v1/', config.root); + await mkdir(deployConfigDir, { recursive: true }); + await writeFile( + new URL('./skew-protection.json', deployConfigDir), + JSON.stringify({ + patterns: ['/_actions/.*', '/_server-islands/.*', '.*\\.(html)$'], + sources: [ + { type: 'header', name: 'X-Netlify-Deploy-ID' }, + { type: 'query', name: 'dpl' }, + ], + }), + ); +} + export interface NetlifyIntegrationConfig { /** * Force files to be bundled with your SSR function. @@ -662,6 +682,18 @@ export default function netlifyIntegration( sharpImageService: 'stable', envGetSecret: 'stable', }, + client: { + internalFetchHeaders: (): Record => { + const deployId = process.env.DEPLOY_ID; + if (deployId) { + return { 'X-Netlify-Deploy-ID': deployId }; + } + return {}; + }, + assetQueryParams: process.env.DEPLOY_ID + ? new URLSearchParams({ dpl: process.env.DEPLOY_ID }) + : undefined, + }, }); }, 'astro:build:generated': ({ experimentalRouteToHeaders }) => { @@ -688,6 +720,7 @@ export default function netlifyIntegration( } await writeNetlifyFrameworkConfig(_config, staticHeadersMap, logger); + await writeSkewProtectionConfig(_config); }, // local dev diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/.gitignore b/packages/integrations/netlify/test/functions/fixtures/skew-protection/.gitignore new file mode 100644 index 000000000000..96b61c7bd084 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/.gitignore @@ -0,0 +1,4 @@ +.astro +.netlify +dist +node_modules diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/astro.config.mjs b/packages/integrations/netlify/test/functions/fixtures/skew-protection/astro.config.mjs new file mode 100644 index 000000000000..071116776c9b --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/astro.config.mjs @@ -0,0 +1,11 @@ +import netlify from '@astrojs/netlify'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: 'server', + adapter: netlify(), + site: `http://example.com`, + security: { + checkOrigin: false + } +}); diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/package.json b/packages/integrations/netlify/test/functions/fixtures/skew-protection/package.json new file mode 100644 index 000000000000..f59243cbcf90 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/netlify-skew-protection", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/netlify": "workspace:", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/actions/index.ts b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/actions/index.ts new file mode 100644 index 000000000000..024595cee9c1 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/actions/index.ts @@ -0,0 +1,11 @@ +import { defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + test: defineAction({ + input: z.object({}).optional(), + handler: async () => { + return { success: true }; + }, + }), +}; diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/components/Island.astro b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/components/Island.astro new file mode 100644 index 000000000000..e37c2f72555f --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/components/Island.astro @@ -0,0 +1,7 @@ +--- +// Server island component +const timestamp = Date.now(); +--- +
+

Server Island Rendered: {timestamp}

+
diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/index.astro b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/index.astro new file mode 100644 index 000000000000..1483cd8e5c2d --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +import { actions } from 'astro:actions'; +--- + + + Skew Protection Test + + + +

Home Page

+ Go to other page (view transition) +
+ +
+ + diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/other.astro b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/other.astro new file mode 100644 index 000000000000..a488d54aee86 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/other.astro @@ -0,0 +1,12 @@ +--- +--- + + + Other Page + + + +

Other Page

+ Back to home + + diff --git a/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/server-island.astro b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/server-island.astro new file mode 100644 index 000000000000..7a60eadb54a8 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/skew-protection/src/pages/server-island.astro @@ -0,0 +1,12 @@ +--- +import Island from '../components/Island.astro'; +--- + + + Server Island Test + + +

Server Island Page

+ + + diff --git a/packages/integrations/netlify/test/functions/skew-protection.test.js b/packages/integrations/netlify/test/functions/skew-protection.test.js new file mode 100644 index 000000000000..52618c31a8ca --- /dev/null +++ b/packages/integrations/netlify/test/functions/skew-protection.test.js @@ -0,0 +1,72 @@ +import * as assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from '../../../../astro/test/test-utils.js'; + +describe( + 'Skew Protection', + () => { + let fixture; + + before(async () => { + // Set DEPLOY_ID env var for the test + process.env.DEPLOY_ID = 'test-deploy-123'; + + fixture = await loadFixture({ + root: new URL('./fixtures/skew-protection/', import.meta.url), + }); + await fixture.build(); + + // Clean up + delete process.env.DEPLOY_ID; + }); + + it('Server islands inline adapter headers', async () => { + // Render a page with server islands and check the HTML contains inline headers + const entryURL = new URL( + './fixtures/skew-protection/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL); + const resp = await handler(new Request('http://example.com/server-island'), {}); + const html = await resp.text(); + + // Check that the HTML contains the inline headers in the server island script + // Should have something like: const headers = new Headers({"X-Netlify-Deploy-ID":"test-deploy-123"}); + assert.ok( + html.includes('test-deploy-123'), + 'Expected server island HTML to include deploy ID in inline script', + ); + }); + + it('Manifest contains internalFetchHeaders', async () => { + // The manifest is embedded in the build output + // Check the manifest file which contains the serialized manifest + const manifestURL = new URL( + './fixtures/skew-protection/.netlify/build/', + import.meta.url, + ); + + // Find the manifest file (it has a hash in the name) + const { readdir } = await import('node:fs/promises'); + const files = await readdir(manifestURL); + const manifestFile = files.find((f) => f.startsWith('manifest_') && f.endsWith('.mjs')); + assert.ok(manifestFile, 'Expected to find a manifest file'); + + const manifestContent = await readFile(new URL(manifestFile, manifestURL), 'utf-8'); + + // The manifest should be serialized with internalFetchHeaders + assert.ok( + manifestContent.includes('internalFetchHeaders'), + 'Expected manifest to include internalFetchHeaders field', + ); + assert.ok( + manifestContent.includes('test-deploy-123'), + 'Expected manifest to include deploy ID value', + ); + }); + }, + { + timeout: 120000, + }, +); diff --git a/packages/integrations/vercel/src/index.ts b/packages/integrations/vercel/src/index.ts index 9234e051aaf6..c0a8e77ff63c 100644 --- a/packages/integrations/vercel/src/index.ts +++ b/packages/integrations/vercel/src/index.ts @@ -130,6 +130,20 @@ function getAdapter({ i18nDomains: 'experimental', envGetSecret: 'stable', }, + client: { + internalFetchHeaders: skewProtection + ? (): Record => { + const deploymentId = process.env.VERCEL_DEPLOYMENT_ID; + if (deploymentId) { + return { 'x-deployment-id': deploymentId }; + } + return {}; + } + : undefined, + assetQueryParams: skewProtection && process.env.VERCEL_DEPLOYMENT_ID + ? new URLSearchParams({ dpl: process.env.VERCEL_DEPLOYMENT_ID }) + : undefined, + }, }; } @@ -211,7 +225,7 @@ export default function vercelAdapter({ edgeMiddleware = false, maxDuration, isr = false, - skewProtection = false, + skewProtection = process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1', experimentalStaticHeaders = false, }: VercelServerlessConfig = {}): AstroIntegration { if (maxDuration) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 494761104895..d4d79fcaecf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5555,6 +5555,15 @@ importers: specifier: workspace:* version: link:../../../../../../astro + packages/integrations/netlify/test/functions/fixtures/skew-protection: + dependencies: + '@astrojs/netlify': + specifier: 'workspace:' + version: link:../../../.. + astro: + specifier: workspace:* + version: link:../../../../../../astro + packages/integrations/netlify/test/hosted/hosted-astro-project: dependencies: '@astrojs/netlify':