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
This commit is contained in:
rqzbeh
2026-07-25 06:06:40 +03:30
parent 1cafd328c7
commit d7142c2154
9 changed files with 324 additions and 4 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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")}
</a>
<NextIntlClientProvider locale={locale} messages={messages}>
<PwaRegister />
<LocaleAutoDetect />
<ThemeProvider>{children}</ThemeProvider>
<BasePathNetworkProvider>
<PwaRegister />
<LocaleAutoDetect />
<ThemeProvider>{children}</ThemeProvider>
</BasePathNetworkProvider>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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(() => {

View File

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

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}
};
}