Web client for Terminal Chat, served under /terminal-chat-web/. A login
form (name, server IP, port) followed by a small, centered terminal
environment that connects to whatever Terminal Chat server the user
specifies — just like client.js, but in the browser.
Browser (WebSocket) → nginx (/terminal-chat-web) → bridge (Node, zero-dep) → user-chosen chat server (TLS)
The browser can't connect directly to a raw TLS socket, so
bridge/ws-bridge.js builds a bridge: it forwards each line of the server
protocol verbatim as a WS text frame, and vice versa. The destination
(IP/port) is specified in the WS connection's query string (since each
user may want to connect to a different server), not a fixed env var. It
has no understanding of what the messages mean, and no changes to
server.js are required.
The real server protocol is line-based (newline-delimited), a mix of
plain text (broadcast, command replies) and raw JSON (key_announce,
pm). The logic for identifying each line's type lives in
src/types.ts (classifyLine).
/pm <ID> <text> works exactly like client.js:
- Encrypted client-side (not server-side) with AES-256-GCM
- A random 32-byte session key is broadcast in the handshake (
key_announce) - Every PM carries the sender's key along with it (for late joiners)
The encryption format is tested and compatible with client.js
(which uses Node's crypto.createCipheriv): src/crypto.ts uses Web
Crypto (SubtleCrypto) but manually splits/rejoins the auth tag so the
output envelope ({iv, tag, data}, hex) exactly matches client.js's format.
All of the following was tested against the real server.js and
client.js (not mocks):
- ✅ Login form: validation of the three required fields (name/IP/port)
- ✅ Handshake, public broadcast, commands (
/help,/users,/admin,/ban, ...), PING/PONG - ✅ Multiple simultaneous users (broadcast between two web clients at once)
- ✅ Two-way encrypted PM: from a real
client.jsto the web client, and back — with real AES-256-GCM, not a simulation - ✅ Bridge: destination selection from the query string (not just a fixed env)
- ✅ Subpath routing:
/terminal-chat-web/is served correctly via nginx'salias(static files and script paths verified) - ✅ Full end-to-end on the actual built bundle (not the TS source):
from filling the login form to receiving
Welcome ...from the real server, run and verified in a real DOM environment
- Long-running live PING/PONG testing (the logic is written and unit tested, but the server pings every 30s and this hasn't been tested over more than a few minutes)
- Security restriction on the bridge (it's still an open relay to any IP/port)
npm install
npm run dev # dev server at http://localhost:5173/terminal-chat-web/For full testing you need an instance of server.js (+
server.key/server.crt) and the bridge:
# Terminal 1: the main chat server (from the main repo)
node server.js 1599
# Terminal 2: the bridge
cd bridge
WS_PORT=8443 node ws-bridge.js
# CHAT_BACKEND_HOST/CHAT_BACKEND_PORT are also supported as a fallback,
# but normally the user specifies the destination via the login form
# Terminal 3: the web client itself
npm run devnpm test # unit tests (vitest + jsdom): Terminal, ChatConnection, classifyLine, LoginForm, crypto (including a real client.js compatibility test)
npm run test:bridge # integration test for the bridge (with a fake TLS echo server, no real server.js required)npm run build # output in dist/, all paths under /terminal-chat-web/docker compose up --buildTwo services come up:
- web at
http://localhost:8080/terminal-chat-web/(nginx + static files) - bridge, which connects to whatever destination the user enters in
the login form (fallback:
host.docker.internal:1599)
src/
main.ts Wires up LoginForm → Terminal → ChatConnection, handshake, /pm, PING/PONG
login.ts Login form (name/IP/port) with validation of the three required fields
terminal.ts Renders output, manages input, command history (↑/↓)
websocket.ts Raw WS connection (line by line) with automatic reconnect
crypto.ts AES-256-GCM encryption compatible with client.js (Web Crypto)
types.ts classifyLine() - identifies each line's type per the real server protocol
style.css Terminal look (small, centered) + login form
__tests__/ Unit tests (vitest)
bridge/
ws-bridge.js WS ↔ TLS bridge, destination from query string, zero-dependency
Dockerfile
__tests__/ Integration tests (node:test)
Dockerfile Build + serve static files with nginx (base path /terminal-chat-web/)
nginx.conf.template alias for /terminal-chat-web/ + proxies /terminal-chat-web/ws to the bridge
docker-compose.yml Two services: web and bridge
vite.config.ts base: '/terminal-chat-web/'
Since there's no domain, instead of assuming HTTP, nginx now serves over HTTPS with a self-signed TLS certificate (port 443; port 80 just redirects to 443). This is necessary because the Web Crypto API (PM encryption) is completely disabled in browsers without HTTPS or localhost.
cd terminal-chat-web
docker compose up -d --buildrestart: unless-stopped is set in docker-compose.yml, meaning the
containers automatically come back up after a crash or after the docker
service restarts (e.g. after a server reboot) — as long as the docker
service itself is enabled on the system:
sudo systemctl enable docker
sudo systemctl status docker # should show "enabled" and "active"On most Ubuntu/Debian installs this is already enabled; the command above is just to make sure.
sudo ufw allow 8080/tcp
sudo ufw allow 8443/tcp"8443:443" line in docker-compose.yml), also update
PUBLIC_HTTPS_PORT in the same file's web service to match — otherwise
the automatic HTTP→HTTPS redirect will send the browser to the wrong port.
Open your browser at:
https://<server-IP>:8443/terminal-chat-web/
The browser will show a "Connection is not private" warning — this
is expected since it's a self-signed certificate (the same trust model
server.js itself uses with rejectUnauthorized: false). Click
Advanced → Proceed; after that it works normally.
In the login form, for "Server IP" you should enter
host.docker.internal (since server.js runs on the host itself, not
inside Docker) — same as before.
docker compose ps
docker compose logs -f bridge
docker compose logs -f webgit pull # or whatever method you use to update the files
docker compose up -d --build--build a new certificate is generated and the browser will show
the "not private" warning again (you'll need to click Proceed again). If
you'd rather this not happen, we could keep the certificate in a Docker
volume outside the build so it stays stable across builds — let me know
if this matters to you.
The bridge is still an open relay (it connects to whatever IP:port the
user enters in the login form). With port 443 open to the public
internet, this risk becomes more practical (anyone who finds the site
could use it for port-scanning). If it stays public, it's recommended to
add an allowlist or rate-limit to the bridge later on.