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.

Session 4 — Token refresh fixes, Tunnel Agent improvements, chat UI

rokojori-auth: page-level token refresh middleware

Added a jwtMiddleware to rokojori-auth/source/server/index.ts before express.static. When a page request (non-/api/) arrives with an expired accessToken, the middleware redirects to /api/auth/refresh-session?redirect=<url>, which rotates both cookies and redirects back. Fixes the reload-to-login loop after the 1-hour TTL.

Roject auth.ts: transparent API token refresh + diagnostic logging

jwtMiddleware now handles TokenExpiredError on API routes: reads the refreshToken cookie, calls POST AUTH_INTERNAL_HOST/api/auth/refresh server-side, sets new cookies on the response, decodes the new JWT into req.user, and calls next(). Added AUTH_INTERNAL_HOST env var (defaults to AUTH_HOST); production .env sets it to http://localhost:3001 to bypass nginx. Added console.log diagnostics throughout tryRefresh for journalctl debugging.

Tunnel Agent: token refresh, logout, getToken getter

Three improvements:

  • tryRefreshTokens() — on any 401 from apiFetch, calls POST account.rokojori.com/api/auth/refresh, saves new tokens, retries once. Falls through to handleLogout() if refresh fails.
  • handleLogout() — stops all agents, clears token file, closes main window, opens login window. Wired to a ⏻ button in the window header and a "Sign Out" item in the tray menu.
  • TunnelAgentConfig.token: string replaced with getToken: () => string so every WebSocket reconnect calls the getter and picks up the current (possibly refreshed) access token, instead of being stuck with the expired one baked in at connect time.

rojo-chat-panel: mobile layout + animated thinking indicator

CSS: min-height: 0 on rojo-chat-panel and .rcp-history so the history can shrink in flex; overflow: hidden on the panel root. Focus listener on the input calls scrollIntoView after 300 ms to push the input above the mobile keyboard.

Replaced the static placeholder with a cycling animation (frames customised by user) at 250 ms per frame via setInterval. The interval is cleared and the bubble wiped the moment the first real response chunk arrives.

Session 3 — Fix session logout after ~1 hour

Root cause

jwtMiddleware in source/server/middleware/auth.ts handled expired tokens differently for page requests vs API requests. Page navigations were redirected to account.rokojori.com/api/auth/refresh-session (correct). API requests with an expired token fell into else { next(); } with req.user = undefined — so requireAuth returned 401 and the SPA had no way to recover. Because the editor never navigates after load, the page-level redirect never fired mid-session, causing every API call (save, file tree, settings) to silently fail after the 1-hour access token expired.

Fix — transparent server-side refresh

When TokenExpiredError is caught on an API route and a refreshToken cookie is present, jwtMiddleware now:

  1. Calls POST account.rokojori.com/api/auth/refresh server-side with the user's refreshToken cookie value.
  2. Sets new accessToken and refreshToken cookies on the response (same domain/options as rokojori-auth).
  3. Decodes the new access token into req.user and calls next() — the original API handler proceeds normally.

If the refresh fails (missing or expired refresh token, network error) the request falls through to requireAuth which returns 401 as before — no silent swallowing. No frontend changes required.

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.