mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
src/shared/components/BasePathNetworkProvider.tsx
Normal file
16
src/shared/components/BasePathNetworkProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
66
src/shared/utils/__tests__/basePath.test.ts
Normal file
66
src/shared/utils/__tests__/basePath.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
55
src/shared/utils/__tests__/basePathFetch.test.ts
Normal file
55
src/shared/utils/__tests__/basePathFetch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
src/shared/utils/basePath.ts
Normal file
70
src/shared/utils/basePath.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
96
src/shared/utils/basePathFetch.ts
Normal file
96
src/shared/utils/basePathFetch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user