From 9f48a7bff5a6e8a6a6437185107b345db3b93847 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 6 Feb 2025 22:56:56 +0100 Subject: [PATCH 01/25] feat: add middleware support WIP --- packages/adapter-vercel/index.js | 3 + packages/kit/src/core/config/index.js | 7 ++ packages/kit/src/core/config/index.spec.js | 3 +- packages/kit/src/core/config/options.js | 3 +- .../core/sync/create_manifest_data/index.js | 4 +- packages/kit/src/exports/public.d.ts | 11 ++ .../src/exports/vite/dev/call_middleware.js | 102 ++++++++++++++++++ packages/kit/src/exports/vite/dev/index.js | 33 +++++- packages/kit/src/utils/features.js | 13 +++ packages/kit/types/index.d.ts | 13 ++- 10 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 packages/kit/src/exports/vite/dev/call_middleware.js diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 18a008816f70..8edfa1656d2d 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -430,6 +430,9 @@ const plugin = function (defaults = {}) { ); } + return true; + }, + middleware: () => { return true; } } diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 5a6830bdaa42..ab5d536acf8c 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -3,6 +3,7 @@ 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'; /** * Loads the template (src/app.html by default) and validates that it has the @@ -95,6 +96,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]); @@ -130,5 +135,7 @@ export function validate_config(config) { } } + 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..f6993317fb31 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -81,7 +81,8 @@ const get_defaults = (prefix = '') => ({ 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'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a2b9bb81759d..2a861c0a6a6d 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -125,7 +125,8 @@ const options = object( 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..1b6a4ef925f9 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 @@ -435,6 +440,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` diff --git a/packages/kit/src/exports/vite/dev/call_middleware.js b/packages/kit/src/exports/vite/dev/call_middleware.js new file mode 100644 index 000000000000..fee3cd16ec71 --- /dev/null +++ b/packages/kit/src/exports/vite/dev/call_middleware.js @@ -0,0 +1,102 @@ +import { add_cookies_to_headers, get_cookies } from '../../../runtime/server/cookie.js'; +import { + add_resolution_prefix, + has_resolution_prefix, + strip_resolution_prefix +} from '../../../runtime/pathname.js'; + +/** + * @param {Request} request + * @param {(options: any) => any} middleware + */ +export async function call_middleware(request, middleware) { + const { cookies, new_cookies } = get_cookies(request, new URL(request.url), 'never'); + + let request_headers_called = false; + let 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); + } + } + }; + + let 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 = new URL(request.url); + const is_route_resolution_request = has_resolution_prefix(url.pathname); + + const result = await middleware({ + request: is_route_resolution_request + ? new Request(new URL(strip_resolution_prefix(url.pathname), url), { + headers: request_headers + }) + : request, + setRequestHeaders, + setResponseHeaders, + cookies, + reroute + }); + + if (result instanceof Response) { + return add_response_headers(result, response_headers, new_cookies); + } + + if (typeof result === 'string' || request_headers_called) { + const url = new URL( + result ? (is_route_resolution_request ? add_resolution_prefix(result) : result) : request.url, + request.url + ); + request = new Request(url, { headers: request_headers }); + } + + return { + request, + /** @param {Response} response */ + add_response_headers: (response) => + add_response_headers(response, response_headers, new_cookies) + }; +} + +/** + * @param {Response} response + * @param {Headers} headers + * @param {Record} new_cookies + */ +function add_response_headers(response, headers, new_cookies) { + for (const [key, value] of headers) { + response.headers.set(key, value); + } + + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + + return response; +} diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index d9f2476e6bc7..95defad66dc2 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -import { URL } from 'node:url'; +import { fileURLToPath, URL } from 'node:url'; import { AsyncLocalStorage } from 'node:async_hooks'; import colors from 'kleur'; import sirv from 'sirv'; @@ -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,33 @@ export async function dev(vite, vite_config, svelte_config) { return; } + let middleware; + let middleware_result; + if (resolve_entry(hooks.middleware)) { + try { + ({ middleware } = await vite.ssrLoadModule(hooks.middleware)); + } catch (e) { + console.error(e); + } + } + + const { call_middleware } = await vite.ssrLoadModule( + posixify(fileURLToPath(new URL('./call_middleware.js', import.meta.url))), + { + fixStacktrace: true + } + ); + + if (middleware) { + middleware_result = await call_middleware(request, middleware); + if (middleware_result instanceof Response) { + setResponse(res, middleware_result); + return; + } else { + request = middleware_result.request; + } + } + const rendered = await server.respond(request, { getClientAddress: () => { const { remoteAddress } = req.socket; @@ -565,6 +592,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/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/types/index.d.ts b/packages/kit/types/index.d.ts index f164abc09c1d..10b51abab7c0 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 @@ -417,6 +422,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` @@ -2011,7 +2022,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; } From ea1f841c0b210fcb500d599cde3d78dca870840f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 7 Feb 2025 14:47:15 +0100 Subject: [PATCH 02/25] build + preview --- .../exports/vite/build/build_middleware.js | 146 ++++++++++++++++++ packages/kit/src/exports/vite/index.js | 12 +- .../kit/src/exports/vite/preview/index.js | 41 +++++ 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 packages/kit/src/exports/vite/build/build_middleware.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..9bf3cdee28ca --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_middleware.js @@ -0,0 +1,146 @@ +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) + ] + } + }); + + // Preview only: build call_middleware from dev + await vite.build({ + build: { + ssr: true, + modulePreload: false, + rollupOptions: { + input: { + middleware: posixify(fileURLToPath(new URL('../dev/call_middleware.js', import.meta.url))) + }, + output: { + entryFileNames: 'middleware-preview.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/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 481fa087eab7..13a91a6aeb75 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -43,6 +43,13 @@ export async function preview(vite, vite_config, svelte_config) { const { manifest } = await import(pathToFileURL(join(dir, 'manifest.js')).href); + const { middleware } = await import(pathToFileURL(join(dir, 'middleware.js')).href).catch( + () => ({}) + ); + const { call_middleware } = await import( + pathToFileURL(join(dir, 'middleware-preview.js')).href + ).catch(() => ({})); + set_assets(assets); const server = new Server(manifest); @@ -110,6 +117,40 @@ 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) { + setResponse(res, result); + } + + for (const [key, value] of result.request.headers.entries()) { + req.headers[key] = value; + } + + const url = new URL(result.request.url); + req.url = url.pathname + url.search; + + const response = new Response(); + result.add_response_headers(response); + + for (const [key, value] of result.request.headers.entries()) { + res.setHeader(key, value); + } + + next(); + }); + } + // prerendered pages (we can't just use sirv because we need to // preserve the correct trailingSlash behaviour) vite.middlewares.use( From 93d553636998676c4b321deac496148fbdb8c455 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 7 Feb 2025 23:20:09 +0100 Subject: [PATCH 03/25] pass URL separately which is normalized, extract logic --- packages/kit/src/exports/public.d.ts | 11 ++++ .../src/exports/vite/dev/call_middleware.js | 17 ++--- packages/kit/src/runtime/server/respond.js | 62 ++++++++++++------- 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 1b6a4ef925f9..6bfceee4d303 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1506,4 +1506,15 @@ export interface Snapshot { restore: (snapshot: T) => void; } +export interface Middleware { + (options: { + request: Request; + url: URL; + setRequestHeaders: (headers: Record) => void; + setResponseHeaders: (headers: Record) => void; + cookies: Cookies; + reroute: (pathname: string) => unknown; + }): void; +} + export * from './index.js'; diff --git a/packages/kit/src/exports/vite/dev/call_middleware.js b/packages/kit/src/exports/vite/dev/call_middleware.js index fee3cd16ec71..f7784b59a75f 100644 --- a/packages/kit/src/exports/vite/dev/call_middleware.js +++ b/packages/kit/src/exports/vite/dev/call_middleware.js @@ -1,9 +1,6 @@ import { add_cookies_to_headers, get_cookies } from '../../../runtime/server/cookie.js'; -import { - add_resolution_prefix, - has_resolution_prefix, - strip_resolution_prefix -} from '../../../runtime/pathname.js'; +import { add_resolution_prefix } from '../../../runtime/pathname.js'; +import { normalize_url } from '../../../runtime/server/respond.js'; /** * @param {Request} request @@ -51,15 +48,11 @@ export async function call_middleware(request, middleware) { return pathname; // TODO think about making this a class object }; - const url = new URL(request.url); - const is_route_resolution_request = has_resolution_prefix(url.pathname); + const { url, is_route_resolution_request } = normalize_url(new URL(request.url)); const result = await middleware({ - request: is_route_resolution_request - ? new Request(new URL(strip_resolution_prefix(url.pathname), url), { - headers: request_headers - }) - : request, + request, + url, setRequestHeaders, setResponseHeaders, cookies, diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 429d523c3715..ff6021dfa12a 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -87,29 +87,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; @@ -578,3 +557,40 @@ export async function respond(request, options, manifest, state) { } } } + +// TODO find better place for this method +/** + * 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 + }; +} From b5985fef36bb9dd4252ed1ec90e94ecbec728a78 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 8 Feb 2025 00:12:02 +0100 Subject: [PATCH 04/25] reorganize, make call-middleware usable for adapters --- packages/kit/src/exports/public.d.ts | 18 ++++++- .../exports/vite/build/build_middleware.js | 5 +- packages/kit/src/exports/vite/dev/index.js | 6 +-- .../server/call-middleware.js} | 48 +++++++++---------- packages/kit/src/runtime/server/respond.js | 44 +++-------------- packages/kit/src/runtime/server/utils.js | 43 +++++++++++++++++ packages/kit/types/index.d.ts | 27 +++++++++++ 7 files changed, 121 insertions(+), 70 deletions(-) rename packages/kit/src/{exports/vite/dev/call_middleware.js => runtime/server/call-middleware.js} (63%) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 6bfceee4d303..46913278aada 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1514,7 +1514,23 @@ export interface Middleware { setResponseHeaders: (headers: Record) => void; cookies: Cookies; reroute: (pathname: string) => unknown; - }): void; + }): Response | unknown; +} + +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 index 9bf3cdee28ca..d8a823ef299f 100644 --- a/packages/kit/src/exports/vite/build/build_middleware.js +++ b/packages/kit/src/exports/vite/build/build_middleware.js @@ -114,17 +114,16 @@ export async function build_middleware( } }); - // Preview only: build call_middleware from dev await vite.build({ build: { ssr: true, modulePreload: false, rollupOptions: { input: { - middleware: posixify(fileURLToPath(new URL('../dev/call_middleware.js', import.meta.url))) + middleware: `${runtime_directory}/server/call-middleware.js` }, output: { - entryFileNames: 'middleware-preview.js', + entryFileNames: 'call-middleware.js', inlineDynamicImports: true } }, diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 95defad66dc2..03dcceb28269 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -557,10 +557,8 @@ export async function dev(vite, vite_config, svelte_config) { } const { call_middleware } = await vite.ssrLoadModule( - posixify(fileURLToPath(new URL('./call_middleware.js', import.meta.url))), - { - fixStacktrace: true - } + `${runtime_base}/server/call-middleware.js`, + { fixStacktrace: true } ); if (middleware) { diff --git a/packages/kit/src/exports/vite/dev/call_middleware.js b/packages/kit/src/runtime/server/call-middleware.js similarity index 63% rename from packages/kit/src/exports/vite/dev/call_middleware.js rename to packages/kit/src/runtime/server/call-middleware.js index f7784b59a75f..8652d5cb2af4 100644 --- a/packages/kit/src/exports/vite/dev/call_middleware.js +++ b/packages/kit/src/runtime/server/call-middleware.js @@ -1,10 +1,11 @@ -import { add_cookies_to_headers, get_cookies } from '../../../runtime/server/cookie.js'; -import { add_resolution_prefix } from '../../../runtime/pathname.js'; -import { normalize_url } from '../../../runtime/server/respond.js'; +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 {(options: any) => any} middleware + * @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'); @@ -59,37 +60,36 @@ export async function call_middleware(request, middleware) { 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) { + response.headers.set(key, value); + } + }; + if (result instanceof Response) { - return add_response_headers(result, response_headers, new_cookies); + return result; } if (typeof result === 'string' || request_headers_called) { const url = new URL( - result ? (is_route_resolution_request ? add_resolution_prefix(result) : result) : request.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, - /** @param {Response} response */ - add_response_headers: (response) => - add_response_headers(response, response_headers, new_cookies) + request_headers, + did_reroute: typeof result === 'string', + response_headers, + add_response_headers }; } - -/** - * @param {Response} response - * @param {Headers} headers - * @param {Record} new_cookies - */ -function add_response_headers(response, headers, new_cookies) { - for (const [key, value] of headers) { - response.headers.set(key, value); - } - - add_cookies_to_headers(response.headers, Object.values(new_cookies)); - - return response; -} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index ff6021dfa12a..b9f7a95b5d85 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'; @@ -557,40 +562,3 @@ export async function respond(request, options, manifest, state) { } } } - -// TODO find better place for this method -/** - * 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/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/types/index.d.ts b/packages/kit/types/index.d.ts index 10b51abab7c0..6a95f6b8990e 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1487,6 +1487,33 @@ declare module '@sveltejs/kit' { capture: () => T; restore: (snapshot: T) => void; } + + export interface Middleware { + (options: { + request: Request; + url: URL; + setRequestHeaders: (headers: Record) => void; + setResponseHeaders: (headers: Record) => void; + cookies: Cookies; + reroute: (pathname: string) => unknown; + }): Response | unknown; + } + + 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. From 2a1153a67980bb0f5947d39fd07e43ebaca4ce05 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 8 Feb 2025 14:16:44 +0100 Subject: [PATCH 05/25] vercel adapter --- packages/adapter-vercel/files/edge.js | 8 ++ packages/adapter-vercel/files/middleware.js | 32 +++++++ packages/adapter-vercel/files/serverless.js | 6 ++ packages/adapter-vercel/index.js | 100 +++++++++++++++----- packages/adapter-vercel/internal.d.ts | 10 ++ packages/adapter-vercel/package.json | 4 +- pnpm-lock.yaml | 11 +++ 7 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 packages/adapter-vercel/files/middleware.js 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..87fd6077c409 --- /dev/null +++ b/packages/adapter-vercel/files/middleware.js @@ -0,0 +1,32 @@ +import { next, rewrite } from '@vercel/edge'; +import { middleware as user_middleware } from 'MIDDLEWARE'; +import { call_middleware } from 'CALL_MIDDLEWARE'; + +// TODO allow customization? +// TODO base path +export const config = { + // @ts-expect-error will be replaced during build + matcher: `^(?!/${APP_DIR}/immutable).*` +}; + +/** + * @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 8edfa1656d2d..321305dbc868 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -6,6 +6,7 @@ import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; import { get_pathname, 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,74 @@ 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) { + if (!fs.existsSync(`${builder.getServerDirectory()}/middleware.js`)) 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`, + APP_DIR: `'${builder.config.kit.appDir}'` + } + }); + + // @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 + ); + + static_config.routes.push({ + src: '/.*', + middlewarePath: `user-middleware`, + continue: true + }); + } + + generate_edge_middleware(defaults); + /** @type {Map[] }>} */ const groups = new Map(); 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..9c527c7e7d27 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -41,7 +41,9 @@ }, "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" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e918a1764f38..0001111aa346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -265,12 +265,18 @@ 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 devDependencies: '@sveltejs/kit': specifier: workspace:^ @@ -2062,6 +2068,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'} @@ -4516,6 +4525,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 From 9a0129026d22b5bf1b73e975e77f0ff77adb1a03 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 11:34:33 +0100 Subject: [PATCH 06/25] tweak --- packages/adapter-vercel/files/middleware.js | 7 ------- packages/adapter-vercel/index.js | 5 ++--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js index 87fd6077c409..1ae06cdc4cc5 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -2,13 +2,6 @@ import { next, rewrite } from '@vercel/edge'; import { middleware as user_middleware } from 'MIDDLEWARE'; import { call_middleware } from 'CALL_MIDDLEWARE'; -// TODO allow customization? -// TODO base path -export const config = { - // @ts-expect-error will be replaced during build - matcher: `^(?!/${APP_DIR}/immutable).*` -}; - /** * @param {Request} request */ diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 321305dbc868..1c9ae3550840 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -237,8 +237,7 @@ const plugin = function (defaults = {}) { builder.copy(`${files}/middleware.js`, dest, { replace: { MIDDLEWARE: `${relativePath}/middleware.js`, - CALL_MIDDLEWARE: `${relativePath}/call-middleware.js`, - APP_DIR: `'${builder.config.kit.appDir}'` + CALL_MIDDLEWARE: `${relativePath}/call-middleware.js` } }); @@ -259,7 +258,7 @@ const plugin = function (defaults = {}) { ); static_config.routes.push({ - src: '/.*', + src: '/.*', // TODO allow customization? middlewarePath: `user-middleware`, continue: true }); From 484864a63f62e674f4d6034e19b514a7f57b98fa Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 12:41:30 +0100 Subject: [PATCH 07/25] adapter netlify --- packages/adapter-netlify/index.js | 103 +++++++++++++++------ packages/adapter-netlify/package.json | 3 +- packages/adapter-netlify/src/middleware.js | 24 +++++ 3 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 packages/adapter-netlify/src/middleware.js diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 034acd70ab94..5b81c195c8ae 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,14 +141,72 @@ async function generate_edge_functions({ builder }) { } }); - const manifest = builder.generateManifest({ - relativePath + await bundle_edge_function({ builder, name: 'render', excludedPaths: builder.prerendered.paths }); +} + +/** + * @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/${name}.js`, + bundle: true, + format: 'esm', + platform: 'browser', + sourcemap: 'linked', + target: 'es2020', + loader: { + '.wasm': 'copy', + '.woff': 'copy', + '.woff2': 'copy', + '.ttf': 'copy', + '.eot': 'copy', + '.otf': 'copy' + }, + // Node built-ins are allowed, but must be prefixed with `node:` + // https://docs.netlify.com/edge-functions/api/#runtime-environment + external: builtinModules.map((id) => `node:${id}`), + alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) + }); + /** @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 @@ -151,7 +214,7 @@ async function generate_edge_functions({ builder }) { const excludedPath = [ // Contains static files `/${builder.getAppPath()}/*`, - ...builder.prerendered.paths, + ...excludedPaths, ...Array.from(assets).flatMap((asset) => { if (asset.endsWith('/index.html')) { const dir = asset.replace(/\/index\.html$/, ''); @@ -169,8 +232,11 @@ async function generate_edge_functions({ builder }) { /** @type {HandlerManifest} */ const edge_manifest = { functions: [ + ...(existsSync('.netlify/edge-functions/manifest.json') + ? JSON.parse(readFileSync('.netlify/edge-functions/manifest.json', 'utf-8')).functions + : []), { - function: 'render', + function: name, path, excludedPath } @@ -178,30 +244,9 @@ async function generate_edge_functions({ builder }) { version: 1 }; - await esbuild.build({ - entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', - bundle: true, - format: 'esm', - platform: 'browser', - sourcemap: 'linked', - target: 'es2020', - loader: { - '.wasm': 'copy', - '.woff': 'copy', - '.woff2': 'copy', - '.ttf': 'copy', - '.eot': 'copy', - '.otf': 'copy' - }, - // Node built-ins are allowed, but must be prefixed with `node:` - // https://docs.netlify.com/edge-functions/api/#runtime-environment - external: builtinModules.map((id) => `node:${id}`), - alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) - }); - 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/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..829a1b55f761 --- /dev/null +++ b/packages/adapter-netlify/src/middleware.js @@ -0,0 +1,24 @@ +import { middleware as user_middleware } from 'MIDDLEWARE'; +import { call_middleware } from 'CALL_MIDDLEWARE'; + +/** + * @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 (result.did_reroute && !has_additional_headers) { + // Fast path + return new URL(result.request.url); + } else { + const response = await context.next(result.request); + result.add_response_headers(response); + return response; + } +} From df7c138c3dd4eaa41eabddab5ae2f6712c4412d5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 12:52:31 +0100 Subject: [PATCH 08/25] tweak --- packages/adapter-netlify/internal.d.ts | 10 ++++++++++ packages/adapter-netlify/src/middleware.js | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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/src/middleware.js b/packages/adapter-netlify/src/middleware.js index 829a1b55f761..696191cfd515 100644 --- a/packages/adapter-netlify/src/middleware.js +++ b/packages/adapter-netlify/src/middleware.js @@ -13,9 +13,11 @@ export default async function middleware(request, context) { const has_additional_headers = [...result.request_headers.keys()].length > 0 || [...result.response_headers.keys()].length > 0; - if (result.did_reroute && !has_additional_headers) { + if (!has_additional_headers) { // Fast path - return new URL(result.request.url); + if (result.did_reroute) { + return new URL(result.request.url); + } } else { const response = await context.next(result.request); result.add_response_headers(response); From e9ab04907e7d8434be715c70709fcc23ee558860 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 21:02:48 +0100 Subject: [PATCH 09/25] adapter cloudflare --- packages/adapter-cloudflare-workers/index.js | 8 ++++ packages/adapter-cloudflare/index.js | 38 +++++++++++++++++-- packages/adapter-cloudflare/internal.d.ts | 12 +++++- packages/adapter-cloudflare/package.json | 2 +- .../adapter-cloudflare/src/noop-middleware.js | 12 ++++++ packages/adapter-cloudflare/src/worker.js | 15 ++++++++ packages/adapter-cloudflare/tsconfig.json | 2 +- 7 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 packages/adapter-cloudflare/src/noop-middleware.js 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..4ef3017d2e71 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/*"] } From a2aecbdd4cc59a55cfd6b5d1e64a4af7838f1d50 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 21:02:54 +0100 Subject: [PATCH 10/25] lockfile --- pnpm-lock.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0001111aa346..7771039ce2ec 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 @@ -1768,6 +1771,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'} @@ -4232,6 +4238,8 @@ snapshots: - encoding - supports-color + '@netlify/edge-functions@2.11.1': {} + '@netlify/functions@3.0.0': dependencies: '@netlify/serverless-functions-api': 1.30.1 From e66a16118941503627f30e42e5846dfbf0f1b342 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 21:49:52 +0100 Subject: [PATCH 11/25] adapter-node --- packages/adapter-node/index.js | 22 ++++++++-- packages/adapter-node/internal.d.ts | 11 +++++ packages/adapter-node/rollup.config.js | 2 +- packages/adapter-node/src/handler.js | 43 +++++++++++++++++++- packages/adapter-node/src/noop-middleware.js | 12 ++++++ 5 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 packages/adapter-node/src/noop-middleware.js 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 966956ff2cb7..ecfd54a2b2e5 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 */ @@ -74,6 +76,44 @@ function serve(path, client = false) { ); } +/** @type {import('polka').Middleware} */ +const middleware = async (req, res, 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) { + 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; + } + + for (const [key, value] of result.response_headers) { + res.setHeader(key, value); + } + + next(); + } +}; + // required because the static file server ignores trailing slashes /** @returns {import('polka').Middleware} */ function serve_prerendered() { @@ -208,6 +248,7 @@ export const handler = sequence( [ serve(path.join(dir, 'client'), true), serve(path.join(dir, 'static')), + has_middleware && middleware, 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: () => {} + }; +} From d15a0133347de4e0fb0ab17848ad97bb1077bab6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 23:28:00 +0100 Subject: [PATCH 12/25] docs --- documentation/docs/30-advanced/20-hooks.md | 47 ++++++++++++++++++++++ packages/kit/src/exports/public.d.ts | 42 +++++++++++++++---- packages/kit/types/index.d.ts | 42 +++++++++++++++---- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index b35e66b73a09..e42bfc765574 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -316,6 +316,53 @@ 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. 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. + +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/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 46913278aada..ec99d2d9bd77 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1507,16 +1507,42 @@ export interface Snapshot { } export interface Middleware { - (options: { - request: Request; - url: URL; - setRequestHeaders: (headers: Record) => void; - setResponseHeaders: (headers: Record) => void; - cookies: Cookies; - reroute: (pathname: string) => unknown; - }): Response | unknown; + (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, diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6a95f6b8990e..9265ee0bfd66 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1489,16 +1489,42 @@ declare module '@sveltejs/kit' { } export interface Middleware { - (options: { - request: Request; - url: URL; - setRequestHeaders: (headers: Record) => void; - setResponseHeaders: (headers: Record) => void; - cookies: Cookies; - reroute: (pathname: string) => unknown; - }): Response | unknown; + (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, From dfd38292c72a57480c55ce74715acdab35aea7e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 10 Feb 2025 23:31:59 +0100 Subject: [PATCH 13/25] only check for middleware support if it's actually used --- packages/kit/src/core/config/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index ab5d536acf8c..07e3435cc477 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -4,6 +4,7 @@ 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 @@ -120,6 +121,7 @@ export function validate_config(config) { ); } + /** @type {import('types').ValidatedConfig} */ const validated = options(config, 'config'); if (validated.kit.router.resolution === 'server') { @@ -135,7 +137,9 @@ export function validate_config(config) { } } - check_middleware_feature(validated.kit.adapter); + if (resolve_entry(validated.kit.files.hooks.middleware)) { + check_middleware_feature(validated.kit.adapter); + } return validated; } From 4b1824d7be8127aa0a108a4cbca2516fafb23c5e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Feb 2025 10:29:13 +0100 Subject: [PATCH 14/25] lint/fix --- packages/adapter-node/src/handler.js | 4 ++-- packages/adapter-vercel/index.js | 4 ++-- .../src/exports/vite/build/build_server.js | 23 +++++++++++++++---- packages/kit/src/exports/vite/dev/index.js | 4 ++-- .../kit/src/exports/vite/preview/index.js | 3 ++- .../kit/src/runtime/server/call-middleware.js | 4 ++-- packages/kit/src/runtime/server/respond.js | 7 ------ 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 174428dbd459..655947c1600f 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -96,7 +96,7 @@ const middleware = async (req, res, next) => { const result = await call_middleware(request, user_middleware); if (result instanceof Response) { - setResponse(res, result); + await setResponse(res, result); } else { if (result.did_reroute) { req.url = new URL(result.request.url).pathname; @@ -110,7 +110,7 @@ const middleware = async (req, res, next) => { res.setHeader(key, value); } - next(); + void next(); } }; diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 1c9ae3550840..9c391e83d1b5 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -259,12 +259,12 @@ const plugin = function (defaults = {}) { static_config.routes.push({ src: '/.*', // TODO allow customization? - middlewarePath: `user-middleware`, + middlewarePath: 'user-middleware', continue: true }); } - generate_edge_middleware(defaults); + await generate_edge_middleware(defaults); /** @type {Map[] }>} */ const groups = new Map(); 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 4a76dbb346f8..1d06efc795c1 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -import { fileURLToPath, URL } from 'node:url'; +import { URL } from 'node:url'; import { AsyncLocalStorage } from 'node:async_hooks'; import colors from 'kleur'; import sirv from 'sirv'; @@ -564,7 +564,7 @@ export async function dev(vite, vite_config, svelte_config) { if (middleware) { middleware_result = await call_middleware(request, middleware); if (middleware_result instanceof Response) { - setResponse(res, middleware_result); + void setResponse(res, middleware_result); return; } else { request = middleware_result.request; diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index b9416b1bcd05..5494784ed5db 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -130,7 +130,8 @@ export async function preview(vite, vite_config, svelte_config) { const result = await call_middleware(request, middleware); if (result instanceof Response) { - setResponse(res, result); + await setResponse(res, result); + return; } for (const [key, value] of result.request.headers.entries()) { diff --git a/packages/kit/src/runtime/server/call-middleware.js b/packages/kit/src/runtime/server/call-middleware.js index 8652d5cb2af4..24250fc8cdb2 100644 --- a/packages/kit/src/runtime/server/call-middleware.js +++ b/packages/kit/src/runtime/server/call-middleware.js @@ -11,7 +11,7 @@ export async function call_middleware(request, middleware) { const { cookies, new_cookies } = get_cookies(request, new URL(request.url), 'never'); let request_headers_called = false; - let request_headers = new Headers(request.headers); + const request_headers = new Headers(request.headers); /** @param {Record} headers */ const setRequestHeaders = (headers) => { for (const key in headers) { @@ -27,7 +27,7 @@ export async function call_middleware(request, middleware) { } }; - let response_headers = new Headers(); + const response_headers = new Headers(); /** @param {Record} headers */ const setResponseHeaders = (headers) => { for (const key in headers) { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index b9f7a95b5d85..7405454541aa 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -27,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__ */ From 000f022e38eaabb667536361cc8a76340f8c8897 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Feb 2025 18:17:15 +0100 Subject: [PATCH 15/25] matcher support for vercel --- .../25-build-and-deploy/90-adapter-vercel.md | 25 +++++++++ packages/adapter-vercel/index.js | 39 ++++++++++--- packages/adapter-vercel/package.json | 3 +- packages/adapter-vercel/utils.js | 56 +++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) 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..571b68d1e47e 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,31 @@ 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. + +```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/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 9c391e83d1b5..96b667c56d4b 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,10 +1,10 @@ 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'; @@ -229,7 +229,9 @@ const plugin = function (defaults = {}) { * @param {import('./index.js').Config} config */ async function generate_edge_middleware(config) { - if (!fs.existsSync(`${builder.getServerDirectory()}/middleware.js`)) return; + 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()); @@ -257,11 +259,32 @@ const plugin = function (defaults = {}) { config ); - static_config.routes.push({ - src: '/.*', // TODO allow customization? - middlewarePath: 'user-middleware', - continue: true - }); + 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); diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index 9c527c7e7d27..6c2cc2cbbc81 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -43,7 +43,8 @@ "@vercel/nft": "^0.29.0", "@vercel/edge": "^1.2.1", "esbuild": "^0.24.0", - "import-meta-resolve": "^4.1.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; +} From f51ca7f2971f088887f5b1a869f09cdd830f5ba8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Feb 2025 21:55:45 +0100 Subject: [PATCH 16/25] allow middleware to influence when it matches in dev --- .../25-build-and-deploy/90-adapter-vercel.md | 2 ++ documentation/docs/30-advanced/20-hooks.md | 8 +++++--- packages/adapter-vercel/index.js | 9 +++++++++ packages/kit/src/exports/public.d.ts | 9 +++++++++ packages/kit/src/exports/vite/dev/index.js | 19 ++++++++++++------- packages/kit/types/index.d.ts | 9 +++++++++ 6 files changed, 46 insertions(+), 10 deletions(-) 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 571b68d1e47e..c182498c9337 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -147,6 +147,8 @@ You can use SvelteKit's [middleware feature](hooks#middleware) with Vercel. It w 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 = { diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index e42bfc765574..14ce8230d272 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -318,18 +318,18 @@ export const transport = { ## Middleware hooks -The following can be added to `src/hooks.middleware.js`. +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. This is useful when +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 +/// file: src/hooks/middleware.js /** @param {import('@sveltejs/kit').MiddlewareEvent} options */ export async function middleware({ url, setRequestHeaders, setResponseHeaders, cookies, reroute }) { if (url.pathname === '/custom') { @@ -362,6 +362,8 @@ If you have no prerendered pages, i.e. every request hits the SvelteKit runtime, 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. + 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-vercel/index.js b/packages/adapter-vercel/index.js index 96b667c56d4b..ec33d7be8e11 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -495,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 }) => { diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ec99d2d9bd77..dc42f5d837c2 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -280,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 { diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 1d06efc795c1..111d64e1ed91 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -548,21 +548,26 @@ export async function dev(vite, vite_config, svelte_config) { let middleware; let middleware_result; + if (resolve_entry(hooks.middleware)) { try { - ({ middleware } = await vite.ssrLoadModule(hooks.middleware)); + middleware = await vite.ssrLoadModule(hooks.middleware); } catch (e) { console.error(e); } } - const { call_middleware } = await vite.ssrLoadModule( - `${runtime_base}/server/call-middleware.js`, - { fixStacktrace: true } - ); + if ( + req.url && + middleware && + (emulator?.shouldRunMiddleware?.(req.url, middleware, svelte_config.kit) ?? true) + ) { + const { call_middleware } = await vite.ssrLoadModule( + `${runtime_base}/server/call-middleware.js`, + { fixStacktrace: true } + ); + middleware_result = await call_middleware(request, middleware.middleware); - if (middleware) { - middleware_result = await call_middleware(request, middleware); if (middleware_result instanceof Response) { void setResponse(res, middleware_result); return; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 9265ee0bfd66..d6b303aa46e5 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -262,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 { From f6a5a3a5e134025fddde6b29492fc48b3982e7e2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 11:52:29 +0100 Subject: [PATCH 17/25] make sure adapter node middleware runs for prerendered/static files, too --- packages/adapter-node/src/handler.js | 111 ++++++++++++++++----------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 655947c1600f..6909bdc0c915 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -70,6 +70,8 @@ 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); } }) }) @@ -78,6 +80,12 @@ function serve(path, client = false) { /** @type {import('polka').Middleware} */ const middleware = async (req, res, next) => { + let { pathname } = polka_url_parser(req); + + if (pathname.startsWith(`/${manifest.appPath}/immutable/`)) { + return next(); + } + /** @type {Request} */ let request; @@ -106,14 +114,22 @@ const middleware = async (req, res, next) => { req.headers[key] = value; } - for (const [key, value] of result.response_headers) { - res.setHeader(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() { @@ -136,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(); @@ -160,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 */ @@ -246,9 +270,8 @@ function get_origin(headers) { export const handler = sequence( [ - serve(path.join(dir, 'client'), true), - serve(path.join(dir, 'static')), has_middleware && middleware, + serve(path.join(dir, 'client'), true), serve_prerendered(), ssr ].filter(Boolean) From 6316e61bdc994baaf399f692c16bb2c377f6270f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 12:23:05 +0100 Subject: [PATCH 18/25] run on static assets for cloudflare/netlify --- packages/adapter-cloudflare/index.js | 2 +- packages/adapter-netlify/index.js | 33 ++++++++++++++++------------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 4ef3017d2e71..083179225ac9 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -139,7 +139,7 @@ function get_routes_json( builder, assets, exclude_prerendered, - { include = ['/*'], exclude = 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-netlify/index.js b/packages/adapter-netlify/index.js index 5b81c195c8ae..4ab878d5adc3 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -141,7 +141,24 @@ async function generate_edge_functions({ builder }) { } }); - await bundle_edge_function({ builder, name: 'render', excludedPaths: builder.prerendered.paths }); + /** @type {{ assets: Set }} */ + const { assets } = (await import(pathToFileURL(`${tmp}/manifest.js`).href)).manifest; + + const excludedPaths = [ + ...builder.prerendered.paths, + ...Array.from(assets).flatMap((asset) => { + if (asset.endsWith('/index.html')) { + const dir = asset.replace(/\/index\.html$/, ''); + return [ + `${builder.config.kit.paths.base}/${asset}`, + `${builder.config.kit.paths.base}/${dir}` + ]; + } + return `${builder.config.kit.paths.base}/${asset}`; + }) + ]; + + await bundle_edge_function({ builder, name: 'render', excludedPaths }); } /** @@ -205,9 +222,6 @@ async function bundle_edge_function({ builder, name, excludedPaths = [] }) { alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) }); - /** @type {{ assets: Set }} */ - 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 @@ -215,16 +229,7 @@ async function bundle_edge_function({ builder, name, excludedPaths = [] }) { // Contains static files `/${builder.getAppPath()}/*`, ...excludedPaths, - ...Array.from(assets).flatMap((asset) => { - if (asset.endsWith('/index.html')) { - const dir = asset.replace(/\/index\.html$/, ''); - return [ - `${builder.config.kit.paths.base}/${asset}`, - `${builder.config.kit.paths.base}/${dir}` - ]; - } - return `${builder.config.kit.paths.base}/${asset}`; - }), + // Should not be served by SvelteKit at all '/.netlify/*' ]; From 63c1b7d509e863168037d051d5f7b3e24f3add61 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 12:23:14 +0100 Subject: [PATCH 19/25] doc tweaks --- documentation/docs/25-build-and-deploy/40-adapter-node.md | 4 ++++ .../docs/25-build-and-deploy/60-adapter-cloudflare.md | 4 ++++ documentation/docs/25-build-and-deploy/80-adapter-netlify.md | 4 ++++ documentation/docs/25-build-and-deploy/90-adapter-vercel.md | 2 +- documentation/docs/30-advanced/20-hooks.md | 2 ++ 5 files changed, 15 insertions(+), 1 deletion(-) 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 c182498c9337..10316bf28b4d 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -143,7 +143,7 @@ A list of valid query parameters that contribute to the cache key. Other paramet ## 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. +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. diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 14ce8230d272..f68d0fb2cc26 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -364,6 +364,8 @@ When using middleware to reroute based on cookies or headers, you probably want > [!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 From 47b6bc64827678da73fcb7d68386897a8aafbee6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 16:58:37 +0100 Subject: [PATCH 20/25] fix --- packages/adapter-netlify/src/middleware.js | 2 ++ pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js index 696191cfd515..e413fd41d7cf 100644 --- a/packages/adapter-netlify/src/middleware.js +++ b/packages/adapter-netlify/src/middleware.js @@ -1,6 +1,8 @@ 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b1e786894d6..178a591df02e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: 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:^ From 39861c65feb47b325e33f9802848e0d7a9c95b86 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 21:29:17 +0100 Subject: [PATCH 21/25] fix --- .../kit/src/exports/vite/preview/index.js | 49 +++++++++++-------- .../kit/src/runtime/server/call-middleware.js | 6 ++- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 5494784ed5db..3f9a8ffae1bd 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -47,7 +47,7 @@ export async function preview(vite, vite_config, svelte_config) { () => ({}) ); const { call_middleware } = await import( - pathToFileURL(join(dir, 'middleware-preview.js')).href + pathToFileURL(join(dir, 'call-middleware.js')).href ).catch(() => ({})); set_assets(assets); @@ -141,11 +141,16 @@ export async function preview(vite, vite_config, svelte_config) { const url = new URL(result.request.url); req.url = url.pathname + url.search; - const response = new Response(); - result.add_response_headers(response); + 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; for (const [key, value] of result.request.headers.entries()) { - res.setHeader(key, value); + req.headers[key] = value; } next(); @@ -234,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?.(res); + + await setResponse(res, response); }); }; } diff --git a/packages/kit/src/runtime/server/call-middleware.js b/packages/kit/src/runtime/server/call-middleware.js index 24250fc8cdb2..2d62574b1123 100644 --- a/packages/kit/src/runtime/server/call-middleware.js +++ b/packages/kit/src/runtime/server/call-middleware.js @@ -64,7 +64,11 @@ export async function call_middleware(request, middleware) { const add_response_headers = /** @param {Response} response */ (response) => { for (const [key, value] of response_headers) { - response.headers.set(key, value); + if (key.toLowerCase() === 'set-cookie') { + response.headers.append('set-cookie', value); + } else { + response.headers.set(key, value); + } } }; From f1163d8d455a462b1063e6f03b407b5aee242190 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 21:29:44 +0100 Subject: [PATCH 22/25] tests --- .../test/apps/basics/src/hooks.middleware.js | 19 +++++++++++ .../kit/test/apps/basics/src/hooks.server.js | 12 +++++++ .../basics/src/routes/middleware/+page.svelte | 3 ++ .../routes/middleware/headers/+page.svelte | 1 + .../routes/middleware/reroute/b/+page.svelte | 1 + .../kit/test/apps/basics/svelte.config.js | 3 +- packages/kit/test/apps/basics/test/test.js | 34 +++++++++++++++++++ 7 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/kit/test/apps/basics/src/hooks.middleware.js create mode 100644 packages/kit/test/apps/basics/src/routes/middleware/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/middleware/headers/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/middleware/reroute/b/+page.svelte 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..2d6bdb7739d3 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -14,7 +14,8 @@ const config = { }; }, supports: { - read: () => true + read: () => true, + middleware: () => true } }, 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'); + }); +}); From 18e6230ff16f5a6dc0ec8447dde426e6c81da38f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Feb 2025 22:38:15 +0100 Subject: [PATCH 23/25] fixes --- packages/adapter-node/src/handler.js | 2 +- packages/adapter-vercel/index.js | 2 +- packages/kit/src/exports/vite/dev/index.js | 15 ++++++++++----- packages/kit/src/exports/vite/preview/index.js | 16 ++++++++-------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 6909bdc0c915..90bb85da1090 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -80,7 +80,7 @@ function serve(path, client = false) { /** @type {import('polka').Middleware} */ const middleware = async (req, res, next) => { - let { pathname } = polka_url_parser(req); + const { pathname } = polka_url_parser(req); if (pathname.startsWith(`/${manifest.appPath}/immutable/`)) { return next(); diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index ec33d7be8e11..278a36e6460a 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -270,7 +270,7 @@ const plugin = function (defaults = {}) { 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.` + 'Failed to import middleware hook. Make sure it is loadable during build, which is necessary to analyze the config object.' ); throw e; } diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 111d64e1ed91..68765c9258e9 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -546,12 +546,15 @@ 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 = await vite.ssrLoadModule(hooks.middleware); + middleware = /** @type {{ middleware: import('@sveltejs/kit').Middleware }} */ ( + await vite.ssrLoadModule(hooks.middleware) + ); } catch (e) { console.error(e); } @@ -562,10 +565,12 @@ export async function dev(vite, vite_config, svelte_config) { middleware && (emulator?.shouldRunMiddleware?.(req.url, middleware, svelte_config.kit) ?? true) ) { - const { call_middleware } = await vite.ssrLoadModule( - `${runtime_base}/server/call-middleware.js`, - { fixStacktrace: 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) { diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 3f9a8ffae1bd..85ecd30c37b9 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -43,9 +43,11 @@ 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(() => ({})); @@ -134,12 +136,14 @@ export async function preview(vite, vite_config, svelte_config) { return; } - for (const [key, value] of result.request.headers.entries()) { + for (const [key, value] of result.request_headers.entries()) { req.headers[key] = value; } - const url = new URL(result.request.url); - req.url = url.pathname + url.search; + 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); @@ -149,10 +153,6 @@ export async function preview(vite, vite_config, svelte_config) { // the response overrides some of them. res.__set_response_headers = result.set_response_headers; - for (const [key, value] of result.request.headers.entries()) { - req.headers[key] = value; - } - next(); }); } @@ -256,7 +256,7 @@ export async function preview(vite, vite_config, svelte_config) { }); // @ts-expect-error - res.__set_response_headers?.(res); + res.__set_response_headers?.(response); await setResponse(res, response); }); From 1e10353e72f297afe04fd88776390609a85a4ed1 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Feb 2025 03:39:06 -0700 Subject: [PATCH 24/25] chore: Add experimental flag to middleware (#13458) --- packages/kit/src/core/config/index.js | 3 +++ packages/kit/src/core/config/options.js | 4 +++ packages/kit/src/exports/public.d.ts | 11 ++++++++ .../kit/test/apps/basics/svelte.config.js | 4 +++ .../experimental-middleware/jsconfig.json | 19 ++++++++++++++ .../apps/experimental-middleware/package.json | 23 +++++++++++++++++ .../apps/experimental-middleware/src/app.d.ts | 13 ++++++++++ .../apps/experimental-middleware/src/app.html | 12 +++++++++ .../src/hooks.middleware.js | 1 + .../experimental-middleware/src/lib/index.js | 1 + .../src/routes/+page.svelte | 2 ++ .../static/favicon.png | Bin 0 -> 1571 bytes .../experimental-middleware/svelte.config.js | 13 ++++++++++ .../experimental-middleware/vite.config.js | 6 +++++ .../test/build-errors/experimental.spec.js | 18 +++++++++++++ packages/kit/types/index.d.ts | 11 ++++++++ pnpm-lock.yaml | 24 ++++++++++++++++++ 17 files changed, 165 insertions(+) create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/package.json create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/app.html create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/svelte.config.js create mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/vite.config.js create mode 100644 packages/kit/test/build-errors/experimental.spec.js diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 07e3435cc477..20fbc771d86c 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -138,6 +138,9 @@ 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); } diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 2a861c0a6a6d..92fa66c4c9eb 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -120,6 +120,10 @@ const options = object( privatePrefix: string('') }), + experimental: object({ + middleware: boolean(false), + }), + files: object({ assets: string('static'), hooks: object({ diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index dc42f5d837c2..e521f81c5065 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -423,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. */ diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 2d6bdb7739d3..a27c8162afad 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -19,6 +19,10 @@ const config = { } }, + experimental: { + middleware: true + }, + prerender: { entries: [ '*', diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json b/packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json new file mode 100644 index 000000000000..0b2d8865f4ef --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/package.json b/packages/kit/test/build-errors/apps/experimental-middleware/package.json new file mode 100644 index 000000000000..dba51cc0bd58 --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/package.json @@ -0,0 +1,23 @@ +{ + "name": "experimental-middleware", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts new file mode 100644 index 000000000000..da08e6da592d --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html new file mode 100644 index 000000000000..77a5ff52c923 --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js b/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js new file mode 100644 index 000000000000..51f94f93a37a --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js @@ -0,0 +1 @@ +export function middleware() {} \ No newline at end of file diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js b/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js new file mode 100644 index 000000000000..856f2b6c38ae --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte b/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte new file mode 100644 index 000000000000..cc88df0ea352 --- /dev/null +++ b/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png b/packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 GIT binary patch literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH { + 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 d6b303aa46e5..3513c4553944 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -405,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. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 178a591df02e..e8e1c62ae0bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,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': From af2eed348051de4856b01209a5ac94045a68040b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 13 Feb 2025 11:52:10 +0100 Subject: [PATCH 25/25] make it a unit test instead --- packages/kit/src/core/config/index.spec.js | 30 ++++++++++++++++-- .../experimental-middleware/jsconfig.json | 19 ----------- .../apps/experimental-middleware/package.json | 23 -------------- .../apps/experimental-middleware/src/app.d.ts | 13 -------- .../apps/experimental-middleware/src/app.html | 12 ------- .../src/hooks.middleware.js | 1 - .../experimental-middleware/src/lib/index.js | 1 - .../src/routes/+page.svelte | 2 -- .../static/favicon.png | Bin 1571 -> 0 bytes .../experimental-middleware/svelte.config.js | 13 -------- .../experimental-middleware/vite.config.js | 6 ---- 11 files changed, 28 insertions(+), 92 deletions(-) delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/package.json delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/app.html delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/svelte.config.js delete mode 100644 packages/kit/test/build-errors/apps/experimental-middleware/vite.config.js diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index f6993317fb31..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,6 +77,9 @@ const get_defaults = (prefix = '') => ({ publicPrefix: 'PUBLIC_', privatePrefix: '' }, + experimental: { + middleware: false + }, files: { assets: join(prefix, 'static'), hooks: { @@ -300,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/test/build-errors/apps/experimental-middleware/jsconfig.json b/packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json deleted file mode 100644 index 0b2d8865f4ef..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/jsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/package.json b/packages/kit/test/build-errors/apps/experimental-middleware/package.json deleted file mode 100644 index dba51cc0bd58..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "experimental-middleware", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^4.0.0", - "@sveltejs/kit": "^2.16.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "typescript": "^5.0.0", - "vite": "^6.0.0" - } -} diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts deleted file mode 100644 index da08e6da592d..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } -} - -export {}; diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html b/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html deleted file mode 100644 index 77a5ff52c923..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/src/app.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js b/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js deleted file mode 100644 index 51f94f93a37a..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/src/hooks.middleware.js +++ /dev/null @@ -1 +0,0 @@ -export function middleware() {} \ No newline at end of file diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js b/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js deleted file mode 100644 index 856f2b6c38ae..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/src/lib/index.js +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte b/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte deleted file mode 100644 index cc88df0ea352..000000000000 --- a/packages/kit/test/build-errors/apps/experimental-middleware/src/routes/+page.svelte +++ /dev/null @@ -1,2 +0,0 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

diff --git a/packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png b/packages/kit/test/build-errors/apps/experimental-middleware/static/favicon.png deleted file mode 100644 index 825b9e65af7c104cfb07089bb28659393b4f2097..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH