Skip to content

feat: add server middleware support #13430

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

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9f48a7b
feat: add middleware support
dummdidumm Feb 6, 2025
ea1f841
build + preview
dummdidumm Feb 7, 2025
93d5536
pass URL separately which is normalized, extract logic
dummdidumm Feb 7, 2025
b5985fe
reorganize, make call-middleware usable for adapters
dummdidumm Feb 7, 2025
2a1153a
vercel adapter
dummdidumm Feb 8, 2025
9a01290
tweak
dummdidumm Feb 10, 2025
484864a
adapter netlify
dummdidumm Feb 10, 2025
df7c138
tweak
dummdidumm Feb 10, 2025
e9ab049
adapter cloudflare
dummdidumm Feb 10, 2025
a2aecbd
lockfile
dummdidumm Feb 10, 2025
e66a161
adapter-node
dummdidumm Feb 10, 2025
d15a013
docs
dummdidumm Feb 10, 2025
dfd3829
only check for middleware support if it's actually used
dummdidumm Feb 10, 2025
f153c3f
Merge remote-tracking branch 'origin/main' into sveltekit-middleware
dummdidumm Feb 11, 2025
4b1824d
lint/fix
dummdidumm Feb 11, 2025
000f022
matcher support for vercel
dummdidumm Feb 11, 2025
f51ca7f
allow middleware to influence when it matches in dev
dummdidumm Feb 11, 2025
f6a5a3a
make sure adapter node middleware runs for prerendered/static files, too
dummdidumm Feb 12, 2025
6316e61
run on static assets for cloudflare/netlify
dummdidumm Feb 12, 2025
63c1b7d
doc tweaks
dummdidumm Feb 12, 2025
47b6bc6
fix
dummdidumm Feb 12, 2025
39861c6
fix
dummdidumm Feb 12, 2025
f1163d8
tests
dummdidumm Feb 12, 2025
18e6230
fixes
dummdidumm Feb 12, 2025
faed1cc
Merge branch 'main' into sveltekit-middleware
elliott-with-the-longest-name-on-github Feb 12, 2025
1e10353
chore: Add experimental flag to middleware (#13458)
elliott-with-the-longest-name-on-github Feb 13, 2025
af2eed3
make it a unit test instead
dummdidumm Feb 13, 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
4 changes: 4 additions & 0 deletions documentation/docs/25-build-and-deploy/40-adapter-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ WantedBy=sockets.target

3. Make sure systemd has recognised both units by running `sudo systemctl daemon-reload`. Then enable the socket on boot and start it immediately using `sudo systemctl enable --now myapp.socket`. The app will then automatically start once the first request is made to `localhost:3000`.

## Middleware

The adapter supports the [middleware hook](hooks#Middleware) and runs on all requests except those to immutable files (normally within `_app/immutable`).

## Custom server

The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ Cloudflare Workers specific values in the `platform` property are emulated durin

For testing the build, you should use [wrangler](https://developers.cloudflare.com/workers/cli-wrangler) **version 3**. Once you have built your site, run `wrangler pages dev .svelte-kit/cloudflare`.

## Middleware

The adapter supports the [middleware hook](hooks#Middleware) and by default runs on all requests except those to immutable files (normally within `_app/immutable`). You can adjust this through the [routes option](#Options-routes), which influences on which paths the underlying worker (which also includes the call to the middleware) runs.

## Notes

Functions contained in the `/functions` directory at the project's root will _not_ be included in the deployment, which is compiled to a [single `_worker.js` file](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode). Functions should be implemented as [server endpoints](routing#server) in your SvelteKit app.
Expand Down
4 changes: 4 additions & 0 deletions documentation/docs/25-build-and-deploy/80-adapter-netlify.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export default {
};
```

## Middleware

The adapter supports the [middleware hook](hooks#Middleware) and runs on all requests except those to immutable files (normally within `_app/immutable`). It will be deployed as an [edge function](https://docs.netlify.com/edge-functions/overview/).

## Netlify alternatives to SvelteKit functionality

You may build your app using functionality provided directly by SvelteKit without relying on any Netlify functionality. Using the SvelteKit versions of these features will allow them to be used in dev mode, tested with integration tests, and to work with other adapters should you ever decide to switch away from Netlify. However, in some scenarios you may find it beneficial to use the Netlify versions of these features. One example would be if you're migrating an app that's already hosted on Netlify to SvelteKit.
Expand Down
27 changes: 27 additions & 0 deletions documentation/docs/25-build-and-deploy/90-adapter-vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,33 @@ A list of valid query parameters that contribute to the cache key. Other paramet

> Pages that are [prerendered](page-options#prerender) will ignore ISR configuration.

## Middleware

You can use SvelteKit's [middleware feature](hooks#Middleware) with Vercel. It will be deployed as [edge middleware](https://vercel.com/docs/functions/edge-middleware). This allows you to for example do A/B testing on prerendered or ISR'd pages, and reroute to a variant based on a cookie.

By default, middleware will run on all paths except immutable files (normally under `_app/immutable`). You can configure for which paths the middleware should run by adding `export const config = { matcher: ... }` to your middleware file. Doing so will increase the speed of other requests since middleware will not be invoked for them. Refer to the [Vercel documentation](https://vercel.com/docs/functions/edge-middleware/middleware-api#match-paths-based-on-custom-matcher-config) for more information on the syntax. When configuring your own matcher, make sure to not accidentally include requests to immutable files, unless you really want to.

> [!NOTE] During dev, requests to immutable files and static assets are never intercepted

```js
/// file: hooks/middleware.js
export const config = {
// only run this on the about page and its subpages
matcher: '/about(.*)'
};

export function middleware({ url, cookies, reroute }) {
if (url.pathname === '/about') {
// Decide which variant of the about page
// (which can be prerendered or ISR'd)
// to load based on a cookie
const aboutPageVariant = cookies.get('aboutPageVariant') || (Math.random() > 0.5 ? 'a' : 'b');
// reroute will use Vercel middleware's rewrite function under the hood
return reroute(aboutPageVariant ? '/about-a' : '/about-b');
}
}
```

## Environment variables

Vercel makes a set of [deployment-specific environment variables](https://vercel.com/docs/concepts/projects/environment-variables#system-environment-variables) available. Like other environment variables, these are accessible from `$env/static/private` and `$env/dynamic/private` (sometimes — more on that later), and inaccessible from their public counterparts. To access one of these variables from the client:
Expand Down
51 changes: 51 additions & 0 deletions documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,57 @@ export const transport = {
};
```

## Middleware hooks

The following can be added to `src/hooks/middleware.js`.

### middleware

This function runs prior to all requests made to the server, including those to prerendered pages but excluding those to immutable assets (though depending on the adapter this may be configurable). This is useful when

- you want to do A/B testing on prerendered pages
- you want to set a cookie on first time visits, not matter if the users hits a prerendered or SSR'd page
- you want to reroute to a different page depending on a cookie value, and need to set that cookie before doing so

```js
/// file: src/hooks/middleware.js
/** @param {import('@sveltejs/kit').MiddlewareEvent} options */
export async function middleware({ url, setRequestHeaders, setResponseHeaders, cookies, reroute }) {
if (url.pathname === '/custom') {
return new Response('Return response directly, SvelteKit runtime will not be called');
}

if (url.pathname === '/headers') {
// You can set headers on the request and response
setRequestHeaders({ 'x-custom-request-header': 'foo'});
setResponseHeaders({ 'x-custom-response-header': 'bar'});
}

if (url.pathname == '/a-b-testing') {
// Retrieve cookies which contain the feature flags.
const flag = cookies.get('homePageVariant') || (Math.random() > 0.5 ? 'a' : 'b');

// Set a cookie to remember the feature flags for this visitor
cookies.set('homePageVariant', flag, { path: '/' });

return reroute(
// Get destination URL based on the feature flag
flag === 'a' ? '/home-a' : '/home-b'
);
}
}

```

If you have no prerendered pages, i.e. every request hits the SvelteKit runtime, and have no advanced rerouting requirements, then it does not make much sense to use middleware, as all requests will eventually go through `handle`.

When using middleware to reroute based on cookies or headers, you probably want to set [`router.resolution` to `"server"`](configuration#router) so that client-side navigations also request the server first to know which files and data to load for a given link.

> [!NOTE] When using server-side route resolution, each path will only be resolved once per user session (e.g. when you visit `/foo` multiple times from different pages, only the first client-side navigation to `/foo` will invoke the resolution endpoint). For that reason, your middleware responses should be stable over the course of a session.

> [!NOTE] During dev, requests to immutable files and static assets are never intercepted

Because the middleware functionality is very adapter-dependent, it is deliberately small in scope to be applicable to as many platforms at possible. How exactly middleware is deployed depends on the adapter you use. For `adapter-node` it's a `sirv` middleware, for Vercel/Netlify it is deployed to the edge, for Cloudflare it becomes part of the worker. Some adapters, for example `adapter-static`, don't support it at all.

## Further reading

Expand Down
8 changes: 8 additions & 0 deletions packages/adapter-cloudflare-workers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ export default function ({ config = 'wrangler.toml', platformProxy = {} } = {})
return prerender ? emulated.prerender_platform : emulated.platform;
}
};
},

supports: {
middleware: () => {
throw new Error(
'@sveltejs/adapter-cloudflare-workers does not support SvelteKit middleware. Use @sveltejs/adapter-cloudflare instead.'
);
}
}
};
}
Expand Down
38 changes: 34 additions & 4 deletions packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,24 @@ export default function (options = {}) {
const written_files = builder.writeClient(dest_dir);
builder.writePrerendered(dest_dir);

const has_middleware = existsSync(`${builder.getServerDirectory()}/middleware.js`);
const relativePath = path.posix.relative(dest, builder.getServerDirectory());

writeFileSync(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({ relativePath })};\n\n` +
`export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n\n` +
`export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n`
`export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` +
`export const app_dir = ${JSON.stringify(builder.config.kit.appDir)};\n`
);

writeFileSync(
`${dest}/_routes.json`,
JSON.stringify(get_routes_json(builder, written_files, options.routes ?? {}), null, '\t')
JSON.stringify(
get_routes_json(builder, written_files, !has_middleware, options.routes ?? {}),
null,
'\t'
)
);

writeFileSync(`${dest}/_headers`, generate_headers(builder.getAppPath()), { flag: 'a' });
Expand All @@ -60,13 +66,27 @@ export default function (options = {}) {

writeFileSync(`${dest}/.assetsignore`, generate_assetsignore(), { flag: 'a' });

if (!has_middleware) {
builder.copy(
`${files}/noop-middleware.js`,
`${builder.getServerDirectory()}/middleware.js`
);
builder.copy(
`${files}/noop-middleware.js`,
`${builder.getServerDirectory()}/call-middleware.js`
);
}

builder.copy(`${files}/worker.js`, `${dest}/_worker.js`, {
replace: {
SERVER: `${relativePath}/index.js`,
MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js`
MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js`,
MIDDLEWARE: `${relativePath}/middleware.js`,
CALL_MIDDLEWARE: `${relativePath}/call-middleware.js`
}
});
},

emulate() {
// we want to invoke `getPlatformProxy` only once, but await it only when it is accessed.
// If we would await it here, it would hang indefinitely because the platform proxy only resolves once a request happens
Expand Down Expand Up @@ -100,17 +120,27 @@ export default function (options = {}) {
return prerender ? emulated.prerender_platform : emulated.platform;
}
};
},

supports: {
middleware: () => true
}
};
}

/**
* @param {import('@sveltejs/kit').Builder} builder
* @param {string[]} assets
* @param {boolean} exclude_prerendered
* @param {import('./index.js').AdapterOptions['routes']} routes
* @returns {import('./index.js').RoutesJSONSpec}
*/
function get_routes_json(builder, assets, { include = ['/*'], exclude = ['<all>'] }) {
function get_routes_json(
builder,
assets,
exclude_prerendered,
{ include = ['/*'], exclude = exclude_prerendered ? ['<all>'] : ['<build>'] }
) {
if (!Array.isArray(include) || !Array.isArray(exclude)) {
throw new Error('routes.include and routes.exclude must be arrays');
}
Expand Down
12 changes: 11 additions & 1 deletion packages/adapter-cloudflare/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ declare module 'MANIFEST' {

export const manifest: SSRManifest;
export const prerendered: Set<string>;
export const app_path: string;
export const base_path: string;
export const app_dir: string;
}

declare module 'MIDDLEWARE' {
import { Middleware } from '@sveltejs/kit';
export const middleware: Middleware;
}

declare module 'CALL_MIDDLEWARE' {
import { CallMiddleware } from '@sveltejs/kit';
export const call_middleware: CallMiddleware;
}
2 changes: 1 addition & 1 deletion packages/adapter-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"ambient.d.ts"
],
"scripts": {
"build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --format=esm",
"build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --external:MIDDLEWARE --external:CALL_MIDDLEWARE --format=esm",
"lint": "prettier --check .",
"format": "pnpm lint --write",
"check": "tsc --skipLibCheck",
Expand Down
12 changes: 12 additions & 0 deletions packages/adapter-cloudflare/src/noop-middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function middleware() {}

/** @param {Request} request */
export function call_middleware(request) {
return {
request,
did_reroute: false,
request_headers: new Headers(),
response_headers: new Headers(),
add_response_headers: () => {}
};
}
15 changes: 15 additions & 0 deletions packages/adapter-cloudflare/src/worker.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import { middleware as user_middleware } from 'MIDDLEWARE';
import { call_middleware } from 'CALL_MIDDLEWARE';
import * as Cache from 'worktop/cfw.cache';

const server = new Server(manifest);
Expand All @@ -14,6 +16,17 @@ const worker = {
async fetch(req, env, context) {
// @ts-ignore
await server.init({ env });

// We can't use the stuff outlined in
// https://developers.cloudflare.com/pages/functions/middleware/ and
// https://developers.cloudflare.com/pages/functions/api-reference/#eventcontext
// because we're using the _worker.js advanced mode
// https://developers.cloudflare.com/pages/functions/advanced-mode/
// so we inline the middleware here
const mw_response = await call_middleware(req, user_middleware);
if (mw_response instanceof Response) return mw_response;
req = mw_response.request;

// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
let res = !pragma.includes('no-cache') && (await Cache.lookup(req));
Expand Down Expand Up @@ -67,6 +80,8 @@ const worker = {
});
}

mw_response.add_response_headers(res);

// write to `Cache` only if response is not an error,
// let `Cache.save` handle the Cache-Control and Vary headers
pragma = res.headers.get('cache-control') || '';
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-cloudflare/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"@sveltejs/kit": ["../kit/types/index"]
}
},
"include": ["index.js", "internal.d.ts", "src/worker.js"]
"include": ["index.js", "internal.d.ts", "src/*"]
}
Loading
Loading