mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 17:08:34 +08:00
* Show onboarding before public backend auth gate Co-authored-by: openhands <openhands@all-hands.dev> * Make backend setup the first onboarding step Co-authored-by: openhands <openhands@all-hands.dev> * Restore Cloud backend option in onboarding Co-authored-by: openhands <openhands@all-hands.dev> * Make first-run backend onboarding calmer Co-authored-by: openhands <openhands@all-hands.dev> * fix: update public onboarding e2e expectation * fix: cover onboarding-first public auth e2e * test: keep ProgressEvent polyfill through teardown * chore: refresh PR checks after QA * Add lock-to-cloud backend setup mode Co-authored-by: openhands <openhands@all-hands.dev> * Hide skip on locked Cloud backend onboarding Co-authored-by: openhands <openhands@all-hands.dev> * Remove add-backend onboarding subtitle Co-authored-by: openhands <openhands@all-hands.dev> * Skip healthy backend onboarding step Co-authored-by: openhands <openhands@all-hands.dev> * fix: support skipped backend step in onboarding e2e * chore: Remove PR-only artifacts * fix: address onboarding review nits * fix: show onboarding for locked cloud first run * ci: support stacked mock llm runs * test: assert scoped shell background * fix: resolve merge conflicts with main (fix-public-onboarding stacking) - Remove duplicate handleConnected/actionRowClassName/titleKey declarations in check-backend-step.tsx that resulted from merging the parent PR's changes on top of our lock-to-cloud additions - Remove erroneous waitFor(onboarding-backend-connected) steps from the 'shows a connection error' test which uses a no-backend context where the connection banner is never shown Co-authored-by: openhands <openhands@all-hands.dev> * fix: remove unused isLockedToCloud export All callsites use getLockedCloudHost() !== null directly. Remove the redundant helper to keep the public API intentional. Co-authored-by: openhands <openhands@all-hands.dev> * fix: show onboarding first in locked-cloud mode when a session key is present On PR #1389 Hiep reported that `static-server.mjs --lock-to-cloud ...` landed on the Manage Backends recovery modal ("Add Backend") instead of first-run onboarding after a fresh `~/.openhands`. Root cause: when the build had a baked-in `VITE_SESSION_API_KEY` (or one was injected via `--session-api-key`), `makeDefaultLocalBackend()` seeded a Local backend even in locked-to-Cloud mode. That made `isNoBackend()` false, so `lockedNoBackend` was false and first-run onboarding was skipped; the subsequent `/server_info` probe failed and `root.tsx` rendered `MissingAgentServerScreen` (Manage Backends recovery modal). Fix: - `makeDefaultLocalBackend()` returns null when `getLockedCloudHost()` is set, so locked mode never auto-seeds a Local backend. - `root.tsx` broadens the gate to `lockedNeedsOnboarding`: locked + (no backend OR active backend is not Cloud) triggers onboarding, covering a stale persisted Local backend from a previous non-locked session too. Verified by building with a baked `VITE_SESSION_API_KEY` and serving with `--lock-to-cloud`: the app now shows the first-run onboarding Cloud-login screen instead of the recovery modal, and no Local backend is seeded. Non-locked mode still seeds the Local backend as before. Co-authored-by: openhands <openhands@all-hands.dev> * fix: locked-cloud onboarding layout + restore CI test mock CI fix: - `use-create-conversation-metadata.test.ts` mocks the whole `agent-server-config` module but was missing `getLockedCloudHost`, which `makeDefaultLocalBackend()` now imports. Add it (returning null) so the default local backend seeds and the create-conversation mutation succeeds again. Onboarding layout (locked-to-Cloud first-run step): - Drop the `max-w-sm` cap on the locked CloudLoginColumn so the "Skip the setup — connect instantly with your OpenHands Cloud account." text fills the modal content width instead of wrapping in a narrow centered column. - Add `pb-7` to the onboarding scroll area so the "Login with OpenHands Cloud" button is no longer flush with / cut off by the modal bottom. Widening the text (fewer lines) plus the bottom padding together give the button breathing room. Co-authored-by: openhands <openhands@all-hands.dev> * test: add getLockedCloudHost to agent-server-config test mocks `makeDefaultLocalBackend()` now imports `getLockedCloudHost` from `agent-server-config`. Two tests that fully mock that module were missing the export, so the default local backend never seeded and every create-/ read-conversation path threw `NoBackendAvailableError`: - `agent-server-conversation-service.test.ts` (23 failures on ubuntu CI) - `use-create-conversation-metadata.test.ts` (already fixed in prev commit) Add `getLockedCloudHost: vi.fn(() => null)` to both mocks so the non-locked default-backend seeding path works again. Co-authored-by: openhands <openhands@all-hands.dev> * Enhance conversation sidebar with pinned section and grouped organization (#1144) * Add pinned conversations and reorderable workspace folders to the sidebar. Persist pins per backend with a capped pinned section, pin-on-hover cards that keep the icon aligned with hover actions via an invisible ellipsis spacer, and drag-and-drop folder ordering stored in panel preferences. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify grouped folder rows for drag and expand. Drop the grip and chevron controls, remove selection highlight and layout animation, and drag or click the folder label directly while keeping row hover feedback. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish folder drag-and-drop and pinned section visuals. Drag the whole folder (and contents) as the drag image, show an accent drop line between folders with position-aware reordering, and animate sibling folders into place only around a reorder. Swap the folder icon to its open or closed counterpart on hover, add a chronological-view divider plus an outline pin icon to the pinned section header, and render that header in normal weight. Co-authored-by: Cursor <cursoragent@cursor.com> * Add hover metadata popover for sidebar conversations. Show a modal-styled popover on conversation hover with the full title, status dot, and repo/branch-or-directory, model, and created-date rows. Reserve the action overlay width so titles truncate instead of colliding with the pin, drop the small status tooltip, and gate the popover behind a new "Hover metadata" toggle in the filter dropdown (persisted, on by default). Co-authored-by: Cursor <cursoragent@cursor.com> * Improve folder drag preview and placeholder. Show a rounded, surfaced drag image anchored to the grab point and blank the original row (preserving its height) via opacity so Chrome does not cancel the native drag. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden sidebar "Load more" pagination Dedupe loaded conversations by id and keep fetching pages until the visible list actually grows, so a single "Load more" click reliably surfaces new rows despite the 10s background refetch dropping in-flight fetchNextPage calls or pages yielding zero visible rows. Show the skeleton throughout. Also drop the native title tooltip on card titles and record the still-intermittent double-click symptom as a KNOWN ISSUE. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep pinned conversations exclusive to the pinned section. Filter pinned threads out of grouped/chronological lists to prevent duplicates, add regression coverage for both list modes, and add the missing upgrade-button translation key with typed i18n usage. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: failing tests * fix: lint --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: hieptl <hieptl.developer@gmail.com> * Fix locked cloud onboarding follow-ups Co-authored-by: openhands <openhands@all-hands.dev> * Slow down onboarding follow-up GIFs Co-authored-by: openhands <openhands@all-hands.dev> * Skip onboarding when active backend already has a configured LLM Detect returning users via flat `llm_api_key_set` + `agent_settings.llm.model` (or subscription auth), regardless of backend kind. Locked-Cloud-not-logged-in and stale local backend still fall through to the modal so the existing recovery paths kick in. Co-authored-by: openhands <openhands@all-hands.dev> * Scope onboarding skip rule to Cloud backends only Local agent-servers can be started with an env-injected `LLM_API_KEY`, which makes `llm_api_key_set` an unreliable returning-user signal — Mock-LLM E2E fresh-install tests were tripping on the SDK default model + env key combo. For Local backends the skip stays driven by the existing `openhands-onboarded` localStorage flag; Cloud backends continue to use the settings-based rule. Co-authored-by: openhands <openhands@all-hands.dev> * Trigger CI re-run (empty commit) Workflows didn't fire on 80ea575a — pushing empty commit to nudge the webhook. Co-authored-by: openhands <openhands@all-hands.dev> * Always pre-fill onboarding LLM step with OpenAI GPT-5.5 default The returning-Cloud-user case is now handled at the host level (OnboardingHost skips the whole modal). Users who actually reach the LLM step are first-time installs who want the default pre-filled — restoring the pre-PR-1389 behavior that the onboarding-regressions E2E asserts. Also drops the now-empty unit test that mirrored the old step-level preservation. Co-authored-by: openhands <openhands@all-hands.dev> * chore: Update PR QA artifacts * Generalize onboarding-skip to Local backends with configured LLMs Hiep flagged that the onboarding modal still walks users through Set Up your LLM after they connect to a pre-configured backend. Investigation: * On Cloud, the fast-path keyed off settings.llm_api_key_set + a non-empty llm.model. That worked. * On Local, the fast-path bailed early on backend.kind !== 'cloud'. But the local agent-server reports the exact same readiness signal via llm_api_key_is_set (and the local settings-service mapper already remaps that to llm_api_key_set on the way through). The only reason the skip didn't fire was the explicit kind gate. Drop the gate, accept either field name, and rename the predicate to reflect what it actually checks (isBackendLlmReady). A truly fresh agent-server reports both flags as false, so the modal still shows for genuine first-run setup. Tests: * Updated 'does not skip onboarding for a Local backend' to its inverse: 'skips for a Local backend with an LLM already configured'. * Added 'still shows the modal for a fresh Local agent-server with no API key set' to lock in the fresh-install case. * All 3328 vitest tests pass; typecheck clean. Refs Hiep's review comment on PR #1389. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): address Hiep's review on PR #1389 (#1389) Resolves the three issues Hiep reported on PR #1389: 1. **Choose Agent step gets skipped after Cloud login.** When the backend slide finished via Cloud login and `skipBackendStep` flipped true, the slide indices renumbered (agent: 1→0, setup: 2→1). The user's numeric `currentStep` of 1 — pointing at Choose Agent before the flip — now pointed at Set Up LLM, and the corrective effect that decremented it ran a render too late. Track the user's *phase* ("backend" | "agent" | "setup" | "hello") instead of a numeric step. The visible slide index is derived from phase + slideOrder, so renumbering can never move the user onto a different logical step. The previous `wasSkippingBackendStep` ref + decrement effect is replaced by a single effect that snaps phase forward only when the current phase is no longer in slideOrder (e.g. "backend" right after the slide collapsed). 2. **Existing Cloud LLM settings not shown to returning users.** The skip-onboarding fix from commit 78254e1b already routes returning users with a configured LLM around the onboarding modal entirely, so they never hit the Set Up LLM step in the first place. The new phase-based flow preserves that behavior; no further change needed. 3. **Redundant 'Or' divider** between manual and Cloud columns in BackendConnectionOptions. Both columns have prominent titles ("OpenHands Cloud" with logo on the right) and a generous gap already; the explicit divider added visual noise without information. Remove the divider markup. Also gitignores local static-server runtime artifacts (workspace/, build-fresh/) that were getting picked up by 'git add -A'. Two regression tests cover the standard (non-locked-cloud) flow: one verifies the user stays on Choose Agent after completing Cloud login from the side-by-side picker, and one verifies the 'Or' divider is gone. All 3,241 unit tests pass; lint and typecheck are clean. Co-authored-by: openhands <openhands@all-hands.dev> * fix: suppress Add Backend modal in locked-to-cloud mode Resolves hieptl's review feedback on PR #1389: when the static server is launched with --lock-to-cloud, navigating to the app showed the Manage Backends recovery modal ("Add Backend") instead of going straight to Cloud onboarding/login. Root cause: the `openhands-onboarded` localStorage flag is origin-scoped and persists across deployments. A user who previously completed onboarding in a non-locked session on the same origin carries that flag into a locked-to-Cloud session. The stale flag suppressed first-run onboarding (`shouldShowFirstRunOnboarding` was gated on `!onboardingCompleted`), so the app fell through to the `/server_info` probe. With no usable local backend in locked mode the probe throws `AgentServerUnavailableError`, and root.tsx renders the `MissingAgentServerScreen` / `ManageBackendsModal` recovery modal. Fix: when `lockedNeedsOnboarding` is true, ignore the completion flag and force first-run onboarding (which owns the Cloud login). The non-locked path is unchanged — `onboardingCompleted` still suppresses the modal for returning users with a configured backend. Also confirms the minor cleanup from the bot review: `isLockedToCloud()` was already removed in commit addda40e; no remaining references. Adds a regression test reproducing hieptl's exact scenario (stale `openhands-onboarded` flag + locked-to-Cloud + no backend) and asserting the onboarding modal renders instead of the Manage Backends modal. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): don't skip onboarding modal for launcher-seeded backend PR #1389 generalized the OnboardingHost "returning user with a configured LLM" skip from Cloud-only to all backends (commit 78254e1b). That broke the mock-LLM E2E fresh-install / onboarding-happy-path / onboarding-regressions specs: tests/e2e/mock-llm/backends/mock-llm-auth-modes.spec.ts:57 "auth mode: fresh install with runtime-injected key › reaches the onboarding modal without pre-seeded localStorage" The mock-LLM E2E stack runs every spec serially against a single shared agent-server. Earlier specs configure an LLM profile that persists in the server's settings, so by the time the fresh-install spec runs (with a clean browser context, no `openhands-onboarded` flag, and a launcher-seeded default-local backend), the server reports `llm_api_key_is_set: true` + a non-empty model. `OnboardingHost.isBackendLlmReady` then returned true, so the host marked onboarding complete and returned null — the first-run modal never mounted and the test timed out waiting for `onboarding-step-choose-agent`. Main is green on the same test because main's skip was Cloud-only. The settings-based LLM-ready signal is unreliable for the launcher-seeded default-local backend: the agent-server can be started with an env-injected LLM key, and shared-server deployments retain configured LLMs across browser sessions. Keying first-run onboarding off the server's LLM state would suppress the modal for a genuinely fresh browser install. Fix: keep the skip for Cloud backends and for Local backends the user explicitly added via "Add Backend" (which carry a non-default id), but suppress it for the launcher-seeded default-local backend (`SEEDED_DEFAULT_BACKEND_ID`). First-run detection for that backend stays driven by the `openhands-onboarded` localStorage flag, matching main's behavior and restoring the E2E fresh-install contract. The PR's core intent (suppress the Add Backend recovery modal in locked-to-Cloud mode, commit 47619f11) is unchanged. Tests: * Updated "skips the modal for a Local backend..." to seed a user-added Local backend (non-default id) so the skip still fires for the Add-Backend scenario. * Added "still shows the modal for a launcher-seeded default-local backend even when the agent-server reports a configured LLM" to lock in the fresh-install regression. * All 3332 vitest tests pass; typecheck + lint + build clean. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): don't auto-complete onboarding for launcher-seeded backend Commit 9029e036 fixed OnboardingHost so the first-run onboarding modal shows for the launcher-seeded default-local backend even when the shared mock-LLM agent-server reports a configured LLM. But the same over-suppression existed in src/root.tsx: a separate `isBackendLlmReady` check (no default-local exclusion) fed a `markCompleted()` effect that persisted `openhands-onboarded=1` whenever the active backend reported a ready LLM — including the launcher-seeded default-local backend. That root-level effect was the remaining cause of the mock-llm-onboarding-regressions.spec.ts:16 failure ("keeps the modal open on backdrop click and Escape"): * The OnboardingModal already renders with no `onClose` on its ModalBackdrop, so backdrop clicks and Escape are no-ops — the modal itself was never closeable that way. * The test failure was actually the `expect.poll` asserting `openhands-onboarded` stays null: root.tsx's `markCompleted` effect fired (agent-server had a configured LLM from earlier serial specs) and persisted completion, even though the modal stayed mounted. Fix: apply the same `SEEDED_DEFAULT_BACKEND_ID` exclusion to root.tsx's `isBackendLlmReady` that OnboardingHost already uses. The settings-based LLM-ready signal is unreliable for the launcher-seeded default backend (env-injected keys, shared-server LLM persistence across browser sessions), so first-run detection there stays driven by the `openhands-onboarded` localStorage flag. The skip still fires for Cloud backends and for Local backends the user explicitly added via "Add Backend" (non-default id). The OnboardingModal's non-dismissible backdrop/Escape behavior is unchanged and already correct (ModalBackdrop receives no `onClose`, so `closeOnEscape`/`closeOnBackdropClick` default-true handlers call `onClose?.()` which is a no-op). Tests: * Added root.test.tsx case "does not mark onboarding complete for the launcher-seeded default-local backend even when the agent-server reports a configured LLM" — verified it fails without the root.tsx fix and passes with it. * All 3333 vitest tests pass; typecheck + lint + build clean. Co-authored-by: openhands <openhands@all-hands.dev> * fix: force Cloud replacement for stale Local backend in locked mode Critical fixes for the locked-to-Cloud flow (PR #1389 review): 1. root.tsx: the ready-backend fast-path in locked mode now requires the active backend to match the locked Cloud host (normalized via the new isSameCloudHost helper), not just . A reachable stale Local backend (or a Cloud backend on a different host) that reports a configured LLM no longer bypasses the Cloud login/replacement flow. The markCompleted effect is also guarded so it only persists completion for the legitimate locked Cloud host. 2. onboarding-modal.tsx: in locked mode, CheckBackendStep is only skipped when the active backend IS the locked Cloud host. A reachable stale Local backend keeps the backend slide visible so Cloud login can replace it. Also addresses minor review suggestions: - LOCK_TO_CLOUD_WINDOW_KEY is now module-private (only getLockedCloudHost reads it; static-server.mjs/tests use the literal string). - Extract shared isBackendLlmReady helper into its own module (is-backend-llm-ready.ts) so root.tsx and OnboardingHost stay in sync without duplicating the rule and without pulling the onboarding modal graph into root's eager bundle. - Inline the no-op initialValueOverrides intermediate in setup-llm-step. Adds regression tests for the stale-Local-backend and other-Cloud-host scenarios in both root.test.tsx and onboarding-modal.test.tsx. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): close stale-backend lock-to-Cloud bypass in CheckBackendStep (#1389) PR-review bot pointed out (HEAD 55d382be) that keeping the backend slide visible for a non-matching backend in locked mode is insufficient: CheckBackendStep itself still hits its connected-backend shortcut for a reachable stale Local backend, hiding the Cloud login UI and showing a Next button that lets the user continue as Local. Apply the same host-match guard inside CheckBackendStep. A new local `treatAsNoBackend` (= noBackendSelected || lockedCloudHostMismatch) drives: - title: ONBOARDING$LOGIN_TO_CLOUD_TITLE (not BACKEND_TITLE) - render: BackendConnectionOptions (Cloud login UI), no ConnectionBanner - no "Show configuration" toggle and no Next-shortcut action row `noBackendSelected` still governs whether handleConnected calls `addBackend` or `updateBackend`, so the stale backend is replaced rather than duplicated. Strengthen the regression test the bot flagged: it now asserts the Cloud login title and login button are visible, and that the `onboarding-backend-show-configuration` toggle, `onboarding-backend-next` button, and the (misleading) Connected subtitle are all absent. All 3,249 unit tests pass; lint and typecheck are clean. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): clear stale active org_id when replacing a Cloud backend host (#1389) PR-review bot raised one remaining state carry-over: replacing a mismatched Cloud backend updates its host/apiKey via `updateBackend`, but the persisted `active.orgId` (X-Org-Id) is keyed to the OLD host's org list. The newly-locked Cloud backend would keep sending an invalid `X-Org-Id` until the user manually re-picked an org. Fix in CheckBackendStep.handleConnected: when the submitted payload's host differs from the previously-active backend's host, call `setActive(backend.id, null)` to drop the now-invalid org selection. The user re-picks an org on the new host via the usual org switcher. Local-only edits are unaffected because Local backends always carry `active.orgId === null`, so the conditional is a no-op there. New regression test seeds a Cloud backend at other-cloud.example.com with `orgId="stale-org-from-other-host"`, drives the Cloud login button, and asserts `getActiveSelection().orgId === null` while the backend row is updated in place (same id). All 3,250 unit tests pass; lint and typecheck are clean. Co-authored-by: openhands <openhands@all-hands.dev> * fix(onboarding): dismiss modal immediately after Cloud login in locked mode (#1389) Resolves the flicker hieptl reported on PR #1389: after logging into OpenHands Cloud in locked-to-Cloud mode, the onboarding modal advanced to the Choose Agent slide (the "next window"), then got torn down by the root first-run gate, then briefly remounted via OnboardingHost — appearing to flash in and out. Cloud login IS the onboarding completion in locked mode, so: - CheckBackendStep now calls onClose (dismiss) instead of onNext when a Cloud login succeeds in locked-to-Cloud mode, so the next slide never shows. Standard (non-locked) mode still walks the user through agent/LLM setup via onNext. - root.tsx's locked-mode first-run gate now treats onboardingCompleted as authoritative once the active backend IS the locked Cloud host, so the first-run screen hides immediately on login (without waiting for the Cloud settings probe to confirm a configured LLM). The flag is still ignored when the active backend is not the locked Cloud host, preserving the stale-flag bypass protection. Added failing tests (now passing) reproducing both halves of the flicker: - onboarding-modal: Cloud login in locked mode calls onClose, not onNext. - root: the first-run screen hides immediately after Cloud login completes (post-login state with no configured LLM), instead of reopening via OnboardingHost. Co-authored-by: openhands <openhands@all-hands.dev> * chore: Remove PR-only artifacts * ci: revert docker.yml pull_request branch filter change Reverts the removal of `branches: [main]` from the `pull_request` trigger in .github/workflows/docker.yml (introduced in 5bb8049f). That change is unrelated to the locked-to-Cloud onboarding work on this PR and is out of scope. Restores the file to match main exactly so the Docker workflow again only runs on PRs targeting `main`. Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Graham Neubig <gneubig@users.noreply.github.com> Co-authored-by: neubig <398875+neubig@users.noreply.github.com> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com> Co-authored-by: hieptl <hieptl.developer@gmail.com> Co-authored-by: FraterCCCLXIII <panentheum@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
707 lines
25 KiB
JavaScript
707 lines
25 KiB
JavaScript
/**
|
|
* 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, request as httpRequest } from "node:http";
|
|
import { createReadStream } from "node:fs";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import { extname, isAbsolute, normalize, relative, resolve } from "node:path";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MIME types
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const MIME = {
|
|
".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",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".ico": "image/x-icon",
|
|
".webp": "image/webp",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".wav": "audio/wav",
|
|
".mp3": "audio/mpeg",
|
|
".webmanifest": "application/manifest+json",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".xml": "application/xml",
|
|
".map": "application/json",
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Args
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function parseArgs(argv = process.argv.slice(2)) {
|
|
const config = {
|
|
port: 3001,
|
|
host: "::",
|
|
dir: "build",
|
|
routes: {},
|
|
rejectPrefixes: [],
|
|
sessionApiKey: null,
|
|
authRequired: false,
|
|
runtimeServicesInfo: null,
|
|
lockToCloud: null,
|
|
};
|
|
|
|
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 "--auth-required":
|
|
config.authRequired = 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 "-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);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
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.
|
|
--reject-prefix <prefix> Return 503 for requests matching <prefix>
|
|
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.
|
|
*/
|
|
function makeConfigInjectionScript(
|
|
sessionApiKey,
|
|
authRequired,
|
|
runtimeServicesInfo,
|
|
lockToCloud,
|
|
) {
|
|
const parts = [];
|
|
|
|
if (sessionApiKey) {
|
|
const keyLiteral = JSON.stringify(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. JSON.stringify produces a safe JS
|
|
// string literal for the inline <script>.
|
|
parts.push(
|
|
`window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__=${JSON.stringify(runtimeServicesInfo)};`,
|
|
);
|
|
}
|
|
|
|
if (lockToCloud) {
|
|
parts.push(
|
|
`window.__AGENT_CANVAS_LOCK_TO_CLOUD__=${JSON.stringify(lockToCloud)};`,
|
|
);
|
|
}
|
|
|
|
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 } = {},
|
|
) {
|
|
let content;
|
|
try {
|
|
content = await readFile(indexPath, "utf8");
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const script = makeConfigInjectionScript(
|
|
sessionApiKey,
|
|
authRequired,
|
|
runtimeServicesInfo,
|
|
lockToCloud,
|
|
);
|
|
// 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": "no-cache",
|
|
});
|
|
if (req.method !== "HEAD") res.end(buf);
|
|
return true;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Router (kept structurally identical to scripts/ingress.mjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function createRouter(routes) {
|
|
const sortedRoutes = Object.entries(routes).sort(
|
|
([a], [b]) => b.length - a.length,
|
|
);
|
|
|
|
return function route(url) {
|
|
for (const [prefix, backend] of sortedRoutes) {
|
|
if (
|
|
url === prefix ||
|
|
url.startsWith(prefix + "/") ||
|
|
url.startsWith(prefix + "?")
|
|
) {
|
|
return backend;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
function parseBackendUrl(backendUrl) {
|
|
const url = new URL(backendUrl);
|
|
return {
|
|
hostname: url.hostname,
|
|
port:
|
|
Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
|
|
protocol: url.protocol,
|
|
};
|
|
}
|
|
|
|
function proxyRequest(req, res, backendUrl) {
|
|
const backend = parseBackendUrl(backendUrl);
|
|
|
|
const proxyReq = httpRequest(
|
|
{
|
|
hostname: backend.hostname,
|
|
port: backend.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: {
|
|
...req.headers,
|
|
host: `${backend.hostname}:${backend.port}`,
|
|
},
|
|
},
|
|
(proxyRes) => {
|
|
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
proxyRes.pipe(res, { end: true });
|
|
},
|
|
);
|
|
|
|
// Absorb client-disconnect errors (EPIPE/ECONNRESET) so the server
|
|
// process survives abrupt navigations and health-check probes.
|
|
req.on("error", () => {});
|
|
res.on("error", () => {});
|
|
|
|
proxyReq.on("error", (err) => {
|
|
console.error(`Proxy error for ${req.url} -> ${backendUrl}:`, err.message);
|
|
if (!res.headersSent) {
|
|
res.writeHead(502);
|
|
res.end(`Bad Gateway: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
req.pipe(proxyReq, { end: true });
|
|
}
|
|
|
|
function proxyWebSocket(req, socket, head, backendUrl) {
|
|
const backend = parseBackendUrl(backendUrl);
|
|
|
|
const proxyReq = httpRequest({
|
|
hostname: backend.hostname,
|
|
port: backend.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: {
|
|
...req.headers,
|
|
host: `${backend.hostname}:${backend.port}`,
|
|
},
|
|
});
|
|
|
|
// Absorb socket errors so the process survives mid-flight disconnects.
|
|
socket.on("error", () => socket.destroy());
|
|
|
|
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
proxySocket.on("error", () => proxySocket.destroy());
|
|
|
|
socket.write(
|
|
`HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n`,
|
|
);
|
|
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
|
socket.write(
|
|
`${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r\n`,
|
|
);
|
|
}
|
|
socket.write("\r\n");
|
|
if (proxyHead.length > 0) {
|
|
socket.write(proxyHead);
|
|
}
|
|
// Handle errors on both sockets so an abrupt disconnect (ECONNRESET,
|
|
// EPIPE) during test cleanup doesn't crash the process with an
|
|
// uncaught 'error' event.
|
|
proxySocket.on("error", (err) => {
|
|
console.error(
|
|
`WebSocket proxy backend socket error (${req.url}):`,
|
|
err.message,
|
|
);
|
|
socket.destroy();
|
|
});
|
|
socket.on("error", (err) => {
|
|
console.error(
|
|
`WebSocket proxy client socket error (${req.url}):`,
|
|
err.message,
|
|
);
|
|
proxySocket.destroy();
|
|
});
|
|
proxySocket.pipe(socket, { end: true });
|
|
socket.pipe(proxySocket, { end: true });
|
|
});
|
|
|
|
proxyReq.on("error", (err) => {
|
|
console.error(
|
|
`WebSocket proxy error for ${req.url} -> ${backendUrl}:`,
|
|
err.message,
|
|
);
|
|
socket.destroy();
|
|
});
|
|
|
|
proxyReq.end();
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Static file serving
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
async function tryStat(path) {
|
|
try {
|
|
const result = await stat(path);
|
|
return result.isFile() ? result : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function makeEtag(stats) {
|
|
// Match sirv's weak-ETag format: W/"<size>-<mtime ms>"
|
|
return `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"`;
|
|
}
|
|
|
|
function pickContentType(filePath) {
|
|
return MIME[extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
}
|
|
|
|
function pickCacheControl(urlPath) {
|
|
// Vite/react-router builds emit content-hashed assets under /assets/.
|
|
// Those are safe to cache forever; everything else (index.html, public/
|
|
// copies, locales) should revalidate so a rebuild is picked up.
|
|
if (urlPath.startsWith("/assets/")) {
|
|
return "public, max-age=31536000, immutable";
|
|
}
|
|
return "no-cache";
|
|
}
|
|
|
|
function looksLikeAssetRequest(urlPath) {
|
|
// If the last path segment has a known file extension, treat 404s as 404
|
|
// instead of falling back to the SPA shell. Avoids serving index.html for
|
|
// missing /favicon.ico, missing source maps, etc.
|
|
const last = urlPath.split("/").pop() ?? "";
|
|
const ext = extname(last).toLowerCase();
|
|
return Boolean(ext) && ext in MIME;
|
|
}
|
|
|
|
function isPathInsideDir(dirAbs, filePath) {
|
|
const relativePath = relative(dirAbs, filePath);
|
|
return (
|
|
relativePath === "" ||
|
|
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
|
);
|
|
}
|
|
|
|
async function serveFile(req, res, filePath, urlPath) {
|
|
const stats = await tryStat(filePath);
|
|
if (!stats) return false;
|
|
|
|
const etag = makeEtag(stats);
|
|
if (req.headers["if-none-match"] === etag) {
|
|
res.writeHead(304, { ETag: etag });
|
|
res.end();
|
|
return true;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": pickContentType(filePath),
|
|
"Content-Length": stats.size,
|
|
"Cache-Control": pickCacheControl(urlPath),
|
|
ETag: etag,
|
|
});
|
|
|
|
if (req.method === "HEAD") {
|
|
res.end();
|
|
return true;
|
|
}
|
|
|
|
createReadStream(filePath).pipe(res);
|
|
return true;
|
|
}
|
|
|
|
async function handleStatic(
|
|
req,
|
|
res,
|
|
dirAbs,
|
|
injectionOpts = {},
|
|
rejectPrefixes = [],
|
|
) {
|
|
const rawPath = req.url.split("?")[0];
|
|
let urlPath;
|
|
try {
|
|
urlPath = decodeURIComponent(rawPath);
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end("Bad Request");
|
|
return;
|
|
}
|
|
|
|
const safe = normalize(urlPath);
|
|
let filePath = resolve(dirAbs, "." + safe);
|
|
if (!isPathInsideDir(dirAbs, filePath)) {
|
|
res.writeHead(403);
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
|
|
// Directory request -> /index.html
|
|
if (urlPath.endsWith("/")) {
|
|
filePath = resolve(filePath, "index.html");
|
|
}
|
|
|
|
const needsInjection =
|
|
injectionOpts.sessionApiKey ||
|
|
injectionOpts.authRequired ||
|
|
injectionOpts.runtimeServicesInfo ||
|
|
injectionOpts.lockToCloud;
|
|
|
|
// Serve index.html with runtime config injection when configured.
|
|
if (needsInjection && filePath.endsWith("index.html")) {
|
|
if (await serveInjectedIndexHtml(req, res, filePath, injectionOpts)) return;
|
|
// Fall through to regular serveFile (handles 404 path correctly).
|
|
}
|
|
|
|
if (await serveFile(req, res, filePath, urlPath)) return;
|
|
|
|
// Reject prefixes: return 503 for known API paths that have no backend
|
|
// configured (e.g. in --frontend-only mode). Checked before SPA fallback
|
|
// so these paths never silently serve index.html.
|
|
if (rejectPrefixes.length > 0) {
|
|
for (const prefix of rejectPrefixes) {
|
|
if (
|
|
urlPath === prefix ||
|
|
urlPath.startsWith(prefix + "/") ||
|
|
urlPath.startsWith(prefix + "?")
|
|
) {
|
|
res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("Service Unavailable (no backend configured for this route)");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// SPA fallback: only for non-asset requests, and not for non-GET/HEAD.
|
|
if (
|
|
(req.method === "GET" || req.method === "HEAD") &&
|
|
!looksLikeAssetRequest(urlPath)
|
|
) {
|
|
const indexPath = resolve(dirAbs, "index.html");
|
|
if (needsInjection) {
|
|
if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts))
|
|
return;
|
|
} else if (await serveFile(req, res, indexPath, "/")) return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("Not Found");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Server
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function startStaticServer(config) {
|
|
const route = createRouter(config.routes);
|
|
const dirAbs = resolve(config.dir);
|
|
const injectionOpts = {
|
|
sessionApiKey: config.sessionApiKey || null,
|
|
authRequired: config.authRequired || false,
|
|
runtimeServicesInfo: config.runtimeServicesInfo || null,
|
|
lockToCloud: config.lockToCloud || null,
|
|
};
|
|
const rejectPrefixes = config.rejectPrefixes ?? [];
|
|
|
|
const server = createServer((req, res) => {
|
|
const backend = route(req.url);
|
|
if (backend) {
|
|
proxyRequest(req, res, backend);
|
|
return;
|
|
}
|
|
handleStatic(req, res, dirAbs, injectionOpts, rejectPrefixes).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) {
|
|
proxyWebSocket(req, socket, head, backend);
|
|
return;
|
|
}
|
|
socket.destroy();
|
|
});
|
|
|
|
return new Promise((resolveListen) => {
|
|
server.listen(config.port, config.host, () => {
|
|
console.log("");
|
|
console.log(
|
|
`Static-server + proxy listening on http://${config.host}:${config.port}/`,
|
|
);
|
|
console.log(` Static dir: ${dirAbs}`);
|
|
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);
|
|
}
|
|
}
|