Files
OmniRoute/src/shared/utils/basePathFetch.ts
Rouzbeh† 8e5dc0de1b 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 <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-07-27 21:12:49 -03:00

97 lines
3.1 KiB
TypeScript

/**
* 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;
}
}
};
}