diff --git a/.env.example b/.env.example index aac4c32344..abf83df5a0 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,9 @@ PORT=20128 # Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware. # Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute # 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). # Split-port mode: serve Dashboard and API on separate ports for network isolation. # Used by: src/lib/runtime/ports.ts — overrides PORT for each service. diff --git a/next.config.mjs b/next.config.mjs index e78a586abb..582ddd8a6a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -102,6 +102,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: process.env.OMNIROUTE_BASE_PATH || "", + // Client-visible mirror of basePath for fetch/EventSource rewriting under reverse proxies. + env: { + NEXT_PUBLIC_OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH || "", + }, distDir, // Turbopack config: redirect native modules to stubs at build time turbopack: { 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/__tests__/basePath.test.ts b/src/shared/utils/__tests__/basePath.test.ts new file mode 100644 index 0000000000..2cfa9ecd98 --- /dev/null +++ b/src/shared/utils/__tests__/basePath.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getDeployBasePath, normalizeBasePath, withBasePath } from "../basePath"; + +describe("normalizeBasePath", () => { + it("normalizes leading/trailing slashes", () => { + expect(normalizeBasePath("omniroute")).toBe("/omniroute"); + expect(normalizeBasePath("/omniroute/")).toBe("/omniroute"); + expect(normalizeBasePath("/omniroute")).toBe("/omniroute"); + expect(normalizeBasePath("")).toBe(""); + expect(normalizeBasePath("/")).toBe(""); + expect(normalizeBasePath(null)).toBe(""); + }); +}); + +describe("getDeployBasePath", () => { + it("reads NEXT_PUBLIC_OMNIROUTE_BASE_PATH first", () => { + expect( + getDeployBasePath({ + NEXT_PUBLIC_OMNIROUTE_BASE_PATH: "/omniroute", + OMNIROUTE_BASE_PATH: "/other", + } as NodeJS.ProcessEnv) + ).toBe("/omniroute"); + }); + + it("falls back to OMNIROUTE_BASE_PATH", () => { + expect( + getDeployBasePath({ + OMNIROUTE_BASE_PATH: "/omniroute", + } as NodeJS.ProcessEnv) + ).toBe("/omniroute"); + }); +}); + +describe("withBasePath", () => { + const base = "/omniroute"; + + it("is a no-op when basePath is empty", () => { + expect(withBasePath("/api/health/ping", "")).toBe("/api/health/ping"); + }); + + it("prefixes absolute app paths", () => { + expect(withBasePath("/api/health/ping", base)).toBe("/omniroute/api/health/ping"); + expect(withBasePath("/v1/models", base)).toBe("/omniroute/v1/models"); + }); + + it("does not double-prefix", () => { + expect(withBasePath("/omniroute/api/health/ping", base)).toBe("/omniroute/api/health/ping"); + expect(withBasePath("/omniroute", base)).toBe("/omniroute"); + }); + + it("rewrites same-origin absolute URLs", () => { + expect(withBasePath("https://host.example/api/x", base, "https://host.example")).toBe( + "https://host.example/omniroute/api/x" + ); + }); + + it("leaves external absolute URLs alone", () => { + expect(withBasePath("https://other.example/api/x", base, "https://host.example")).toBe( + "https://other.example/api/x" + ); + }); + + it("leaves protocol-relative URLs alone", () => { + expect(withBasePath("//cdn.example/app.js", base)).toBe("//cdn.example/app.js"); + }); +}); diff --git a/src/shared/utils/__tests__/basePathFetch.test.ts b/src/shared/utils/__tests__/basePathFetch.test.ts new file mode 100644 index 0000000000..c64d7f6796 --- /dev/null +++ b/src/shared/utils/__tests__/basePathFetch.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __resetBasePathFetchForTests, installBasePathFetch } from "../basePathFetch"; + +describe("installBasePathFetch", () => { + afterEach(() => { + __resetBasePathFetchForTests(); + vi.unstubAllGlobals(); + }); + + it("is a no-op when basePath is empty", async () => { + const native = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", native); + + const uninstall = installBasePathFetch(""); + await fetch("/api/health/ping"); + expect(native).toHaveBeenCalledWith("/api/health/ping", undefined); + uninstall(); + }); + + it("prefixes absolute paths on fetch when basePath is set", async () => { + const native = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", native); + + const uninstall = installBasePathFetch("/omniroute"); + await fetch("/api/health/ping"); + expect(native).toHaveBeenCalledWith("/omniroute/api/health/ping", undefined); + uninstall(); + }); + + it("does not double-prefix already-prefixed paths", async () => { + const native = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", native); + + const uninstall = installBasePathFetch("/omniroute"); + await fetch("/omniroute/api/health/ping"); + expect(native).toHaveBeenCalledWith("/omniroute/api/health/ping", undefined); + uninstall(); + }); + + it("restores native fetch after last uninstall", async () => { + const native = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", native); + + const a = installBasePathFetch("/omniroute"); + const b = installBasePathFetch("/omniroute"); + a(); + await fetch("/api/x"); + expect(native).toHaveBeenCalledWith("/omniroute/api/x", undefined); + b(); + native.mockClear(); + await fetch("/api/x"); + expect(native).toHaveBeenCalledWith("/api/x", undefined); + }); +}); diff --git a/src/shared/utils/basePath.ts b/src/shared/utils/basePath.ts new file mode 100644 index 0000000000..93f6803933 --- /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 : {} +): 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; + } + } + }; +}