name: hd-desktop-sse description: How HD's local workspace API + SSE works (a.k.a. the workspace API) — replaces Carbon for workspace commands so the workspace's adom-cli controls HD, not cloud Hydrogen. The workspace is the WSL2 Adom-Workspace distro by default (or a Docker container under HD_RUNTIME=docker). Covers the full pipeline, routing, env vars, field format differences, and hard-won lessons. Trigger words — HD SSE, workspace API, local API, adom-cli workspace, add-tab, move-tab, webview tab, EventSource, workspace_updated, ADOM_CARBON_URL, ADOM_HYDROGEN_URL.

Hydrogen Desktop — Local Workspace API + SSE

Architecture

Workspace adom-cli (WSL2 Adom-Workspace distro by default, or Docker container)
    → http://127.0.0.1:47083 (HD's proxy)
    → HD handles workspace commands locally + broadcasts SSE
    → HD frontend receives SSE, re-fetches workspace, renders

Cloud Docker's adom-cli (unchanged)
    → carbon.adom.inc → Hydrogen web

HD runs its own workspace API on port 47083 (reverse proxy, default — see PortConfig) so the workspace's adom-cli controls HD's UI, not cloud Hydrogen. No cross-talk between environments. (Under WSL2 the workspace reaches HD at 127.0.0.1:47083 — mirrored networking shares loopback with the Windows host.)

Port 47083 routing (the hd-proxy / reverse-proxy)

All requests hit port 47083 (reverse proxy, default — see PortConfig). The router in handle_request() decides:

