From 70fb83be1bc80c5aaf1551163c70c0a2be23ea3d Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 12:01:28 +0000 Subject: [PATCH] perf(router): compile dynamic parameter extraction Reuse registration-order route arrays and backtrack over one parameter object instead of allocating intermediate copies during dynamic matching. Snapshot parameters only when handlers are emitted so middleware and monitor handlers remain isolated. Amp-Thread-ID: https://ampcode.com/threads/T-01a019cf-991c-74c8-9d37-1a285cd5f912 Co-authored-by: Upd4ting --- src/server.ts | 182 ++++++++++++------ src/test/routing-parameters.test.ts | 275 ++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+), 60 deletions(-) create mode 100644 src/test/routing-parameters.test.ts diff --git a/src/server.ts b/src/server.ts index 444bceb..b384059 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,8 +29,9 @@ export interface RequestContext { interface DynamicRoute { match: RegExp; + parameterName?: string; + parameterNames: string[]; sub: RouteLevel; - mapping: string[]; } interface CatchAllRoute { @@ -43,6 +44,7 @@ class RouteLevel { handlers: IndexedRouteCallback[] = []; staticRoutes: Record = {}; dynamicRoutes: Record = {}; + dynamicRouteList: DynamicRoute[] = []; catchAllRoutes: CatchAllRoute[] = []; } @@ -111,6 +113,10 @@ function updateExactHandler( index[method].set(exactPath, handler.callback); } +function hasParameter(parameters: Record, name: string) { + return Object.getOwnPropertyDescriptor(parameters, name) !== undefined; +} + function findHandlers( path: string[], depth: number, @@ -118,15 +124,16 @@ function findHandlers( result: Array, parameters: Record, multi = false, + parameterCount = 0, ) { if (multi) { - result.push( - ...level.handlers.map((handler) => ({ + for (const handler of level.handlers) { + result.push({ handler: handler.callback, - parameters, + parameters: { ...parameters }, priority: handler.priority, - })), - ); + }); + } } if (depth >= path.length) { if (!multi && level.handlers.length > 0) { @@ -149,27 +156,69 @@ function findHandlers( result, parameters, multi, + parameterCount, ); if (result.length > 0 && !multi) { return; } } - for (const { match, sub, mapping } of Object.values(level.dynamicRoutes)) { - const res = match.exec(part); - if (res) { - const newParameters = { ...parameters }; - for (let i = 0; i < mapping.length; ++i) { - const parameterName = mapping[i]; - const parameterValue = res[i + 1]; - if (parameterName !== undefined && parameterValue !== undefined) { - newParameters[parameterName] = parameterValue; - } - } - findHandlers(path, depth + 1, sub, result, newParameters, multi); + for (const route of level.dynamicRouteList) { + const match = route.match.exec(part); + if (!match) { + continue; + } + if (route.parameterName !== undefined) { + const parameterName = route.parameterName; + const previousValue = parameters[parameterName]; + const existingParameter = + parameterCount > 0 && hasParameter(parameters, parameterName); + parameters[parameterName] = match[1]; + findHandlers( + path, + depth + 1, + route.sub, + result, + parameters, + multi, + parameterCount + 1, + ); if (result.length > 0 && !multi) { return; } + if (existingParameter) { + parameters[parameterName] = previousValue; + } else { + delete parameters[parameterName]; + } + continue; + } + const previousValues = route.parameterNames.map((name) => parameters[name]); + const existingParameters = route.parameterNames.map((name) => + hasParameter(parameters, name), + ); + for (let index = 0; index < route.parameterNames.length; ++index) { + parameters[route.parameterNames[index]] = match[index + 1]; + } + findHandlers( + path, + depth + 1, + route.sub, + result, + parameters, + multi, + parameterCount + route.parameterNames.length, + ); + if (result.length > 0 && !multi) { + return; + } + for (let index = 0; index < route.parameterNames.length; ++index) { + const name = route.parameterNames[index]; + if (existingParameters[index]) { + parameters[name] = previousValues[index]; + } else { + delete parameters[name]; + } } } @@ -202,21 +251,26 @@ function findHandlers( continue; } - const newParameters = { - ...parameters, - [catchAll.paramName]: captured.join("/"), - }; + const existingParameter = hasParameter(parameters, catchAll.paramName); + const previousValue = parameters[catchAll.paramName]; + parameters[catchAll.paramName] = captured.join("/"); findHandlers( path, path.length, catchAll.level, result, - newParameters, + parameters, multi, + parameterCount + 1, ); if (result.length > 0 && !multi) { return; } + if (existingParameter) { + parameters[catchAll.paramName] = previousValue; + } else { + delete parameters[catchAll.paramName]; + } } } @@ -289,7 +343,7 @@ function removeHandlerFromLevel( } } - for (const route of Object.values(level.dynamicRoutes)) { + for (const route of level.dynamicRouteList) { const dynamicRemovedCount = removeHandlerFromLevel( id, route.sub, @@ -340,6 +394,47 @@ const special = { ")": true, ",": true, }; + +function compileDynamicRoute(part: string): DynamicRoute { + const mapping = []; + const pattern = ["^"]; + let word: string[] | undefined; + for (const char of part) { + if (char in special) { + if (word) { + mapping.push(word.join("")); + pattern.push(`([^\\${char}]*)`); + word = undefined; + } + pattern.push(`\\${char}`); + } else if (char === ":") { + if (word) { + throw new Error("Invalid URL parameter"); + } + word = []; + } else if (char.match(/[a-zA-Z0-9]/)) { + if (word) { + word.push(char); + } else { + pattern.push(char); + } + } else { + throw new Error("Invalid character in URL"); + } + } + if (word) { + mapping.push(word.join("")); + pattern.push(`(.*)`); + } + pattern.push("$"); + return { + match: new RegExp(pattern.join("")), + parameterName: mapping.length === 1 ? mapping[0] : undefined, + parameterNames: mapping, + sub: new RouteLevel(), + }; +} + export function registerHandler( id: string, mode: HandlerMode, @@ -389,42 +484,9 @@ export function registerHandler( if (part.indexOf(":") >= 0) { if (!(part in level.dynamicRoutes)) { - const mapping = []; - const match = ["^"]; - let word: string[] | undefined; - for (const char of part) { - if (char in special) { - if (word) { - mapping.push(word.join("")); - match.push(`([^\\${char}]*)`); - word = undefined; - } - match.push(`\\${char}`); - } else if (char === ":") { - if (word) { - throw new Error("Invalid URL parameter"); - } - word = []; - } else if (char.match(/[a-zA-Z0-9]/)) { - if (word) { - word.push(char); - } else { - match.push(char); - } - } else { - throw new Error("Invalid character in URL"); - } - } - if (word) { - mapping.push(word.join("")); - match.push(`(.*)`); - } - match.push("$"); - level.dynamicRoutes[part] = { - match: new RegExp(match.join("")), - sub: new RouteLevel(), - mapping, - }; + const route = compileDynamicRoute(part); + level.dynamicRoutes[part] = route; + level.dynamicRouteList.push(route); } level = level.dynamicRoutes[part].sub; } else { diff --git a/src/test/routing-parameters.test.ts b/src/test/routing-parameters.test.ts new file mode 100644 index 0000000..e1f8fcd --- /dev/null +++ b/src/test/routing-parameters.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; +import { HandlerPriority } from "@antelopejs/interface-api"; +import { + type RequestContext, + type RouteCallback, + registerHandler, + requestListener, + unregisterHandler, +} from "../server"; + +type RouteMode = Parameters[1]; + +const registeredIds: string[] = []; +let testServer: http.Server; +let baseUrl: string; + +function register( + id: string, + mode: RouteMode, + method: string | undefined, + location: string, + callback: RouteCallback, + priority = HandlerPriority.NORMAL, +) { + registeredIds.push(id); + registerHandler(id, mode, method, location, callback, priority); +} + +async function request(path: string, method = "GET") { + const response = await fetch(`${baseUrl}${path}`, { method }); + return { status: response.status, body: await response.text() }; +} + +describe("Compiled route parameter extraction", () => { + before(async () => { + testServer = http.createServer( + (req, res) => void requestListener(req, res, "http"), + ); + await new Promise((resolve) => + testServer.listen(0, "127.0.0.1", resolve), + ); + const address = testServer.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + after(async () => { + await new Promise((resolve, reject) => + testServer.close((error) => (error ? reject(error) : resolve())), + ); + }); + + afterEach(() => { + for (const id of registeredIds.splice(0)) { + unregisterHandler(id); + } + }); + + it("preserves static, dynamic, then catch-all precedence", async () => { + register( + "compiled-static", + "handler", + "get", + "/compiled/static", + () => "static", + ); + register( + "compiled-dynamic", + "handler", + "get", + "/compiled/:id", + () => "dynamic", + ); + register( + "compiled-catch", + "handler", + "get", + "/compiled/::path", + () => "catch-all", + ); + + assert.equal((await request("/compiled/static")).body, "static"); + assert.equal((await request("/compiled/value")).body, "dynamic"); + assert.equal((await request("/compiled/a/b")).body, "catch-all"); + }); + + it("extracts multiple segments and patterned segment parameters", async () => { + register( + "compiled-pattern", + "handler", + "get", + "/compiled/:team/files/:base.:extension", + ({ routeParameters }) => JSON.stringify(routeParameters), + ); + + const response = await request("/compiled/core/files/report.final.json"); + assert.deepEqual(JSON.parse(response.body), { + team: "core", + base: "report", + extension: "final.json", + }); + }); + + it("keeps URL-encoded captures unchanged", async () => { + register( + "compiled-encoded", + "handler", + "get", + "/compiled/encoded/:value", + ({ routeParameters }) => routeParameters.value, + ); + + assert.equal( + (await request("/compiled/encoded/caf%C3%A9%20cr%C3%A8me")).body, + "caf%C3%A9%20cr%C3%A8me", + ); + }); + + it("backtracks across concurrent dynamic routes", async () => { + register( + "compiled-early", + "handler", + "get", + "/compiled/items/p:id", + () => "early", + ); + register( + "compiled-late", + "handler", + "get", + "/compiled/items/:slug.json", + ({ routeParameters }) => routeParameters.slug, + ); + register( + "compiled-dead-branch", + "handler", + "get", + "/compiled/branch/:first/missing", + () => "dead", + ); + register( + "compiled-live-branch", + "handler", + "get", + "/compiled/branch/:second/:third", + ({ routeParameters }) => JSON.stringify(routeParameters), + ); + + assert.equal((await request("/compiled/items/report.json")).body, "report"); + assert.equal((await request("/compiled/items/p123")).body, "early"); + assert.deepEqual(JSON.parse((await request("/compiled/branch/a/b")).body), { + second: "a", + third: "b", + }); + }); + + it("isolates parameter objects for multiple handlers", async () => { + const observed: string[] = []; + register( + "compiled-prefix-first", + "prefix", + "get", + "/compiled/isolated/:id", + ({ routeParameters }) => { + observed.push(routeParameters.id); + routeParameters.id = "mutated"; + }, + ); + register( + "compiled-prefix-second", + "prefix", + "get", + "/compiled/isolated/:id", + ({ routeParameters }) => { + observed.push(routeParameters.id); + }, + ); + register( + "compiled-isolated-handler", + "handler", + "get", + "/compiled/isolated/:id", + ({ routeParameters }) => routeParameters.id, + ); + + assert.equal( + (await request("/compiled/isolated/original")).body, + "original", + ); + assert.deepEqual(observed, ["original", "original"]); + }); + + it("provides independent parameters to middleware and monitors", async () => { + const parameters: RequestContext["routeParameters"][] = []; + const observe = (context: RequestContext) => { + parameters.push(context.routeParameters); + }; + register( + "compiled-modes-prefix", + "prefix", + "get", + "/compiled/modes/:id", + observe, + ); + register( + "compiled-modes-handler", + "handler", + "get", + "/compiled/modes/:id", + observe, + ); + register( + "compiled-modes-postfix", + "postfix", + "get", + "/compiled/modes/:id", + observe, + ); + register( + "compiled-modes-monitor", + "monitor", + "get", + "/compiled/modes/:id", + observe, + ); + + await request("/compiled/modes/value"); + + assert.deepEqual( + parameters.map(({ id }) => id), + ["value", "value", "value", "value"], + ); + assert.equal(new Set(parameters).size, 4); + }); + + it("falls back to any handlers with extracted parameters", async () => { + register( + "compiled-any", + "handler", + undefined, + "/compiled/any/:id", + ({ routeParameters }) => routeParameters.id, + ); + + assert.equal((await request("/compiled/any/value", "PATCH")).body, "value"); + }); + + it("reuses compiled routes after unregister and re-register", async () => { + register( + "compiled-lifecycle-old", + "handler", + "get", + "/compiled/lifecycle/:id", + () => "old", + ); + assert.equal((await request("/compiled/lifecycle/value")).body, "old"); + + unregisterHandler("compiled-lifecycle-old"); + registeredIds.splice(registeredIds.indexOf("compiled-lifecycle-old"), 1); + assert.equal((await request("/compiled/lifecycle/value")).status, 404); + + register( + "compiled-lifecycle-new", + "handler", + "get", + "/compiled/lifecycle/:id", + ({ routeParameters }) => `new:${routeParameters.id}`, + ); + assert.equal( + (await request("/compiled/lifecycle/value")).body, + "new:value", + ); + }); +});