feat(T-1.5/1.6/1.7): stream+input+snapshot routes, sessions CRUD, commands, side-channel, health endpoint

This commit is contained in:
Johannes Merz 2026-05-15 11:35:55 +02:00
parent db6be6dcf8
commit b94b668df6
9 changed files with 900 additions and 13 deletions

View File

@ -0,0 +1,36 @@
/**
* S-08 slash-command registry route.
*
* GET /sessions/:id/commands [{ name, description, args }]
*
* Returns the list of slash commands available in the current pi session.
* Delegates to pi/commands.ts (T-1.4).
*
* Owner: T-1.6
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getCommands } from "../../pi/commands.js";
import { getSession } from "../../tmux/manager.js";
import { sendJson } from "../util.js";
export async function handleCommands(
_req: IncomingMessage,
res: ServerResponse,
sessionId: string,
pi: ExtensionAPI,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
try {
const commands = await getCommands(pi);
sendJson(res, 200, commands);
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}

View File

@ -0,0 +1,96 @@
/**
* S-12 health endpoint.
*
* GET /health { ok, sessions, bufferBytes, uptime, version }
*
* Also integrates the disk watchdog: on each health call we check free space
* and total buffer size, returning a warning if caps are near.
*
* Owner: T-1.7
*/
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { listSessions } from "../../tmux/manager.js";
import { sendJson } from "../util.js";
const execFileAsync = promisify(execFile);
const _startedAt = Date.now();
export interface HealthOptions {
stateDir?: string;
}
export async function handleHealth(
_req: IncomingMessage,
res: ServerResponse,
opts: HealthOptions = {},
): Promise<void> {
const stateDir =
opts.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
const [sessions, bufferBytes, freeBytes] = await Promise.all([
listSessions().catch(() => []),
getTotalBufferBytes(stateDir),
getFreeBytes(stateDir),
]);
const freeGb = freeBytes / (1024 * 1024 * 1024);
const bufferMb = bufferBytes / (1024 * 1024);
sendJson(res, 200, {
ok: true,
uptime: Math.floor((Date.now() - _startedAt) / 1000),
sessions: sessions.length,
sessionIds: sessions.map((s) => s.id),
bufferMb: Math.round(bufferMb * 10) / 10,
diskFreeGb: Math.round(freeGb * 10) / 10,
warnings: buildWarnings(freeGb, bufferMb),
});
}
function buildWarnings(freeGb: number, bufferMb: number): string[] {
const warnings: string[] = [];
if (freeGb < 1) warnings.push(`Low disk space: ${freeGb.toFixed(1)} GB free`);
if (bufferMb > 900)
warnings.push(`Buffer near cap: ${bufferMb.toFixed(0)} MB used`);
return warnings;
}
async function getTotalBufferBytes(stateDir: string): Promise<number> {
const bufDir = path.join(stateDir, "buffers");
try {
const entries = await fs.readdir(bufDir, { withFileTypes: true });
let total = 0;
for (const e of entries) {
if (!e.name.endsWith(".buf")) continue;
try {
const stat = await fs.stat(path.join(bufDir, e.name));
total += stat.size;
} catch {
// skip
}
}
return total;
} catch {
return 0;
}
}
async function getFreeBytes(dir: string): Promise<number> {
try {
const { stdout } = await execFileAsync("df", ["-k", dir]);
const lines = stdout.trim().split("\n");
const last = lines[lines.length - 1];
const parts = last.split(/\s+/);
const availKb = parseInt(parts[3], 10);
return availKb * 1024;
} catch {
return Number.POSITIVE_INFINITY; // unknown — don't warn
}
}

View File