Path pattern Handler Where
/api/workspaces/editor/{owner}/{repo}/current/* Local workspace API In-memory
/api/panels/webview/*, /api/panels/sandbox/* Local panel API SSE broadcast
/containers/* Local container discovery Canned response
/proxy/{port}/{path} Workspace proxy TCP forward to the workspace (WSL2 distro)
Everything else (/api/*, etc.) Carbon proxy HTTPS to carbon.adom.inc

The dev file server on port 1420 forwards /api/* directly to handle_request() (same function, not TCP — supports SSE streaming via BoxBody).

SSE Connection

Endpoint: GET /api/workspaces/editor/{owner}/{repo}/current/events

Uses tokio::sync::broadcast channel. On connect sends {"type":"connected","connectionId":"hd-local"}. Workspace mutations broadcast {"type":"workspace_updated"}.

Frontend connects in PanelWorkspaceComponent.sveltesetupSSE(). In Tauri mode, $page.params may be empty (adapter-static), so it falls back to owner=local, repo=workspace.

Critical lesson: The onopen handler PUTs the workspace state. This broadcasts workspace_updated, which the frontend re-fetches. Set skipNextSSESync = true before the PUT to prevent the feedback loop that kills the connection.

Container Discovery

adom-cli calls /containers/{slug} to get owner/repo. The slug comes from parsing VSCODE_PROXY_URI's hostname — split on -, take the LAST segment.

Response format (must match Carbon's exactly):

{
  "repository": {
    "owner": { "name": "jlauer12" },
    "name": "workspace"
  },
  "slug": "hdlocal",
  "status": "running"
}

NOT {"owner": "jlauer12", "repository": "workspace"} — adom-cli reads container["repository"]["owner"]["name"].

Workspace Env Vars

Set when the workspace is created. WSL2 is the default runtime, so the primary path is the WSL2 workspace-creation code; create_container() in hd-docker/src/lib.rs is the legacy Docker path (HD_RUNTIME=docker):

Var Value Why
VSCODE_PROXY_URI http://hd-hdlocal/proxy/{{port}}/ Slug = hdlocal (last segment after -)
ADOM_CARBON_URL http://127.0.0.1:47083 Container discovery + non-workspace API
ADOM_HYDROGEN_URL http://127.0.0.1:47083 Workspace mutations (adom-cli sends these HERE, not to Carbon)
ADOM_DESKTOP_MODE local Tells adom-cli it's a local environment

Under WSL2, 127.0.0.1 reaches the Windows host directly — mirrored networking shares loopback with the host, so the same 127.0.0.1:<port> works from inside the distro and on Windows. The canonical code-server port HD exposes on the host is 7380.

Also set in adom-cli config (persists across sessions):

adom-cli config set-url http://127.0.0.1:47083
adom-cli config set-token <session-token>

Plus manually write hydrogen_url to ~/.config/adom-cli/config.json (no CLI command for it).

API key injected at /var/run/adom/api-key by bootstrap via GET "$(cat ~/.adom/hd-control-url)/auth-token" (the control URL is http://127.0.0.1:<dynamic>).

Workspace Tree Format

The frontend serializes splits with first/second, NOT children:

{
  "type": "split",
  "id": "desktop-root-split",
  "direction": "horizontal",
  "ratio": 0.5,
  "first": { "type": "leaf", "id": "pane-left", ... },
  "second": { "type": "leaf", "id": "pane-right", ... }
}

All tree traversal functions (find_leaf_path, find_split_path, resolve_path) must handle BOTH children array and first/second keys.

REST Endpoint Field Names (adom-cli vs HD)

adom-cli sends different field names than you might expect:

Endpoint adom-cli sends HD must read
POST /tabs {panelId, panelType, displayName, initialState} Construct tab with UUID, merge initialState into panelState
POST /moves {sourcePanelId, tabId, targetPanelId} NOT sourcePanel/targetPanel
DELETE /tabs {panelId, tabId} or {name} Support both

Smoke Test (from inside the workspace)

# 1. Probe — confirms SSE alive
adom-cli hydrogen probe
# → browser_connected: true, sse_connections: 1

# 2. Get workspace
adom-cli hydrogen workspace get

# 3. Add tab
adom-cli hydrogen workspace add-tab \
  --panel-id desktop-pane-right \
  --panel-type adom/a1b2c3d4-0031-4000-a000-000000000031 \
  --display-name "Test Page" \
  --initial-state '{"url":"https://wiki.adom.inc/apps/shotlog"}'

# 4. Move tab
adom-cli hydrogen workspace move-tab \
  --from-panel-id desktop-pane-right \
  --tab-id <tab-id-from-step-3> \
  --to-panel-id desktop-pane-left

# 5. Screenshot to verify
adom-desktop hd_screenshot

What Broke and Why

SSE never connected on fresh launch

setupSSE() checks $page.params.owner and $page.params.repo. In Tauri mode with adapter-static, these are empty. Fix: fall back to owner=local, repo=workspace when in Tauri mode.

SSE connected then immediately disconnected

The onopen handler PUTs workspace state → broadcasts workspace_updated → frontend re-fetches → gets the state it just PUT → re-applies → triggers autosave → another PUT → loop. Fix: set skipNextSSESync = true before the onopen PUT.

adom-cli "Could not get owner from container info"

Three stacked issues:

  1. VSCODE_PROXY_URI was http://localhost:8080/... → slug parsed as 8080 → called /containers/8080 → Carbon 404
  2. Container info response had flat {"owner": "..."} but adom-cli reads container["repository"]["owner"]["name"]
  3. ADOM_HYDROGEN_URL wasn't set → workspace commands went to hydrogen.adom.inc instead of HD

add-tab returned "Missing 'tab' in body"

HD expected {"panelId", "tab": {id, panelType, panelState}} but adom-cli sends {"panelId", "panelType", "displayName", "initialState"} — flat format. Fix: accept both, construct tab with UUID for the flat format.

move-tab returned "Source/target panel not found"

Two issues:

  1. Handler read sourcePanel/targetPanel but adom-cli sends sourcePanelId/targetPanelId
  2. find_leaf_path only traversed children array, not first/second keys

Webview tabs were blank + opened in native browser

add_FrameNavigationStarting was blocking ALL external URLs in child iframes, including legitimate webview panel content. Fix: stop blocking frame navigations entirely — webview panels need external URLs. URL interception for window.open still works via on_new_window.

Google showed "blocked" icon

Not an HD bug. Google sends X-Frame-Options: SAMEORIGIN which prevents iframe embedding. Sites that allow embedding (adom.inc, wiki) load fine.

Port Discovery — How Any Client Finds HD

All HD ports are dynamic (configurable via ports.json). The ONE constant is the discovery port: 47080. It never changes.

GET http://127.0.0.1:47080/discover

Returns:

{
  "ok": true,
  "code_server": 7380,
  "proxy": 47083,
  "control": 47084,
  "cdp": 47085,
  "hostname": "hd.localhost"
}

Relay ports: under the WSL2 runtime the relay listens same-port 8765 (WS) / 8766 (HTTP) inside the distro and is reached same-port via mirrored loopback (or via /proxy/). There are no host 47081/47082 listeners. Clients discover the relay ports from ~/.adom/cli-relay-ports.json (fallback 8765/8766), not from /discover.

Who uses it

Client What it discovers How
adom-desktop relay Control API port (was hardcoded 9001) curl :47080/discover → use control field
adom-cli in the workspace (WSL2 distro / Docker) Proxy + Hydrogen URL Already set via ADOM_CARBON_URL env var at workspace creation
External tools / diagnostics Any port Hit discovery, get the full map
Cloud Docker (this container) All HD ports curl http://<desktop-ip>:47080/discover via relay

Implementation

Tiny std::net::TcpListener on port 47080 in hd-app/src/lib.rs. Runs on a plain OS thread (no tokio). Reads get_runtime_ports() on each request. Returns raw HTTP response with JSON body.

std::thread::spawn(move || {
    let listener = std::net::TcpListener::bind("127.0.0.1:47080").unwrap();
    for stream in listener.incoming() {
        let ports = hd_control::get_runtime_ports();
        let body = serde_json::json!({ "ok": true, ...ports });
        // write HTTP/1.1 200 OK + JSON
    }
});

Port defaults (PortConfig)

Service Default Port User-visible? Workspace Port
Discovery 47080 (FIXED) No n/a
Code-server 7380 Yes (in webview URLs) 8080
Proxy 47083 No n/a
Control API 47084 No n/a
CDP 47085 No n/a
Relay WS — (same-port under WSL2) No 8765
Relay HTTP — (same-port under WSL2) No 8766

Config stored at %APPDATA%\hydrogen-desktop\ports.json. On first launch, defaults are written without scanning. Workspace-side ports (8080, 8765, 8766) are always fixed — only host-side ports are dynamic. The relay (8765/8766) is reached same-port under WSL2 (mirrored loopback) — no host 47081/47082 mapping; discover it from ~/.adom/cli-relay-ports.json (fallback 8765/8766).

Files

File What
src-tauri/crates/hd-proxy/src/lib.rs Workspace API, SSE, Carbon proxy, tree mutations (~1500 lines)
src-tauri/crates/hd-docker/src/lib.rs Container env vars (CARBON_URL, HYDROGEN_URL, VSCODE_PROXY_URI)
src-tauri/crates/hd-control/src/lib.rs /auth-token endpoint, /ports endpoint, /setup/* routes
src-tauri/crates/hd-control/src/ports.rs PortConfig struct, load/save/resolve, DISCOVERY_PORT constant
src-tauri/crates/hd-app/src/lib.rs Discovery server on :47080, port resolution at startup
src/lib/stores/portStore.ts Frontend Svelte store, initPorts(), controlUrl(), codeServerUrl()
src/lib/components/editor/workspaces/PanelWorkspaceComponent.svelte Frontend SSE setup, autosave, workspace re-fetch
scripts/hd-bootstrap.sh Sets adom-cli config, injects API key, uses HD_CONTROL_PORT env var