| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createServer } from "node:http"; |
| import { readFile } from "node:fs/promises"; |
| import { extname, resolve } from "node:path"; |
| import process from "node:process"; |
| import { pathToFileURL } from "node:url"; |
| import sirv from "sirv"; |
|
|
| import { |
| createProxyHandlers, |
| createRouter, |
| isServerInfoRequest, |
| matchesPathPrefix, |
| proxyServerInfoRequest, |
| } from "./proxy-utils.mjs"; |
|
|
| |
| |
| |
|
|
| const ASSET_LIKE_EXTENSIONS = new Set([ |
| ".br", |
| ".css", |
| ".gif", |
| ".gz", |
| ".html", |
| ".htm", |
| ".ico", |
| ".jpeg", |
| ".jpg", |
| ".js", |
| ".json", |
| ".map", |
| ".mjs", |
| ".mp3", |
| ".png", |
| ".svg", |
| ".ttf", |
| ".txt", |
| ".wav", |
| ".webmanifest", |
| ".webp", |
| ".woff", |
| ".woff2", |
| ".xml", |
| ]); |
|
|
| |
| |
| |
|
|
| function isEnvFlagEnabled(value) { |
| if (typeof value !== "string") return false; |
| const normalized = value.trim().toLowerCase(); |
| return normalized === "1" || normalized === "true"; |
| } |
|
|
| export function parseArgs(argv = process.argv.slice(2), env = process.env) { |
| const config = { |
| port: 3001, |
| host: "::", |
| dir: "build", |
| routes: {}, |
| rejectPrefixes: [], |
| noReferrerPrefixes: [], |
| sessionApiKey: null, |
| authRequired: false, |
| runtimeServicesInfo: null, |
| lockToCloud: null, |
| basePath: "/", |
| vscodeBasePath: null, |
| |
| disableTelemetry: isEnvFlagEnabled(env.AGENT_CANVAS_DISABLE_TELEMETRY), |
| }; |
|
|
| for (let i = 0; i < argv.length; i++) { |
| const flag = argv[i]; |
| switch (flag) { |
| case "-p": |
| case "--port": |
| config.port = Number.parseInt(argv[++i], 10); |
| break; |
| case "-H": |
| case "--host": |
| config.host = argv[++i]; |
| break; |
| case "-d": |
| case "--dir": |
| config.dir = argv[++i]; |
| break; |
| case "-r": |
| case "--route": { |
| const value = argv[++i]; |
| const eq = value.indexOf("="); |
| if (eq < 0) { |
| throw new Error(`Invalid --route (expected /prefix=url): ${value}`); |
| } |
| const prefix = value.slice(0, eq); |
| const url = value.slice(eq + 1); |
| if (!prefix.startsWith("/")) { |
| throw new Error(`--route prefix must start with '/': ${prefix}`); |
| } |
| config.routes[prefix] = url; |
| break; |
| } |
| case "--session-api-key": |
| config.sessionApiKey = argv[++i] || null; |
| break; |
| case "--runtime-services-info": |
| config.runtimeServicesInfo = argv[++i] || null; |
| break; |
| case "--lock-to-cloud": |
| config.lockToCloud = argv[++i] || null; |
| break; |
| case "--base-path": |
| config.basePath = normalizeBasePath(argv[++i]); |
| break; |
| case "--vscode-base-path": { |
| const prefix = argv[++i]; |
| if (!prefix || !prefix.startsWith("/")) { |
| throw new Error( |
| `--vscode-base-path value must start with '/': ${prefix ?? "(empty)"}`, |
| ); |
| } |
| config.vscodeBasePath = prefix.replace(/\/+$/, "") || "/"; |
| break; |
| } |
|
|
| case "--auth-required": |
| config.authRequired = true; |
| break; |
| case "--disable-telemetry": |
| config.disableTelemetry = true; |
| break; |
| case "--reject-prefix": { |
| const prefix = argv[++i]; |
| if (!prefix || !prefix.startsWith("/")) { |
| throw new Error( |
| `--reject-prefix value must start with '/': ${prefix ?? "(empty)"}`, |
| ); |
| } |
| config.rejectPrefixes.push(prefix); |
| break; |
| } |
| case "--no-referrer-prefix": { |
| const prefix = argv[++i]; |
| if (!prefix || !prefix.startsWith("/")) { |
| throw new Error( |
| `--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`, |
| ); |
| } |
| config.noReferrerPrefixes.push(prefix); |
| break; |
| } |
| case "-h": |
| case "--help": |
| showHelp(); |
| process.exit(0); |
| default: |
| throw new Error(`Unknown flag: ${flag}`); |
| } |
| } |
|
|
| |
| |
| |
| |
| if (config.sessionApiKey && config.authRequired) { |
| console.error( |
| "ERROR: --session-api-key and --auth-required are mutually exclusive.\n" + |
| " Use --session-api-key for local mode (key auto-injected).\n" + |
| " Use --auth-required for public mode (user pastes key).", |
| ); |
| process.exit(1); |
| } |
|
|
| |
| |
| |
| |
| |
| if (config.vscodeBasePath && !config.routes[config.vscodeBasePath]) { |
| console.error( |
| `ERROR: --vscode-base-path ${config.vscodeBasePath} has no matching --route.\n` + |
| " This server would advertise an editor it does not serve.\n" + |
| ` Add --route ${config.vscodeBasePath}=<editor-url>, or drop --vscode-base-path.`, |
| ); |
| process.exit(1); |
| } |
|
|
| return config; |
| } |
|
|
| function normalizeBasePath(value) { |
| const raw = (value ?? "").trim(); |
| if (!raw || raw === "/") return "/"; |
|
|
| const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; |
| return withLeadingSlash.replace(/\/+$/, ""); |
| } |
|
|
| function showHelp() { |
| console.log(` |
| Combined static file server + reverse proxy. |
| |
| USAGE: |
| node scripts/static-server.mjs [options] |
| |
| OPTIONS: |
| -p, --port <port> Port to bind (default: 3001) |
| -H, --host <host> Hostname to bind (default: :: dual-stack) |
| -d, --dir <dir> Directory to serve (default: build) |
| -r, --route <prefix=url> Proxy <prefix> (and subpaths) to <url>; |
| may be repeated. WebSockets supported. |
| --session-api-key <key> Inject session API key into index.html so the |
| pre-built frontend authenticates to agent-server |
| without needing VITE_SESSION_API_KEY baked in. |
| --auth-required Inject authRequired flag into index.html so the |
| pre-built frontend shows the API key entry screen |
| (public mode) without VITE_AUTH_REQUIRED baked in. |
| --runtime-services-info <json> |
| Inject a JSON description of the local runtime |
| services into index.html so the pre-built |
| frontend can populate the agent's |
| <RUNTIME_SERVICES> system-prompt block without |
| VITE_RUNTIME_SERVICES_INFO baked in. |
| --lock-to-cloud <cloud-url> Lock backend setup to a single OpenHands Cloud |
| URL. Hides manual/local backend setup and the |
| custom Cloud URL field in the pre-built frontend. |
| --disable-telemetry Disable all product telemetry (including the |
| anonymous install event) in the pre-built |
| frontend at runtime, without VITE_DO_NOT_TRACK |
| baked in. Injects |
| window.__AGENT_CANVAS_DO_NOT_TRACK__ = true. |
| Equivalent to AGENT_CANVAS_DISABLE_TELEMETRY=1. |
| --base-path <path> Mount the SPA under <path> (default: /). |
| For example, --base-path /canvas serves |
| index.html and assets under /canvas. |
| --vscode-base-path <path> Advertise to the frontend that this origin |
| serves the editor under <path>, so the editor |
| control renders here. Requires a matching |
| --route; the server refuses to start otherwise, |
| since advertising a prefix it does not route |
| produces a control that opens the SPA. Omit on |
| any origin without the editor route. |
| --reject-prefix <prefix> Return 503 for requests matching <prefix> |
| --no-referrer-prefix <p> Send "Referrer-Policy: no-referrer" on proxied |
| responses under <p>. For upstreams whose URL |
| carries a credential in the query string. |
| instead of SPA-fallbacking to index.html; |
| may be repeated. Useful in --frontend-only |
| mode to cleanly reject API paths. |
| -h, --help Show this help |
| |
| ROUTING: |
| β’ Routes are matched by longest prefix first (most-specific wins). |
| β’ Reject prefixes are checked before SPA fallback β matching requests |
| get 503 immediately. |
| β’ Anything that does not match a route or reject prefix is served |
| from --dir. |
| β’ Unknown paths fall back to index.html (SPA mode), unless they look |
| like an asset request (have a known file extension), in which case |
| a 404 is returned. |
| `); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function serializeForInlineScript(value) { |
| return JSON.stringify(value) |
| .replace(/</g, "\\u003c") |
| .replace(/>/g, "\\u003e") |
| .replace(/\u2028/g, "\\u2028") |
| .replace(/\u2029/g, "\\u2029"); |
| } |
|
|
| function makeConfigInjectionScript( |
| sessionApiKey, |
| authRequired, |
| runtimeServicesInfo, |
| lockToCloud, |
| basePath, |
| vscodeBasePath, |
| disableTelemetry, |
| ) { |
| const parts = []; |
|
|
| if (sessionApiKey) { |
| const keyLiteral = serializeForInlineScript(sessionApiKey); |
| |
| |
| parts.push(`window.__AGENT_CANVAS_SESSION_API_KEY__=${keyLiteral};`); |
| |
| |
| |
| parts.push( |
| `try{` + |
| `var _k='openhands-agent-server-config',` + |
| `_c=JSON.parse(localStorage.getItem(_k)||'{}');` + |
| `if(_c.sessionApiKey!==${keyLiteral}){` + |
| `_c.sessionApiKey=${keyLiteral};` + |
| `localStorage.setItem(_k,JSON.stringify(_c));` + |
| `}` + |
| `}catch(e){}`, |
| ); |
| } |
|
|
| if (authRequired) { |
| parts.push(`window.__AGENT_CANVAS_AUTH_REQUIRED__=true;`); |
| } |
|
|
| if (runtimeServicesInfo) { |
| |
| |
| |
| |
| parts.push( |
| `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__=${serializeForInlineScript(runtimeServicesInfo)};`, |
| ); |
| } |
|
|
| if (lockToCloud) { |
| parts.push( |
| `window.__AGENT_CANVAS_LOCK_TO_CLOUD__=${serializeForInlineScript(lockToCloud)};`, |
| ); |
| } |
|
|
| if (basePath && basePath !== "/") { |
| parts.push( |
| `window.__AGENT_CANVAS_BASE_PATH__=${serializeForInlineScript(basePath)};`, |
| ); |
| } |
|
|
| if (vscodeBasePath) { |
| parts.push( |
| `window.__AGENT_CANVAS_VSCODE_BASE_PATH__=${serializeForInlineScript(vscodeBasePath)};`, |
| ); |
| } |
|
|
| if (disableTelemetry) { |
| parts.push(`window.__AGENT_CANVAS_DO_NOT_TRACK__=true;`); |
| } |
|
|
| if (parts.length === 0) return ""; |
|
|
| return `<script>(function(){${parts.join("")}}());</script>`; |
| } |
|
|
| |
| |
| |
| |
| async function serveInjectedIndexHtml( |
| req, |
| res, |
| indexPath, |
| { |
| sessionApiKey, |
| authRequired, |
| runtimeServicesInfo, |
| lockToCloud, |
| basePath, |
| vscodeBasePath, |
| disableTelemetry, |
| } = {}, |
| ) { |
| let content; |
| try { |
| content = await readFile(indexPath, "utf8"); |
| } catch { |
| return false; |
| } |
|
|
| const script = makeConfigInjectionScript( |
| sessionApiKey, |
| authRequired, |
| runtimeServicesInfo, |
| lockToCloud, |
| basePath, |
| vscodeBasePath, |
| disableTelemetry, |
| ); |
| |
| |
| const injected = content.includes("</head>") |
| ? content.replace("</head>", `${script}\n</head>`) |
| : content.includes("</body>") |
| ? content.replace("</body>", `${script}\n</body>`) |
| : script + content; |
|
|
| const buf = Buffer.from(injected, "utf8"); |
| res.writeHead(200, { |
| "Content-Type": "text/html; charset=utf-8", |
| "Content-Length": buf.length, |
| "Cache-Control": sessionApiKey ? "no-store" : "no-cache", |
| }); |
| if (req.method === "HEAD") { |
| res.end(); |
| } else { |
| res.end(buf); |
| } |
| return true; |
| } |
|
|
| |
| |
| |
|
|
| function parseUrlPath(req, res) { |
| const rawPath = (req.url ?? "/").split("?")[0]; |
| try { |
| return decodeURIComponent(rawPath); |
| } catch { |
| res.writeHead(400); |
| res.end("Bad Request"); |
| return null; |
| } |
| } |
|
|
| function isGetOrHead(req) { |
| return req.method === "GET" || req.method === "HEAD"; |
| } |
|
|
| function needsRuntimeInjection(injectionOpts) { |
| return Boolean( |
| injectionOpts.sessionApiKey || |
| injectionOpts.authRequired || |
| injectionOpts.runtimeServicesInfo || |
| injectionOpts.lockToCloud || |
| injectionOpts.vscodeBasePath || |
| injectionOpts.disableTelemetry || |
| (injectionOpts.basePath && injectionOpts.basePath !== "/"), |
| ); |
| } |
|
|
| function looksLikeAssetRequest(urlPath) { |
| const last = urlPath.split("/").pop() ?? ""; |
| return ASSET_LIKE_EXTENSIONS.has(extname(last).toLowerCase()); |
| } |
|
|
| function matchesAnyPrefix(urlPath, prefixes) { |
| return prefixes.some((prefix) => matchesPathPrefix(urlPath, prefix)); |
| } |
|
|
| function rejectUnavailable(res) { |
| res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" }); |
| res.end("Service Unavailable (no backend configured for this route)"); |
| } |
|
|
| function notFound(res) { |
| res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); |
| res.end("Not Found"); |
| } |
|
|
| function isMountedPath(urlPath, basePath) { |
| return ( |
| basePath === "/" || |
| urlPath === basePath || |
| urlPath.startsWith(`${basePath}/`) |
| ); |
| } |
|
|
| function stripBasePathFromUrl(rawUrl, basePath) { |
| if (basePath === "/") return rawUrl; |
|
|
| const [rawPath = "/", ...rest] = (rawUrl || "/").split("?"); |
| const suffix = rawPath.slice(basePath.length) || "/"; |
| const path = suffix.startsWith("/") ? suffix : `/${suffix}`; |
| return rest.length > 0 ? `${path}?${rest.join("?")}` : path; |
| } |
|
|
| function redirectToMountedPath(req, res, urlPath, basePath) { |
| if (basePath === "/" || !isGetOrHead(req) || looksLikeAssetRequest(urlPath)) { |
| return false; |
| } |
|
|
| const [, query = ""] = (req.url ?? "/").split("?", 2); |
| const path = urlPath === "/" ? "/" : urlPath; |
| const location = `${basePath}${path}${query ? `?${query}` : ""}`; |
| res.writeHead(308, { Location: location }); |
| res.end(); |
| return true; |
| } |
|
|
| function setStaticHeaders(res, pathname) { |
| const extension = extname(pathname).toLowerCase(); |
| if (extension === ".js" || extension === ".mjs") { |
| res.setHeader("Content-Type", "application/javascript; charset=utf-8"); |
| } |
|
|
| if (pathname.startsWith("/assets/")) { |
| res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); |
| return; |
| } |
| res.setHeader("Cache-Control", "no-cache"); |
| } |
|
|
| function createStaticMiddleware(dirAbs) { |
| return sirv(dirAbs, { |
| etag: true, |
| single: false, |
| setHeaders: setStaticHeaders, |
| }); |
| } |
|
|
| async function handleStatic( |
| req, |
| res, |
| dirAbs, |
| staticMiddleware, |
| injectionOpts = {}, |
| rejectPrefixes = [], |
| basePath = "/", |
| ) { |
| const urlPath = parseUrlPath(req, res); |
| if (urlPath === null) return; |
|
|
| if (!isMountedPath(urlPath, basePath)) { |
| if (matchesAnyPrefix(urlPath, rejectPrefixes)) { |
| rejectUnavailable(res); |
| return; |
| } |
| if (!redirectToMountedPath(req, res, urlPath, basePath)) notFound(res); |
| return; |
| } |
|
|
| const mountedUrl = stripBasePathFromUrl(req.url ?? "/", basePath); |
| const mountedPath = parseUrlPath({ ...req, url: mountedUrl }, res); |
| if (mountedPath === null) return; |
|
|
| const injectRuntimeConfig = needsRuntimeInjection(injectionOpts); |
| const indexPath = resolve(dirAbs, "index.html"); |
|
|
| if ( |
| injectRuntimeConfig && |
| isGetOrHead(req) && |
| (mountedPath === "/" || mountedPath === "/index.html") |
| ) { |
| if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) |
| return; |
| } |
|
|
| const mountedReq = Object.create(req); |
| mountedReq.url = mountedUrl; |
|
|
| staticMiddleware(mountedReq, res, async () => { |
| if (matchesAnyPrefix(mountedPath, rejectPrefixes)) { |
| rejectUnavailable(res); |
| return; |
| } |
|
|
| if (isGetOrHead(req) && !looksLikeAssetRequest(mountedPath)) { |
| if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) { |
| return; |
| } |
| } |
|
|
| notFound(res); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| export function startStaticServer(config) { |
| const route = createRouter(config.routes); |
| const proxy = createProxyHandlers({ label: `static:${config.port}` }); |
| const dirAbs = resolve(config.dir); |
| const injectionOpts = { |
| sessionApiKey: config.sessionApiKey || null, |
| authRequired: config.authRequired || false, |
| runtimeServicesInfo: config.runtimeServicesInfo || null, |
| lockToCloud: config.lockToCloud || null, |
| basePath: normalizeBasePath(config.basePath), |
| vscodeBasePath: config.vscodeBasePath || null, |
| disableTelemetry: config.disableTelemetry || false, |
| }; |
| const basePath = injectionOpts.basePath; |
| const rejectPrefixes = config.rejectPrefixes ?? []; |
| const noReferrerPrefixes = config.noReferrerPrefixes ?? []; |
| const staticMiddleware = createStaticMiddleware(dirAbs); |
|
|
| const uninstallDiagnostics = proxy.installDiagnostics(); |
|
|
| const server = createServer((req, res) => { |
| const url = req.url ?? "/"; |
| const backend = route(url); |
| if (backend) { |
| |
| |
| |
| |
| if (matchesAnyPrefix(url, noReferrerPrefixes)) { |
| res.setHeader("Referrer-Policy", "no-referrer"); |
| } |
| if ( |
| config.runtimeServicesInfo && |
| isServerInfoRequest(req) && |
| (req.method === "GET" || req.method === "HEAD") |
| ) { |
| proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); |
| return; |
| } |
| proxy.proxyHttp(req, res, backend); |
| return; |
| } |
| handleStatic( |
| req, |
| res, |
| dirAbs, |
| staticMiddleware, |
| injectionOpts, |
| rejectPrefixes, |
| basePath, |
| ).catch((err) => { |
| console.error(`Static handler error for ${req.url}:`, err); |
| if (!res.headersSent) { |
| res.writeHead(500); |
| res.end("Internal Server Error"); |
| } |
| }); |
| }); |
|
|
| server.on("upgrade", (req, socket, head) => { |
| const backend = route(req.url ?? "/"); |
| if (backend) { |
| proxy.proxyWebSocket(req, socket, head, backend); |
| return; |
| } |
| socket.destroy(); |
| }); |
| server.on("close", uninstallDiagnostics); |
|
|
| return new Promise((resolveListen) => { |
| server.listen(config.port, config.host, () => { |
| const displayPath = basePath === "/" ? "/" : `${basePath}/`; |
| console.log(""); |
| console.log( |
| `Static-server + proxy listening on http://${config.host}:${config.port}${displayPath}`, |
| ); |
| console.log(` Static dir: ${dirAbs}`); |
| console.log(` Base path: ${basePath}`); |
| const sortedRoutes = Object.entries(config.routes).sort( |
| ([a], [b]) => b.length - a.length, |
| ); |
| for (const [prefix, backend] of sortedRoutes) { |
| console.log(` ${prefix} -> ${backend}`); |
| } |
| if (rejectPrefixes.length > 0) { |
| for (const prefix of rejectPrefixes) { |
| console.log(` ${prefix} -> 503 (rejected)`); |
| } |
| } |
| if (config.lockToCloud) { |
| console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`); |
| } |
| console.log(" * (default) -> static files + SPA fallback"); |
| console.log(""); |
| resolveListen(server); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| const isMainModule = |
| process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; |
|
|
| if (isMainModule) { |
| try { |
| const config = parseArgs(); |
| await startStaticServer(config); |
| } catch (err) { |
| console.error(err instanceof Error ? err.message : err); |
| process.exit(1); |
| } |
| } |
|
|