diff --git a/documentation/docs/25-build-and-deploy/40-adapter-node.md b/documentation/docs/25-build-and-deploy/40-adapter-node.md index 0a7c553c4acc..16a5d2461b03 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -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. diff --git a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md index b4daaa2fbf53..caa0a3f2437c 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -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. diff --git a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md index ccba74650895..67d9bfd1c070 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -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. 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 43e237b80902..10316bf28b4d 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -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: diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index b35e66b73a09..f68d0fb2cc26 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -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 diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index 5da3fe275022..aac20f11f91b 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -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.' + ); + } } }; } diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index ceac64d92a2a..083179225ac9 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -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' }); @@ -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 @@ -100,6 +120,10 @@ export default function (options = {}) { return prerender ? emulated.prerender_platform : emulated.platform; } }; + }, + + supports: { + middleware: () => true } }; } @@ -107,10 +131,16 @@ export default function (options = {}) { /** * @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 = [''] }) { +function get_routes_json( + builder, + assets, + exclude_prerendered, + { include = ['/*'], exclude = exclude_prerendered ? [''] : [''] } +) { if (!Array.isArray(include) || !Array.isArray(exclude)) { throw new Error('routes.include and routes.exclude must be arrays'); } diff --git a/packages/adapter-cloudflare/internal.d.ts b/packages/adapter-cloudflare/internal.d.ts index 6c79569f7f7f..51b592f7040e 100644 --- a/packages/adapter-cloudflare/internal.d.ts +++ b/packages/adapter-cloudflare/internal.d.ts @@ -7,6 +7,16 @@ declare module 'MANIFEST' { export const manifest: SSRManifest; export const prerendered: Set; - 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; } diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index a705531a6863..ee2205f69019 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -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", diff --git a/packages/adapter-cloudflare/src/noop-middleware.js b/packages/adapter-cloudflare/src/noop-middleware.js new file mode 100644 index 000000000000..6394265b3b64 --- /dev/null +++ b/packages/adapter-cloudflare/src/noop-middleware.js @@ -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: () => {} + }; +} diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index c3c27a0b041f..7fedc218924c 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -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); @@ -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)); @@ -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') || ''; diff --git a/packages/adapter-cloudflare/tsconfig.json b/packages/adapter-cloudflare/tsconfig.json index b258035a3555..4d6012728f8d 100644 --- a/packages/adapter-cloudflare/tsconfig.json +++ b/packages/adapter-cloudflare/tsconfig.json @@ -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/*"] } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 034acd70ab94..4ab878d5adc3 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,6 +1,6 @@ import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve, posix } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { builtinModules } from 'node:module'; import process from 'node:process'; import esbuild from 'esbuild'; @@ -99,6 +99,8 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } else { generate_lambda_functions({ builder, split, publish }); } + + await generate_middleware(builder); }, supports: { @@ -111,10 +113,12 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } return true; - } + }, + middleware: () => true } }; } + /** * @param { object } params * @param {import('@sveltejs/kit').Builder} params.builder @@ -127,6 +131,7 @@ async function generate_edge_functions({ builder }) { builder.mkdirp('.netlify/edge-functions'); builder.log.minor('Generating Edge Function...'); + const relativePath = posix.relative(tmp, builder.getServerDirectory()); builder.copy(`${files}/edge.js`, `${tmp}/entry.js`, { @@ -136,21 +141,10 @@ async function generate_edge_functions({ builder }) { } }); - const manifest = builder.generateManifest({ - relativePath - }); - - writeFileSync(`${tmp}/manifest.js`, `export const manifest = ${manifest};\n`); - /** @type {{ assets: Set }} */ - const { assets } = (await import(`${tmp}/manifest.js`)).manifest; + const { assets } = (await import(pathToFileURL(`${tmp}/manifest.js`).href)).manifest; - const path = '/*'; - // We only need to specify paths without the trailing slash because - // Netlify will handle the optional trailing slash for us - const excludedPath = [ - // Contains static files - `/${builder.getAppPath()}/*`, + const excludedPaths = [ ...builder.prerendered.paths, ...Array.from(assets).flatMap((asset) => { if (asset.endsWith('/index.html')) { @@ -161,26 +155,54 @@ async function generate_edge_functions({ builder }) { ]; } return `${builder.config.kit.paths.base}/${asset}`; - }), - // Should not be served by SvelteKit at all - '/.netlify/*' + }) ]; - /** @type {HandlerManifest} */ - const edge_manifest = { - functions: [ - { - function: 'render', - path, - excludedPath - } - ], - version: 1 - }; + await bundle_edge_function({ builder, name: 'render', excludedPaths }); +} + +/** + * @param {import('@sveltejs/kit').Builder} builder + */ +async function generate_middleware(builder) { + if (!existsSync(`${builder.getServerDirectory()}/middleware.js`)) return; + + builder.log.minor('Generating SvelteKit middleware as Edge Function...'); + + const tmp = builder.getBuildDirectory('netlify-tmp'); + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.rimraf(tmp); + builder.mkdirp(tmp); + builder.mkdirp('.netlify/edge-functions'); + + builder.copy(`${files}/middleware.js`, `${tmp}/entry.js`, { + replace: { + MIDDLEWARE: `${relativePath}/middleware.js`, + CALL_MIDDLEWARE: `${relativePath}/call-middleware.js` + } + }); + + await bundle_edge_function({ builder, name: 'middleware' }); +} + +/** + * + * @param {object} params + * @param {import('@sveltejs/kit').Builder} params.builder + * @param {string} params.name + * @param {string[]} [params.excludedPaths] + */ +async function bundle_edge_function({ builder, name, excludedPaths = [] }) { + const tmp = builder.getBuildDirectory('netlify-tmp'); + + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + const manifest = builder.generateManifest({ relativePath }); + writeFileSync(`${tmp}/manifest.js`, `export const manifest = ${manifest};\n`); await esbuild.build({ entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', + outfile: `.netlify/edge-functions/${name}.js`, bundle: true, format: 'esm', platform: 'browser', @@ -200,8 +222,36 @@ async function generate_edge_functions({ builder }) { alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) }); + const path = '/*'; + // We only need to specify paths without the trailing slash because + // Netlify will handle the optional trailing slash for us + const excludedPath = [ + // Contains static files + `/${builder.getAppPath()}/*`, + ...excludedPaths, + + // Should not be served by SvelteKit at all + '/.netlify/*' + ]; + + /** @type {HandlerManifest} */ + const edge_manifest = { + functions: [ + ...(existsSync('.netlify/edge-functions/manifest.json') + ? JSON.parse(readFileSync('.netlify/edge-functions/manifest.json', 'utf-8')).functions + : []), + { + function: name, + path, + excludedPath + } + ], + version: 1 + }; + writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); } + /** * @param { object } params * @param {import('@sveltejs/kit').Builder} params.builder diff --git a/packages/adapter-netlify/internal.d.ts b/packages/adapter-netlify/internal.d.ts index 55da8ba1fbf5..45fdcb1f0e7c 100644 --- a/packages/adapter-netlify/internal.d.ts +++ b/packages/adapter-netlify/internal.d.ts @@ -7,3 +7,13 @@ declare module 'MANIFEST' { export const manifest: SSRManifest; } + +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; +} diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index bcc1d4d2ed0f..f71778459d05 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -33,7 +33,7 @@ ], "scripts": { "dev": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -cw", - "build": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -c && node -e \"fs.cpSync('src/edge.js', 'files/edge.js')\"", + "build": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -c && node -e \"fs.cpSync('src/edge.js', 'files/edge.js')\" && node -e \"fs.cpSync('src/middleware.js', 'files/middleware.js')\"", "test": "vitest run", "check": "tsc", "lint": "prettier --check .", @@ -47,6 +47,7 @@ }, "devDependencies": { "@netlify/functions": "^3.0.0", + "@netlify/edge-functions": "^2.11.1", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js new file mode 100644 index 000000000000..e413fd41d7cf --- /dev/null +++ b/packages/adapter-netlify/src/middleware.js @@ -0,0 +1,28 @@ +import { middleware as user_middleware } from 'MIDDLEWARE'; +import { call_middleware } from 'CALL_MIDDLEWARE'; + +// https://docs.netlify.com/edge-functions/overview/ + +/** + * @param {Request} request + * @param {import('@netlify/edge-functions').Context} context + */ +export default async function middleware(request, context) { + const result = await call_middleware(request, user_middleware); + + if (result instanceof Response) return result; + + const has_additional_headers = + [...result.request_headers.keys()].length > 0 || [...result.response_headers.keys()].length > 0; + + if (!has_additional_headers) { + // Fast path + if (result.did_reroute) { + return new URL(result.request.url); + } + } else { + const response = await context.next(result.request); + result.add_response_headers(response); + return response; + } +} diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..47d3465bd13c 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; @@ -37,12 +37,15 @@ export default function (opts = {}) { builder.writeServer(tmp); + const has_middleware = existsSync(`${builder.getServerDirectory()}/middleware.js`); + writeFileSync( `${tmp}/manifest.js`, [ `export const manifest = ${builder.generateManifest({ relativePath: './' })};`, `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`, - `export const base = ${JSON.stringify(builder.config.kit.paths.base)};` + `export const base = ${JSON.stringify(builder.config.kit.paths.base)};`, + `export const has_middleware = ${has_middleware};` ].join('\n\n') ); @@ -79,6 +82,14 @@ export default function (opts = {}) { chunkFileNames: 'chunks/[name]-[hash].js' }); + if (has_middleware) { + builder.copy(`${builder.getServerDirectory()}/middleware.js`, `${out}/middleware.js`); + builder.copy( + `${builder.getServerDirectory()}/call-middleware.js`, + `${out}/call-middleware.js` + ); + } + builder.copy(files, out, { replace: { ENV: './env.js', @@ -86,13 +97,16 @@ export default function (opts = {}) { MANIFEST: './server/manifest.js', SERVER: './server/index.js', SHIMS: './shims.js', - ENV_PREFIX: JSON.stringify(envPrefix) + ENV_PREFIX: JSON.stringify(envPrefix), + MIDDLEWARE: has_middleware ? './middleware.js' : './noop-middleware.js', + CALL_MIDDLEWARE: has_middleware ? './call-middleware.js' : './noop-middleware.js' } }); }, supports: { - read: () => true + read: () => true, + middleware: () => true } }; } diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index fed0584d1851..4dca57e4c18a 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -12,8 +12,19 @@ declare module 'MANIFEST' { export const base: string; export const manifest: SSRManifest; export const prerendered: Set; + export const has_middleware: boolean; } declare module 'SERVER' { export { Server } from '@sveltejs/kit'; } + +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; +} diff --git a/packages/adapter-node/rollup.config.js b/packages/adapter-node/rollup.config.js index fb9d113b5965..f0ef87790a7c 100644 --- a/packages/adapter-node/rollup.config.js +++ b/packages/adapter-node/rollup.config.js @@ -40,7 +40,7 @@ export default [ inlineDynamicImports: true }, plugins: [nodeResolve(), commonjs(), json(), prefixBuiltinModules()], - external: ['ENV', 'MANIFEST', 'SERVER', 'SHIMS'] + external: ['ENV', 'MANIFEST', 'SERVER', 'SHIMS', 'MIDDLEWARE', 'CALL_MIDDLEWARE'] }, { input: 'src/shims.js', diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index b6c628dd4e0c..90bb85da1090 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url'; import { parse as polka_url_parser } from '@polka/url'; import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node'; import { Server } from 'SERVER'; -import { manifest, prerendered, base } from 'MANIFEST'; +import { manifest, prerendered, base, has_middleware } from 'MANIFEST'; import { env } from 'ENV'; +import { middleware as user_middleware } from 'MIDDLEWARE'; +import { call_middleware } from 'CALL_MIDDLEWARE'; /* global ENV_PREFIX */ @@ -68,12 +70,66 @@ function serve(path, client = false) { // only apply to build directory, not e.g. version.json if (pathname.startsWith(`/${manifest.appPath}/immutable/`) && res.statusCode === 200) { res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } else { + set_middleware_headers(res); } }) }) ); } +/** @type {import('polka').Middleware} */ +const middleware = async (req, res, next) => { + const { pathname } = polka_url_parser(req); + + if (pathname.startsWith(`/${manifest.appPath}/immutable/`)) { + return next(); + } + + /** @type {Request} */ + let request; + + try { + request = await getRequest({ + base: origin || get_origin(req.headers), + request: req, + bodySizeLimit: body_size_limit + }); + } catch { + res.statusCode = 400; + res.end('Bad Request'); + return; + } + + const result = await call_middleware(request, user_middleware); + + if (result instanceof Response) { + await setResponse(res, result); + } else { + if (result.did_reroute) { + req.url = new URL(result.request.url).pathname; + } + + for (const [key, value] of result.request_headers) { + req.headers[key] = value; + } + + // @ts-expect-error + res.__response_headers = result.response_headers; + + void next(); + } +}; + +/** + * @param {import('polka').Response} res + */ +function set_middleware_headers(res) { + for (const [name, value] of /** @type {any} */ (res).__response_headers) { + res.setHeader(name, value); + } +} + // required because the static file server ignores trailing slashes /** @returns {import('polka').Middleware} */ function serve_prerendered() { @@ -96,6 +152,7 @@ function serve_prerendered() { let location = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; if (prerendered.has(location)) { if (query) location += search; + set_middleware_headers(res); res.writeHead(308, { location }).end(); } else { void next(); @@ -120,53 +177,60 @@ const ssr = async (req, res) => { return; } - await setResponse( - res, - await server.respond(request, { - platform: { req }, - getClientAddress: () => { - if (address_header) { - if (!(address_header in req.headers)) { - throw new Error( - `Address header was specified with ${ - ENV_PREFIX + 'ADDRESS_HEADER' - }=${address_header} but is absent from request` - ); - } - - const value = /** @type {string} */ (req.headers[address_header]) || ''; + const response = await server.respond(request, { + platform: { req }, + getClientAddress: () => { + if (address_header) { + if (!(address_header in req.headers)) { + throw new Error( + `Address header was specified with ${ + ENV_PREFIX + 'ADDRESS_HEADER' + }=${address_header} but is absent from request` + ); + } - if (address_header === 'x-forwarded-for') { - const addresses = value.split(','); + const value = /** @type {string} */ (req.headers[address_header]) || ''; - if (xff_depth < 1) { - throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`); - } + if (address_header === 'x-forwarded-for') { + const addresses = value.split(','); - if (xff_depth > addresses.length) { - throw new Error( - `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${ - addresses.length - } addresses` - ); - } - return addresses[addresses.length - xff_depth].trim(); + if (xff_depth < 1) { + throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`); } - return value; + if (xff_depth > addresses.length) { + throw new Error( + `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${ + addresses.length + } addresses` + ); + } + return addresses[addresses.length - xff_depth].trim(); } - return ( - req.connection?.remoteAddress || - // @ts-expect-error - req.connection?.socket?.remoteAddress || - req.socket?.remoteAddress || - // @ts-expect-error - req.info?.remoteAddress - ); + return value; } - }) - ); + + return ( + req.connection?.remoteAddress || + // @ts-expect-error + req.connection?.socket?.remoteAddress || + req.socket?.remoteAddress || + // @ts-expect-error + req.info?.remoteAddress + ); + } + }); + + for (const [name, value] of /** @type {any} */ (res).__response_headers) { + if (name === 'set-cookie') { + response.headers.append(name, value); + } else { + response.headers.set(name, value); + } + } + + await setResponse(res, response); }; /** @param {import('polka').Middleware[]} handlers */ @@ -206,8 +270,8 @@ function get_origin(headers) { export const handler = sequence( [ + has_middleware && middleware, serve(path.join(dir, 'client'), true), - serve(path.join(dir, 'static')), serve_prerendered(), ssr ].filter(Boolean) diff --git a/packages/adapter-node/src/noop-middleware.js b/packages/adapter-node/src/noop-middleware.js new file mode 100644 index 000000000000..6394265b3b64 --- /dev/null +++ b/packages/adapter-node/src/noop-middleware.js @@ -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: () => {} + }; +} diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js index 1098fbf31379..af09df103547 100644 --- a/packages/adapter-vercel/files/edge.js +++ b/packages/adapter-vercel/files/edge.js @@ -15,6 +15,14 @@ const initialized = server.init({ export default async (request, context) => { await initialized; + const pathname = request.headers.get('x-sveltekit-vercel-rewrite'); + if (pathname) { + let url = new URL(request.url); + url.pathname = pathname; + request = new Request(url, request); + request.headers.delete('x-sveltekit-vercel-rewrite'); + } + return server.respond(request, { getClientAddress() { return /** @type {string} */ (request.headers.get('x-forwarded-for')); diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js new file mode 100644 index 000000000000..1ae06cdc4cc5 --- /dev/null +++ b/packages/adapter-vercel/files/middleware.js @@ -0,0 +1,25 @@ +import { next, rewrite } from '@vercel/edge'; +import { middleware as user_middleware } from 'MIDDLEWARE'; +import { call_middleware } from 'CALL_MIDDLEWARE'; + +/** + * @param {Request} request + */ +export default async function middleware(request) { + const result = await call_middleware(request, user_middleware); + + if (result instanceof Response) return result; + + if (result.did_reroute) { + const url = new URL(result.request.url); + result.request_headers.set('x-sveltekit-vercel-rewrite', url.pathname); + return rewrite(url, { + headers: result.response_headers, + request: { + headers: result.request_headers + } + }); + } else { + return next({ headers: result.response_headers }); + } +} diff --git a/packages/adapter-vercel/files/serverless.js b/packages/adapter-vercel/files/serverless.js index a8f774be9424..e203f4bd4bdf 100644 --- a/packages/adapter-vercel/files/serverless.js +++ b/packages/adapter-vercel/files/serverless.js @@ -31,6 +31,12 @@ export default async (req, res) => { // Optional routes' pathname replacements look like `/foo/$1/bar` which means we could end up with an url like /foo//bar pathname = pathname.replace(/\/+/g, '/'); req.url = `${pathname}${path.endsWith(DATA_SUFFIX) ? DATA_SUFFIX : ''}?${params}`; + } else { + pathname = /** @type {string | null} */ (req.headers['x-sveltekit-vercel-rewrite']); + if (pathname) { + req.url = pathname; + delete req.headers['x-sveltekit-vercel-rewrite']; + } } } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 18a008816f70..278a36e6460a 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,11 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; -import { get_pathname, pattern_to_src } from './utils.js'; +import { get_pathname, get_regex_from_matchers, pattern_to_src } from './utils.js'; import { VERSION } from '@sveltejs/kit'; +import { resolve } from 'import-meta-resolve'; const name = '@sveltejs/adapter-vercel'; const DEFAULT_FUNCTION_NAME = 'fn'; @@ -113,29 +114,13 @@ const plugin = function (defaults = {}) { } /** + * @param {import('esbuild').BuildOptions & Required>} esbuild_options * @param {string} name - * @param {import('./index.js').EdgeConfig} config - * @param {import('@sveltejs/kit').RouteDefinition[]} routes + * @param {import('./index.js').Config} adapter_config */ - async function generate_edge_function(name, config, routes) { - const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); - const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); - - builder.copy(`${files}/edge.js`, `${tmp}/edge.js`, { - replace: { - SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' - } - }); - - write( - `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` - ); - + async function bundle_edge_function(esbuild_options, name, adapter_config) { try { const result = await esbuild.build({ - entryPoints: [`${tmp}/edge.js`], outfile: `${dirs.functions}/${name}.func/index.js`, target: 'es2020', // TODO verify what the edge runtime supports bundle: true, @@ -144,7 +129,7 @@ const plugin = function (defaults = {}) { external: [ ...compatible_node_modules, ...compatible_node_modules.map((id) => `node:${id}`), - ...(config.external || []) + ...((adapter_config.runtime === 'edge' && adapter_config.external) || []) ], sourcemap: 'linked', banner: { js: 'globalThis.global = globalThis;' }, @@ -155,7 +140,8 @@ const plugin = function (defaults = {}) { '.ttf': 'copy', '.eot': 'copy', '.otf': 'copy' - } + }, + ...(esbuild_options || {}) }); if (result.warnings.length > 0) { @@ -199,8 +185,8 @@ const plugin = function (defaults = {}) { `${dirs.functions}/${name}.func/.vc-config.json`, JSON.stringify( { - runtime: config.runtime, - regions: config.regions, + runtime: 'edge', + regions: adapter_config.regions, entrypoint: 'index.js', framework: { slug: 'sveltekit', @@ -213,6 +199,96 @@ const plugin = function (defaults = {}) { ); } + /** + * @param {string} name + * @param {import('./index.js').EdgeConfig} config + * @param {import('@sveltejs/kit').RouteDefinition[]} routes + */ + async function generate_edge_function(name, config, routes) { + const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); + const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); + + const dest = `${tmp}/edge.js`; + + builder.copy(`${files}/edge.js`, dest, { + replace: { + SERVER: `${relativePath}/index.js`, + MANIFEST: './manifest.js' + } + }); + + write( + `${tmp}/manifest.js`, + `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` + ); + + await bundle_edge_function({ entryPoints: [dest] }, name, config); + } + + /** + * @param {import('./index.js').Config} config + */ + async function generate_edge_middleware(config) { + const middleware_path = `${builder.getServerDirectory()}/middleware.js`; + + if (!fs.existsSync(middleware_path)) return; + + const dest = `${tmp}/middleware.js`; + const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${files}/middleware.js`, dest, { + replace: { + MIDDLEWARE: `${relativePath}/middleware.js`, + CALL_MIDDLEWARE: `${relativePath}/call-middleware.js` + } + }); + + // @vercel/edge is a dependency of this package, but the middleware is bundled within the user's project, + // where transitive dependencies could not be available (e.g. in case of pnpm). We therefore copy it over. + const vercel_edge_pkg = resolve('@vercel/edge/package.json', import.meta.url); + builder.copy( + path.dirname(fileURLToPath(vercel_edge_pkg)), + `${tmp}/node_modules/@vercel/edge` + ); + + await bundle_edge_function( + { + entryPoints: [dest] + }, + 'user-middleware', + config + ); + + let matcher = `/((?!${builder.getAppPath()}/immutable|favicon.ico|favicon.png).*)`; + + try { + const file_path = pathToFileURL(middleware_path).href; + const { config } = await import(file_path); + if (config?.matcher) matcher = config.matcher; + } catch (e) { + // Don't bother showing the error if we know there's no config object + const text = fs.readFileSync(middleware_path, 'utf-8'); + if (text.includes('config') || text.includes('export *')) { + builder.log.error( + 'Failed to import middleware hook. Make sure it is loadable during build, which is necessary to analyze the config object.' + ); + throw e; + } + } + + static_config.routes.splice( + static_config.routes.findIndex((r) => r.handle === 'filesystem'), + 0, + { + src: get_regex_from_matchers(matcher), + middlewarePath: 'user-middleware', + continue: true + } + ); + } + + await generate_edge_middleware(defaults); + /** @type {Map[] }>} */ const groups = new Map(); @@ -419,6 +495,15 @@ const plugin = function (defaults = {}) { write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); }, + emulate() { + return { + shouldRunMiddleware: (path, middlewareModule) => { + const rexex = new RegExp(get_regex_from_matchers(middlewareModule.config?.matcher)); + return rexex.test(path); + } + }; + }, + supports: { // reading from the filesystem only works in serverless functions read: ({ config, route }) => { @@ -430,6 +515,9 @@ const plugin = function (defaults = {}) { ); } + return true; + }, + middleware: () => { return true; } } diff --git a/packages/adapter-vercel/internal.d.ts b/packages/adapter-vercel/internal.d.ts index 537f7cc041d1..079a9998460f 100644 --- a/packages/adapter-vercel/internal.d.ts +++ b/packages/adapter-vercel/internal.d.ts @@ -6,3 +6,13 @@ declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; export const manifest: SSRManifest; } + +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; +} diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index c8e823ce8f72..6c2cc2cbbc81 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -41,7 +41,10 @@ }, "dependencies": { "@vercel/nft": "^0.29.0", - "esbuild": "^0.24.0" + "@vercel/edge": "^1.2.1", + "esbuild": "^0.24.0", + "import-meta-resolve": "^4.1.0", + "path-to-regexp": "^6.3.0" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/packages/adapter-vercel/utils.js b/packages/adapter-vercel/utils.js index a41a9cb7a92c..7fb3f049f102 100644 --- a/packages/adapter-vercel/utils.js +++ b/packages/adapter-vercel/utils.js @@ -1,3 +1,5 @@ +import { pathToRegexp } from 'path-to-regexp'; + /** @param {import("@sveltejs/kit").RouteDefinition} route */ export function get_pathname(route) { let i = 1; @@ -67,3 +69,57 @@ export function pattern_to_src(pattern) { return src; } + +/** + * @param {unknown} matchers + * @returns {string} + */ +export function get_regex_from_matchers(matchers) { + const regex = getRegExpFromMatchers(matchers); + // Make sure that we also match on our special internal routes + const special_routes = ['__data.json', '__route.js']; + const modified_regex = regex + .replace(/\$\|\^/g, `(?:|${special_routes.join('|')})$|`) + .replace(/\$$/g, `(?:|${special_routes.join('|')})$`); + return modified_regex; +} + +// Copied from https://github.com/vercel/vercel/blob/main/packages/node/src/utils.ts#L97 which hopefully is available via @vercel/routing-utils at some point +/** + * @param {unknown} matcherOrMatchers + * @returns {string} + */ +function getRegExpFromMatchers(matcherOrMatchers) { + if (!matcherOrMatchers) { + return '^/.*$'; + } + const matchers = Array.isArray(matcherOrMatchers) ? matcherOrMatchers : [matcherOrMatchers]; + const regExps = matchers.flatMap(getRegExpFromMatcher).join('|'); + return regExps; +} + +/** + * @param {unknown} matcher + * @param {number} _index + * @param {unknown[]} allMatchers + * @returns {string[]} + */ +function getRegExpFromMatcher(matcher, _index, allMatchers) { + if (typeof matcher !== 'string') { + throw new Error( + "Middleware's `config.matcher` must be a path matcher (string) or an array of path matchers (string[])" + ); + } + + if (!matcher.startsWith('/')) { + throw new Error( + `Middleware's \`config.matcher\` values must start with "/". Received: ${matcher}` + ); + } + + const regExps = [pathToRegexp(matcher).source]; + if (matcher === '/' && !allMatchers.includes('/index')) { + regExps.push(pathToRegexp('/index').source); + } + return regExps; +} diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 5a6830bdaa42..20fbc771d86c 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -3,6 +3,8 @@ import path from 'node:path'; import process from 'node:process'; import * as url from 'node:url'; import options from './options.js'; +import { check_middleware_feature } from '../../utils/features.js'; +import { resolve_entry } from '../../utils/filesystem.js'; /** * Loads the template (src/app.html by default) and validates that it has the @@ -95,6 +97,10 @@ function process_config(config, { cwd = process.cwd() } = {}) { validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client); validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server); validated.kit.files.hooks.universal = path.resolve(cwd, validated.kit.files.hooks.universal); + validated.kit.files.hooks.middleware = path.resolve( + cwd, + validated.kit.files.hooks.middleware + ); } else { // @ts-expect-error validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]); @@ -115,6 +121,7 @@ export function validate_config(config) { ); } + /** @type {import('types').ValidatedConfig} */ const validated = options(config, 'config'); if (validated.kit.router.resolution === 'server') { @@ -130,5 +137,12 @@ export function validate_config(config) { } } + if (resolve_entry(validated.kit.files.hooks.middleware)) { + if (!validated.kit.experimental.middleware) { + throw new Error('To use middleware, set `experimental.middleware` to `true`'); + } + check_middleware_feature(validated.kit.adapter); + } + return validated; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 9c577f5425c0..d7e23e6491a8 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -1,8 +1,9 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assert, expect, test } from 'vitest'; -import { validate_config, load_config } from './index.js'; import process from 'node:process'; +import { assert, expect, test, vi } from 'vitest'; +import { validate_config, load_config } from './index.js'; +import * as filesystem_utils from '../../utils/filesystem.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = join(__filename, '..'); @@ -76,12 +77,16 @@ const get_defaults = (prefix = '') => ({ publicPrefix: 'PUBLIC_', privatePrefix: '' }, + experimental: { + middleware: false + }, files: { assets: join(prefix, 'static'), hooks: { client: join(prefix, 'src/hooks.client'), server: join(prefix, 'src/hooks.server'), - universal: join(prefix, 'src/hooks') + universal: join(prefix, 'src/hooks'), + middleware: join(prefix, 'src/hooks.middleware') }, lib: join(prefix, 'src/lib'), params: join(prefix, 'src/params'), @@ -299,6 +304,28 @@ test('fails if prerender.entries are invalid', () => { }, /^Each member of config\.kit.prerender.entries must be either '\*' or an absolute path beginning with '\/' — saw 'foo'$/); }); +test('can use middleware when setting the experimental flag', () => { + const spy = vi.spyOn(filesystem_utils, 'resolve_entry').mockReturnValue('/some/path'); + assert.doesNotThrow(() => { + validate_config({ + kit: { + experimental: { + middleware: true + } + } + }); + }); + spy.mockRestore(); +}); + +test('fail if middleware is used without setting the experimental flag', () => { + const spy = vi.spyOn(filesystem_utils, 'resolve_entry').mockReturnValue('/some/path'); + assert.throws(() => { + validate_config({}); + }, /^To use middleware, set `experimental.middleware` to `true`$/); + spy.mockRestore(); +}); + /** * @param {string} name * @param {import('@sveltejs/kit').KitConfig['paths']} input diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a2b9bb81759d..92fa66c4c9eb 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -120,12 +120,17 @@ const options = object( privatePrefix: string('') }), + experimental: object({ + middleware: boolean(false), + }), + files: object({ assets: string('static'), hooks: object({ client: string(join('src', 'hooks.client')), server: string(join('src', 'hooks.server')), - universal: string(join('src', 'hooks')) + universal: string(join('src', 'hooks')), + middleware: string(join('src', 'hooks.middleware')) }), lib: string(join('src', 'lib')), params: string(join('src', 'params')), diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index 037f8dc8f6ba..8e8389e095cc 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -64,11 +64,13 @@ function create_hooks(config, cwd) { const client = resolve_entry(config.kit.files.hooks.client); const server = resolve_entry(config.kit.files.hooks.server); const universal = resolve_entry(config.kit.files.hooks.universal); + const middleware = resolve_entry(config.kit.files.hooks.middleware); return { client: client && posixify(path.relative(cwd, client)), server: server && posixify(path.relative(cwd, server)), - universal: universal && posixify(path.relative(cwd, universal)) + universal: universal && posixify(path.relative(cwd, universal)), + middleware: middleware && posixify(path.relative(cwd, middleware)) }; } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f25cc225e194..e521f81c5065 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -44,6 +44,11 @@ export interface Adapter { * @param config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + /** + * Test support for middleware + * @since 2.18.0 + */ + middleware?: () => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -275,6 +280,15 @@ export interface Emulator { * and returns an `App.Platform` object */ platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; + /** + * A function that is called with the current path, the middleware module and the SvelteKit config, + * and returns a boolean stating whether or not middleware should run on the given path. + */ + shouldRunMiddleware?: ( + path: string, + middlewareModule: any, + kitConfig: KitConfig + ) => MaybePromise; } export interface KitConfig { @@ -409,6 +423,17 @@ export interface KitConfig { */ privatePrefix?: string; }; + /** + * Experimental features. Not subject to semver; may be updated or removed in any release. + */ + experimental?: { + /** + * Experimental middleware support. Allows creation of `hooks.middleware.js` files that export functions to run on the server prior to all requests to the app. + * @default false + * @since 2.18.0 + */ + middleware?: boolean; + }; /** * Where to find various files within your project. */ @@ -435,6 +460,12 @@ export interface KitConfig { * @since 2.3.0 */ universal?: string; + /** + * The location of your middleware [hooks](https://svelte.dev/docs/kit/hooks). + * @default "src/hooks.middleware" + * @since 2.18.0 + */ + middleware?: string; }; /** * your app's internal library, accessible throughout the codebase as `$lib` @@ -1495,4 +1526,57 @@ export interface Snapshot { restore: (snapshot: T) => void; } +export interface Middleware { + (options: MiddlewareEvent): Response | unknown; +} + +export interface MiddlewareEvent { + /** + * The original request object, as passed by the adapter + */ + request: Request; + /** + * The normalized URL of the request. E.g. data requests or route resolution requests will have their internal information stripped. + * Most of the time you want to use this instead of `request.url` to match against a specific pathname. + */ + url: URL; + /** + * Add headers to the request before it is sent to the server. + */ + setRequestHeaders: (headers: Record) => void; + /** + * Add headers to the response before it is sent to the client. + */ + setResponseHeaders: (headers: Record) => void; + /** + * Use this to get cookies from the request and set cookies for the response. + */ + cookies: Cookies; + /** + * Return from middleware with this call to route the request to a different path. + */ + reroute: (pathname: string) => unknown; +} + +/** + * A convenience function that takes a Request and a Middleware, + * and takes care of calling the middleware with the appropriate parameters. + * Useful for when you write an adapter and want to call middleware. + */ +export interface CallMiddleware { + ( + request: Request, + middleware: Middleware + ): Promise< + | Response + | { + request: Request; + request_headers: Headers; + did_reroute: boolean; + response_headers: Headers; + add_response_headers: (response: Response) => void; + } + >; +} + export * from './index.js'; diff --git a/packages/kit/src/exports/vite/build/build_middleware.js b/packages/kit/src/exports/vite/build/build_middleware.js new file mode 100644 index 000000000000..d8a823ef299f --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_middleware.js @@ -0,0 +1,145 @@ +import * as vite from 'vite'; +import { dedent } from '../../../core/sync/utils.js'; +import { s } from '../../../utils/misc.js'; +import { sveltekit_paths } from '../module_ids.js'; +import { get_config_aliases } from '../utils.js'; +import { posixify } from '../../../utils/filesystem.js'; +import { fileURLToPath } from 'url'; + +/** + * @param {string} out + * @param {import('types').ValidatedKitConfig} kit + * @param {import('vite').ResolvedConfig} vite_config + * @param {string} runtime_directory + * @param {string} middleware_entry_file + */ +export async function build_middleware( + out, + kit, + vite_config, + runtime_directory, + middleware_entry_file +) { + /** + * @type {import('vite').Plugin} + */ + const mw_virtual_modules = { + name: 'middleware-build-virtual-modules', + + resolveId(id) { + if ( + id.startsWith('$env/') || + id === '$service-worker' || + (id.startsWith('$app/') && id !== '$app/paths') + ) { + throw new Error( + `Cannot import ${id} into middleware code. Only the $app/paths module is available in middleware.` + ); + } + + if (id.startsWith('__sveltekit/')) { + return `\0virtual:${id}`; + } + }, + + load(id) { + if (!id.startsWith('\0virtual:')) { + return; + } + + if (id === sveltekit_paths) { + const { assets, base } = kit.paths; + + // TODO duplicated in vite/index.js, extract to a shared module? + return dedent` + export let base = ${s(base)}; + export let assets = ${assets ? s(assets) : 'base'}; + export const app_dir = ${s(kit.appDir)}; + + export const relative = ${kit.paths.relative}; + + const initial = { base, assets }; + + export function override(paths) { + base = paths.base; + assets = paths.assets; + } + + export function reset() { + base = initial.base; + assets = initial.assets; + } + + /** @param {string} path */ + export function set_assets(path) { + assets = initial.assets = path; + } + `; + } + + throw new Error( + `Cannot import ${id} into middleware code. Only the $app/paths module is available in middleware.` + ); + } + }; + + await vite.build({ + build: { + ssr: true, + modulePreload: false, + rollupOptions: { + input: { + middleware: middleware_entry_file + }, + output: { + entryFileNames: 'middleware.js', + // TODO disallow assets? where should they go? + assetFileNames: `${kit.appDir}/immutable/assets/[name].[hash][extname]`, + inlineDynamicImports: true + } + }, + outDir: `${out}/server`, + emptyOutDir: false, + minify: vite_config.build.minify + }, + configFile: false, + define: vite_config.define, + publicDir: false, + plugins: [mw_virtual_modules], + resolve: { + alias: [ + { find: '$app/paths', replacement: `${runtime_directory}/app/paths` }, + ...get_config_aliases(kit) + ] + } + }); + + await vite.build({ + build: { + ssr: true, + modulePreload: false, + rollupOptions: { + input: { + middleware: `${runtime_directory}/server/call-middleware.js` + }, + output: { + entryFileNames: 'call-middleware.js', + inlineDynamicImports: true + } + }, + outDir: `${out}/server`, + emptyOutDir: false, + minify: vite_config.build.minify + }, + configFile: false, + define: vite_config.define, + publicDir: false, + plugins: [mw_virtual_modules], + resolve: { + alias: [ + { find: '$app/paths', replacement: `${runtime_directory}/app/paths` }, + ...get_config_aliases(kit) + ] + } + }); +} diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 96ce9a7ed5fa..49ca69d292b6 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -14,7 +14,15 @@ import { basename } from 'node:path'; * @param {import('vite').Rollup.OutputAsset[] | null} css * @param {import('types').RecursiveRequired} output_config */ -export function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css, output_config) { +export function build_server_nodes( + out, + kit, + manifest_data, + server_manifest, + client_manifest, + css, + output_config +) { mkdirp(`${out}/server/nodes`); mkdirp(`${out}/server/stylesheets`); @@ -34,7 +42,9 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli /** @type {Map} */ const server_stylesheets = new Map(); - const component_stylesheet_map = new Map(Object.values(server_manifest).map((file) => [file.src, file.css?.[0]])); + const component_stylesheet_map = new Map( + Object.values(server_manifest).map((file) => [file.src, file.css?.[0]]) + ); manifest_data.nodes.forEach((node, i) => { const server_stylesheet = component_stylesheet_map.get(node.component); @@ -44,7 +54,8 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli }); // ignore dynamically imported stylesheets since we can't inline those - css.filter(asset => client_stylesheets.has(asset.fileName)) + css + .filter((asset) => client_stylesheets.has(asset.fileName)) .forEach((asset) => { if (asset.source.length < kit.inlineStyleThreshold) { // We know that the names for entry points are numbers. @@ -111,7 +122,11 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli exports.push(`export const server_id = ${s(node.server)};`); } - if (client_manifest && (node.universal || node.component) && output_config.bundleStrategy === 'split') { + if ( + client_manifest && + (node.universal || node.component) && + output_config.bundleStrategy === 'split' + ) { const entry = find_deps( client_manifest, `${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`, diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 39f4ef41e0cd..68765c9258e9 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -519,7 +519,7 @@ export async function dev(vite, vite_config, svelte_config) { read: (file) => createReadableStream(from_fs(file)) }); - const request = await getRequest({ + let request = await getRequest({ base, request: req }); @@ -546,6 +546,41 @@ export async function dev(vite, vite_config, svelte_config) { return; } + /** @type {{ middleware: import('@sveltejs/kit').Middleware } | undefined} */ + let middleware; + let middleware_result; + + if (resolve_entry(hooks.middleware)) { + try { + middleware = /** @type {{ middleware: import('@sveltejs/kit').Middleware }} */ ( + await vite.ssrLoadModule(hooks.middleware) + ); + } catch (e) { + console.error(e); + } + } + + if ( + req.url && + middleware && + (emulator?.shouldRunMiddleware?.(req.url, middleware, svelte_config.kit) ?? true) + ) { + const { call_middleware } = + /** @type {{ call_middleware: import('@sveltejs/kit').CallMiddleware }} */ ( + await vite.ssrLoadModule(`${runtime_base}/server/call-middleware.js`, { + fixStacktrace: true + }) + ); + middleware_result = await call_middleware(request, middleware.middleware); + + if (middleware_result instanceof Response) { + void setResponse(res, middleware_result); + return; + } else { + request = middleware_result.request; + } + } + const rendered = await server.respond(request, { getClientAddress: () => { const { remoteAddress } = req.socket; @@ -565,6 +600,8 @@ export async function dev(vite, vite_config, svelte_config) { emulator }); + middleware_result?.add_response_headers?.(rendered); + if (rendered.status === 404) { // @ts-expect-error serve_static_middleware.handle(req, res, () => { diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 4885d000ec15..79e6e3b639a7 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -36,6 +36,7 @@ import { } from './module_ids.js'; import { resolve_peer_dependency } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; +import { build_middleware } from './build/build_middleware.js'; const cwd = process.cwd(); @@ -206,6 +207,7 @@ async function kit({ svelte_config }) { /** @type {import('vite').UserConfig} */ let initial_config; + const middleware_file = resolve_entry(kit.files.hooks.middleware); const service_worker_entry_file = resolve_entry(kit.files.serviceWorker); const parsed_service_worker = path.parse(kit.files.serviceWorker); @@ -769,9 +771,9 @@ Tips: }, /** - * Vite builds a single bundle. We need three bundles: client, server, and service worker. + * Vite builds a single bundle. We need four bundles: client, server, service worker and middleware. * The user's package.json scripts will invoke the Vite CLI to execute the server build. We - * then use this hook to kick off builds for the client and service worker. + * then use this hook to kick off builds for the other ones. */ writeBundle: { sequential: true, @@ -1016,6 +1018,12 @@ Tips: ); } + if (middleware_file) { + log.info('Building server middleware'); + + await build_middleware(out, kit, vite_config, runtime_directory, middleware_file); + } + // we need to defer this to closeBundle, so that adapters copy files // created by other Vite plugins finalise = async () => { diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 0342e718c75c..85ecd30c37b9 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -43,6 +43,15 @@ export async function preview(vite, vite_config, svelte_config) { const { manifest } = await import(pathToFileURL(join(dir, 'manifest.js')).href); + /** @type {{ middleware: import('@sveltejs/kit').Middleware }} */ + const { middleware } = await import(pathToFileURL(join(dir, 'middleware.js')).href).catch( + () => ({}) + ); + /** @type {{ call_middleware: import('@sveltejs/kit').CallMiddleware }} */ + const { call_middleware } = await import( + pathToFileURL(join(dir, 'call-middleware.js')).href + ).catch(() => ({})); + set_assets(assets); const server = new Server(manifest); @@ -110,6 +119,44 @@ export async function preview(vite, vite_config, svelte_config) { scoped(base, mutable(join(svelte_config.kit.outDir, 'output/prerendered/dependencies'))) ); + // middleware + if (middleware) { + vite.middlewares.use(async (req, res, next) => { + const host = req.headers[':authority'] || req.headers.host; + + const request = await getRequest({ + base: `${protocol}://${host}`, + request: req + }); + + const result = await call_middleware(request, middleware); + + if (result instanceof Response) { + await setResponse(res, result); + return; + } + + for (const [key, value] of result.request_headers.entries()) { + req.headers[key] = value; + } + + if (result.did_reroute) { + const url = new URL(result.request.url); + req.url = url.pathname + url.search; + } + + for (const [key, value] of result.response_headers.entries()) { + res.setHeader(key, value); + } + // @ts-expect-error set headers directly but also put them on the response object + // so that we can set them once more after the SvelteKit runtime, in case + // the response overrides some of them. + res.__set_response_headers = result.set_response_headers; + + next(); + }); + } + // prerendered pages (we can't just use sirv because we need to // preserve the correct trailingSlash behaviour) vite.middlewares.use( @@ -192,24 +239,26 @@ export async function preview(vite, vite_config, svelte_config) { request: req }); - await setResponse( - res, - await server.respond(request, { - getClientAddress: () => { - const { remoteAddress } = req.socket; - if (remoteAddress) return remoteAddress; - throw new Error('Could not determine clientAddress'); - }, - read: (file) => { - if (file in manifest._.server_assets) { - return fs.readFileSync(join(dir, file)); - } + const response = await server.respond(request, { + getClientAddress: () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + }, + read: (file) => { + if (file in manifest._.server_assets) { + return fs.readFileSync(join(dir, file)); + } - return fs.readFileSync(join(svelte_config.kit.files.assets, file)); - }, - emulator - }) - ); + return fs.readFileSync(join(svelte_config.kit.files.assets, file)); + }, + emulator + }); + + // @ts-expect-error + res.__set_response_headers?.(response); + + await setResponse(res, response); }); }; } diff --git a/packages/kit/src/runtime/server/call-middleware.js b/packages/kit/src/runtime/server/call-middleware.js new file mode 100644 index 000000000000..2d62574b1123 --- /dev/null +++ b/packages/kit/src/runtime/server/call-middleware.js @@ -0,0 +1,99 @@ +import { add_cookies_to_headers, get_cookies } from './cookie.js'; +import { add_resolution_prefix } from '../pathname.js'; +import { normalize_url } from './utils.js'; + +/** + * @param {Request} request + * @param {import('@sveltejs/kit').Middleware} middleware + * @returns {ReturnType} + */ +export async function call_middleware(request, middleware) { + const { cookies, new_cookies } = get_cookies(request, new URL(request.url), 'never'); + + let request_headers_called = false; + const request_headers = new Headers(request.headers); + /** @param {Record} headers */ + const setRequestHeaders = (headers) => { + for (const key in headers) { + const lower = key.toLowerCase(); + const value = headers[key]; + + if (lower === 'set-cookie') { + throw new Error('Cannot set cookies on the request header'); + } else { + request_headers_called = true; + request_headers.set(key, value); + } + } + }; + + const response_headers = new Headers(); + /** @param {Record} headers */ + const setResponseHeaders = (headers) => { + for (const key in headers) { + const lower = key.toLowerCase(); + const value = headers[key]; + + if (lower === 'set-cookie') { + throw new Error( + 'Use `cookies.set(name, value, options)` instead of `setResponseHeaders` to set cookies' + ); + } else { + response_headers.set(key, value); + } + } + }; + + /** @param {string} pathname */ + const reroute = (pathname) => { + return pathname; // TODO think about making this a class object + }; + + const { url, is_route_resolution_request } = normalize_url(new URL(request.url)); + + const result = await middleware({ + request, + url, + setRequestHeaders, + setResponseHeaders, + cookies, + reroute + }); + + add_cookies_to_headers(response_headers, Object.values(new_cookies)); + + const add_response_headers = /** @param {Response} response */ (response) => { + for (const [key, value] of response_headers) { + if (key.toLowerCase() === 'set-cookie') { + response.headers.append('set-cookie', value); + } else { + response.headers.set(key, value); + } + } + }; + + if (result instanceof Response) { + return result; + } + + if (typeof result === 'string' || request_headers_called) { + const url = new URL( + typeof result === 'string' + ? is_route_resolution_request + ? add_resolution_prefix(result) + : result + : request.url, + request.url + ); + + request = new Request(url, { headers: request_headers }); + } + + return { + request, + request_headers, + did_reroute: typeof result === 'string', + response_headers, + add_response_headers + }; +} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 429d523c3715..7405454541aa 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -5,7 +5,12 @@ import { render_page } from './page/index.js'; import { render_response } from './page/render.js'; import { respond_with_error } from './page/respond_with_error.js'; import { is_form_content_type } from '../../utils/http.js'; -import { handle_fatal_error, method_not_allowed, redirect_response } from './utils.js'; +import { + handle_fatal_error, + method_not_allowed, + normalize_url, + redirect_response +} from './utils.js'; import { decode_pathname, decode_params, disable_search, normalize_path } from '../../utils/url.js'; import { exec } from '../../utils/routing.js'; import { redirect_json_response, render_data } from './data/index.js'; @@ -22,18 +27,11 @@ import { import { get_option } from '../../utils/options.js'; import { json, text } from '../../exports/index.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; -import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; import { load_page_nodes } from './page/load_page_nodes.js'; import { get_page_config } from '../../utils/route_config.js'; import { resolve_route } from './page/server_routing.js'; import { validateHeaders } from './validate-headers.js'; -import { - has_data_suffix, - has_resolution_prefix, - strip_data_suffix, - strip_resolution_prefix -} from '../pathname.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ /* global __SVELTEKIT_DEV__ */ @@ -87,29 +85,8 @@ export async function respond(request, options, manifest, state) { return text('Not found', { status: 404 }); } - /** @type {boolean[] | undefined} */ - let invalidated_data_nodes; - - /** - * If the request is for a route resolution, first modify the URL, then continue as normal - * for path resolution, then return the route object as a JS file. - */ - const is_route_resolution_request = has_resolution_prefix(url.pathname); - const is_data_request = has_data_suffix(url.pathname); - - if (is_route_resolution_request) { - url.pathname = strip_resolution_prefix(url.pathname); - } else if (is_data_request) { - url.pathname = - strip_data_suffix(url.pathname) + - (url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; - url.searchParams.delete(TRAILING_SLASH_PARAM); - invalidated_data_nodes = url.searchParams - .get(INVALIDATED_PARAM) - ?.split('') - .map((node) => node === '1'); - url.searchParams.delete(INVALIDATED_PARAM); - } + const { is_route_resolution_request, is_data_request, invalidated_data_nodes } = + normalize_url(url); let resolved_path; diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 473804cf9183..08463a700dc9 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -6,6 +6,13 @@ import { HttpError } from '../control.js'; import { fix_stack_trace } from '../shared-server.js'; import { ENDPOINT_METHODS } from '../../constants.js'; import { escape_html } from '../../utils/escape.js'; +import { + has_resolution_prefix, + has_data_suffix, + strip_resolution_prefix, + strip_data_suffix +} from '../pathname.js'; +import { TRAILING_SLASH_PARAM, INVALIDATED_PARAM } from '../shared.js'; /** @param {any} body */ export function is_pojo(body) { @@ -163,3 +170,39 @@ export function stringify_uses(node) { return `"uses":{${uses.join(',')}}`; } + +/** + * Strips route resolution/data request from the URL pathname (incoming URL is manipulated) and return info about the URL + * @param {URL} url + */ +export function normalize_url(url) { + const is_route_resolution_request = has_resolution_prefix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + /** @type {boolean[] | undefined} */ + let invalidated_data_nodes; + + if (is_route_resolution_request) { + url.pathname = strip_resolution_prefix(url.pathname); + } else if (is_data_request) { + url.pathname = + strip_data_suffix(url.pathname) + + (url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; + url.searchParams.delete(TRAILING_SLASH_PARAM); + invalidated_data_nodes = url.searchParams + .get(INVALIDATED_PARAM) + ?.split('') + .map((node) => node === '1'); + url.searchParams.delete(INVALIDATED_PARAM); + } + + return { + /** + * If the request is for a route resolution, first modify the URL, then continue as normal + * for path resolution, then return the route object as a JS file. + */ + is_route_resolution_request, + is_data_request, + invalidated_data_nodes, + url + }; +} diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js index 4a8530d22bbb..7744d0ff7cdf 100644 --- a/packages/kit/src/utils/features.js +++ b/packages/kit/src/utils/features.js @@ -22,3 +22,16 @@ export function check_feature(route_id, config, feature, adapter) { } } } + +/** + * @param {import('@sveltejs/kit').Adapter | undefined} adapter + */ +export function check_middleware_feature(adapter) { + if (!adapter) return; + + if (!adapter.supports?.middleware?.()) { + throw new Error( + `Cannot use middleware when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` + ); + } +} diff --git a/packages/kit/test/apps/basics/src/hooks.middleware.js b/packages/kit/test/apps/basics/src/hooks.middleware.js new file mode 100644 index 000000000000..b2cacbd88fbf --- /dev/null +++ b/packages/kit/test/apps/basics/src/hooks.middleware.js @@ -0,0 +1,19 @@ +export function middleware({ url, setRequestHeaders, setResponseHeaders, cookies, reroute }) { + if (url.pathname === '/middleware/custom-response') { + return new Response('

Custom Response

', { + headers: { + 'content-type': 'text/html' + } + }); + } + + if (url.pathname === '/middleware/reroute/a') { + return reroute('/middleware/reroute/b'); + } + + if (url.pathname === '/middleware/headers') { + setRequestHeaders({ 'x-custom-request-header': 'value' }); + setResponseHeaders({ 'x-custom-response-header': 'value' }); + cookies.set('cookie', 'value', { path: '/middleware' }); + } +} diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 1c825a6a6c90..78fd2276d657 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -151,6 +151,18 @@ export const handle = sequence( event.locals.url = new URL(event.request.url); } return resolve(event); + }, + async ({ event, resolve }) => { + if (event.url.pathname === '/middleware/headers') { + if (event.request.headers.get('x-custom-request-header') !== 'value') { + throw new Error('Request header not set'); + } + } + const response = await resolve(event); + if (response.headers.has('x-custom-response-header')) { + throw new Error('Expected no response header from middleware at this point'); + } + return response; } ); diff --git a/packages/kit/test/apps/basics/src/routes/middleware/+page.svelte b/packages/kit/test/apps/basics/src/routes/middleware/+page.svelte new file mode 100644 index 000000000000..655b660c7d6b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/middleware/+page.svelte @@ -0,0 +1,3 @@ +Response +Reroute +Headers diff --git a/packages/kit/test/apps/basics/src/routes/middleware/headers/+page.svelte b/packages/kit/test/apps/basics/src/routes/middleware/headers/+page.svelte new file mode 100644 index 000000000000..af373416d943 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/middleware/headers/+page.svelte @@ -0,0 +1 @@ +

Headers

diff --git a/packages/kit/test/apps/basics/src/routes/middleware/reroute/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/middleware/reroute/b/+page.svelte new file mode 100644 index 000000000000..e7e339d7f82c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/middleware/reroute/b/+page.svelte @@ -0,0 +1 @@ +

Rerouted

diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index bca05e5376ee..a27c8162afad 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -14,10 +14,15 @@ const config = { }; }, supports: { - read: () => true + read: () => true, + middleware: () => true } }, + experimental: { + middleware: true + }, + prerender: { entries: [ '*', diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index c5867f34e00c..84b95095ebe8 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1532,3 +1532,37 @@ test.describe('Serialization', () => { expect(await page.textContent('h1')).toBe('It works!'); }); }); + +test.describe('Middleware', () => { + test('Responds with custom Response', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) return; // TODO figure out what should happen in this case + + await page.goto('/middleware'); + await page.click('a[href="/middleware/custom-response"]'); + expect(await page.textContent('h1')).toBe('Custom Response'); + }); + + test('Reroutes to a different page', async ({ page }) => { + await page.goto('/middleware'); + await page.click('a[href="/middleware/reroute/a"]'); + expect(await page.textContent('p')).toBe('Rerouted'); + expect(new URL(page.url()).pathname).toBe('/middleware/reroute/a'); + }); + + test('Sets request/response headers', async ({ page, javaScriptEnabled }) => { + if ( + javaScriptEnabled && + /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) !== 'server' + ) { + // For client side navigation, we need server side route resolution to have a request to add the headers to + return; + } + + await page.goto('/middleware'); + page.click('a[href="/middleware/headers"]'); + const response = await page.waitForResponse((response) => + new URL(response.url()).pathname.includes('/middleware/headers') + ); + expect(await response.headerValue('x-custom-response-header')).toBe('value'); + }); +}); diff --git a/packages/kit/test/build-errors/experimental.spec.js b/packages/kit/test/build-errors/experimental.spec.js new file mode 100644 index 000000000000..cba3263a430d --- /dev/null +++ b/packages/kit/test/build-errors/experimental.spec.js @@ -0,0 +1,18 @@ +import { assert, test } from 'vitest'; +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; + +const timeout = 60_000; + +test('Cannot use middleware without experimental flag', { timeout }, () => { + assert.throws( + () => + execSync('pnpm build', { + cwd: path.join(process.cwd(), 'apps/experimental-middleware'), + stdio: 'pipe', + timeout + }), + /.*To use middleware, set `experimental.middleware` to `true`*/gs + ); +}); \ No newline at end of file diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f164abc09c1d..3513c4553944 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -26,6 +26,11 @@ declare module '@sveltejs/kit' { * @param config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + /** + * Test support for middleware + * @since 2.18.0 + */ + middleware?: () => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -257,6 +262,15 @@ declare module '@sveltejs/kit' { * and returns an `App.Platform` object */ platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; + /** + * A function that is called with the current path, the middleware module and the SvelteKit config, + * and returns a boolean stating whether or not middleware should run on the given path. + */ + shouldRunMiddleware?: ( + path: string, + middlewareModule: any, + kitConfig: KitConfig + ) => MaybePromise; } export interface KitConfig { @@ -391,6 +405,17 @@ declare module '@sveltejs/kit' { */ privatePrefix?: string; }; + /** + * Experimental features. Not subject to semver; may be updated or removed in any release. + */ + experimental?: { + /** + * Experimental middleware support. Allows creation of `hooks.middleware.js` files that export functions to run on the server prior to all requests to the app. + * @default false + * @since 2.18.0 + */ + middleware?: boolean; + }; /** * Where to find various files within your project. */ @@ -417,6 +442,12 @@ declare module '@sveltejs/kit' { * @since 2.3.0 */ universal?: string; + /** + * The location of your middleware [hooks](https://svelte.dev/docs/kit/hooks). + * @default "src/hooks.middleware" + * @since 2.18.0 + */ + middleware?: string; }; /** * your app's internal library, accessible throughout the codebase as `$lib` @@ -1476,6 +1507,59 @@ declare module '@sveltejs/kit' { capture: () => T; restore: (snapshot: T) => void; } + + export interface Middleware { + (options: MiddlewareEvent): Response | unknown; + } + + export interface MiddlewareEvent { + /** + * The original request object, as passed by the adapter + */ + request: Request; + /** + * The normalized URL of the request. E.g. data requests or route resolution requests will have their internal information stripped. + * Most of the time you want to use this instead of `request.url` to match against a specific pathname. + */ + url: URL; + /** + * Add headers to the request before it is sent to the server. + */ + setRequestHeaders: (headers: Record) => void; + /** + * Add headers to the response before it is sent to the client. + */ + setResponseHeaders: (headers: Record) => void; + /** + * Use this to get cookies from the request and set cookies for the response. + */ + cookies: Cookies; + /** + * Return from middleware with this call to route the request to a different path. + */ + reroute: (pathname: string) => unknown; + } + + /** + * A convenience function that takes a Request and a Middleware, + * and takes care of calling the middleware with the appropriate parameters. + * Useful for when you write an adapter and want to call middleware. + */ + export interface CallMiddleware { + ( + request: Request, + middleware: Middleware + ): Promise< + | Response + | { + request: Request; + request_headers: Headers; + did_reroute: boolean; + response_headers: Headers; + add_response_headers: (response: Response) => void; + } + >; + } interface AdapterEntry { /** * A string that uniquely identifies an HTTP service (e.g. serverless function) and is used for deduplication. @@ -2011,7 +2095,7 @@ declare module '@sveltejs/kit' { class Redirect_1 { constructor(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string); - status: 301 | 302 | 303 | 307 | 308 | 300 | 304 | 305 | 306; + status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308; location: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83af29446412..e8e1c62ae0bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: specifier: ^2.6.0 version: 2.6.0 devDependencies: + '@netlify/edge-functions': + specifier: ^2.11.1 + version: 2.11.1 '@netlify/functions': specifier: ^3.0.0 version: 3.0.0 @@ -265,12 +268,21 @@ importers: packages/adapter-vercel: dependencies: + '@vercel/edge': + specifier: ^1.2.1 + version: 1.2.1 '@vercel/nft': specifier: ^0.29.0 version: 0.29.0(rollup@4.30.1) esbuild: specifier: ^0.24.0 version: 0.24.2 + import-meta-resolve: + specifier: ^4.1.0 + version: 4.1.0 + path-to-regexp: + specifier: ^6.3.0 + version: 6.3.0 devDependencies: '@sveltejs/kit': specifier: workspace:^ @@ -677,6 +689,30 @@ importers: specifier: ^3.0.1 version: 3.0.5(@types/node@18.19.50)(lightningcss@1.24.1) + packages/kit/test/build-errors/apps/experimental-middleware: + devDependencies: + '@sveltejs/adapter-auto': + specifier: ^4.0.0 + version: link:../../../../../adapter-auto + '@sveltejs/kit': + specifier: ^2.16.0 + version: link:../../../.. + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.0.1(svelte@5.2.9)(vite@6.0.11(@types/node@18.19.50)(lightningcss@1.24.1)) + svelte: + specifier: ^5.0.0 + version: 5.2.9 + svelte-check: + specifier: ^4.0.0 + version: 4.1.1(picomatch@4.0.2)(svelte@5.2.9)(typescript@5.6.3) + typescript: + specifier: ^5.0.0 + version: 5.6.3 + vite: + specifier: ^6.0.0 + version: 6.0.11(@types/node@18.19.50)(lightningcss@1.24.1) + packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch: devDependencies: '@sveltejs/adapter-auto': @@ -1762,6 +1798,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@netlify/edge-functions@2.11.1': + resolution: {integrity: sha512-pyQOTZ8a+ge5lZlE+H/UAHyuqQqtL5gE0pXrHT9mOykr3YQqnkB2hZMtx12odatZ87gHg4EA+UPyMZUbLfnXvw==} + '@netlify/functions@3.0.0': resolution: {integrity: sha512-XXf9mNw4+fkxUzukDpJtzc32bl1+YlXZwEhc5ZgMcTbJPLpgRLDs5WWSPJ4eY/Mv1ZFvtxmMwmfgoQYVt68Qog==} engines: {node: '>=18.0.0'} @@ -2062,6 +2101,9 @@ packages: resolution: {integrity: sha512-zTQD6WLNTre1hj5wp09nBIDiOc2U5r/qmzo7wxPn4ZgAjHql09EofqhF9WF+fZHzL5aCyaIpPcT2hyxl73kr9A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/edge@1.2.1': + resolution: {integrity: sha512-1++yncEyIAi68D3UEOlytYb1IUcIulMWdoSzX2h9LuSeeyR7JtaIgR8DcTQ6+DmYOQn+5MCh6LY+UmK6QBByNA==} + '@vercel/nft@0.29.0': resolution: {integrity: sha512-LAkWyznNySxZ57ibqEGKnWFPqiRxyLvewFyB9iCHFfMsZlVyiu8MNFbjrGk3eV0vuyim5HzBloqlvSrG4BpZ7g==} engines: {node: '>=18'} @@ -4231,6 +4273,8 @@ snapshots: - encoding - supports-color + '@netlify/edge-functions@2.11.1': {} + '@netlify/functions@3.0.0': dependencies: '@netlify/serverless-functions-api': 1.30.1 @@ -4524,6 +4568,8 @@ snapshots: '@typescript-eslint/types': 8.4.0 eslint-visitor-keys: 3.4.3 + '@vercel/edge@1.2.1': {} + '@vercel/nft@0.29.0(rollup@4.30.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.0-rc.0