341 lines
16 KiB
HTML
341 lines
16 KiB
HTML
<!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>
|
|
<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>
|
|
</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>
|
|
|
|
<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>
|
|
|
|
<section>
|
|
<h2>Session 4 — Token refresh fixes, Tunnel Agent improvements, chat UI</h2>
|
|
|
|
<div class="card">
|
|
<h3>rokojori-auth: page-level token refresh middleware</h3>
|
|
<p>
|
|
Added a <code>jwtMiddleware</code> to <code>rokojori-auth/source/server/index.ts</code>
|
|
before <code>express.static</code>. When a page request (non-<code>/api/</code>) arrives
|
|
with an expired <code>accessToken</code>, the middleware redirects to
|
|
<code>/api/auth/refresh-session?redirect=<url></code>, which rotates both
|
|
cookies and redirects back. Fixes the reload-to-login loop after the 1-hour TTL.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Roject auth.ts: transparent API token refresh + diagnostic logging</h3>
|
|
<p>
|
|
<code>jwtMiddleware</code> now handles <code>TokenExpiredError</code> on API routes:
|
|
reads the <code>refreshToken</code> cookie, calls
|
|
<code>POST AUTH_INTERNAL_HOST/api/auth/refresh</code> server-side, sets new cookies
|
|
on the response, decodes the new JWT into <code>req.user</code>, and calls
|
|
<code>next()</code>. Added <code>AUTH_INTERNAL_HOST</code> env var (defaults to
|
|
<code>AUTH_HOST</code>); production <code>.env</code> sets it to
|
|
<code>http://localhost:3001</code> to bypass nginx. Added <code>console.log</code>
|
|
diagnostics throughout <code>tryRefresh</code> for journalctl debugging.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Tunnel Agent: token refresh, logout, getToken getter</h3>
|
|
<p>
|
|
Three improvements:
|
|
</p>
|
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
|
<li><code>tryRefreshTokens()</code> — on any 401 from <code>apiFetch</code>,
|
|
calls <code>POST account.rokojori.com/api/auth/refresh</code>, saves new tokens,
|
|
retries once. Falls through to <code>handleLogout()</code> if refresh fails.</li>
|
|
<li><code>handleLogout()</code> — 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.</li>
|
|
<li><code>TunnelAgentConfig.token: string</code> replaced with
|
|
<code>getToken: () => string</code> 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.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>rojo-chat-panel: mobile layout + animated thinking indicator</h3>
|
|
<p>
|
|
CSS: <code>min-height: 0</code> on <code>rojo-chat-panel</code> and
|
|
<code>.rcp-history</code> so the history can shrink in flex; <code>overflow: hidden</code>
|
|
on the panel root. Focus listener on the input calls <code>scrollIntoView</code>
|
|
after 300 ms to push the input above the mobile keyboard.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
Replaced the static <code>…</code> placeholder with a cycling animation
|
|
(frames customised by user) at 250 ms per frame via <code>setInterval</code>.
|
|
The interval is cleared and the bubble wiped the moment the first real response
|
|
chunk arrives.
|
|
</p>
|
|
</div>
|
|
|
|
</section>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
</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>
|