From 8e5dc0de1bacf5c9c45282e73030a9e7de93adb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:42:49 +0330 Subject: [PATCH] fix(client): rewrite absolute fetch/EventSource paths under basePath (#8515) * fix(client): rewrite absolute fetch/EventSource paths under basePath Absolute browser calls like fetch("/api/...") and new EventSource("/api/...") do not honor Next.js basePath, so subpath deploys (OMNIROUTE_BASE_PATH) break dashboard health checks, settings APIs, and SSE unless a reverse proxy rewrites the domain root. - Add withBasePath / getDeployBasePath helpers - Install ref-counted fetch + EventSource rewrite when basePath is set (same pattern as installDashboardCsrfFetch) - Mount BasePathNetworkProvider at the root so login works too - Mirror OMNIROUTE_BASE_PATH to NEXT_PUBLIC_OMNIROUTE_BASE_PATH for the client - Document in .env.example; unit tests for rewrite rules * docs(changelog): add fragment for #8515 basePath client fetch * test(client): move basePath tests into a scanned dir and fix no-op call-shape asserts Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(client-sweep-8515): restore CHANGELOG #8471 bullet and fix basePath TS2322 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: rqzbeh Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw --- .env.example | 3 + .../fixes/8515-basepath-client-fetch.md | 1 + next.config.mjs | 7 +- src/app/layout.tsx | 9 +- .../components/BasePathNetworkProvider.tsx | 16 ++++ .../components/layouts/DashboardLayout.tsx | 9 +- src/shared/utils/basePath.ts | 70 ++++++++++++++ src/shared/utils/basePathFetch.ts | 96 +++++++++++++++++++ tests/unit/shared/basePath.test.ts | 75 +++++++++++++++ tests/unit/shared/basePathFetch.test.ts | 78 +++++++++++++++ 10 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/8515-basepath-client-fetch.md create mode 100644 src/shared/components/BasePathNetworkProvider.tsx create mode 100644 src/shared/utils/basePath.ts create mode 100644 src/shared/utils/basePathFetch.ts create mode 100644 tests/unit/shared/basePath.test.ts create mode 100644 tests/unit/shared/basePathFetch.test.ts diff --git a/.env.example b/.env.example index 0656dd7151..2ba4099968 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,9 @@ PORT=20128 # Docker: baked at image build time via build-arg; root-path images can also apply this at # container start (see docs/guides/DOCKER_GUIDE.md). # OMNIROUTE_BASE_PATH= +# Client fetch/EventSource under this path are rewritten via installBasePathFetch +# (src/shared/utils/basePathFetch.ts) so absolute `/api/*` and `/v1/*` hits work +# without a reverse-proxy rewrite. Rebuild after changing (Next basePath is build-time). # # Browser-visible mirror of OMNIROUTE_BASE_PATH, inlined at build time so the # dashboard endpoint display can read it client-side. Set it to the same value diff --git a/changelog.d/fixes/8515-basepath-client-fetch.md b/changelog.d/fixes/8515-basepath-client-fetch.md new file mode 100644 index 0000000000..7c1289f161 --- /dev/null +++ b/changelog.d/fixes/8515-basepath-client-fetch.md @@ -0,0 +1 @@ +- **fix(client):** Absolute `fetch("/api/...")` and `EventSource("/api/...")` honor `OMNIROUTE_BASE_PATH` under reverse-proxy subpath deploys (no domain-root 404s for dashboard health/SSE) ([#8515](https://github.com/diegosouzapw/OmniRoute/pull/8515)) — thanks @rqzbeh diff --git a/next.config.mjs b/next.config.mjs index 3ae4091413..8eccf141e3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -103,9 +103,10 @@ const nextConfig = { // keeps operating on un-prefixed paths — see src/server/authz/pipeline.ts for // the two redirect call sites that re-add it via `request.nextUrl.basePath`. basePath: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), - // Mirror OMNIROUTE_BASE_PATH into a NEXT_PUBLIC_* so client display helpers - // (useDisplayBaseUrl) can append the subpath to window.location.origin when - // building curl/endpoint examples. Empty by default (root deploys unchanged). + // Client-visible mirror of basePath for fetch/EventSource rewriting under reverse + // proxies (installBasePathFetch), and for client display helpers (useDisplayBaseUrl) + // that append the subpath to window.location.origin when building curl/endpoint + // examples. Empty by default (root deploys unchanged). env: { NEXT_PUBLIC_OMNIROUTE_BASE_PATH: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b5e048b6ad..2925668f16 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { getSettings } from "@/lib/db/settings"; import type { Viewport } from "next"; import { PwaRegister } from "@/shared/components/PwaRegister"; import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect"; +import { BasePathNetworkProvider } from "@/shared/components/BasePathNetworkProvider"; const inter = Inter({ subsets: ["latin"], @@ -142,9 +143,11 @@ export default async function RootLayout({ children }) { {t("skipToContent")} - - - {children} + + + + {children} + diff --git a/src/shared/components/BasePathNetworkProvider.tsx b/src/shared/components/BasePathNetworkProvider.tsx new file mode 100644 index 0000000000..d6c7e1e372 --- /dev/null +++ b/src/shared/components/BasePathNetworkProvider.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useInsertionEffect, type ReactNode } from "react"; +import { installBasePathFetch } from "@/shared/utils/basePathFetch"; + +/** + * Install basePath-aware fetch/EventSource for reverse-proxy subpath deploys. + * Mount near the root so login + dashboard both pick it up before other effects. + */ +export function BasePathNetworkProvider({ children }: { children: ReactNode }) { + useInsertionEffect(() => { + return installBasePathFetch(); + }, []); + + return children; +} diff --git a/src/shared/components/layouts/DashboardLayout.tsx b/src/shared/components/layouts/DashboardLayout.tsx index 1016e7daa1..f56f73478c 100644 --- a/src/shared/components/layouts/DashboardLayout.tsx +++ b/src/shared/components/layouts/DashboardLayout.tsx @@ -13,6 +13,7 @@ import { installDashboardCsrfFetch, prefetchDashboardCsrfToken, } from "@/shared/utils/dashboardCsrf"; +import { installBasePathFetch } from "@/shared/utils/basePathFetch"; const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed"; const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; @@ -47,9 +48,15 @@ export default function DashboardLayout({ children }) { }, [isMacElectron]); useInsertionEffect(() => { + // basePath rewrite must wrap native fetch first so CSRF's originalFetch + // chain (and bare `fetch("/api/...")` call sites) hit the subpath. + const uninstallBasePathFetch = installBasePathFetch(); const uninstallDashboardCsrfFetch = installDashboardCsrfFetch(); void prefetchDashboardCsrfToken(); - return uninstallDashboardCsrfFetch; + return () => { + uninstallDashboardCsrfFetch(); + uninstallBasePathFetch(); + }; }, []); useEffect(() => { diff --git a/src/shared/utils/basePath.ts b/src/shared/utils/basePath.ts new file mode 100644 index 0000000000..80d012e263 --- /dev/null +++ b/src/shared/utils/basePath.ts @@ -0,0 +1,70 @@ +/** + * Client/server helpers for Next.js `basePath` / `OMNIROUTE_BASE_PATH` deploys. + * + * Next.js rewrites Link/router automatically, but absolute browser calls like + * `fetch("/api/...")` and `new EventSource("/api/...")` do not get the prefix. + * Under a reverse-proxy subpath those hit the domain root instead of the app. + */ + +/** Normalize to leading slash, no trailing slash. Empty / root → `""`. */ +export function normalizeBasePath(value?: string | null): string { + const trimmed = value?.trim() ?? ""; + if (!trimmed || trimmed === "/") return ""; + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.replace(/\/+$/, ""); +} + +/** + * Deploy basePath as seen by the client bundle. + * Set via next.config `env.NEXT_PUBLIC_OMNIROUTE_BASE_PATH` from `OMNIROUTE_BASE_PATH`. + */ +export function getDeployBasePath( + env: NodeJS.ProcessEnv = typeof process !== "undefined" ? process.env : ({} as NodeJS.ProcessEnv) +): string { + return normalizeBasePath( + env.NEXT_PUBLIC_OMNIROUTE_BASE_PATH || env.OMNIROUTE_BASE_PATH || "" + ); +} + +/** + * Prefix a same-origin app path with the deploy basePath when needed. + * + * - Relative absolute paths: `/api/health/ping` → `/omniroute/api/health/ping` + * - Absolute same-origin URLs: `https://host/api/x` → `https://host/omniroute/api/x` + * - Already-prefixed paths, external URLs, and protocol-relative URLs are unchanged + */ +export function withBasePath( + input: string, + basePath: string = getDeployBasePath(), + origin?: string +): string { + if (!basePath) return input; + if (!input) return input; + + // Protocol-relative or non-path forms + if (input.startsWith("//")) return input; + + // Absolute path on this origin + if (input.startsWith("/")) { + if (input === basePath || input.startsWith(`${basePath}/`)) return input; + return `${basePath}${input}`; + } + + // Absolute URL — only rewrite same-origin + try { + const baseOrigin = + origin || + (typeof window !== "undefined" ? window.location.origin : "http://localhost"); + const url = new URL(input, baseOrigin); + const currentOrigin = new URL(baseOrigin).origin; + if (url.origin !== currentOrigin) return input; + + if (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`)) { + return url.toString(); + } + url.pathname = `${basePath}${url.pathname.startsWith("/") ? url.pathname : `/${url.pathname}`}`; + return url.toString(); + } catch { + return input; + } +} diff --git a/src/shared/utils/basePathFetch.ts b/src/shared/utils/basePathFetch.ts new file mode 100644 index 0000000000..616658768e --- /dev/null +++ b/src/shared/utils/basePathFetch.ts @@ -0,0 +1,96 @@ +/** + * Install a same-origin fetch + EventSource rewrite for Next.js basePath deploys. + * + * Pattern mirrors `installDashboardCsrfFetch` (ref-counted global wrap). + * Only activates when `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` / `OMNIROUTE_BASE_PATH` is set. + */ + +import { getDeployBasePath, withBasePath } from "./basePath"; + +let originalFetch: typeof fetch | null = null; +let originalEventSource: typeof EventSource | null = null; +let installCount = 0; + +export function __resetBasePathFetchForTests(): void { + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } + if (originalEventSource && typeof window !== "undefined") { + window.EventSource = originalEventSource; + originalEventSource = null; + } + installCount = 0; +} + +function rewriteInput(input: RequestInfo | URL, basePath: string): RequestInfo | URL { + if (typeof input === "string") { + return withBasePath(input, basePath); + } + if (typeof URL !== "undefined" && input instanceof URL) { + return new URL(withBasePath(input.href, basePath)); + } + if (typeof Request !== "undefined" && input instanceof Request) { + const rewritten = withBasePath(input.url, basePath); + if (rewritten === input.url) return input; + return new Request(rewritten, input); + } + return input; +} + +/** + * Wrap `globalThis.fetch` (and `EventSource` in the browser) so absolute + * same-origin app paths receive the Next.js basePath prefix. + * + * No-op when basePath is empty (root deploys). Safe to call multiple times; + * uninstall when the last consumer unmounts. + */ +export function installBasePathFetch( + basePath: string = getDeployBasePath() +): () => void { + if (!basePath) return () => {}; + if (typeof globalThis.fetch !== "function") return () => {}; + + if (installCount === 0) { + originalFetch = globalThis.fetch.bind(globalThis); + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + if (!originalFetch) return fetch(input, init); + return originalFetch(rewriteInput(input, basePath), init); + }) as typeof fetch; + + if (typeof window !== "undefined" && typeof window.EventSource === "function") { + originalEventSource = window.EventSource; + const BaseES = originalEventSource; + + // Subclass so `instanceof EventSource` and prototype methods keep working. + class BasePathEventSource extends BaseES { + constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) { + const raw = typeof url === "string" ? url : url.toString(); + super(withBasePath(raw, basePath), eventSourceInitDict); + } + } + + window.EventSource = BasePathEventSource as typeof EventSource; + } + } + + installCount++; + let active = true; + + return () => { + if (!active) return; + active = false; + installCount = Math.max(0, installCount - 1); + if (installCount === 0) { + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } + if (originalEventSource && typeof window !== "undefined") { + window.EventSource = originalEventSource; + originalEventSource = null; + } + } + }; +} diff --git a/tests/unit/shared/basePath.test.ts b/tests/unit/shared/basePath.test.ts new file mode 100644 index 0000000000..8b86e95701 --- /dev/null +++ b/tests/unit/shared/basePath.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getDeployBasePath, + normalizeBasePath, + withBasePath, +} from "../../../src/shared/utils/basePath"; + +describe("normalizeBasePath", () => { + it("normalizes leading/trailing slashes", () => { + assert.equal(normalizeBasePath("omniroute"), "/omniroute"); + assert.equal(normalizeBasePath("/omniroute/"), "/omniroute"); + assert.equal(normalizeBasePath("/omniroute"), "/omniroute"); + assert.equal(normalizeBasePath(""), ""); + assert.equal(normalizeBasePath("/"), ""); + assert.equal(normalizeBasePath(null), ""); + }); +}); + +describe("getDeployBasePath", () => { + it("reads NEXT_PUBLIC_OMNIROUTE_BASE_PATH first", () => { + assert.equal( + getDeployBasePath({ + NEXT_PUBLIC_OMNIROUTE_BASE_PATH: "/omniroute", + OMNIROUTE_BASE_PATH: "/other", + } as NodeJS.ProcessEnv), + "/omniroute" + ); + }); + + it("falls back to OMNIROUTE_BASE_PATH", () => { + assert.equal( + getDeployBasePath({ + OMNIROUTE_BASE_PATH: "/omniroute", + } as NodeJS.ProcessEnv), + "/omniroute" + ); + }); +}); + +describe("withBasePath", () => { + const base = "/omniroute"; + + it("is a no-op when basePath is empty", () => { + assert.equal(withBasePath("/api/health/ping", ""), "/api/health/ping"); + }); + + it("prefixes absolute app paths", () => { + assert.equal(withBasePath("/api/health/ping", base), "/omniroute/api/health/ping"); + assert.equal(withBasePath("/v1/models", base), "/omniroute/v1/models"); + }); + + it("does not double-prefix", () => { + assert.equal(withBasePath("/omniroute/api/health/ping", base), "/omniroute/api/health/ping"); + assert.equal(withBasePath("/omniroute", base), "/omniroute"); + }); + + it("rewrites same-origin absolute URLs", () => { + assert.equal( + withBasePath("https://host.example/api/x", base, "https://host.example"), + "https://host.example/omniroute/api/x" + ); + }); + + it("leaves external absolute URLs alone", () => { + assert.equal( + withBasePath("https://other.example/api/x", base, "https://host.example"), + "https://other.example/api/x" + ); + }); + + it("leaves protocol-relative URLs alone", () => { + assert.equal(withBasePath("//cdn.example/app.js", base), "//cdn.example/app.js"); + }); +}); diff --git a/tests/unit/shared/basePathFetch.test.ts b/tests/unit/shared/basePathFetch.test.ts new file mode 100644 index 0000000000..5ce000cf90 --- /dev/null +++ b/tests/unit/shared/basePathFetch.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, mock } from "node:test"; +import { + __resetBasePathFetchForTests, + installBasePathFetch, +} from "../../../src/shared/utils/basePathFetch"; + +describe("installBasePathFetch", () => { + afterEach(() => { + __resetBasePathFetchForTests(); + }); + + it("is a no-op when basePath is empty", async () => { + const native = mock.fn(async () => new Response("ok")); + const originalFetch = globalThis.fetch; + globalThis.fetch = native as unknown as typeof fetch; + try { + const uninstall = installBasePathFetch(""); + await fetch("/api/health/ping"); + // No-op install never wraps fetch, so the call keeps its original single-argument + // shape (the caller passed no `init`) instead of the two-argument shape the wrapper + // produces once basePath is set. + assert.deepEqual(native.mock.calls[0].arguments, ["/api/health/ping"]); + uninstall(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("prefixes absolute paths on fetch when basePath is set", async () => { + const native = mock.fn(async () => new Response("ok")); + const originalFetch = globalThis.fetch; + globalThis.fetch = native as unknown as typeof fetch; + try { + const uninstall = installBasePathFetch("/omniroute"); + await fetch("/api/health/ping"); + assert.deepEqual(native.mock.calls[0].arguments, ["/omniroute/api/health/ping", undefined]); + uninstall(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not double-prefix already-prefixed paths", async () => { + const native = mock.fn(async () => new Response("ok")); + const originalFetch = globalThis.fetch; + globalThis.fetch = native as unknown as typeof fetch; + try { + const uninstall = installBasePathFetch("/omniroute"); + await fetch("/omniroute/api/health/ping"); + assert.deepEqual(native.mock.calls[0].arguments, ["/omniroute/api/health/ping", undefined]); + uninstall(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("restores native fetch after last uninstall", async () => { + const native = mock.fn(async () => new Response("ok")); + const originalFetch = globalThis.fetch; + globalThis.fetch = native as unknown as typeof fetch; + try { + const a = installBasePathFetch("/omniroute"); + const b = installBasePathFetch("/omniroute"); + a(); + await fetch("/api/x"); + assert.deepEqual(native.mock.calls[0].arguments, ["/omniroute/api/x", undefined]); + b(); + native.mock.resetCalls(); + await fetch("/api/x"); + // Once the last consumer uninstalls, fetch is native again — same single-argument + // call shape as the no-op case above. + assert.deepEqual(native.mock.calls[0].arguments, ["/api/x"]); + } finally { + globalThis.fetch = originalFetch; + } + }); +});