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:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user