From 403499341fc39777593c2d88ed5b60832e3147a8 Mon Sep 17 00:00:00 2001
From: Rokojori
Date: Thu, 16 Jul 2026 15:46:51 +0200
Subject: [PATCH] tunnel: Electron agent, production deployment, streaming,
Roject integration, chunk animation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
— 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
---
.../rojo-chat-panel/rojo-chat-panel.ts | 25 +++-
workspace/boards/tasks.html | 13 +--
.../2026/07-July/16-Wednesday/index.html | 109 +++++++++++++++++-
workspace/history/index.html | 2 +-
workspace/outline/index.html | 32 +++--
5 files changed, 158 insertions(+), 23 deletions(-)
diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.ts b/source/components/rojo-chat-panel/rojo-chat-panel.ts
index 93dd59f..f979c44 100644
--- a/source/components/rojo-chat-panel/rojo-chat-panel.ts
+++ b/source/components/rojo-chat-panel/rojo-chat-panel.ts
@@ -3,6 +3,22 @@ import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/
declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string };
+async function typeText( text: string, onPiece: ( piece: string ) => void ): Promise
+{
+ const THRESHOLD = 6;
+ const STEP = 3;
+ const DELAY_MS = 18;
+
+ if ( text.length <= THRESHOLD ) { onPiece( text ); return; }
+
+ for ( let i = 0; i < text.length; i += STEP )
+ {
+ onPiece( text.slice( i, i + STEP ) );
+ if ( i + STEP < text.length )
+ await new Promise( r => setTimeout( r, DELAY_MS ) );
+ }
+}
+
function extractRawText( node: Node ): string
{
if ( node.nodeType === Node.TEXT_NODE ) return node.nodeValue ?? '';
@@ -215,9 +231,12 @@ class RojoChatPanel extends HTMLElement
const msg = JSON.parse( trimmed ) as { type: string; text?: string };
if ( msg.type === 'CHAT' && msg.text )
{
- markdown += msg.text;
- assistantBubble.innerHTML = this._md!.render( markdown );
- history.scrollTop = history.scrollHeight;
+ await typeText( msg.text, piece =>
+ {
+ markdown += piece;
+ assistantBubble.innerHTML = this._md!.render( markdown );
+ history.scrollTop = history.scrollHeight;
+ } );
}
}
catch {}
diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html
index 4dcb7c1..da3abe8 100644
--- a/workspace/boards/tasks.html
+++ b/workspace/boards/tasks.html
@@ -168,16 +168,15 @@
rokojori-tunnel — Phase 2
- Phase 1 complete: relay server, CRUD tunnel API, WebSocket agent endpoint,
- HTTP proxy, test agent script. Tested end-to-end with local LLM (gemma4-coding
- on port 8900) — request relayed and response returned correctly.
- Service lives at C:\rokojori\projects\web-projects\tunnel.
+ Production-deployed at tunnel.rokojori.com. Complete so far:
+ relay server, CRUD API, streaming WebSocket protocol (res_start / res_data / res_end),
+ HTTP proxy with SSE streaming, Electron Tunnel Agent (tray, login, tunnel list),
+ Roject browse-tunnels button, Roject LLM chat via tunnel, client-side chunk animation.
- Phase 2 remaining:
+ Remaining:
— Allowed users list enforcement (multi-user private access)
- — GET /api/tunnels/available with ?purpose= filter
— Public access mode (no auth required on proxy route)
- — Roject LLM provider picker integrating the discovery API
+ — GET /api/tunnels/available with ?purpose= filter surfaced in Roject provider picker
diff --git a/workspace/history/2026/07-July/16-Wednesday/index.html b/workspace/history/2026/07-July/16-Wednesday/index.html
index f4e9557..4f22567 100644
--- a/workspace/history/2026/07-July/16-Wednesday/index.html
+++ b/workspace/history/2026/07-July/16-Wednesday/index.html
@@ -13,7 +13,7 @@
Wednesday, 16 July 2026
Session History
-
Brainstormed and scaffolded rokojori-tunnel — user-based local tunneling service.
+
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.
@@ -75,6 +75,87 @@
+
+
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 devDependencies → dependencies
+ 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
@@ -112,6 +193,32 @@
+
+
+ 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.
+