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", + ); + }); +});