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..07146cca93bc --- /dev/null +++ b/.changeset/eleven-snakes-vanish.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': minor +--- + +feat: add support for 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..26bf266542b0 --- /dev/null +++ b/.changeset/nice-tools-marry.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: add support for 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 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..ecfb8edb5761 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,45 @@ 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 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 +// @filename: ambient.d.ts +declare module 'polka'; + +// @filename: index.js +// ---cut--- +import { parse } from 'cookie'; + +/** + * @param {import('polka').Request} req + * @param {import('polka').Response} res + * @param {import('polka').NextHandler} next + */ +export default function middleware(req, res, next) { + if (req.url !== '/') return next(); + + // Retrieve feature flag from cookies + let flag = parse(req.headers.cookie ?? '').flag; + + 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'; + + return next(); +} +``` + ## Options The adapter can be configured with various 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 e0f71c8dcdce..741e03081045 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -107,6 +107,53 @@ Cloudflare Workers specific values in the `platform` property are emulated durin For testing the build, you should use [Wrangler](https://developers.cloudflare.com/workers/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/). 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 an `onRequest` function from it: + +```js +/// file: src/cloudflare-middleware.js +import { normalizeUrl } from '@sveltejs/kit'; +import { parse } from 'cookie'; + +/** + * @param {import('@sveltejs/adapter-cloudflare').EventContext)} context + */ +export function onRequest({ request, next }) { + const url = new URL(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve cookies which contain the feature 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'; + + // Get destination URL based on the feature flag + request = new Request(new URL(flag === 'a' ? '/home-a' : '/home-b', url), 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; +} +``` + +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`. + +> [!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. + +The middleware runs on all requests that your worker is invoked for, which is dependent on the [`include/exclude` 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 the `include/exclude` options are not honored. + ## Notes Functions contained in the [`/functions` directory](https://developers.cloudflare.com/pages/functions/routing/) at the project's root will _not_ be included in the deployment. Instead, functions should be implemented as [server endpoints](routing#server) in your SvelteKit app, which is compiled to a [single `_worker.js` file](https://developers.cloudflare.com/pages/functions/advanced-mode/). 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..c01fb8ef099b 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,50 @@ 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. 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 +// @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 = new URL(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve feature flag from cookies + let flag = cookies.get('flag'); + + 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); + } + + // Get destination URL based on the feature flag + return new URL(flag === 'a' ? '/home-a' : '/home-b', url); +} +``` + +[!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. + +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] 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 You may build your app using functionality provided directly by SvelteKit without relying on any Netlify functionality. Using the SvelteKit versions of these features will allow them to be used in dev mode, tested with integration tests, and to work with other adapters should you ever decide to switch away from Netlify. However, in some scenarios you may find it beneficial to use the Netlify versions of these features. One example would be if you're migrating an app that's already hosted on Netlify to SvelteKit. diff --git a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md index 43e237b80902..774af189d18a 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,52 @@ 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 + +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 +// @filename: ambient.d.ts +declare module '@vercel/edge'; + +// @filename: index.js +// ---cut--- +import { rewrite, next } from '@vercel/edge'; +import { parse } from 'cookie'; + +/** + * @param {Request} request + */ +export default async function middleware(request) { + const url = new URL(request.url); + + if (url.pathname !== '/') return next(); + + // Retrieve feature flag from cookies + 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'; + + 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=/` + } + } + ); +} +``` + +> [!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. + +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 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..0db48db6499c 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 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. + const module = await importEntryPoint('additional-entry-point'); + module.default(req, res, next); } } }, @@ -35,6 +42,12 @@ 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 } + }, + // 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' } }; 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; +} diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index ceac64d92a2a..5c4ee1a40d5f 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'; +// 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] = kit.VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'src/cloudflare-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'src/cloudflare-middleware.ts'; + if (!existsSync(middleware_path)) middleware_path = null; +} /** @type {import('./index.js').default} */ export default function (options = {}) { @@ -45,6 +58,19 @@ export default function (options = {}) { `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); + 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`, JSON.stringify(get_routes_json(builder, written_files, options.routes ?? {}), null, '\t') @@ -63,11 +89,13 @@ 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: `${path.posix.relative(dest, tmp)}/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,9 +126,81 @@ export default function (options = {}) { platform: async ({ prerender }) => { emulated ??= await get_emulated(); return prerender ? emulated.prerender_platform : emulated.platform; + }, + interceptRequest: async (req, res, next) => { + emulated ??= await get_emulated(); + const middleware = await opts.importEntryPoint('cloudflare-middleware'); + + const { url, denormalize } = kit.normalizeUrl(req.url); + + const request = new Request(url, { + headers: node_kit.getRequestHeaders(req), + 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; + + // 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>} */ ({ + // 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: (input, init) => { + // More any casts because of annoying CF types + const request = + input instanceof Request + ? input + : input && new Request(/** @type {any} */ (input), /** @type {any} */ (init)); + + if (request) { + const url = denormalize(request.url); + req.url = url.pathname + url.search; + for (const [key, value] of request.headers) { + req.headers[key] = value; + } + } + + 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 + return node_kit.setResponse(res, response); + } else { + for (const header of fake_response.headers) { + res.setHeader(header[0], header[1]); + } + + return next(); + } } }; - } + }, + + additionalEntryPoints: { 'cloudflare-middleware': middleware_path } }; } 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/package.json b/packages/adapter-cloudflare/package.json index 235a6129ad36..a979952801a9 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 && 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-cloudflare/src/middleware.js b/packages/adapter-cloudflare/src/middleware.js new file mode 100644 index 000000000000..b9037b313302 --- /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, wasNormalized, denormalize } = normalizeUrl(context.request.url); + + if (wasNormalized) { + 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-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index c3c27a0b041f..519e5f925d41 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(req, 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-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/**/*"] } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index a405702118e6..077dfcc85cd5 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,10 +1,13 @@ 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'; 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 ? 'src/edge-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'src/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,10 +118,117 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } }, + emulate: (opts) => { + if (!middleware_path) return {}; + + return { + 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 { url, denormalize } = kit.normalizeUrl(req.url); + + const request = new Request(url, { + headers: node_kit.getRequestHeaders(req), + 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(); + + 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 new_url = denormalize(request.url); + req.url = new_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 + 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 + return node_kit.setResponse(res, response); + } else { + return next(); + } + } + }; + }, + + 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` ); @@ -127,6 +251,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,52 +261,87 @@ 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 }} */ // we have to prepend the file:// protocol because Windows doesn't support absolute path imports const { assets } = (await import(`file://${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.getAppPath()}/*`, // Contains static files + ...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()); + + 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`, + MIDDLEWARE: `${relativePath}/adapter/edge-middleware.js` + } + }); + + await bundle_edge_function({ builder, name: 'edge-middleware', pattern }); +} + +/** + * @param {{ builder: import('@sveltejs/kit').Builder; name: string; } & ({ path: string; excludedPath: string[] } | { pattern: string })} params + */ +async function bundle_edge_function(params) { + const tmp = params.builder.getBuildDirectory('netlify-tmp'); await esbuild.build({ entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', + outfile: `.netlify/edge-functions/${params.name}.js`, bundle: true, format: 'esm', platform: 'browser', @@ -201,8 +361,35 @@ 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 + : []), + '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 + }; + 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/package.json b/packages/adapter-netlify/package.json index 5d348330663c..f90f93c04377 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 new file mode 100644 index 000000000000..d69680804113 --- /dev/null +++ b/packages/adapter-netlify/src/middleware.js @@ -0,0 +1,45 @@ +import { normalizeUrl } from '@sveltejs/kit'; +import { initServer } from 'SERVER_INIT'; +import user_middleware from 'MIDDLEWARE'; + +initServer({ + env: { + // @ts-ignore + env: Deno.env.toObject(), + public_prefix: 'PUBLIC_PREFIX', + private_prefix: 'PRIVATE_PREFIX' + } +}); + +/** + * @param {Request} request + * @param {any} context + */ +export default async function middleware(request, context) { + const { url, wasNormalized, denormalize } = normalizeUrl(request.url); + + if (wasNormalized) { + 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 9b0b3158ab82..57d7572b3874 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,9 +1,21 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { rollup } from 'rollup'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; +// 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] = kit.VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'src/node-middleware.js' : null; +if (middleware_path && !existsSync(middleware_path)) { + middleware_path = 'src/node-middleware.ts'; + if (!existsSync(middleware_path)) middleware_path = null; +} const files = fileURLToPath(new URL('./files', import.meta.url).href); @@ -46,6 +58,20 @@ export default function (opts = {}) { ].join('\n\n') ); + if (middleware_path) { + builder.copy(`${files}/middleware.js`, `${tmp}/adapter/node-middleware-wrapper.js`, { + replace: { + MIDDLEWARE: './node-middleware.js' + } + }); + } else { + builder.mkdirp(`${tmp}/adapter`); + writeFileSync( + `${tmp}/adapter/node-middleware-wrapper.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 +80,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}/adapter/node-middleware-wrapper.js` }, external: [ // dependencies could have deep exports, so we need a regex @@ -80,17 +107,42 @@ export default function (opts = {}) { }); builder.copy(files, out, { + filter: (file) => file !== 'middleware.js', replace: { ENV: './env.js', HANDLER: './handler.js', 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: (opts) => { + if (!middleware_path) return {}; + + return { + 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'); + + 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); + } + }; + }, + + additionalEntryPoints: { 'node-middleware': middleware_path }, + 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/rollup.config.js b/packages/adapter-node/rollup.config.js index fb9d113b5965..4e655d150e3e 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', '@sveltejs/kit'] } ]; diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 37827e64042c..807279aabdee 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 middleware from 'MIDDLEWARE'; import { parse_as_bytes } from '../utils.js'; /* global ENV_PREFIX */ @@ -194,6 +195,7 @@ function get_origin(headers) { export const handler = sequence( [ + middleware, serve(path.join(dir, 'client'), true), serve(path.join(dir, 'static')), serve_prerendered(), diff --git a/packages/adapter-node/src/middleware.js b/packages/adapter-node/src/middleware.js new file mode 100644 index 000000000000..3be4e93df8a4 --- /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, wasNormalized, denormalize } = normalizeUrl(req.url); + + if (wasNormalized) { + 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/edge.js b/packages/adapter-vercel/files/edge.js index 1098fbf31379..492855859e52 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('REWRITE_HEADER'); + } + return server.respond(request, { getClientAddress() { return /** @type {string} */ (request.headers.get('x-forwarded-for')); diff --git a/packages/adapter-vercel/files/middleware.js b/packages/adapter-vercel/files/middleware.js new file mode 100644 index 000000000000..9c3024467ef9 --- /dev/null +++ b/packages/adapter-vercel/files/middleware.js @@ -0,0 +1,41 @@ +/* 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'; + +initServer({ + env: { + env: /** @type {Record} */ (process.env), + public_prefix: 'PUBLIC_PREFIX', + private_prefix: 'PRIVATE_PREFIX' + } +}); + +export const config = user_middleware.config; + +/** + * @param {Request} request + * @param {any} context + */ +export default async function middleware(request, context) { + const { url, wasNormalized, denormalize } = normalizeUrl(request.url); + + if (wasNormalized) { + 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 = denormalize( + /** @type {string} */ (response.headers.get('x-middleware-rewrite')) + ); + 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/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..cb6a5b7441ab 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,11 +1,13 @@ 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 { VERSION } from '@sveltejs/kit'; +import { get_pathname, get_regex_from_matchers, pattern_to_src, REWRITE_HEADER } from './utils.js'; +// 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,6 +39,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] = kit.VERSION.split('.').map(Number); +const can_use_middleware = major > 2 || (major === 2 && minor > 17); + +/** @type {string | null} */ +let middleware_path = can_use_middleware ? 'src/edge-middleware.js' : null; +if (middleware_path && !fs.existsSync(middleware_path)) { + middleware_path = 'src/edge-middleware.ts'; + if (!fs.existsSync(middleware_path)) middleware_path = null; +} + /** @type {import('./index.js').default} **/ const plugin = function (defaults = {}) { if ('edge' in defaults) { @@ -95,7 +107,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 } }); @@ -113,29 +126,13 @@ const plugin = function (defaults = {}) { } /** + * @param {import('esbuild').BuildOptions & Required>} esbuild_options * @param {string} name - * @param {import('./index.js').EdgeConfig} config - * @param {import('@sveltejs/kit').RouteDefinition[]} routes + * @param {import('./index.js').Config} adapter_config */ - async function generate_edge_function(name, config, routes) { - const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); - const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); - - builder.copy(`${files}/edge.js`, `${tmp}/edge.js`, { - replace: { - SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' - } - }); - - write( - `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` - ); - + async function bundle_edge_function(esbuild_options, name, adapter_config) { try { const result = await esbuild.build({ - entryPoints: [`${tmp}/edge.js`], outfile: `${dirs.functions}/${name}.func/index.js`, target: 'es2020', // TODO verify what the edge runtime supports bundle: true, @@ -144,7 +141,7 @@ const plugin = function (defaults = {}) { external: [ ...compatible_node_modules, ...compatible_node_modules.map((id) => `node:${id}`), - ...(config.external || []) + ...((adapter_config.runtime === 'edge' && adapter_config.external) || []) ], sourcemap: 'linked', banner: { js: 'globalThis.global = globalThis;' }, @@ -155,7 +152,8 @@ const plugin = function (defaults = {}) { '.ttf': 'copy', '.eot': 'copy', '.otf': 'copy' - } + }, + ...(esbuild_options || {}) }); if (result.warnings.length > 0) { @@ -199,12 +197,12 @@ 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', - version: VERSION + version: kit.VERSION } }, null, @@ -213,6 +211,94 @@ 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 + } + }); + + write( + `${tmp}/manifest.js`, + `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` + ); + + await bundle_edge_function({ entryPoints: [dest] }, name, config); + } + + /** + * @param {import('./index.js').Config} config + */ + async function generate_edge_middleware(config) { + if (!middleware_path) return; + + const dest = `${tmp}/middleware.js`; + const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${files}/middleware.js`, dest, { + replace: { + SERVER_INIT: `${relativePath}/init.js`, + MIDDLEWARE: `${relativePath}/adapter/edge-middleware.js`, + PUBLIC_PREFIX: builder.config.kit.env.publicPrefix, + PRIVATE_PREFIX: builder.config.kit.env.privatePrefix + } + }); + + await bundle_edge_function( + { + 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 + ); + + let matcher = `/((?!${builder.getAppPath()}/|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(); @@ -419,9 +505,60 @@ const plugin = function (defaults = {}) { write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); }, + emulate: (opts) => { + if (!middleware_path) return {}; + + return { + 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)); + const original_url = /** @type {string} */ (req.url); + + if (matcher.test(original_url)) { + 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' + }); + + 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 === 'x-middleware-rewrite') { + 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; + } 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 }) => { + 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') { @@ -432,7 +569,9 @@ const plugin = function (defaults = {}) { return true; } - } + }, + + additionalEntryPoints: { 'edge-middleware': middleware_path } }; }; @@ -688,7 +827,7 @@ async function create_function_bundle(builder, entry, dir, config) { experimentalResponseStreaming: !config.isr, framework: { slug: 'sveltekit', - version: VERSION + version: kit.VERSION } }, null, diff --git a/packages/adapter-vercel/internal.d.ts b/packages/adapter-vercel/internal.d.ts index 537f7cc041d1..540f6812d960 100644 --- a/packages/adapter-vercel/internal.d.ts +++ b/packages/adapter-vercel/internal.d.ts @@ -2,7 +2,16 @@ 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; } + +declare module 'MIDDLEWARE' { + export const config: any; + export default function middleware(request: Request, context: any): any; +} diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index db09ede3b6ce..d6bf656ae428 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -41,7 +41,9 @@ }, "dependencies": { "@vercel/nft": "^0.29.2", - "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..349cfa329b47 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/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 2484aea4831d..aa71d17f9b1d 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: Record * env: Record * }} opts */ @@ -35,6 +36,7 @@ async function analyse({ manifest_path, manifest_data, server_manifest, + additional_entry_points, tracked_features, env }) { @@ -92,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 = @@ -121,13 +131,13 @@ 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, tracked_features )) { - check_feature(route.id, route_config, feature, config.adapter); + check_feature(route.id, route_config, undefined, feature, config.adapter); } } @@ -213,18 +223,12 @@ 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 + * @param {Set} [features] */ -function list_features(route, manifest_data, server_manifest, tracked_features) { - const features = new Set(); - - const route_data = /** @type {import('types').RouteData} */ ( - manifest_data.routes.find((r) => r.id === route.id) - ); - +function list_features(entry, server_manifest, tracked_features, features = new Set()) { /** @param {string} id */ function visit(id) { const chunk = server_manifest[id]; @@ -243,21 +247,40 @@ function list_features(route, manifest_data, server_manifest, tracked_features) } } + visit(entry); + + return 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 = new Set(); + 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) { + list_features(page_node.server, server_manifest, tracked_features, features); + } page_node = page_node.parent ?? null; } if (route_data.endpoint) { - visit(route_data.endpoint.file); + 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 - visit(manifest_data.hooks.server); + list_features(manifest_data.hooks.server, server_manifest, tracked_features, features); } - return Array.from(features); + return features; } diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 7c84269e3306..375651190ae0 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?.(); + const emulator = await config.adapter?.emulate?.({ + importEntryPoint: (entry) => + import(pathToFileURL(`${config.outDir}/output/server/adapter/${entry}.js`).href) + }); /** @type {import('types').Logger} */ const log = logger({ verbose }); diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 3b69f24de40e..1b4b7aa373b0 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,49 @@ export function fail(status, data) { export function isActionFailure(e) { return e instanceof ActionFailure; } + +/** + * 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 + * 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, 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, + 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/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/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f25cc225e194..0a080bb3dd59 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'; @@ -42,14 +43,26 @@ 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 * during dev, build and prerendering */ - emulate?: () => MaybePromise; + emulate?: (helpers: { + /** Allows to import an entry point defined within `additionalEntryPoints` by referencing its name */ + importEntryPoint: (name: string) => Promise; + }) => MaybePromise; + /** + * 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?: Record; } export type LoadProperties | void> = input extends void @@ -275,6 +288,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. + * 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 + */ + interceptRequest?: ( + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + next: () => void + ) => MaybePromise; } export interface KitConfig { @@ -1295,12 +1320,25 @@ export interface RouteDefinition { config: Config; } +/** + * 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); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; } +/** + * 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 */ + 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 6d7b7acf0456..06bcc956e1d4 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -37,9 +37,37 @@ 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; - check_feature(context.event.route.id, context.config, label, svelte_config.kit.adapter); + 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 + ); }; const fetch = globalThis.fetch; @@ -421,7 +449,25 @@ 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 additional_entry_points = svelte_config.kit.adapter?.additionalEntryPoints ?? {}; + const emulator = await svelte_config.kit.adapter?.emulate?.({ + importEntryPoint: (entry) => { + const file = additional_entry_points[entry]; + if (!file) { + throw new Error( + `Entry point '${entry}' not found: ` + + 'Adapters can only import entry points defined previously through additionalEntryPoints' + ); + } + return 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 }`; @@ -434,18 +480,69 @@ 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?.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 + // wrong pathnames to compare against, too, so we just filter them out at dev time. + if ( + req.url?.startsWith('/@fs/') || + req.url?.startsWith('/@vite/') || + req.url?.includes('virtual:') + ) { + return next(); + } + + 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(); + } + + // 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 = () => { + 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.interceptRequest(req, res, _next); + } catch (e) { + const error = coalesce_to_error(e); + res.statusCode = 500; + res.end(fix_stack_trace(error)); + } }); - 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( @@ -466,7 +563,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 = diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index bdb37b1f9cff..d19a8e3ed159 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,15 @@ 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 [entry, file] of Object.entries(additional_entry_points)) { + if (file) { + input[`adapter/${entry}`] = file; + } + } + // add entry points for every endpoint... manifest_data.routes.forEach((route) => { if (route.endpoint) { @@ -825,6 +835,7 @@ Tips: manifest_data, server_manifest, tracked_features, + additional_entry_points, env: { ...env.private, ...env.public } }); diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 0342e718c75c..16e885dd26c9 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?.({ + importEntryPoint: (entry) => import(pathToFileURL(join(dir, `adapter/${entry}.js`)).href) + }); return () => { // Remove the base middleware. It screws with the URL. @@ -66,6 +68,13 @@ export async function preview(vite, vite_config, svelte_config) { } } + // adapter-provided middleware + vite.middlewares.use(async (req, res, next) => { + if (!emulator?.interceptRequest) return next(); + + return emulator.interceptRequest(req, res, next); + }); + // generated client assets and the contents of `static` vite.middlewares.use( scoped( 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..3237e26108f1 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -155,6 +155,8 @@ export interface Logger { export type MaybePromise = T | Promise; +export type TrackedFeature = '$app/server:read'; + 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..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} feature + * @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/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..72803a3353cd 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 interceptRequest(req, res, next) { + const middleware = await opts.importEntryPoint('test-adapter-middleware'); + await middleware.default(req, res, next); + }, platform({ config, prerender }) { return { config, prerender }; } @@ -15,6 +19,9 @@ const config = { }, supports: { read: () => true + }, + additionalEntryPoints: { + 'test-adapter-middleware': 'test-adapter-middleware.js' } }, 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..51bdcd8f4f16 --- /dev/null +++ b/packages/kit/test/apps/basics/test-adapter-middleware.js @@ -0,0 +1,24 @@ +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) { + 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 (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'); + } + + next(); + } +} 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'); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index c5867f34e00c..84b95095ebe8 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1532,3 +1532,37 @@ test.describe('Serialization', () => { expect(await page.textContent('h1')).toBe('It works!'); }); }); + +test.describe('Middleware', () => { + test('Responds with custom Response', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) return; // TODO figure out what should happen in this case + + await page.goto('/middleware'); + await page.click('a[href="/middleware/custom-response"]'); + expect(await page.textContent('h1')).toBe('Custom Response'); + }); + + test('Reroutes to a different page', async ({ page }) => { + await page.goto('/middleware'); + await page.click('a[href="/middleware/reroute/a"]'); + expect(await page.textContent('p')).toBe('Rerouted'); + expect(new URL(page.url()).pathname).toBe('/middleware/reroute/a'); + }); + + test('Sets request/response headers', async ({ page, javaScriptEnabled }) => { + if ( + javaScriptEnabled && + /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) !== 'server' + ) { + // For client side navigation, we need server side route resolution to have a request to add the headers to + return; + } + + await page.goto('/middleware'); + page.click('a[href="/middleware/headers"]'); + const response = await page.waitForResponse((response) => + new URL(response.url()).pathname.includes('/middleware/headers') + ); + expect(await response.headerValue('x-custom-response-header')).toBe('value'); + }); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f7ca3bce33f9..41af77f9f1ee 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. */ @@ -24,14 +25,26 @@ 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 * during dev, build and prerendering */ - emulate?: () => MaybePromise; + emulate?: (helpers: { + /** Allows to import an entry point defined within `additionalEntryPoints` by referencing its name */ + importEntryPoint: (name: string) => Promise; + }) => MaybePromise; + /** + * 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?: Record; } export type LoadProperties | void> = input extends void @@ -257,6 +270,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. + * 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 + */ + interceptRequest?: ( + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + next: () => void + ) => MaybePromise; } export interface KitConfig { @@ -1277,12 +1302,25 @@ declare module '@sveltejs/kit' { config: Config; } + /** + * 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); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; } + /** + * 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 */ + 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; @@ -2002,6 +2040,23 @@ declare module '@sveltejs/kit' { * @param e The object to check. * */ export function isActionFailure(e: unknown): e is ActionFailure; + /** + * 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 + * 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 + * ``` + * */ + export function normalizeUrl(url: URL | string): { + url: URL; + wasNormalized: boolean; + denormalize: (url?: string | URL) => URL; + }; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; export type NumericRange = Exclude, LessThan>; export const VERSION: string; @@ -2101,6 +2156,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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 009f9699b389..abab766c9d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,12 +262,18 @@ importers: packages/adapter-vercel: dependencies: + '@vercel/edge': + specifier: ^1.2.1 + version: 1.2.1 '@vercel/nft': specifier: ^0.29.2 version: 0.29.2(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:^ @@ -2052,6 +2058,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.2': resolution: {integrity: sha512-A/Si4mrTkQqJ6EXJKv5EYCDQ3NL6nJXxG8VGXePsaiQigsomHYQC9xSpX8qGk7AEZk4b1ssbYIqJ0ISQQ7bfcA==} engines: {node: '>=18'} @@ -4460,6 +4469,8 @@ snapshots: '@typescript-eslint/types': 8.4.0 eslint-visitor-keys: 3.4.3 + '@vercel/edge@1.2.1': {} + '@vercel/nft@0.29.2(rollup@4.30.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.0