feat: T2 freeze src/types.ts (shared contracts)

- All ARCHITECTURE §3 interfaces as pure types; dependency-free (no ws/node/DOM)
  so both backend and frontend can import it
- Refinements vs §3 (documented): EnvLike (not NodeJS.ProcessEnv), WebSocketLike +
  WS_OPEN (not ws.WebSocket), added Config.wsPath (invariant 8)
- ARCHITECTURE §3.1/§3.4 reconciled to match (anti-drift)
- tsc --noEmit passes

T2 of docs/PLAN.md
This commit is contained in:
Yaojia Wang
2026-06-16 07:58:52 +02:00
parent 409b208928
commit 0e10dc21c3
3 changed files with 203 additions and 8 deletions

170
src/types.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* src/types.ts — FROZEN shared contracts (T2).
*
* Single source of truth, imported READ-ONLY by every other module.
* Transcribed from ARCHITECTURE §3. **Dependency-free on purpose**: no `ws`,
* no `@types/node` ambient (`NodeJS.*`), no DOM — so BOTH the backend and the
* browser frontend can import it without dragging in each other's type space.
*
* Need a new shared type? Change it HERE (a coordination point). Do not
* redeclare it locally in another module.
*
* Three deliberate refinements vs ARCHITECTURE §3 (for the portability goal):
* 1. `loadConfig` takes `EnvLike` instead of `NodeJS.ProcessEnv` (process.env is assignable).
* 2. `Session.attachedWs` is `WebSocketLike` instead of `ws`'s `WebSocket` (a real ws socket
* is structurally assignable); `WS_OPEN` exported for the M5 readyState guard.
* 3. `Config.wsPath` added (invariant 8 requires the WS path to be config, not hardcoded).
*/
/* ──────────────────────── config (§3.1) ──────────────────────── */
export interface Config {
readonly port: number; // PORT, default 3000
readonly bindHost: string; // BIND_HOST, default '0.0.0.0'
readonly shellPath: string; // SHELL_PATH, default process.env.SHELL ?? '/bin/zsh'
readonly homeDir: string; // PTY cwd, default os.homedir()
readonly idleTtlMs: number; // IDLE_TTL, default 24h
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
}
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
export type EnvLike = Readonly<Record<string, string | undefined>>;
// impl anchor [src/config.ts]:
// export function loadConfig(env: EnvLike): Config // invalid values → throw (fail-fast)
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
/** client → server */
export type ClientMessage =
| { type: 'attach'; sessionId: string | null }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number };
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */
export type ServerMessage =
| { type: 'attached'; sessionId: string }
| { type: 'output'; data: string }
| { type: 'exit'; code: number; reason?: string };
/** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult =
| { ok: true; message: ClientMessage }
| { ok: false; error: string };
export interface Dims {
cols: number;
rows: number;
}
// impl anchors [src/protocol.ts]:
// export const SESSION_ID_RE: RegExp // UUID v4 (M7)
// export function parseClientMessage(raw: string): ParseResult // NEVER throws
// export function serialize(msg: ServerMessage): string
/* ──────────────────────── origin (§3.3) ──────────────────────── */
// impl anchor [src/http/origin.ts]:
// export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean
// host+port must both match; undefined Origin rejected by default.
/* ─────────────────── PTY abstraction (§3.4) ──────────────────── */
export interface IDisposable {
dispose(): void;
}
/** node-pty's IEvent shape: subscribe a listener, get a disposable back. */
export type PtyEvent<T> = (listener: (e: T) => void) => IDisposable;
/** Subset of node-pty's IPty that we use. A real node-pty IPty is assignable to
* this; test/helpers/mock-pty.ts (T3) implements exactly this. */
export interface IPty {
readonly pid: number;
readonly cols: number;
readonly rows: number;
onData: PtyEvent<string>;
onExit: PtyEvent<{ exitCode: number; signal?: number }>;
write(data: string): void;
resize(cols: number, rows: number): void;
kill(signal?: string): void;
}
/* ────────────────────── ring buffer (§3.4) ───────────────────── */
export interface RingBuffer {
append(chunk: string): void;
/** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */
snapshot(): string;
}
// impl anchor [src/session/ring-buffer.ts]:
// export function createRingBuffer(maxBytes: number): RingBuffer
// M2: measure REAL bytes (Buffer), evict the whole oldest chunk — never split a
// multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way.
/* ──────────────────────── session (§3.4) ─────────────────────── */
/** The minimal WebSocket the server holds per session. A real `ws` WebSocket is
* structurally assignable; keeping this local makes types.ts portable. */
export interface WebSocketLike {
readonly readyState: number;
send(data: string): void;
close(): void;
}
/** WebSocket.OPEN. Guard every send with `ws.readyState === WS_OPEN` (M5). */
export const WS_OPEN = 1;
/** Immutable snapshot, fixed at creation (§5.2). */
export interface SessionMeta {
readonly id: string; // crypto.randomUUID() — UUID v4
readonly createdAt: number; // caller-supplied timestamp (injectable for tests)
readonly shellPath: string;
}
export interface Session {
readonly meta: SessionMeta;
readonly buffer: RingBuffer;
/** attached ws, or null = detached but PTY still alive (vibe-coding core). */
attachedWs: WebSocketLike | null;
detachedAt: number | null; // detach time
/** last pty.onData timestamp; reapIdle liveness proxy (M3). */
lastOutputAt: number;
/** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4),
* and attach replays the buffer then re-sends `exit` (L1). */
exitedAt: number | null;
exitCode: number | null;
readonly pty: IPty;
}
// impl anchors [src/session/session.ts]:
// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
// // spawn failure THROWS, not swallowed (M4)
// attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null // returns kicked old ws
// detachWs(session: Session, now: number): void // never kills PTY
// writeInput(session: Session, data: string): void // no-op after exit (L4)
// resize(session: Session, cols: number, rows: number): void // no-op after exit; idempotent
// kill(session: Session): void
/* ──────────────────────── manager (§3.5) ─────────────────────── */
export interface SessionManager {
handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session;
get(id: string): Session | undefined;
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number;
shutdown(): void;
}
// impl anchor [src/session/manager.ts]:
// export function createSessionManager(cfg: Config): SessionManager
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
/** keybar.ts (T10) exports a mount fn of this shape; main.ts (T11) calls it. */
export type MountKeybar = (onSend: (data: string) => void) => void;