From f6bf39cf36e819e8345177312978373489c54dcc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 13 Jul 2026 12:24:02 -0700 Subject: [PATCH] Support trailing slashes --- apps/backend/src/server/app.ts | 4 ++- apps/backend/src/server/error-handler.ts | 31 +++++++++++++++++++ apps/backend/src/smart-router.tsx | 22 +++++++++++-- .../backend/endpoints/api/v1/index.test.ts | 15 +++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/server/error-handler.ts diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts index 6c8be7203..ea9e13dd2 100644 --- a/apps/backend/src/server/app.ts +++ b/apps/backend/src/server/app.ts @@ -5,8 +5,9 @@ import { parseCookieHeader, requestContextALS, type RequestContext } from "@/lib import { node } from "@elysiajs/node"; import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { Elysia } from "elysia"; -import { runRequestPipeline } from "./middleware"; import { createBackendRequest } from "./backend-request"; +import { handleUncaughtBackendError } from "./error-handler"; +import { runRequestPipeline } from "./middleware"; import { MalformedRouteParamError, matchRoute } from "./registry"; const globalSecurityHeaders = { @@ -39,6 +40,7 @@ export const app = new Elysia({ const pathname = new URL(request.url).pathname; console.log(`[Elysia] ${request.method} ${pathname} ${set.status} ${elapsedMilliseconds}ms`); }) + .onError(({ error }) => withGlobalHeaders(handleUncaughtBackendError(error))) .get("/", () => htmlResponse(homeHtml())) .get("/dev-stats", () => htmlResponse(devStatsHtml())) .get("/health/error-handler-debug", () => htmlResponse(errorHandlerDebugHtml())) diff --git a/apps/backend/src/server/error-handler.ts b/apps/backend/src/server/error-handler.ts new file mode 100644 index 000000000..fa7caf051 --- /dev/null +++ b/apps/backend/src/server/error-handler.ts @@ -0,0 +1,31 @@ +import { captureError } from "@hexclave/shared/dist/utils/errors"; + +function createUncaughtErrorResponse() { + return new Response("Internal Server Error", { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }); +} + +export function handleUncaughtBackendError(error: unknown) { + captureError("backend-global-error", error); + return createUncaughtErrorResponse(); +} + +import.meta.vitest?.test("uncaught backend errors do not expose internal details", async ({ expect }) => { + const response = createUncaughtErrorResponse(); + + expect({ + status: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }).toMatchInlineSnapshot(` + { + "body": "Internal Server Error", + "contentType": "text/plain; charset=utf-8", + "status": 500, + } + `); +}); diff --git a/apps/backend/src/smart-router.tsx b/apps/backend/src/smart-router.tsx index ceca8831b..2dfa3e992 100644 --- a/apps/backend/src/smart-router.tsx +++ b/apps/backend/src/smart-router.tsx @@ -103,9 +103,11 @@ function parseApiVersionStringToArray(version: string): [number, number] { function matchPath(path: string, toMatchWith: string): Record | false { - // get the relative part, and modify it to have a leading slash, without a trailing slash, without ./.., etc. - const url = new URL(path + "/", "http://example.com"); - const modifiedPath = url.pathname.slice(1, -1); + // Normalize to relative path segments without a trailing slash. Appending "/" before URL parsing + // turns an already-trailing path into "//", which URL interprets as a protocol-relative URL during + // the final recursive match and rejects because it has no host. + const url = new URL(path === "" ? "/" : path, "http://example.com"); + const modifiedPath = url.pathname.slice(1).replace(/\/+$/, ""); const modifiedToMatchWith = toMatchWith.slice(1); if (modifiedPath === "" && modifiedToMatchWith === "") { @@ -146,6 +148,20 @@ function matchPath(path: string, toMatchWith: string): Record { + expect({ + staticRoute: SmartRouter.matchNormalizedPath("/api/v1/", "/api/v1"), + dynamicRoute: SmartRouter.matchNormalizedPath("/users/user-123/", "/users/[user_id]"), + }).toMatchInlineSnapshot(` + { + "dynamicRoute": { + "user_id": "user-123", + }, + "staticRoute": {}, + } + `); +}); + /** * Modified from: https://github.com/vercel/next.js/blob/6ff13369bb18045657d0f84ddc86b540340603a1/packages/next/src/shared/lib/router/utils/app-paths.ts#L23 */ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts index 9f636f92f..74ae165d4 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts @@ -10,6 +10,21 @@ describe("without project ID", () => { it("should load", async ({ expect }) => { const response = await niceBackendFetch("/api/v1"); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": deindent\` + Welcome to the Hexclave API endpoint! Please refer to the documentation at https://docs.hexclave.com. + + Authentication: None + \`, + "headers": Headers {