Wednesday, 16 July 2026

Session History

Brainstormed and scaffolded rokojori-tunnel; Phase 1 relay built; Electron agent app, production deployment, streaming protocol, Roject integration, and client-side chunk animation added in a second session.

What we built

rokojori-tunnel — Phase 1 (relay server)

New standalone service at C:\rokojori\projects\web-projects\tunnel, same stack as the rest of the ecosystem (Node.js, Express, ts-node, JSON file storage, shared JWT verification).

  • source/server/db.ts — JSON file storage for tunnel configs (build/data/db/tunnels.json)
  • source/server/middleware/requireAuth.ts — JWT verification, same pattern as rokojori-auth
  • source/server/relay/TunnelRegistry.ts — in-memory Map<tunnelId, WebSocket> of active agent connections
  • source/server/relay/pending.ts — pending request callbacks keyed by reqId for matching responses to waiting HTTP connections
  • source/server/routes/tunnels.ts — full CRUD plus GET /api/tunnels/available?purpose= discovery endpoint
  • source/server/routes/agent.ts — WebSocket upgrade handler; verifies JWT, confirms ownership, registers socket in registry
  • source/server/routes/proxy.tsALL /t/:tunnelId/* relay; soft auth check for access mode, raw body forwarding, 30s timeout
  • source/server/index.ts — Express + HTTP server with manual WebSocket upgrade routing; JSON middleware applied only to /api routes so proxy receives raw body streams
  • scripts/test-agent.ts — standalone Node.js agent for testing before the Electron app exists; connects via WebSocket and forwards inbound relay requests to a local port

End-to-end test — local LLM over tunnel

Registered a tunnel via POST /api/tunnels, started the test agent forwarding to port 8900 (gemma4-coding-Q4_K_M.gguf running locally), and sent an OpenAI-compatible /v1/chat/completions request through the relay. Full round-trip succeeded — request forwarded, response relayed back, streaming token count confirmed in the response.

tunneling.html outline document

New plan document at workspace/outline/tunneling.html covering: the relay mechanic (three-leg model, minimal inspection, raw byte forwarding), access modes (private / public / password-protected), tunnel metadata shape and purpose tags, all three components (relay server, Electron agent app, rokojori-auth permission), API endpoint reference, Roject LLM provider integration example, file structure, and phased implementation plan.

Session 2 — Electron agent, deployment, streaming, Roject integration

Electron Tunnel Agent app

Full Electron desktop app at tunnel/electron-agent/ — system tray icon, login window (email + password → account.rokojori.com), persistent token storage in userData/tokens.json, and a main window with a tunnel list. Each tunnel row shows its status (green dot when agent connected) and Start / Stop / Delete buttons. A modal handles creating new tunnels. The agent process runs in Electron's main process via the existing TunnelAgent class (WebSocket, auto-reconnect, exponential back-off). Build: npm run electron:dev.

Production deployment — tunnel.rokojori.com

Deployed to the same server as roject.rokojori.com. Key config:

  • deploy/nginx-tunnel.conf — HTTP proxy with WebSocket upgrade for /api/agent/ (proxy_read_timeout 3600s), TLS via Let's Encrypt
  • deploy/tunnel-rokojori.service — systemd unit; EnvironmentFile points to /opt/tunnel-rokojori/.env; PORT=3003 (3002 was already taken by styles.rokojori.com)
  • ts-node moved from devDependenciesdependencies so npm install --omit=dev still installs it on the server
  • ExecStart uses the local node_modules/.bin/ts-node to avoid version mismatches with any globally installed npx wrapper

Streaming relay protocol

Replaced the single-shot RelayResponse (collect all chunks, send one JSON blob) with a three-message streaming protocol over the agent WebSocket: res_start (status + headers), res_data (base64 chunk), res_end. The proxy route calls res.flushHeaders() on the first res_start, streams each chunk with res.write(), and ends with res.end(). The 30 s timeout was extended to 120 s and cancelled the moment res_start arrives. Legacy single-shot RelayResponse messages still work via a backward-compat path in pending.ts.

Roject integration — tunnel browser & chat

Two integration points added in Roject:

  • Browse button in rojo-settings-panel — calls GET /api/rojos/tunnels/browse (Roject server proxies to tunnel.rokojori.com/api/tunnels/available), renders a picker list; clicking an entry fills the Tunnel ID field automatically.
  • Chat via tunnel — fixed baseURL to append /v1 (/t/:tunnelId/v1); fixed auth token forwarding (cookie OR Authorization Bearer header — dual-source pattern) so the Electron app can use tunnels without a cookie.
  • TUNNEL_SERVER_URL env var used throughout; defaults to https://tunnel.rokojori.com in production.

Client-side chunk animation

TCP batching through the relay delivers larger chunks than direct local streaming, making the chat feel like it buffers instead of streams. Fixed client-side in rojo-chat-panel.ts: a typeText() function splits any chunk longer than 6 characters into 3-character pieces and awaits 18 ms between each piece (~166 chars/sec). Chunks of ≤ 6 chars are displayed instantly, so true single-token responses from a local direct LLM are unaffected.

Key decisions

Separate service, not part of rokojori-auth. Auth stays focused on identity. tunnel.rokojori.com is its own Express process that verifies the shared JWT but owns all relay logic independently.

HTTP-only for Phase 1. Stable Diffusion (AUTOMATIC1111) and OpenAI-compatible LLMs (Ollama, LM Studio) all speak HTTP with SSE streaming — no WebSocket from the app side needed. Language servers use raw TCP and are out of scope for now.

JSON envelope protocol for Phase 1. Requests and responses are wrapped as { reqId, method, path, headers, body (base64) } JSON messages over the agent WebSocket. No raw byte framing needed at this stage; the JSON envelope is simple enough and sidesteps binary WebSocket complexity.

No express.json() on proxy routes. The JSON middleware is applied only to /api/tunnels so the proxy handler always receives a raw readable body stream, regardless of content type.

ts-node in production dependencies. Moving ts-node (and typescript) from devDependencies to dependencies is the simplest way to ensure they survive npm install --omit=dev on the server without a separate build step.

Dual-source token extraction. The Roject server extracts the JWT from the accessToken cookie first, then falls back to the Authorization: Bearer header. This lets both browser-based (cookie) and Electron-based (Bearer) clients use the tunnel chat without separate code paths.

Client-side fake streaming instead of server changes. Splitting large relay chunks in the browser is zero-risk and zero-latency overhead — no server changes, no protocol changes. The animation runs entirely in the UI and is transparent to the LLM backend.