Skip to content

Server Bootstrap & HTTP WebSocket Entry

dazeb edited this page Sep 17, 2026 · 2 revisions

Server Bootstrap & HTTP/WebSocket Entry

src/server/index.ts is the non-Electron entrypoint for Server Edition. It serves the built renderer from out/renderer with the browser shim injected, and tunnels the renderer's window.termsprawl calls to the same electron-free core services the desktop main process uses, over a WebSocket RPC channel. The file has two halves: createApp() — a testable factory that returns an unstarted server, the single dispatch, and a close() — and a boot driver that only runs when TERMSPRAWL_SERVER_ENTRY === '1', which adds bind validation, auth policy, port fallback, welcome-project seeding, space sync, auto-save, and signal-driven shutdown.

Module map

File Responsibility
src/server/index.ts Entrypoint. Static serving + shim injection, HTTP routes, WS upgrade gate, RPC framing, createApp() factory, boot driver, shutdown.
src/server/platform.ts ServerPlatform implements CorePlatform. Holds userDataPath; broadcast(channel, payload) fans out to every OPEN socket. defaultServerDataPath() resolves TERMSPRAWL_DATA ?? ~/.config/termsprawl.
src/server/server-auth.ts Pure, electron-free auth policy: createAuthPolicy(), authorizeUpgrade(), timingSafeCompare() over SHA-256 hashes so token length never leaks.
src/server/server-boundary.ts Boundary guards imported by the entrypoint: assertSafeServerBind(host, inSpace) and resolveContainedPath(root, relative).
src/server/rpc.ts createDispatcher() / RpcDispatcher / RpcResponse — the request→handler indirection the entrypoint instantiates exactly once.
src/server/handlers.ts buildHandlers(platform, { onDispose }) — the method table bound to the platform seam.
src/server/agent-bridge.ts startAgentBridge(platform) — started during createApp; its hookUrl is surfaced on the app object and printed at boot.
src/server/space-sync-wiring.ts restoreFromCloud() and createSpacePusher() — cloud space snapshot restore/flush, active only when the space env is present.
src/server/shim.js The browser-side shim served verbatim at /termsprawl-shim.js; unsupported surfaces (git/cloud/accounts/agent-hooks) reject renderer-side.
Tests server.test.ts, server-boot-gate.test.ts, server-boundary.test.ts, security.test.ts (plus rpc, handlers-links, agent-bridge, space-sync-wiring) pin the factory, the token-bootstrap gate, bind/path guards, and auth regressions.

Request and connection entry points

flowchart TD
  subgraph HTTP["HTTP request path"]
    R["request"] --> P1{"pathname"}
    P1 -->|"/termsprawl-shim.js"| S1["200 text/javascript<br/>shimSource"]
    P1 -->|"/termsprawl-boot.js"| G1{"routerAuthenticated?<br/>TERMSPRAWL_SPACE_HEADER set"}
    G1 -->|"no"| S2["401 router required"]
    G1 -->|"yes or header absent"| S3["200 no-store<br/>window.__TERMPRAWL_WS_TOKEN"]
    P1 -->|"/"| S4["index.html<br/>boot.js + shim.js tags injected"]
    P1 -->|"anything else"| G2{"resolveContainedPath<br/>+ isRegularFile"}
    G2 -->|"no"| S5["404 not found"]
    G2 -->|"yes"| S6["200, MIME by extension"]
  end
  subgraph WS["WebSocket upgrade path"]
    U["upgrade"] --> P2{"url === '/ws'?"}
    P2 -->|"no"| S7["404, socket.destroy()"]
    P2 -->|"yes"| G3{"authorizeUpgrade<br/>Bearer header or ?token="}
    G3 -->|"no"| S8["401, socket.destroy()"]
    G3 -->|"yes"| S9["wss.handleUpgrade<br/>clients.add(socket)"]
  end
Loading

