Wednesday, 16 July 2026
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.
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-authsource/server/relay/TunnelRegistry.ts — in-memory
Map<tunnelId, WebSocket> of active agent connectionssource/server/relay/pending.ts — pending request callbacks
keyed by reqId for matching responses to waiting HTTP connectionssource/server/routes/tunnels.ts — full CRUD plus
GET /api/tunnels/available?purpose= discovery endpointsource/server/routes/agent.ts — WebSocket upgrade handler;
verifies JWT, confirms ownership, registers socket in registrysource/server/routes/proxy.ts — ALL /t/:tunnelId/*
relay; soft auth check for access mode, raw body forwarding, 30s timeoutsource/server/index.ts — Express + HTTP server with manual
WebSocket upgrade routing; JSON middleware applied only to /api
routes so proxy receives raw body streamsscripts/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
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.
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.
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.
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 Encryptdeploy/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 devDependencies → dependencies
so npm install --omit=dev still installs it on the serverExecStart uses the local node_modules/.bin/ts-node
to avoid version mismatches with any globally installed npx wrapper
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.
Two integration points added in Roject:
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.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.
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.
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.
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.
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.
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.
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.
When TokenExpiredError is caught on an API route and a
refreshToken cookie is present, jwtMiddleware now:
POST account.rokojori.com/api/auth/refresh server-side
with the user's refreshToken cookie value.accessToken and refreshToken cookies
on the response (same domain/options as rokojori-auth).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.
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.