Serve the web client from the daemon (#1635)
* Serve the web client from the daemon Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer. * Escape daemon web UI bootstrap hint * Fix bundled web UI dist path
This commit is contained in:
@@ -11,7 +11,9 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
readInitialDaemonConnectionHint,
|
||||
type HostRuntimeControllerDeps,
|
||||
type HostRuntimeStorage,
|
||||
} from "./host-runtime";
|
||||
|
||||
class FakeDaemonClient {
|
||||
@@ -122,6 +124,8 @@ class FakeDaemonClient {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
function useHostRuntimeClock(): void {
|
||||
@@ -312,6 +316,32 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
|
||||
const values = new Map(Object.entries(entries));
|
||||
return {
|
||||
getItem: async (key) => values.get(key) ?? null,
|
||||
setItem: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise<void> {
|
||||
if (predicate()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let unsubscribe = (): void => {};
|
||||
unsubscribe = store.subscribeHostList(() => {
|
||||
if (!predicate()) {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
||||
const oldRelay: HostConnection = {
|
||||
@@ -1885,3 +1915,111 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInitialDaemonConnectionHint", () => {
|
||||
it("returns null when no hint is present", () => {
|
||||
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a valid listen-only hint", () => {
|
||||
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||
listen: "localhost:6767",
|
||||
};
|
||||
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
|
||||
listen: "localhost:6767",
|
||||
useTls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves useTls when explicitly true", () => {
|
||||
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||
listen: "paseo.example.com:443",
|
||||
useTls: true,
|
||||
};
|
||||
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
|
||||
listen: "paseo.example.com:443",
|
||||
useTls: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid shapes", () => {
|
||||
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = "localhost:6767";
|
||||
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||
|
||||
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||
useTls: true,
|
||||
};
|
||||
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
||||
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ connection }) => {
|
||||
if (connection.type === "directTcp") {
|
||||
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
|
||||
}
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: "srv_hint",
|
||||
hostname: "hint host",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
readInitialConnectionHint: () => ({
|
||||
listen: "daemon-origin:6767",
|
||||
useTls: true,
|
||||
}),
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
|
||||
const hostAdded = onceHostListMatches(store, () => store.getHosts().length > 0);
|
||||
store.boot();
|
||||
await hostAdded;
|
||||
|
||||
expect(seenProbes).toContainEqual({ endpoint: "daemon-origin:6767", useTls: true });
|
||||
const host = store.getHosts()[0];
|
||||
expect(host?.serverId).toBe("srv_hint");
|
||||
expect(host?.connections).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ endpoint: "daemon-origin:6767", useTls: true }),
|
||||
]),
|
||||
);
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("does not infer window.location.host when no explicit hint is present", async () => {
|
||||
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||
const firstProbe = createDeferred<void>();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ connection }) => {
|
||||
if (connection.type === "directTcp") {
|
||||
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
|
||||
}
|
||||
firstProbe.resolve();
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
location: { host: "metro-host:8081", protocol: "http:" },
|
||||
};
|
||||
store.boot();
|
||||
await firstProbe.promise;
|
||||
|
||||
expect(seenProbes).not.toContainEqual(expect.objectContaining({ endpoint: "metro-host:8081" }));
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import {
|
||||
@@ -136,6 +137,12 @@ export interface HostRuntimeControllerDeps {
|
||||
hostname: string | null;
|
||||
}>;
|
||||
getClientId: () => Promise<string>;
|
||||
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
|
||||
}
|
||||
|
||||
export interface HostRuntimeStorage {
|
||||
getItem: (key: string) => Promise<string | null>;
|
||||
setItem: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostRuntimeStartOptions {
|
||||
@@ -1273,6 +1280,37 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
const INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY = "__PASEO_INITIAL_DAEMON_CONNECTION__";
|
||||
|
||||
export interface InitialDaemonConnectionHint {
|
||||
listen: string;
|
||||
useTls?: boolean;
|
||||
}
|
||||
|
||||
function isInitialConnectionHintRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function readInitialDaemonConnectionHint(input?: {
|
||||
isWebRuntime?: boolean;
|
||||
}): InitialDaemonConnectionHint | null {
|
||||
const isWebRuntime = input?.isWebRuntime ?? isWeb;
|
||||
if (!isWebRuntime || typeof globalThis === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const value = (globalThis as Record<string, unknown>)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY];
|
||||
if (!isInitialConnectionHintRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const listen = typeof value.listen === "string" ? value.listen.trim() : "";
|
||||
if (!listen) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
listen,
|
||||
useTls: value.useTls === true,
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredLocalDaemonOverride(): string | null {
|
||||
const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim();
|
||||
@@ -1314,9 +1352,11 @@ export class HostRuntimeStore {
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootStarted = false;
|
||||
private storage: HostRuntimeStorage;
|
||||
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
}
|
||||
|
||||
// --- Host registry ---
|
||||
@@ -1354,7 +1394,7 @@ export class HostRuntimeStore {
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
isE2E = await this.storage.getItem(E2E_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -1366,6 +1406,16 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialHint = this.deps.readInitialConnectionHint
|
||||
? this.deps.readInitialConnectionHint()
|
||||
: readInitialDaemonConnectionHint();
|
||||
if (initialHint) {
|
||||
const bootstrapped = await this.bootstrapInitialConnectionHint(initialHint);
|
||||
if (bootstrapped) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (override) {
|
||||
this.bootstrapConfiguredOverride(override);
|
||||
} else {
|
||||
@@ -1376,7 +1426,7 @@ export class HostRuntimeStore {
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
let shouldPersistHosts = false;
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
@@ -1469,6 +1519,37 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapInitialConnectionHint(
|
||||
hint: InitialDaemonConnectionHint,
|
||||
): Promise<boolean> {
|
||||
const connection = connectionFromListen(hint.listen);
|
||||
if (!connection) {
|
||||
return false;
|
||||
}
|
||||
const connectionWithHint: HostConnection =
|
||||
connection.type === "directTcp"
|
||||
? { ...connection, useTls: hint.useTls ?? connection.useTls ?? false }
|
||||
: connection;
|
||||
if (registryHasConnection(this.hosts, connectionWithHint)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.probeAndUpsertConnection({
|
||||
connection: connectionWithHint,
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] initial connection hint probe failed", {
|
||||
listen: hint.listen,
|
||||
useTls: hint.useTls,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
reconcileServerId(oldServerId: string, newServerId: string): void {
|
||||
if (oldServerId === newServerId) {
|
||||
return;
|
||||
@@ -1746,7 +1827,7 @@ export class HostRuntimeStore {
|
||||
|
||||
private async persistHosts(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
||||
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
||||
} catch (error) {
|
||||
console.error("[HostRuntime] Failed to persist host registry", error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user