@ -0,0 +1,105 @@
/**
* S-03 send-keys input route.
*
* POST /sessions/:id/input
*
* Body (IC-1 ClientToServer subset, but as HTTP POST for non-WS clients):
* { type: "key"; name: string }
* { type: "keys"; data: string }
* { type: "paste"; data: string }
*
* Response: 204 No Content on success, 400 on bad input, 404 if session missing.
*
* Note: the primary path for send-keys is via WS (T-1.5 stream route handles
* key/keys/paste messages inline). This HTTP endpoint is for clients that
* don't have an open stream (e.g. one-shot CLI tools).
*
* Owner: T-1.5
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
import { getSession } from "../../tmux/manager.js";
import { readBody, sendJson } from "../util.js";
export async function handleInput(
req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, {
error: "session_not_found",
message: `Session "${sessionId}" not found`,
});
return;
}
let body: unknown;
try {
body = JSON.parse(await readBody(req));
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON body" });
return;
}
if (!body || typeof body !== "object") {
sendJson(res, 400, {
error: "bad_request",
message: "Body must be a JSON object",
});
return;
}
const m = body as Record<string, unknown>;
try {
switch (m.type) {
case "key": {
if (typeof m.name !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "key.name must be a string",
});
return;
}
await sendKey(sessionId, m.name);
break;
}
case "keys": {
if (typeof m.data !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "keys.data must be a string",
});
return;
}
await sendKeys(sessionId, m.data);
break;
}
case "paste": {
if (typeof m.data !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "paste.data must be a string",
});
return;
}
await sendPaste(sessionId, m.data);
break;
}
default:
sendJson(res, 400, {
error: "bad_request",
message: `Unknown type: ${String(m.type)}`,
});
return;
}
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
return;
}
res.writeHead(204).end();
}

View File

@ -0,0 +1,166 @@
/**
* S-09 multi-session CRUD routes.
*
* POST /sessions { id, name }
* GET /sessions [{ id, name, description, state, lastOutputAt }]
* PATCH /sessions/:id updates @description
* DELETE /sessions/:id kills tmux session, optionally clears buffer
* GET /sessions/:id/thumbnail text/plain capture-pane (40×12)
*
* Owner: T-1.6
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { BufferWriter } from "../../buffer/writer.js";
import {
getSession,
killSession,
listSessions,
setDescription,
spawnSession,
} from "../../tmux/manager.js";
import { captureThumbnail } from "../../tmux/snapshot.js";
import { readBody, sendJson } from "../util.js";
export async function handleSessions(
req: IncomingMessage,
res: ServerResponse,
sessionId?: string,
sub?: string,
): Promise<void> {
const method = req.method?.toUpperCase();
// GET /sessions/:id/thumbnail
if (sessionId && sub === "thumbnail" && method === "GET") {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
const text = await captureThumbnail(sessionId).catch(() => "");
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end(text);
return;
}
// /sessions/:id — PATCH / DELETE
if (sessionId && !sub) {
if (method === "PATCH") {
await handlePatch(req, res, sessionId);
return;
}
if (method === "DELETE") {
await handleDelete(req, res, sessionId);
return;
}
sendJson(res, 405, { error: "method_not_allowed" });
return;
}
// /sessions — GET / POST
if (!sessionId) {
if (method === "GET") {
await handleList(res);
return;
}
if (method === "POST") {
await handleCreate(req, res);
return;
}
sendJson(res, 405, { error: "method_not_allowed" });
return;
}
sendJson(res, 404, { error: "not_found" });
}
async function handleList(res: ServerResponse): Promise<void> {
try {
const sessions = await listSessions();
const payload = sessions.map((s) => ({
id: s.id,
name: s.name,
description: s.description,
state: "idle", // T-1.4 events will feed real state in Phase 2
lastOutputAt: s.lastActivityAt,
}));
sendJson(res, 200, payload);
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}
async function handleCreate(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
let body: Record<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
return;
}
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: `session-${Date.now()}`;
try {
const id = await spawnSession({ name });
sendJson(res, 201, { id, name });
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}
async function handlePatch(
req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
let body: Record<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
return;
}
if (typeof body.description === "string") {
await setDescription(sessionId, body.description);
}
sendJson(res, 200, { id: sessionId, description: body.description });
}
async function handleDelete(
_req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
try {
await killSession(sessionId);
// Optionally clear buffer
const writer = new BufferWriter(sessionId);
await writer.delete().catch(() => {}); // best-effort
res.writeHead(204).end();
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}

View File

@ -0,0 +1,119 @@
/**
* S-07 state side-channel route.
*
* WS endpoint: GET /sessions/:id/side
*
* Pushes IC-1 ServerToClient JSON frames (state, session-meta, error).
* Does NOT carry binary output (that's /stream).
* Lightweight channel for UI state updates without full output stream.
*
* Owner: T-1.6
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import type { StateEvent } from "../../pi/events.js";
import { getSession } from "../../tmux/manager.js";
import type { WsClient, WsServer } from "../types.js";
export interface SideRouteOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
getCurrentState?: () => StateEvent | null;
}
// Subscribers per session: sessionId → set of ws clients
const _subscribers = new Map<string, Set<WsClient>>();
/**
* Broadcast a state event to all side-channel subscribers of a session.
*/
export function broadcastState(sessionId: string, event: StateEvent): void {
const subs = _subscribers.get(sessionId);
if (!subs) return;
const msg = JSON.stringify({
type: "state",
value: event.value,
tool: event.tool,
ts: event.ts,
});
for (const ws of subs) {
if (ws.readyState === 1 /* OPEN */) {
ws.send(msg);
}
}
}
/**
* Handle a WebSocket upgrade for /sessions/:id/side.
*/
export function handleSideUpgrade(
sessionId: string,
request: IncomingMessage,
socket: Socket,
head: Buffer,
opts: SideRouteOptions,
): void {
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
handleSideConnection(sessionId, ws, opts);
});
}
async function handleSideConnection(
sessionId: string,
ws: WsClient,
opts: SideRouteOptions,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
ws.send(
JSON.stringify({
type: "error",
code: "session_not_found",
message: `Session "${sessionId}" not found`,
}),
);
ws.terminate();
return;
}
// Push session-meta on connect
ws.send(
JSON.stringify({
type: "session-meta",
name: session.name,
description: session.description,
createdAt: session.createdAt,
}),
);
// Push current state
const currentState = opts.getCurrentState?.();
if (currentState) {
ws.send(
JSON.stringify({
type: "state",
value: currentState.value,
tool: currentState.tool,
ts: currentState.ts,
}),
);
}
// Register subscriber
let subs = _subscribers.get(sessionId);
if (!subs) {
subs = new Set();
_subscribers.set(sessionId, subs);
}
subs.add(ws);
ws.on("close", () => {
subs?.delete(ws);
if (subs?.size === 0) _subscribers.delete(sessionId);
});
ws.on("error", () => {
subs?.delete(ws);
});
}

