diff --git a/docs/1.docs/50.lifecycle.md b/docs/1.docs/50.lifecycle.md index 4675e0e21d..8f9cb5f3f0 100644 --- a/docs/1.docs/50.lifecycle.md +++ b/docs/1.docs/50.lifecycle.md @@ -32,7 +32,7 @@ Errors thrown inside the `request` hook are captured by the [`error` hook](#erro ### Route rules -Matching [route rules](/docs/routing#route-rules) from the Nitro config execute next. Route rules run as middleware, before any global middleware, and most of them alter the response without terminating it (for instance, adding a header or setting a cache policy). +Matching [route rules](/docs/routing#route-rules) from the Nitro config execute next. Route rules run as middleware, before any global middleware, and most of them alter the response without terminating it (for instance, adding a header or setting a cache policy). The matched rules are resolved before any middleware runs and are available as `event.context.routeRules` in every middleware. ```ts [nitro.config.ts] import { defineConfig } from "nitro"; diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 15c87d2acb..a9a0e04b66 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -39,6 +39,10 @@ The plugin function receives a `nitroApp` object with the following properties: | `fetch` | `(req: Request) => Response \| Promise` | The app's internal fetch handler. | | `captureError` | `(error: Error, context) => void` | Programmatically capture errors into the error hook pipeline. | +::note +H3 composes the middleware chain once, on the first request. Middleware a plugin adds to `nitroApp.h3["~middleware"]` during startup is included, but it runs before Nitro's route rules when placed at the front of the array. If a plugin changes the array after the first request was handled, it must also reset `nitroApp.h3["~dispatch"]` and `nitroApp.h3["~composed"]` to `undefined` so the chain is recomposed. +:: + ## Nitro runtime hooks Use Nitro [hooks](https://github.com/unjs/hookable) to run custom functions at specific points in the [request lifecycle](/docs/lifecycle). Register them inside plugins with `nitroApp.hooks.hook()`: diff --git a/src/build/virtual/app.ts b/src/build/virtual/app.ts index dc983303ff..a1500ab6c1 100644 --- a/src/build/virtual/app.ts +++ b/src/build/virtual/app.ts @@ -10,7 +10,6 @@ export default function app(nitro: Nitro) { const hasGlobalMiddleware = nitro.routing.globalMiddleware.length > 0; const hasPlugins = nitro.options.plugins.length > 0; const hasHooks = nitro.options.features?.runtimeHooks ?? hasPlugins; - const hasGetMiddleware = hasRouteRules || hasRoutedMiddleware; const hasAsyncContext = !!nitro.options.experimental.asyncContext; const routingImports = [ @@ -126,7 +125,11 @@ export default function app(nitro: Nitro) { ` app.captureError?.(error, { tags: ["plugin"] });`, ` throw error;`, ` }`, - ` }` + ` }`, + // h3 composes `~middleware` into a dispatcher on first dispatch and + // never re-reads it. A plugin may have dispatched already (warm-up + // fetch) before a later plugin (e.g. tracing) touched the chain. + ` app.h3["~dispatch"] = app.h3["~composed"] = undefined;` ); } code.push(` return app;`, `}`); @@ -138,47 +141,41 @@ export default function app(nitro: Nitro) { } code.push(``, `function createH3App(config) {`, ` const h3App = new H3Core(config);`); - if (hasRoutes) { + // `~findRoute` runs before dispatch on the original pathname, so route + // rules are resolved there and `event.context.routeRules` is populated for + // every middleware, including ones plugins unshift onto `~middleware`. + if (hasRoutes || hasRouteRules) { + code.push(` h3App["~findRoute"] = (event) => {`); + if (hasRouteRules) { + code.push( + ` event.context.routeRules = getRouteRules(event.req.method, event.url.pathname).routeRules;` + ); + } code.push( - ` h3App["~findRoute"] = (event) => findRoute(event.req.method, event.url.pathname);` + hasRoutes + ? ` return findRoute(event.req.method, event.url.pathname);` + : ` return undefined;`, + ` };` ); } + // Route rules, global and routed middleware are registered on `~middleware` + // in that order so h3 precomposes the chain once on first dispatch. + const runtimeAppImports = [ + hasRouteRules && "createRouteRulesMiddleware", + hasRoutedMiddleware && "createRoutedMiddleware", + hasRouteRules && "getRouteRules", + ].filter(Boolean); + if (runtimeAppImports.length) { + imports.push(`import { ${runtimeAppImports.join(", ")} } from "#nitro/runtime/app";`); + } + if (hasRouteRules) { + code.push(` h3App["~middleware"].push(createRouteRulesMiddleware());`); + } if (hasGlobalMiddleware) { code.push(` h3App["~middleware"].push(...globalMiddleware);`); } - if (hasGetMiddleware) { - code.push( - ` h3App["~getMiddleware"] = (event, route) => {`, - ` const pathname = event.url.pathname;`, - ` const method = event.req.method;`, - ` const middleware = [];` - ); - if (hasRouteRules) { - imports.push(`import { getRouteRules } from "#nitro/runtime/app";`); - code.push( - ` const routeRules = getRouteRules(method, pathname);`, - ` event.context.routeRules = routeRules?.routeRules;`, - ` if (routeRules?.routeRuleMiddleware.length) {`, - ` middleware.push(...routeRules.routeRuleMiddleware);`, - ` }` - ); - } - if (hasGlobalMiddleware) { - code.push(` middleware.push(...h3App["~middleware"]);`); - } - if (hasRoutedMiddleware) { - code.push( - ` middleware.push(...findRoutedMiddleware(method, pathname).map((r) => r.data));` - ); - } - if (hasRoutes) { - code.push( - ` if (route?.data?.middleware?.length) {`, - ` middleware.push(...route.data.middleware);`, - ` }` - ); - } - code.push(` return middleware;`, ` };`); + if (hasRoutedMiddleware) { + code.push(` h3App["~middleware"].push(createRoutedMiddleware(findRoutedMiddleware));`); } code.push(` return h3App;`, `}`); diff --git a/src/runtime/internal/app.ts b/src/runtime/internal/app.ts index 6788c0b65d..51c6d3df3c 100644 --- a/src/runtime/internal/app.ts +++ b/src/runtime/internal/app.ts @@ -1,7 +1,7 @@ import type { NitroApp, NitroRuntimeHooks, ResolvedRouteRules } from "nitro/types"; import type { ServerRequest, ServerRequestContext } from "srvx"; -import type { H3EventContext, Middleware, WebSocketHooks } from "h3"; -import { toRequest } from "h3"; +import type { ComposedMiddleware, H3EventContext, Middleware, WebSocketHooks } from "h3"; +import { composeMiddleware, toRequest } from "h3"; import { HookableCore } from "hookable"; import { createMatcherFromFind, memoizeRouteRulesMatcher } from "h3/rules"; @@ -95,3 +95,70 @@ export function getRouteRules( pathname ); } + +/** + * Middleware that runs the route-rule middleware (`redirect`, `headers`, + * `cors`, ...) matched for the current request. The composed chain is cached + * per memoized match, so each distinct match is composed once. + * + * `event.context.routeRules` is assigned earlier, from `~findRoute`, so it is + * populated for every middleware regardless of its position in the chain. + */ +export function createRouteRulesMiddleware(): Middleware { + const composed = new WeakMap(); + const middleware: Middleware = (event, next) => { + const ruleMiddleware = getRouteRules(event.req.method, event.url.pathname).routeRuleMiddleware; + if (ruleMiddleware.length === 0) { + return next(); + } + let chain = composed.get(ruleMiddleware); + if (!chain) { + chain = composeMiddleware(ruleMiddleware); + composed.set(ruleMiddleware, chain); + } + return chain(event, next as any); + }; + return markUntraced(middleware); +} + +/** + * Middleware that runs the routed (`server/middleware/**` with a route) + * middleware matched for the current request. Chains are cached by the identity + * of the matched handlers (a trie keyed on the router's stable data slots), so + * the cache is bounded by the number of distinct match combinations rather than + * by request pathnames. + */ +export function createRoutedMiddleware( + findRoutedMiddleware: (method: string, pathname: string) => { data: Middleware }[] +): Middleware { + const root: RoutedChainNode = { children: new Map() }; + const middleware: Middleware = (event, next) => { + const matched = findRoutedMiddleware(event.req.method, event.url.pathname); + if (matched.length === 0) { + return next(); + } + let node = root; + for (const entry of matched) { + let child = node.children.get(entry); + if (!child) { + child = { children: new Map() }; + node.children.set(entry, child); + } + node = child; + } + return (node.chain ??= composeMiddleware(matched.map((r) => r.data)))(event, next as any); + }; + return markUntraced(middleware); +} + +type RoutedChainNode = { + children: Map; + chain?: ComposedMiddleware; +}; + +// Nitro's own wrappers are not user middleware: opt them out of `h3/tracing` +// so they do not add anonymous spans around the whole downstream chain. +function markUntraced(middleware: Middleware): Middleware { + (middleware as Middleware & { __traced__?: boolean }).__traced__ = true; + return middleware; +} diff --git a/test/fixture/server/middleware/order.ts b/test/fixture/server/middleware/order.ts index 74d8068397..59242f59d3 100644 --- a/test/fixture/server/middleware/order.ts +++ b/test/fixture/server/middleware/order.ts @@ -2,5 +2,5 @@ import { defineHandler } from "nitro/h3"; export default defineHandler((event) => { const order = (event.context.middlewareOrder ??= []) as string[]; - order.push("global"); + order.push(event.context.routeRules ? "rules" : "no-rules", "global"); }); diff --git a/test/tests.ts b/test/tests.ts index 6e1250ff74..d33b653015 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -249,11 +249,11 @@ export function testNitro( expect(headers["x-test"]).toBe("test"); }); - it("middleware runs in order: global, routed, then the route handler", async () => { + it("middleware runs in order: route rules, global, routed, then the route handler", async () => { const { data, headers } = await callHandler({ url: "/api/middleware-order" }); - expect(data).toEqual(["global", "routed"]); - // Route rule middleware applied. Its position in the chain is not - // observable in-band: `headers` rules only set headers on the way out. + // `rules` is recorded by the global middleware when `event.context.routeRules` + // is already populated, i.e. route rules resolved before it ran. + expect(data).toEqual(["rules", "global", "routed"]); expect(headers["x-test"]).toBe("test"); }); diff --git a/test/unit/runtime-middleware.test.ts b/test/unit/runtime-middleware.test.ts new file mode 100644 index 0000000000..abe4ba6235 --- /dev/null +++ b/test/unit/runtime-middleware.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { mockEvent } from "h3"; +import type { Middleware } from "h3"; + +const { findRouteRules } = vi.hoisted(() => ({ + findRouteRules: vi.fn(() => []), +})); + +vi.mock("#nitro/virtual/routing", () => ({ findRouteRules })); +vi.mock("#nitro/virtual/app", () => ({ + createNitroApp: () => ({}), + initNitroPlugins: () => {}, +})); + +const { createRouteRulesMiddleware, createRoutedMiddleware } = + await import("../../src/runtime/internal/app.ts"); + +describe("runtime middleware wrappers", () => { + it("are opted out of h3 tracing", () => { + expect((createRouteRulesMiddleware() as any).__traced__).toBe(true); + expect((createRoutedMiddleware(() => []) as any).__traced__).toBe(true); + }); + + it("route rules: calls next directly when no rule middleware matched", async () => { + const middleware = createRouteRulesMiddleware(); + const next = vi.fn(() => "handler"); + expect(await middleware(mockEvent("/nothing"), next)).toBe("handler"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("routed: composes the matched chain once per handler set and runs it in order", async () => { + const calls: string[] = []; + const a: Middleware = (event, next) => { + calls.push("a"); + return next(); + }; + const b: Middleware = (event, next) => { + calls.push("b"); + return next(); + }; + const entryA = { data: a }; + const entryB = { data: b }; + const find = vi.fn((_method: string, pathname: string) => + pathname.startsWith("/ab") ? [entryA, entryB] : pathname.startsWith("/a") ? [entryA] : [] + ); + const middleware = createRoutedMiddleware(find); + + const next = vi.fn(() => "handler"); + expect(await middleware(mockEvent("/ab/1"), next)).toBe("handler"); + expect(calls).toEqual(["a", "b"]); + expect(await middleware(mockEvent("/ab/2"), next)).toBe("handler"); + expect(await middleware(mockEvent("/a/1"), next)).toBe("handler"); + expect(calls).toEqual(["a", "b", "a", "b", "a"]); + expect(next).toHaveBeenCalledTimes(3); + + // No match: next is called without composing anything. + expect(await middleware(mockEvent("/x"), next)).toBe("handler"); + expect(calls).toHaveLength(5); + expect(next).toHaveBeenCalledTimes(4); + }); + + it("routed: a short-circuiting routed middleware stops the chain", async () => { + const stop: Middleware = () => "stopped"; + const middleware = createRoutedMiddleware(() => [{ data: stop }]); + const next = vi.fn(); + expect(await middleware(mockEvent("/a"), next)).toBe("stopped"); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/virtual-app.test.ts b/test/unit/virtual-app.test.ts new file mode 100644 index 0000000000..b7f8a5f9c7 --- /dev/null +++ b/test/unit/virtual-app.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { Nitro } from "nitro/types"; + +import app from "../../src/build/virtual/app.ts"; + +function createNitroStub(opts: { + routes?: boolean; + routeRules?: boolean; + routedMiddleware?: boolean; + globalMiddleware?: boolean; + plugins?: boolean; +}): Nitro { + return { + options: { + plugins: opts.plugins ? ["plugin.ts"] : [], + experimental: {}, + }, + routing: { + routes: { hasRoutes: () => opts.routes ?? true }, + routeRules: { hasRoutes: () => !!opts.routeRules }, + routedMiddleware: { hasRoutes: () => !!opts.routedMiddleware }, + globalMiddleware: opts.globalMiddleware ? [{}] : [], + }, + } as unknown as Nitro; +} + +describe("virtual/app template", () => { + it("does not override `~getMiddleware`, so h3 can precompose the middleware chain", () => { + const template = app( + createNitroStub({ routeRules: true, routedMiddleware: true, globalMiddleware: true }) + ).template(); + expect(template).not.toContain("~getMiddleware"); + }); + + it("registers route rules, global and routed middleware in that order", () => { + const template = app( + createNitroStub({ routeRules: true, routedMiddleware: true, globalMiddleware: true }) + ).template(); + const routeRules = template.indexOf("push(createRouteRulesMiddleware())"); + const global = template.indexOf("push(...globalMiddleware)"); + const routed = template.indexOf("push(createRoutedMiddleware(findRoutedMiddleware))"); + expect(routeRules).toBeGreaterThan(-1); + expect(global).toBeGreaterThan(routeRules); + expect(routed).toBeGreaterThan(global); + }); + + it("resolves `event.context.routeRules` in `~findRoute`, before any middleware", () => { + const template = app(createNitroStub({ routeRules: true })).template(); + const findRoute = template.indexOf('h3App["~findRoute"] = (event) => {'); + const routeRules = template.indexOf( + "event.context.routeRules = getRouteRules(event.req.method, event.url.pathname).routeRules;" + ); + const returnRoute = template.indexOf("return findRoute(event.req.method, event.url.pathname);"); + expect(findRoute).toBeGreaterThan(-1); + expect(routeRules).toBeGreaterThan(findRoute); + expect(returnRoute).toBeGreaterThan(routeRules); + }); + + it("still overrides `~findRoute` for route rules when there are no routes", () => { + const template = app(createNitroStub({ routes: false, routeRules: true })).template(); + expect(template).toContain('h3App["~findRoute"] = (event) => {'); + expect(template).toContain("event.context.routeRules = getRouteRules("); + expect(template).toContain("return undefined;"); + expect(template).not.toContain("findRoute(event.req.method"); + }); + + it("only imports the runtime helpers it needs", () => { + expect(app(createNitroStub({ routeRules: true })).template()).toContain( + 'import { createRouteRulesMiddleware, getRouteRules } from "#nitro/runtime/app";' + ); + expect(app(createNitroStub({ routedMiddleware: true })).template()).toContain( + 'import { createRoutedMiddleware } from "#nitro/runtime/app";' + ); + expect(app(createNitroStub({ routeRules: true, routedMiddleware: true })).template()).toContain( + 'import { createRouteRulesMiddleware, createRoutedMiddleware, getRouteRules } from "#nitro/runtime/app";' + ); + }); + + it("does not compose in the template nor import `h3/rules`", () => { + const template = app( + createNitroStub({ routeRules: true, routedMiddleware: true, globalMiddleware: true }) + ).template(); + expect(template).toContain('import { H3Core } from "h3";'); + expect(template).not.toContain("composeMiddleware"); + expect(template).not.toContain("h3/rules"); + }); + + it("does not import the runtime helpers when nothing is path-dependent", () => { + const template = app(createNitroStub({ globalMiddleware: true })).template(); + expect(template).not.toContain("#nitro/runtime/app"); + expect(template).not.toContain('~findRoute"] = (event) => {\n event.context.routeRules'); + expect(template).toContain("push(...globalMiddleware)"); + }); + + it("resets h3's cached dispatcher after plugins ran", () => { + const reset = 'app.h3["~dispatch"] = app.h3["~composed"] = undefined;'; + const withPlugins = app(createNitroStub({ plugins: true })).template(); + const plugins = withPlugins.indexOf("for (const plugin of plugins)"); + expect(plugins).toBeGreaterThan(-1); + expect(withPlugins.indexOf(reset)).toBeGreaterThan(plugins); + expect(app(createNitroStub({})).template()).not.toContain(reset); + }); +});