fix: support dev server previews over relay
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user