View File

@ -0,0 +1,205 @@
/**
* S-02 binary stream + S-04 sequence cursor resume + S-05 snapshot route.
*
* WS endpoint: GET /sessions/:id/stream
*
* Protocol (IC-1):
* - On connect: client sends { type: "resume"; lastSeq: number | null }
* - Server replays buffer chunks after lastSeq (binary frames with 8-byte seq header)
* - Then live output arrives as binary frames
* - Client may send { type: "snapshot-request" } server sends { type: "snapshot"; ... }
* - State events pushed unsolicited: { type: "state"; ... }
* - Session meta pushed on connect: { type: "session-meta"; ... }
*
* Binary frame format: [seq: 8 bytes BE uint64][data: N bytes]
*
* Owner: T-1.5
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import { readChunks } from "../../buffer/reader.js";
import type { StateEvent } from "../../pi/events.js";
import { SequenceCounter } from "../../sequence.js";
import { ControlClient } from "../../tmux/control.js";
import { capturePane } from "../../tmux/snapshot.js";
import type { WsClient, WsServer } from "../types.js";
export interface StreamRouteOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
/** Called to get the current agent state for new connections */
getCurrentState?: () => StateEvent | null;
stateDir?: string;
}
// Per-session registry of active ControlClients and sequence counters
const _clients = new Map<
string,
{ control: ControlClient; seq: SequenceCounter }
>();
/**
* Get or create a ControlClient + SequenceCounter for a session.
*/
function getOrCreateSession(sessionId: string): {
control: ControlClient;
seq: SequenceCounter;
} {
const existing = _clients.get(sessionId);
if (existing) return existing;
const seq = new SequenceCounter();
const control = new ControlClient({
session: sessionId,
onClose: (reason) => {
console.error(
`[stream] control client closed for ${sessionId}: ${reason}`,
);
_clients.delete(sessionId);
},
});
control.start();
const entry = { control, seq };
_clients.set(sessionId, entry);
return entry;
}
/**
* Stop and remove a session's ControlClient (call on session kill).
*/
export function stopSession(sessionId: string): void {
const entry = _clients.get(sessionId);
if (entry) {
entry.control.stop();
_clients.delete(sessionId);
}
}
/**
* Handle a WebSocket upgrade for /sessions/:id/stream.
*/
export function handleStreamUpgrade(
sessionId: string,
request: IncomingMessage,
socket: Socket,
head: Buffer,
opts: StreamRouteOptions,
): void {
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
handleStreamConnection(sessionId, ws, opts);
});
}
function handleStreamConnection(
sessionId: string,
ws: WsClient,
opts: StreamRouteOptions,
): void {
const { control, seq } = getOrCreateSession(sessionId);
let resumed = false;
// Push session-meta immediately
sendJson(ws, {
type: "session-meta",
name: sessionId,
createdAt: new Date().toISOString(),
});
// Push current state if available
const currentState = opts.getCurrentState?.();
if (currentState) {
sendJson(ws, {
type: "state",
value: currentState.value,
tool: currentState.tool,
ts: currentState.ts,
});
}
// Subscribe to live output
const unsubscribe = control.subscribe((chunk: Buffer) => {
if (ws.readyState !== 1 /* OPEN */) return;
const seqNum = seq.next();
const frame = buildBinaryFrame(seqNum, chunk);
sendBinary(ws, frame);
});
// Handle client messages
ws.on("message", (data: Buffer) => {
let msg: unknown;
try {
msg = JSON.parse(data.toString());
} catch {
sendJson(ws, {
type: "error",
code: "bad_message",
message: "Invalid JSON",
});
return;
}
if (!msg || typeof msg !== "object") return;
const m = msg as Record<string, unknown>;
if (m.type === "resume" && !resumed) {
resumed = true;
const lastSeq = typeof m.lastSeq === "number" ? m.lastSeq : 0;
// Replay buffered chunks after lastSeq
const chunks = readChunks(sessionId, {
afterSeq: lastSeq,
cfg: opts.stateDir ? { stateDir: opts.stateDir } : undefined,
});
for (const chunk of chunks) {
if (ws.readyState !== 1) break;
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
}
} else if (m.type === "snapshot-request") {
capturePane({ session: sessionId })
.then((text) => {
const data = Buffer.from(text).toString("base64");
const s = seq.next();
sendJson(ws, { type: "snapshot", seq: s, data });
})
.catch(() => {
sendJson(ws, {
type: "error",
code: "snapshot_failed",
message: "Failed to capture pane",
});
});
}
});
ws.on("close", () => {
unsubscribe();
});
ws.on("error", () => {
unsubscribe();
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a binary frame: [seq: 8 bytes BE][data] */
function buildBinaryFrame(seqNum: number, data: Buffer): Buffer {
const header = Buffer.allocUnsafe(8);
header.writeBigUInt64BE(BigInt(seqNum), 0);
return Buffer.concat([header, data]);
}
function sendJson(ws: WsClient, msg: object): void {
if (ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify(msg));
}
}
function sendBinary(ws: WsClient, data: Buffer): void {
if (ws.readyState === 1 /* OPEN */) {
(ws as unknown as { send(data: Buffer): void }).send(data);
}
}

View File

@ -28,6 +28,7 @@ import type {
ExtensionAPI, ExtensionAPI,
ExtensionContext, ExtensionContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { validateBearer } from "../auth/tokens.js";
import { import {
generateSessionId, generateSessionId,
loadOrCreateToken, loadOrCreateToken,
@ -38,8 +39,13 @@ import {
import { parseBindAddress, readRemoteControlConfig } from "../config.js"; import { parseBindAddress, readRemoteControlConfig } from "../config.js";
import { buildHTML } from "../html.js"; // LEGACY: browser HTML client import { buildHTML } from "../html.js"; // LEGACY: browser HTML client
import { buildSyncMessage } from "../messages.js"; import { buildSyncMessage } from "../messages.js";
import { handleCommands } from "./routes/commands.js";
import { handleHealth } from "./routes/health.js";
import { handleInput } from "./routes/input.js";
import { handleSessions } from "./routes/sessions.js";
import type { RemoteServer, WsClient, WsServer } from "./types.js"; import type { RemoteServer, WsClient, WsServer } from "./types.js";
import { createUpgradeHandler } from "./upgrade.js"; import { createUpgradeHandler } from "./upgrade.js";
import { extractBearer } from "./util.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Load ws (bundled with pi) without needing @types/ws installed locally // Load ws (bundled with pi) without needing @types/ws installed locally
@ -196,8 +202,75 @@ export async function startServer(
return; return;
} }
// New API routes (IC-2) — bearer token auth
const asyncHandler = async (): Promise<boolean> => {
// Auth check for API routes
const bearer = extractBearer(req);
const isApiAuthed =
(bearer && validateToken(bearer, token)) ||
(bearer ? !!(await validateBearer(bearer).catch(() => null)) : false);
if (!isApiAuthed) return false;
// GET /health
if (pathname === "/health" && req.method === "GET") {
await handleHealth(req, res);
return true;
}
// /sessions — list + create
if (pathname === "/sessions") {
await handleSessions(req, res);
return true;
}
// /sessions/:id — PATCH, DELETE
const sessMatch = pathname.match(/^\/sessions\/([^/]+)$/);
if (sessMatch) {
await handleSessions(req, res, decodeURIComponent(sessMatch[1]));
return true;
}
// /sessions/:id/thumbnail
const thumbMatch = pathname.match(/^\/sessions\/([^/]+)\/thumbnail$/);
if (thumbMatch) {
await handleSessions(
req,
res,
decodeURIComponent(thumbMatch[1]),
"thumbnail",
);
return true;
}
// /sessions/:id/commands
const cmdMatch = pathname.match(/^\/sessions\/([^/]+)\/commands$/);
if (cmdMatch) {
await handleCommands(req, res, decodeURIComponent(cmdMatch[1]), pi);
return true;
}
// /sessions/:id/input
const inputMatch = pathname.match(/^\/sessions\/([^/]+)\/input$/);
if (inputMatch) {
await handleInput(req, res, decodeURIComponent(inputMatch[1]));
return true;
}
return false;
};
asyncHandler()
.then((handled) => {
if (!handled) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found"); res.end("Not found");
}
})
.catch((err: Error) => {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end(err.message);
});
}); });
// ── WebSocket server ──────────────────────────────────────────────────── // ── WebSocket server ────────────────────────────────────────────────────

