Download scripts/static-server.mjs from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/scripts/static-server.mjs
- Command line
-
hf download hf://SaylorTwift/openhands/scripts/static-server.mjs
-
curl -L -o static-server.mjs https://huggingface.co/SaylorTwift/openhands/resolve/main/scripts/static-server.mjs
28.4 kB
| /** | |
| * Combined static file server + reverse proxy. | |
| * | |
| * Replaces `sirv-cli` for the static launcher. The reason a plain static | |
| * server is not enough: Vite's dev server (used by `npm run dev`) configures | |
| * a proxy for `/api`, `/sockets`, `/server_info`, `/alive`, `/health`, | |
| * `/ready`, `/docs`, `/redoc`, `/openapi.json` (see vite.config.ts) so | |
| * requests to those paths are forwarded to | |
| * the agent-server even when the browser is hitting Vite directly on :3001. | |
| * sirv-cli has no proxy support, so under `--single` it falls back to | |
| * index.html for any of those paths β making `/server_info` look like HTML | |
| * to the SPA when the user hits the static port directly (e.g. via a tunnel | |
| * that exposes :3001). | |
| * | |
| * This script provides Vite-equivalent behaviour: serve static files from | |
| * --dir, fall back to index.html for HTML navigations, and reverse-proxy | |
| * configured prefixes to upstream backends. The proxy + WebSocket logic is | |
| * deliberately kept identical in spirit to scripts/ingress.mjs so the two | |
| * servers route the same way. | |
| * | |
| * Usage (mirrors scripts/ingress.mjs's --route flag style): | |
| * node scripts/static-server.mjs \ | |
| * --port 3001 --dir build \ | |
| * --route "/api/automation=http://localhost:18001" \ | |
| * --route "/api=http://localhost:18000" \ | |
| * --route "/server_info=http://localhost:18000" \ | |
| * --route "/sockets=http://localhost:18000" | |
| */ | |
| 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"; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // SPA fallback helpers | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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", | |
| ]); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Args | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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, | |
| // Also settable via the --disable-telemetry flag below. | |
| 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}`); | |
| } | |
| } | |
| // Guard: --session-api-key and --auth-required are semantically | |
| // mutually exclusive. The first auto-injects the key (local mode); | |
| // the second forces the user to paste it (public mode). Combining | |
| // both is a misconfiguration. | |
| 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); | |
| } | |
| // Guard: advertising the editor and routing it are the same decision, so | |
| // they cannot be allowed to drift. This flag is what the frontend gates the | |
| // editor control on; if it named a prefix with no route behind it, the | |
| // control would render and the navigation would fall through to the SPA β | |
| // which is precisely the bug this flag exists to prevent. | |
| 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. | |
| `); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Runtime config injection | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Build a tiny inline script that seeds runtime config into the page. | |
| * | |
| * - `sessionApiKey`: exposed to the app two ways so a fresh-localStorage | |
| * browser can authenticate even though the published bundle has no | |
| * VITE_SESSION_API_KEY baked in: | |
| * 1. `window.__AGENT_CANVAS_SESSION_API_KEY__` β read by | |
| * `getBakedSessionApiKey()` in `agent-server-config.ts` as a fallback | |
| * when the env var is empty. This is symmetric with how | |
| * `__AGENT_CANVAS_AUTH_REQUIRED__` works for the auth-required flag. | |
| * 2. Written to `openhands-agent-server-config.sessionApiKey` in | |
| * localStorage for compatibility with the legacy storage key. Useful | |
| * for any code path that still reads it (e.g. e2e test fixtures). | |
| * Always overwrites when the stored value differs so a rotated key | |
| * is not shadowed by a stale one. | |
| * | |
| * - `authRequired`: sets `window.__AGENT_CANVAS_AUTH_REQUIRED__ = true` so the | |
| * pre-built frontend shows the API key entry screen (public mode) without | |
| * VITE_AUTH_REQUIRED baked in. | |
| * | |
| * - `runtimeServicesInfo`: a JSON string describing the local services | |
| * (agent-server, automation, β¦), exposed as | |
| * `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__`. Read by | |
| * `parseRuntimeServicesInfo()` in `agent-server-adapter.ts` as a fallback | |
| * when `VITE_RUNTIME_SERVICES_INFO` is empty, so static builds (Docker / | |
| * published binary) still populate the agent's `<RUNTIME_SERVICES>` block. | |
| * | |
| * - `lockToCloud`: an OpenHands Cloud URL exposed as | |
| * `window.__AGENT_CANVAS_LOCK_TO_CLOUD__`. Read by `getLockedCloudHost()` in | |
| * `agent-server-config.ts` so pre-built frontend bundles can hide manual | |
| * backend setup and the custom Cloud URL field at runtime. | |
| * | |
| * - `basePath`: the path prefix the SPA is mounted under, exposed as | |
| * `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale | |
| * files can resolve through the same subpath as the built bundle. | |
| * | |
| * - `vscodeBasePath`: the prefix *this origin* serves the editor under, exposed | |
| * as `window.__AGENT_CANVAS_VSCODE_BASE_PATH__`. Read by | |
| * `getOriginVSCodeBasePath()` in `#/utils/vscode-origin` to decide whether the | |
| * editor control can render here at all. Absent means this origin serves no | |
| * editor β which is the correct answer for the public-mode instance, whose | |
| * route table deliberately omits it. | |
| * | |
| * - `disableTelemetry`: sets `window.__AGENT_CANVAS_DO_NOT_TRACK__ = true` so a | |
| * published bundle disables all telemetry (including the anonymous install | |
| * event) at runtime without VITE_DO_NOT_TRACK baked in. Read by | |
| * `isDoNotTrackEnabled()` in `#/services/telemetry`. Enabled by | |
| * AGENT_CANVAS_DISABLE_TELEMETRY=1 or the --disable-telemetry flag. | |
| */ | |
| /** | |
| * Serialize a value into a safe JavaScript literal for inclusion in an inline <script> tag. | |
| * Escapes characters that could terminate or manipulate the surrounding HTML context: | |
| * - '<' -> \u003c (prevents </script> breakout) | |
| * - '>' -> \u003e (prevents premature tag closing in some contexts) | |
| * - '\u2028' -> \u2028 (prevents syntax errors in JS parsers) | |
| * - '\u2029' -> \u2029 (prevents syntax errors in JS parsers) | |
| */ | |
| 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); | |
| // Window global β read at module init by getBakedSessionApiKey(). | |
| // Set first so it's available even if the localStorage write throws. | |
| parts.push(`window.__AGENT_CANVAS_SESSION_API_KEY__=${keyLiteral};`); | |
| // Always overwrite when the stored key differs from the runtime key. | |
| // A previous session may have persisted a now-stale key; the runtime | |
| // value (from --session-api-key) is the server's truth. | |
| 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) { | |
| // Stored as the raw JSON string so the browser-side parser | |
| // (parseRuntimeServicesInfo) can JSON.parse it exactly like the | |
| // VITE_RUNTIME_SERVICES_INFO env var. serializeForInlineScript produces a safe JS | |
| // string literal for the inline <script>. | |
| 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>`; | |
| } | |
| /** | |
| * Serve index.html with runtime config injected into <head>. | |
| * Returns true if the response was written, false if the file was not found. | |
| */ | |
| 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, | |
| ); | |
| // Inject right before </head> so the key is available before any app code runs. | |
| // replace() targets the first (and only) </head> in well-formed HTML. | |
| 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; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Static file serving | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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); | |
| }); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Server | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) { | |
| // The editor is advertised as `<origin><prefix>/?tkn=<token>`, and that | |
| // token is agent-server's session key. The workbench loads webviews, | |
| // previews and extension content from that document, so without this a | |
| // Referer carrying the key rides along on those subrequests. | |
| 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); | |
| }); | |
| }); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Main | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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); | |
| } | |
| } | |