diff --git a/src/server.ts b/src/server.ts index 43932c4..444bceb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,10 @@ export interface IdentifiableRouteCallback { type HandlerMode = "prefix" | "postfix" | "handler" | "monitor" | "websocket"; type MiddlewareMode = "prefix" | "postfix"; +interface IndexedRouteCallback extends IdentifiableRouteCallback { + exactPath?: string; +} + export interface RequestContext { rawRequest: IncomingMessage; rawResponse: ServerResponse; @@ -36,7 +40,7 @@ interface CatchAllRoute { } class RouteLevel { - handlers: IdentifiableRouteCallback[] = []; + handlers: IndexedRouteCallback[] = []; staticRoutes: Record = {}; dynamicRoutes: Record = {}; catchAllRoutes: CatchAllRoute[] = []; @@ -64,7 +68,48 @@ interface HandlerResult { priority: HandlerPriority; } -type HandlerLookupResult = HandlerResult | HandlerResult[] | undefined; +type HandlerLookupResult = + | HandlerResult + | RouteCallback + | HandlerResult[] + | undefined; +type ExactHandlerIndex = Record>; + +const exactHandlerIndexes = new WeakMap< + Record, + ExactHandlerIndex +>([ + [roots.handler, {}], + [roots.websocket, {}], +]); + +function updateExactHandler( + source: Record, + method: string, + level: RouteLevel, + removedExactPath?: string, +) { + const index = exactHandlerIndexes.get(source); + const exactPath = level.handlers[0]?.exactPath ?? removedExactPath; + if (!index || !exactPath) { + return; + } + + const methodIndex = index[method]; + if (level.handlers.length !== 1) { + methodIndex?.delete(exactPath); + if (methodIndex?.size === 0) { + delete index[method]; + } + return; + } + + const handler = level.handlers[0]; + if (!methodIndex) { + index[method] = new Map(); + } + index[method].set(exactPath, handler.callback); +} function findHandlers( path: string[], @@ -180,62 +225,107 @@ function getHandler( path: string[], source: Record, multi = false, + exactPath?: string, ): HandlerLookupResult { - const result: Array = []; + let result: Array | undefined; if (method in source) { + if (!multi && exactPath) { + const exactHandler = exactHandlerIndexes + .get(source) + ?.[method]?.get(exactPath); + if (exactHandler) { + return exactHandler; + } + } + result = []; findHandlers(path, 0, source[method], result, {}, multi); if (result.length > 0 && !multi) { return result[0]; } } if ("any" in source) { + if (!multi && exactPath) { + const exactHandler = exactHandlerIndexes.get(source)?.any?.get(exactPath); + if (exactHandler) { + return exactHandler; + } + } + result ??= []; findHandlers(path, 0, source.any, result, {}, multi); if (result.length > 0 && !multi) { return result[0]; } } - return multi ? result : undefined; + return multi ? (result ?? []) : undefined; } -function removeHandler(id: string, source: Record): number { - for (const level of Object.values(source)) { - const handlerLength = level.handlers.length; - level.handlers = level.handlers.filter((handler) => handler.id !== id); - const removedCount = handlerLength - level.handlers.length; - - if (removedCount > 0) { - return removedCount; - } +function removeHandlerFromLevel( + id: string, + level: RouteLevel, + source: Record, + method: string, +): number { + const handlerLength = level.handlers.length; + const removedExactPath = level.handlers.find( + (handler) => handler.id === id, + )?.exactPath; + level.handlers = level.handlers.filter((handler) => handler.id !== id); + const removedCount = handlerLength - level.handlers.length; + + if (removedCount > 0) { + updateExactHandler(source, method, level, removedExactPath); + return removedCount; + } - const staticRemovedCount = removeHandler(id, level.staticRoutes); + for (const child of Object.values(level.staticRoutes)) { + const staticRemovedCount = removeHandlerFromLevel( + id, + child, + source, + method, + ); if (staticRemovedCount > 0) { return staticRemovedCount; } + } - const dynamicRemovedCount = removeHandler( + for (const route of Object.values(level.dynamicRoutes)) { + const dynamicRemovedCount = removeHandlerFromLevel( id, - Object.fromEntries( - Object.entries(level.dynamicRoutes).map(([key, route]) => [ - key, - route.sub, - ]), - ), + route.sub, + source, + method, ); if (dynamicRemovedCount > 0) { return dynamicRemovedCount; } + } - for (const catchAll of level.catchAllRoutes) { - const catchAllRemovedCount = removeHandler(id, { _: catchAll.level }); - if (catchAllRemovedCount > 0) { - return catchAllRemovedCount; - } + for (const catchAll of level.catchAllRoutes) { + const catchAllRemovedCount = removeHandlerFromLevel( + id, + catchAll.level, + source, + method, + ); + if (catchAllRemovedCount > 0) { + return catchAllRemovedCount; } } return 0; } +function removeHandler(id: string, source: Record): number { + for (const [method, level] of Object.entries(source)) { + const removedCount = removeHandlerFromLevel(id, level, source, method); + if (removedCount > 0) { + return removedCount; + } + } + return 0; +} + const special = { $: true, "-": true, @@ -260,10 +350,11 @@ export function registerHandler( ) { const parts = location.split("/").filter((part) => part); const source = roots[mode]; - let level = source[method?.toLowerCase() || "any"]; + const routeMethod = method?.toLowerCase() || "any"; + let level = source[routeMethod]; if (!level) { level = new RouteLevel(); - source[method?.toLowerCase() || "any"] = level; + source[routeMethod] = level; } for (let i = 0; i < parts.length; ++i) { const part = parts[i]; @@ -343,7 +434,16 @@ export function registerHandler( level = level.staticRoutes[part]; } } - level.handlers.push({ id, callback: handler, priority }); + const identifiableHandler: IndexedRouteCallback = { + id, + callback: handler, + priority, + }; + if (parts.every((part) => !part.includes(":"))) { + identifiableHandler.exactPath = `/${parts.join("/")}`; + } + level.handlers.push(identifiableHandler); + updateExactHandler(source, routeMethod, level); handlerCounts[mode] += 1; } @@ -416,6 +516,17 @@ function getMultiHandlers( return Array.isArray(handlers) ? handlers : []; } +function executeHandler( + handler: HandlerResult | RouteCallback, + requestContext: RequestContext, +) { + if (typeof handler === "function") { + return handler(requestContext); + } + requestContext.routeParameters = handler.parameters; + return handler.handler(requestContext); +} + async function executePriorityHandlers( handlers: HandlerResult[], requestContext: RequestContext, @@ -509,9 +620,9 @@ export async function requestListener( let requestError: unknown; try { - let handler = getHandler(method, path, roots.handler, false); + let handler = getHandler(method, path, roots.handler, false, url.pathname); if (!handler && method === "head") { - handler = getHandler("get", path, roots.handler, false); + handler = getHandler("get", path, roots.handler, false, url.pathname); } if (!handler && method !== "options") { // Fall through to finally block to execute monitors. @@ -533,8 +644,7 @@ export async function requestListener( // Fall through to finally block to execute monitors. } else { if (handler && !Array.isArray(handler)) { - requestContext.routeParameters = handler.parameters; - const result = await handler.handler(requestContext); + const result = await executeHandler(handler, requestContext); setHandlerResponse(requestContext, result); } @@ -606,7 +716,13 @@ export async function upgradeListener( let mustDestroySocket = false; try { - const handler = getHandler(method, path, roots.websocket, false); + const handler = getHandler( + method, + path, + roots.websocket, + false, + url.pathname, + ); if (!handler || Array.isArray(handler)) { mustSendResponse = true; mustDestroySocket = true; @@ -631,8 +747,7 @@ export async function upgradeListener( } else { requestContext.connection = await upgrader(req, socket, head); hasUpgradedConnection = true; - requestContext.routeParameters = handler.parameters; - await handler.handler(requestContext); + await executeHandler(handler, requestContext); } } } catch (error: unknown) { diff --git a/src/test/server-routing.test.ts b/src/test/server-routing.test.ts new file mode 100644 index 0000000..4cc1734 --- /dev/null +++ b/src/test/server-routing.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert"; +import { createServer, type Server } from "node:http"; +import { HandlerPriority } from "@antelopejs/interface-api"; +import { registerHandler, requestListener, unregisterHandler } from "../server"; + +interface TestResponse { + body: string; + status: number; +} + +const registeredIds = new Set(); +let server: Server; +let baseUrl: string; + +function register( + id: string, + mode: "prefix" | "postfix" | "handler" | "monitor" | "websocket", + method: string | undefined, + location: string, + body: string, + priority = HandlerPriority.NORMAL, +) { + registeredIds.add(id); + registerHandler(id, mode, method, location, () => body, priority); +} + +async function request(path: string, method = "GET"): Promise { + const response = await fetch(`${baseUrl}${path}`, { method }); + return { body: await response.text(), status: response.status }; +} + +describe("Static route dispatch", () => { + before(async () => { + server = createServer((req, res) => void requestListener(req, res, "http")); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Test server did not expose a TCP address"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + after(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + afterEach(() => { + for (const id of registeredIds) { + unregisterHandler(id); + } + registeredIds.clear(); + }); + + it("preserves static, dynamic, and any precedence", async () => { + register("dispatch-any", "handler", undefined, "/dispatch/value", "any"); + register("dispatch-dynamic", "handler", "GET", "/dispatch/:id", "dynamic"); + register("dispatch-static", "handler", "GET", "/dispatch/value", "static"); + + assert.deepEqual(await request("/dispatch/value"), { + body: "static", + status: 200, + }); + assert.deepEqual(await request("/dispatch/other"), { + body: "dynamic", + status: 200, + }); + assert.deepEqual(await request("/dispatch/value", "POST"), { + body: "any", + status: 200, + }); + }); + + it("preserves HEAD fallback and OPTIONS dispatch", async () => { + register("dispatch-head-get", "handler", "GET", "/dispatch/head", "get"); + register( + "dispatch-options", + "handler", + "OPTIONS", + "/dispatch/options", + "options", + ); + + assert.deepEqual(await request("/dispatch/head", "HEAD"), { + body: "", + status: 200, + }); + assert.deepEqual(await request("/dispatch/options", "OPTIONS"), { + body: "options", + status: 200, + }); + assert.equal((await request("/dispatch/missing", "OPTIONS")).status, 404); + }); + + it("invalidates the exact entry across remove and hot reload", async () => { + register( + "dispatch-reload-old", + "handler", + "GET", + "/dispatch/reload", + "old", + ); + assert.equal((await request("/dispatch/reload")).body, "old"); + + unregisterHandler("dispatch-reload-old"); + registeredIds.delete("dispatch-reload-old"); + assert.equal((await request("/dispatch/reload")).status, 404); + + register( + "dispatch-reload-new", + "handler", + "GET", + "/dispatch/reload", + "new", + ); + assert.equal((await request("/dispatch/reload")).body, "new"); + }); + + it("falls back for multiple handlers and restores the exact entry", async () => { + register( + "dispatch-first", + "handler", + "GET", + "/dispatch/duplicate", + "first", + ); + register( + "dispatch-second", + "handler", + "GET", + "/dispatch/duplicate", + "second", + ); + assert.equal((await request("/dispatch/duplicate")).body, "first"); + + unregisterHandler("dispatch-first"); + registeredIds.delete("dispatch-first"); + assert.equal((await request("/dispatch/duplicate")).body, "second"); + }); + + it("keeps duplicate IDs on separate routes independently invalidated", async () => { + register("dispatch-shared", "handler", "GET", "/dispatch/shared-a", "a"); + register("dispatch-shared", "handler", "GET", "/dispatch/shared-b", "b"); + + unregisterHandler("dispatch-shared"); + assert.equal((await request("/dispatch/shared-a")).status, 404); + assert.equal((await request("/dispatch/shared-b")).body, "b"); + unregisterHandler("dispatch-shared"); + registeredIds.delete("dispatch-shared"); + assert.equal((await request("/dispatch/shared-b")).status, 404); + }); + + it("leaves multi-handler priority ordering unchanged", async () => { + register( + "dispatch-prefix-late", + "prefix", + "GET", + "/dispatch/priority", + "late", + HandlerPriority.LOW, + ); + register( + "dispatch-prefix-early", + "prefix", + "GET", + "/dispatch/priority", + "early", + HandlerPriority.HIGH, + ); + register( + "dispatch-priority-handler", + "handler", + "GET", + "/dispatch/priority", + "handler", + ); + + assert.equal((await request("/dispatch/priority")).body, "early"); + }); +});