View File

@ -5,30 +5,37 @@
* based on the request path and session/topic. Non-matching paths are * based on the request path and session/topic. Non-matching paths are
* destroyed immediately. * destroyed immediately.
* *
* Current routes (all LEGACY browser HTML client): * Routes:
* /ws legacy browser client WebSocket endpoint * /ws LEGACY browser HTML client WebSocket endpoint
* * /sessions/:id/stream binary ANSI stream per tmux session (T-1.5)
* Future routes (T-1.5):
* /sessions/:id/stream binary ANSI stream per tmux session
*
* T-1.5 will extend createUpgradeHandler to accept a session registry and
* dispatch to per-session stream handlers.
*/ */
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net"; import type { Socket } from "node:net";
import { handleSideUpgrade, type SideRouteOptions } from "./routes/side.js";
import {
handleStreamUpgrade,
type StreamRouteOptions,
} from "./routes/stream.js";
import type { WsClient, WsServer } from "./types.js"; import type { WsClient, WsServer } from "./types.js";
export interface UpgradeHandlerOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
stream: Omit<StreamRouteOptions, "wss" | "isAuthenticated">;
side: Omit<SideRouteOptions, "wss" | "isAuthenticated">;
}
/** /**
* Create the HTTP `upgrade` event handler. * Create the HTTP `upgrade` event handler.
* *
* @param wss - WebSocket server instance. * @param opts - Handler options including wss, auth predicate, and stream config.
* @param isAuthenticated - Predicate that checks token/session on the request.
* @returns A handler suitable for `httpServer.on("upgrade", handler)`. * @returns A handler suitable for `httpServer.on("upgrade", handler)`.
*/ */
export function createUpgradeHandler( export function createUpgradeHandler(
wss: WsServer, wss: WsServer,
isAuthenticated: (req: IncomingMessage) => boolean, isAuthenticated: (req: IncomingMessage) => boolean,
streamOpts?: Omit<StreamRouteOptions, "wss" | "isAuthenticated">,
): (request: IncomingMessage, socket: Socket, head: Buffer) => void { ): (request: IncomingMessage, socket: Socket, head: Buffer) => void {
return (request: IncomingMessage, socket: Socket, head: Buffer): void => { return (request: IncomingMessage, socket: Socket, head: Buffer): void => {
const url = new URL(request.url ?? "/", "http://localhost"); const url = new URL(request.url ?? "/", "http://localhost");
@ -46,6 +53,39 @@ export function createUpgradeHandler(
return; return;
} }
// /sessions/:id/stream
const streamMatch = url.pathname.match(/^\/sessions\/([^/]+)\/stream$/);
if (streamMatch) {
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
const sessionId = decodeURIComponent(streamMatch[1]);
handleStreamUpgrade(sessionId, request, socket, head, {
wss,
isAuthenticated,
...streamOpts,
});
return;
}
// /sessions/:id/side
const sideMatch = url.pathname.match(/^\/sessions\/([^/]+)\/side$/);
if (sideMatch) {
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
const sessionId = decodeURIComponent(sideMatch[1]);
handleSideUpgrade(sessionId, request, socket, head, {
wss,
isAuthenticated,
});
return;
}
// Unknown upgrade path — reject // Unknown upgrade path — reject
socket.destroy(); socket.destroy();
}; };

View File

@ -0,0 +1,47 @@
/**
* Shared HTTP server utilities for route handlers.
* Owner: T-1.5
*/
import type { IncomingMessage, ServerResponse } from "node:http";
/** Read the full request body as a string. */
export function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
/** Send a JSON response. */
export function sendJson(
res: ServerResponse,
status: number,
body: unknown,
): void {
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
});
res.end(payload);
}
/** Extract a path segment by index (0-based, after splitting on '/'). */
export function pathSegment(url: string, index: number): string | undefined {
return url.split("?")[0].split("/").filter(Boolean)[index];
}
/**
* Parse bearer token from Authorization header or ?token= query param.
* Returns the raw token string or null.
*/
export function extractBearer(req: IncomingMessage): string | null {
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) return auth.slice(7).trim();
if (auth?.startsWith("bearer ")) return auth.slice(7).trim();
const url = new URL(req.url ?? "/", "http://localhost");
return url.searchParams.get("token");
}