2026-07-16 05:27:48 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Wednesday, 16 July 2026 — Roject< / title >
< link rel = "stylesheet" href = "../../../../_assets_/styles.css" >
< link rel = "stylesheet" href = "../../../../_assets_/nav.css" >
< / head >
< body >
< div class = "page" >
< header >
< p class = "date" > Wednesday, 16 July 2026< / p >
< h1 > Session History< / h1 >
tunnel: Electron agent, production deployment, streaming, Roject integration, chunk animation
— Electron Tunnel Agent app (tray, login, tunnel list, start/stop/delete/create)
— tunnel.rokojori.com deployed (nginx WS upgrade, systemd, certbot, port 3003)
— Streaming relay protocol: res_start/res_data/res_end replaces single-shot response
— Roject: browse-tunnels button in rojo-settings-panel, /tunnels/browse proxy route
— Roject: LLM chat via tunnel (TUNNEL_SERVER_URL, /v1 path, dual-source JWT)
— rojo-chat-panel: typeText() splits large relay chunks for smooth streaming appearance
— Boards, outline, and history updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 13:46:51 +00:00
< p class = "subtitle" > 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.< / p >
2026-07-16 05:27:48 +00:00
< / header >
< section >
< h2 > What we built< / h2 >
< div class = "card" >
< h3 > rokojori-tunnel — Phase 1 (relay server)< / h3 >
< p >
New standalone service at < code > C:\rokojori\projects\web-projects\tunnel< / code > ,
same stack as the rest of the ecosystem (Node.js, Express, ts-node, JSON file
storage, shared JWT verification).
< / p >
< ul style = "line-height:1.9;margin-top:0.75rem" >
< li > < code > source/server/db.ts< / code > — JSON file storage for tunnel configs
(< code > build/data/db/tunnels.json< / code > )< / li >
< li > < code > source/server/middleware/requireAuth.ts< / code > — JWT verification,
same pattern as rokojori-auth< / li >
< li > < code > source/server/relay/TunnelRegistry.ts< / code > — in-memory
< code > Map< tunnelId, WebSocket> < / code > of active agent connections< / li >
< li > < code > source/server/relay/pending.ts< / code > — pending request callbacks
keyed by < code > reqId< / code > for matching responses to waiting HTTP connections< / li >
< li > < code > source/server/routes/tunnels.ts< / code > — full CRUD plus
< code > GET /api/tunnels/available?purpose=< / code > discovery endpoint< / li >
< li > < code > source/server/routes/agent.ts< / code > — WebSocket upgrade handler;
verifies JWT, confirms ownership, registers socket in registry< / li >
< li > < code > source/server/routes/proxy.ts< / code > — < code > ALL /t/:tunnelId/*< / code >
relay; soft auth check for access mode, raw body forwarding, 30s timeout< / li >
< li > < code > source/server/index.ts< / code > — Express + HTTP server with manual
WebSocket upgrade routing; JSON middleware applied only to < code > /api< / code >
routes so proxy receives raw body streams< / li >
< li > < code > scripts/test-agent.ts< / code > — standalone Node.js agent for
testing before the Electron app exists; connects via WebSocket and forwards
inbound relay requests to a local port< / li >
< / ul >
< / div >
< div class = "card" >
< h3 > End-to-end test — local LLM over tunnel< / h3 >
< p >
Registered a tunnel via < code > POST /api/tunnels< / code > , started the test agent
forwarding to port 8900 (< code > gemma4-coding-Q4_K_M.gguf< / code > running locally),
and sent an OpenAI-compatible < code > /v1/chat/completions< / code > request through
the relay. Full round-trip succeeded — request forwarded, response relayed back,
streaming token count confirmed in the response.
< / p >
< / div >
< div class = "card" >
< h3 > tunneling.html outline document< / h3 >
< p >
New plan document at < code > workspace/outline/tunneling.html< / code > 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.
< / p >
< / div >
< / section >
tunnel: Electron agent, production deployment, streaming, Roject integration, chunk animation
— Electron Tunnel Agent app (tray, login, tunnel list, start/stop/delete/create)
— tunnel.rokojori.com deployed (nginx WS upgrade, systemd, certbot, port 3003)
— Streaming relay protocol: res_start/res_data/res_end replaces single-shot response
— Roject: browse-tunnels button in rojo-settings-panel, /tunnels/browse proxy route
— Roject: LLM chat via tunnel (TUNNEL_SERVER_URL, /v1 path, dual-source JWT)
— rojo-chat-panel: typeText() splits large relay chunks for smooth streaming appearance
— Boards, outline, and history updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 13:46:51 +00:00
< section >
< h2 > Session 2 — Electron agent, deployment, streaming, Roject integration< / h2 >
< div class = "card" >
< h3 > Electron Tunnel Agent app< / h3 >
< p >
Full Electron desktop app at < code > tunnel/electron-agent/< / code > — system tray icon,
login window (email + password → account.rokojori.com), persistent token storage in
< code > userData/tokens.json< / code > , 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 < code > TunnelAgent< / code > class (WebSocket, auto-reconnect,
exponential back-off). Build: < code > npm run electron:dev< / code > .
< / p >
< / div >
< div class = "card" >
< h3 > Production deployment — tunnel.rokojori.com< / h3 >
< p >
Deployed to the same server as roject.rokojori.com. Key config:
< / p >
< ul style = "line-height:1.9;margin-top:0.75rem" >
< li > < code > deploy/nginx-tunnel.conf< / code > — HTTP proxy with WebSocket upgrade for
< code > /api/agent/< / code > (< code > proxy_read_timeout 3600s< / code > ), TLS via Let's Encrypt< / li >
< li > < code > deploy/tunnel-rokojori.service< / code > — systemd unit; < code > EnvironmentFile< / code >
points to < code > /opt/tunnel-rokojori/.env< / code > ; < code > PORT=3003< / code >
(3002 was already taken by styles.rokojori.com)< / li >
< li > < code > ts-node< / code > moved from < code > devDependencies< / code > → < code > dependencies< / code >
so < code > npm install --omit=dev< / code > still installs it on the server< / li >
< li > < code > ExecStart< / code > uses the local < code > node_modules/.bin/ts-node< / code >
to avoid version mismatches with any globally installed npx wrapper< / li >
< / ul >
< / div >
< div class = "card" >
< h3 > Streaming relay protocol< / h3 >
< p >
Replaced the single-shot < code > RelayResponse< / code > (collect all chunks, send one JSON blob)
with a three-message streaming protocol over the agent WebSocket:
< code > res_start< / code > (status + headers), < code > res_data< / code > (base64 chunk),
< code > res_end< / code > . The proxy route calls < code > res.flushHeaders()< / code > on the first
< code > res_start< / code > , streams each chunk with < code > res.write()< / code > , and ends with
< code > res.end()< / code > . The 30 s timeout was extended to 120 s and cancelled the moment
< code > res_start< / code > arrives. Legacy single-shot < code > RelayResponse< / code > messages
still work via a backward-compat path in < code > pending.ts< / code > .
< / p >
< / div >
< div class = "card" >
< h3 > Roject integration — tunnel browser & chat< / h3 >
< p >
Two integration points added in Roject:
< / p >
< ul style = "line-height:1.9;margin-top:0.75rem" >
< li > < strong > Browse button< / strong > in < code > rojo-settings-panel< / code > — calls
< code > GET /api/rojos/tunnels/browse< / code > (Roject server proxies to
< code > tunnel.rokojori.com/api/tunnels/available< / code > ), renders a picker list;
clicking an entry fills the Tunnel ID field automatically.< / li >
< li > < strong > Chat via tunnel< / strong > — fixed < code > baseURL< / code > to append
< code > /v1< / code > (< code > /t/:tunnelId/v1< / code > ); fixed auth token forwarding
(cookie OR Authorization Bearer header — dual-source pattern) so the Electron app
can use tunnels without a cookie.< / li >
< li > < code > TUNNEL_SERVER_URL< / code > env var used throughout; defaults to
< code > https://tunnel.rokojori.com< / code > in production.< / li >
< / ul >
< / div >
< div class = "card" >
< h3 > Client-side chunk animation< / h3 >
< p >
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
< code > rojo-chat-panel.ts< / code > : a < code > typeText()< / code > 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.
< / p >
< / div >
< / section >
2026-07-16 16:44:10 +00:00
< section >
< h2 > Session 3 — Fix session logout after ~1 hour< / h2 >
< div class = "card" >
< h3 > Root cause< / h3 >
< p >
< code > jwtMiddleware< / code > in < code > source/server/middleware/auth.ts< / code >
handled expired tokens differently for page requests vs API requests.
Page navigations were redirected to
< code > account.rokojori.com/api/auth/refresh-session< / code > (correct).
API requests with an expired token fell into < code > else { next(); }< / code >
with < code > req.user = undefined< / code > — so < code > requireAuth< / code >
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.
< / p >
< / div >
< div class = "card" >
< h3 > Fix — transparent server-side refresh< / h3 >
< p >
When < code > TokenExpiredError< / code > is caught on an API route and a
< code > refreshToken< / code > cookie is present, < code > jwtMiddleware< / code > now:
< / p >
< ol style = "line-height:1.9;margin-top:0.75rem" >
< li > Calls < code > POST account.rokojori.com/api/auth/refresh< / code > server-side
with the user's < code > refreshToken< / code > cookie value.< / li >
< li > Sets new < code > accessToken< / code > and < code > refreshToken< / code > cookies
on the response (same domain/options as rokojori-auth).< / li >
< li > Decodes the new access token into < code > req.user< / code > and calls
< code > next()< / code > — the original API handler proceeds normally.< / li >
< / ol >
< p style = "margin-top:0.75rem" >
If the refresh fails (missing or expired refresh token, network error)
the request falls through to < code > requireAuth< / code > which returns 401
as before — no silent swallowing. No frontend changes required.
< / p >
< / div >
< / section >
2026-07-16 05:27:48 +00:00
< section >
< h2 > Key decisions< / h2 >
< div class = "card" >
< p >
< strong > Separate service, not part of rokojori-auth.< / strong >
Auth stays focused on identity. tunnel.rokojori.com is its own Express process
that verifies the shared JWT but owns all relay logic independently.
< / p >
< / div >
< div class = "card" >
< p >
< strong > HTTP-only for Phase 1.< / strong > 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.
< / p >
< / div >
< div class = "card" >
< p >
< strong > JSON envelope protocol for Phase 1.< / strong > Requests and responses are
wrapped as < code > { reqId, method, path, headers, body (base64) }< / code > 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.
< / p >
< / div >
< div class = "card" >
< p >
< strong > No express.json() on proxy routes.< / strong > The JSON middleware is applied
only to < code > /api/tunnels< / code > so the proxy handler always receives a raw
readable body stream, regardless of content type.
< / p >
< / div >
tunnel: Electron agent, production deployment, streaming, Roject integration, chunk animation
— Electron Tunnel Agent app (tray, login, tunnel list, start/stop/delete/create)
— tunnel.rokojori.com deployed (nginx WS upgrade, systemd, certbot, port 3003)
— Streaming relay protocol: res_start/res_data/res_end replaces single-shot response
— Roject: browse-tunnels button in rojo-settings-panel, /tunnels/browse proxy route
— Roject: LLM chat via tunnel (TUNNEL_SERVER_URL, /v1 path, dual-source JWT)
— rojo-chat-panel: typeText() splits large relay chunks for smooth streaming appearance
— Boards, outline, and history updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 13:46:51 +00:00
< div class = "card" >
< p >
< strong > ts-node in production dependencies.< / strong > Moving ts-node (and typescript)
from devDependencies to dependencies is the simplest way to ensure they survive
< code > npm install --omit=dev< / code > on the server without a separate build step.
< / p >
< / div >
< div class = "card" >
< p >
< strong > Dual-source token extraction.< / strong > The Roject server extracts the JWT
from the < code > accessToken< / code > cookie first, then falls back to the
< code > Authorization: Bearer< / code > header. This lets both browser-based (cookie)
and Electron-based (Bearer) clients use the tunnel chat without separate code paths.
< / p >
< / div >
< div class = "card" >
< p >
< strong > Client-side fake streaming instead of server changes.< / strong >
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.
< / p >
< / div >
2026-07-16 05:27:48 +00:00
< / section >
< footer >
Roject — session history
< / footer >
< / div >
< script > var NAV _ROOT = '../../../../' ; < / script >
< script src = "../../../../_assets_/nav-data.js" > < / script >
< script src = "../../../../_assets_/nav.js" > < / script >
< / body >
< / html >