diff --git a/docs/architecture.md b/docs/architecture.md index c1d3389dc..3f89e4502 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,7 @@ The heart of Paseo. A Node.js process that: - Streams agent output in real time via a timeline model - Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter - Optionally connects outbound to a relay for remote access +- Optionally serves the browser web client from the same HTTP server All paths are under `packages/server/src/`. diff --git a/docs/development.md b/docs/development.md index fba9b95a3..8864c7138 100644 --- a/docs/development.md +++ b/docs/development.md @@ -236,6 +236,54 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l } ``` +## Bundled daemon web UI + +The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default. + +Enable it for a running daemon with: + +```bash +paseo daemon start --web-ui +``` + +Or set the environment variable: + +```bash +PASEO_WEB_UI_ENABLED=true paseo daemon start +``` + +Or persist it in `config.json`: + +```json +{ + "features": { + "webUi": { + "enabled": true + } + } +} +``` + +When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth. + +The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step. + +Build the artifact for packaging or measurement with: + +```bash +npm run build:daemon-web-ui +``` + +This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`. + +Measured bundle size for a standard Expo web export: + +- raw: 10.77 MiB +- gzip: 2.55 MiB +- brotli: 1.93 MiB + +The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app. + ## Built workspace packages Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths. diff --git a/package.json b/package.json index 34e1b5259..aa223beb7 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", + "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", "build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", "build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", "watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput", diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 6f9468966..a89457133 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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).__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 = {}): 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 { + 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).__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).__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).__PASEO_INITIAL_DAEMON_CONNECTION__ = "localhost:6767"; + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull(); + + (globalThis as Record).__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(); + 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); + }); +}); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 672642fdb..e1c6e27c3 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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; + readInitialConnectionHint?: () => InitialDaemonConnectionHint | null; +} + +export interface HostRuntimeStorage { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; } 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 { + 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)[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>(); private configuredOverrideBootstrapInFlight: Promise | 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 { 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 { + 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 { 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); } diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 7eb0a91ca..82547cdbd 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -43,6 +43,8 @@ export function createDaemonCommand(): Command { .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") + .option("--web-ui", "Enable the bundled daemon web UI on restarted daemon") + .option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon") .option( "--hostnames ", 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts index 9b51eec51..f8dd7dc4e 100644 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -153,6 +153,44 @@ describe("local daemon launch supervision", () => { expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); }); + test("web UI flag is passed to the supervised daemon", async () => { + const runtime = new FakeDaemonRuntime(); + + const status = startLocalDaemonForeground( + { + home: "/tmp/paseo-test", + webUi: true, + }, + runtime, + ); + + expect(status).toBe(0); + expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); + const launch = runtime.recordedLaunches[0]; + expect(launch?.mode).toBe("foreground"); + expect(launch?.args).toContain("--web-ui"); + expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true"); + }); + + test("no-web UI flag is passed to the supervised daemon", async () => { + const runtime = new FakeDaemonRuntime(); + + const status = startLocalDaemonForeground( + { + home: "/tmp/paseo-test", + webUi: false, + }, + runtime, + ); + + expect(status).toBe(0); + expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); + const launch = runtime.recordedLaunches[0]; + expect(launch?.mode).toBe("foreground"); + expect(launch?.args).toContain("--no-web-ui"); + expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false"); + }); + test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => { const home = await createPaseoHome({ version: 1, diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 3968d3eed..f5c17b4ae 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -15,6 +15,7 @@ export interface DaemonStartOptions { relayUseTls?: boolean; mcp?: boolean; injectMcp?: boolean; + webUi?: boolean; hostnames?: string; } @@ -138,6 +139,12 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] { if (options.injectMcp === false) { args.push("--no-inject-mcp"); } + if (options.webUi === true) { + args.push("--web-ui"); + } + if (options.webUi === false) { + args.push("--no-web-ui"); + } return args; } @@ -158,6 +165,12 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { if (options.relayUseTls === true) { childEnv.PASEO_RELAY_USE_TLS = "true"; } + if (options.webUi === true) { + childEnv.PASEO_WEB_UI_ENABLED = "true"; + } + if (options.webUi === false) { + childEnv.PASEO_WEB_UI_ENABLED = "false"; + } return childEnv; } diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 741057f6a..94bf1e098 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -61,6 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions { relay: typeof options.relay === "boolean" ? options.relay : undefined, mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, + webUi: typeof options.webUi === "boolean" ? options.webUi : undefined, hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, }; diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 65d3e7834..abb38fc1e 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -24,6 +24,8 @@ export function startCommand(): Command { .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") + .option("--web-ui", "Enable the bundled daemon web UI") + .option("--no-web-ui", "Disable the bundled daemon web UI") .option( "--hostnames ", 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 000909477..c0534e146 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -12,6 +12,7 @@ files: - "!node_modules/@getpaseo/*/src/**" - "!node_modules/@getpaseo/**/*.test.*" - "!node_modules/@getpaseo/**/*.spec.*" + - "!node_modules/@getpaseo/server/dist/server/web-ui/**" asarUnpack: - dist/daemon/node-entrypoint-runner.js - node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/* diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 0c5a1ee17..443e9f9fb 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -369,6 +369,7 @@ describe("daemon-manager commands", () => { expect.objectContaining({ detached: true, stdio: ["ignore", "ignore", "ignore"], + envOverlay: expect.objectContaining({ PASEO_WEB_UI_ENABLED: "false" }), }), ); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index c429acd0a..a80899076 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -350,7 +350,7 @@ async function startDaemon(): Promise { detached: true, envMode: "internal", env: invocation.env, - envOverlay: { PASEO_DESKTOP_MANAGED: "1" }, + envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" }, stdio: ["ignore", "ignore", "ignore"], }); diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index e1220d82a..8da28cc75 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -26,6 +26,12 @@ describe("desktop packaging", () => { expect(config).toContain("!node_modules/@getpaseo/**/*.spec.*"); }); + it("excludes the bundled daemon web UI from the packaged app", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**"); + }); + // electron-builder packs production dependencies declared in package.json into // app.asar. Runtime code in runtime-paths.ts and bin/paseo dynamically resolves // these workspace packages by string, so static analysis (TypeScript, Knip) cannot diff --git a/packages/server/package.json b/packages/server/package.json index a9bfa4e19..be47d73e8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,7 +40,7 @@ "build:clean": "npm run clean && npm run build", "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", - "prepack": "npm run build:clean", + "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", "typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 23034898e..3816cec18 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -159,6 +159,7 @@ import { isAgentMcpRequestAuthorized, type DaemonAuthConfig, } from "./auth.js"; +import { createWebUiMiddleware } from "./web-ui.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -345,6 +346,10 @@ export interface PaseoDaemonConfig { publicBaseUrl: string | null; standaloneListen: string | null; }; + webUi?: { + enabled: boolean; + distDir: string | null; + }; appBaseUrl?: string; auth?: DaemonAuthConfig; openai?: PaseoOpenAIConfig; @@ -407,6 +412,17 @@ async function reconcileManagedProcessLedger( } } +function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger: Logger): void { + app.use( + createWebUiMiddleware({ + enabled: config.webUi?.enabled ?? false, + distDir: config.webUi?.distDir ?? null, + label: getHostname(), + logger, + }), + ); +} + export async function createPaseoDaemon( config: PaseoDaemonConfig, rootLogger: Logger, @@ -568,6 +584,12 @@ export async function createPaseoDaemon( createTerminalActivityRouteHandler(terminalManager), ); + // Serve the bundled browser web UI when enabled. Mounted after service-proxy + // classification and host/CORS handling, but before daemon bearer auth, so + // static app files load without the daemon password while API/WebSocket calls + // remain protected. + mountWebUi(app, config, logger); + app.use( createRequireBearerMiddleware(config.auth, (context) => { logger.warn(context, "Rejected HTTP request with invalid daemon password"); diff --git a/packages/server/src/server/config-web-ui.test.ts b/packages/server/src/server/config-web-ui.test.ts new file mode 100644 index 000000000..3640ff645 --- /dev/null +++ b/packages/server/src/server/config-web-ui.test.ts @@ -0,0 +1,148 @@ +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, test } from "vitest"; + +import { loadConfig, resolveBundledWebUiDistDir } from "./config.js"; + +const roots: string[] = []; + +async function createPaseoHome(config: unknown): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-web-ui-")); + roots.push(root); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); + return paseoHome; +} + +function expectBundledWebUiDistDir(distDir: string | null): void { + expect(distDir).not.toBeNull(); + expect(path.isAbsolute(distDir ?? "")).toBe(true); + expect(distDir?.endsWith(path.join("packages", "server", "dist", "server", "web-ui"))).toBe(true); +} + +describe("daemon web UI config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("web UI is disabled by default", async () => { + const home = await createPaseoHome({ version: 1 }); + + const config = loadConfig(home, { env: {} }); + + expect(config.webUi.enabled).toBe(false); + expectBundledWebUiDistDir(config.webUi.distDir); + }); + + test("enables web UI from persisted config", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { enabled: true } }, + }); + + const config = loadConfig(home, { env: {} }); + + expect(config.webUi.enabled).toBe(true); + expectBundledWebUiDistDir(config.webUi.distDir); + }); + + test("resolves bundled web UI dist dir from TypeScript source modules", () => { + const packageRoot = path.join(os.tmpdir(), "paseo-config-web-ui-source-package"); + const moduleUrl = pathToFileURL(path.join(packageRoot, "src", "server", "config.ts")); + + expect(resolveBundledWebUiDistDir(moduleUrl)).toBe( + path.join(packageRoot, "dist", "server", "web-ui"), + ); + }); + + test("resolves bundled web UI dist dir from compiled server modules", () => { + const packageRoot = path.join(os.tmpdir(), "paseo-config-web-ui-dist-package"); + const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "server", "config.js")); + + expect(resolveBundledWebUiDistDir(moduleUrl)).toBe( + path.join(packageRoot, "dist", "server", "web-ui"), + ); + }); + + test("PASEO_WEB_UI_ENABLED overrides persisted setting", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { enabled: true } }, + }); + + const config = loadConfig(home, { env: { PASEO_WEB_UI_ENABLED: "false" } }); + + expect(config.webUi.enabled).toBe(false); + }); + + test("PASEO_WEB_UI_ENABLED=true enables web UI", async () => { + const home = await createPaseoHome({ version: 1 }); + + const config = loadConfig(home, { env: { PASEO_WEB_UI_ENABLED: "true" } }); + + expect(config.webUi.enabled).toBe(true); + }); + + test("CLI web UI enable override wins over env and persisted config", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { enabled: false } }, + }); + + const config = loadConfig(home, { + env: { PASEO_WEB_UI_ENABLED: "false" }, + cli: { webUiEnabled: true }, + }); + + expect(config.webUi.enabled).toBe(true); + }); + + test("CLI web UI disable override wins over env and persisted config", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { enabled: true } }, + }); + + const config = loadConfig(home, { + env: { PASEO_WEB_UI_ENABLED: "true" }, + cli: { webUiEnabled: false }, + }); + + expect(config.webUi.enabled).toBe(false); + }); + + test("resolves PASEO_WEB_UI_DIST_DIR as absolute path", async () => { + const home = await createPaseoHome({ version: 1 }); + const distDir = path.join(os.tmpdir(), "paseo-web-ui-dist"); + + const config = loadConfig(home, { env: { PASEO_WEB_UI_DIST_DIR: distDir } }); + + expect(config.webUi.distDir).toBe(path.resolve(distDir)); + }); + + test("resolves relative persisted distDir against PASEO_HOME", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { distDir: "web-ui-dist" } }, + }); + + const config = loadConfig(home, { env: {} }); + + expect(config.webUi.distDir).toBe(path.join(home, "web-ui-dist")); + }); + + test("PASEO_WEB_UI_DIST_DIR overrides persisted distDir", async () => { + const home = await createPaseoHome({ + version: 1, + features: { webUi: { distDir: "/persisted/dist" } }, + }); + const envDir = path.join(os.tmpdir(), "env-dist"); + + const config = loadConfig(home, { env: { PASEO_WEB_UI_DIST_DIR: envDir } }); + + expect(config.webUi.distDir).toBe(path.resolve(envDir)); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index ee4af864d..e567eb4e1 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import { resolvePaseoNodeEnv } from "./paseo-env.js"; import { z } from "zod"; import { expandTilde } from "../utils/path.js"; @@ -25,6 +26,18 @@ const DEFAULT_PORT = 6767; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; +export function resolveBundledWebUiDistDir(moduleUrl: string | URL = import.meta.url): string { + const moduleDir = path.dirname(fileURLToPath(moduleUrl)); + + if (path.basename(moduleDir) === "server" && path.basename(path.dirname(moduleDir)) === "src") { + return path.resolve(moduleDir, "..", "..", "dist", "server", "web-ui"); + } + + return path.resolve(moduleDir, "web-ui"); +} + +const BUNDLED_WEB_UI_DIST_DIR = resolveBundledWebUiDistDir(); + function parseBooleanEnv(value: string | undefined): boolean | undefined { if (value === undefined) { return undefined; @@ -55,6 +68,7 @@ export type CliConfigOverrides = Partial<{ relayUseTls: boolean; mcpEnabled: boolean; mcpInjectIntoAgents: boolean; + webUiEnabled: boolean; hostnames: HostnamesConfig; }>; @@ -238,6 +252,33 @@ function resolveServiceProxyConfig( return { publicBaseUrl, standaloneListen }; } +interface ResolvedWebUi { + enabled: boolean; + distDir: string | null; +} + +function resolveWebUiConfig( + paseoHome: string, + env: NodeJS.ProcessEnv, + cli: CliConfigOverrides | undefined, + persisted: ReturnType, +): ResolvedWebUi { + const enabled = + cli?.webUiEnabled ?? + parseBooleanEnv(env.PASEO_WEB_UI_ENABLED) ?? + persisted.features?.webUi?.enabled ?? + false; + const rawDistDir = env.PASEO_WEB_UI_DIST_DIR ?? persisted.features?.webUi?.distDir; + const trimmedDistDir = rawDistDir?.trim(); + const distDir = trimmedDistDir + ? path.resolve(path.isAbsolute(trimmedDistDir) ? trimmedDistDir : paseoHome, trimmedDistDir) + : BUNDLED_WEB_UI_DIST_DIR; + return { + enabled, + distDir, + }; +} + function resolveVoiceLlmConfig( env: NodeJS.ProcessEnv, persisted: ReturnType, @@ -365,6 +406,7 @@ export function loadConfig( cliRelayUseTls: options?.cli?.relayUseTls, }); const serviceProxy = resolveServiceProxyConfig(env, persisted); + const webUi = resolveWebUiConfig(paseoHome, env, options?.cli, persisted); const { openai, speech } = resolveSpeechConfig({ paseoHome, @@ -400,6 +442,7 @@ export function loadConfig( relayUseTls: relay.useTls, relayPublicUseTls: relay.publicUseTls, serviceProxy, + webUi, appBaseUrl, auth: resolveAuthConfig(env, persisted), openai, diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index b84e608ef..188b9b35e 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -94,6 +94,12 @@ function applyCliFlagOverrides(config: ReturnType): void { if (process.argv.includes("--no-inject-mcp")) { config.mcpInjectIntoAgents = false; } + if (process.argv.includes("--web-ui")) { + config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: true }; + } + if (process.argv.includes("--no-web-ui")) { + config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: false }; + } } async function main() { diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 2269bf51b..94571d3ce 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -63,6 +63,24 @@ describe("PersistedConfigSchema daemon relay config", () => { }); }); +describe("PersistedConfigSchema daemon web UI feature config", () => { + test("accepts optional web UI enable flag and dist dir", () => { + const parsed = PersistedConfigSchema.parse({ + features: { + webUi: { + enabled: true, + distDir: "web-ui-dist", + }, + }, + }); + + expect(parsed.features?.webUi).toEqual({ + enabled: true, + distDir: "web-ui-dist", + }); + }); +}); + describe("PersistedConfigSchema worktrees config", () => { test("accepts optional worktree root", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 51fa73eb8..b141465fd 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -147,6 +147,13 @@ const FeatureVoiceModeSchema = z }) .strict(); +const FeatureWebUiSchema = z + .object({ + enabled: z.boolean().optional(), + distDir: z.string().min(1).optional(), + }) + .strict(); + const StructuredGenerationProviderConfigSchema = z .object({ provider: z.string().min(1), @@ -291,6 +298,7 @@ export const PersistedConfigSchema = z .object({ dictation: FeatureDictationSchema.optional(), voiceMode: FeatureVoiceModeSchema.optional(), + webUi: FeatureWebUiSchema.optional(), }) .strict() .optional(), diff --git a/packages/server/src/server/web-ui.test.ts b/packages/server/src/server/web-ui.test.ts new file mode 100644 index 000000000..2e1f6ba66 --- /dev/null +++ b/packages/server/src/server/web-ui.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { once } from "node:events"; +import express from "express"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import pino from "pino"; + +import { createWebUiMiddleware } from "./web-ui.js"; + +const logger = pino({ level: "silent" }); + +interface ResponseSnapshot { + status: number; + headers: Record; + body: string; +} + +async function readResponseBody(res: http.IncomingMessage): Promise { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString("utf-8"); + }); + await once(res, "end"); + return body; +} + +async function request( + app: express.Application, + method: string, + requestPath: string, + headers: Record = {}, +): Promise { + const server = http.createServer(app); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Server did not bind to a TCP port"); + } + + try { + const res = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: address.port, + path: requestPath, + method, + headers: { host: `localhost:${address.port}`, ...headers }, + }, + resolve, + ); + req.on("error", reject); + req.end(); + }); + + const body = await readResponseBody(res); + return { + status: res.statusCode ?? 0, + headers: res.headers, + body, + }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +function createApp(options: { + enabled: boolean; + distDir: string | null; + publicDir?: string; +}): express.Application { + const app = express(); + app.use( + createWebUiMiddleware({ + enabled: options.enabled, + distDir: options.distDir, + label: "test-label", + logger, + }), + ); + app.get("/api/health", (_req, res) => res.json({ ok: true })); + app.get("/mcp/agents", (_req, res) => res.json({ mcp: true })); + if (options.publicDir) { + app.use("/public", express.static(options.publicDir)); + } + return app; +} + +describe("daemon web UI route module", () => { + let tempRoot: string; + let distDir: string; + let publicDir: string; + + beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-web-ui-")); + distDir = path.join(tempRoot, "dist"); + publicDir = path.join(tempRoot, "public"); + await mkdir(path.join(distDir, "_expo", "static", "js", "web"), { recursive: true }); + await mkdir(publicDir, { recursive: true }); + + await writeFile( + path.join(distDir, "index.html"), + "app", + ); + await writeFile( + path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js"), + "console.log('uncompressed');", + ); + await writeFile( + path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js.br"), + "console.log('brotli');", + ); + await writeFile( + path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js.gz"), + "console.log('gzip');", + ); + await writeFile(path.join(distDir, "styles.css"), "body { color: red; }"); + await writeFile(path.join(publicDir, "asset.txt"), "public asset"); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + test("returns 404 when web UI is disabled", async () => { + const app = createApp({ enabled: false, distDir, publicDir }); + + const res = await request(app, "GET", "/"); + + expect(res.status).toBe(404); + expect(res.body).toBe(""); + }); + + test("returns 404 when dist directory is missing", async () => { + const app = createApp({ enabled: true, distDir: path.join(tempRoot, "missing"), publicDir }); + + const res = await request(app, "GET", "/"); + + expect(res.status).toBe(404); + }); + + test("serves index.html with injected initial connection hint", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/html; charset=utf-8"); + expect(res.body).toContain("window.__PASEO_INITIAL_DAEMON_CONNECTION__"); + expect(res.body).toContain('"listen":"localhost:'); + expect(res.body).toContain('"useTls":false'); + expect(res.body).toContain('"label":"test-label"'); + }); + + test("injects hint before closing head tag", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/index.html"); + + expect(res.body).toMatch(/window\.__PASEO_INITIAL_DAEMON_CONNECTION__.*<\/head>/); + }); + + test("escapes the injected host hint for inline script safety", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/", { + host: "evil.test", + }); + + expect(res.status).toBe(200); + expect(res.body).toContain("evil.test\\u003C/script\\u003E"); + expect(res.body).not.toContain("evil.test"); + }); + + test("falls back to index.html for SPA deep links", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/h/some-server-id/agent/123"); + + expect(res.status).toBe(200); + expect(res.body).toContain("window.__PASEO_INITIAL_DAEMON_CONNECTION__"); + expect(res.body).toContain("app"); + }); + + test("serves static assets", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/styles.css"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/css; charset=utf-8"); + expect(res.body).toBe("body { color: red; }"); + }); + + test("selects brotli precompressed asset when accepted", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js", { + "accept-encoding": "br, gzip", + }); + + expect(res.status).toBe(200); + expect(res.headers["content-encoding"]).toBe("br"); + expect(res.headers["vary"]).toBe("Accept-Encoding"); + expect(res.body).toBe("console.log('brotli');"); + }); + + test("selects gzip precompressed asset when brotli is not accepted", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js", { + "accept-encoding": "gzip", + }); + + expect(res.status).toBe(200); + expect(res.headers["content-encoding"]).toBe("gzip"); + expect(res.body).toBe("console.log('gzip');"); + }); + + test("falls back to uncompressed asset when no encoding is accepted", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js"); + + expect(res.status).toBe(200); + expect(res.headers["content-encoding"]).toBeUndefined(); + expect(res.body).toBe("console.log('uncompressed');"); + }); + + test("does not catch /api/* paths", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/api/health"); + + expect(res.status).toBe(200); + expect(res.body).toBe('{"ok":true}'); + }); + + test("does not catch /mcp/* paths", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/mcp/agents"); + + expect(res.status).toBe(200); + expect(res.body).toBe('{"mcp":true}'); + }); + + test("does not catch /public/* paths", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/public/asset.txt"); + + expect(res.status).toBe(200); + expect(res.body).toBe("public asset"); + }); + + test("sets no-cache headers for index.html", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/"); + + expect(res.headers["cache-control"]).toBe( + "no-store, no-cache, must-revalidate, proxy-revalidate", + ); + }); + + test("sets immutable caching for hashed assets", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js"); + + expect(res.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + }); + + test("sets no-cache for unhashed static assets", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "GET", "/styles.css"); + + expect(res.headers["cache-control"]).toBe("no-cache"); + }); + + test("only handles GET and HEAD requests", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + app.post("/", (_req, res) => res.json({ posted: true })); + + const res = await request(app, "POST", "/"); + + expect(res.status).toBe(200); + expect(res.body).toBe('{"posted":true}'); + }); + + test("responds to HEAD without a body", async () => { + const app = createApp({ enabled: true, distDir, publicDir }); + + const res = await request(app, "HEAD", "/"); + + expect(res.status).toBe(200); + expect(res.body).toBe(""); + }); +}); diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts new file mode 100644 index 000000000..e7135a9ea --- /dev/null +++ b/packages/server/src/server/web-ui.ts @@ -0,0 +1,271 @@ +import { createReadStream, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import type { RequestHandler, Response } from "express"; +import type { Logger } from "pino"; + +const EXCLUDED_PATH_PREFIXES = ["/api/", "/mcp/", "/public/"]; +const EXCLUDED_PATHS = new Set(["/api", "/mcp", "/public"]); + +function isExcludedPath(requestPath: string): boolean { + for (const prefix of EXCLUDED_PATH_PREFIXES) { + if (requestPath.startsWith(prefix)) { + return true; + } + } + return EXCLUDED_PATHS.has(requestPath); +} + +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".map": "application/json", +}; + +function getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +function selectEncoding(acceptEncoding: string | undefined): "br" | "gzip" | null { + if (!acceptEncoding) { + return null; + } + const normalized = acceptEncoding.toLowerCase(); + if (normalized.includes("br")) { + return "br"; + } + if (normalized.includes("gzip")) { + return "gzip"; + } + return null; +} + +function isHashedAsset(filePath: string): boolean { + const base = path.basename(filePath); + // Match content hashes like index-abc123def456.js or main.abc123def456.css. + return /[-.][0-9a-f]{16,}[-.]/i.test(base); +} + +function isInsideDir(targetPath: string, dirPath: string): boolean { + const resolvedDir = path.resolve(dirPath); + const resolvedTarget = path.resolve(targetPath); + return resolvedTarget === resolvedDir || resolvedTarget.startsWith(resolvedDir + path.sep); +} + +interface ResolvedTarget { + resolvedFile: string; + isIndexHtml: boolean; +} + +function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null { + const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); + let filePath = path.join(distDir, safePath); + + const stat = safeStat(filePath); + if (stat?.isDirectory()) { + filePath = path.join(filePath, "index.html"); + } + + const finalStat = safeStat(filePath); + if (!finalStat?.isFile()) { + filePath = path.join(distDir, "index.html"); + const fallbackStat = safeStat(filePath); + if (!fallbackStat?.isFile()) { + return null; + } + } + + if (!isInsideDir(filePath, distDir)) { + return null; + } + + const resolvedFile = path.resolve(filePath); + const isIndexHtml = path.basename(resolvedFile).toLowerCase() === "index.html"; + return { resolvedFile, isIndexHtml }; +} + +function safeStat(filePath: string): ReturnType | null { + try { + return statSync(filePath); + } catch { + return null; + } +} + +interface ContentEncodingResult { + finalFile: string; + contentEncoding: string | null; +} + +function resolveContentEncoding( + resolvedFile: string, + acceptEncoding: string | undefined, +): ContentEncodingResult { + const encoding = selectEncoding(acceptEncoding); + if (!encoding) { + return { finalFile: resolvedFile, contentEncoding: null }; + } + const compressedFile = `${resolvedFile}.${encoding === "br" ? "br" : "gz"}`; + const compressedStat = safeStat(compressedFile); + if (compressedStat?.isFile()) { + return { finalFile: compressedFile, contentEncoding: encoding }; + } + return { finalFile: resolvedFile, contentEncoding: null }; +} + +function setResponseCacheHeaders(res: Response, isIndexHtml: boolean, resolvedFile: string): void { + if (isIndexHtml) { + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } else if (isHashedAsset(resolvedFile)) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } else { + res.setHeader("Cache-Control", "no-cache"); + } +} + +export interface WebUiMiddlewareOptions { + enabled: boolean; + distDir: string | null; + label: string; + logger: Logger; +} + +export function createWebUiMiddleware(options: WebUiMiddlewareOptions): RequestHandler { + const { enabled, distDir, label, logger } = options; + const childLogger = logger.child({ module: "web-ui" }); + + if (!enabled || !distDir) { + childLogger.info( + { enabled, hasDistDir: !!distDir }, + "Daemon web UI disabled or missing dist directory", + ); + } else { + childLogger.info({ distDir }, "Daemon web UI mounted"); + } + + return (req, res, next) => { + if (req.method !== "GET" && req.method !== "HEAD") { + next(); + return; + } + + if (isExcludedPath(req.path)) { + next(); + return; + } + + if (!enabled || !distDir) { + res.status(404).end(); + return; + } + + serveWebUiFile({ distDir, requestPath: req.path, label, req, res }); + }; +} + +interface ServeWebUiFileOptions { + distDir: string; + requestPath: string; + label: string; + req: Parameters[0]; + res: Parameters[1]; +} + +function serveWebUiFile(options: ServeWebUiFileOptions): void { + const { distDir, requestPath, label, req, res } = options; + + const target = resolveTargetFile(distDir, requestPath); + if (!target) { + res.status(404).end(); + return; + } + + const { resolvedFile, isIndexHtml } = target; + const acceptEncoding = isIndexHtml ? undefined : req.headers["accept-encoding"]; + const { finalFile, contentEncoding } = resolveContentEncoding(resolvedFile, acceptEncoding); + + res.setHeader("Content-Type", getContentType(resolvedFile)); + if (contentEncoding) { + res.setHeader("Content-Encoding", contentEncoding); + res.setHeader("Vary", "Accept-Encoding"); + } + setResponseCacheHeaders(res, isIndexHtml, resolvedFile); + + if (req.method === "HEAD") { + res.status(200).end(); + return; + } + + if (isIndexHtml) { + sendIndexHtml(res, finalFile, req, label); + return; + } + + const stream = createReadStream(finalFile); + stream.on("error", () => { + if (!res.headersSent) { + res.status(500).end(); + } else { + res.end(); + } + }); + stream.pipe(res); +} + +function sendIndexHtml( + res: Response, + filePath: string, + req: Parameters[0], + label: string, +): void { + try { + const html = readFileSync(filePath, "utf-8"); + const injected = injectConnectionHint(html, req, label); + res.status(200).send(injected); + } catch { + res.status(500).end(); + } +} + +function serializeInlineScriptJson(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003E") + .replace(/&/g, "\\u0026"); +} + +function injectConnectionHint( + html: string, + req: Parameters[0], + label: string, +): string { + const host = typeof req.headers.host === "string" ? req.headers.host : ""; + const useTls = req.protocol === "https"; + const hint = { + listen: host, + useTls, + label, + }; + const script = ``; + const headClose = /<\/head>/i; + if (headClose.test(html)) { + return html.replace(headClose, `${script}`); + } + return script + html; +} diff --git a/scripts/build-daemon-web-ui.mjs b/scripts/build-daemon-web-ui.mjs new file mode 100644 index 000000000..bd026752e --- /dev/null +++ b/scripts/build-daemon-web-ui.mjs @@ -0,0 +1,141 @@ +import { spawn } from "node:child_process"; +import { createReadStream, createWriteStream } from "node:fs"; +import { cp, mkdir, readdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; +import { constants as zlibConstants, createBrotliCompress, createGzip } from "node:zlib"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); +const APP_DIR = path.join(REPO_ROOT, "packages", "app"); +const SOURCE_DIST = path.join(APP_DIR, "dist"); +const TARGET_DIST = path.join(REPO_ROOT, "packages", "server", "dist", "server", "web-ui"); +const COMPRESS_EXTENSIONS = new Set([".html", ".js", ".css", ".json", ".svg", ".map"]); + +function fmtMiB(bytes) { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; +} + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + shell: false, + ...options, + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(" ")}`)); + return; + } + resolve(); + }); + }); +} + +async function exportBrowserWebApp() { + console.log("Exporting browser web app..."); + await run("npm", ["run", "build:web", "--workspace=@getpaseo/app"], { + cwd: REPO_ROOT, + }); +} + +async function cleanTarget() { + console.log(`Cleaning ${path.relative(REPO_ROOT, TARGET_DIST)}...`); + await rm(TARGET_DIST, { recursive: true, force: true }); + await mkdir(TARGET_DIST, { recursive: true }); +} + +async function copyAssets() { + console.log(`Copying assets to ${path.relative(REPO_ROOT, TARGET_DIST)}...`); + await cp(SOURCE_DIST, TARGET_DIST, { recursive: true, force: true }); +} + +async function compressFile(filePath) { + const brotliPath = `${filePath}.br`; + const gzipPath = `${filePath}.gz`; + await Promise.all([ + pipeline( + createReadStream(filePath), + createBrotliCompress({ + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY, + }, + }), + createWriteStream(brotliPath), + ), + pipeline(createReadStream(filePath), createGzip(), createWriteStream(gzipPath)), + ]); +} + +async function precompressAssets(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = entries.filter((entry) => entry.isFile()); + const dirs = entries.filter((entry) => entry.isDirectory()); + + for (const file of files) { + const filePath = path.join(dir, file.name); + if (COMPRESS_EXTENSIONS.has(path.extname(file.name).toLowerCase())) { + await compressFile(filePath); + } + } + + for (const subdir of dirs) { + await precompressAssets(path.join(dir, subdir.name)); + } +} + +async function measureBundle(dir) { + let raw = 0; + let gzip = 0; + let brotli = 0; + + async function walk(current) { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(entryPath); + continue; + } + const info = await stat(entryPath); + const ext = path.extname(entry.name).toLowerCase(); + if (ext === ".br") { + brotli += info.size; + } else if (ext === ".gz") { + gzip += info.size; + } else { + raw += info.size; + } + } + } + + await walk(dir); + return { raw, gzip, brotli }; +} + +async function main() { + await exportBrowserWebApp(); + + const sourceStat = await stat(SOURCE_DIST).catch(() => null); + if (!sourceStat?.isDirectory()) { + throw new Error(`Browser web export not found at ${SOURCE_DIST}`); + } + + await cleanTarget(); + await copyAssets(); + await precompressAssets(TARGET_DIST); + + const sizes = await measureBundle(TARGET_DIST); + console.log("Daemon web UI bundle:"); + console.log(` raw: ${fmtMiB(sizes.raw)}`); + console.log(` gzip: ${fmtMiB(sizes.gzip)}`); + console.log(` brotli: ${fmtMiB(sizes.brotli)}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +});