mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -50,6 +50,7 @@ The heart of Paseo. A Node.js process that:
|
|||||||
- Streams agent output in real time via a timeline model
|
- 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
|
- 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 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/`.
|
All paths are under `packages/server/src/`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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-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": "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: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": "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",
|
"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",
|
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
|
|||||||
import {
|
import {
|
||||||
HostRuntimeController,
|
HostRuntimeController,
|
||||||
HostRuntimeStore,
|
HostRuntimeStore,
|
||||||
|
readInitialDaemonConnectionHint,
|
||||||
type HostRuntimeControllerDeps,
|
type HostRuntimeControllerDeps,
|
||||||
|
type HostRuntimeStorage,
|
||||||
} from "./host-runtime";
|
} from "./host-runtime";
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
@@ -122,6 +124,8 @@ class FakeDaemonClient {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
delete (globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__;
|
||||||
|
delete (globalThis as { window?: unknown }).window;
|
||||||
});
|
});
|
||||||
|
|
||||||
function useHostRuntimeClock(): void {
|
function useHostRuntimeClock(): void {
|
||||||
@@ -312,6 +316,32 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
|||||||
return client;
|
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", () => {
|
describe("HostRuntimeController", () => {
|
||||||
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
||||||
const oldRelay: HostConnection = {
|
const oldRelay: HostConnection = {
|
||||||
@@ -1885,3 +1915,111 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readInitialDaemonConnectionHint", () => {
|
||||||
|
it("returns null when no hint is present", () => {
|
||||||
|
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a valid listen-only hint", () => {
|
||||||
|
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||||
|
listen: "localhost:6767",
|
||||||
|
};
|
||||||
|
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
|
||||||
|
listen: "localhost:6767",
|
||||||
|
useTls: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves useTls when explicitly true", () => {
|
||||||
|
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||||
|
listen: "paseo.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
};
|
||||||
|
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
|
||||||
|
listen: "paseo.example.com:443",
|
||||||
|
useTls: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid shapes", () => {
|
||||||
|
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = "localhost:6767";
|
||||||
|
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
|
||||||
|
useTls: true,
|
||||||
|
};
|
||||||
|
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||||
|
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
||||||
|
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ connection }) => {
|
||||||
|
if (connection.type === "directTcp") {
|
||||||
|
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: "srv_hint",
|
||||||
|
hostname: "hint host",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_test_runtime",
|
||||||
|
readInitialConnectionHint: () => ({
|
||||||
|
listen: "daemon-origin:6767",
|
||||||
|
useTls: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostAdded = onceHostListMatches(store, () => store.getHosts().length > 0);
|
||||||
|
store.boot();
|
||||||
|
await hostAdded;
|
||||||
|
|
||||||
|
expect(seenProbes).toContainEqual({ endpoint: "daemon-origin:6767", useTls: true });
|
||||||
|
const host = store.getHosts()[0];
|
||||||
|
expect(host?.serverId).toBe("srv_hint");
|
||||||
|
expect(host?.connections).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ endpoint: "daemon-origin:6767", useTls: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer window.location.host when no explicit hint is present", async () => {
|
||||||
|
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||||
|
const firstProbe = createDeferred<void>();
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ connection }) => {
|
||||||
|
if (connection.type === "directTcp") {
|
||||||
|
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
|
||||||
|
}
|
||||||
|
firstProbe.resolve();
|
||||||
|
throw new Error("probe unavailable");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_test_runtime",
|
||||||
|
readInitialConnectionHint: () => null,
|
||||||
|
},
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(globalThis as { window?: unknown }).window = {
|
||||||
|
location: { host: "metro-host:8081", protocol: "http:" },
|
||||||
|
};
|
||||||
|
store.boot();
|
||||||
|
await firstProbe.promise;
|
||||||
|
|
||||||
|
expect(seenProbes).not.toContainEqual(expect.objectContaining({ endpoint: "metro-host:8081" }));
|
||||||
|
expect(store.getHosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +137,12 @@ export interface HostRuntimeControllerDeps {
|
|||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
}>;
|
}>;
|
||||||
getClientId: () => Promise<string>;
|
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 {
|
export interface HostRuntimeStartOptions {
|
||||||
@@ -1273,6 +1280,37 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
|||||||
const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767";
|
const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767";
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
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 {
|
function readConfiguredLocalDaemonOverride(): string | null {
|
||||||
const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim();
|
const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim();
|
||||||
@@ -1314,9 +1352,11 @@ export class HostRuntimeStore {
|
|||||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||||
private bootStarted = false;
|
private bootStarted = false;
|
||||||
|
private storage: HostRuntimeStorage;
|
||||||
|
|
||||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) {
|
||||||
this.deps = input?.deps ?? createDefaultDeps();
|
this.deps = input?.deps ?? createDefaultDeps();
|
||||||
|
this.storage = input?.storage ?? AsyncStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Host registry ---
|
// --- Host registry ---
|
||||||
@@ -1354,7 +1394,7 @@ export class HostRuntimeStore {
|
|||||||
|
|
||||||
let isE2E: string | null = null;
|
let isE2E: string | null = null;
|
||||||
try {
|
try {
|
||||||
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
isE2E = await this.storage.getItem(E2E_STORAGE_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1366,6 +1406,16 @@ export class HostRuntimeStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialHint = this.deps.readInitialConnectionHint
|
||||||
|
? this.deps.readInitialConnectionHint()
|
||||||
|
: readInitialDaemonConnectionHint();
|
||||||
|
if (initialHint) {
|
||||||
|
const bootstrapped = await this.bootstrapInitialConnectionHint(initialHint);
|
||||||
|
if (bootstrapped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (override) {
|
if (override) {
|
||||||
this.bootstrapConfiguredOverride(override);
|
this.bootstrapConfiguredOverride(override);
|
||||||
} else {
|
} else {
|
||||||
@@ -1376,7 +1426,7 @@ export class HostRuntimeStore {
|
|||||||
private async loadFromStorage(): Promise<void> {
|
private async loadFromStorage(): Promise<void> {
|
||||||
let shouldPersistHosts = false;
|
let shouldPersistHosts = false;
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
return;
|
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 {
|
reconcileServerId(oldServerId: string, newServerId: string): void {
|
||||||
if (oldServerId === newServerId) {
|
if (oldServerId === newServerId) {
|
||||||
return;
|
return;
|
||||||
@@ -1746,7 +1827,7 @@ export class HostRuntimeStore {
|
|||||||
|
|
||||||
private async persistHosts(): Promise<void> {
|
private async persistHosts(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[HostRuntime] Failed to persist host registry", error);
|
console.error("[HostRuntime] Failed to persist host registry", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function createDaemonCommand(): Command {
|
|||||||
.option("--no-relay", "Disable relay on restarted daemon")
|
.option("--no-relay", "Disable relay on restarted daemon")
|
||||||
.option("--no-mcp", "Disable Agent MCP 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("--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(
|
.option(
|
||||||
"--hostnames <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
|
|||||||
@@ -153,6 +153,44 @@ describe("local daemon launch supervision", () => {
|
|||||||
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
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 () => {
|
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
|
||||||
const home = await createPaseoHome({
|
const home = await createPaseoHome({
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface DaemonStartOptions {
|
|||||||
relayUseTls?: boolean;
|
relayUseTls?: boolean;
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
injectMcp?: boolean;
|
injectMcp?: boolean;
|
||||||
|
webUi?: boolean;
|
||||||
hostnames?: string;
|
hostnames?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +139,12 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
|||||||
if (options.injectMcp === false) {
|
if (options.injectMcp === false) {
|
||||||
args.push("--no-inject-mcp");
|
args.push("--no-inject-mcp");
|
||||||
}
|
}
|
||||||
|
if (options.webUi === true) {
|
||||||
|
args.push("--web-ui");
|
||||||
|
}
|
||||||
|
if (options.webUi === false) {
|
||||||
|
args.push("--no-web-ui");
|
||||||
|
}
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
@@ -158,6 +165,12 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
|||||||
if (options.relayUseTls === true) {
|
if (options.relayUseTls === true) {
|
||||||
childEnv.PASEO_RELAY_USE_TLS = "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;
|
return childEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
|
|||||||
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
||||||
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
||||||
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : 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,
|
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function startCommand(): Command {
|
|||||||
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
|
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
|
||||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
.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(
|
.option(
|
||||||
"--hostnames <hosts>",
|
"--hostnames <hosts>",
|
||||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ files:
|
|||||||
- "!node_modules/@getpaseo/*/src/**"
|
- "!node_modules/@getpaseo/*/src/**"
|
||||||
- "!node_modules/@getpaseo/**/*.test.*"
|
- "!node_modules/@getpaseo/**/*.test.*"
|
||||||
- "!node_modules/@getpaseo/**/*.spec.*"
|
- "!node_modules/@getpaseo/**/*.spec.*"
|
||||||
|
- "!node_modules/@getpaseo/server/dist/server/web-ui/**"
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- dist/daemon/node-entrypoint-runner.js
|
- dist/daemon/node-entrypoint-runner.js
|
||||||
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
|
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ describe("daemon-manager commands", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
|
envOverlay: expect.objectContaining({ PASEO_WEB_UI_ENABLED: "false" }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
detached: true,
|
detached: true,
|
||||||
envMode: "internal",
|
envMode: "internal",
|
||||||
env: invocation.env,
|
env: invocation.env,
|
||||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1" },
|
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ describe("desktop packaging", () => {
|
|||||||
expect(config).toContain("!node_modules/@getpaseo/**/*.spec.*");
|
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
|
// electron-builder packs production dependencies declared in package.json into
|
||||||
// app.asar. Runtime code in runtime-paths.ts and bin/paseo dynamically resolves
|
// app.asar. Runtime code in runtime-paths.ts and bin/paseo dynamically resolves
|
||||||
// these workspace packages by string, so static analysis (TypeScript, Knip) cannot
|
// these workspace packages by string, so static analysis (TypeScript, Knip) cannot
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"build:clean": "npm run clean && npm run build",
|
"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: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');\"",
|
"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",
|
"start": "node dist/scripts/supervisor-entrypoint.js",
|
||||||
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
||||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ import {
|
|||||||
isAgentMcpRequestAuthorized,
|
isAgentMcpRequestAuthorized,
|
||||||
type DaemonAuthConfig,
|
type DaemonAuthConfig,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
import { createWebUiMiddleware } from "./web-ui.js";
|
||||||
|
|
||||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||||
const REDACTED_LOG_VALUE = "[redacted]";
|
const REDACTED_LOG_VALUE = "[redacted]";
|
||||||
@@ -345,6 +346,10 @@ export interface PaseoDaemonConfig {
|
|||||||
publicBaseUrl: string | null;
|
publicBaseUrl: string | null;
|
||||||
standaloneListen: string | null;
|
standaloneListen: string | null;
|
||||||
};
|
};
|
||||||
|
webUi?: {
|
||||||
|
enabled: boolean;
|
||||||
|
distDir: string | null;
|
||||||
|
};
|
||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
auth?: DaemonAuthConfig;
|
auth?: DaemonAuthConfig;
|
||||||
openai?: PaseoOpenAIConfig;
|
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(
|
export async function createPaseoDaemon(
|
||||||
config: PaseoDaemonConfig,
|
config: PaseoDaemonConfig,
|
||||||
rootLogger: Logger,
|
rootLogger: Logger,
|
||||||
@@ -568,6 +584,12 @@ export async function createPaseoDaemon(
|
|||||||
createTerminalActivityRouteHandler(terminalManager),
|
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(
|
app.use(
|
||||||
createRequireBearerMiddleware(config.auth, (context) => {
|
createRequireBearerMiddleware(config.auth, (context) => {
|
||||||
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
||||||
|
|||||||
148
packages/server/src/server/config-web-ui.test.ts
Normal file
148
packages/server/src/server/config-web-ui.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolvePaseoNodeEnv } from "./paseo-env.js";
|
import { resolvePaseoNodeEnv } from "./paseo-env.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
@@ -25,6 +26,18 @@ const DEFAULT_PORT = 6767;
|
|||||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
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 {
|
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -55,6 +68,7 @@ export type CliConfigOverrides = Partial<{
|
|||||||
relayUseTls: boolean;
|
relayUseTls: boolean;
|
||||||
mcpEnabled: boolean;
|
mcpEnabled: boolean;
|
||||||
mcpInjectIntoAgents: boolean;
|
mcpInjectIntoAgents: boolean;
|
||||||
|
webUiEnabled: boolean;
|
||||||
hostnames: HostnamesConfig;
|
hostnames: HostnamesConfig;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -238,6 +252,33 @@ function resolveServiceProxyConfig(
|
|||||||
return { publicBaseUrl, standaloneListen };
|
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(
|
function resolveVoiceLlmConfig(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||||
@@ -365,6 +406,7 @@ export function loadConfig(
|
|||||||
cliRelayUseTls: options?.cli?.relayUseTls,
|
cliRelayUseTls: options?.cli?.relayUseTls,
|
||||||
});
|
});
|
||||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||||
|
const webUi = resolveWebUiConfig(paseoHome, env, options?.cli, persisted);
|
||||||
|
|
||||||
const { openai, speech } = resolveSpeechConfig({
|
const { openai, speech } = resolveSpeechConfig({
|
||||||
paseoHome,
|
paseoHome,
|
||||||
@@ -400,6 +442,7 @@ export function loadConfig(
|
|||||||
relayUseTls: relay.useTls,
|
relayUseTls: relay.useTls,
|
||||||
relayPublicUseTls: relay.publicUseTls,
|
relayPublicUseTls: relay.publicUseTls,
|
||||||
serviceProxy,
|
serviceProxy,
|
||||||
|
webUi,
|
||||||
appBaseUrl,
|
appBaseUrl,
|
||||||
auth: resolveAuthConfig(env, persisted),
|
auth: resolveAuthConfig(env, persisted),
|
||||||
openai,
|
openai,
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
|||||||
if (process.argv.includes("--no-inject-mcp")) {
|
if (process.argv.includes("--no-inject-mcp")) {
|
||||||
config.mcpInjectIntoAgents = false;
|
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() {
|
async function main() {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("PersistedConfigSchema worktrees config", () => {
|
||||||
test("accepts optional worktree root", () => {
|
test("accepts optional worktree root", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ const FeatureVoiceModeSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const FeatureWebUiSchema = z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
distDir: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const StructuredGenerationProviderConfigSchema = z
|
const StructuredGenerationProviderConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
provider: z.string().min(1),
|
provider: z.string().min(1),
|
||||||
@@ -291,6 +298,7 @@ export const PersistedConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
dictation: FeatureDictationSchema.optional(),
|
dictation: FeatureDictationSchema.optional(),
|
||||||
voiceMode: FeatureVoiceModeSchema.optional(),
|
voiceMode: FeatureVoiceModeSchema.optional(),
|
||||||
|
webUi: FeatureWebUiSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
306
packages/server/src/server/web-ui.test.ts
Normal file
306
packages/server/src/server/web-ui.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
271
packages/server/src/server/web-ui.ts
Normal file
271
packages/server/src/server/web-ui.ts
Normal 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;
|
||||||
|
}
|
||||||
141
scripts/build-daemon-web-ui.mjs
Normal file
141
scripts/build-daemon-web-ui.mjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user