diff --git a/src/build/virtual/routing.ts b/src/build/virtual/routing.ts index a64087c4a0..afbf6eac83 100644 --- a/src/build/virtual/routing.ts +++ b/src/build/virtual/routing.ts @@ -27,6 +27,18 @@ import * as srvxNode from "srvx/node" import * as h3 from "h3";${traceH3 ? `\nimport { wrapHandlerWithTracing } from "h3/tracing";` : ""} ${routeRulesModule} +${ + traceH3 + ? `const wrapMiddlewareWithTracing = (middleware) => { + const { tracingChannel } = globalThis.process?.getBuiltinModule?.("diagnostics_channel") ?? {}; + if (!tracingChannel || middleware.__traced__) return middleware; + const channel = tracingChannel("h3.request"); + const wrapped = (...args) => channel.tracePromise(async () => middleware(...args), { event: args[0], type: "middleware" }); + wrapped.__traced__ = true; + return wrapped; +};` + : "" +} const multiHandler = (...handlers) => { const final = handlers.pop() const middleware = handlers.filter(Boolean).map(h => h3.toMiddleware(h)); @@ -48,7 +60,7 @@ ${allHandlers export const findRoute = ${nitro.routing.routes.compileToString({ serialize: (h) => serializeHandler(h, { tracing: traceH3 }) })} -export const findRoutedMiddleware = ${nitro.routing.routedMiddleware.compileToString({ serialize: serializeHandler, matchAll: true })}; +export const findRoutedMiddleware = ${nitro.routing.routedMiddleware.compileToString({ serialize: (h) => serializeHandlerFn(h, { tracing: traceH3, type: "middleware" }), matchAll: true })}; export const globalMiddleware = [ ${nitro.routing.globalMiddleware.map((h) => (h.lazy ? h._importHash : `h3.toEventHandler(${h._importHash})`)).join(",")} @@ -72,20 +84,23 @@ function serializeHandler( ): string { const meta = Array.isArray(h) ? h[0] : h; const handler = Array.isArray(h) - ? `multiHandler(${h.map((handler) => serializeHandlerFn(handler)).join(",")})` - : serializeHandlerFn(h); + ? `multiHandler(${h.map((handler) => serializeHandlerFn(handler, { ...opts, type: "route" })).join(",")})` + : serializeHandlerFn(h, { ...opts, type: "route" }); return `{${[ `route:${JSON.stringify(meta.route)}`, meta.method && `method:${JSON.stringify(meta.method)}`, meta.meta && `meta:${JSON.stringify(meta.meta)}`, - `handler:${opts.tracing ? `wrapHandlerWithTracing(${handler})` : handler}`, + `handler:${handler}`, ] .filter(Boolean) .join(",")}}`; } -function serializeHandlerFn(h: NitroEventHandler & { _importHash: string }): string { +function serializeHandlerFn( + h: NitroEventHandler & { _importHash: string }, + opts: { tracing?: boolean; type?: "route" | "middleware" } = {} +): string { let code = h._importHash; if (!h.lazy) { if (h.format === "node") { @@ -93,5 +108,11 @@ function serializeHandlerFn(h: NitroEventHandler & { _importHash: string }): str } code = `h3.toEventHandler(${code})`; } + if (opts.tracing) { + code = + opts.type === "middleware" + ? `wrapMiddlewareWithTracing(${code})` + : `wrapHandlerWithTracing(${code})`; + } return code; } diff --git a/test/unit/routed-middleware.test.ts b/test/unit/routed-middleware.test.ts new file mode 100644 index 0000000000..99361cfa19 --- /dev/null +++ b/test/unit/routed-middleware.test.ts @@ -0,0 +1,127 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { createNitro, build, prepare } from "nitro/builder"; +import { join } from "pathe"; +import { fileURLToPath } from "node:url"; +import { rm, mkdir } from "node:fs/promises"; + +const tmpDir = fileURLToPath(new URL("./.tmp/routed-middleware", import.meta.url)); + +describe("route-scoped middleware", () => { + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("executes route-scoped middleware without throwing fn is not a function", async () => { + const outDir = join(tmpDir, "output"); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + const nitro = await createNitro({ + rootDir: tmpDir, + preset: "standard", + output: { dir: outDir }, + virtual: { + "#auth-middleware": () => + `export default (event) => { event.res.headers.set("x-auth-middleware", "true"); }`, + "#hello-handler": () => `export default () => ({ message: "hello" })`, + "#other-handler": () => `export default () => ({ message: "other" })`, + }, + handlers: [ + { + route: "/api/**", + handler: "#auth-middleware", + middleware: true, + }, + { + route: "/api/hello", + handler: "#hello-handler", + }, + { + route: "/other", + handler: "#other-handler", + }, + ], + }); + + await prepare(nitro); + await build(nitro); + + const entry = join(outDir, "server/index.mjs"); + const { fetch } = await import(entry).then((m) => m.default); + + // Request matching routed middleware + const res = await fetch(new Request("http://localhost/api/hello")); + expect(res.status).toBe(200); + expect(res.headers.get("x-auth-middleware")).toBe("true"); + expect(await res.json()).toEqual({ message: "hello" }); + + // Request not matching routed middleware + const resOther = await fetch(new Request("http://localhost/other")); + expect(resOther.status).toBe(200); + expect(resOther.headers.get("x-auth-middleware")).toBeNull(); + expect(await resOther.json()).toEqual({ message: "other" }); + }); + + it("executes middleware in route-rules -> global -> routed middleware order", async () => { + const outDir = join(tmpDir, "output-ordering"); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + const nitro = await createNitro({ + rootDir: tmpDir, + preset: "standard", + output: { dir: outDir }, + routeRules: { + "/api/**": { + headers: { "x-route-rule": "applied" }, + }, + }, + virtual: { + "#global-middleware": () => + `export default (event) => { + const current = event.res.headers.get("x-execution-order") || ""; + event.res.headers.set("x-execution-order", current ? current + ", global" : "global"); + }`, + "#routed-middleware": () => + `export default (event) => { + const current = event.res.headers.get("x-execution-order") || ""; + event.res.headers.set("x-execution-order", current + ", routed"); + }`, + "#order-handler": () => `export default (event) => ({ + status: "ok", + order: event.res.headers.get("x-execution-order"), + hasContextRouteRules: !!event.context?.routeRules, + })`, + }, + handlers: [ + { + handler: "#global-middleware", + middleware: true, + }, + { + route: "/api/**", + handler: "#routed-middleware", + middleware: true, + }, + { + route: "/api/order", + handler: "#order-handler", + }, + ], + }); + + await prepare(nitro); + await build(nitro); + + const entry = join(outDir, "server/index.mjs"); + const { fetch } = await import(entry).then((m) => m.default); + + const res = await fetch(new Request("http://localhost/api/order")); + expect(res.status).toBe(200); + expect(res.headers.get("x-execution-order")).toBe("global, routed"); + const data = await res.json(); + expect(data.status).toBe("ok"); + expect(data.order).toBe("global, routed"); + expect(data.hasContextRouteRules).toBe(true); + }); +}); diff --git a/test/unit/virtual-routing.test.ts b/test/unit/virtual-routing.test.ts index 412453fd73..5bff673bd0 100644 --- a/test/unit/virtual-routing.test.ts +++ b/test/unit/virtual-routing.test.ts @@ -52,4 +52,69 @@ describe("virtual/routing template", () => { expect(template).toContain(`import { wrapHandlerWithTracing } from "h3/tracing"`); expect(template).toContain("wrapHandlerWithTracing(h3.toEventHandler(_abc123))"); }); + + it("serializes routed middleware as handler functions instead of route descriptor objects", () => { + const middlewareHandler: NitroEventHandler & { _importHash: string } = { + route: "/api/**", + handler: "/path/to/middleware.ts", + middleware: true, + _importHash: "_mid123", + }; + const nitroStub = { + options: { + tracingChannel: undefined, + baseURL: "/", + routeRules: {}, + }, + routing: { + routes: { + routes: [], + compileToString: () => `{}`, + }, + routedMiddleware: { + routes: [{ route: "/api/**", method: "", data: middlewareHandler }], + compileToString: ({ serialize }: { serialize: (h: unknown) => string }) => + `{"/api/**":${serialize(middlewareHandler)}}`, + }, + globalMiddleware: [], + }, + } as unknown as Nitro; + + const template = routing(nitroStub).template(); + expect(template).toContain("export const findRoutedMiddleware ="); + expect(template).toContain("h3.toEventHandler(_mid123)"); + expect(template).not.toContain(`route:"/api/**"`); + }); + + it("wraps routed middleware with wrapMiddlewareWithTracing when tracingChannel.h3 is true", () => { + const middlewareHandler: NitroEventHandler & { _importHash: string } = { + route: "/api/**", + handler: "/path/to/middleware.ts", + middleware: true, + _importHash: "_mid123", + }; + const nitroStub = { + options: { + tracingChannel: { h3: true }, + baseURL: "/", + routeRules: {}, + }, + routing: { + routes: { + routes: [], + compileToString: () => `{}`, + }, + routedMiddleware: { + routes: [{ route: "/api/**", method: "", data: middlewareHandler }], + compileToString: ({ serialize }: { serialize: (h: unknown) => string }) => + `{"/api/**":${serialize(middlewareHandler)}}`, + }, + globalMiddleware: [], + }, + } as unknown as Nitro; + + const template = routing(nitroStub).template(); + expect(template).toContain("export const findRoutedMiddleware ="); + expect(template).toContain("wrapMiddlewareWithTracing(h3.toEventHandler(_mid123))"); + }); });