Files
openchamber/packages/ui/src/lib/runtime-url.ts
T

184 lines
6.7 KiB
TypeScript

import { getLocalRuntimeUrlAuthTokenSync, getRuntimeExtraHeadersSync, getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth';
type QueryValue = string | number | boolean | null | undefined;
export type RuntimeUrlQuery = Record<string, QueryValue> | URLSearchParams;
export interface RuntimeUrlConfig {
apiBaseUrl?: string | null;
realtimeBaseUrl?: string | null;
currentHref?: () => string;
}
export interface RuntimeUrlResolver {
api(path: string, query?: RuntimeUrlQuery): string;
authenticatedAsset(path: string, query?: RuntimeUrlQuery): string;
auth(path: string, query?: RuntimeUrlQuery): string;
health(query?: RuntimeUrlQuery): string;
rawFile(path: string, options?: { download?: boolean; allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): string;
sse(path: string, query?: RuntimeUrlQuery): string;
websocket(path: string, query?: RuntimeUrlQuery): string;
}
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
const normalizePath = (path: string): string => {
const trimmed = path.trim();
if (!trimmed) return '/';
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
};
const normalizeBaseUrl = (value: string | null | undefined): string => {
if (typeof value !== 'string') return '';
return value.trim().replace(/\/+$/, '');
};
const readInjectedApiBaseUrl = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
return normalizeBaseUrl(injected);
};
const readInjectedLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
return normalizeBaseUrl(injected);
};
const hasRuntimeExtraHeaders = (): boolean => Object.keys(getRuntimeExtraHeadersSync()).length > 0;
const currentHref = (config: RuntimeUrlConfig): string => {
const configured = config.currentHref?.();
if (configured) return configured;
if (typeof window !== 'undefined') {
return window.location.href || window.location.origin;
}
return '';
};
const appendQuery = (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 appendRelativeQuery = (path: string, query?: RuntimeUrlQuery): string => {
if (!query) return path;
const params = new URLSearchParams();
appendQuery({ searchParams: params } as URL, query);
const serialized = params.toString();
if (!serialized) return path;
return path.includes('?') ? `${path}&${serialized}` : `${path}?${serialized}`;
};
const buildHttpUrl = (baseUrl: string, path: string, query?: RuntimeUrlQuery): string => {
if (ABSOLUTE_URL_PATTERN.test(path)) {
const url = new URL(path);
appendQuery(url, query);
return url.toString();
}
const normalizedPath = normalizePath(path);
if (!baseUrl) {
return appendRelativeQuery(normalizedPath, query);
}
const url = new URL(normalizedPath, `${baseUrl}/`);
appendQuery(url, query);
return url.toString();
};
const withUrlAuth = (urlValue: string): string => {
const token = getRuntimeUrlAuthTokenSync();
if (!token) return urlValue;
const url = ABSOLUTE_URL_PATTERN.test(urlValue)
? new URL(urlValue)
: new URL(urlValue, 'http://openchamber.local');
url.searchParams.set('oc_url_token', token);
if (ABSOLUTE_URL_PATTERN.test(urlValue)) return url.toString();
return `${url.pathname}${url.search}${url.hash}`;
};
const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => {
const url = ABSOLUTE_URL_PATTERN.test(candidate)
? new URL(candidate)
: new URL(candidate, currentHref(config));
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
return url.toString();
}
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
};
const toRealtimeProxyUrl = (kind: 'sse' | 'ws', targetUrl: string, config: RuntimeUrlConfig): string | null => {
if (!hasRuntimeExtraHeaders()) return null;
const localOrigin = readInjectedLocalOrigin();
if (!localOrigin) return null;
try {
const proxy = new URL(`/api/openchamber/realtime-proxy/${kind === 'sse' ? 'sse' : 'ws'}`, `${localOrigin}/`);
proxy.searchParams.set('url', targetUrl);
const localToken = getLocalRuntimeUrlAuthTokenSync(localOrigin);
if (localToken) proxy.searchParams.set('oc_url_token', localToken);
if (kind === 'ws') {
proxy.protocol = proxy.protocol === 'https:' ? 'wss:' : 'ws:';
return toWebSocketUrl(proxy.toString(), config);
}
return proxy.toString();
} catch {
return null;
}
};
export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => {
const configuredApiBaseUrl = normalizeBaseUrl(config.apiBaseUrl);
const configuredRealtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl);
const apiBaseUrl = (): string => configuredApiBaseUrl || readInjectedApiBaseUrl();
const realtimeBaseUrl = (): string => configuredRealtimeBaseUrl || apiBaseUrl();
const http = (path: string, query?: RuntimeUrlQuery): string => buildHttpUrl(apiBaseUrl(), path, query);
const realtime = (path: string, query?: RuntimeUrlQuery): string => buildHttpUrl(realtimeBaseUrl(), path, query);
return {
api: http,
authenticatedAsset: (path, query) => withUrlAuth(http(path, query)),
auth: http,
health: (query) => http('/health', query),
rawFile: (path, options) => http('/api/fs/raw', {
path,
download: options?.download === true ? true : undefined,
allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined,
outsideFileGrant: options?.outsideFileGrant,
}),
sse: (path, query) => {
const target = withUrlAuth(realtime(path, query));
return toRealtimeProxyUrl('sse', target, config) || target;
},
websocket: (path, query) => {
const target = toWebSocketUrl(withUrlAuth(realtime(path, query)), config);
return toRealtimeProxyUrl('ws', target, config) || target;
},
};
};
let activeRuntimeUrlResolver = createRuntimeUrlResolver();
export const getRuntimeUrlResolver = (): RuntimeUrlResolver => activeRuntimeUrlResolver;
export const setRuntimeUrlResolver = (resolver: RuntimeUrlResolver): void => {
activeRuntimeUrlResolver = resolver;
};
export const configureRuntimeUrlResolver = (config: RuntimeUrlConfig): RuntimeUrlResolver => {
activeRuntimeUrlResolver = createRuntimeUrlResolver(config);
return activeRuntimeUrlResolver;
};