-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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. |
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
Key nodes:
-
/termsprawl-boot.jsis 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 isscript-src 'self';bootJsis literallywindow.__TERMPRAWL_WS_TOKEN=…, and the shim reads that global and stores it inlocalStoragefor reconnects. -
routerAuthenticated(req)is inert unlessTERMSPRAWL_SPACE_HEADERis set (which the space manager does only inside a hosted container). When set, the request must present a matchingX-Termsprawl-Spaceheader — compared withtimingSafeCompare— otherwise the boot token endpoint answers401before 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
/toindex.html, everything else to a path underRENDERER_DIR, resolved throughresolveContainedPath(traversal guard) and gated byisRegularFile(). The directory check is deliberate: a directory passes an existence check but throwsEISDIRon read, and an uncaught throw takes down the process — "one unauthenticatedGET /assetswas 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 manualserver.on('upgrade'): non-/wspaths get a404+socket.destroy(), failed auth gets401+ destroy, and only then doeshandleUpgradeemitconnection.
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
- Messages are parsed defensively: bad JSON, non-objects,
t === 'res', or a missingmethodare dropped silently. -
t === 'send'is fire-and-forget (dispatch({ id: 0, … }).then(markApplied));t === 'req'replies with theRpcResponseonly if the socket is still OPEN. -
MUTATING_METHODS(workspace:save-nodes,project:add|import|delete|rename|close|archive|reopen|update-settings,terminal:close) triggersopts.onRequest(method)— but only inmarkApplied(), 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
dispatchreturned fromcreateAppis 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 thesendAllclosure that iterates theclients: Set<WebSocket>, so all downstream events (pty data/exit, agent status, update status) are broadcast as{ t: 'evt', channel, payload }.
| 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:
-
assertSafeServerBind(HOST, spaceHeaderPresent)— on failure, log andprocess.exit(1). -
createAuthPolicy(envToken); warn ifpolicy.disabled. -
createApp({ auth, onRequest }), whereonRequestforwards tospacePusher?.markDirty(). - Optionally restore + start space sync.
restoreFromCloudruns before the welcome-project check, so a restored space is never mistaken for an empty one. -
listenWithFallback(PORT)— triesPORT,PORT+1, … up to 10 attempts;EADDRINUSEadvances, 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. - Welcome project: if
workspace:snapshotshowsindex.projects.length === 0, callproject:add ['Welcome', null, undefined]and, if it returned an id,workspace:save-nodeswith a singlen-welcometerminal node carrying explicitwidth/height/styleso it renders at terminal size rather than collapsing. - GitHub import suggestions, space mode only: a fire-and-forget async IIFE dynamically imports
core/github-import, lists suggested repos, and broadcastsIPC.githubSuggest. It never blocks boot and never throws. - Auto-save: a 30s interval snapshots the workspace, JSON-compares node arrays per project against
currentProjectNodes, writes changed projects viaworkspace:save-nodes, and callsspacePusher?.markDirty(). This poll is described as a safety net; the renderer's own saves are the real change signal, andtrackDirty()in the factory is an unused no-op remnant.
SIGTERM and SIGINT both call shutdown(), which:
- Flushes space sync first:
Promise.race([spacePusher.flush(), 5s timeout]), best-effort. - Re-reads
workspace:snapshotand saves every project's nodes — a final dirty flush. - Calls
app.close(), which runsdisposeHandlers(),agents.stop(), closes every tracked client, awaitswss.close(), then awaitsserver.close()— ordered so no handler or bridge outlives the listener. -
process.exit(0).
-
Fail-closed auth: no policy passed to
createAppyields a freshly generated token; the absence ofTERMSPRAWL_SERVER_TOKENdoes 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/rendereror 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.
-
createApp(opts)accepts an injectedAuthPolicyand anonRequesthook — the seam used by tests and by the space-sync pusher. -
MUTATING_METHODSmust be extended when a new persisted-canvas RPC is added, or space pushes will lag behind the 30s poll. -
ServerPlatform.broadcastis the single downstream channel for new event types; theMIMEtable 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance