πŸ–₯️ Web Terminal (wterm.dev)

Browser-based terminal giving a real bash shell on the server. Accessible at https://funday.gg/dev/terminal for dev-gated users.

Architecture

flowchart TD
    Browser["Browser (wterm WASM emulator)"]
    Nginx["Nginx (/terminal-ws/)"]
    Bridge["terminal-bridge.mjs (node-pty + ws on 127.0.0.1:7681)"]
    Shell["/bin/bash -l (login shell, Funday user)"]

    Browser <-->|WebSocket (JSON, wss://)| Nginx
    Nginx <-->|ws://| Bridge
    Bridge <-->|PTY| Shell

Services

ServicePortsystemdPurpose
funday-terminal7681 (localhost only)funday-terminal.serviceWS↔PTY bridge
nginx443 (public)nginxTLS + WS proxy at /terminal-ws/
SvelteKit frontend3000funday-frontend.service/dev/terminal page

File Map

server/
└── terminal-bridge.mjs              ← WS↔PTY bridge (ESM module, node-pty + ws)

etc/systemd/system/
└── funday-terminal.service          ← systemd unit (enabled, restart-on-failure)

etc/nginx/sites-available/
└── funday                           ← contains location ^~ /terminal-ws/ { ... }

frontend/src/
β”œβ”€β”€ lib/components/dev/
β”‚   └── WTermTerminal.svelte         ← wterm.dev Svelte 5 wrapper component
β”œβ”€β”€ routes/dev/terminal/
β”‚   └── +page.svelte                 ← /dev/terminal route page
β”œβ”€β”€ lib/config/
β”‚   └── devTools.ts                  ← sidebar registry (terminal entry)
└── types/
    └── wterm.d.ts                   ← TypeScript declarations for @wterm/dom

WS Protocol

JSON frames over WebSocket. Each message: {"type": "...", ...}

Browser β†’ Bridge

TypePurposeExample
createSpawn PTY{"type":"create","cols":100,"rows":30}
inputKeystrokes{"type":"input","data":"ls\n"}
resizeResize{"type":"resize","cols":120,"rows":40}
killKill PTY{"type":"kill"}

Bridge β†’ Browser

TypeFieldsWhen
createdpidPTY spawned
outputdata (ANSI string)Shell output (streaming)
exitcode, signalShell exited
errormessageServer error

Security

  • Dev access gate β€” /dev/* requires Nakama auth + developer role
  • Max 4 concurrent PTYs β€” configurable via TERMINAL_MAX_CONN
  • Non-root β€” User=usr, NoNewPrivileges=true, ProtectSystem=strict
  • Private port β€” bridge binds 127.0.0.1:7681 only
  • TLS terminated at nginx β€” wss:// over the wire
  • No shell escape β€” wterm is render-only, keystrokes via WS bridge

Operations

# Service
sudo systemctl status funday-terminal
sudo systemctl restart funday-terminal
sudo journalctl -u funday-terminal -f
 
# Health
curl -s http://127.0.0.1:7681/ | python3 -m json.tool
 
# Change max connections
sudo systemctl edit funday-terminal  # add Environment=TERMINAL_MAX_CONN=8
sudo systemctl daemon-reload && sudo systemctl restart funday-terminal

Nginx

The /terminal-ws/ proxy block in sites-available/funday:

location ^~ /terminal-ws/ {
    proxy_pass http://127.0.0.1:7681/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 86400;
    proxy_send_timeout 86400;
}

⚠️ sites-enabled/funday is a file copy, not a symlink. After editing sites-available/funday, you MUST:

sudo cp /etc/nginx/sites-available/funday /etc/nginx/sites-enabled/funday
sudo nginx -t && sudo systemctl reload nginx

Verify with: sudo nginx -T 2>/dev/null | grep terminal-ws

Troubleshooting

SymptomCauseFix
502 Bad GatewayNginx stale file or bridge downcp sites-available β†’ sites-enabled, restart bridge
”Disconnected” in status barWS can’t reach bridgeCheck: systemctl is-active funday-terminal
Blank terminal@wterm/dom not in buildRebuild: bash scripts/build-atomic.sh
Connection rejected (1013)Max connections (default 4)Wait or increase TERMINAL_MAX_CONN
No shell outputPTY not createdSend {"type":"create","cols":80,"rows":24}

Gotchas

  • class: directive + Tailwind / β€” Svelte parser treats / as division. Use inline ternary: class="bg-{status === 'ok' ? 'success' : 'error'}"
  • Dynamic @wterm/dom import β€” must be await import() in onMount, never static (crashes SSR)
  • wterm.destroy() β€” call in onDestroy or WASM leaks
  • nginx reload β‰  pick up edits if sites-enabled/funday is stale β€” always cp from sites-available/

πŸ–₯️ Funday Web Terminal β€” Idiot Overstanding Cheat Sheet

wterm.dev browser terminal + custom PTY bridge = full bash shell at funday.gg/dev/terminal

Latest best practice as of 2026-05-31. Internal only β€” never expose externally.


πŸ—οΈ WHAT IT IS

A web-based terminal emulator embedded in the Funday dev zone. It gives you a real bash shell running on the server, accessible from any browser. Built with:

LayerTechPackage
Emulatorwterm (Vercel)@wterm/dom v0.3.0
ComponentSvelte 5 runesWTermTerminal.svelte
RouteSvelteKit page/dev/terminal
BridgeNode.js WS ↔ PTYterminal-bridge.mjs
Shellbash (login)node-pty native
Proxynginx WebSocket/terminal-ws/ β†’ :7681
Servicesystemdfunday-terminal.service

Full data flow:

flowchart TD
    Browser["Browser (wterm WASM)"]
    Nginx["Nginx (/terminal-ws/)"]
    Bridge["terminal-bridge.mjs (node-pty + ws on 127.0.0.1:7681)"]
    Shell["/bin/bash -l (login shell, Funday env)"]

    Browser <-->|WebSocket: JSON frames| Nginx
    Nginx <-->|ws://| Bridge
    Bridge <-->|PTY| Shell

πŸš€ QUICK START

Open the terminal

https://funday.gg/dev/terminal     ← requires dev access gate

Dev access = Nakama user with DEV_ACCESS_* env or β€œdeveloper”/β€œadmin” role.

Verify it’s running

# Service status
systemctl is-active funday-terminal     # β†’ active
 
# Health check
curl -s http://127.0.0.1:7681/ | python3 -m json.tool
# β†’ {"status":"ok","connections":0,"max":4,"uptime":...}
 
# Nginx proxy has the location
sudo nginx -T 2>/dev/null | grep "terminal-ws"
 
# WS round-trip test
python3 -c "
import asyncio, json, ssl, websockets
async def t():
    ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
    async with websockets.connect('wss://funday.gg/terminal-ws/ws', ssl=ctx) as ws:
        await ws.send(json.dumps({'type':'create','cols':80,'rows':24}))
        print(json.loads(await ws.recv()))         # β†’ {"type":"created","pid":...}
        await ws.send(json.dumps({'type':'input','data':'echo ok\\n'}))
        for _ in range(15):
            m = json.loads(await asyncio.wait_for(ws.recv(), 2.0))
            if 'ok' in m.get('data',''): print('WORKS'); return
asyncio.run(t())
"

Restart after deploy

sudo systemctl restart funday-terminal    # restart bridge
sudo systemctl reload nginx             # reload proxy
# No frontend rebuild needed for bridge changes

πŸ“‘ WS PROTOCOL (Bridge ↔ Browser)

JSON frames over WebSocket. Each message is {"type": ...}.

Browser β†’ Bridge

MessagePurposeExample
createSpawn a PTY{"type":"create","cols":100,"rows":30}
inputSend keystrokes{"type":"input","data":"ls -la\n"}
resizeResize PTY{"type":"resize","cols":120,"rows":40}
killKill PTY{"type":"kill"}

Bridge β†’ Browser

MessageWhenFields
createdPTY spawnedpid
outputShell outputdata (string with ANSI escapes)
exitShell exitedcode, signal
errorServer errormessage

πŸ› οΈ OPERATIONS

Service management

sudo systemctl start|stop|restart|status funday-terminal
sudo journalctl -u funday-terminal -f           # live logs

View active connections

curl -s http://127.0.0.1:7681/ | python3 -m json.tool
# "connections": N, "max": 4

Change max connections

Edit /etc/systemd/system/funday-terminal.service:

Environment=TERMINAL_MAX_CONN=8   # default 4

Then: sudo systemctl daemon-reload && sudo systemctl restart funday-terminal

Change shell

Environment=SHELL=/bin/zsh   # default /bin/bash

🚨 CRITICAL GOTCHA: NGINX STALE FILE

/etc/nginx/sites-enabled/funday was a stale file copy (not a symlink!) from Mar 3. Edits to sites-available/funday were silently ignored by the running nginx.

ALWAYS after editing sites-available:

sudo cp /etc/nginx/sites-available/funday /etc/nginx/sites-enabled/funday
sudo nginx -t && sudo systemctl reload nginx

Verify the running config has your changes:

sudo nginx -T 2>/dev/null | grep "terminal-ws"   # must appear

🧩 ADDING TERMINAL TO A NEW DEV PAGE

  1. Add entry to frontend/src/lib/config/devTools.ts:
    {
      id: "mytool",
      label: "My Tool",
      route: "/dev/mytool",
      group: "code-tools",
      icon: MyIcon,
      ...
    }
  2. Create route: frontend/src/routes/dev/mytool/+page.svelte
  3. Embed terminal:
    <WTermTerminal wsUrl={`${location.host}/terminal-ws/ws`} rows={24} cols={80} />
  4. Rebuild frontend: bash scripts/build-atomic.sh

πŸ”’ SECURITY

ConcernMitigation
Access controlDev access gate (Nakama auth + role check)
Max PTYsTERMINAL_MAX_CONN=4 β€” WS reject at limit (1013)
No rootUser=usr, NoNewPrivileges=true, ProtectSystem=strict
Read-only homeProtectHome=read-only β€” can’t modify system dirs
Private portBridge binds 127.0.0.1:7681 β€” not public
TLSNginx terminates TLS β€” WS is wss:// over the wire
Max open filesEach PTY = ~4 fd β€” systemd default limits apply
No shell escapewterm is render-only β€” keystrokes go through WS bridge

🧠 SVELTER 5 + WTERM ANTI-PATTERNS

❌ class: with Tailwind / opacity = BREAKS

<!-- ❌ WRONG β€” Svelte parser sees / as division -->
<span class:bg-base-content/20={status === "disconnected"} />
 
<!-- βœ… CORRECT β€” inline class with ternary -->
<span class="w-2 h-2 {status === 'connected' ? 'bg-success' : 'bg-base-content/20'}" />

❌ Don’t import @wterm statically for SSR

<!-- ❌ WRONG β€” crashes SSR (no DOM) -->
<script>import { WTerm } from "@wterm/dom";</script>
 
<!-- βœ… CORRECT β€” dynamic import in onMount -->
<script>import { onMount } from "svelte";
onMount(async () => {
  const { WTerm } = await import("@wterm/dom");
  await import("@wterm/dom/css");
  ...
});
</script>

❌ Don’t forget wterm.destroy() in onDestroy

Memory leak β€” the WASM terminal keeps rendering in the background.


πŸ“ FILE MAP

server/
└── terminal-bridge.mjs          ← πŸ–₯️ WS↔PTY bridge (ESM, port 7681)

frontend/src/
β”œβ”€β”€ lib/components/dev/
β”‚   └── WTermTerminal.svelte     ← 🎨 wterm Svelte wrapper component
β”œβ”€β”€ lib/config/
β”‚   └── devTools.ts              ← πŸ“‹ dev sidebar registry (terminal entry)
β”œβ”€β”€ routes/dev/terminal/
β”‚   └── +page.svelte             ← πŸ“„ /dev/terminal route
└── types/
    └── wterm.d.ts               ← πŸ“ TypeScript declarations

/etc/nginx/sites-available/funday  ← πŸ”€ nginx config (TERMINAL BLOCK)
/etc/systemd/system/funday-terminal.service  ← βš™οΈ systemd unit

🎨 TERMINAL THEMES

wterm ships 4 built-in themes (CSS class on .wterm):

ThemeClassFeel
Default(none)VS Code dark
Solarized Dark.theme-solarized-darkWarm dark
Monokai.theme-monokaiClassic bright
Light.theme-lightLight mode

The terminal CSS auto-bundled from @wterm/dom/css into terminal.BCS3iTzZ.css at build time.


πŸ†˜ TROUBLESHOOTING

SymptomCauseFix
502 Bad GatewayNginx stale file or bridge downcp sites-available β†’ sites-enabled, restart funday-terminal
”Disconnected” statusWS can’t reach bridgeCheck systemctl is-active funday-terminal
”Loading terminal…” foreverbrowser check failsSSR issue β€” page needs browser from $app/environment
No input echoedPTY not createdCheck WS protocol: send {"type":"create"} first
Black screen (wterm init)@wterm/dom not bundledRebuild frontend: build-atomic.sh
Connection rejected (1013)Max connections reachedWait or increase TERMINAL_MAX_CONN

Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send