Key nodes:

  • /termsprawl-boot.js is the only place the WS token leaves the process. It is served as a real same-origin script rather than inline HTML because the page's CSP is script-src 'self'; bootJs is literally window.__TERMPRAWL_WS_TOKEN=…, and the shim reads that global and stores it in localStorage for reconnects.
  • routerAuthenticated(req) is inert unless TERMSPRAWL_SPACE_HEADER is set (which the space manager does only inside a hosted container). When set, the request must present a matching X-Termsprawl-Space header — compared with timingSafeCompare — otherwise the boot token endpoint answers 401 before any token bytes are written. This is defense-in-depth against another tenant on a shared bridge hitting the app port directly.
  • Static serving maps / to index.html, everything else to a path under RENDERER_DIR, resolved through resolveContainedPath (traversal guard) and gated by isRegularFile(). The directory check is deliberate: a directory passes an existence check but throws EISDIR on read, and an uncaught throw takes down the process — "one unauthenticated GET /assets was enough."
  • Index injection puts <script src="/termsprawl-boot.js"></script><script src="/termsprawl-shim.js"></script> immediately after <head> (or prefixed to the document if no <head> exists) so the token is in place before the shim runs.
  • Upgrade uses WebSocketServer({ noServer: true }) with a manual server.on('upgrade'): non-/ws paths get a 404 + socket.destroy(), failed auth gets 401 + destroy, and only then does handleUpgrade emit connection.

RPC framing and the single-dispatcher invariant

sequenceDiagram
  participant Shim as Browser shim
  participant WS as index.ts connection handler
  participant D as RpcDispatcher (the one instance)
  participant Svc as Core services via ServerPlatform

  Shim->>WS: {t:'req', id, method, args}
  WS->>D: dispatch({id, method, args})
  D->>Svc: handler bound to platform
  Svc-->>D: result
  D-->>WS: RpcResponse
  WS->>WS: markApplied() — mutating methods only, after apply
  WS-->>Shim: {t:'res', id, ok, result, error}

  Svc->>WS: platform.broadcast(channel, payload)
  WS-->>Shim: {t:'evt', channel, payload} to every OPEN client
Loading
  • Messages are parsed defensively: bad JSON, non-objects, t === 'res', or a missing method are dropped silently.
  • t === 'send' is fire-and-forget (dispatch({ id: 0, … }).then(markApplied)); t === 'req' replies with the RpcResponse only if the socket is still OPEN.
  • MUTATING_METHODS (workspace:save-nodes, project:add|import|delete|rename|close|archive|reopen|update-settings, terminal:close) triggers opts.onRequest(method) — but only in markApplied(), i.e. after the mutation is applied. The inline comment records why: the coalescing space pusher snapshots live state, so marking dirty on message arrival races the save and the push carries stale nodes with nothing left to re-mark dirty.
  • The dispatch returned from createApp is documented as the app's one dispatcher. The boot driver must reuse it rather than building its own, because WS traffic and boot-time calls must share the same handler/store instances — separate dispatchers would desync the workspace store's revs map.
  • ServerPlatform's constructor takes the sendAll closure that iterates the clients: Set<WebSocket>, so all downstream events (pty data/exit, agent status, update status) are broadcast as { t: 'evt', channel, payload }.

Configuration and boot driver

Env / arg Effect
TERMSPRAWL_SERVER_ENTRY=1 Enables the boot driver; without it createApp can be imported without opening a listener.
PORT or process.argv[2] Listen port, default 3110.
TERMSPRAWL_SERVER_HOST Bind host, default 127.0.0.1; passed to assertSafeServerBind before anything listens.
TERMSPRAWL_SERVER_TOKEN Absent → fresh 24-byte hex token generated and printed once. A 48-hex value is honored as-is (scripted restarts). '' explicitly disables auth and logs a loud warning ("any local process can drive this instance. Loopback bind only.").
TERMSPRAWL_DATA Data dir for settings, tmux config/sockets, scrollback, index.
TERMSPRAWL_SPACE_HEADER Arms the container-side /termsprawl-boot.js gate.
TS_CLOUD_API + TS_SPACE_BOOT_TOKEN Enables restoreFromCloud() before the welcome check and createSpacePusher() for pushes.

