fix: support dev server previews over relay

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent da6ab52a65
commit a87f068256
16 changed files with 504 additions and 36 deletions
+81 -8
View File
@@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
let apiBaseUrl = 'https://remote.example.test';
let tunnelResult: unknown = { localPort: 52418, reused: false };
type TunnelResult = { localPort: number; reused: boolean } | Error;
type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string };
type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } };
type RelaySocketFixture = {
binaryType: string;
onopen: (() => void) | null;
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null;
onerror: (() => void) | null;
onclose: (() => void) | null;
send: ReturnType<typeof mock>;
close: ReturnType<typeof mock>;
readyState: number;
};
let tunnelResult: TunnelResult = { localPort: 52418, reused: false };
let desktopArgs: DesktopTunnelArgs | undefined;
let relayActive = false;
let openedRelayUrl = '';
let refreshedBaseUrl = '';
let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
let relayHandler: ((event: RelayEvent) => void) | null = null;
const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = [];
const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 };
mock.module('@/lib/desktopNative', () => ({
invokeDesktopCommand: mock(async () => {
invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
desktopArgs = args;
if (tunnelResult instanceof Error) throw tunnelResult;
return tunnelResult;
}),
listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; },
postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }),
}));
mock.module('@/lib/relay/runtime-tunnel', () => ({
isRelayModeActive: () => relayActive,
getActiveRelayTunnel: () => relayActive ? {} : null,
}));
mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } }));
mock.module('@/lib/runtime-auth', () => ({
getRuntimeBearerTokenSync: () => 'token',
getRuntimeExtraHeadersSync: () => ({}),
refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl),
}));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) }));
mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: () => apiBaseUrl,
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
subscribeRuntimeEndpointChanged: () => () => {},
}));
@@ -25,23 +58,30 @@ const {
toDisplayUrl,
} = await import('./devTunnel');
const globalScope = globalThis as unknown as { window?: unknown };
const asDesktop = (value: boolean) => {
globalScope.window = value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } };
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } },
});
};
describe('loopback navigations against a remote instance', () => {
beforeEach(() => {
apiBaseUrl = 'https://remote.example.test';
tunnelResult = { localPort: 52418, reused: false };
desktopArgs = undefined;
relayActive = false;
relayPosts.length = 0;
openedRelayUrl = '';
refreshedBaseUrl = '';
refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
asDesktop(true);
});
afterEach(() => {
delete globalScope.window;
Reflect.deleteProperty(globalThis, 'window');
});
test('a page reached through a tunnel keeps its other ports on the host', () => {
@@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => {
expect(failed).toBe(true);
});
test('a relay-only runtime asks Electron for a local relay bridge', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/');
expect(resolved).toBe('http://127.0.0.1:52418/docs/');
expect(desktopArgs?.relay).toBe(true);
expect(desktopArgs?.targetKey).toBe('host:exe');
expect(desktopArgs?.port).toBe(4322);
relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } });
await Promise.resolve();
await Promise.resolve();
relaySocket.onopen?.();
expect(refreshedBaseUrl).toBe('openchamber-ui://app');
expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test');
expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true);
});
test('a local disconnect during auth does not leave an orphan relay socket', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
let finishAuth = () => {};
refreshUrlAuth = () => new Promise<string>((resolve) => { finishAuth = () => resolve('url-token'); });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } });
finishAuth();
await Promise.resolve();
await Promise.resolve();
expect(openedRelayUrl).toBe('');
});
test('a local instance resolves its own loopback correctly', () => {
apiBaseUrl = 'http://127.0.0.1:3901';
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
+69 -14
View File
@@ -10,17 +10,66 @@
* Everywhere else — local runtime, web, mobile — the URL is already correct and
* is returned untouched.
*/
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket';
import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { isLoopbackUrl } from './url';
type TunnelResult = { localPort: number; reused: boolean; url: string };
type TunnelResult = { localPort: number };
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
const localPortByTarget = new Map<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>();
const relaySockets = new Map<string, RelayTunnelWebSocket>();
const pendingRelayConnections = new Set<string>();
const openRelayConnection = async (connectionId: string, remotePort: number): Promise<void> => {
if (!getActiveRelayTunnel()) {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
return;
}
await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl());
if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return;
const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`);
const socket = openRuntimeWebSocket(url);
relaySockets.set(connectionId, socket);
socket.binaryType = 'arraybuffer';
socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' });
socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) });
socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
socket.onclose = () => {
pendingRelayConnections.delete(connectionId);
relaySockets.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
};
};
listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => {
switch (message.type) {
case 'data': {
const socket = relaySockets.get(connectionId);
if (socket && message.data) socket.send(message.data);
return;
}
case 'close':
pendingRelayConnections.delete(connectionId);
relaySockets.get(connectionId)?.close();
relaySockets.delete(connectionId);
return;
case 'connect':
pendingRelayConnections.add(connectionId);
void openRelayConnection(connectionId, remotePort).catch(() => {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
});
}
});
const isDesktopRuntime = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
@@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => {
}
};
const rememberOriginalOrigin = (url: string, localPort: number): void => {
try {
originByLocalPort.set(localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
};
/** Thrown when a remote dev server exists but could not be reached from here. */
export class DevTunnelUnavailableError extends Error {
constructor(message: string) {
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const key = `${baseUrl}|${port}`;
const cached = localPortByTarget.get(key);
if (cached) {
try {
originByLocalPort.set(cached, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, cached);
return rewriteToLocalPort(url, cached);
}
@@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
baseUrl,
port,
relay: isRelayModeActive(),
targetKey: getRuntimeKey(),
clientToken: getRuntimeBearerTokenSync(),
requestHeaders: getRuntimeExtraHeadersSync(),
});
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
throw new DevTunnelUnavailableError(url);
}
localPortByTarget.set(key, result.localPort);
try {
originByLocalPort.set(result.localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, result.localPort);
return rewriteToLocalPort(url, result.localPort);
} catch (error) {
if (error instanceof DevTunnelUnavailableError) throw error;
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
const resetDevTunnelCache = (): void => {
localPortByTarget.clear();
originByLocalPort.clear();
pendingRelayConnections.clear();
for (const socket of relaySockets.values()) socket.close();
relaySockets.clear();
void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {});
};
if (typeof window !== 'undefined') {
+28
View File
@@ -1,6 +1,34 @@
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>;
type RelayDevTunnelData = ArrayBuffer | Uint8Array;
type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData };
type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage };
type RelayDevTunnelBridge = {
relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void;
relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void;
};
declare global {
interface Window {
__OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge;
}
}
const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => {
return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null;
};
export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => {
const bridge = getRelayDevTunnelBridge();
if (!bridge?.relayDevTunnelListen) return false;
bridge.relayDevTunnelListen(handler);
return true;
};
export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => {
getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message);
};
export const invokeDesktopCommand = async <TValue = unknown>(
command: string,