diff --git a/.changeset/slow-drinks-burn.md b/.changeset/slow-drinks-burn.md new file mode 100644 index 000000000000..c14a938f7368 --- /dev/null +++ b/.changeset/slow-drinks-burn.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: implement version skew protection diff --git a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md index 0a6aa44b01ba..4d6ad4fa9aec 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -153,6 +153,14 @@ export function load() { Since all of these variables are unchanged between build time and run time when building on Vercel, we recommend using `$env/static/private` — which will statically replace the variables, enabling optimisations like dead code elimination — rather than `$env/dynamic/private`. +## Skew protection + +When a new version of your app is deployed, assets belonging to the previous version may no longer be accessible. If a user is actively using your app when this happens, it can cause errors when they navigate — this is known as _version skew_. SvelteKit mitigates this by detecting errors resulting from version skew and causing a hard reload to get the latest version of the app, but this will cause any client-side state to be lost. (You can also proactively mitigate it by observing the [`updated`](/docs/modules#$app-stores-updated) store value, which tells clients when a new version has been deployed.) + +[Skew protection](https://vercel.com/docs/deployments/skew-protection) is a Vercel feature that routes client requests to their original deployment. When a user visits your app, a cookie is set with the deployment ID, and any subsequent requests will be routed to that deployment for as long as skew protection is active. When they reload the page, they will get the newest deployment. (The `updated` store is exempted from this behaviour, and so will continue to report new deployments.) To enable it, visit the Advanced section of your project settings on Vercel. + +Cookie-based skew protection comes with one caveat: if a user has multiple versions of your app open in multiple tabs, requests from older versions will be routed to the newer one, meaning they will fall back to SvelteKit's built-in skew protection. + ## Notes ### Vercel functions diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js index ce603b6e3920..9834559a2235 100644 --- a/packages/adapter-vercel/files/edge.js +++ b/packages/adapter-vercel/files/edge.js @@ -12,6 +12,7 @@ const initialized = server.init({ */ export default async (request, context) => { await initialized; + return server.respond(request, { getClientAddress() { return /** @type {string} */ (request.headers.get('x-forwarded-for')); diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index a6d33b515ba1..7506a90d782d 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -58,7 +58,12 @@ const plugin = function (defaults = {}) { functions: `${dir}/functions` }; - const static_config = static_vercel_config(builder, defaults); + builder.log.minor('Copying assets...'); + + builder.writeClient(dirs.static); + builder.writePrerendered(dirs.static); + + const static_config = static_vercel_config(builder, defaults, dirs.static); builder.log.minor('Generating serverless function...'); @@ -368,11 +373,6 @@ const plugin = function (defaults = {}) { // including ISR aliases if there is only one function static_config.routes.push({ src: '/.*', dest: `/${DEFAULT_FUNCTION_NAME}` }); - builder.log.minor('Copying assets...'); - - builder.writeClient(dirs.static); - builder.writePrerendered(dirs.static); - builder.log.minor('Writing routes...'); write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); @@ -425,8 +425,9 @@ function write(file, data) { /** * @param {import('@sveltejs/kit').Builder} builder * @param {import('.').Config} config + * @param {string} dir */ -function static_vercel_config(builder, config) { +function static_vercel_config(builder, config, dir) { /** @type {any[]} */ const prerendered_redirects = []; @@ -467,20 +468,60 @@ function static_vercel_config(builder, config) { overrides[page.file] = { path: overrides_path }; } - return { - version: 3, - routes: [ - ...prerendered_redirects, - { - src: `/${builder.getAppPath()}/immutable/.+`, - headers: { - 'cache-control': 'public, immutable, max-age=31536000' + const routes = [ + ...prerendered_redirects, + { + src: `/${builder.getAppPath()}/immutable/.+`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + } + } + ]; + + // https://vercel.com/docs/deployments/skew-protection + if (process.env.VERCEL_SKEW_PROTECTION_ENABLED) { + routes.push({ + src: '/.*', + has: [ + { + type: 'header', + key: 'Sec-Fetch-Dest', + value: 'document' } + ], + headers: { + 'Set-Cookie': `__vdpl=${process.env.VERCEL_DEPLOYMENT_ID}; Path=${builder.config.kit.paths.base}/; SameSite=Strict; Secure; HttpOnly` }, - { - handle: 'filesystem' - } - ], + continue: true + }); + + // this is a dreadful hack that is necessary until the Vercel Build Output API + // allows you to set multiple cookies for a single route. essentially, since we + // know that the entry file will be requested immediately, we can set the second + // cookie in _that_ response rather than the document response + const base = `${dir}/${builder.config.kit.appDir}/immutable/entry`; + const entry = fs.readdirSync(base).find((file) => file.startsWith('start.')); + + if (!entry) { + throw new Error('Could not find entry point'); + } + + routes.splice(-2, 0, { + src: `/${builder.getAppPath()}/immutable/entry/${entry}`, + headers: { + 'Set-Cookie': `__vdpl=; Path=/${builder.getAppPath()}/version.json; SameSite=Strict; Secure; HttpOnly` + }, + continue: true + }); + } + + routes.push({ + handle: 'filesystem' + }); + + return { + version: 3, + routes, overrides, images };