Serve the web client from the daemon (#1635)

* Serve the web client from the daemon

Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer.

* Escape daemon web UI bootstrap hint

* Fix bundled web UI dist path
This commit is contained in:
Mohamed Boudra
2026-06-26 12:00:11 +08:00
committed by GitHub
parent 3d6b4adc68
commit 4e2c06ec71
24 changed files with 1302 additions and 6 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
"build:clean": "npm run clean && npm run build",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build:clean",
"prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
"start": "node dist/scripts/supervisor-entrypoint.js",
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
+22
View File
@@ -159,6 +159,7 @@ import {
isAgentMcpRequestAuthorized,
type DaemonAuthConfig,
} from "./auth.js";
import { createWebUiMiddleware } from "./web-ui.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -345,6 +346,10 @@ export interface PaseoDaemonConfig {
publicBaseUrl: string | null;
standaloneListen: string | null;
};
webUi?: {
enabled: boolean;
distDir: string | null;
};
appBaseUrl?: string;
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
@@ -407,6 +412,17 @@ async function reconcileManagedProcessLedger(
}
}
function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger: Logger): void {
app.use(
createWebUiMiddleware({
enabled: config.webUi?.enabled ?? false,
distDir: config.webUi?.distDir ?? null,
label: getHostname(),
logger,
}),
);
}
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger,
@@ -568,6 +584,12 @@ export async function createPaseoDaemon(
createTerminalActivityRouteHandler(terminalManager),
);
// Serve the bundled browser web UI when enabled. Mounted after service-proxy
// classification and host/CORS handling, but before daemon bearer auth, so
// static app files load without the daemon password while API/WebSocket calls
// remain protected.
mountWebUi(app, config, logger);
app.use(
createRequireBearerMiddleware(config.auth, (context) => {
logger.warn(context, "Rejected HTTP request with invalid daemon password");
@@ -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));
});
});
+43
View File
@@ -1,4 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import { expandTilde } from "../utils/path.js";
@@ -25,6 +26,18 @@ const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
export function resolveBundledWebUiDistDir(moduleUrl: string | URL = import.meta.url): string {
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
if (path.basename(moduleDir) === "server" && path.basename(path.dirname(moduleDir)) === "src") {
return path.resolve(moduleDir, "..", "..", "dist", "server", "web-ui");
}
return path.resolve(moduleDir, "web-ui");
}
const BUNDLED_WEB_UI_DIST_DIR = resolveBundledWebUiDistDir();
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) {
return undefined;
@@ -55,6 +68,7 @@ export type CliConfigOverrides = Partial<{
relayUseTls: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
webUiEnabled: boolean;
hostnames: HostnamesConfig;
}>;
@@ -238,6 +252,33 @@ function resolveServiceProxyConfig(
return { publicBaseUrl, standaloneListen };
}
interface ResolvedWebUi {
enabled: boolean;
distDir: string | null;
}
function resolveWebUiConfig(
paseoHome: string,
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedWebUi {
const enabled =
cli?.webUiEnabled ??
parseBooleanEnv(env.PASEO_WEB_UI_ENABLED) ??
persisted.features?.webUi?.enabled ??
false;
const rawDistDir = env.PASEO_WEB_UI_DIST_DIR ?? persisted.features?.webUi?.distDir;
const trimmedDistDir = rawDistDir?.trim();
const distDir = trimmedDistDir
? path.resolve(path.isAbsolute(trimmedDistDir) ? trimmedDistDir : paseoHome, trimmedDistDir)
: BUNDLED_WEB_UI_DIST_DIR;
return {
enabled,
distDir,
};
}
function resolveVoiceLlmConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
@@ -365,6 +406,7 @@ export function loadConfig(
cliRelayUseTls: options?.cli?.relayUseTls,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, options?.cli, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
@@ -400,6 +442,7 @@ export function loadConfig(
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
serviceProxy,
webUi,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,
@@ -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(),
+306
View File
@@ -0,0 +1,306 @@
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import express from "express";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import { createWebUiMiddleware } from "./web-ui.js";
const logger = pino({ level: "silent" });
interface ResponseSnapshot {
status: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
async function readResponseBody(res: http.IncomingMessage): Promise<string> {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString("utf-8");
});
await once(res, "end");
return body;
}
async function request(
app: express.Application,
method: string,
requestPath: string,
headers: Record<string, string> = {},
): Promise<ResponseSnapshot> {
const server = http.createServer(app);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Server did not bind to a TCP port");
}
try {
const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port: address.port,
path: requestPath,
method,
headers: { host: `localhost:${address.port}`, ...headers },
},
resolve,
);
req.on("error", reject);
req.end();
});
const body = await readResponseBody(res);
return {
status: res.statusCode ?? 0,
headers: res.headers,
body,
};
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function createApp(options: {
enabled: boolean;
distDir: string | null;
publicDir?: string;
}): express.Application {
const app = express();
app.use(
createWebUiMiddleware({
enabled: options.enabled,
distDir: options.distDir,
label: "test-label",
logger,
}),
);
app.get("/api/health", (_req, res) => res.json({ ok: true }));
app.get("/mcp/agents", (_req, res) => res.json({ mcp: true }));
if (options.publicDir) {
app.use("/public", express.static(options.publicDir));
}
return app;
}
describe("daemon web UI route module", () => {
let tempRoot: string;
let distDir: string;
let publicDir: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-web-ui-"));
distDir = path.join(tempRoot, "dist");
publicDir = path.join(tempRoot, "public");
await mkdir(path.join(distDir, "_expo", "static", "js", "web"), { recursive: true });
await mkdir(publicDir, { recursive: true });
await writeFile(
path.join(distDir, "index.html"),
"<!DOCTYPE html><html><head></head><body>app</body></html>",
);
await writeFile(
path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js"),
"console.log('uncompressed');",
);
await writeFile(
path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js.br"),
"console.log('brotli');",
);
await writeFile(
path.join(distDir, "_expo", "static", "js", "web", "index-abc123def4567890.js.gz"),
"console.log('gzip');",
);
await writeFile(path.join(distDir, "styles.css"), "body { color: red; }");
await writeFile(path.join(publicDir, "asset.txt"), "public asset");
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
test("returns 404 when web UI is disabled", async () => {
const app = createApp({ enabled: false, distDir, publicDir });
const res = await request(app, "GET", "/");
expect(res.status).toBe(404);
expect(res.body).toBe("");
});
test("returns 404 when dist directory is missing", async () => {
const app = createApp({ enabled: true, distDir: path.join(tempRoot, "missing"), publicDir });
const res = await request(app, "GET", "/");
expect(res.status).toBe(404);
});
test("serves index.html with injected initial connection hint", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
expect(res.body).toContain("window.__PASEO_INITIAL_DAEMON_CONNECTION__");
expect(res.body).toContain('"listen":"localhost:');
expect(res.body).toContain('"useTls":false');
expect(res.body).toContain('"label":"test-label"');
});
test("injects hint before closing head tag", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/index.html");
expect(res.body).toMatch(/window\.__PASEO_INITIAL_DAEMON_CONNECTION__.*<\/head>/);
});
test("escapes the injected host hint for inline script safety", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/", {
host: "evil.test</script><script>alert(1)</script>",
});
expect(res.status).toBe(200);
expect(res.body).toContain("evil.test\\u003C/script\\u003E");
expect(res.body).not.toContain("evil.test</script>");
});
test("falls back to index.html for SPA deep links", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/h/some-server-id/agent/123");
expect(res.status).toBe(200);
expect(res.body).toContain("window.__PASEO_INITIAL_DAEMON_CONNECTION__");
expect(res.body).toContain("app");
});
test("serves static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/styles.css");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/css; charset=utf-8");
expect(res.body).toBe("body { color: red; }");
});
test("selects brotli precompressed asset when accepted", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js", {
"accept-encoding": "br, gzip",
});
expect(res.status).toBe(200);
expect(res.headers["content-encoding"]).toBe("br");
expect(res.headers["vary"]).toBe("Accept-Encoding");
expect(res.body).toBe("console.log('brotli');");
});
test("selects gzip precompressed asset when brotli is not accepted", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js", {
"accept-encoding": "gzip",
});
expect(res.status).toBe(200);
expect(res.headers["content-encoding"]).toBe("gzip");
expect(res.body).toBe("console.log('gzip');");
});
test("falls back to uncompressed asset when no encoding is accepted", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js");
expect(res.status).toBe(200);
expect(res.headers["content-encoding"]).toBeUndefined();
expect(res.body).toBe("console.log('uncompressed');");
});
test("does not catch /api/* paths", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/api/health");
expect(res.status).toBe(200);
expect(res.body).toBe('{"ok":true}');
});
test("does not catch /mcp/* paths", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/mcp/agents");
expect(res.status).toBe(200);
expect(res.body).toBe('{"mcp":true}');
});
test("does not catch /public/* paths", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/public/asset.txt");
expect(res.status).toBe(200);
expect(res.body).toBe("public asset");
});
test("sets no-cache headers for index.html", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/");
expect(res.headers["cache-control"]).toBe(
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
});
test("sets immutable caching for hashed assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/_expo/static/js/web/index-abc123def4567890.js");
expect(res.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
});
test("sets no-cache for unhashed static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/styles.css");
expect(res.headers["cache-control"]).toBe("no-cache");
});
test("only handles GET and HEAD requests", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
app.post("/", (_req, res) => res.json({ posted: true }));
const res = await request(app, "POST", "/");
expect(res.status).toBe(200);
expect(res.body).toBe('{"posted":true}');
});
test("responds to HEAD without a body", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "HEAD", "/");
expect(res.status).toBe(200);
expect(res.body).toBe("");
});
});
+271
View File
@@ -0,0 +1,271 @@
import { createReadStream, readFileSync, statSync } from "node:fs";
import path from "node:path";
import type { RequestHandler, Response } from "express";
import type { Logger } from "pino";
const EXCLUDED_PATH_PREFIXES = ["/api/", "/mcp/", "/public/"];
const EXCLUDED_PATHS = new Set(["/api", "/mcp", "/public"]);
function isExcludedPath(requestPath: string): boolean {
for (const prefix of EXCLUDED_PATH_PREFIXES) {
if (requestPath.startsWith(prefix)) {
return true;
}
}
return EXCLUDED_PATHS.has(requestPath);
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".eot": "application/vnd.ms-fontobject",
".map": "application/json",
};
function getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return CONTENT_TYPES[ext] ?? "application/octet-stream";
}
function selectEncoding(acceptEncoding: string | undefined): "br" | "gzip" | null {
if (!acceptEncoding) {
return null;
}
const normalized = acceptEncoding.toLowerCase();
if (normalized.includes("br")) {
return "br";
}
if (normalized.includes("gzip")) {
return "gzip";
}
return null;
}
function isHashedAsset(filePath: string): boolean {
const base = path.basename(filePath);
// Match content hashes like index-abc123def456.js or main.abc123def456.css.
return /[-.][0-9a-f]{16,}[-.]/i.test(base);
}
function isInsideDir(targetPath: string, dirPath: string): boolean {
const resolvedDir = path.resolve(dirPath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedDir || resolvedTarget.startsWith(resolvedDir + path.sep);
}
interface ResolvedTarget {
resolvedFile: string;
isIndexHtml: boolean;
}
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
let filePath = path.join(distDir, safePath);
const stat = safeStat(filePath);
if (stat?.isDirectory()) {
filePath = path.join(filePath, "index.html");
}
const finalStat = safeStat(filePath);
if (!finalStat?.isFile()) {
filePath = path.join(distDir, "index.html");
const fallbackStat = safeStat(filePath);
if (!fallbackStat?.isFile()) {
return null;
}
}
if (!isInsideDir(filePath, distDir)) {
return null;
}
const resolvedFile = path.resolve(filePath);
const isIndexHtml = path.basename(resolvedFile).toLowerCase() === "index.html";
return { resolvedFile, isIndexHtml };
}
function safeStat(filePath: string): ReturnType<typeof statSync> | null {
try {
return statSync(filePath);
} catch {
return null;
}
}
interface ContentEncodingResult {
finalFile: string;
contentEncoding: string | null;
}
function resolveContentEncoding(
resolvedFile: string,
acceptEncoding: string | undefined,
): ContentEncodingResult {
const encoding = selectEncoding(acceptEncoding);
if (!encoding) {
return { finalFile: resolvedFile, contentEncoding: null };
}
const compressedFile = `${resolvedFile}.${encoding === "br" ? "br" : "gz"}`;
const compressedStat = safeStat(compressedFile);
if (compressedStat?.isFile()) {
return { finalFile: compressedFile, contentEncoding: encoding };
}
return { finalFile: resolvedFile, contentEncoding: null };
}
function setResponseCacheHeaders(res: Response, isIndexHtml: boolean, resolvedFile: string): void {
if (isIndexHtml) {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
} else if (isHashedAsset(resolvedFile)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else {
res.setHeader("Cache-Control", "no-cache");
}
}
export interface WebUiMiddlewareOptions {
enabled: boolean;
distDir: string | null;
label: string;
logger: Logger;
}
export function createWebUiMiddleware(options: WebUiMiddlewareOptions): RequestHandler {
const { enabled, distDir, label, logger } = options;
const childLogger = logger.child({ module: "web-ui" });
if (!enabled || !distDir) {
childLogger.info(
{ enabled, hasDistDir: !!distDir },
"Daemon web UI disabled or missing dist directory",
);
} else {
childLogger.info({ distDir }, "Daemon web UI mounted");
}
return (req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") {
next();
return;
}
if (isExcludedPath(req.path)) {
next();
return;
}
if (!enabled || !distDir) {
res.status(404).end();
return;
}
serveWebUiFile({ distDir, requestPath: req.path, label, req, res });
};
}
interface ServeWebUiFileOptions {
distDir: string;
requestPath: string;
label: string;
req: Parameters<RequestHandler>[0];
res: Parameters<RequestHandler>[1];
}
function serveWebUiFile(options: ServeWebUiFileOptions): void {
const { distDir, requestPath, label, req, res } = options;
const target = resolveTargetFile(distDir, requestPath);
if (!target) {
res.status(404).end();
return;
}
const { resolvedFile, isIndexHtml } = target;
const acceptEncoding = isIndexHtml ? undefined : req.headers["accept-encoding"];
const { finalFile, contentEncoding } = resolveContentEncoding(resolvedFile, acceptEncoding);
res.setHeader("Content-Type", getContentType(resolvedFile));
if (contentEncoding) {
res.setHeader("Content-Encoding", contentEncoding);
res.setHeader("Vary", "Accept-Encoding");
}
setResponseCacheHeaders(res, isIndexHtml, resolvedFile);
if (req.method === "HEAD") {
res.status(200).end();
return;
}
if (isIndexHtml) {
sendIndexHtml(res, finalFile, req, label);
return;
}
const stream = createReadStream(finalFile);
stream.on("error", () => {
if (!res.headersSent) {
res.status(500).end();
} else {
res.end();
}
});
stream.pipe(res);
}
function sendIndexHtml(
res: Response,
filePath: string,
req: Parameters<RequestHandler>[0],
label: string,
): void {
try {
const html = readFileSync(filePath, "utf-8");
const injected = injectConnectionHint(html, req, label);
res.status(200).send(injected);
} catch {
res.status(500).end();
}
}
function serializeInlineScriptJson(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003C")
.replace(/>/g, "\\u003E")
.replace(/&/g, "\\u0026");
}
function injectConnectionHint(
html: string,
req: Parameters<RequestHandler>[0],
label: string,
): string {
const host = typeof req.headers.host === "string" ? req.headers.host : "";
const useTls = req.protocol === "https";
const hint = {
listen: host,
useTls,
label,
};
const script = `<script>window.__PASEO_INITIAL_DAEMON_CONNECTION__=${serializeInlineScriptJson(hint)}</script>`;
const headClose = /<\/head>/i;
if (headClose.test(html)) {
return html.replace(headClose, `${script}</head>`);
}
return script + html;
}