From 8269fd8550421013f26b09a37fb72258a1be8976 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 14 Feb 2025 16:59:17 +0100 Subject: [PATCH 01/45] provide emulate hook to intercept request early on --- packages/kit/src/core/postbuild/prerender.js | 2 +- packages/kit/src/exports/public.d.ts | 15 ++++++- packages/kit/src/exports/vite/dev/index.js | 45 ++++++++++++++++--- .../kit/src/exports/vite/preview/index.js | 15 ++++++- packages/kit/types/index.d.ts | 15 ++++++- 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 7c84269e3306..bb2da7f452dd 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -117,7 +117,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { return { prerendered, prerender_map }; } - const emulator = await config.adapter?.emulate?.(); + const emulator = await config.adapter?.emulate?.({ importFile: (file) => import(file) }); /** @type {import('types').Logger} */ const log = logger({ verbose }); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f25cc225e194..121d89063773 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -19,6 +19,7 @@ import { } from '../types/private.js'; import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; import type { PluginOptions } from '@sveltejs/vite-plugin-svelte'; +import type { IncomingMessage, ServerResponse } from 'node:http'; export { PrerenderOption } from '../types/private.js'; @@ -49,7 +50,7 @@ export interface Adapter { * Creates an `Emulator`, which allows the adapter to influence the environment * during dev, build and prerendering */ - emulate?: () => MaybePromise; + emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; } export type LoadProperties | void> = input extends void @@ -275,6 +276,18 @@ export interface Emulator { * and returns an `App.Platform` object */ platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; + /** + * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. + * Can be used to replicate middleware behavior in dev mode. + * Implementation notes: + * - `req.url` does not include the base path, but `req.originalUrl` does, and you will have to adjust both in case you want to proxy/rewrite requests. + * - you either have to call `next()` to pass on the request/response, or `res.end()` to finish the request + */ + beforeRequest?: ( + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + next: () => void + ) => 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 39f4ef41e0cd..0b41e5ade241 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -420,7 +420,15 @@ export async function dev(vite, vite_config, svelte_config) { return ws_send.apply(vite.ws, args); }; - vite.middlewares.use((req, res, next) => { + const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''); + const emulator = await svelte_config.kit.adapter?.emulate?.({ + importFile: (file) => vite.ssrLoadModule(file) + }); + + /** + * @param {import('node:http').IncomingMessage} req + */ + function get_asset_uri(req) { const base = `${vite.config.server.https ? 'https' : 'http'}://${ req.headers[':authority'] || req.headers.host }`; @@ -433,18 +441,41 @@ export async function dev(vite, vite_config, svelte_config) { if (fs.existsSync(file) && !fs.statSync(file).isDirectory()) { if (has_correct_case(file, svelte_config.kit.files.assets)) { - req.url = encodeURI(pathname); // don't need query/hash - asset_server(req, res); - return; + return encodeURI(pathname); // don't need query/hash } } } + } - next(); + // adapter-provided middleware + vite.middlewares.use(async (req, res, next) => { + if (!emulator?.beforeRequest) return next(); + if (req.url?.startsWith('/@fs/') || req.url?.includes('virtual:')) return next(); + + const base = `${vite.config.server.https ? 'https' : 'http'}://${ + req.headers[':authority'] || req.headers.host + }`; + const decoded = decodeURI(new URL(base + req.url).pathname); + const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1))); + const is_file = fs.existsSync(file) && !fs.statSync(file).isDirectory(); + const is_static_asset = !!get_asset_uri(req); + + if (is_file && !is_static_asset) { + return next(); + } + + return emulator.beforeRequest(req, res, next); }); - const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''); - const emulator = await svelte_config.kit.adapter?.emulate?.(); + vite.middlewares.use((req, res, next) => { + const asset_uri = get_asset_uri(req); + if (asset_uri) { + req.url = asset_uri; + return asset_server(req, res); + } + + next(); + }); return () => { const serve_static_middleware = vite.middlewares.stack.find( diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 0342e718c75c..04e754ac9425 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -51,7 +51,9 @@ export async function preview(vite, vite_config, svelte_config) { read: (file) => createReadableStream(`${dir}/${file}`) }); - const emulator = await svelte_config.kit.adapter?.emulate?.(); + const emulator = await svelte_config.kit.adapter?.emulate?.({ + importFile: (file) => import(file) + }); return () => { // Remove the base middleware. It screws with the URL. @@ -66,6 +68,17 @@ export async function preview(vite, vite_config, svelte_config) { } } + // adapter-provided middleware + vite.middlewares.use(async (req, res, next) => { + if (!emulator?.beforeRequest) return next(); + + const { pathname } = new URL(/** @type {string} */ (req.url), 'http://dummy'); + + if (pathname.startsWith(`/${svelte_config.kit.appDir}/immutable`)) return next(); + + return emulator.beforeRequest(req, res, next); + }); + // generated client assets and the contents of `static` vite.middlewares.use( scoped( diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f164abc09c1d..cc1297db183b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -4,6 +4,7 @@ declare module '@sveltejs/kit' { import type { CompileOptions } from 'svelte/compiler'; import type { PluginOptions } from '@sveltejs/vite-plugin-svelte'; + import type { IncomingMessage, ServerResponse } from 'node:http'; /** * [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing. */ @@ -31,7 +32,7 @@ declare module '@sveltejs/kit' { * Creates an `Emulator`, which allows the adapter to influence the environment * during dev, build and prerendering */ - emulate?: () => MaybePromise; + emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; } export type LoadProperties | void> = input extends void @@ -257,6 +258,18 @@ declare module '@sveltejs/kit' { * and returns an `App.Platform` object */ platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; + /** + * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. + * Can be used to replicate middleware behavior in dev mode. + * Implementation notes: + * - `req.url` does not include the base path, but `req.originalUrl` does, and you will have to adjust both in case you want to proxy/rewrite requests. + * - you either have to call `next()` to pass on the request/response, or `res.end()` to finish the request + */ + beforeRequest?: ( + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + next: () => void + ) => MaybePromise; } export interface KitConfig { From 9d0beac8542f3911638fa889c7d759b74f49864b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 15 Feb 2025 15:52:12 +0100 Subject: [PATCH 02/45] vercel WIP --- packages/adapter-vercel/files/edge.js | 8 ++ packages/adapter-vercel/files/serverless.js | 6 ++ packages/adapter-vercel/index.js | 73 +++++++++++++- packages/adapter-vercel/middleware.d.ts | 47 +++++++++ packages/adapter-vercel/middleware.js | 104 ++++++++++++++++++++ packages/adapter-vercel/package.json | 8 +- packages/adapter-vercel/tsconfig.json | 1 - packages/adapter-vercel/utils.js | 58 +++++++++++ pnpm-lock.yaml | 11 +++ 9 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 packages/adapter-vercel/middleware.d.ts create mode 100644 packages/adapter-vercel/middleware.js diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js index 1098fbf31379..93914eff345e 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('REWRITE_HEADER'); + 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/serverless.js b/packages/adapter-vercel/files/serverless.js index a8f774be9424..2286b4ffe338 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['REWRITE_HEADER']); + if (pathname) { + req.url = pathname; + delete req.headers['REWRITE_HEADER']; + } } } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index d87f6946ed79..c9f8cfa7bc63 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, REWRITE_HEADER } from './utils.js'; import { VERSION } from '@sveltejs/kit'; const name = '@sveltejs/adapter-vercel'; @@ -95,7 +95,8 @@ const plugin = function (defaults = {}) { builder.copy(`${files}/serverless.js`, `${tmp}/index.js`, { replace: { SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' + MANIFEST: './manifest.js', + REWRITE_HEADER: REWRITE_HEADER } }); @@ -124,7 +125,8 @@ const plugin = function (defaults = {}) { builder.copy(`${files}/edge.js`, `${tmp}/edge.js`, { replace: { SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' + MANIFEST: './manifest.js', + REWRITE_HEADER: REWRITE_HEADER } }); @@ -419,6 +421,69 @@ const plugin = function (defaults = {}) { write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); }, + emulate: async (opts) => { + const middleware_path = process.cwd() + '/middleware.js'; + if (!fs.existsSync(middleware_path)) return {}; + + return { + beforeRequest: async (req, res, next) => { + // We have to import this here or else we wouldn't notice when the middleware file changes + const middleware = await opts.importFile(pathToFileURL(middleware_path).href); + const matcher = new RegExp(get_regex_from_matchers(middleware.config?.matcher)); + const original_url = req.originalUrl || '/'; + + if (matcher.test(original_url)) { + // TODO copied from exports/node/index.js, expose it? + let headers = /** @type {Record} */ (req.headers); + if (req.httpVersionMajor >= 2) { + // the Request constructor rejects headers with ':' in the name + headers = Object.assign({}, headers); + // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.3.1-2.3.5 + if (headers[':authority']) { + headers.host = headers[':authority']; + } + delete headers[':authority']; + delete headers[':method']; + delete headers[':path']; + delete headers[':scheme']; + } + + // We omit the body here because it would consume the stream + const request = new Request(new URL(original_url, 'https://localhost'), { + headers: new Headers(Object.entries(headers)), + method: req.method, + body: + req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] + ? undefined + : 'Cannot read body in dev mode' + }); + + const response = await middleware.default(request, { waitUntil: () => {} }); + if (!response) return next(); + + // Do the reverse of https://github.com/vercel/vercel/blob/main/packages/functions/src/middleware.ts#L38 + // to apply the headers to the original request/response + for (const [key, value] of response.headers) { + if (key === REWRITE_HEADER) { + // Vite removes the base path from req.url + req.url = value.slice(original_url.length - (req.url || '/').length); + req.originalUrl = value; + } else if (key.startsWith('x-middleware-request-')) { + const header = key.slice('x-middleware-request-'.length); + req.headers[header] = value; + } else if (key !== 'x-middleware-override-headers') { + // This isn't 100% correct because a header could be overwritten by later middleware + // but it's the closest we can get given how Vite/Express/Polka middleware works. + res.setHeader(key, value); + } + } + } + + return next(); + } + }; + }, + supports: { // reading from the filesystem only works in serverless functions read: ({ config, route }) => { diff --git a/packages/adapter-vercel/middleware.d.ts b/packages/adapter-vercel/middleware.d.ts new file mode 100644 index 000000000000..0bf02392e43b --- /dev/null +++ b/packages/adapter-vercel/middleware.d.ts @@ -0,0 +1,47 @@ +/** + * Use this string to scope the middleware to only run on paths other than immutable assets. + * + * ```ts + * import { createMatcher } from '@sveltejs/adapter-vercel'; + * + * export const config = { matcher: createMatcher() }; + * ``` + * @param {{ appDir?: string, base?: string }} options + */ +export function createMatcher(options?: { + /** Corresponds to `kit.appDir` in your `svelte.config.js`; only necessary if you deviated from the default. */ + appDir?: string; + /** Corresponds to `kit.paths.base`; only necessary if you deviated from the default. */ + base?: string; +}): string; + +/** + * Normalizes the incoming URL to remove any differences between direct page hits and + * data or route resolution requests. Returns the `url` and a `rewrite` function + * that is aware of said differences, and which should be used in place of the `rewrite` + * function from `@vercel/edge`. + * + * ```ts + * import { createMiddlewareHelpers } from '@sveltejs/adapter-vercel'; + * + * const { normalizeUrl } = createMiddlewareHelpers(); + * + * export default function middleware(request: Request) { + * const { url, rewrite } = normalizeUrl(request.url); + * if (url.pathname === '/some-page') { + * return rewrite('/some-other-page'); + * } + * } + * ``` + * + * @param {string} url The original URL as given by the request + */ +export function normalizeUrl(url: string): { + url: URL; + rewrite: typeof import('@vercel/edge').rewrite; +}; + +/** + * `@vercel/edge`'s `next` function + */ +export const next: typeof import('@vercel/edge').next; diff --git a/packages/adapter-vercel/middleware.js b/packages/adapter-vercel/middleware.js new file mode 100644 index 000000000000..cf1328bb95a8 --- /dev/null +++ b/packages/adapter-vercel/middleware.js @@ -0,0 +1,104 @@ +import { rewrite, next } from '@vercel/edge'; +import { REWRITE_HEADER } from './utils.js'; + +/** + * @type {typeof import('./middleware.js').createMatcher} + */ +export function createMatcher({ appDir = '_app', base = '' } = {}) { + if (base) { + return `/((?!${base.slice(1)}/${appDir}/immutable).*)`; + } else { + return `/((?!${appDir}/immutable).*)`; + } +} + +export { next }; + +/** + * @type {typeof import('./middleware.js').normalizeUrl} + */ +export function normalizeUrl(url) { + let normalized = new URL(url); + + const is_route_resolution = has_resolution_suffix(normalized.pathname); + const is_data_request = has_data_suffix(normalized.pathname); + + if (is_route_resolution) { + normalized.pathname = strip_resolution_suffix(normalized.pathname); + } else if (is_data_request) { + normalized.pathname = strip_data_suffix(normalized.pathname); + } + + return { + url: normalized, + rewrite: (destination, init) => { + const rewritten = new URL(destination, url); + + if (rewritten.hostname === normalized.hostname) { + if (is_route_resolution) { + rewritten.pathname = add_resolution_suffix(rewritten.pathname); + } else if (is_data_request) { + rewritten.pathname = add_data_suffix(rewritten.pathname); + } + + init ||= {}; + init.headers = new Headers(init.headers); + init.headers.set(REWRITE_HEADER, rewritten.pathname); + } + + return rewrite(rewritten, init); + } + }; +} + +// the following internal helpers are a copy-paste of kit/src/runtime/pathname.js - should we expose them publicly? + +const DATA_SUFFIX = '/__data.json'; +const HTML_DATA_SUFFIX = '.html__data.json'; + +/** @param {string} pathname */ +function has_data_suffix(pathname) { + return pathname.endsWith(DATA_SUFFIX) || pathname.endsWith(HTML_DATA_SUFFIX); +} + +/** @param {string} pathname */ +function add_data_suffix(pathname) { + if (pathname.endsWith('.html')) return pathname.replace(/\.html$/, HTML_DATA_SUFFIX); + return pathname.replace(/\/$/, '') + DATA_SUFFIX; +} + +/** @param {string} pathname */ +function strip_data_suffix(pathname) { + if (pathname.endsWith(HTML_DATA_SUFFIX)) { + return pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html'; + } + + return pathname.slice(0, -DATA_SUFFIX.length); +} + +const ROUTE_SUFFIX = '/__route.js'; + +/** + * @param {string} pathname + * @returns {boolean} + */ +function has_resolution_suffix(pathname) { + return pathname.endsWith(ROUTE_SUFFIX); +} + +/** + * Convert a regular URL to a route to send to SvelteKit's server-side route resolution endpoint + * @param {string} pathname + * @returns {string} + */ +function add_resolution_suffix(pathname) { + return pathname.replace(/\/$/, '') + ROUTE_SUFFIX; +} + +/** + * @param {string} pathname + * @returns {string} + */ +function strip_resolution_suffix(pathname) { + return pathname.slice(0, -ROUTE_SUFFIX.length); +} diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index c8e823ce8f72..b6b2818de070 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -23,6 +23,10 @@ "types": "./index.d.ts", "import": "./index.js" }, + "./middleware": { + "types": "./middleware.d.ts", + "import": "./middleware.js" + }, "./package.json": "./package.json" }, "types": "index.d.ts", @@ -41,7 +45,9 @@ }, "dependencies": { "@vercel/nft": "^0.29.0", - "esbuild": "^0.24.0" + "@vercel/edge": "^1.2.1", + "esbuild": "^0.24.0", + "path-to-regexp": "^6.3.0" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/packages/adapter-vercel/tsconfig.json b/packages/adapter-vercel/tsconfig.json index 3d157ebc29e5..eafd44522c14 100644 --- a/packages/adapter-vercel/tsconfig.json +++ b/packages/adapter-vercel/tsconfig.json @@ -9,7 +9,6 @@ "module": "node16", "moduleResolution": "node16", "allowSyntheticDefaultImports": true, - "baseUrl": ".", "paths": { "@sveltejs/kit": ["../kit/types/index"] } diff --git a/packages/adapter-vercel/utils.js b/packages/adapter-vercel/utils.js index a41a9cb7a92c..ce565c800b2d 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,59 @@ export function pattern_to_src(pattern) { return src; } + +export const REWRITE_HEADER = 'x-sveltekit-vercel-rewrite'; + +/** + * @param {unknown} matchers + * @returns {string} + */ +export function get_regex_from_matchers(matchers) { + const regex = getRegExpFromMatchers(matchers); + // Make sure that we also match on our special internal routes + const special_routes = ['__data.json', '__route.js']; + const modified_regex = regex + .replace(/\$\|\^/g, `(?:|${special_routes.join('|')})$|`) + .replace(/\$$/g, `(?:|${special_routes.join('|')})$`); + return modified_regex; +} + +// Copied from https://github.com/vercel/vercel/blob/main/packages/node/src/utils.ts#L97 which hopefully is available via @vercel/routing-utils at some point +/** + * @param {unknown} matcherOrMatchers + * @returns {string} + */ +function getRegExpFromMatchers(matcherOrMatchers) { + if (!matcherOrMatchers) { + return '^/.*$'; + } + const matchers = Array.isArray(matcherOrMatchers) ? matcherOrMatchers : [matcherOrMatchers]; + const regExps = matchers.flatMap(getRegExpFromMatcher).join('|'); + return regExps; +} + +/** + * @param {unknown} matcher + * @param {number} _index + * @param {unknown[]} allMatchers + * @returns {string[]} + */ +function getRegExpFromMatcher(matcher, _index, allMatchers) { + if (typeof matcher !== 'string') { + throw new Error( + "Middleware's `config.matcher` must be a path matcher (string) or an array of path matchers (string[])" + ); + } + + if (!matcher.startsWith('/')) { + throw new Error( + `Middleware's \`config.matcher\` values must start with "/". Received: ${matcher}` + ); + } + + const regExps = [pathToRegexp(matcher).source]; + if (matcher === '/' && !allMatchers.includes('/index')) { + regExps.push(pathToRegexp('/index').source); + } + return regExps; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83af29446412..e2ae7ed626ee 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 + path-to-regexp: + specifier: ^6.3.0 + version: 6.3.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'} @@ -4524,6 +4533,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 fd8c296d49ce8515891af7b1dcdaca7e45ef35ae Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 15 Feb 2025 16:19:34 +0100 Subject: [PATCH 03/45] fix --- packages/adapter-vercel/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-vercel/utils.js b/packages/adapter-vercel/utils.js index ce565c800b2d..349cfa329b47 100644 --- a/packages/adapter-vercel/utils.js +++ b/packages/adapter-vercel/utils.js @@ -79,7 +79,7 @@ export const REWRITE_HEADER = 'x-sveltekit-vercel-rewrite'; 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 special_routes = ['__data\\.json', '__route\\.js']; const modified_regex = regex .replace(/\$\|\^/g, `(?:|${special_routes.join('|')})$|`) .replace(/\$$/g, `(?:|${special_routes.join('|')})$`); From b5ea7391d13adc7787544a4e28eccc02b71e56ff Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 15 Feb 2025 16:23:29 +0100 Subject: [PATCH 04/45] bundle ourselves instead --- packages/adapter-vercel/index.js | 109 ++++++++++++++++++------ packages/adapter-vercel/middleware.d.ts | 17 ---- packages/adapter-vercel/middleware.js | 11 --- 3 files changed, 85 insertions(+), 52 deletions(-) diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index c9f8cfa7bc63..94651bd837ac 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -114,30 +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', - REWRITE_HEADER: REWRITE_HEADER - } - }); - - 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, @@ -146,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;' }, @@ -157,7 +140,8 @@ const plugin = function (defaults = {}) { '.ttf': 'copy', '.eot': 'copy', '.otf': 'copy' - } + }, + ...(esbuild_options || {}) }); if (result.warnings.length > 0) { @@ -201,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', @@ -215,6 +199,83 @@ 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', + REWRITE_HEADER: REWRITE_HEADER + } + }); + + 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) { + let middleware_path = './vercel-middleware.js'; + + if (!fs.existsSync(middleware_path)) { + middleware_path = './vercel-middleware.ts'; + } + + if (!fs.existsSync(middleware_path)) return; + + await bundle_edge_function( + { + entryPoints: [middleware_path] + }, + 'user-middleware', + config + ); + + let matcher = `/((?!${builder.getAppPath()}/immutable|favicon.ico|favicon.png).*)`; + + try { + const file_path = pathToFileURL(`${dirs.functions}/user-middleware.func/index.js`).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. Make sure it is loadable during build, which is necessary to analyze the config object.' + ); + throw e; + } + } + + static_config.routes.splice( + static_config.routes.findIndex((r) => r.handle === 'filesystem'), + 0, + { + src: get_regex_from_matchers(matcher), + middlewarePath: 'user-middleware', + continue: true + } + ); + } + + await generate_edge_middleware(defaults); + /** @type {Map[] }>} */ const groups = new Map(); diff --git a/packages/adapter-vercel/middleware.d.ts b/packages/adapter-vercel/middleware.d.ts index 0bf02392e43b..34e7f895e54c 100644 --- a/packages/adapter-vercel/middleware.d.ts +++ b/packages/adapter-vercel/middleware.d.ts @@ -1,20 +1,3 @@ -/** - * Use this string to scope the middleware to only run on paths other than immutable assets. - * - * ```ts - * import { createMatcher } from '@sveltejs/adapter-vercel'; - * - * export const config = { matcher: createMatcher() }; - * ``` - * @param {{ appDir?: string, base?: string }} options - */ -export function createMatcher(options?: { - /** Corresponds to `kit.appDir` in your `svelte.config.js`; only necessary if you deviated from the default. */ - appDir?: string; - /** Corresponds to `kit.paths.base`; only necessary if you deviated from the default. */ - base?: string; -}): string; - /** * Normalizes the incoming URL to remove any differences between direct page hits and * data or route resolution requests. Returns the `url` and a `rewrite` function diff --git a/packages/adapter-vercel/middleware.js b/packages/adapter-vercel/middleware.js index cf1328bb95a8..136b7fc59683 100644 --- a/packages/adapter-vercel/middleware.js +++ b/packages/adapter-vercel/middleware.js @@ -1,17 +1,6 @@ import { rewrite, next } from '@vercel/edge'; import { REWRITE_HEADER } from './utils.js'; -/** - * @type {typeof import('./middleware.js').createMatcher} - */ -export function createMatcher({ appDir = '_app', base = '' } = {}) { - if (base) { - return `/((?!${base.slice(1)}/${appDir}/immutable).*)`; - } else { - return `/((?!${appDir}/immutable).*)`; - } -} - export { next }; /** From ac0ca4b91fcda1da5ea3a390c5dd99a3d503de96 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 15 Feb 2025 17:05:54 +0100 Subject: [PATCH 05/45] wrap rewrite and url automatically --- .../adapter-vercel/{ => files}/middleware.js | 58 ++++++++++--------- packages/adapter-vercel/index.js | 17 +++++- packages/adapter-vercel/internal.d.ts | 5 ++ packages/adapter-vercel/middleware.d.ts | 30 ---------- packages/adapter-vercel/package.json | 4 -- 5 files changed, 51 insertions(+), 63 deletions(-) rename packages/adapter-vercel/{ => files}/middleware.js (56%) delete mode 100644 packages/adapter-vercel/middleware.d.ts diff --git a/packages/adapter-vercel/middleware.js b/packages/adapter-vercel/files/middleware.js similarity index 56% rename from packages/adapter-vercel/middleware.js rename to packages/adapter-vercel/files/middleware.js index 136b7fc59683..b61b85d80319 100644 --- a/packages/adapter-vercel/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -1,43 +1,47 @@ -import { rewrite, next } from '@vercel/edge'; -import { REWRITE_HEADER } from './utils.js'; +import * as user_middleware from 'MIDDLEWARE'; -export { next }; +export const config = user_middleware.config; /** - * @type {typeof import('./middleware.js').normalizeUrl} + * @param {Request} request + * @param {any} context */ -export function normalizeUrl(url) { - let normalized = new URL(url); +export default async function middleware(request, context) { + const url = new URL(request.url); - const is_route_resolution = has_resolution_suffix(normalized.pathname); - const is_data_request = has_data_suffix(normalized.pathname); + const is_route_resolution = has_resolution_suffix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); if (is_route_resolution) { - normalized.pathname = strip_resolution_suffix(normalized.pathname); + url.pathname = strip_resolution_suffix(url.pathname); } else if (is_data_request) { - normalized.pathname = strip_data_suffix(normalized.pathname); + url.pathname = strip_data_suffix(url.pathname); } - return { - url: normalized, - rewrite: (destination, init) => { - const rewritten = new URL(destination, url); - - if (rewritten.hostname === normalized.hostname) { - if (is_route_resolution) { - rewritten.pathname = add_resolution_suffix(rewritten.pathname); - } else if (is_data_request) { - rewritten.pathname = add_data_suffix(rewritten.pathname); - } - - init ||= {}; - init.headers = new Headers(init.headers); - init.headers.set(REWRITE_HEADER, rewritten.pathname); + if (is_route_resolution || is_data_request) { + request = new Request(url, request); + } + + const response = await user_middleware.default(request, context); + + if (response instanceof Response && response.headers.has('x-middleware-rewrite')) { + const rewritten = new URL( + /** @type {string} */ (response.headers.get('x-middleware-rewrite')), + url + ); + + if (rewritten.hostname === url.hostname) { + if (is_route_resolution) { + rewritten.pathname = add_resolution_suffix(rewritten.pathname); + } else if (is_data_request) { + rewritten.pathname = add_data_suffix(rewritten.pathname); } - return rewrite(rewritten, init); + response.headers.set('REWRITE_HEADER', rewritten.pathname); } - }; + } + + return response; } // the following internal helpers are a copy-paste of kit/src/runtime/pathname.js - should we expose them publicly? diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 94651bd837ac..afa9c2108f5e 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -234,13 +234,26 @@ const plugin = function (defaults = {}) { if (!fs.existsSync(middleware_path)) { middleware_path = './vercel-middleware.ts'; + if (!fs.existsSync(middleware_path)) return; } - if (!fs.existsSync(middleware_path)) return; + const dest = `${tmp}/middleware.js`; + const relativePath = path.posix.relative(tmp, '.'); + + builder.copy(`${files}/middleware.js`, dest, { + replace: { + MIDDLEWARE: `${relativePath}/vercel-middleware.js` + } + }); await bundle_edge_function( { - entryPoints: [middleware_path] + entryPoints: [dest], + logOverride: { + // Silence this warning which can occur when the user has no config export + // in their middleware (because we reference it in our generated middleware wrapper) + 'import-is-undefined': 'verbose' + } }, 'user-middleware', config diff --git a/packages/adapter-vercel/internal.d.ts b/packages/adapter-vercel/internal.d.ts index 537f7cc041d1..97bc4a97fdaf 100644 --- a/packages/adapter-vercel/internal.d.ts +++ b/packages/adapter-vercel/internal.d.ts @@ -6,3 +6,8 @@ declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; export const manifest: SSRManifest; } + +declare module 'MIDDLEWARE' { + export const config: any; + export default function middleware(request: Request, context: any): any; +} diff --git a/packages/adapter-vercel/middleware.d.ts b/packages/adapter-vercel/middleware.d.ts deleted file mode 100644 index 34e7f895e54c..000000000000 --- a/packages/adapter-vercel/middleware.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Normalizes the incoming URL to remove any differences between direct page hits and - * data or route resolution requests. Returns the `url` and a `rewrite` function - * that is aware of said differences, and which should be used in place of the `rewrite` - * function from `@vercel/edge`. - * - * ```ts - * import { createMiddlewareHelpers } from '@sveltejs/adapter-vercel'; - * - * const { normalizeUrl } = createMiddlewareHelpers(); - * - * export default function middleware(request: Request) { - * const { url, rewrite } = normalizeUrl(request.url); - * if (url.pathname === '/some-page') { - * return rewrite('/some-other-page'); - * } - * } - * ``` - * - * @param {string} url The original URL as given by the request - */ -export function normalizeUrl(url: string): { - url: URL; - rewrite: typeof import('@vercel/edge').rewrite; -}; - -/** - * `@vercel/edge`'s `next` function - */ -export const next: typeof import('@vercel/edge').next; diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index b6b2818de070..b7da5341cfca 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -23,10 +23,6 @@ "types": "./index.d.ts", "import": "./index.js" }, - "./middleware": { - "types": "./middleware.d.ts", - "import": "./middleware.js" - }, "./package.json": "./package.json" }, "types": "index.d.ts", From 9a38b4862971d50140be4415e1c85e0b63dc731a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 15 Feb 2025 17:11:16 +0100 Subject: [PATCH 06/45] devtimefix --- packages/adapter-vercel/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index afa9c2108f5e..02c2d0276863 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -496,8 +496,11 @@ const plugin = function (defaults = {}) { }, emulate: async (opts) => { - const middleware_path = process.cwd() + '/middleware.js'; - if (!fs.existsSync(middleware_path)) return {}; + let middleware_path = process.cwd() + '/vercel-middleware.js'; + if (!fs.existsSync(middleware_path)) { + middleware_path = process.cwd() + '/vercel-middleware.ts'; + if (!fs.existsSync(middleware_path)) return {}; + } return { beforeRequest: async (req, res, next) => { From 2d406bd9413e293197f57004d283520a90ee03aa Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 18 Feb 2025 21:28:11 +0100 Subject: [PATCH 07/45] add capability for adapters to provide additional entry points --- packages/kit/src/core/postbuild/analyse.js | 52 +++++++++++++++----- packages/kit/src/exports/public.d.ts | 19 ++++++++ packages/kit/src/exports/vite/dev/index.js | 15 +++++- packages/kit/src/exports/vite/index.js | 11 ++++- packages/kit/src/runtime/server/index.js | 55 ++++++---------------- packages/kit/src/runtime/server/init.js | 42 +++++++++++++++++ packages/kit/src/types/private.d.ts | 8 ++++ packages/kit/src/utils/features.js | 2 +- packages/kit/types/index.d.ts | 26 ++++++++++ 9 files changed, 172 insertions(+), 58 deletions(-) create mode 100644 packages/kit/src/runtime/server/init.js diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 2484aea4831d..9842e2642599 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -26,7 +26,8 @@ export default forked(import.meta.url, analyse); * manifest_path: string; * manifest_data: import('types').ManifestData; * server_manifest: import('vite').Manifest; - * tracked_features: Record; + * tracked_features: Record; + * additional_entry_points: import('types').AdditionalEntryPoint[] * env: Record * }} opts */ @@ -35,6 +36,7 @@ async function analyse({ manifest_path, manifest_data, server_manifest, + additional_entry_points, tracked_features, env }) { @@ -121,7 +123,7 @@ async function analyse({ const prerender = page?.prerender ?? endpoint?.prerender; if (prerender !== true) { - for (const feature of list_features( + for (const feature of list_route_features( route, manifest_data, server_manifest, @@ -129,6 +131,16 @@ async function analyse({ )) { check_feature(route.id, route_config, feature, config.adapter); } + + for (const additional of additional_entry_points) { + for (const feature of list_features(additional.file, server_manifest, tracked_features)) { + if (!additional.allowedFeatures.includes(feature)) { + throw new Error( + `Usage of ${feature} (imported directly or indirectly) is not allowed in ${additional.file}` + ); + } + } + } } const page_methods = page?.methods ?? []; @@ -213,18 +225,13 @@ function analyse_page(layouts, leaf) { } /** - * @param {import('types').SSRRoute} route - * @param {import('types').ManifestData} manifest_data + * @param {string} entry * @param {import('vite').Manifest} server_manifest - * @param {Record} tracked_features + * @param {Record} tracked_features */ -function list_features(route, manifest_data, server_manifest, tracked_features) { +function list_features(entry, server_manifest, tracked_features) { const features = new Set(); - const route_data = /** @type {import('types').RouteData} */ ( - manifest_data.routes.find((r) => r.id === route.id) - ); - /** @param {string} id */ function visit(id) { const chunk = server_manifest[id]; @@ -243,20 +250,39 @@ function list_features(route, manifest_data, server_manifest, tracked_features) } } + visit(entry); + + return Array.from(features); +} + +/** + * @param {import('types').SSRRoute} route + * @param {import('types').ManifestData} manifest_data + * @param {import('vite').Manifest} server_manifest + * @param {Record} tracked_features + */ +function list_route_features(route, manifest_data, server_manifest, tracked_features) { + const features = []; + const route_data = /** @type {import('types').RouteData} */ ( + manifest_data.routes.find((r) => r.id === route.id) + ); + let page_node = route_data?.leaf; while (page_node) { - if (page_node.server) visit(page_node.server); + if (page_node.server) { + features.push(...list_features(page_node.server, server_manifest, tracked_features)); + } page_node = page_node.parent ?? null; } if (route_data.endpoint) { - visit(route_data.endpoint.file); + features.push(...list_features(route_data.endpoint.file, server_manifest, tracked_features)); } if (manifest_data.hooks.server) { // TODO if hooks.server.js imports `read`, it will be in the entry chunk // we don't currently account for that case - visit(manifest_data.hooks.server); + features.push(...list_features(manifest_data.hooks.server, server_manifest, tracked_features)); } return Array.from(features); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 121d89063773..108e3ecd9d6a 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -5,6 +5,7 @@ import '../types/ambient.js'; import { CompileOptions } from 'svelte/compiler'; import { AdapterEntry, + AdditionalEntryPoint, CspDirectives, HttpMethod, Logger, @@ -51,6 +52,11 @@ export interface Adapter { * during dev, build and prerendering */ emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; + /** + * A function that returns additional entry points for Vite to consider during compilation. + * This is useful for adapters that want to generate separate bundles for e.g. middleware. + */ + additionalEntryPoints?: () => AdditionalEntryPoint[]; } export type LoadProperties | void> = input extends void @@ -1308,12 +1314,25 @@ export interface RouteDefinition { config: Config; } +/** + * Represents the SvelteKit server runtime. Adapters should use this via `${output}/server/index.js` to create a server to send requests to. + */ export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; } +/** + * Similar to Server#init. Can be used via `${output}/server/init.js` for other entry points that don't start the server but still need to setup the environment. + */ +export function initServer(options: { + /** Required for `$env/*` to work */ + env: { env: Record; public_prefix: string; private_prefix: string }; + /** Required for the `read` export from `$app/server` to work */ + read?: { read: (file: string) => ReadableStream; manifest: SSRManifest }; +}): void; + export interface ServerInitOptions { /** A map of environment variables */ env: Record; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 0b41e5ade241..7d965e2ff41c 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -39,7 +39,12 @@ export async function dev(vite, vite_config, svelte_config) { const context = async_local_storage.getStore(); if (!context || context.prerender === true) return; - check_feature(context.event.route.id, context.config, label, svelte_config.kit.adapter); + check_feature( + context.event.route.id, + context.config, + /** @type {import('types').TrackedFeature} */ (label), + svelte_config.kit.adapter + ); }; const fetch = globalThis.fetch; @@ -450,7 +455,13 @@ export async function dev(vite, vite_config, svelte_config) { // adapter-provided middleware vite.middlewares.use(async (req, res, next) => { if (!emulator?.beforeRequest) return next(); - if (req.url?.startsWith('/@fs/') || req.url?.includes('virtual:')) return next(); + if ( + req.url?.startsWith('/@fs/') || + req.url?.startsWith('/@vite/') || + req.url?.includes('virtual:') + ) { + return next(); + } const base = `${vite.config.server.https ? 'https' : 'http'}://${ req.headers[':authority'] || req.headers.host diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 4885d000ec15..3ea2b863fef2 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -212,10 +212,13 @@ async function kit({ svelte_config }) { /** * A map showing which features (such as `$app/server:read`) are defined * in which chunks, so that we can later determine which routes use which features - * @type {Record} + * @type {Record} */ const tracked_features = {}; + /** Adapter-provided additional entry points */ + const additional_entry_points = kit.adapter?.additionalEntryPoints?.() ?? []; + const sourcemapIgnoreList = /** @param {string} relative_path */ (relative_path) => relative_path.includes('node_modules') || relative_path.includes(kit.outDir); @@ -599,8 +602,13 @@ Tips: if (ssr) { input.index = `${runtime_directory}/server/index.js`; + input.init = `${runtime_directory}/server/init.js`; input.internal = `${kit.outDir}/generated/server/internal.js`; + for (const additional of additional_entry_points) { + input[additional.name] = additional.file; + } + // add entry points for every endpoint... manifest_data.routes.forEach((route) => { if (route.endpoint) { @@ -825,6 +833,7 @@ Tips: manifest_data, server_manifest, tracked_features, + additional_entry_points, env: { ...env.private, ...env.public } }); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a2740a8e6aa4..b71829279436 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,19 +1,8 @@ -import { respond } from './respond.js'; -import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js'; -import { options, get_hooks } from '__SERVER__/internal.js'; +import { get_hooks, options } from '__SERVER__/internal.js'; +import { set_manifest } from '__sveltekit/server'; import { DEV } from 'esm-env'; -import { filter_private_env, filter_public_env } from '../../utils/env.js'; -import { prerendering } from '__sveltekit/environment'; -import { set_read_implementation, set_manifest } from '__sveltekit/server'; - -/** @type {ProxyHandler<{ type: 'public' | 'private' }>} */ -const prerender_env_handler = { - get({ type }, prop) { - throw new Error( - `Cannot read values from $env/dynamic/${type} while prerendering (attempted to read env.${prop.toString()}). Use $env/static/${type} instead` - ); - } -}; +import { initServer } from './init.js'; +import { respond } from './respond.js'; /** @type {Promise} */ let init_promise; @@ -40,33 +29,17 @@ export class Server { * read?: (file: string) => ReadableStream; * }} opts */ - async init({ env, read }) { - // Take care: Some adapters may have to call `Server.init` per-request to set env vars, - // so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't - // been done already. - - // set env, in case it's used in initialisation - const prefixes = { - public_prefix: this.#options.env_public_prefix, - private_prefix: this.#options.env_private_prefix - }; - - const private_env = filter_private_env(env, prefixes); - const public_env = filter_public_env(env, prefixes); - - set_private_env( - prerendering ? new Proxy({ type: 'private' }, prerender_env_handler) : private_env - ); - set_public_env( - prerendering ? new Proxy({ type: 'public' }, prerender_env_handler) : public_env - ); - set_safe_public_env(public_env); - - if (read) { - set_read_implementation(read); - } + async init(opts) { + initServer({ + env: { + private_prefix: options.env_private_prefix, + public_prefix: options.env_public_prefix, + env: opts.env + }, + read: opts.read && { read: opts.read, manifest: this.#manifest } + }); - // During DEV and for some adapters this function might be called in quick succession, + // During DEV and for some adapters this function might be called in quick succession (per-request), // so we need to make sure we're not invoking this logic (most notably the init hook) multiple times await (init_promise ??= (async () => { try { diff --git a/packages/kit/src/runtime/server/init.js b/packages/kit/src/runtime/server/init.js new file mode 100644 index 000000000000..6ee07276e10b --- /dev/null +++ b/packages/kit/src/runtime/server/init.js @@ -0,0 +1,42 @@ +import { prerendering } from '__sveltekit/environment'; +import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { filter_private_env, filter_public_env } from '../../utils/env.js'; +import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js'; + +/** + * Separate, more lightweight init in case an adapter entry point doesn't need the whole server + * @param {{ + * env: { public_prefix: string; private_prefix: string; env: Record; }; + * read?: { read: (file: string) => ReadableStream; manifest: import('@sveltejs/kit').SSRManifest; }; + * }} options + */ +export function initServer({ env, read }) { + // set env, in case it's used in initialisation + const prefixes = { + public_prefix: env.public_prefix, + private_prefix: env.private_prefix + }; + + const private_env = filter_private_env(env.env, prefixes); + const public_env = filter_public_env(env.env, prefixes); + + set_private_env( + prerendering ? new Proxy({ type: 'private' }, prerender_env_handler) : private_env + ); + set_public_env(prerendering ? new Proxy({ type: 'public' }, prerender_env_handler) : public_env); + set_safe_public_env(public_env); + + if (read) { + set_read_implementation(read.read); + set_manifest(read.manifest); + } +} + +/** @type {ProxyHandler<{ type: 'public' | 'private' }>} */ +const prerender_env_handler = { + get({ type }, prop) { + throw new Error( + `Cannot read values from $env/dynamic/${type} while prerendering (attempted to read env.${prop.toString()}). Use $env/static/${type} instead` + ); + } +}; diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index 0207a8f5f05b..439d1dec6e3d 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -155,6 +155,14 @@ export interface Logger { export type MaybePromise = T | Promise; +export type TrackedFeature = '$app/server:read'; + +export interface AdditionalEntryPoint { + name: string; + file: string; + allowedFeatures: TrackedFeature[]; +} + export interface Prerendered { /** * A map of `path` to `{ file }` objects, where a path like `/foo` corresponds to `foo.html` and a path like `/bar/` corresponds to `bar/index.html`. diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js index 4a8530d22bbb..4184f99f28fd 100644 --- a/packages/kit/src/utils/features.js +++ b/packages/kit/src/utils/features.js @@ -1,7 +1,7 @@ /** * @param {string} route_id * @param {any} config - * @param {string} feature + * @param {import('types').TrackedFeature} feature * @param {import('@sveltejs/kit').Adapter | undefined} adapter */ export function check_feature(route_id, config, feature, adapter) { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index cc1297db183b..70a3b5718738 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -33,6 +33,11 @@ declare module '@sveltejs/kit' { * during dev, build and prerendering */ emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; + /** + * A function that returns additional entry points for Vite to consider during compilation. + * This is useful for adapters that want to generate separate bundles for e.g. middleware. + */ + additionalEntryPoints?: () => AdditionalEntryPoint[]; } export type LoadProperties | void> = input extends void @@ -1290,12 +1295,25 @@ declare module '@sveltejs/kit' { config: Config; } + /** + * Represents the SvelteKit server runtime. Adapters should use this via `${output}/server/index.js` to create a server to send requests to. + */ export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; } + /** + * Similar to Server#init. Can be used via `${output}/server/init.js` for other entry points that don't start the server but still need to setup the environment. + */ + export function initServer(options: { + /** Required for `$env/*` to work */ + env: { env: Record; public_prefix: string; private_prefix: string }; + /** Required for the `read` export from `$app/server` to work */ + read?: { read: (file: string) => ReadableStream; manifest: SSRManifest }; + }): void; + export interface ServerInitOptions { /** A map of environment variables */ env: Record; @@ -1640,6 +1658,14 @@ declare module '@sveltejs/kit' { type MaybePromise = T | Promise; + type TrackedFeature = '$app/server:read'; + + interface AdditionalEntryPoint { + name: string; + file: string; + allowedFeatures: TrackedFeature[]; + } + interface Prerendered { /** * A map of `path` to `{ file }` objects, where a path like `/foo` corresponds to `foo.html` and a path like `/bar/` corresponds to `bar/index.html`. From 253318a02b306079c8880289551350469f0915bb Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 18 Feb 2025 23:22:17 +0100 Subject: [PATCH 08/45] vercel middleware --- .../25-build-and-deploy/90-adapter-vercel.md | 47 +++++++++++++++++++ packages/adapter-vercel/files/middleware.js | 9 ++++ packages/adapter-vercel/index.js | 42 ++++++++++++----- packages/adapter-vercel/internal.d.ts | 4 ++ 4 files changed, 89 insertions(+), 13 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..ff4af34aff24 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,53 @@ 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. +## Edge Middleware + +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing a `vercel-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. + +```js +/// file: vercel-middleware.js +import { rewrite, next } from '@vercel/edge'; + +export default async function middleware(request: Request) { + const url = new URL(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve feature flag from cookies + let flag = split_cookies(request.headers.get('cookie') ?? '')?.flag; + + // Fall back to random value if this is a new visitor + flag ||= Math.random() > 0.5 ? 'a' : 'b'; + + return rewrite( + // Get destination URL based on the feature flag + flag === 'a' ? '/home-a' : '/home-b', + { + headers: { + // Set a cookie to remember the feature flags for this visitor + 'Set-Cookie': `flag=${flag}; Path=/` + } + } + ); +} + +function split_cookies(cookies: string) { + return cookies.split(';').reduce( + (acc, cookie) => { + const [name, value] = cookie.trim().split('='); + acc[name] = value; + return acc; + }, + {} as Record + ); +} +``` + +By default, middleware runs on all requests except for files within `_app/immutable`. You can customize this by exporting a `config` object with a `matcher` property as described in Vercel's [API documentation](https://vercel.com/docs/functions/edge-middleware/middleware-api#match-paths-based-on-custom-matcher-config). + +If you want to run code prior to a request but neither have prerendered nor ISR'd pages and have no rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. + ## 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/files/middleware.js b/packages/adapter-vercel/files/middleware.js index b61b85d80319..03c99fc0350e 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -1,5 +1,14 @@ +import { initServer } from 'SERVER_INIT'; import * as user_middleware from 'MIDDLEWARE'; +initServer({ + env: { + env: /** @type {Record} */ (process.env), + public_prefix: 'PUBLIC_PREFIX', + private_prefix: 'PRIVATE_PREFIX' + } +}); + export const config = user_middleware.config; /** diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 02c2d0276863..2730d62c0464 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -37,6 +37,16 @@ const get_default_runtime = () => { // https://vercel.com/docs/functions/edge-functions/edge-runtime#compatible-node.js-modules const compatible_node_modules = ['async_hooks', 'events', 'buffer', 'assert', 'util']; +const [major, minor] = VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'vercel-middleware.js' : null; +if (middleware_path && !fs.existsSync(middleware_path)) { + middleware_path = 'vercel-middleware.ts'; + if (!fs.existsSync(middleware_path)) middleware_path = null; +} + /** @type {import('./index.js').default} **/ const plugin = function (defaults = {}) { if ('edge' in defaults) { @@ -230,19 +240,17 @@ const plugin = function (defaults = {}) { * @param {import('./index.js').Config} config */ async function generate_edge_middleware(config) { - let middleware_path = './vercel-middleware.js'; - - if (!fs.existsSync(middleware_path)) { - middleware_path = './vercel-middleware.ts'; - if (!fs.existsSync(middleware_path)) return; - } + if (!middleware_path) return; const dest = `${tmp}/middleware.js`; - const relativePath = path.posix.relative(tmp, '.'); + const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); builder.copy(`${files}/middleware.js`, dest, { replace: { - MIDDLEWARE: `${relativePath}/vercel-middleware.js` + SERVER_INIT: `${relativePath}/init.js`, + MIDDLEWARE: `${relativePath}/vercel-middleware.js`, + PUBLIC_PREFIX: builder.config.kit.env.publicPrefix, + PRIVATE_PREFIX: builder.config.kit.env.privatePrefix } }); @@ -496,11 +504,7 @@ const plugin = function (defaults = {}) { }, emulate: async (opts) => { - let middleware_path = process.cwd() + '/vercel-middleware.js'; - if (!fs.existsSync(middleware_path)) { - middleware_path = process.cwd() + '/vercel-middleware.ts'; - if (!fs.existsSync(middleware_path)) return {}; - } + if (!middleware_path) return {}; return { beforeRequest: async (req, res, next) => { @@ -574,6 +578,18 @@ const plugin = function (defaults = {}) { return true; } + }, + + additionalEntryPoints: () => { + if (!middleware_path) return []; + + return [ + { + name: 'vercel-middleware', + file: middleware_path, + allowedFeatures: [] + } + ]; } }; }; diff --git a/packages/adapter-vercel/internal.d.ts b/packages/adapter-vercel/internal.d.ts index 97bc4a97fdaf..540f6812d960 100644 --- a/packages/adapter-vercel/internal.d.ts +++ b/packages/adapter-vercel/internal.d.ts @@ -2,6 +2,10 @@ declare module 'SERVER' { export { Server } from '@sveltejs/kit'; } +declare module 'SERVER_INIT' { + export { initServer } from '@sveltejs/kit'; +} + declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; export const manifest: SSRManifest; From 05e9b621321a2094d83b9151235facdfe6fc47ee Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 18 Feb 2025 23:31:10 +0100 Subject: [PATCH 09/45] node --- .../25-build-and-deploy/40-adapter-node.md | 12 +++++ packages/adapter-node/index.js | 49 +++++++++++++++++-- packages/adapter-node/internal.d.ts | 5 ++ packages/adapter-node/src/handler.js | 13 +++++ 4 files changed, 75 insertions(+), 4 deletions(-) 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..c01d3e9596b0 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -137,6 +137,18 @@ The number of seconds to wait before forcefully closing any remaining connection When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of seconds after which the app is automatically put to sleep when receiving no requests. If not set, the app runs continuously. See [Socket activation](#Socket-activation) for more details. +## Middleware + +You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file at the root of your project. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests except those to files inside `_app/immutable`. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. + +```js +/// file: node-middleware.js +export default function middleware(req, res, next) { + console.log(`Received ${req.method} on ${req.url}`); + next(); // move on +} +``` + ## Options The adapter can be configured with various options: diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..2b764165665d 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,9 +1,20 @@ -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; +import { VERSION } from '@sveltejs/kit'; + +const [major, minor] = VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'node-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'node-middleware.ts'; + if (!existsSync(middleware_path)) middleware_path = null; +} const files = fileURLToPath(new URL('./files', import.meta.url).href); @@ -46,6 +57,10 @@ export default function (opts = {}) { ].join('\n\n') ); + if (!middleware_path) { + writeFileSync(`${tmp}/node-middleware.js`, 'export default (req, res, next) => next();'); + } + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); // we bundle the Vite output so that deployments only need @@ -54,7 +69,8 @@ export default function (opts = {}) { const bundle = await rollup({ input: { index: `${tmp}/index.js`, - manifest: `${tmp}/manifest.js` + manifest: `${tmp}/manifest.js`, + 'node-middleware': `${tmp}/node-middleware.js` }, external: [ // dependencies could have deep exports, so we need a regex @@ -86,11 +102,36 @@ 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: './server/node-middleware.js' } }); }, + emulate: ({ importFile }) => { + if (!existsSync(middleware_path)) return {}; + + return { + beforeRequest: async (req, res, next) => { + // We have to import this here or else we wouldn't notice when the middleware file changes + const middleware = await importFile(pathToFileURL(middleware_path).href); + return middleware.default(req, res, next); + } + }; + }, + + additionalEntryPoints: () => { + if (!middleware_path) return []; + + return [ + { + name: 'node-middleware', + file: middleware_path, + allowedFeatures: ['$app/server:read'] + } + ]; + }, + supports: { read: () => true } diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index fed0584d1851..a8c9ab683dfe 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -17,3 +17,8 @@ declare module 'MANIFEST' { declare module 'SERVER' { export { Server } from '@sveltejs/kit'; } + +declare module 'MIDDLEWARE' { + const middleware: import('polka').Middleware; + export default middleware; +} diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index b6c628dd4e0c..47e5d2e45527 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -9,6 +9,7 @@ import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/nod import { Server } from 'SERVER'; import { manifest, prerendered, base } from 'MANIFEST'; import { env } from 'ENV'; +import node_middleware from 'MIDDLEWARE'; /* global ENV_PREFIX */ @@ -51,6 +52,17 @@ await server.init({ read: (file) => createReadableStream(`${asset_dir}/${file}`) }); +/** @type {import('polka').Middleware} */ +const middleware = async (req, res, next) => { + const { pathname } = polka_url_parser(req); + + if (pathname.startsWith(`/${manifest.appPath}/immutable/`)) { + return next(); + } + + return node_middleware(req, res, next); +}; + /** * @param {string} path * @param {boolean} client @@ -206,6 +218,7 @@ function get_origin(headers) { export const handler = sequence( [ + middleware, serve(path.join(dir, 'client'), true), serve(path.join(dir, 'static')), serve_prerendered(), From 8b8c4acdd5f70078ff3135e8c5ef07e0c1d8839b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 10:06:48 +0100 Subject: [PATCH 10/45] lint --- packages/adapter-vercel/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 2730d62c0464..276e2d260010 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -106,7 +106,7 @@ const plugin = function (defaults = {}) { replace: { SERVER: `${relativePath}/index.js`, MANIFEST: './manifest.js', - REWRITE_HEADER: REWRITE_HEADER + REWRITE_HEADER } }); @@ -224,7 +224,7 @@ const plugin = function (defaults = {}) { replace: { SERVER: `${relativePath}/index.js`, MANIFEST: './manifest.js', - REWRITE_HEADER: REWRITE_HEADER + REWRITE_HEADER } }); @@ -503,7 +503,7 @@ const plugin = function (defaults = {}) { write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); }, - emulate: async (opts) => { + emulate: (opts) => { if (!middleware_path) return {}; return { From 344dd7bb5426ed6613be33ef347ea731b4e3443d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 14:00:16 +0100 Subject: [PATCH 11/45] expose new helpers from Kit --- packages/kit/src/exports/index.js | 47 ++++++++++++++++++++++++++ packages/kit/src/exports/node/index.js | 30 ++++++++++------ packages/kit/types/index.d.ts | 18 ++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 3b69f24de40e..abba33237a06 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -1,5 +1,13 @@ import { HttpError, Redirect, ActionFailure } from '../runtime/control.js'; import { BROWSER, DEV } from 'esm-env'; +import { + add_data_suffix, + add_resolution_suffix, + has_data_suffix, + has_resolution_suffix, + strip_data_suffix, + strip_resolution_suffix +} from '../runtime/pathname.js'; export { VERSION } from '../version.js'; @@ -207,3 +215,42 @@ export function fail(status, data) { export function isActionFailure(e) { return e instanceof ActionFailure; } + +/** + * Strips possible SvelteKit-internal suffixes from the URL pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back based on a new pathname. + * ```js + * import { normalizeUrl } from '@sveltejs/kit'; + * + * const { url, denormalize } = normalizeUrl('/blog/post/__data.json'); + * console.log(url.pathname); // /blog/post + * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json + * ``` + * @param {URL | string} url + * @returns {{ url: URL, denormalize: (pathname?: string) => URL }} + */ +export function normalizeUrl(url) { + url = new URL(url, 'http://internal'); + + const is_route_resolution = has_resolution_suffix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + + if (is_route_resolution) { + url.pathname = strip_resolution_suffix(url.pathname); + } else if (is_data_request) { + url.pathname = strip_data_suffix(url.pathname); + } + + return { + url, + denormalize: (pathname = /** @type {URL} */ (url).pathname) => { + url = new URL(pathname, url); + if (is_route_resolution) { + url.pathname = add_resolution_suffix(url.pathname); + } else if (is_data_request) { + url.pathname = add_data_suffix(url.pathname); + } + return url; + } + }; +} diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index a69b7ae6d906..e0753d673c3c 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -96,17 +96,13 @@ function get_raw_body(req, body_size_limit) { } /** - * @param {{ - * request: import('http').IncomingMessage; - * base: string; - * bodySizeLimit?: number; - * }} options - * @returns {Promise} + * Turns the Node request headers into a `Headers` instance + * @param {import('http').IncomingMessage} request + * @returns {Headers} */ -// TODO 3.0 make the signature synchronous? -// eslint-disable-next-line @typescript-eslint/require-await -export async function getRequest({ request, base, bodySizeLimit }) { +export function getRequestHeaders(request) { let headers = /** @type {Record} */ (request.headers); + if (request.httpVersionMajor >= 2) { // the Request constructor rejects headers with ':' in the name headers = Object.assign({}, headers); @@ -120,11 +116,25 @@ export async function getRequest({ request, base, bodySizeLimit }) { delete headers[':scheme']; } + return new Headers(Object.entries(headers)); +} + +/** + * @param {{ + * request: import('http').IncomingMessage; + * base: string; + * bodySizeLimit?: number; + * }} options + * @returns {Promise} + */ +// TODO 3.0 make the signature synchronous? +// eslint-disable-next-line @typescript-eslint/require-await +export async function getRequest({ request, base, bodySizeLimit }) { return new Request(base + request.url, { // @ts-expect-error duplex: 'half', method: request.method, - headers: Object.entries(headers), + headers: getRequestHeaders(request), body: request.method === 'GET' || request.method === 'HEAD' ? undefined diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 70a3b5718738..4d9e3aa904b0 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2035,6 +2035,19 @@ declare module '@sveltejs/kit' { * @param e The object to check. * */ export function isActionFailure(e: unknown): e is ActionFailure; + /** + * Strips possible SvelteKit-internal suffixes from the URL pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back based on a new pathname. + * ```js + * const { url, denormalize } = normalizeUrl('/blog/post/__data.json'); + * console.log(url.pathname); // /blog/post + * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json + * ``` + * */ + export function normalizeUrl(url: URL | string): { + url: URL; + denormalize: (pathname?: string) => URL; + }; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; export type NumericRange = Exclude, LessThan>; export const VERSION: string; @@ -2134,6 +2147,11 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { + /** + * Turns the Node request headers into a `Headers` instance + * */ + export function getRequestHeaders(request: import("http").IncomingMessage): Headers; + export function getRequest({ request, base, bodySizeLimit }: { request: import("http").IncomingMessage; base: string; From cb2d6930ae9c32dfde26811321cd9f0aba4079b0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 14:01:32 +0100 Subject: [PATCH 12/45] incorporate new helpers in Vercel adapter + fix a few bugs --- packages/adapter-vercel/files/middleware.js | 78 ++------------------- packages/adapter-vercel/index.js | 40 ++++------- 2 files changed, 22 insertions(+), 96 deletions(-) diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js index 03c99fc0350e..eb6636cbeb79 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -1,3 +1,4 @@ +import { normalizeUrl } from '@sveltejs/kit'; import { initServer } from 'SERVER_INIT'; import * as user_middleware from 'MIDDLEWARE'; @@ -16,91 +17,26 @@ export const config = user_middleware.config; * @param {any} context */ export default async function middleware(request, context) { - const url = new URL(request.url); + const { url, denormalize } = normalizeUrl(request.url); - const is_route_resolution = has_resolution_suffix(url.pathname); - const is_data_request = has_data_suffix(url.pathname); - - if (is_route_resolution) { - url.pathname = strip_resolution_suffix(url.pathname); - } else if (is_data_request) { - url.pathname = strip_data_suffix(url.pathname); - } - - if (is_route_resolution || is_data_request) { + if (url.pathname !== new URL(request.url).pathname) { request = new Request(url, request); } const response = await user_middleware.default(request, context); if (response instanceof Response && response.headers.has('x-middleware-rewrite')) { - const rewritten = new URL( + let rewritten = new URL( /** @type {string} */ (response.headers.get('x-middleware-rewrite')), url ); if (rewritten.hostname === url.hostname) { - if (is_route_resolution) { - rewritten.pathname = add_resolution_suffix(rewritten.pathname); - } else if (is_data_request) { - rewritten.pathname = add_data_suffix(rewritten.pathname); - } - - response.headers.set('REWRITE_HEADER', rewritten.pathname); + rewritten = denormalize(rewritten.pathname); + response.headers.set('REWRITE_HEADER', rewritten.pathname + rewritten.search); + response.headers.set('x-middleware-rewrite', rewritten.pathname + rewritten.search); } } return response; } - -// the following internal helpers are a copy-paste of kit/src/runtime/pathname.js - should we expose them publicly? - -const DATA_SUFFIX = '/__data.json'; -const HTML_DATA_SUFFIX = '.html__data.json'; - -/** @param {string} pathname */ -function has_data_suffix(pathname) { - return pathname.endsWith(DATA_SUFFIX) || pathname.endsWith(HTML_DATA_SUFFIX); -} - -/** @param {string} pathname */ -function add_data_suffix(pathname) { - if (pathname.endsWith('.html')) return pathname.replace(/\.html$/, HTML_DATA_SUFFIX); - return pathname.replace(/\/$/, '') + DATA_SUFFIX; -} - -/** @param {string} pathname */ -function strip_data_suffix(pathname) { - if (pathname.endsWith(HTML_DATA_SUFFIX)) { - return pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html'; - } - - return pathname.slice(0, -DATA_SUFFIX.length); -} - -const ROUTE_SUFFIX = '/__route.js'; - -/** - * @param {string} pathname - * @returns {boolean} - */ -function has_resolution_suffix(pathname) { - return pathname.endsWith(ROUTE_SUFFIX); -} - -/** - * Convert a regular URL to a route to send to SvelteKit's server-side route resolution endpoint - * @param {string} pathname - * @returns {string} - */ -function add_resolution_suffix(pathname) { - return pathname.replace(/\/$/, '') + ROUTE_SUFFIX; -} - -/** - * @param {string} pathname - * @returns {string} - */ -function strip_resolution_suffix(pathname) { - return pathname.slice(0, -ROUTE_SUFFIX.length); -} diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 276e2d260010..7b18973bc49b 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -5,7 +5,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; import { get_pathname, get_regex_from_matchers, pattern_to_src, REWRITE_HEADER } from './utils.js'; -import { VERSION } from '@sveltejs/kit'; +// TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as kit from '@sveltejs/kit'; +import * as node_kit from '@sveltejs/kit/node'; const name = '@sveltejs/adapter-vercel'; const DEFAULT_FUNCTION_NAME = 'fn'; @@ -37,7 +39,7 @@ const get_default_runtime = () => { // https://vercel.com/docs/functions/edge-functions/edge-runtime#compatible-node.js-modules const compatible_node_modules = ['async_hooks', 'events', 'buffer', 'assert', 'util']; -const [major, minor] = VERSION.split('.').map(Number); +const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ @@ -200,7 +202,7 @@ const plugin = function (defaults = {}) { entrypoint: 'index.js', framework: { slug: 'sveltekit', - version: VERSION + version: kit.VERSION } }, null, @@ -514,26 +516,12 @@ const plugin = function (defaults = {}) { const original_url = req.originalUrl || '/'; if (matcher.test(original_url)) { - // TODO copied from exports/node/index.js, expose it? - let headers = /** @type {Record} */ (req.headers); - if (req.httpVersionMajor >= 2) { - // the Request constructor rejects headers with ':' in the name - headers = Object.assign({}, headers); - // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.3.1-2.3.5 - if (headers[':authority']) { - headers.host = headers[':authority']; - } - delete headers[':authority']; - delete headers[':method']; - delete headers[':path']; - delete headers[':scheme']; - } - - // We omit the body here because it would consume the stream - const request = new Request(new URL(original_url, 'https://localhost'), { - headers: new Headers(Object.entries(headers)), + const { url, denormalize } = kit.normalizeUrl(original_url); + const request = new Request(url, { + headers: node_kit.getRequestHeaders(req), method: req.method, body: + // We omit the body here because it would consume the stream req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] ? undefined : 'Cannot read body in dev mode' @@ -545,10 +533,12 @@ const plugin = function (defaults = {}) { // Do the reverse of https://github.com/vercel/vercel/blob/main/packages/functions/src/middleware.ts#L38 // to apply the headers to the original request/response for (const [key, value] of response.headers) { - if (key === REWRITE_HEADER) { + if (key === 'x-middleware-rewrite') { // Vite removes the base path from req.url - req.url = value.slice(original_url.length - (req.url || '/').length); - req.originalUrl = value; + const d1 = denormalize(value.slice(original_url.length - (req.url || '/').length)); + req.url = d1.pathname + d1.search; + const d2 = denormalize(value); + req.originalUrl = d2.pathname + d2.search; } else if (key.startsWith('x-middleware-request-')) { const header = key.slice('x-middleware-request-'.length); req.headers[header] = value; @@ -846,7 +836,7 @@ async function create_function_bundle(builder, entry, dir, config) { experimentalResponseStreaming: !config.isr, framework: { slug: 'sveltekit', - version: VERSION + version: kit.VERSION } }, null, From db4ed04d62fc528a338b48cf102b71dd2f8eda66 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 14:35:08 +0100 Subject: [PATCH 13/45] do base path normalization within Vite middleware so adapters don't have to care about it --- packages/adapter-vercel/index.js | 9 +++------ packages/kit/src/exports/vite/dev/index.js | 14 +++++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 7b18973bc49b..95d484b2d427 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -513,7 +513,7 @@ const plugin = function (defaults = {}) { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importFile(pathToFileURL(middleware_path).href); const matcher = new RegExp(get_regex_from_matchers(middleware.config?.matcher)); - const original_url = req.originalUrl || '/'; + const original_url = /** @type {string} */ (req.url); if (matcher.test(original_url)) { const { url, denormalize } = kit.normalizeUrl(original_url); @@ -534,11 +534,8 @@ const plugin = function (defaults = {}) { // to apply the headers to the original request/response for (const [key, value] of response.headers) { if (key === 'x-middleware-rewrite') { - // Vite removes the base path from req.url - const d1 = denormalize(value.slice(original_url.length - (req.url || '/').length)); - req.url = d1.pathname + d1.search; - const d2 = denormalize(value); - req.originalUrl = d2.pathname + d2.search; + const url = denormalize(value); + req.url = url.pathname + url.search; } else if (key.startsWith('x-middleware-request-')) { const header = key.slice('x-middleware-request-'.length); req.headers[header] = value; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 7d965e2ff41c..1ad2927ce084 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -475,7 +475,19 @@ export async function dev(vite, vite_config, svelte_config) { return next(); } - return emulator.beforeRequest(req, res, next); + // Vite's base middleware strips out the base path. Restore it for the duration of beforeRequest + const prev_url = req.url; + req.url = req.originalUrl; + const _next = () => { + if (prev_url !== req.url) { + req.originalUrl = req.url; + req.url = /** @type {string} */ (req.url).slice(svelte_config.kit.paths.base.length); + } else { + req.url = prev_url; + } + return next(); + }; + return emulator.beforeRequest(req, res, _next); }); vite.middlewares.use((req, res, next) => { From b6e623f8517ac3f16d02add260bb6aa2e37629f0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 15:34:18 +0100 Subject: [PATCH 14/45] tweak adapter API: switch allowed to disallowed, restrict imports to endpoints which allows SvelteKit of handling the difference of imports in dev/preview --- packages/adapter-node/index.js | 7 +++---- packages/adapter-vercel/index.js | 4 ++-- packages/kit/src/core/postbuild/analyse.js | 2 +- packages/kit/src/exports/public.d.ts | 5 ++++- packages/kit/src/exports/vite/dev/index.js | 12 +++++++++++- packages/kit/src/exports/vite/preview/index.js | 2 +- packages/kit/src/types/private.d.ts | 5 ++++- packages/kit/types/index.d.ts | 12 ++++++++++-- 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 2b764165665d..aa3ff358d25c 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -108,13 +108,13 @@ export default function (opts = {}) { }); }, - emulate: ({ importFile }) => { + emulate: (opts) => { if (!existsSync(middleware_path)) return {}; return { beforeRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes - const middleware = await importFile(pathToFileURL(middleware_path).href); + const middleware = await opts.importEntryPoint('node-middleware'); return middleware.default(req, res, next); } }; @@ -126,8 +126,7 @@ export default function (opts = {}) { return [ { name: 'node-middleware', - file: middleware_path, - allowedFeatures: ['$app/server:read'] + file: middleware_path } ]; }, diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 95d484b2d427..11cc3b1cb92e 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -511,7 +511,7 @@ const plugin = function (defaults = {}) { return { beforeRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes - const middleware = await opts.importFile(pathToFileURL(middleware_path).href); + const middleware = await opts.importEntryPoint('vercel-middleware'); const matcher = new RegExp(get_regex_from_matchers(middleware.config?.matcher)); const original_url = /** @type {string} */ (req.url); @@ -574,7 +574,7 @@ const plugin = function (defaults = {}) { { name: 'vercel-middleware', file: middleware_path, - allowedFeatures: [] + disallowedFeatures: ['$app/server:read'] } ]; } diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 9842e2642599..050640f52cbd 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -134,7 +134,7 @@ async function analyse({ for (const additional of additional_entry_points) { for (const feature of list_features(additional.file, server_manifest, tracked_features)) { - if (!additional.allowedFeatures.includes(feature)) { + if (additional.disallowedFeatures?.includes(feature)) { throw new Error( `Usage of ${feature} (imported directly or indirectly) is not allowed in ${additional.file}` ); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 108e3ecd9d6a..8993e43a68c2 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -51,7 +51,10 @@ export interface Adapter { * Creates an `Emulator`, which allows the adapter to influence the environment * during dev, build and prerendering */ - emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; + emulate?: (helpers: { + /** Allows to import an entry point defined within `additionalEntryPoints` by referencing its name */ + importEntryPoint: (name: string) => Promise; + }) => MaybePromise; /** * A function that returns additional entry points for Vite to consider during compilation. * This is useful for adapters that want to generate separate bundles for e.g. middleware. diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 1ad2927ce084..4a6e13f8f302 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -426,8 +426,18 @@ export async function dev(vite, vite_config, svelte_config) { }; const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''); + const additional_entry_points = svelte_config.kit.adapter?.additionalEntryPoints?.() ?? []; const emulator = await svelte_config.kit.adapter?.emulate?.({ - importFile: (file) => vite.ssrLoadModule(file) + importEntryPoint: (entry) => { + const file = additional_entry_points.find((e) => e.name === entry)?.file; + if (!file) { + throw new Error( + `Entry point '${entry}' not found: ` + + 'Adapters can only import entry points defined previously through additionalEntryPoints' + ); + } + return vite.ssrLoadModule(file); + } }); /** diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 04e754ac9425..6c5acbcfd8d3 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -52,7 +52,7 @@ export async function preview(vite, vite_config, svelte_config) { }); const emulator = await svelte_config.kit.adapter?.emulate?.({ - importFile: (file) => import(file) + importEntryPoint: (entry) => import(pathToFileURL(join(dir, `${entry}.js`)).href) }); return () => { diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index 439d1dec6e3d..fdef49337b0e 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -158,9 +158,12 @@ export type MaybePromise = T | Promise; export type TrackedFeature = '$app/server:read'; export interface AdditionalEntryPoint { + /** Unique name of the entry point. Will be written to disk during build at `output/server/.js` */ name: string; + /** Path relative to the project root of the corresponding file (e.g. `foo.js` means it's at `/foo.js`) */ file: string; - allowedFeatures: TrackedFeature[]; + /** Define which features should not be allowed within the entry point (or the files it imports) */ + disallowedFeatures?: TrackedFeature[]; } export interface Prerendered { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 4d9e3aa904b0..fac7100faab8 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -32,7 +32,10 @@ declare module '@sveltejs/kit' { * Creates an `Emulator`, which allows the adapter to influence the environment * during dev, build and prerendering */ - emulate?: (helpers: { importFile: (fileUrl: string) => Promise }) => MaybePromise; + emulate?: (helpers: { + /** Allows to import an entry point defined within `additionalEntryPoints` by referencing its name */ + importEntryPoint: (name: string) => Promise; + }) => MaybePromise; /** * A function that returns additional entry points for Vite to consider during compilation. * This is useful for adapters that want to generate separate bundles for e.g. middleware. @@ -1661,9 +1664,12 @@ declare module '@sveltejs/kit' { type TrackedFeature = '$app/server:read'; interface AdditionalEntryPoint { + /** Unique name of the entry point. Will be written to disk during build at `output/server/.js` */ name: string; + /** Path relative to the project root of the corresponding file (e.g. `foo.js` means it's at `/foo.js`) */ file: string; - allowedFeatures: TrackedFeature[]; + /** Define which features should not be allowed within the entry point (or the files it imports) */ + disallowedFeatures?: TrackedFeature[]; } interface Prerendered { @@ -2039,6 +2045,8 @@ declare module '@sveltejs/kit' { * Strips possible SvelteKit-internal suffixes from the URL pathname. * Returns the normalized URL as well as a method for adding the potential suffix back based on a new pathname. * ```js + * import { normalizeUrl } from '@sveltejs/kit'; + * * const { url, denormalize } = normalizeUrl('/blog/post/__data.json'); * console.log(url.pathname); // /blog/post * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json From 1c9b512d9a1dbdc1cc6781b5a6f95fbe833b1ff8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 15:34:48 +0100 Subject: [PATCH 15/45] tests --- .../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 | 15 ++++++-- .../apps/basics/test-adapter-middleware.js | 20 +++++++++++ packages/kit/test/apps/basics/test/test.js | 34 +++++++++++++++++++ 7 files changed, 84 insertions(+), 2 deletions(-) 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 create mode 100644 packages/kit/test/apps/basics/test-adapter-middleware.js 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..0fbe59a560dc 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -6,8 +6,12 @@ const config = { adapter: { name: 'test-adapter', adapt() {}, - emulate() { + emulate(opts) { return { + async beforeRequest(req, res, next) { + const middleware = await opts.importEntryPoint('test-adapter-middleware'); + await middleware.default(req, res, next); + }, platform({ config, prerender }) { return { config, prerender }; } @@ -15,7 +19,14 @@ const config = { }, supports: { read: () => true - } + }, + additionalEntryPoints: () => [ + { + name: 'test-adapter-middleware', + file: 'test-adapter-middleware.js', + allowedFeatures: [] + } + ] }, prerender: { diff --git a/packages/kit/test/apps/basics/test-adapter-middleware.js b/packages/kit/test/apps/basics/test-adapter-middleware.js new file mode 100644 index 000000000000..abe4feae073f --- /dev/null +++ b/packages/kit/test/apps/basics/test-adapter-middleware.js @@ -0,0 +1,20 @@ +/** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + * @param {() => void} next + */ +export default function middleware(req, res, next) { + if (req.url === '/middleware/custom-response') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Custom Response

'); + } else { + if (req.url === '/middleware/reroute/a') { + req.url = '/middleware/reroute/b'; + } else if (req.url === '/middleware/headers') { + req.headers['x-custom-request-header'] = 'value'; + res.setHeader('x-custom-response-header', 'value'); + } + + next(); + } +} 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 fe1b82b7b2b004eeb92eb0c2c216ab3da9e622f7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 15:40:57 +0100 Subject: [PATCH 16/45] writing adapters docs --- .../99-writing-adapters.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index c4092af15fb2..c75ad6a2e18a 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -21,11 +21,18 @@ export default function (options) { async adapt(builder) { // adapter implementation }, - async emulate() { + async emulate({ importEntryPoint }) { return { async platform({ config, prerender }) { // the returned object becomes `event.platform` during dev, build and // preview. Its shape is that of `App.Platform` + }, + async beforeRequest(req, res, next) { + // Allows you to run code before a request to a prerendered page, a static asset, + // or a regular request to the SvelteKit runtime, both in dev and preview mode. + // Allows you to for example replicate middleware during dev and preview. + const module = await importEntryPoint('additional-entry-point'); + module.default(req, res, next); } } }, @@ -35,7 +42,16 @@ export default function (options) { // from `$app/server` in production, return `false` if it can't. // Or throw a descriptive error describing how to configure the deployment } - } + }, + additionalEntryPoints: () => [ + // Allows you to configure additional entry points for compilation. + // You can use these via `importEntryPoint` within `emulate` or reference them + // from `${builder.getServerDirectory()}/.js` for further compilation/bundling. + { + name: 'additional-entry-point', + file: 'my-project-root-relative-file.js', + } + ] }; return adapter; From 45b47068857bd8fa90cec50983de8315b0ed6fe3 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 16:53:24 +0100 Subject: [PATCH 17/45] fix --- packages/kit/src/exports/vite/dev/index.js | 54 ++++++++++++---------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 4a6e13f8f302..b1aefc3e16b0 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -473,31 +473,37 @@ export async function dev(vite, vite_config, svelte_config) { return next(); } - const base = `${vite.config.server.https ? 'https' : 'http'}://${ - req.headers[':authority'] || req.headers.host - }`; - const decoded = decodeURI(new URL(base + req.url).pathname); - const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1))); - const is_file = fs.existsSync(file) && !fs.statSync(file).isDirectory(); - const is_static_asset = !!get_asset_uri(req); + try { + const base = `${vite.config.server.https ? 'https' : 'http'}://${ + req.headers[':authority'] || req.headers.host + }`; + const decoded = decodeURI(new URL(base + req.url).pathname); // this can fail when req.url is malformed, hence the early try-catch + const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1))); + const is_file = fs.existsSync(file) && !fs.statSync(file).isDirectory(); + const is_static_asset = !!get_asset_uri(req); + + if (is_file && !is_static_asset) { + return next(); + } - if (is_file && !is_static_asset) { - return next(); + // Vite's base middleware strips out the base path. Restore it for the duration of beforeRequest + const prev_url = req.url; + req.url = req.originalUrl; + const _next = () => { + if (prev_url !== req.url) { + req.originalUrl = req.url; + req.url = /** @type {string} */ (req.url).slice(svelte_config.kit.paths.base.length); + } else { + req.url = prev_url; + } + return next(); + }; + return emulator.beforeRequest(req, res, _next); + } catch (e) { + const error = coalesce_to_error(e); + res.statusCode = 500; + res.end(fix_stack_trace(error)); } - - // Vite's base middleware strips out the base path. Restore it for the duration of beforeRequest - const prev_url = req.url; - req.url = req.originalUrl; - const _next = () => { - if (prev_url !== req.url) { - req.originalUrl = req.url; - req.url = /** @type {string} */ (req.url).slice(svelte_config.kit.paths.base.length); - } else { - req.url = prev_url; - } - return next(); - }; - return emulator.beforeRequest(req, res, _next); }); vite.middlewares.use((req, res, next) => { @@ -529,7 +535,7 @@ export async function dev(vite, vite_config, svelte_config) { req.headers[':authority'] || req.headers.host }`; - const decoded = decodeURI(new URL(base + req.url).pathname); + const decoded = decodeURI(new URL(base + req.url).pathname); // this can fail when req.url is malformed, hence the early try-catch const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1))); const is_file = fs.existsSync(file) && !fs.statSync(file).isDirectory(); const allowed = From c20d4234eae2741f54c56eaede9439b47f0f168c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 17:11:44 +0100 Subject: [PATCH 18/45] fix lint / test --- packages/adapter-node/index.js | 2 +- .../kit/test/apps/basics/test-adapter-middleware.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index aa3ff358d25c..8d020283b05d 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,5 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; diff --git a/packages/kit/test/apps/basics/test-adapter-middleware.js b/packages/kit/test/apps/basics/test-adapter-middleware.js index abe4feae073f..51bdcd8f4f16 100644 --- a/packages/kit/test/apps/basics/test-adapter-middleware.js +++ b/packages/kit/test/apps/basics/test-adapter-middleware.js @@ -1,16 +1,20 @@ +import { normalizeUrl } from '@sveltejs/kit'; + /** * @param {import('node:http').IncomingMessage} req * @param {import('node:http').ServerResponse} res * @param {() => void} next */ export default function middleware(req, res, next) { - if (req.url === '/middleware/custom-response') { + const { url, denormalize } = normalizeUrl(req.url || '/'); + + if (url.pathname === '/middleware/custom-response') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Custom Response

'); } else { - if (req.url === '/middleware/reroute/a') { - req.url = '/middleware/reroute/b'; - } else if (req.url === '/middleware/headers') { + if (url.pathname === '/middleware/reroute/a') { + req.url = denormalize('/middleware/reroute/b').pathname; + } else if (url.pathname === '/middleware/headers') { req.headers['x-custom-request-header'] = 'value'; res.setHeader('x-custom-response-header', 'value'); } From 62289cb8c70364f0ff094d79da04c3f363e0d9c8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 21:16:56 +0100 Subject: [PATCH 19/45] vercel-middleware -> edge-middleware --- .../docs/25-build-and-deploy/90-adapter-vercel.md | 6 +++--- packages/adapter-vercel/index.js | 10 +++++----- 2 files changed, 8 insertions(+), 8 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 ff4af34aff24..94c047bb2857 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -143,10 +143,10 @@ A list of valid query parameters that contribute to the cache key. Other paramet ## Edge Middleware -You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing a `vercel-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing a `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. ```js -/// file: vercel-middleware.js +/// file: edge-middleware.js import { rewrite, next } from '@vercel/edge'; export default async function middleware(request: Request) { @@ -186,7 +186,7 @@ function split_cookies(cookies: string) { By default, middleware runs on all requests except for files within `_app/immutable`. You can customize this by exporting a `config` object with a `matcher` property as described in Vercel's [API documentation](https://vercel.com/docs/functions/edge-middleware/middleware-api#match-paths-based-on-custom-matcher-config). -If you want to run code prior to a request but neither have prerendered nor ISR'd pages and have no rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. +> [!NOTE] If you want to run code prior to a request but neither have prerendered nor ISR'd pages and have no rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. ## Environment variables diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 11cc3b1cb92e..e0e910a0c010 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -43,9 +43,9 @@ const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ -let middleware_path = can_use_middleware ? 'vercel-middleware.js' : null; +let middleware_path = can_use_middleware ? 'edge-middleware.js' : null; if (middleware_path && !fs.existsSync(middleware_path)) { - middleware_path = 'vercel-middleware.ts'; + middleware_path = 'edge-middleware.ts'; if (!fs.existsSync(middleware_path)) middleware_path = null; } @@ -250,7 +250,7 @@ const plugin = function (defaults = {}) { builder.copy(`${files}/middleware.js`, dest, { replace: { SERVER_INIT: `${relativePath}/init.js`, - MIDDLEWARE: `${relativePath}/vercel-middleware.js`, + MIDDLEWARE: `${relativePath}/edge-middleware.js`, PUBLIC_PREFIX: builder.config.kit.env.publicPrefix, PRIVATE_PREFIX: builder.config.kit.env.privatePrefix } @@ -511,7 +511,7 @@ const plugin = function (defaults = {}) { return { beforeRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes - const middleware = await opts.importEntryPoint('vercel-middleware'); + const middleware = await opts.importEntryPoint('edge-middleware'); const matcher = new RegExp(get_regex_from_matchers(middleware.config?.matcher)); const original_url = /** @type {string} */ (req.url); @@ -572,7 +572,7 @@ const plugin = function (defaults = {}) { return [ { - name: 'vercel-middleware', + name: 'edge-middleware', file: middleware_path, disallowedFeatures: ['$app/server:read'] } From 9a3c6e28c88eedfb63efd6ffdabd7a96932c3a8c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 19 Feb 2025 21:18:52 +0100 Subject: [PATCH 20/45] netlify edge middleware --- .../25-build-and-deploy/80-adapter-netlify.md | 31 +++ .../25-build-and-deploy/90-adapter-vercel.md | 2 +- packages/adapter-netlify/index.js | 232 +++++++++++++++--- packages/adapter-netlify/internal.d.ts | 8 + packages/adapter-netlify/src/middleware.js | 10 + 5 files changed, 246 insertions(+), 37 deletions(-) create mode 100644 packages/adapter-netlify/src/middleware.js 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..9f59ab3876b1 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,37 @@ export default { }; ``` +## Edge Middleware + +You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. + +```js +/// file: edge-middleware.js +export default async function middleware(request: Request, { next, cookies }) { + const url = new URL(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve feature flag from cookies + let flag = cookies.get('flag'); + + // Fall back to random value if this is a new visitor + flag ||= Math.random() > 0.5 ? 'a' : 'b'; + + // Set a cookie to remember the feature flags for this visitor + cookies.set('flag', flag); + + // Get destination URL based on the feature flag + return new URL(flag === 'a' ? '/home-a' : '/home-b'); +} +``` + +Middleware runs on all requests except for files within `_app/immutable`. + +> [!NOTE] Locally during dev and preview this only approximates the capabilities of edge functions. Notably, you cannot read the request or response body, and many properties on the context object are `null`ed. + +> [!NOTE] If you want to run code prior to a request but neither have prerendered pages nor rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. + ## 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 94c047bb2857..185629c07323 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 ## Edge Middleware -You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing a `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 034acd70ab94..2243c5a7b00a 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -5,6 +5,9 @@ import { builtinModules } from 'node:module'; import process from 'node:process'; import esbuild from 'esbuild'; import toml from '@iarna/toml'; +// TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as kit from '@sveltejs/kit'; +import * as node_kit from '@sveltejs/kit/node'; /** * @typedef {{ @@ -42,6 +45,16 @@ const edge_set_in_env_var = const FUNCTION_PREFIX = 'sveltekit-'; +const [major, minor] = kit.VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'edge-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'edge-middleware.ts'; + if (!existsSync(middleware_path)) middleware_path = null; +} + /** @type {import('./index.js').default} */ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { return { @@ -90,6 +103,10 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { `\n\n/${builder.getAppPath()}/immutable/*\n cache-control: public\n cache-control: immutable\n cache-control: max-age=31536000\n` ); + if (middleware_path) { + await generate_edge_middleware({ builder }); + } + if (edge) { if (split) { throw new Error('Cannot use `split: true` alongside `edge: true`'); @@ -101,6 +118,105 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } }, + emulate: (opts) => { + if (!middleware_path) return {}; + + return { + beforeRequest: async (req, res, next) => { + // We have to import this here or else we wouldn't notice when the middleware file changes + const middleware = await opts.importEntryPoint('edge-middleware'); + + const request = new Request(new URL(req.url, 'http://localhost'), { + headers: node_kit.getRequestHeaders(req), + method: req.method, + body: + // We omit the body here because it would consume the stream + req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] + ? undefined + : 'Cannot read body in dev mode' + }); + + // Netlify allows you to modify the response object after calling next(). + // This isn't replicable using Vite or Polka middleware, so we approximate it. + const fake_response = new Response(); + + const response = await middleware.default(request, { + // approximation of the Netlify context object + // https://docs.netlify.com/edge-functions/api/ + account: { id: null }, + cookies: { + /** @param {string} name */ + get: (name) => + req.headers.cookie + ?.split(';') + .find((c) => c.trim().startsWith(`${name}=`)) + ?.split('=')[1], + /** @param {string} name @param {string} value */ + set: (name, value) => res.appendHeader('Set-Cookie', `${name}=${value}`), + /** @param {string} name */ + delete: (name) => res.appendHeader('Set-Cookie', `${name}=; Max-Age=0`) + }, + deploy: { + context: null, + id: null, + published: null + }, + geo: { + city: null, + country: { code: null, name: null }, + latitude: null, + longitude: null, + subdivision: { code: null, name: null }, + timezone: null, + postalCode: null + }, + ip: null, + params: {}, + requestId: null, + site: { + id: null, + name: null, + url: null + }, + /** @param {any} request */ + next: (request) => { + if (request instanceof Request) { + const url = new URL(request.url); + req.url = url.pathname + url.search; + for (const header of request.headers) { + req.headers[header[0]] = header[1]; + } + } + + return fake_response; + } + }); + + for (const header of fake_response.headers) { + res.setHeader(header[0], header[1]); + } + + if (response instanceof URL) { + // https://docs.netlify.com/edge-functions/api/#return-a-rewrite + req.url = response.pathname + response.search; + return next(); + } else if (response instanceof Response && response !== fake_response) { + // We assume that middleware bails out when returning a custom response + return node_kit.setResponse(res, response); + } else { + return next(); + } + } + }; + }, + + additionalEntryPoints: () => { + if (!middleware_path) return []; + return [ + { name: 'edge-middleware', file: middleware_path, disallowedFeatures: ['$app/server:read'] } + ]; + }, + supports: { // reading from the filesystem only works in serverless functions read: ({ route }) => { @@ -127,6 +243,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,51 +253,70 @@ async function generate_edge_functions({ builder }) { } }); - const manifest = builder.generateManifest({ - relativePath - }); - + const manifest = builder.generateManifest({ relativePath }); writeFileSync(`${tmp}/manifest.js`, `export const manifest = ${manifest};\n`); /** @type {{ assets: Set }} */ const { assets } = (await import(`${tmp}/manifest.js`)).manifest; - const path = '/*'; - // We only need to specify paths without the trailing slash because - // Netlify will handle the optional trailing slash for us - const excludedPath = [ - // Contains static files - `/${builder.getAppPath()}/*`, - ...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}`; - }), - // Should not be served by SvelteKit at all - '/.netlify/*' - ]; + await bundle_edge_function({ + builder, + name: 'render', + path: '/*', + excludedPath: [ + ...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}`; + }) + ] + }); +} - /** @type {HandlerManifest} */ - const edge_manifest = { - functions: [ - { - function: 'render', - path, - excludedPath - } - ], - version: 1 - }; +/** + * @param {object} params + * @param {import('@sveltejs/kit').Builder} params.builder + */ +async function generate_edge_middleware({ builder }) { + const tmp = builder.getBuildDirectory('netlify-tmp'); + builder.rimraf(tmp); + builder.mkdirp(tmp); + + builder.mkdirp('.netlify/edge-functions'); + + builder.log.minor('Generating Edge Middleware...'); + + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${files}/middleware.js`, `${tmp}/entry.js`, { + replace: { + SERVER_INIT: `${relativePath}/init.js`, + MIDDLEWARE: `${relativePath}/edge-middleware.js` + } + }); + + await bundle_edge_function({ builder, name: 'edge-middleware', path: '/*', excludedPath: [] }); +} + +/** + * @param {object} params + * @param {import('@sveltejs/kit').Builder} params.builder + * @param {string} params.name + * @param {string} params.path + * @param {string[]} params.excludedPath + */ +async function bundle_edge_function({ builder, name, path, excludedPath }) { + const tmp = builder.getBuildDirectory('netlify-tmp'); await esbuild.build({ entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', + outfile: `.netlify/edge-functions/${name}.js`, bundle: true, format: 'esm', platform: 'browser', @@ -200,8 +336,32 @@ async function generate_edge_functions({ builder }) { alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) }); + /** @type {HandlerManifest} */ + const edge_manifest = { + functions: [ + ...(existsSync('.netlify/edge-functions/manifest.json') + ? JSON.parse(readFileSync('.netlify/edge-functions/manifest.json', 'utf-8')).functions + : []), + { + function: name, + path, + // We only need to specify paths without the trailing slash because + // Netlify will handle the optional trailing slash for us + excludedPath: [ + // Contains static files + `/${builder.getAppPath()}/*`, + ...excludedPath, + // Should not be served by SvelteKit at all + '/.netlify/*' + ] + } + ], + version: 1 + }; + writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); } + /** * @param { object } params * @param {import('@sveltejs/kit').Builder} params.builder diff --git a/packages/adapter-netlify/internal.d.ts b/packages/adapter-netlify/internal.d.ts index 55da8ba1fbf5..f18e157ef060 100644 --- a/packages/adapter-netlify/internal.d.ts +++ b/packages/adapter-netlify/internal.d.ts @@ -2,6 +2,14 @@ declare module '0SERVER' { export { Server } from '@sveltejs/kit'; } +declare module 'SERVER_INIT' { + export { initServer } from '@sveltejs/kit'; +} + +declare module 'MIDDLEWARE' { + export default function (req: Request, context: any): any; +} + declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js new file mode 100644 index 000000000000..d868b833d7f2 --- /dev/null +++ b/packages/adapter-netlify/src/middleware.js @@ -0,0 +1,10 @@ +import { initServer } from 'SERVER_INIT'; +export { default } from 'MIDDLEWARE'; + +initServer({ + env: { + env: /** @type {Record} */ (process.env), + public_prefix: 'PUBLIC_PREFIX', + private_prefix: 'PRIVATE_PREFIX' + } +}); From e994082c7bc1bf8c0e58fab7b1c0f1753009856a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 17:31:16 +0100 Subject: [PATCH 21/45] cloudflare adapter --- packages/adapter-cloudflare/index.js | 93 ++++++++++++++- packages/adapter-cloudflare/internal.d.ts | 4 + packages/adapter-cloudflare/src/worker.js | 137 +++++++++++++--------- packages/adapter-netlify/index.js | 4 +- 4 files changed, 180 insertions(+), 58 deletions(-) diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index ceac64d92a2a..34b6d5e3306b 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -2,6 +2,19 @@ import { existsSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getPlatformProxy } from 'wrangler'; +import { VERSION } from '@sveltejs/kit'; +// TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as node_kit from '@sveltejs/kit/node'; + +const [major, minor] = VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'cloudflare-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'cloudflare-middleware.ts'; + if (!existsSync(middleware_path)) middleware_path = null; +} /** @type {import('./index.js').default} */ export default function (options = {}) { @@ -45,6 +58,11 @@ export default function (options = {}) { `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); + writeFileSync( + `${tmp}/noop-middleware.js`, + 'export function onRequest({ next }) { return next() }' + ); + writeFileSync( `${dest}/_routes.json`, JSON.stringify(get_routes_json(builder, written_files, options.routes ?? {}), null, '\t') @@ -63,11 +81,15 @@ export default function (options = {}) { 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: middleware_path + ? `${relativePath}/cloudflare-middleware.js` + : `${path.posix.relative(dest, tmp)}/noop-middleware.js` } }); }, - emulate() { + + emulate(opts) { // 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 const get_emulated = async () => { @@ -98,8 +120,75 @@ export default function (options = {}) { platform: async ({ prerender }) => { emulated ??= await get_emulated(); return prerender ? emulated.prerender_platform : emulated.platform; + }, + beforeRequest: async (req, res, next) => { + emulated ??= await get_emulated(); + const middleware = await opts.importEntryPoint('cloudflare-middleware'); + + const request = new Request(new URL(req.url, 'http://localhost'), { + headers: node_kit.getRequestHeaders(req), + method: req.method, + body: + // We omit the body here because it would consume the stream + req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] + ? undefined + : 'Cannot read body in dev mode' + }); + // @ts-expect-error slight type mismatch which seems harmless + request.cf = emulated.platform.cf; + + // Cloudflare allows you to modify the response object after calling next(). + // This isn't replicable using Vite or Polka middleware, so we approximate it. + const fake_response = new Response(); + + const response = await middleware.onRequest( + /** @type {Partial>} */ ({ + request: /** @type {any} */ (request), // requires a fetcher property which we don't have + env: /** @type {any} */ (emulated.platform).env, // does exist, see above + ...emulated.platform.context, + next: async (input, init) => { + // More any casts because of annoying CF types + const adjusted = + input instanceof Request + ? input + : input && new Request(/** @type {any} */ (input), /** @type {any} */ (init)); + + if (adjusted) { + const url = new URL(adjusted.url); + req.url = url.pathname + url.search; + for (const [key, value] of adjusted.headers) { + req.headers[key] = value; + } + } + + return /** @type {any} */ (fake_response); + } + }) + ); + + if (response instanceof Response && response !== fake_response) { + // We assume that middleware bails out when returning a custom response + node_kit.setResponse(res, response); + } else { + for (const header of fake_response.headers) { + res.setHeader(header[0], header[1]); + } + + next(); + } } }; + }, + + additionalEntryPoints: () => { + if (!middleware_path) return []; + return [ + { + name: 'cloudflare-middleware', + file: middleware_path, + disallowedFeatures: ['$app/server:read'] + } + ]; } }; } diff --git a/packages/adapter-cloudflare/internal.d.ts b/packages/adapter-cloudflare/internal.d.ts index 6c79569f7f7f..6aa3ce58b22b 100644 --- a/packages/adapter-cloudflare/internal.d.ts +++ b/packages/adapter-cloudflare/internal.d.ts @@ -10,3 +10,7 @@ declare module 'MANIFEST' { export const app_path: string; export const base_path: string; } + +declare module 'MIDDLEWARE' { + export function onRequest(context: any): Promise | Response; +} diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index c3c27a0b041f..48661126006f 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -1,5 +1,6 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; +import { onRequest } from 'MIDDLEWARE'; import * as Cache from 'worktop/cfw.cache'; const server = new Server(manifest); @@ -11,67 +12,95 @@ const version_file = `${app_path}/version.json`; /** @type {import('worktop/cfw').Module.Worker<{ ASSETS: import('worktop/cfw.durable').Durable.Object }>} */ const worker = { - async fetch(req, env, context) { + async fetch(request, env, context) { // @ts-ignore await server.init({ env }); // 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)); - if (res) return res; - - let { pathname, search } = new URL(req.url); - try { - pathname = decodeURIComponent(pathname); - } catch { - // ignore invalid URI - } - - const stripped_pathname = pathname.replace(/\/$/, ''); - - // prerendered pages and /static files - let is_static_asset = false; - const filename = stripped_pathname.slice(base_path.length + 1); - if (filename) { - is_static_asset = - manifest.assets.has(filename) || - manifest.assets.has(filename + '/index.html') || - filename in manifest._.server_assets || - filename + '/index.html' in manifest._.server_assets; - } - - let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; - - if ( - is_static_asset || - prerendered.has(pathname) || - pathname === version_file || - pathname.startsWith(immutable) - ) { - res = await env.ASSETS.fetch(req); - } else if (location && prerendered.has(location)) { - if (search) location += search; - res = new Response('', { - status: 308, - headers: { - location - } - }); - } else { - // dynamically-generated pages - res = await server.respond(req, { - // @ts-ignore - platform: { env, context, caches, cf: req.cf }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); - } + let pragma = request.headers.get('cache-control') || ''; + let response = !pragma.includes('no-cache') && (await Cache.lookup(request)); + if (response) return response; + + /** + * @param {Request | string} input + * @param {RequestInit} init + */ + const next = async (input, init) => { + /** @type {Request} */ + let req; + + if (!input && !init) { + req = request; + } else if (input instanceof Request) { + req = input; + } else { + req = new Request(input, init); + } + + return inner_fetch(request, env, context); + }; + + response = await onRequest({ ...context, request, env, next }); // 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') || ''; - return pragma && res.status < 400 ? Cache.save(req, res, context) : res; + pragma = response.headers.get('cache-control') || ''; + return pragma && response.status < 400 ? Cache.save(request, response, context) : response; } }; +/** @type {import('worktop/cfw').Module.Worker<{ ASSETS: import('worktop/cfw.durable').Durable.Object }>['fetch']} */ +async function inner_fetch(request, env, context) { + let { pathname, search } = new URL(request.url); + try { + pathname = decodeURIComponent(pathname); + } catch { + // ignore invalid URI + } + + const stripped_pathname = pathname.replace(/\/$/, ''); + + // prerendered pages and /static files + let is_static_asset = false; + const filename = stripped_pathname.slice(base_path.length + 1); + if (filename) { + is_static_asset = + manifest.assets.has(filename) || + manifest.assets.has(filename + '/index.html') || + filename in manifest._.server_assets || + filename + '/index.html' in manifest._.server_assets; + } + + /** @type {Response} */ + let response; + let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; + + if ( + is_static_asset || + prerendered.has(pathname) || + pathname === version_file || + pathname.startsWith(immutable) + ) { + response = await env.ASSETS.fetch(request); + } else if (location && prerendered.has(location)) { + if (search) location += search; + response = new Response('', { + status: 308, + headers: { + location + } + }); + } else { + // dynamically-generated pages + response = await server.respond(request, { + // @ts-ignore + platform: { env, context, caches, cf: request.cf }, + getClientAddress() { + return request.headers.get('cf-connecting-ip'); + } + }); + } + + return response; +} + export default worker; diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 2243c5a7b00a..c246b40600d2 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -5,8 +5,8 @@ import { builtinModules } from 'node:module'; import process from 'node:process'; import esbuild from 'esbuild'; import toml from '@iarna/toml'; +import { VERSION } from '@sveltejs/kit'; // TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit -import * as kit from '@sveltejs/kit'; import * as node_kit from '@sveltejs/kit/node'; /** @@ -45,7 +45,7 @@ const edge_set_in_env_var = const FUNCTION_PREFIX = 'sveltekit-'; -const [major, minor] = kit.VERSION.split('.').map(Number); +const [major, minor] = VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ From 05c941ea6a640bb7219af36eada63df0d0580f22 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 17:32:43 +0100 Subject: [PATCH 22/45] fix --- packages/adapter-netlify/src/middleware.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js index d868b833d7f2..07f1bd94e9bb 100644 --- a/packages/adapter-netlify/src/middleware.js +++ b/packages/adapter-netlify/src/middleware.js @@ -3,7 +3,8 @@ export { default } from 'MIDDLEWARE'; initServer({ env: { - env: /** @type {Record} */ (process.env), + // @ts-ignore + env: Deno.env.toObject(), public_prefix: 'PUBLIC_PREFIX', private_prefix: 'PRIVATE_PREFIX' } From 1d9839a2fddc6ee8912fe298b1d4157a5e25f654 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 17:33:36 +0100 Subject: [PATCH 23/45] fix --- packages/adapter-vercel/files/middleware.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js index eb6636cbeb79..90b04f9e2260 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -1,3 +1,5 @@ +/* eslint-disable n/prefer-global/process -- + Vercel Edge Runtime does not support node:process */ import { normalizeUrl } from '@sveltejs/kit'; import { initServer } from 'SERVER_INIT'; import * as user_middleware from 'MIDDLEWARE'; From 5d5eb26ec09b618477b47c024d16ad5861c5bcbd Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 18:14:33 +0100 Subject: [PATCH 24/45] fix, docs --- .../60-adapter-cloudflare.md | 61 +++++++++++++++++++ packages/adapter-cloudflare/package.json | 2 +- packages/adapter-cloudflare/src/worker.js | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) 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..8a3f088fd22b 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,67 @@ 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`. +## Pages Middleware + +You can deploy one middleware function that closely follows the [Pages Middleware API](https://developers.cloudflare.com/pages/functions/middleware/). You can use it to intercept requests even for prerendered pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. + +> [!NOTE] It isn't really Pages Middleware because the adapter compiles to a [single `_worker.js` file](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) (also see the [Notes](#Notes) section), which ignores middleware, but it closely mirrors its capabilities. + +To get started, place a `cloudflare-middleware.js` file at the root of your project and export a `onRequest` function from it: + +```js +/// file: cloudflare-middleware.js +// @filename: ambient.d.ts +declare module '@cloudflare/workers-types'; + +// @filename: index.js +// ---cut--- +import { normalizeUrl } from '@sveltejs/kit'; + +/** @type {import('@cloudflare/workers-types'.EventContext)} */ +export function onRequest(context) { + const { url, denormalize } = normalizeUrl(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve cookies which contain the feature flags. + let flag = split_cookies(request.headers.get('cookie') ?? '')?.['flags']; + + // Fall back to random value if this is a new visitor + flag ||= Math.random() > 0.5 ? 'a' : 'b'; + + // Get destination URL based on the feature flag + request = new Request(denormalize(flag === 'a' ? '/home-a' : '/home-b'), request); + + const response = await next(request); + + // Set a cookie to remember the feature flags for this visitor + response.headers.set('Set-Cookie', `flags=${flag}; Path=/`); + + return response; +} + +function split_cookies(cookies: string) { + return cookies.split(';').reduce( + (acc, cookie) => { + const [name, value] = cookie.trim().split('='); + acc[name] = value; + return acc; + }, + {} as Record + ); +} + +``` + +The `context` parameter closely follows the [EventContext](https://developers.cloudflare.com/pages/functions/api-reference/#eventcontext) object but is missing some Pages-specific parameters such as `data`, `params` and `functionPath`. + +The middleware runs on all requests that your worker is invoked for, which is dependent on the [`include/exlcude` options](#Options-routes). + +> [!NOTE] Locally during dev and preview this only approximates the capabilities of middleware. Notably, you cannot read the request or response body, and middleware runs on all requests except those that would end up in `_app/immutable`. + +> [!NOTE] If you want to run code prior to a request but neither have prerendered pages nor rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. + ## 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/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index a705531a6863..5ce1450bf88d 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 --format=esm", "lint": "prettier --check .", "format": "pnpm lint --write", "check": "tsc --skipLibCheck", diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index 48661126006f..519e5f925d41 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -36,7 +36,7 @@ const worker = { req = new Request(input, init); } - return inner_fetch(request, env, context); + return inner_fetch(req, env, context); }; response = await onRequest({ ...context, request, env, next }); From 70ad0f848f3f9583ad8298c8fe4f07355356b875 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 18:39:44 +0100 Subject: [PATCH 25/45] fix, docs tweaks --- .../25-build-and-deploy/40-adapter-node.md | 43 ++++++++++++++++++- .../60-adapter-cloudflare.md | 6 ++- .../25-build-and-deploy/80-adapter-netlify.md | 17 ++++++-- .../25-build-and-deploy/90-adapter-vercel.md | 10 ++++- packages/adapter-cloudflare/index.js | 9 ++-- packages/kit/src/exports/public.d.ts | 8 ++-- packages/kit/types/index.d.ts | 8 ++-- 7 files changed, 79 insertions(+), 22 deletions(-) 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 c01d3e9596b0..ba264b546e7c 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -143,9 +143,48 @@ You can integrate Express or Polka middleware into your SvelteKit application bu ```js /// file: node-middleware.js +// @filename: ambient.d.ts +declare module '@vercel/edge'; + +// @filename: index.js +// ---cut--- +import { normalizeUrl } from '@sveltejs/kit'; + +/** + * @param {import('polka').Request} req + * @param {import('polka').Response} res + * @param {import('polka').NextHandler} next + */ export default function middleware(req, res, next) { - console.log(`Received ${req.method} on ${req.url}`); - next(); // move on + const { url, denormalize } = normalizeUrl(req.url); + + if (url.pathname !== '/') return next(); + + // Retrieve feature flag from cookies + let flag = split_cookies(req.headers.cookie ?? '')?.flag; + + // Fall back to random value if this is a new visitor + flag ||= Math.random() > 0.5 ? 'a' : 'b'; + + // Get destination URL based on the feature flag + const rewritten = denormalize(flag === 'a' ? '/home-a' : '/home-b'); + req.url = rewritten.pathname + rewritten.search; + + // Set a cookie to remember the feature flags for this visitor + res.appendHeader('Set-Cookie', `flag=${flag}; Path=/`); + + return next(); +} + +function split_cookies(cookies: string) { + return cookies.split(';').reduce( + (acc, cookie) => { + const [name, value] = cookie.trim().split('='); + acc[name] = value; + return acc; + }, + {} as Record + ); } ``` 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 8a3f088fd22b..e2f984964ba4 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -125,8 +125,10 @@ declare module '@cloudflare/workers-types'; // ---cut--- import { normalizeUrl } from '@sveltejs/kit'; -/** @type {import('@cloudflare/workers-types'.EventContext)} */ -export function onRequest(context) { +/** + * @param {import('@cloudflare/workers-types'.EventContext)} context + */ +export function onRequest({ request, next }) { const { url, denormalize } = normalizeUrl(request.url); if (url.pathname !== '/') return next(); 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 9f59ab3876b1..6ef951ce41a4 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -72,8 +72,19 @@ You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.co ```js /// file: edge-middleware.js -export default async function middleware(request: Request, { next, cookies }) { - const url = new URL(request.url); +// @filename: ambient.d.ts +declare module '@netlify/edge-functions'; + +// @filename: index.js +// ---cut--- +import { normalizeUrl } from '@sveltejs/kit'; + +/** + * @param {Request} request + * @param {import('@netlify/edge-functions').Context} context + */ +export default async function middleware(request, { next, cookies }) { + const { url, denormalize } = normalizeUrl(request.url); if (url.pathname !== '/') return next(); @@ -87,7 +98,7 @@ export default async function middleware(request: Request, { next, cookies }) { cookies.set('flag', flag); // Get destination URL based on the feature flag - return new URL(flag === 'a' ? '/home-a' : '/home-b'); + return denormalize(flag === 'a' ? '/home-a' : '/home-b'); } ``` 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 185629c07323..663858b3dfd3 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -147,9 +147,17 @@ You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/e ```js /// file: edge-middleware.js +// @filename: ambient.d.ts +declare module '@vercel/edge'; + +// @filename: index.js +// ---cut--- import { rewrite, next } from '@vercel/edge'; -export default async function middleware(request: Request) { +/** + * @param {Request} request + */ +export default async function middleware(request) { const url = new URL(request.url); if (url.pathname !== '/') return next(); diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 34b6d5e3306b..1e5bd788a956 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -143,10 +143,11 @@ export default function (options = {}) { const response = await middleware.onRequest( /** @type {Partial>} */ ({ + // eslint-disable-next-line object-shorthand request: /** @type {any} */ (request), // requires a fetcher property which we don't have env: /** @type {any} */ (emulated.platform).env, // does exist, see above ...emulated.platform.context, - next: async (input, init) => { + next: (input, init) => { // More any casts because of annoying CF types const adjusted = input instanceof Request @@ -161,20 +162,20 @@ export default function (options = {}) { } } - return /** @type {any} */ (fake_response); + return Promise.resolve(/** @type {any} */ (fake_response)); } }) ); if (response instanceof Response && response !== fake_response) { // We assume that middleware bails out when returning a custom response - node_kit.setResponse(res, response); + return node_kit.setResponse(res, response); } else { for (const header of fake_response.headers) { res.setHeader(header[0], header[1]); } - next(); + return next(); } } }; diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 8993e43a68c2..db95040ce2dc 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -288,9 +288,7 @@ export interface Emulator { /** * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. * Can be used to replicate middleware behavior in dev mode. - * Implementation notes: - * - `req.url` does not include the base path, but `req.originalUrl` does, and you will have to adjust both in case you want to proxy/rewrite requests. - * - you either have to call `next()` to pass on the request/response, or `res.end()` to finish the request + * Implementation note: You either have to call `next()` to pass on the request/response, or `res.end()` to finish the request */ beforeRequest?: ( req: IncomingMessage & { originalUrl?: string }, @@ -1318,7 +1316,7 @@ export interface RouteDefinition { } /** - * Represents the SvelteKit server runtime. Adapters should use this via `${output}/server/index.js` to create a server to send requests to. + * Represents the SvelteKit server runtime. Adapters should use this via `${builder.getServerDirectory()}/index.js` to create a server to send requests to. */ export class Server { constructor(manifest: SSRManifest); @@ -1327,7 +1325,7 @@ export class Server { } /** - * Similar to Server#init. Can be used via `${output}/server/init.js` for other entry points that don't start the server but still need to setup the environment. + * Similar to Server#init. Can be used via `${builder.getServerDirectory()}/init.js` for other entry points that don't start the server but still need to setup the environment. */ export function initServer(options: { /** Required for `$env/*` to work */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index fac7100faab8..59191b421fbb 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -269,9 +269,7 @@ declare module '@sveltejs/kit' { /** * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. * Can be used to replicate middleware behavior in dev mode. - * Implementation notes: - * - `req.url` does not include the base path, but `req.originalUrl` does, and you will have to adjust both in case you want to proxy/rewrite requests. - * - you either have to call `next()` to pass on the request/response, or `res.end()` to finish the request + * Implementation note: You either have to call `next()` to pass on the request/response, or `res.end()` to finish the request */ beforeRequest?: ( req: IncomingMessage & { originalUrl?: string }, @@ -1299,7 +1297,7 @@ declare module '@sveltejs/kit' { } /** - * Represents the SvelteKit server runtime. Adapters should use this via `${output}/server/index.js` to create a server to send requests to. + * Represents the SvelteKit server runtime. Adapters should use this via `${builder.getServerDirectory()}/index.js` to create a server to send requests to. */ export class Server { constructor(manifest: SSRManifest); @@ -1308,7 +1306,7 @@ declare module '@sveltejs/kit' { } /** - * Similar to Server#init. Can be used via `${output}/server/init.js` for other entry points that don't start the server but still need to setup the environment. + * Similar to Server#init. Can be used via `${builder.getServerDirectory()}/init.js` for other entry points that don't start the server but still need to setup the environment. */ export function initServer(options: { /** Required for `$env/*` to work */ From b159f219668de78e860789bd2198ab4ef8af1534 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 21:00:28 +0100 Subject: [PATCH 26/45] fix --- documentation/docs/25-build-and-deploy/40-adapter-node.md | 5 +++-- .../docs/25-build-and-deploy/60-adapter-cloudflare.md | 3 ++- documentation/docs/25-build-and-deploy/90-adapter-vercel.md | 3 ++- packages/kit/src/core/postbuild/prerender.js | 5 ++++- 4 files changed, 11 insertions(+), 5 deletions(-) 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 ba264b546e7c..ef309f5caa34 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -144,7 +144,7 @@ You can integrate Express or Polka middleware into your SvelteKit application bu ```js /// file: node-middleware.js // @filename: ambient.d.ts -declare module '@vercel/edge'; +declare module 'polka'; // @filename: index.js // ---cut--- @@ -176,7 +176,8 @@ export default function middleware(req, res, next) { return next(); } -function split_cookies(cookies: string) { +/** @param {string} cookies */ +function split_cookies(cookies) { return cookies.split(';').reduce( (acc, cookie) => { const [name, value] = cookie.trim().split('='); 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 e2f984964ba4..36e0bedfc3a2 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -150,7 +150,8 @@ export function onRequest({ request, next }) { return response; } -function split_cookies(cookies: string) { +/** @param {string} cookies */ +function split_cookies(cookies) { return cookies.split(';').reduce( (acc, cookie) => { const [name, value] = cookie.trim().split('='); 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 663858b3dfd3..d2e0a02e7d54 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -180,7 +180,8 @@ export default async function middleware(request) { ); } -function split_cookies(cookies: string) { +/** @param {string} cookies */ +function split_cookies(cookies) { return cookies.split(';').reduce( (acc, cookie) => { const [name, value] = cookie.trim().split('='); diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index bb2da7f452dd..2e069820e74d 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -117,7 +117,10 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { return { prerendered, prerender_map }; } - const emulator = await config.adapter?.emulate?.({ importFile: (file) => import(file) }); + const emulator = await config.adapter?.emulate?.({ + importEntryPoint: (entry) => + import(pathToFileURL(`${config.outDir}/output/server/${entry}.js`).href) + }); /** @type {import('types').Logger} */ const log = logger({ verbose }); From 4335dc51e54b0d4c7cbcf650b6d6549f937c9474 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 20 Feb 2025 21:04:15 +0100 Subject: [PATCH 27/45] changesets --- .changeset/brave-trains-hang.md | 5 +++++ .changeset/eleven-snakes-vanish.md | 5 +++++ .changeset/famous-boats-enjoy.md | 5 +++++ .changeset/nice-tools-marry.md | 5 +++++ .changeset/tasty-shirts-shout.md | 5 +++++ 5 files changed, 25 insertions(+) create mode 100644 .changeset/brave-trains-hang.md create mode 100644 .changeset/eleven-snakes-vanish.md create mode 100644 .changeset/famous-boats-enjoy.md create mode 100644 .changeset/nice-tools-marry.md create mode 100644 .changeset/tasty-shirts-shout.md diff --git a/.changeset/brave-trains-hang.md b/.changeset/brave-trains-hang.md new file mode 100644 index 000000000000..4e0492f3e84e --- /dev/null +++ b/.changeset/brave-trains-hang.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: support edge middleware diff --git a/.changeset/eleven-snakes-vanish.md b/.changeset/eleven-snakes-vanish.md new file mode 100644 index 000000000000..1fc1ab39f3a4 --- /dev/null +++ b/.changeset/eleven-snakes-vanish.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': minor +--- + +feat: add possibility for adding polka/express middleware diff --git a/.changeset/famous-boats-enjoy.md b/.changeset/famous-boats-enjoy.md new file mode 100644 index 000000000000..9f18d0941672 --- /dev/null +++ b/.changeset/famous-boats-enjoy.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow adapters to influence compilation entry points and to intercept requests at dev/preview time diff --git a/.changeset/nice-tools-marry.md b/.changeset/nice-tools-marry.md new file mode 100644 index 000000000000..4d3222e22ed1 --- /dev/null +++ b/.changeset/nice-tools-marry.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: add possibility of defining edge middleware diff --git a/.changeset/tasty-shirts-shout.md b/.changeset/tasty-shirts-shout.md new file mode 100644 index 000000000000..279ef531e32e --- /dev/null +++ b/.changeset/tasty-shirts-shout.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare': minor +--- + +feat: add pages-like middleware From b5f68cb33a10f722963a059cfc9f42b5dc5cb93e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 21 Feb 2025 10:58:04 +0100 Subject: [PATCH 28/45] drive-by notes fix --- documentation/docs/25-build-and-deploy/90-adapter-vercel.md | 6 +++--- 1 file changed, 3 insertions(+), 3 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 d2e0a02e7d54..01908807a4cb 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -90,7 +90,7 @@ export default { Vercel supports [Incremental Static Regeneration](https://vercel.com/docs/incremental-static-regeneration) (ISR), which provides the performance and cost advantages of prerendered content with the flexibility of dynamically rendered content. -> Use ISR only on routes where every visitor should see the same content (much like when you prerender). If there's anything user-specific happening (like session cookies), they should happen on the client via JavaScript only to not leak sensitive information across visits +> [!NOTE] Use ISR only on routes where every visitor should see the same content (much like when you prerender). If there's anything user-specific happening (like session cookies), they should happen on the client via JavaScript only to not leak sensitive information across visits To add ISR to a route, include the `isr` property in your `config` object: @@ -107,7 +107,7 @@ export const config = { }; ``` -> Using ISR on a route with `export const prerender = true` will have no effect, since the route is prerendered at build time +> [!NOTE] Using ISR on a route with `export const prerender = true` will have no effect, since the route is prerendered at build time The `expiration` property is required; all others are optional. The properties are discussed in more detail below. @@ -139,7 +139,7 @@ vercel env pull .env.development.local A list of valid query parameters that contribute to the cache key. Other parameters (such as utm tracking codes) will be ignored, ensuring that they do not result in content being re-generated unnecessarily. By default, query parameters are ignored. -> Pages that are [prerendered](page-options#prerender) will ignore ISR configuration. +> [!NOTE] Pages that are [prerendered](page-options#prerender) will ignore ISR configuration. ## Edge Middleware From 37bc902206094406098b2b3ee3e40ce33eaef98b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 21 Feb 2025 23:43:29 +0100 Subject: [PATCH 29/45] reuse `supports` mechanism, simplify additionalEntryPoints to a record, write additional entry points to adapter sub folder to prevent collisions --- .../99-writing-adapters.md | 15 ++++------ packages/adapter-cloudflare/index.js | 13 ++------- packages/adapter-netlify/index.js | 13 +++------ packages/adapter-node/index.js | 13 ++------- packages/adapter-vercel/index.js | 20 +++++-------- packages/kit/src/core/postbuild/analyse.js | 22 +++++++------- packages/kit/src/exports/public.d.ts | 11 ++++--- packages/kit/src/exports/vite/dev/index.js | 29 +++++++++++++++++-- packages/kit/src/exports/vite/index.js | 8 +++-- .../kit/src/exports/vite/preview/index.js | 2 +- packages/kit/src/types/private.d.ts | 9 ------ packages/kit/src/utils/features.js | 6 ++-- packages/kit/types/index.d.ts | 21 +++++--------- 13 files changed, 81 insertions(+), 101 deletions(-) diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index c75ad6a2e18a..a865a929c502 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -43,15 +43,12 @@ export default function (options) { // Or throw a descriptive error describing how to configure the deployment } }, - additionalEntryPoints: () => [ - // Allows you to configure additional entry points for compilation. - // You can use these via `importEntryPoint` within `emulate` or reference them - // from `${builder.getServerDirectory()}/.js` for further compilation/bundling. - { - name: 'additional-entry-point', - file: 'my-project-root-relative-file.js', - } - ] + // Allows you to configure additional entry points for compilation. + // You can use these via `importEntryPoint` within `emulate` and reference them + // from `${builder.getServerDirectory()}/adapter/.js` for further compilation/bundling. + additionalEntryPoints: { + 'additional-entry-point': 'my-project-root-relative-file.js' + } }; return adapter; diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 1e5bd788a956..8f64ffa8cba1 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -83,7 +83,7 @@ export default function (options = {}) { SERVER: `${relativePath}/index.js`, MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js`, MIDDLEWARE: middleware_path - ? `${relativePath}/cloudflare-middleware.js` + ? `${relativePath}/adapter/cloudflare-middleware.js` : `${path.posix.relative(dest, tmp)}/noop-middleware.js` } }); @@ -181,16 +181,7 @@ export default function (options = {}) { }; }, - additionalEntryPoints: () => { - if (!middleware_path) return []; - return [ - { - name: 'cloudflare-middleware', - file: middleware_path, - disallowedFeatures: ['$app/server:read'] - } - ]; - } + additionalEntryPoints: { 'cloudflare-middleware': middleware_path } }; } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index c246b40600d2..6c3ac60a7876 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -210,17 +210,12 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { }; }, - additionalEntryPoints: () => { - if (!middleware_path) return []; - return [ - { name: 'edge-middleware', file: middleware_path, disallowedFeatures: ['$app/server:read'] } - ]; - }, + additionalEntryPoints: { 'edge-middleware': middleware_path }, supports: { // reading from the filesystem only works in serverless functions - read: ({ route }) => { - if (edge) { + read: ({ route, entry }) => { + if (edge || entry === 'edge-middleware') { throw new Error( `${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using edge functions` ); @@ -297,7 +292,7 @@ async function generate_edge_middleware({ builder }) { builder.copy(`${files}/middleware.js`, `${tmp}/entry.js`, { replace: { SERVER_INIT: `${relativePath}/init.js`, - MIDDLEWARE: `${relativePath}/edge-middleware.js` + MIDDLEWARE: `${relativePath}/adapter/edge-middleware.js` } }); diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 8d020283b05d..25b1e11bf031 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -70,7 +70,7 @@ export default function (opts = {}) { input: { index: `${tmp}/index.js`, manifest: `${tmp}/manifest.js`, - 'node-middleware': `${tmp}/node-middleware.js` + 'node-middleware': `${tmp}/adapter/node-middleware.js` }, external: [ // dependencies could have deep exports, so we need a regex @@ -120,16 +120,7 @@ export default function (opts = {}) { }; }, - additionalEntryPoints: () => { - if (!middleware_path) return []; - - return [ - { - name: 'node-middleware', - file: middleware_path - } - ]; - }, + additionalEntryPoints: { 'node-middleware': middleware_path }, supports: { read: () => true diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index e0e910a0c010..6ed8158d9324 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -250,7 +250,7 @@ const plugin = function (defaults = {}) { builder.copy(`${files}/middleware.js`, dest, { replace: { SERVER_INIT: `${relativePath}/init.js`, - MIDDLEWARE: `${relativePath}/edge-middleware.js`, + MIDDLEWARE: `${relativePath}/adapter/edge-middleware.js`, PUBLIC_PREFIX: builder.config.kit.env.publicPrefix, PRIVATE_PREFIX: builder.config.kit.env.privatePrefix } @@ -554,7 +554,11 @@ const plugin = function (defaults = {}) { supports: { // reading from the filesystem only works in serverless functions - read: ({ config, route }) => { + read: ({ config, route, entry }) => { + if (entry === 'edge-middleware') { + throw new Error(`${name}: Cannot use \`read\` from \`$app/server\` in Edge Middleware`); + } + const runtime = config.runtime ?? defaults.runtime; if (runtime === 'edge') { @@ -567,17 +571,7 @@ const plugin = function (defaults = {}) { } }, - additionalEntryPoints: () => { - if (!middleware_path) return []; - - return [ - { - name: 'edge-middleware', - file: middleware_path, - disallowedFeatures: ['$app/server:read'] - } - ]; - } + additionalEntryPoints: { 'edge-middleware': middleware_path } }; }; diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 050640f52cbd..672df6dce0b5 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -27,7 +27,7 @@ export default forked(import.meta.url, analyse); * manifest_data: import('types').ManifestData; * server_manifest: import('vite').Manifest; * tracked_features: Record; - * additional_entry_points: import('types').AdditionalEntryPoint[] + * additional_entry_points: Record * env: Record * }} opts */ @@ -94,6 +94,14 @@ async function analyse({ }; } + for (const [entry, file] of Object.entries(additional_entry_points)) { + if (file) { + for (const feature of list_features(file, server_manifest, tracked_features)) { + check_feature('', {}, entry, feature, config.adapter); + } + } + } + // analyse routes for (const route of manifest._.routes) { const page = @@ -129,17 +137,7 @@ async function analyse({ server_manifest, tracked_features )) { - check_feature(route.id, route_config, feature, config.adapter); - } - - for (const additional of additional_entry_points) { - for (const feature of list_features(additional.file, server_manifest, tracked_features)) { - if (additional.disallowedFeatures?.includes(feature)) { - throw new Error( - `Usage of ${feature} (imported directly or indirectly) is not allowed in ${additional.file}` - ); - } - } + check_feature(route.id, route_config, undefined, feature, config.adapter); } } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index db95040ce2dc..71dcf7bda18d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -5,7 +5,6 @@ import '../types/ambient.js'; import { CompileOptions } from 'svelte/compiler'; import { AdapterEntry, - AdditionalEntryPoint, CspDirectives, HttpMethod, Logger, @@ -44,8 +43,10 @@ export interface Adapter { /** * Test support for `read` from `$app/server` * @param config The merged route config + * @param route The route and its ID + * @param entry Name of the entry point, in case this was called from an additional entry point (route and config are irrelevant in this case) */ - read?: (details: { config: any; route: { id: string } }) => boolean; + read?: (details: { config: any; route: { id: string }; entry?: string }) => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -56,10 +57,12 @@ export interface Adapter { importEntryPoint: (name: string) => Promise; }) => MaybePromise; /** - * A function that returns additional entry points for Vite to consider during compilation. + * An object with additional entry points for Vite to consider during compilation. + * The key is the name of the entry point that will be later available at `${builder.getServerDirectory()}/adapter/.js`, + * the value is the relative path to the entry point file. * This is useful for adapters that want to generate separate bundles for e.g. middleware. */ - additionalEntryPoints?: () => AdditionalEntryPoint[]; + additionalEntryPoints?: Record; } export type LoadProperties | void> = input extends void diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index b1aefc3e16b0..20d6300daa16 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -37,11 +37,34 @@ export async function dev(vite, vite_config, svelte_config) { globalThis.__SVELTEKIT_TRACK__ = (label) => { const context = async_local_storage.getStore(); - if (!context || context.prerender === true) return; + + if (!context) { + const files = new Error().stack + ?.split('\n') + .map((line) => line.match(/\((.*?):\d+:\d+\)/)?.[1]) + .map((file) => file?.replace(cwd.replaceAll('\\', '/') + '/', '')); + + for (const [entry, file] of Object.entries(additional_entry_points)) { + if (files?.includes(file ?? undefined)) { + check_feature( + '', + {}, + entry, + /** @type {import('types').TrackedFeature} */ (label), + svelte_config.kit.adapter + ); + } + } + + return; + } else if (context.prerender === true) { + return; + } check_feature( context.event.route.id, context.config, + undefined, /** @type {import('types').TrackedFeature} */ (label), svelte_config.kit.adapter ); @@ -426,10 +449,10 @@ export async function dev(vite, vite_config, svelte_config) { }; const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''); - const additional_entry_points = svelte_config.kit.adapter?.additionalEntryPoints?.() ?? []; + const additional_entry_points = svelte_config.kit.adapter?.additionalEntryPoints ?? {}; const emulator = await svelte_config.kit.adapter?.emulate?.({ importEntryPoint: (entry) => { - const file = additional_entry_points.find((e) => e.name === entry)?.file; + const file = additional_entry_points[entry]; if (!file) { throw new Error( `Entry point '${entry}' not found: ` + diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 3ea2b863fef2..224fb7c07518 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -217,7 +217,7 @@ async function kit({ svelte_config }) { const tracked_features = {}; /** Adapter-provided additional entry points */ - const additional_entry_points = kit.adapter?.additionalEntryPoints?.() ?? []; + const additional_entry_points = kit.adapter?.additionalEntryPoints ?? {}; const sourcemapIgnoreList = /** @param {string} relative_path */ (relative_path) => relative_path.includes('node_modules') || relative_path.includes(kit.outDir); @@ -605,8 +605,10 @@ Tips: input.init = `${runtime_directory}/server/init.js`; input.internal = `${kit.outDir}/generated/server/internal.js`; - for (const additional of additional_entry_points) { - input[additional.name] = additional.file; + for (const [entry, file] of Object.entries(additional_entry_points)) { + if (file) { + input[`adapter/${entry}`] = file; + } } // add entry points for every endpoint... diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 6c5acbcfd8d3..359aedbc30db 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -52,7 +52,7 @@ export async function preview(vite, vite_config, svelte_config) { }); const emulator = await svelte_config.kit.adapter?.emulate?.({ - importEntryPoint: (entry) => import(pathToFileURL(join(dir, `${entry}.js`)).href) + importEntryPoint: (entry) => import(pathToFileURL(join(dir, `adapter/${entry}.js`)).href) }); return () => { diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index fdef49337b0e..3237e26108f1 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -157,15 +157,6 @@ export type MaybePromise = T | Promise; export type TrackedFeature = '$app/server:read'; -export interface AdditionalEntryPoint { - /** Unique name of the entry point. Will be written to disk during build at `output/server/.js` */ - name: string; - /** Path relative to the project root of the corresponding file (e.g. `foo.js` means it's at `/foo.js`) */ - file: string; - /** Define which features should not be allowed within the entry point (or the files it imports) */ - disallowedFeatures?: TrackedFeature[]; -} - export interface Prerendered { /** * A map of `path` to `{ file }` objects, where a path like `/foo` corresponds to `foo.html` and a path like `/bar/` corresponds to `bar/index.html`. diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js index 4184f99f28fd..d57bf018e2e0 100644 --- a/packages/kit/src/utils/features.js +++ b/packages/kit/src/utils/features.js @@ -1,22 +1,24 @@ /** * @param {string} route_id * @param {any} config + * @param {string | undefined} entry * @param {import('types').TrackedFeature} feature * @param {import('@sveltejs/kit').Adapter | undefined} adapter */ -export function check_feature(route_id, config, feature, adapter) { +export function check_feature(route_id, config, entry, feature, adapter) { if (!adapter) return; switch (feature) { case '$app/server:read': { const supported = adapter.supports?.read?.({ route: { id: route_id }, + entry, config }); if (!supported) { throw new Error( - `Cannot use \`read\` from \`$app/server\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` + `Cannot use \`read\` from \`$app/server\` in ${route_id ?? entry} 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 59191b421fbb..868e053105f9 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -25,8 +25,10 @@ declare module '@sveltejs/kit' { /** * Test support for `read` from `$app/server` * @param config The merged route config + * @param route The route and its ID + * @param entry Name of the entry point, in case this was called from an additional entry point (route and config are irrelevant in this case) */ - read?: (details: { config: any; route: { id: string } }) => boolean; + read?: (details: { config: any; route: { id: string }; entry?: string }) => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -37,10 +39,12 @@ declare module '@sveltejs/kit' { importEntryPoint: (name: string) => Promise; }) => MaybePromise; /** - * A function that returns additional entry points for Vite to consider during compilation. + * An object with additional entry points for Vite to consider during compilation. + * The key is the name of the entry point that will be later available at `${builder.getServerDirectory()}/adapter/.js`, + * the value is the relative path to the entry point file. * This is useful for adapters that want to generate separate bundles for e.g. middleware. */ - additionalEntryPoints?: () => AdditionalEntryPoint[]; + additionalEntryPoints?: Record; } export type LoadProperties | void> = input extends void @@ -1659,17 +1663,6 @@ declare module '@sveltejs/kit' { type MaybePromise = T | Promise; - type TrackedFeature = '$app/server:read'; - - interface AdditionalEntryPoint { - /** Unique name of the entry point. Will be written to disk during build at `output/server/.js` */ - name: string; - /** Path relative to the project root of the corresponding file (e.g. `foo.js` means it's at `/foo.js`) */ - file: string; - /** Define which features should not be allowed within the entry point (or the files it imports) */ - disallowedFeatures?: TrackedFeature[]; - } - interface Prerendered { /** * A map of `path` to `{ file }` objects, where a path like `/foo` corresponds to `foo.html` and a path like `/bar/` corresponds to `bar/index.html`. From 5585df25c57430b284eb7ef6508fb1aab4d1aec5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 22 Feb 2025 00:08:00 +0100 Subject: [PATCH 30/45] fix --- packages/adapter-node/index.js | 5 ++++- packages/kit/src/core/postbuild/prerender.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 25b1e11bf031..56cf33a7c0e8 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -58,7 +58,10 @@ export default function (opts = {}) { ); if (!middleware_path) { - writeFileSync(`${tmp}/node-middleware.js`, 'export default (req, res, next) => next();'); + writeFileSync( + `${tmp}/adapter/node-middleware.js`, + 'export default (req, res, next) => next();' + ); } const pkg = JSON.parse(readFileSync('package.json', 'utf8')); diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 2e069820e74d..375651190ae0 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -119,7 +119,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { const emulator = await config.adapter?.emulate?.({ importEntryPoint: (entry) => - import(pathToFileURL(`${config.outDir}/output/server/${entry}.js`).href) + import(pathToFileURL(`${config.outDir}/output/server/adapter/${entry}.js`).href) }); /** @type {import('types').Logger} */ From 0b5396efbe246a80157edb977317171b8d976ba0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 22 Feb 2025 20:44:05 +0100 Subject: [PATCH 31/45] do normalization inside adapters --- .../25-build-and-deploy/40-adapter-node.md | 8 ++--- .../60-adapter-cloudflare.md | 4 +-- .../25-build-and-deploy/80-adapter-netlify.md | 4 +-- packages/adapter-cloudflare/index.js | 36 +++++++++++-------- packages/adapter-cloudflare/package.json | 2 +- packages/adapter-cloudflare/src/middleware.js | 31 ++++++++++++++++ packages/adapter-netlify/index.js | 15 ++++---- packages/adapter-netlify/package.json | 2 +- packages/adapter-netlify/src/middleware.js | 36 ++++++++++++++++++- packages/adapter-node/index.js | 31 ++++++++++++---- packages/adapter-node/rollup.config.js | 11 +++++- packages/adapter-node/src/middleware.js | 22 ++++++++++++ packages/adapter-vercel/files/middleware.js | 19 +++++----- packages/kit/src/exports/index.js | 16 +++++---- packages/kit/types/index.d.ts | 6 ++-- 15 files changed, 182 insertions(+), 61 deletions(-) create mode 100644 packages/adapter-cloudflare/src/middleware.js create mode 100644 packages/adapter-node/src/middleware.js 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 ef309f5caa34..4f18818a8468 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -148,7 +148,6 @@ declare module 'polka'; // @filename: index.js // ---cut--- -import { normalizeUrl } from '@sveltejs/kit'; /** * @param {import('polka').Request} req @@ -156,9 +155,7 @@ import { normalizeUrl } from '@sveltejs/kit'; * @param {import('polka').NextHandler} next */ export default function middleware(req, res, next) { - const { url, denormalize } = normalizeUrl(req.url); - - if (url.pathname !== '/') return next(); + if (req.url !== '/') return next(); // Retrieve feature flag from cookies let flag = split_cookies(req.headers.cookie ?? '')?.flag; @@ -167,8 +164,7 @@ export default function middleware(req, res, next) { flag ||= Math.random() > 0.5 ? 'a' : 'b'; // Get destination URL based on the feature flag - const rewritten = denormalize(flag === 'a' ? '/home-a' : '/home-b'); - req.url = rewritten.pathname + rewritten.search; + req.url = flag === 'a' ? '/home-a' : '/home-b'; // Set a cookie to remember the feature flags for this visitor res.appendHeader('Set-Cookie', `flag=${flag}; Path=/`); 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 36e0bedfc3a2..a463d7cb647b 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -129,7 +129,7 @@ import { normalizeUrl } from '@sveltejs/kit'; * @param {import('@cloudflare/workers-types'.EventContext)} context */ export function onRequest({ request, next }) { - const { url, denormalize } = normalizeUrl(request.url); + const url = new URL(request.url); if (url.pathname !== '/') return next(); @@ -140,7 +140,7 @@ export function onRequest({ request, next }) { flag ||= Math.random() > 0.5 ? 'a' : 'b'; // Get destination URL based on the feature flag - request = new Request(denormalize(flag === 'a' ? '/home-a' : '/home-b'), request); + request = new Request(flag === 'a' ? '/home-a' : '/home-b', request); const response = await next(request); 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 6ef951ce41a4..5fc046722b79 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -84,7 +84,7 @@ import { normalizeUrl } from '@sveltejs/kit'; * @param {import('@netlify/edge-functions').Context} context */ export default async function middleware(request, { next, cookies }) { - const { url, denormalize } = normalizeUrl(request.url); + const url = new URL(request.url); if (url.pathname !== '/') return next(); @@ -98,7 +98,7 @@ export default async function middleware(request, { next, cookies }) { cookies.set('flag', flag); // Get destination URL based on the feature flag - return denormalize(flag === 'a' ? '/home-a' : '/home-b'); + return new URL(flag === 'a' ? '/home-a' : '/home-b', url); } ``` diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 8f64ffa8cba1..c74668fc5c45 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -2,11 +2,11 @@ import { existsSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getPlatformProxy } from 'wrangler'; -import { VERSION } from '@sveltejs/kit'; // TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as kit from '@sveltejs/kit'; import * as node_kit from '@sveltejs/kit/node'; -const [major, minor] = VERSION.split('.').map(Number); +const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ @@ -58,10 +58,18 @@ export default function (options = {}) { `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); - writeFileSync( - `${tmp}/noop-middleware.js`, - 'export function onRequest({ next }) { return next() }' - ); + if (middleware_path) { + builder.copy(`${files}/middleware.js`, `${tmp}/middleware.js`, { + replace: { + MIDDLEWARE: `${path.posix.relative(tmp, builder.getServerDirectory())}/adapter/cloudflare-middleware.js` + } + }); + } else { + writeFileSync( + `${tmp}/middleware.js`, + 'export function onRequest({ next }) { return next() }' + ); + } writeFileSync( `${dest}/_routes.json`, @@ -82,9 +90,7 @@ export default function (options = {}) { replace: { SERVER: `${relativePath}/index.js`, MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js`, - MIDDLEWARE: middleware_path - ? `${relativePath}/adapter/cloudflare-middleware.js` - : `${path.posix.relative(dest, tmp)}/noop-middleware.js` + MIDDLEWARE: `${path.posix.relative(dest, tmp)}/middleware.js` } }); }, @@ -125,7 +131,9 @@ export default function (options = {}) { emulated ??= await get_emulated(); const middleware = await opts.importEntryPoint('cloudflare-middleware'); - const request = new Request(new URL(req.url, 'http://localhost'), { + const { url, denormalize } = kit.normalizeUrl(req.url); + + const request = new Request(url, { headers: node_kit.getRequestHeaders(req), method: req.method, body: @@ -149,15 +157,15 @@ export default function (options = {}) { ...emulated.platform.context, next: (input, init) => { // More any casts because of annoying CF types - const adjusted = + const request = input instanceof Request ? input : input && new Request(/** @type {any} */ (input), /** @type {any} */ (init)); - if (adjusted) { - const url = new URL(adjusted.url); + if (request) { + const url = denormalize(request.url); req.url = url.pathname + url.search; - for (const [key, value] of adjusted.headers) { + for (const [key, value] of request.headers) { req.headers[key] = value; } } diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index 5ce1450bf88d..8812a69a3afe 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 --external:MIDDLEWARE --format=esm", + "build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --external:MIDDLEWARE --format=esm && esbuild src/middleware.js --bundle --outfile=files/middleware.js --external:MIDDLEWARE --format=esm", "lint": "prettier --check .", "format": "pnpm lint --write", "check": "tsc --skipLibCheck", diff --git a/packages/adapter-cloudflare/src/middleware.js b/packages/adapter-cloudflare/src/middleware.js new file mode 100644 index 000000000000..20896d32c909 --- /dev/null +++ b/packages/adapter-cloudflare/src/middleware.js @@ -0,0 +1,31 @@ +import { normalizeUrl } from '@sveltejs/kit'; +import { onRequest as user_middleware } from 'MIDDLEWARE'; + +/** + * @param {{ request: Request, next: (init?: any, options?: any) => any}} context + */ +export async function onRequest(context) { + const { url, neededNormalization, denormalize } = normalizeUrl(context.request.url); + + if (neededNormalization) { + const request = new Request(url.href, context.request); + + /** + * @param {any} request + * @param {any} options + */ + const next = (request, options) => { + if (request) { + request = new Request( + denormalize(typeof request === 'string' ? request : request.url), + options ?? (request instanceof Request ? request : context.request) + ); + } + return context.next(request, options); + }; + + return user_middleware({ ...context, request, next }); + } else { + return user_middleware(context); + } +} diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 6c3ac60a7876..f4a012235f7e 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -5,8 +5,8 @@ import { builtinModules } from 'node:module'; import process from 'node:process'; import esbuild from 'esbuild'; import toml from '@iarna/toml'; -import { VERSION } from '@sveltejs/kit'; // TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as kit from '@sveltejs/kit'; import * as node_kit from '@sveltejs/kit/node'; /** @@ -45,7 +45,7 @@ const edge_set_in_env_var = const FUNCTION_PREFIX = 'sveltekit-'; -const [major, minor] = VERSION.split('.').map(Number); +const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ @@ -126,7 +126,9 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importEntryPoint('edge-middleware'); - const request = new Request(new URL(req.url, 'http://localhost'), { + const { url, denormalize } = kit.normalizeUrl(req.url); + + const request = new Request(url, { headers: node_kit.getRequestHeaders(req), method: req.method, body: @@ -181,8 +183,8 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { /** @param {any} request */ next: (request) => { if (request instanceof Request) { - const url = new URL(request.url); - req.url = url.pathname + url.search; + const new_url = denormalize(request.url); + req.url = new_url.pathname + url.search; for (const header of request.headers) { req.headers[header[0]] = header[1]; } @@ -198,7 +200,8 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { if (response instanceof URL) { // https://docs.netlify.com/edge-functions/api/#return-a-rewrite - req.url = response.pathname + response.search; + const new_url = denormalize(response); + req.url = new_url.pathname + new_url.search; return next(); } else if (response instanceof Response && response !== fake_response) { // We assume that middleware bails out when returning a custom response diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index bcc1d4d2ed0f..f0d2a186f2ef 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 .", diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js index 07f1bd94e9bb..5ff098874e82 100644 --- a/packages/adapter-netlify/src/middleware.js +++ b/packages/adapter-netlify/src/middleware.js @@ -1,5 +1,6 @@ +import { normalizeUrl } from '@sveltejs/kit'; import { initServer } from 'SERVER_INIT'; -export { default } from 'MIDDLEWARE'; +import user_middleware from 'MIDDLEWARE'; initServer({ env: { @@ -9,3 +10,36 @@ initServer({ private_prefix: 'PRIVATE_PREFIX' } }); + +/** + * @param {Request} request + * @param {any} context + */ +export default async function middleware(request, context) { + const { url, neededNormalization, denormalize } = normalizeUrl(request.url); + + if (neededNormalization) { + request = new Request(url.href, request); + + /** + * @param {any} request + * @param {any} options + */ + const next = (request, options) => { + if (request instanceof Request) { + request = new Request(denormalize(request.url), request); + } + return context.next(request, options); + }; + + const response = await user_middleware(request, { ...context, next }); + + if (response instanceof URL) { + return new URL(denormalize(response)); + } else { + return response; + } + } else { + return user_middleware(request, context); + } +} diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 56cf33a7c0e8..38c405890228 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -4,9 +4,10 @@ import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; -import { VERSION } from '@sveltejs/kit'; +// TODO 3.0: switch to named imports, right now we're doing `import * as ..` to avoid having to bump the peer dependency on Kit +import * as kit from '@sveltejs/kit'; -const [major, minor] = VERSION.split('.').map(Number); +const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ @@ -57,9 +58,15 @@ export default function (opts = {}) { ].join('\n\n') ); - if (!middleware_path) { + if (middleware_path) { + builder.copy(`${files}/middleware.js`, `${tmp}/adapter/node-middleware-wrapper.js`, { + replace: { + MIDDLEWARE: './node-middleware.js' + } + }); + } else { writeFileSync( - `${tmp}/adapter/node-middleware.js`, + `${tmp}/adapter/node-middleware-wrapper.js`, 'export default (req, res, next) => next();' ); } @@ -73,7 +80,7 @@ export default function (opts = {}) { input: { index: `${tmp}/index.js`, manifest: `${tmp}/manifest.js`, - 'node-middleware': `${tmp}/adapter/node-middleware.js` + 'node-middleware': `${tmp}/adapter/node-middleware-wrapper.js` }, external: [ // dependencies could have deep exports, so we need a regex @@ -99,6 +106,7 @@ export default function (opts = {}) { }); builder.copy(files, out, { + filter: (file) => file !== 'middleware.js', replace: { ENV: './env.js', HANDLER: './handler.js', @@ -112,13 +120,22 @@ export default function (opts = {}) { }, emulate: (opts) => { - if (!existsSync(middleware_path)) return {}; + if (!middleware_path) return {}; return { beforeRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importEntryPoint('node-middleware'); - return middleware.default(req, res, next); + + const { url, denormalize } = kit.normalizeUrl(req.url); + req.url = url.pathname + url.search; + const _next = () => { + const { pathname, search } = denormalize(req.url); + req.url = pathname + search; + return next(); + }; + + return middleware.default(req, res, _next); } }; }, diff --git a/packages/adapter-node/rollup.config.js b/packages/adapter-node/rollup.config.js index fb9d113b5965..09c51cbc63bc 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'] }, { input: 'src/shims.js', @@ -49,5 +49,14 @@ export default [ format: 'esm' }, plugins: [nodeResolve(), commonjs(), prefixBuiltinModules()] + }, + { + input: 'src/middleware.js', + output: { + file: 'files/middleware.js', + format: 'esm' + }, + plugins: [nodeResolve(), commonjs(), prefixBuiltinModules()], + external: ['MIDDLEWARE'] } ]; diff --git a/packages/adapter-node/src/middleware.js b/packages/adapter-node/src/middleware.js new file mode 100644 index 000000000000..30b14b985980 --- /dev/null +++ b/packages/adapter-node/src/middleware.js @@ -0,0 +1,22 @@ +import { normalizeUrl } from '@sveltejs/kit'; +import user_middleware from 'MIDDLEWARE'; + +/** + * @type {import('polka').Middleware} + */ +export default function middleware(req, res, next) { + const { url, neededNormalization, denormalize } = normalizeUrl(req.url); + + if (neededNormalization) { + req.url = url.pathname + url.search; + const _next = () => { + const { pathname, search } = denormalize(req.url); + req.url = pathname + search; + return next(); + }; + + return user_middleware(req, res, _next); + } else { + return user_middleware(req, res, next); + } +} diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js index 90b04f9e2260..ace49a4f89ba 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -19,25 +19,22 @@ export const config = user_middleware.config; * @param {any} context */ export default async function middleware(request, context) { - const { url, denormalize } = normalizeUrl(request.url); + const { url, neededNormalization, denormalize } = normalizeUrl(request.url); - if (url.pathname !== new URL(request.url).pathname) { + if (neededNormalization) { request = new Request(url, request); } const response = await user_middleware.default(request, context); if (response instanceof Response && response.headers.has('x-middleware-rewrite')) { - let rewritten = new URL( - /** @type {string} */ (response.headers.get('x-middleware-rewrite')), - url + const rewritten = denormalize( + /** @type {string} */ (response.headers.get('x-middleware-rewrite')) ); - - if (rewritten.hostname === url.hostname) { - rewritten = denormalize(rewritten.pathname); - response.headers.set('REWRITE_HEADER', rewritten.pathname + rewritten.search); - response.headers.set('x-middleware-rewrite', rewritten.pathname + rewritten.search); - } + const str = + rewritten.hostname !== url.hostname ? rewritten.href : rewritten.pathname + rewritten.search; + response.headers.set('REWRITE_HEADER', str); + response.headers.set('x-middleware-rewrite', str); } return response; diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index abba33237a06..99be90ac87d6 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -218,7 +218,8 @@ export function isActionFailure(e) { /** * Strips possible SvelteKit-internal suffixes from the URL pathname. - * Returns the normalized URL as well as a method for adding the potential suffix back based on a new pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back + * based on a new pathname (possibly including search) or URL. * ```js * import { normalizeUrl } from '@sveltejs/kit'; * @@ -227,7 +228,7 @@ export function isActionFailure(e) { * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json * ``` * @param {URL | string} url - * @returns {{ url: URL, denormalize: (pathname?: string) => URL }} + * @returns {{ url: URL, neededNormalization: boolean, denormalize: (url?: string | URL) => URL }} */ export function normalizeUrl(url) { url = new URL(url, 'http://internal'); @@ -243,14 +244,15 @@ export function normalizeUrl(url) { return { url, - denormalize: (pathname = /** @type {URL} */ (url).pathname) => { - url = new URL(pathname, url); + neededNormalization: is_data_request || is_route_resolution, + denormalize: (new_url = url) => { + new_url = new URL(new_url, url); if (is_route_resolution) { - url.pathname = add_resolution_suffix(url.pathname); + new_url.pathname = add_resolution_suffix(new_url.pathname); } else if (is_data_request) { - url.pathname = add_data_suffix(url.pathname); + new_url.pathname = add_data_suffix(new_url.pathname); } - return url; + return new_url; } }; } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 868e053105f9..62a34b7f1c91 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2034,7 +2034,8 @@ declare module '@sveltejs/kit' { export function isActionFailure(e: unknown): e is ActionFailure; /** * Strips possible SvelteKit-internal suffixes from the URL pathname. - * Returns the normalized URL as well as a method for adding the potential suffix back based on a new pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back + * based on a new pathname (possibly including search) or URL. * ```js * import { normalizeUrl } from '@sveltejs/kit'; * @@ -2045,7 +2046,8 @@ declare module '@sveltejs/kit' { * */ export function normalizeUrl(url: URL | string): { url: URL; - denormalize: (pathname?: string) => URL; + neededNormalization: boolean; + denormalize: (url?: string | URL) => URL; }; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; export type NumericRange = Exclude, LessThan>; From 76f5a9ba8d3a861585879aa7c90741649b06c194 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 22 Feb 2025 21:34:18 +0100 Subject: [PATCH 32/45] don't bundle sveltekit --- packages/adapter-cloudflare/package.json | 2 +- packages/adapter-node/rollup.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index 8812a69a3afe..8a4ab1e90947 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 --external:MIDDLEWARE --format=esm && esbuild src/middleware.js --bundle --outfile=files/middleware.js --external:MIDDLEWARE --format=esm", + "build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --external:MIDDLEWARE --format=esm && esbuild src/middleware.js --bundle --outfile=files/middleware.js --external:MIDDLEWARE --external:@sveltejs/kit --format=esm", "lint": "prettier --check .", "format": "pnpm lint --write", "check": "tsc --skipLibCheck", diff --git a/packages/adapter-node/rollup.config.js b/packages/adapter-node/rollup.config.js index 09c51cbc63bc..4e655d150e3e 100644 --- a/packages/adapter-node/rollup.config.js +++ b/packages/adapter-node/rollup.config.js @@ -57,6 +57,6 @@ export default [ format: 'esm' }, plugins: [nodeResolve(), commonjs(), prefixBuiltinModules()], - external: ['MIDDLEWARE'] + external: ['MIDDLEWARE', '@sveltejs/kit'] } ]; From 679759fd8381fb048d88329f4cb4599b6fa573f5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 22 Feb 2025 21:36:23 +0100 Subject: [PATCH 33/45] put them into src so that they benefit from better intellisense --- packages/adapter-cloudflare/index.js | 4 ++-- packages/adapter-netlify/index.js | 4 ++-- packages/adapter-node/index.js | 4 ++-- packages/adapter-vercel/index.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index c74668fc5c45..6bc24790684b 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -10,9 +10,9 @@ const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ -let middleware_path = can_use_middleware ? 'cloudflare-middleware.js' : null; +let middleware_path = can_use_middleware ? 'src/cloudflare-middleware.js' : null; if (middleware_path && !existsSync(middleware_path)) { - middleware_path = 'cloudflare-middleware.ts'; + middleware_path = 'src/cloudflare-middleware.ts'; if (!existsSync(middleware_path)) middleware_path = null; } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index f4a012235f7e..1f8e4fc24672 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -49,9 +49,9 @@ const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ -let middleware_path = can_use_middleware ? 'edge-middleware.js' : null; +let middleware_path = can_use_middleware ? 'src/edge-middleware.js' : null; if (middleware_path && !existsSync(middleware_path)) { - middleware_path = 'edge-middleware.ts'; + middleware_path = 'src/edge-middleware.ts'; if (!existsSync(middleware_path)) middleware_path = null; } diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 38c405890228..4f30ac86cef3 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -11,9 +11,9 @@ const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ -let middleware_path = can_use_middleware ? 'node-middleware.js' : null; +let middleware_path = can_use_middleware ? 'src/node-middleware.js' : null; if (middleware_path && !existsSync(middleware_path)) { - middleware_path = 'node-middleware.ts'; + middleware_path = 'src/node-middleware.ts'; if (!existsSync(middleware_path)) middleware_path = null; } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 6ed8158d9324..dd591cb659f9 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -43,9 +43,9 @@ const [major, minor] = kit.VERSION.split('.').map(Number); const can_use_middleware = major > 2 || (major === 2 && minor > 17); /** @type {string | null} */ -let middleware_path = can_use_middleware ? 'edge-middleware.js' : null; +let middleware_path = can_use_middleware ? 'src/edge-middleware.js' : null; if (middleware_path && !fs.existsSync(middleware_path)) { - middleware_path = 'edge-middleware.ts'; + middleware_path = 'src/edge-middleware.ts'; if (!fs.existsSync(middleware_path)) middleware_path = null; } From e39683e6701ff36a65ed85b58898a113605705bc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 22 Feb 2025 21:51:42 +0100 Subject: [PATCH 34/45] fix docs --- documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a463d7cb647b..b565ac4d61b5 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -140,7 +140,7 @@ export function onRequest({ request, next }) { flag ||= Math.random() > 0.5 ? 'a' : 'b'; // Get destination URL based on the feature flag - request = new Request(flag === 'a' ? '/home-a' : '/home-b', request); + request = new Request(new URL(flag === 'a' ? '/home-a' : '/home-b', url), request); const response = await next(request); From 087671642ffff54e4517eeb6d44a93bfac743f3c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 24 Feb 2025 20:19:16 +0100 Subject: [PATCH 35/45] tweaks --- packages/adapter-cloudflare/src/middleware.js | 4 ++-- packages/adapter-netlify/src/middleware.js | 4 ++-- packages/adapter-node/src/middleware.js | 4 ++-- packages/adapter-vercel/files/edge.js | 2 +- packages/adapter-vercel/files/middleware.js | 4 ++-- packages/adapter-vercel/index.js | 2 +- packages/kit/src/core/postbuild/analyse.js | 17 ++++++++--------- packages/kit/src/exports/index.js | 11 ++++++++--- packages/kit/types/index.d.ts | 4 ++-- 9 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/adapter-cloudflare/src/middleware.js b/packages/adapter-cloudflare/src/middleware.js index 20896d32c909..b9037b313302 100644 --- a/packages/adapter-cloudflare/src/middleware.js +++ b/packages/adapter-cloudflare/src/middleware.js @@ -5,9 +5,9 @@ import { onRequest as user_middleware } from 'MIDDLEWARE'; * @param {{ request: Request, next: (init?: any, options?: any) => any}} context */ export async function onRequest(context) { - const { url, neededNormalization, denormalize } = normalizeUrl(context.request.url); + const { url, wasNormalized, denormalize } = normalizeUrl(context.request.url); - if (neededNormalization) { + if (wasNormalized) { const request = new Request(url.href, context.request); /** diff --git a/packages/adapter-netlify/src/middleware.js b/packages/adapter-netlify/src/middleware.js index 5ff098874e82..d69680804113 100644 --- a/packages/adapter-netlify/src/middleware.js +++ b/packages/adapter-netlify/src/middleware.js @@ -16,9 +16,9 @@ initServer({ * @param {any} context */ export default async function middleware(request, context) { - const { url, neededNormalization, denormalize } = normalizeUrl(request.url); + const { url, wasNormalized, denormalize } = normalizeUrl(request.url); - if (neededNormalization) { + if (wasNormalized) { request = new Request(url.href, request); /** diff --git a/packages/adapter-node/src/middleware.js b/packages/adapter-node/src/middleware.js index 30b14b985980..3be4e93df8a4 100644 --- a/packages/adapter-node/src/middleware.js +++ b/packages/adapter-node/src/middleware.js @@ -5,9 +5,9 @@ import user_middleware from 'MIDDLEWARE'; * @type {import('polka').Middleware} */ export default function middleware(req, res, next) { - const { url, neededNormalization, denormalize } = normalizeUrl(req.url); + const { url, wasNormalized, denormalize } = normalizeUrl(req.url); - if (neededNormalization) { + if (wasNormalized) { req.url = url.pathname + url.search; const _next = () => { const { pathname, search } = denormalize(req.url); diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js index 93914eff345e..492855859e52 100644 --- a/packages/adapter-vercel/files/edge.js +++ b/packages/adapter-vercel/files/edge.js @@ -20,7 +20,7 @@ export default async (request, context) => { let url = new URL(request.url); url.pathname = pathname; request = new Request(url, request); - request.headers.delete('x-sveltekit-vercel-rewrite'); + request.headers.delete('REWRITE_HEADER'); } return server.respond(request, { diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js index ace49a4f89ba..9c3024467ef9 100644 --- a/packages/adapter-vercel/files/middleware.js +++ b/packages/adapter-vercel/files/middleware.js @@ -19,9 +19,9 @@ export const config = user_middleware.config; * @param {any} context */ export default async function middleware(request, context) { - const { url, neededNormalization, denormalize } = normalizeUrl(request.url); + const { url, wasNormalized, denormalize } = normalizeUrl(request.url); - if (neededNormalization) { + if (wasNormalized) { request = new Request(url, request); } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index dd591cb659f9..a28454f688a2 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -556,7 +556,7 @@ const plugin = function (defaults = {}) { // reading from the filesystem only works in serverless functions read: ({ config, route, entry }) => { if (entry === 'edge-middleware') { - throw new Error(`${name}: Cannot use \`read\` from \`$app/server\` in Edge Middleware`); + throw new Error(`${name}: Cannot use \`read\` from \`$app/server\` in edge middleware`); } const runtime = config.runtime ?? defaults.runtime; diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 672df6dce0b5..aa71d17f9b1d 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -226,10 +226,9 @@ function analyse_page(layouts, leaf) { * @param {string} entry * @param {import('vite').Manifest} server_manifest * @param {Record} tracked_features + * @param {Set} [features] */ -function list_features(entry, server_manifest, tracked_features) { - const features = new Set(); - +function list_features(entry, server_manifest, tracked_features, features = new Set()) { /** @param {string} id */ function visit(id) { const chunk = server_manifest[id]; @@ -250,7 +249,7 @@ function list_features(entry, server_manifest, tracked_features) { visit(entry); - return Array.from(features); + return features; } /** @@ -260,7 +259,7 @@ function list_features(entry, server_manifest, tracked_features) { * @param {Record} tracked_features */ function list_route_features(route, manifest_data, server_manifest, tracked_features) { - const features = []; + const features = new Set(); const route_data = /** @type {import('types').RouteData} */ ( manifest_data.routes.find((r) => r.id === route.id) ); @@ -268,20 +267,20 @@ function list_route_features(route, manifest_data, server_manifest, tracked_feat let page_node = route_data?.leaf; while (page_node) { if (page_node.server) { - features.push(...list_features(page_node.server, server_manifest, tracked_features)); + list_features(page_node.server, server_manifest, tracked_features, features); } page_node = page_node.parent ?? null; } if (route_data.endpoint) { - features.push(...list_features(route_data.endpoint.file, server_manifest, tracked_features)); + list_features(route_data.endpoint.file, server_manifest, tracked_features, features); } if (manifest_data.hooks.server) { // TODO if hooks.server.js imports `read`, it will be in the entry chunk // we don't currently account for that case - features.push(...list_features(manifest_data.hooks.server, server_manifest, tracked_features)); + list_features(manifest_data.hooks.server, server_manifest, tracked_features, features); } - return Array.from(features); + return features; } diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 99be90ac87d6..1b4b7aa373b0 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -217,7 +217,7 @@ export function isActionFailure(e) { } /** - * Strips possible SvelteKit-internal suffixes from the URL pathname. + * Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname. * Returns the normalized URL as well as a method for adding the potential suffix back * based on a new pathname (possibly including search) or URL. * ```js @@ -228,29 +228,34 @@ export function isActionFailure(e) { * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json * ``` * @param {URL | string} url - * @returns {{ url: URL, neededNormalization: boolean, denormalize: (url?: string | URL) => URL }} + * @returns {{ url: URL, wasNormalized: boolean, denormalize: (url?: string | URL) => URL }} */ export function normalizeUrl(url) { url = new URL(url, 'http://internal'); const is_route_resolution = has_resolution_suffix(url.pathname); const is_data_request = has_data_suffix(url.pathname); + const has_trailing_slash = url.pathname !== '/' && url.pathname.endsWith('/'); if (is_route_resolution) { url.pathname = strip_resolution_suffix(url.pathname); } else if (is_data_request) { url.pathname = strip_data_suffix(url.pathname); + } else if (has_trailing_slash) { + url.pathname = url.pathname.slice(0, -1); } return { url, - neededNormalization: is_data_request || is_route_resolution, + wasNormalized: is_data_request || is_route_resolution || has_trailing_slash, denormalize: (new_url = url) => { new_url = new URL(new_url, url); if (is_route_resolution) { new_url.pathname = add_resolution_suffix(new_url.pathname); } else if (is_data_request) { new_url.pathname = add_data_suffix(new_url.pathname); + } else if (has_trailing_slash && !new_url.pathname.endsWith('/')) { + new_url.pathname += '/'; } return new_url; } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 62a34b7f1c91..319f8b8cb7a2 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2033,7 +2033,7 @@ declare module '@sveltejs/kit' { * */ export function isActionFailure(e: unknown): e is ActionFailure; /** - * Strips possible SvelteKit-internal suffixes from the URL pathname. + * Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname. * Returns the normalized URL as well as a method for adding the potential suffix back * based on a new pathname (possibly including search) or URL. * ```js @@ -2046,7 +2046,7 @@ declare module '@sveltejs/kit' { * */ export function normalizeUrl(url: URL | string): { url: URL; - neededNormalization: boolean; + wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; From 8524d6fa527e1a45ac1b534379f377c13efc8498 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 24 Feb 2025 21:33:14 +0100 Subject: [PATCH 36/45] netlify: make where it runs on configurable; node/preview: run it on all requests --- .../25-build-and-deploy/40-adapter-node.md | 2 +- .../25-build-and-deploy/80-adapter-netlify.md | 2 +- packages/adapter-netlify/index.js | 65 ++++++++++++------- packages/adapter-node/index.js | 1 + packages/adapter-node/src/handler.js | 13 +--- packages/adapter-vercel/index.js | 2 +- packages/kit/src/exports/vite/dev/index.js | 4 ++ .../kit/src/exports/vite/preview/index.js | 4 -- 8 files changed, 51 insertions(+), 42 deletions(-) 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 4f18818a8468..4214ce21b976 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -139,7 +139,7 @@ When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of sec ## Middleware -You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file at the root of your project. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests except those to files inside `_app/immutable`. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. +You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file at the root of your project. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. ```js /// file: node-middleware.js 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 5fc046722b79..32945b4ca539 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -102,7 +102,7 @@ export default async function middleware(request, { next, cookies }) { } ``` -Middleware runs on all requests except for files within `_app/immutable`. +By default middleware runs on all requests except for files within `_app/`. You can customize this by exporting a `export const config = { pattern: '' }` object from the file similar to [how you can do it for native edge functions](https://docs.netlify.com/edge-functions/declarations/#declare-edge-functions-inline). > [!NOTE] Locally during dev and preview this only approximates the capabilities of edge functions. Notably, you cannot read the request or response body, and many properties on the context object are `null`ed. diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 1f8e4fc24672..9d88d6f13814 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'; @@ -262,6 +262,7 @@ async function generate_edge_functions({ builder }) { name: 'render', path: '/*', excludedPath: [ + `/${builder.getAppPath()}/*`, // Contains static files ...builder.prerendered.paths, ...Array.from(assets).flatMap((asset) => { if (asset.endsWith('/index.html')) { @@ -292,6 +293,25 @@ async function generate_edge_middleware({ builder }) { const relativePath = posix.relative(tmp, builder.getServerDirectory()); + let pattern = `/((?!${builder.getAppPath()}/|favicon.ico|favicon.png).*)`; + + try { + const file_path = pathToFileURL( + `${builder.getServerDirectory()}/adapter/edge-middleware.js` + ).href; + const { config } = await import(file_path); + if (config?.pattern) pattern = config.pattern; + } catch (e) { + // Don't bother showing the error if we know there's no config object + const text = readFileSync(middleware_path, 'utf-8'); + if (text.includes('config') || text.includes('export *')) { + builder.log.error( + 'Failed to import middleware. Make sure it is loadable during build, which is necessary to analyze the config object.' + ); + throw e; + } + } + builder.copy(`${files}/middleware.js`, `${tmp}/entry.js`, { replace: { SERVER_INIT: `${relativePath}/init.js`, @@ -299,22 +319,18 @@ async function generate_edge_middleware({ builder }) { } }); - await bundle_edge_function({ builder, name: 'edge-middleware', path: '/*', excludedPath: [] }); + await bundle_edge_function({ builder, name: 'edge-middleware', pattern }); } /** - * @param {object} params - * @param {import('@sveltejs/kit').Builder} params.builder - * @param {string} params.name - * @param {string} params.path - * @param {string[]} params.excludedPath + * @param {{ builder: import('@sveltejs/kit').Builder; name: string; } & ({ path: string; excludedPath: string[] } | { pattern: string })} params */ -async function bundle_edge_function({ builder, name, path, excludedPath }) { - const tmp = builder.getBuildDirectory('netlify-tmp'); +async function bundle_edge_function(params) { + const tmp = params.builder.getBuildDirectory('netlify-tmp'); await esbuild.build({ entryPoints: [`${tmp}/entry.js`], - outfile: `.netlify/edge-functions/${name}.js`, + outfile: `.netlify/edge-functions/${params.name}.js`, bundle: true, format: 'esm', platform: 'browser', @@ -340,19 +356,22 @@ async function bundle_edge_function({ builder, name, path, excludedPath }) { ...(existsSync('.netlify/edge-functions/manifest.json') ? JSON.parse(readFileSync('.netlify/edge-functions/manifest.json', 'utf-8')).functions : []), - { - function: name, - path, - // We only need to specify paths without the trailing slash because - // Netlify will handle the optional trailing slash for us - excludedPath: [ - // Contains static files - `/${builder.getAppPath()}/*`, - ...excludedPath, - // Should not be served by SvelteKit at all - '/.netlify/*' - ] - } + 'pattern' in params + ? { + function: params.name, + pattern: params.pattern + } + : { + function: params.name, + path: params.path, + // We only need to specify paths without the trailing slash because + // Netlify will handle the optional trailing slash for us + excludedPath: [ + ...params.excludedPath, + // Should not be served by SvelteKit at all + '/.netlify/*' + ] + } ], version: 1 }; diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 4f30ac86cef3..1999d6e2421a 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -65,6 +65,7 @@ export default function (opts = {}) { } }); } else { + builder.mkdirp(`${tmp}/adapter`); writeFileSync( `${tmp}/adapter/node-middleware-wrapper.js`, 'export default (req, res, next) => next();' diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 47e5d2e45527..83bcc03c9776 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -9,7 +9,7 @@ import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/nod import { Server } from 'SERVER'; import { manifest, prerendered, base } from 'MANIFEST'; import { env } from 'ENV'; -import node_middleware from 'MIDDLEWARE'; +import middleware from 'MIDDLEWARE'; /* global ENV_PREFIX */ @@ -52,17 +52,6 @@ await server.init({ read: (file) => createReadableStream(`${asset_dir}/${file}`) }); -/** @type {import('polka').Middleware} */ -const middleware = async (req, res, next) => { - const { pathname } = polka_url_parser(req); - - if (pathname.startsWith(`/${manifest.appPath}/immutable/`)) { - return next(); - } - - return node_middleware(req, res, next); -}; - /** * @param {string} path * @param {boolean} client diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index a28454f688a2..d14cbdae397e 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -269,7 +269,7 @@ const plugin = function (defaults = {}) { config ); - let matcher = `/((?!${builder.getAppPath()}/immutable|favicon.ico|favicon.png).*)`; + let matcher = `/((?!${builder.getAppPath()}/|favicon.ico|favicon.png).*)`; try { const file_path = pathToFileURL(`${dirs.functions}/user-middleware.func/index.js`).href; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 20d6300daa16..447dd994946f 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -488,6 +488,10 @@ export async function dev(vite, vite_config, svelte_config) { // adapter-provided middleware vite.middlewares.use(async (req, res, next) => { if (!emulator?.beforeRequest) return next(); + + // Middleware can run on all files, but for some it does not run on _app/* by default. + // This isn't replicable in dev mode because everything is unbundled, and it would give + // wrong pathnames to compare against, too, so we just filter them out at dev time. if ( req.url?.startsWith('/@fs/') || req.url?.startsWith('/@vite/') || diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 359aedbc30db..9a6681a12605 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -72,10 +72,6 @@ export async function preview(vite, vite_config, svelte_config) { vite.middlewares.use(async (req, res, next) => { if (!emulator?.beforeRequest) return next(); - const { pathname } = new URL(/** @type {string} */ (req.url), 'http://dummy'); - - if (pathname.startsWith(`/${svelte_config.kit.appDir}/immutable`)) return next(); - return emulator.beforeRequest(req, res, next); }); From 6e90adc3a528066956701b679469b5dffba42e6d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 24 Feb 2025 21:35:41 +0100 Subject: [PATCH 37/45] beforeRequest -> interceptRequest --- .../docs/25-build-and-deploy/99-writing-adapters.md | 2 +- packages/adapter-cloudflare/index.js | 2 +- packages/adapter-netlify/index.js | 2 +- packages/adapter-node/index.js | 2 +- packages/adapter-vercel/index.js | 2 +- packages/kit/src/exports/public.d.ts | 6 ++++-- packages/kit/src/exports/vite/dev/index.js | 6 +++--- packages/kit/src/exports/vite/preview/index.js | 4 ++-- packages/kit/test/apps/basics/svelte.config.js | 2 +- packages/kit/types/index.d.ts | 6 ++++-- 10 files changed, 19 insertions(+), 15 deletions(-) diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index a865a929c502..0db48db6499c 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -27,7 +27,7 @@ export default function (options) { // the returned object becomes `event.platform` during dev, build and // preview. Its shape is that of `App.Platform` }, - async beforeRequest(req, res, next) { + async interceptRequest(req, res, next) { // Allows you to run code before a request to a prerendered page, a static asset, // or a regular request to the SvelteKit runtime, both in dev and preview mode. // Allows you to for example replicate middleware during dev and preview. diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 6bc24790684b..6fb8a38af1d5 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -127,7 +127,7 @@ export default function (options = {}) { emulated ??= await get_emulated(); return prerender ? emulated.prerender_platform : emulated.platform; }, - beforeRequest: async (req, res, next) => { + interceptRequest: async (req, res, next) => { emulated ??= await get_emulated(); const middleware = await opts.importEntryPoint('cloudflare-middleware'); diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 9d88d6f13814..4e485d96b01e 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -122,7 +122,7 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { if (!middleware_path) return {}; return { - beforeRequest: async (req, res, next) => { + interceptRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importEntryPoint('edge-middleware'); diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 1999d6e2421a..57d7572b3874 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -124,7 +124,7 @@ export default function (opts = {}) { if (!middleware_path) return {}; return { - beforeRequest: async (req, res, next) => { + interceptRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importEntryPoint('node-middleware'); diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index d14cbdae397e..cb6a5b7441ab 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -509,7 +509,7 @@ const plugin = function (defaults = {}) { if (!middleware_path) return {}; return { - beforeRequest: async (req, res, next) => { + interceptRequest: async (req, res, next) => { // We have to import this here or else we wouldn't notice when the middleware file changes const middleware = await opts.importEntryPoint('edge-middleware'); const matcher = new RegExp(get_regex_from_matchers(middleware.config?.matcher)); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 71dcf7bda18d..0a080bb3dd59 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -290,10 +290,12 @@ export interface Emulator { platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; /** * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. - * Can be used to replicate middleware behavior in dev mode. + * In preview mode, in runs prior to all requests. + * Can be used to replicate middleware behavior outside of production environments. + * * Implementation note: You either have to call `next()` to pass on the request/response, or `res.end()` to finish the request */ - beforeRequest?: ( + interceptRequest?: ( req: IncomingMessage & { originalUrl?: string }, res: ServerResponse, next: () => void diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 447dd994946f..cb0122172b07 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -487,7 +487,7 @@ export async function dev(vite, vite_config, svelte_config) { // adapter-provided middleware vite.middlewares.use(async (req, res, next) => { - if (!emulator?.beforeRequest) return next(); + if (!emulator?.interceptRequest) return next(); // Middleware can run on all files, but for some it does not run on _app/* by default. // This isn't replicable in dev mode because everything is unbundled, and it would give @@ -513,7 +513,7 @@ export async function dev(vite, vite_config, svelte_config) { return next(); } - // Vite's base middleware strips out the base path. Restore it for the duration of beforeRequest + // Vite's base middleware strips out the base path. Restore it for the duration of interceptRequest const prev_url = req.url; req.url = req.originalUrl; const _next = () => { @@ -525,7 +525,7 @@ export async function dev(vite, vite_config, svelte_config) { } return next(); }; - return emulator.beforeRequest(req, res, _next); + return emulator.interceptRequest(req, res, _next); } catch (e) { const error = coalesce_to_error(e); res.statusCode = 500; diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 9a6681a12605..16e885dd26c9 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -70,9 +70,9 @@ export async function preview(vite, vite_config, svelte_config) { // adapter-provided middleware vite.middlewares.use(async (req, res, next) => { - if (!emulator?.beforeRequest) return next(); + if (!emulator?.interceptRequest) return next(); - return emulator.beforeRequest(req, res, next); + return emulator.interceptRequest(req, res, next); }); // generated client assets and the contents of `static` diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 0fbe59a560dc..d22f7a1c9ddd 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -8,7 +8,7 @@ const config = { adapt() {}, emulate(opts) { return { - async beforeRequest(req, res, next) { + async interceptRequest(req, res, next) { const middleware = await opts.importEntryPoint('test-adapter-middleware'); await middleware.default(req, res, next); }, diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 319f8b8cb7a2..dc77aa1c474b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -272,10 +272,12 @@ declare module '@sveltejs/kit' { platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; /** * Runs before every request that would hit the SvelteKit runtime and before requests to static assets in dev mode. - * Can be used to replicate middleware behavior in dev mode. + * In preview mode, in runs prior to all requests. + * Can be used to replicate middleware behavior outside of production environments. + * * Implementation note: You either have to call `next()` to pass on the request/response, or `res.end()` to finish the request */ - beforeRequest?: ( + interceptRequest?: ( req: IncomingMessage & { originalUrl?: string }, res: ServerResponse, next: () => void From 083b22d3c9e74c18fe6bc8011ae175831dad0b5b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 24 Feb 2025 21:39:36 +0100 Subject: [PATCH 38/45] fix test config --- packages/kit/test/apps/basics/svelte.config.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index d22f7a1c9ddd..72803a3353cd 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -20,13 +20,9 @@ const config = { supports: { read: () => true }, - additionalEntryPoints: () => [ - { - name: 'test-adapter-middleware', - file: 'test-adapter-middleware.js', - allowedFeatures: [] - } - ] + additionalEntryPoints: { + 'test-adapter-middleware': 'test-adapter-middleware.js' + } }, prerender: { From b367434f8acb279e4a4776625929fbd1162f742e Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:30:17 +0100 Subject: [PATCH 39/45] Update tsconfig.json --- packages/adapter-cloudflare/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-cloudflare/tsconfig.json b/packages/adapter-cloudflare/tsconfig.json index b258035a3555..90d60e50a7e9 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 1ee1d04d5965329b22408b1a9bc07a0396c8df6c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 28 Feb 2025 22:21:40 +0100 Subject: [PATCH 40/45] docs tweaks --- .changeset/eleven-snakes-vanish.md | 2 +- .changeset/nice-tools-marry.md | 2 +- .../docs/25-build-and-deploy/80-adapter-netlify.md | 8 ++++---- .../docs/25-build-and-deploy/90-adapter-vercel.md | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/eleven-snakes-vanish.md b/.changeset/eleven-snakes-vanish.md index 1fc1ab39f3a4..07146cca93bc 100644 --- a/.changeset/eleven-snakes-vanish.md +++ b/.changeset/eleven-snakes-vanish.md @@ -2,4 +2,4 @@ '@sveltejs/adapter-node': minor --- -feat: add possibility for adding polka/express middleware +feat: add support for polka/express middleware diff --git a/.changeset/nice-tools-marry.md b/.changeset/nice-tools-marry.md index 4d3222e22ed1..26bf266542b0 100644 --- a/.changeset/nice-tools-marry.md +++ b/.changeset/nice-tools-marry.md @@ -2,4 +2,4 @@ '@sveltejs/adapter-vercel': minor --- -feat: add possibility of defining edge middleware +feat: add support for edge middleware 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 32945b4ca539..94bad866d88a 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -68,7 +68,7 @@ export default { ## Edge Middleware -You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. +You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered pages and other static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js @@ -102,11 +102,11 @@ export default async function middleware(request, { next, cookies }) { } ``` -By default middleware runs on all requests except for files within `_app/`. You can customize this by exporting a `export const config = { pattern: '' }` object from the file similar to [how you can do it for native edge functions](https://docs.netlify.com/edge-functions/declarations/#declare-edge-functions-inline). +[!NOTE] If you can do what you need to by using the [handle hook](hooks#Server-hooks-handle), do so. Avoid using edge middleware for requests that will end up hitting the SvelteKit server runtime (instead of e.g. static content) — it would be unnecessary (even if very small) overhead. Notable use cases include A/B testing using rerouting on prerendered pages, or adding headers to requests for static assets. -> [!NOTE] Locally during dev and preview this only approximates the capabilities of edge functions. Notably, you cannot read the request or response body, and many properties on the context object are `null`ed. +By default middleware runs on all requests except for SvelteKit-internal artifacts (such as the compiled JS files; normally within `_app/`). You can customize this by exporting a `export const config = { pattern: '' }` object from the file similar to [how you can do it for native edge functions](https://docs.netlify.com/edge-functions/declarations/#declare-edge-functions-inline). Due to the aforementioned performance impact, you should configure this to only run on requests that actually need edge middleware. -> [!NOTE] If you want to run code prior to a request but neither have prerendered pages nor rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. +> [!NOTE] Locally during dev and preview this only approximates the capabilities of edge functions. Notably, you cannot read the request or response body, and many properties on the context object are `null`ed. ## Netlify alternatives to SvelteKit functionality 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 01908807a4cb..74c63413717e 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 ## Edge Middleware -You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered or ISR'd pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered or ISR'd pages by rerouting a user to either variant A or B depending on a cookie. +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept all requests including those for ISRed or prerendered pages and static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to, for example, run A/B tests on prerendered or ISRed pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js @@ -193,9 +193,9 @@ function split_cookies(cookies) { } ``` -By default, middleware runs on all requests except for files within `_app/immutable`. You can customize this by exporting a `config` object with a `matcher` property as described in Vercel's [API documentation](https://vercel.com/docs/functions/edge-middleware/middleware-api#match-paths-based-on-custom-matcher-config). +> [!NOTE] If you can do what you need to by using the [handle hook](hooks#Server-hooks-handle), do so. Avoid using edge middleware for requests that will end up hitting the SvelteKit server runtime (instead of e.g. an ISRed page or static content) — it would be unnecessary (even if very small) overhead. Notable use cases for edge middleware include A/B testing using rewrites on prerendered pages, or running lightweight logic (such as adding headers) while serving ISRed content. -> [!NOTE] If you want to run code prior to a request but neither have prerendered nor ISR'd pages and have no rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. +By default, middleware runs on all requests except for SvelteKit-internal artifacts (such as the compiled JS files; normally within `_app/`). You can customize this by exporting a `config` object with a `matcher` property as described in Vercel's [API documentation](https://vercel.com/docs/functions/edge-middleware/middleware-api#match-paths-based-on-custom-matcher-config). Due to the aforementioned performance impact, you should configure this to only run on requests that actually need edge middleware. ## Environment variables From a2c52229472590689a3c12081fade41ed41d869a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 Mar 2025 17:56:36 +0100 Subject: [PATCH 41/45] fix folder path --- documentation/docs/25-build-and-deploy/40-adapter-node.md | 2 +- documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md | 2 +- documentation/docs/25-build-and-deploy/80-adapter-netlify.md | 2 +- documentation/docs/25-build-and-deploy/90-adapter-vercel.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 4214ce21b976..ac9cc8fd889e 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -139,7 +139,7 @@ When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of sec ## Middleware -You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file at the root of your project. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. +You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file in your `src` folder. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. ```js /// file: node-middleware.js 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 de0d6f7f82d5..deea65a116e9 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -113,7 +113,7 @@ You can deploy one middleware function that closely follows the [Pages Middlewar > [!NOTE] It isn't really Pages Middleware because the adapter compiles to a [single `_worker.js` file](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) (also see the [Notes](#Notes) section), which ignores middleware, but it closely mirrors its capabilities. -To get started, place a `cloudflare-middleware.js` file at the root of your project and export a `onRequest` function from it: +To get started, place a `cloudflare-middleware.js` file in your `src` folder and export a `onRequest` function from it: ```js /// file: cloudflare-middleware.js 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 94bad866d88a..68a8b82f5a1e 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -68,7 +68,7 @@ export default { ## Edge Middleware -You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept requests even for prerendered pages and other static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. +You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file in your `src` folder. You can use it to intercept requests even for prerendered pages and other static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js 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 74c63413717e..a7d02cc2798c 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 ## Edge Middleware -You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file at the root of your project. You can use it to intercept all requests including those for ISRed or prerendered pages and static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to, for example, run A/B tests on prerendered or ISRed pages by rerouting a user to either variant A or B depending on a cookie. +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file in your `src` folder. You can use it to intercept all requests including those for ISRed or prerendered pages and static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to, for example, run A/B tests on prerendered or ISRed pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js From 30a3680e69bb60db64094b01bf1dab4d8d6a3d5a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 Mar 2025 18:15:16 +0100 Subject: [PATCH 42/45] tweak --- documentation/docs/25-build-and-deploy/40-adapter-node.md | 2 +- 1 file changed, 1 insertion(+), 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 ac9cc8fd889e..c10f0044b054 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -139,7 +139,7 @@ When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of sec ## Middleware -You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file in your `src` folder. It must export a default function which receives the same arguments as [Polka middleware](https://github.com/lukeed/polka?tab=readme-ov-file#middleware). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. +You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file in your `src` folder. It must export a default function which receives the same arguments as [Express middleware](https://expressjs.com/en/guide/using-middleware.html) (if you don't use a custom server, then you may also make use of additional [Polka-specific API](https://github.com/lukeed/polka?tab=readme-ov-file#middleware), since that's what the Node adapter uses by default). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. ```js /// file: node-middleware.js From cd116e9926b77a757f4b5b70223965496a0cc9c2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 Mar 2025 22:53:39 +0100 Subject: [PATCH 43/45] docs feedback --- .../25-build-and-deploy/40-adapter-node.md | 29 ++++++--------- .../60-adapter-cloudflare.md | 35 +++++-------------- .../25-build-and-deploy/80-adapter-netlify.md | 12 ++++--- .../25-build-and-deploy/90-adapter-vercel.md | 17 ++------- packages/adapter-cloudflare/index.d.ts | 13 +++++++ 5 files changed, 42 insertions(+), 64 deletions(-) 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 c10f0044b054..ecfb8edb5761 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -139,7 +139,7 @@ When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of sec ## Middleware -You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file in your `src` folder. It must export a default function which receives the same arguments as [Express middleware](https://expressjs.com/en/guide/using-middleware.html) (if you don't use a custom server, then you may also make use of additional [Polka-specific API](https://github.com/lukeed/polka?tab=readme-ov-file#middleware), since that's what the Node adapter uses by default). The middleware runs on all requests. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter prerendered or not and no matter client- or server-side. +You can integrate Express or Polka middleware into your SvelteKit application built with the Node adapter by placing a `node-middleware.js` file in your `src` folder. It must export a default function which receives the same arguments as [Express middleware](https://expressjs.com/en/guide/using-middleware.html) (if you don't use a custom server, then you may also make use of additional [Polka-specific API](https://github.com/lukeed/polka?tab=readme-ov-file#middleware), since that's what the Node adapter uses by default). Unlike the [handle](/docs/kit/hooks#Server-hooks-handle) hook, middleware runs on all requests, including for static assets and prerendered pages. If using [server-side route resolution](configuration#router) this means it runs prior to all navigations, no matter client- or server-side. ```js /// file: node-middleware.js @@ -148,6 +148,7 @@ declare module 'polka'; // @filename: index.js // ---cut--- +import { parse } from 'cookie'; /** * @param {import('polka').Request} req @@ -158,31 +159,21 @@ export default function middleware(req, res, next) { if (req.url !== '/') return next(); // Retrieve feature flag from cookies - let flag = split_cookies(req.headers.cookie ?? '')?.flag; + let flag = parse(req.headers.cookie ?? '').flag; - // Fall back to random value if this is a new visitor - flag ||= Math.random() > 0.5 ? 'a' : 'b'; + if (!flag) { + // Fall back to random value if this is a new visitor + flag = Math.random() > 0.5 ? 'a' : 'b'; + + // Set a cookie to remember the feature flags for this visitor + res.appendHeader('Set-Cookie', `flag=${flag}; Path=/`); + } // Get destination URL based on the feature flag req.url = flag === 'a' ? '/home-a' : '/home-b'; - // Set a cookie to remember the feature flags for this visitor - res.appendHeader('Set-Cookie', `flag=${flag}; Path=/`); - return next(); } - -/** @param {string} cookies */ -function split_cookies(cookies) { - return cookies.split(';').reduce( - (acc, cookie) => { - const [name, value] = cookie.trim().split('='); - acc[name] = value; - return acc; - }, - {} as Record - ); -} ``` ## Options 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 deea65a116e9..741e03081045 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -109,23 +109,19 @@ For testing the build, you should use [Wrangler](https://developers.cloudflare.c ## Pages Middleware -You can deploy one middleware function that closely follows the [Pages Middleware API](https://developers.cloudflare.com/pages/functions/middleware/). You can use it to intercept requests even for prerendered pages. Combined with using [server-side route resolution](configuration#router) you can make sure it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. +You can deploy one middleware function that closely follows the [Pages Middleware API](https://developers.cloudflare.com/pages/functions/middleware/). Unlike the [handle](/docs/kit/hooks#Server-hooks-handle) hook, middleware runs on all requests, including for static assets and prerendered pages (depending on your configuration). If using [server-side route resolution](configuration#router) this means it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. > [!NOTE] It isn't really Pages Middleware because the adapter compiles to a [single `_worker.js` file](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) (also see the [Notes](#Notes) section), which ignores middleware, but it closely mirrors its capabilities. -To get started, place a `cloudflare-middleware.js` file in your `src` folder and export a `onRequest` function from it: +To get started, place a `cloudflare-middleware.js` file in your `src` folder and export an `onRequest` function from it: ```js -/// file: cloudflare-middleware.js -// @filename: ambient.d.ts -declare module '@cloudflare/workers-types'; - -// @filename: index.js -// ---cut--- +/// file: src/cloudflare-middleware.js import { normalizeUrl } from '@sveltejs/kit'; +import { parse } from 'cookie'; /** - * @param {import('@cloudflare/workers-types'.EventContext)} context + * @param {import('@sveltejs/adapter-cloudflare').EventContext)} context */ export function onRequest({ request, next }) { const url = new URL(request.url); @@ -133,7 +129,7 @@ export function onRequest({ request, next }) { if (url.pathname !== '/') return next(); // Retrieve cookies which contain the feature flags. - let flag = split_cookies(request.headers.get('cookie') ?? '')?.['flags']; + let flag = parse(request.headers.get('cookie') ?? '').flags; // Fall back to random value if this is a new visitor flag ||= Math.random() > 0.5 ? 'a' : 'b'; @@ -148,28 +144,15 @@ export function onRequest({ request, next }) { return response; } - -/** @param {string} cookies */ -function split_cookies(cookies) { - return cookies.split(';').reduce( - (acc, cookie) => { - const [name, value] = cookie.trim().split('='); - acc[name] = value; - return acc; - }, - {} as Record - ); -} - ``` The `context` parameter closely follows the [EventContext](https://developers.cloudflare.com/pages/functions/api-reference/#eventcontext) object but is missing some Pages-specific parameters such as `data`, `params` and `functionPath`. -The middleware runs on all requests that your worker is invoked for, which is dependent on the [`include/exlcude` options](#Options-routes). +> [!NOTE] If you want to run code prior to a request but neither have prerendered pages nor rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. -> [!NOTE] Locally during dev and preview this only approximates the capabilities of middleware. Notably, you cannot read the request or response body, and middleware runs on all requests except those that would end up in `_app/immutable`. +The middleware runs on all requests that your worker is invoked for, which is dependent on the [`include/exclude` options](#Options-routes). -> [!NOTE] If you want to run code prior to a request but neither have prerendered pages nor rerouting logic, then it makes more sense to use the [handle hook](hooks#Server-hooks-handle) instead. +> [!NOTE] Locally during dev and preview this only approximates the capabilities of middleware. Notably, you cannot read the request or response body, and the `include/exclude` options are not honored. ## Notes 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 68a8b82f5a1e..c01fb8ef099b 100644 --- a/documentation/docs/25-build-and-deploy/80-adapter-netlify.md +++ b/documentation/docs/25-build-and-deploy/80-adapter-netlify.md @@ -68,7 +68,7 @@ export default { ## Edge Middleware -You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file in your `src` folder. You can use it to intercept requests even for prerendered pages and other static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. +You can deploy one Netlify Edge Function [as middleware](https://docs.netlify.com/edge-functions/api/#modify-a-response) by placing an `edge-middleware.js` file in your `src` folder. Unlike the [handle](/docs/kit/hooks#Server-hooks-handle) hook, middleware can run on all requests, including for static assets and prerendered pages. If using [server-side route resolution](configuration#router) this means it runs prior to all navigations, no matter client- or server-side. This allows you to for example run A/B-tests on prerendered pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js @@ -91,11 +91,13 @@ export default async function middleware(request, { next, cookies }) { // Retrieve feature flag from cookies let flag = cookies.get('flag'); - // Fall back to random value if this is a new visitor - flag ||= Math.random() > 0.5 ? 'a' : 'b'; + if (!flag) { + // Fall back to random value if this is a new visitor + flag = Math.random() > 0.5 ? 'a' : 'b'; - // Set a cookie to remember the feature flags for this visitor - cookies.set('flag', flag); + // Set a cookie to remember the feature flags for this visitor + cookies.set('flag', flag); + } // Get destination URL based on the feature flag return new URL(flag === 'a' ? '/home-a' : '/home-b', url); 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 a7d02cc2798c..774af189d18a 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 ## Edge Middleware -You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file in your `src` folder. You can use it to intercept all requests including those for ISRed or prerendered pages and static content. Combined with [server-side route resolution](configuration#router), you can ensure it runs prior to all navigations, whether client- or server-side. This allows you to, for example, run A/B tests on prerendered or ISRed pages by rerouting a user to either variant A or B depending on a cookie. +You can make use of [Vercel Edge Middleware](https://vercel.com/docs/functions/edge-middleware) by placing an `edge-middleware.js` file in your `src` folder. Unlike the [handle](/docs/kit/hooks#Server-hooks-handle) hook, middleware can run on all requests, including for static assets and prerendered or ISRed pages. If using [server-side route resolution](configuration#router) this means it runs prior to all navigations, no matter client- or server-side. This allows you to, for example, run A/B tests on prerendered or ISRed pages by rerouting a user to either variant A or B depending on a cookie. ```js /// file: edge-middleware.js @@ -153,6 +153,7 @@ declare module '@vercel/edge'; // @filename: index.js // ---cut--- import { rewrite, next } from '@vercel/edge'; +import { parse } from 'cookie'; /** * @param {Request} request @@ -163,7 +164,7 @@ export default async function middleware(request) { if (url.pathname !== '/') return next(); // Retrieve feature flag from cookies - let flag = split_cookies(request.headers.get('cookie') ?? '')?.flag; + let flag = parse(request.headers.get('cookie') ?? '').flag; // Fall back to random value if this is a new visitor flag ||= Math.random() > 0.5 ? 'a' : 'b'; @@ -179,18 +180,6 @@ export default async function middleware(request) { } ); } - -/** @param {string} cookies */ -function split_cookies(cookies) { - return cookies.split(';').reduce( - (acc, cookie) => { - const [name, value] = cookie.trim().split('='); - acc[name] = value; - return acc; - }, - {} as Record - ); -} ``` > [!NOTE] If you can do what you need to by using the [handle hook](hooks#Server-hooks-handle), do so. Avoid using edge middleware for requests that will end up hitting the SvelteKit server runtime (instead of e.g. an ISRed page or static content) — it would be unnecessary (even if very small) overhead. Notable use cases for edge middleware include A/B testing using rewrites on prerendered pages, or running lightweight logic (such as adding headers) while serving ISRed content. diff --git a/packages/adapter-cloudflare/index.d.ts b/packages/adapter-cloudflare/index.d.ts index a1cacb498e24..a04a598956f7 100644 --- a/packages/adapter-cloudflare/index.d.ts +++ b/packages/adapter-cloudflare/index.d.ts @@ -57,3 +57,16 @@ export interface RoutesJSONSpec { include: string[]; exclude: string[]; } + +/** + * The type of the parameter that is passed to the middleware. + * Closely modelled after Cloudflare's EventContext type. + */ +export interface EventContext> { + request: Request; + bindings?: Bindings; + waitUntil(f: any): void; + passThroughOnException(): void; + env: Record & { ASSETS: { fetch: typeof fetch } }; + next: (input?: Request | string, init?: RequestInit) => Promise; +} From 663d59db89e2b776af80f5731a7e6148533b45dc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 Mar 2025 22:57:58 +0100 Subject: [PATCH 44/45] warn on stream read (don't error because you can't guard against it in preview) --- packages/adapter-cloudflare/index.js | 23 +++++++++++++++++------ packages/adapter-netlify/index.js | 22 ++++++++++++++++------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 6fb8a38af1d5..5c4ee1a40d5f 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -135,13 +135,24 @@ export default function (options = {}) { const request = new Request(url, { headers: node_kit.getRequestHeaders(req), - method: req.method, - body: - // We omit the body here because it would consume the stream - req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] - ? undefined - : 'Cannot read body in dev mode' + method: req.method }); + + // We omit the body here because it would consume the stream + if (req.method !== 'GET' && req.method !== 'HEAD') { + Object.defineProperty(request, 'body', { + get() { + console.warn('Cannot read request body in dev/preview.'); + return new ReadableStream({ + start(controller) { + controller.enqueue('Cannot read request body in dev/preview.'); + controller.close(); + } + }); + } + }); + } + // @ts-expect-error slight type mismatch which seems harmless request.cf = emulated.platform.cf; diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index f234c7b5db24..077dfcc85cd5 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -130,14 +130,24 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { const request = new Request(url, { headers: node_kit.getRequestHeaders(req), - method: req.method, - body: - // We omit the body here because it would consume the stream - req.method === 'GET' || req.method === 'HEAD' || !req.headers['content-type'] - ? undefined - : 'Cannot read body in dev mode' + method: req.method }); + // We omit the body here because it would consume the stream + if (req.method !== 'GET' && req.method !== 'HEAD') { + Object.defineProperty(request, 'body', { + get() { + console.warn('Cannot read request body in dev/preview.'); + return new ReadableStream({ + start(controller) { + controller.enqueue('Cannot read request body in dev/preview.'); + controller.close(); + } + }); + } + }); + } + // Netlify allows you to modify the response object after calling next(). // This isn't replicable using Vite or Polka middleware, so we approximate it. const fake_response = new Response(); From f9efcbde5589c383b46ecd9b7c4fe01660d8e73b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 Mar 2025 23:01:17 +0100 Subject: [PATCH 45/45] fix test --- .../kit/test/apps/basics/test/cross-platform/client.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index c1de68907024..5641ad79dac1 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -863,8 +863,10 @@ test.describe('Routing', () => { await page.locator('input').fill('updated'); await page.locator('button').click(); - // Filter out server-side route resolution request - expect(requests.filter((r) => !r.includes('__route.js'))).toEqual([]); + // Filter out server-side route resolution request-related stuff + expect( + requests.filter((r) => !r.includes('__route.js') && !r.includes('_app/immutable/assets')) + ).toEqual([]); expect(await page.textContent('h1')).toBe('updated'); expect(await page.textContent('h2')).toBe('form'); expect(await page.textContent('h3')).toBe('bar');