Boot order in the driver:

  1. assertSafeServerBind(HOST, spaceHeaderPresent) — on failure, log and process.exit(1).
  2. createAuthPolicy(envToken); warn if policy.disabled.
  3. createApp({ auth, onRequest }), where onRequest forwards to spacePusher?.markDirty().
  4. Optionally restore + start space sync. restoreFromCloud runs before the welcome-project check, so a restored space is never mistaken for an empty one.
  5. listenWithFallback(PORT) — tries PORT, PORT+1, … up to 10 attempts; EADDRINUSE advances, other errors reject, exhaustion logs and exits. On success it prints host/port, the auth token (once), the data dir, and the agent hook URL.
  6. Welcome project: if workspace:snapshot shows index.projects.length === 0, call project:add ['Welcome', null, undefined] and, if it returned an id, workspace:save-nodes with a single n-welcome terminal node carrying explicit width/height/style so it renders at terminal size rather than collapsing.
  7. GitHub import suggestions, space mode only: a fire-and-forget async IIFE dynamically imports core/github-import, lists suggested repos, and broadcasts IPC.githubSuggest. It never blocks boot and never throws.
  8. Auto-save: a 30s interval snapshots the workspace, JSON-compares node arrays per project against currentProjectNodes, writes changed projects via workspace:save-nodes, and calls spacePusher?.markDirty(). This poll is described as a safety net; the renderer's own saves are the real change signal, and trackDirty() in the factory is an unused no-op remnant.

Shutdown safety

SIGTERM and SIGINT both call shutdown(), which:

  1. Flushes space sync first: Promise.race([spacePusher.flush(), 5s timeout]), best-effort.
  2. Re-reads workspace:snapshot and saves every project's nodes — a final dirty flush.
  3. Calls app.close(), which runs disposeHandlers(), agents.stop(), closes every tracked client, awaits wss.close(), then awaits server.close() — ordered so no handler or bridge outlives the listener.
  4. process.exit(0).

Boundaries and failure modes

  • Fail-closed auth: no policy passed to createApp yields a freshly generated token; the absence of TERMSPRAWL_SERVER_TOKEN does not disable auth. Disabling requires the explicit empty string.
  • Timing-safe comparison hashes both sides before timingSafeEqual, so unequal lengths do not leak length.
  • Path containment + regular-file check are the two guards that keep static serving from escaping out/renderer or crashing the process on a directory read.
  • Port exhaustion terminates the process after 11 failed binds rather than silently not listening.
  • Dirty-mark ordering and the single-dispatcher rule are the two invariants most likely to be broken by future edits.

Extension points

  • createApp(opts) accepts an injected AuthPolicy and an onRequest hook — the seam used by tests and by the space-sync pusher.
  • MUTATING_METHODS must be extended when a new persisted-canvas RPC is added, or space pushes will lag behind the 30s poll.
  • ServerPlatform.broadcast is the single downstream channel for new event types; the MIME table is the single place to teach static serving a new asset extension.
  • Unsupported desktop surfaces are rejected in src/server/shim.js, not in the dispatcher — new capabilities should be added as handlers first, then exposed through the shim (see the RPC Dispatch and Renderer Shim pages).

Sources: src/server/index.ts, src/server/index.ts, src/server/index.ts, src/server/index.ts, src/server/platform.ts, src/server/server-auth.ts, src/server/rpc.ts, src/server/handlers.ts, src/server/agent-bridge.ts, src/server/space-sync-wiring.ts, src/server/server-boundary.ts, src/server/shim.js, src/server/server.test.ts, src/server/server-boot-gate.test.ts, src/server/security.test.ts, src/server/server-boundary.test.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally