Files
web-terminal/src/config.ts
Yaojia Wang 9099f73534 feat(v0.3): H1 — tmux keepalive (sessions survive a server restart)
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort
- config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH)
- session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the
  node-pty proc is a tmux CLIENT); createSession takes an optional id for
  re-attach; Session.tmuxName; kill() runs tmux kill-session
- manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after
  a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so
  the shell keeps running
- tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs
  useTmux:false; integration 'H1' (real tmux): set var → restart server →
  re-attach → var survived. 208 tests green.
2026-06-17 19:48:39 +02:00

170 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* src/config.ts — T4: environment config loader
*
* Reads env vars once at startup, validates them, derives allowedOrigins from
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
* and returns a frozen Config object.
*/
import os from 'node:os'
import type { Config, EnvLike } from './types.js'
import { tmuxAvailable } from './session/tmux.js'
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
function resolveUseTmux(raw: string | undefined): boolean {
const v = raw?.trim().toLowerCase()
if (v === '1' || v === 'true' || v === 'on') return true
if (v === '0' || v === 'false' || v === 'off') return false
return tmuxAvailable() // unset / 'auto'
}
// ── constants ─────────────────────────────────────────────────────────────────
const DEFAULT_PORT = 3000
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
// ── helpers ───────────────────────────────────────────────────────────────────
function parsePositiveInt(
raw: string | undefined,
label: string,
fallback: number,
): number {
if (raw === undefined) return fallback
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`,
)
}
return n
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_PORT
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
throw new Error(
`Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 165535`,
)
}
return n
}
function parseIdleTtl(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
)
}
return n * 1000
}
/**
* Derive allowedOrigins from host NIC IPs (M1).
*
* Rules:
* - Enumerate os.networkInterfaces(), pick IPv4 entries.
* - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly.
* - For every qualifying address, produce both http:// and https:// origins.
* - Append ALLOWED_ORIGINS env-var items (comma-separated).
* - Deduplicate.
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
*/
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
const set = new Set<string>()
// 1. Always include localhost / loopback
const loopbackHosts = ['localhost', '127.0.0.1']
for (const host of loopbackHosts) {
set.add(`http://${host}:${port}`)
set.add(`https://${host}:${port}`)
}
// 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above)
const ifaces = os.networkInterfaces()
for (const ifaceList of Object.values(ifaces)) {
if (!ifaceList) continue
for (const entry of ifaceList) {
// Only IPv4, non-internal entries; never 0.0.0.0
if (
entry.family === 'IPv4' &&
!entry.internal &&
entry.address !== '0.0.0.0'
) {
set.add(`http://${entry.address}:${port}`)
set.add(`https://${entry.address}:${port}`)
}
}
}
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
if (extraEnv) {
for (const raw of extraEnv.split(',')) {
const trimmed = raw.trim()
if (trimmed) set.add(trimmed)
}
}
return Object.freeze([...set])
}
// ── loadConfig ────────────────────────────────────────────────────────────────
/**
* Read and validate environment variables; fill in defaults for missing keys.
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
* so the server never starts in a misconfigured state (fail-fast).
*
* The returned Config is Object.freeze'd — immutable after construction.
*/
export function loadConfig(env: EnvLike): Config {
const port = parsePort(env['PORT'])
const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST
const shellPath =
env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh'
const homeDir = os.homedir()
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
const scrollbackBytes = parsePositiveInt(
env['SCROLLBACK_BYTES'],
'SCROLLBACK_BYTES',
DEFAULT_SCROLLBACK_BYTES,
)
const maxPayloadBytes = parsePositiveInt(
env['MAX_PAYLOAD_BYTES'],
'MAX_PAYLOAD_BYTES',
DEFAULT_MAX_PAYLOAD_BYTES,
)
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const useTmux = resolveUseTmux(env['USE_TMUX'])
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
return Object.freeze({
port,
bindHost,
shellPath,
homeDir,
idleTtlMs,
scrollbackBytes,
maxPayloadBytes,
wsPath,
useTmux,
allowedOrigins,
} satisfies Config)
}