Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement skew protection #11987

Merged
merged 14 commits into from Mar 19, 2024
5 changes: 5 additions & 0 deletions .changeset/slow-drinks-burn.md
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-vercel': minor
---

feat: implement skew protection
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 8 additions & 0 deletions documentation/docs/25-build-and-deploy/90-adapter-vercel.md
Expand Up @@ -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
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

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.
benmccann marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
19 changes: 17 additions & 2 deletions packages/adapter-vercel/files/edge.js
@@ -1,5 +1,5 @@
import { Server } from 'SERVER';
import { manifest } from 'MANIFEST';
import { manifest, base, version_file, skew_protection, deployment_id } from 'MANIFEST';

const server = new Server(manifest);
const initialized = server.init({
Expand All @@ -12,12 +12,27 @@ const initialized = server.init({
*/
export default async (request, context) => {
await initialized;
return server.respond(request, {

const response = await server.respond(request, {
getClientAddress() {
return /** @type {string} */ (request.headers.get('x-forwarded-for'));
},
platform: {
context
}
});

if (skew_protection) {
response.headers.set(
'Set-Cookie',
`__vdpl=${deployment_id}; Path=${base}; SameSite=Lax; Secure; HttpOnly`
);

response.headers.set(
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
'Set-Cookie',
`__vdpl=; Path=${version_file}; SameSite=Lax; Secure; HttpOnly`
);
}

return response;
};
29 changes: 20 additions & 9 deletions packages/adapter-vercel/files/serverless.js
@@ -1,7 +1,7 @@
import { installPolyfills } from '@sveltejs/kit/node/polyfills';
import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node';
import { Server } from 'SERVER';
import { manifest } from 'MANIFEST';
import { manifest, base, version_file, skew_protection, deployment_id } from 'MANIFEST';

installPolyfills();

Expand Down Expand Up @@ -35,12 +35,23 @@ export default async (req, res) => {

const request = await getRequest({ base: `https://${req.headers.host}`, request: req });

setResponse(
res,
await server.respond(request, {
getClientAddress() {
return /** @type {string} */ (request.headers.get('x-forwarded-for'));
}
})
);
const response = await server.respond(request, {
getClientAddress() {
return /** @type {string} */ (request.headers.get('x-forwarded-for'));
}
});

if (skew_protection) {
response.headers.set(
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
'Set-Cookie',
`__vdpl=${deployment_id}; Path=${base}; SameSite=Lax; Secure; HttpOnly`
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
);

response.headers.set(
'Set-Cookie',
`__vdpl=; Path=${version_file}; SameSite=Lax; Secure; HttpOnly`
);
}

setResponse(res, response);
};
31 changes: 22 additions & 9 deletions packages/adapter-vercel/index.js
Expand Up @@ -62,10 +62,29 @@ const plugin = function (defaults = {}) {

builder.log.minor('Generating serverless function...');

/**
* @param {string} tmp
* @param {import('@sveltejs/kit').RouteDefinition<import('.').Config>[]} routes
*/
function generate_manifest(tmp, routes) {
const relativePath = path.posix.relative(tmp, builder.getServerDirectory());

// TODO this is messy
write(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` +
`export const base = ${JSON.stringify(`/${builder.config.kit.paths.base}`)};\n` +
`export const version_file = ${JSON.stringify(
`/${builder.getAppPath()}/version.json`
)};\n` +
`export const skew_protection = ${JSON.stringify(!!process.env.VERCEL_SKEW_PROTECTION_ENABLED)};\n` +
`export const deployment_id = ${JSON.stringify(process.env.VERCEL_DEPLOYMENT_ID)};\n`
);
}

/**
* @param {string} name
* @param {import('.').ServerlessConfig} config
* @param {import('@sveltejs/kit').RouteDefinition<import('.').Config>[]} routes
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
*/
async function generate_serverless_function(name, config, routes) {
const dir = `${dirs.functions}/${name}.func`;
Expand All @@ -79,10 +98,7 @@ const plugin = function (defaults = {}) {
}
});

write(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({ relativePath, routes })};\n`
);
generate_manifest(tmp, routes);

await create_function_bundle(builder, `${tmp}/index.js`, dir, config);

Expand All @@ -108,10 +124,7 @@ const plugin = function (defaults = {}) {
}
});

write(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({ relativePath, routes })};\n`
);
generate_manifest(tmp, routes);

try {
const result = await esbuild.build({
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-vercel/internal.d.ts
Expand Up @@ -5,4 +5,8 @@ declare module 'SERVER' {
declare module 'MANIFEST' {
import { SSRManifest } from '@sveltejs/kit';
export const manifest: SSRManifest;
export const base: string;
export const deployment_id: string;
export const skew_protection: boolean;
export const version_file: string;
}