Files
openchamber/packages/ui/src/lib/runtime-fetch.ts
T
Bohdan Triapitsyn f7a006dc6a feat(auth): detect session expiry live and offer re-login in place
Every response already funnels through runtimeFetch, so a classifier there
spots 401s, confirms them against /auth/session (a proxied provider 401
must not read as a logout), and flips a small auth-session store. The web
and hosted surfaces show a frosted banner under the header whose Log in
button hands off to the session gate's existing unlock flow; sends are
paused while expired, the session-load error screen explains the auth case
and retries itself after login, and returning to a long-idle window
revalidates once via visibility/focus. Native mobile feeds the same signal
into its connection re-probe instead of showing the banner; VS Code is
exempt.
2026-08-26 18:22:04 +03:00

395 lines
16 KiB
TypeScript

import { getActiveRelayTunnel } from './relay/runtime-tunnel';
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
import { buildRuntimeAuthHeaders } from './runtime-auth';
import { observeRuntimeAuthResponse } from './runtime-auth-expiry';
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
export interface RuntimeFetchOptions extends RequestInit {
query?: RuntimeUrlQuery;
}
const shouldResolveApiPath = (input: string): boolean => {
return input.startsWith('/api/') || input === '/api' || input.startsWith('/auth/') || input === '/auth' || input === '/health';
};
const getCurrentOrigin = (): string => {
if (typeof window === 'undefined') return '';
return window.location.origin || '';
};
const isCurrentWindowUrl = (url: URL): boolean => {
if (typeof window === 'undefined') return false;
const currentOrigin = getCurrentOrigin();
if (currentOrigin && url.origin === currentOrigin) return true;
try {
const current = new URL(window.location.href || currentOrigin);
return url.protocol === current.protocol && url.host === current.host;
} catch {
return false;
}
};
const isAbsoluteUrl = (value: string): boolean => /^[a-z][a-z\d+.-]*:\/\//i.test(value);
const isNgrokHost = (hostname: string): boolean =>
/(^|\.)ngrok(?:-free)?\.(?:app|dev|io)$/i.test(hostname);
export const addRuntimeProxyHeaders = (url: string, headers: Headers): Headers => {
try {
if (isNgrokHost(new URL(url).hostname) && !headers.has('ngrok-skip-browser-warning')) {
headers.set('ngrok-skip-browser-warning', 'openchamber');
}
} catch {
// Relative and non-HTTP runtime paths do not need proxy-specific headers.
}
return headers;
};
const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => {
if (!query) return;
const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query);
for (const [key, value] of entries) {
if (value === null || value === undefined) continue;
url.searchParams.set(key, String(value));
}
};
const isActiveRuntimeServiceUrl = (url: URL): boolean => {
try {
const apiBase = getRuntimeUrlResolver().api('/api');
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(apiBase)) return false;
const base = new URL(apiBase);
if (url.origin !== base.origin) return false;
return shouldResolveApiPath(url.pathname);
} catch {
return false;
}
};
const shouldResolveFetchInput = (input: string): boolean => {
if (shouldResolveApiPath(input)) return true;
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(input)) return false;
try {
const url = new URL(input);
return isCurrentWindowUrl(url) && shouldResolveApiPath(url.pathname);
} catch {
return false;
}
};
const buildRuntimeFetchUrlFromAbsolute = (input: string, query?: RuntimeUrlQuery): string => {
try {
const url = new URL(input);
if (!isCurrentWindowUrl(url)) return input;
const rewritten = buildRuntimeFetchUrl(`${url.pathname}${url.search}`, query);
if (!isAbsoluteUrl(rewritten) && (url.protocol === 'http:' || url.protocol === 'https:')) {
appendRuntimeQuery(url, query);
return url.toString();
}
return url.hash ? `${rewritten}${url.hash}` : rewritten;
} catch {
return input;
}
};
export const buildRuntimeFetchUrl = (input: string, query?: RuntimeUrlQuery): string => {
if (input === '/health') return getRuntimeUrlResolver().health(query);
if (input.startsWith('/auth/') || input === '/auth') return getRuntimeUrlResolver().auth(input, query);
if (shouldResolveApiPath(input)) return getRuntimeUrlResolver().api(input, query);
if (/^[a-z][a-z\d+.-]*:\/\//i.test(input)) return buildRuntimeFetchUrlFromAbsolute(input, query);
return input;
};
const shouldAttachRuntimeAuth = (input: string | URL | Request): boolean => {
const raw = input instanceof Request ? input.url : input.toString();
if (!isAbsoluteUrl(raw)) {
return shouldResolveApiPath(raw);
}
try {
return isActiveRuntimeServiceUrl(new URL(raw));
} catch {
return false;
}
};
// Headers API only accepts ISO-8859-1 (Latin-1) characters. Any value containing
// characters outside \u0000-\u00FF causes "Failed to construct/set 'Headers':
// String contains non ISO-8859-1 code point." Encode those values so they round-trip
// safely through the browser's Headers API. Directory hints get an explicit marker
// only when encoded, so plain ASCII paths remain compatible with routes that read
// the header directly.
export const isLatin1Safe = (value: string): boolean => {
for (let i = 0; i < value.length; i += 1) {
if (value.charCodeAt(i) > 0xFF) return false;
}
return true;
};
const shouldEncodeHeaderValue = (_key: string, value: string): boolean => !isLatin1Safe(value);
export const sanitizeHeadersForBrowser = (init?: HeadersInit): [string, string][] | undefined => {
if (!init) return undefined;
// Normalize any HeadersInit shape into a plain array of entries so we can
// safely inspect and re-encode non-Latin-1 values.
const sourceEntries: [string, string][] = init instanceof Headers
? Array.from(init.entries())
: Array.isArray(init)
? init
: Object.entries(init);
if (sourceEntries.length === 0) return undefined;
const entries: [string, string][] = [];
let dirty = false;
let encodedDirectoryHint = false;
for (const [key, value] of sourceEntries) {
if (shouldEncodeHeaderValue(key, value)) {
entries.push([key, encodeURIComponent(value)]);
dirty = true;
if (key.toLowerCase() === 'x-opencode-directory') encodedDirectoryHint = true;
} else {
entries.push([key, value]);
}
}
if (encodedDirectoryHint) {
entries.push(['x-opencode-directory-encoding', 'uri']);
}
return dirty ? entries : undefined;
};
const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersInit, attachAuth = true): Promise<Headers> => {
const headers = new Headers(sanitizeHeadersForBrowser(inputHeaders) ?? inputHeaders);
if (initHeaders) {
new Headers(sanitizeHeadersForBrowser(initHeaders) ?? initHeaders).forEach((value, key) => headers.set(key, value));
}
if (!attachAuth) {
return headers;
}
return buildRuntimeAuthHeaders(headers);
};
// ── Relay-mode routing ─────────────────────────────────────────────────────
// When the active runtime is a private relay, runtime HTTP does not go to the
// network: it rides the E2EE tunnel. We route exactly the same paths we would
// resolve for a network runtime (/api, /auth, /health) and attach identical
// auth headers; the bearer/url-token semantics are unchanged, only the
// transport differs. Non-runtime requests (external URLs) fall through to the
// real network fetch.
const appendPathQuery = (path: string, query?: RuntimeUrlQuery): string => {
if (!query) return path;
const url = new URL(path, TUNNEL_PARSE_BASE);
appendRuntimeQuery(url, query);
return `${url.pathname}${url.search}`;
};
const extractRelayPath = (input: string | URL | Request, query?: RuntimeUrlQuery): string | null => {
const raw = input instanceof Request ? input.url : input.toString();
if (!isAbsoluteUrl(raw)) {
if (!shouldResolveApiPath(raw)) return null;
return appendPathQuery(raw, query);
}
try {
const url = new URL(raw);
if (!isCurrentWindowUrl(url) || !shouldResolveApiPath(url.pathname)) return null;
appendRuntimeQuery(url, query);
return `${url.pathname}${url.search}`;
} catch {
return null;
}
};
const tryRelayFetch = async (
input: string | URL | Request,
requestInit: RequestInit,
query?: RuntimeUrlQuery,
): Promise<Response | null> => {
const relay = getActiveRelayTunnel();
if (!relay) return null;
const path = extractRelayPath(input, query);
if (path === null) return null;
const inputHeaders = input instanceof Request ? input.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
if (input instanceof Request) {
// Forward the Request itself — the tunnel reads its method/body/signal
// natively (incl. stream bodies). Re-wrapping as `new Request(path, input)`
// throws on a stream body without duplex:'half'.
return relay.fetch(input, { ...requestInit, headers });
}
return relay.fetch(path, { ...requestInit, headers });
};
const resolveRuntimeFetchInput = (input: string | URL | Request, query?: RuntimeUrlQuery): string | URL | Request => {
if (typeof input === 'string') {
return buildRuntimeFetchUrl(input, query);
}
if (input instanceof URL) {
return buildRuntimeFetchUrl(input.toString(), query);
}
const target = buildRuntimeFetchUrl(input.url, query);
return target === input.url ? input : new Request(target, input);
};
// ---------------------------------------------------------------------------
// In-flight read coalescing
//
// On cold start two independent data layers (the sync bootstrap and the config
// store) fire the SAME idempotent reads — providers, config, path, agents,
// project — concurrently, with no shared dedup. That saturates the single
// OpenCode process and delays everything queued behind it (e.g. createSession).
// Coalesce genuinely-concurrent identical GETs to those read endpoints so
// OpenCode does the work once; every caller gets an independent `clone()`.
//
// Scope is deliberately tight: GET only, an allowlist of read paths, never an
// event stream, and never a request carrying an AbortSignal (so one caller
// aborting can't cancel the shared fetch for the others). The entry is removed
// as soon as the request settles, so this only ever shares overlapping in-flight
// requests — it never serves a stale/cached response.
// ---------------------------------------------------------------------------
const COALESCE_READ_PATH = /\/api\/(config|path|app\/agents|agent|project|command)(\b|\/|\?|$)/;
const READ_COALESCE = new Map<string, Promise<Response>>();
const coalesceReadKey = (method: string, url: string, hasSignal: boolean): string | null => {
if (hasSignal) return null;
if (method !== 'GET') return null;
if (url.includes('/event')) return null;
if (!COALESCE_READ_PATH.test(url)) return null;
return `GET ${url}`;
};
export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise<Response> => {
const { query, ...requestInit } = init;
// Resolve the transport once — relay tunnel or network — then apply the SAME
// read-coalescing to both. On a relay the tunnel is bandwidth/latency-bound, so
// deduping concurrent identical GETs matters there most.
const relay = getActiveRelayTunnel();
const relayPath = relay ? extractRelayPath(input, query) : null;
let doFetch: () => Promise<Response>;
let url: string;
let method: string;
if (relay && relayPath !== null) {
const inputHeaders = input instanceof Request ? input.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
doFetch = input instanceof Request
? () => relay.fetch(input, { ...requestInit, headers })
: () => relay.fetch(relayPath, { ...requestInit, headers });
url = relayPath;
method = String(requestInit.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
} else {
const resolvedInput = resolveRuntimeFetchInput(input, query);
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
const resolvedUrl =
resolvedInput instanceof Request ? resolvedInput.url
: resolvedInput instanceof URL ? resolvedInput.toString()
: String(resolvedInput);
addRuntimeProxyHeaders(resolvedUrl, headers);
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url = resolvedUrl;
method = String(
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
).toUpperCase();
}
// Session-expiry classification rides on responses that already flow
// through here; only the status is read, never the body.
const rawFetch = doFetch;
doFetch = () => rawFetch().then((response) => {
observeRuntimeAuthResponse(url, response.status);
return response;
});
// A Request always carries a (possibly default) signal; treat any Request, or
// an explicit init.signal, as "has signal" and skip coalescing for safety.
const hasSignal = requestInit.signal != null || input instanceof Request;
const key = coalesceReadKey(method, url, hasSignal);
if (!key) return doFetch();
const existing = READ_COALESCE.get(key);
if (existing) return existing.then((res) => res.clone());
const pending = doFetch();
READ_COALESCE.set(key, pending);
pending.then(
() => READ_COALESCE.delete(key),
() => READ_COALESCE.delete(key),
);
return pending.then((res) => res.clone());
};
let runtimeFetchBridgeInstalled = false;
export const installRuntimeFetchBridge = (): void => {
if (runtimeFetchBridgeInstalled || typeof window === 'undefined') return;
runtimeFetchBridgeInstalled = true;
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const relayResponse = await tryRelayFetch(input, init ?? {});
if (relayResponse) return relayResponse;
if (typeof input === 'string') {
if (!shouldResolveFetchInput(input)) {
try {
const url = new URL(input);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
} catch {
// Non-URL fetch inputs should fall through unchanged.
}
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
const target = buildRuntimeFetchUrl(input);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof URL) {
const raw = input.toString();
if (!shouldResolveFetchInput(raw)) {
if (isActiveRuntimeServiceUrl(input)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(input.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
const target = buildRuntimeFetchUrl(raw);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof Request) {
if (!shouldResolveFetchInput(input.url)) {
try {
const url = new URL(input.url);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(input.headers, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(new Request(input, { ...init, headers }));
}
} catch {
// Non-URL request inputs should fall through unchanged.
}
return nativeFetch(input, init);
}
const headers = await mergeHeaders(input.headers, init?.headers);
const target = buildRuntimeFetchUrl(input.url);
addRuntimeProxyHeaders(target, headers);
const request = target === input.url ? input : new Request(target, input);
return nativeFetch(new Request(request, { ...init, headers }));
}
return nativeFetch(input, init);
};
};