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:
Mohamed Boudra
2026-06-26 12:00:11 +08:00
committed by GitHub
parent 3d6b4adc68
commit 4e2c06ec71
24 changed files with 1302 additions and 6 deletions

View File

@@ -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/`.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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 <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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 <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',

View File

@@ -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/**/*

View File

@@ -369,6 +369,7 @@ describe("daemon-manager commands", () => {
expect.objectContaining({
detached: true,
stdio: ["ignore", "ignore", "ignore"],
envOverlay: expect.objectContaining({ PASEO_WEB_UI_ENABLED: "false" }),
}),
);
});

View File

@@ -350,7 +350,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
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"],
});

View File

@@ -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

View File

@@ -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",

View File

@@ -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");

View File

@@ -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<string> {
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));
});
});

View File

@@ -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<typeof loadPersistedConfig>,
): 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<typeof loadPersistedConfig>,
@@ -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,

View File

@@ -94,6 +94,12 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): 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() {

View File

@@ -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({

View File

@@ -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(),

View File

@@ -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<string, string | string[] | undefined>;
body: string;
}
async function readResponseBody(res: http.IncomingMessage): Promise<string> {
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<string, string> = {},
): Promise<ResponseSnapshot> {
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<http.IncomingMessage>((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<void>((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"),
"<!DOCTYPE html><html><head></head><body>app</body></html>",
);
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</script><script>alert(1)</script>",
});
expect(res.status).toBe(200);
expect(res.body).toContain("evil.test\\u003C/script\\u003E");
expect(res.body).not.toContain("evil.test</script>");
});
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("");
});
});

View File

@@ -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<string, string> = {
".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<typeof statSync> | 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<RequestHandler>[0];
res: Parameters<RequestHandler>[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<RequestHandler>[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, "\\u003C")
.replace(/>/g, "\\u003E")
.replace(/&/g, "\\u0026");
}
function injectConnectionHint(
html: string,
req: Parameters<RequestHandler>[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 = `<script>window.__PASEO_INITIAL_DAEMON_CONNECTION__=${serializeInlineScriptJson(hint)}</script>`;
const headClose = /<\/head>/i;
if (headClose.test(html)) {
return html.replace(headClose, `${script}</head>`);
}
return script + html;
}

View File

@@ -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);
});