169 lines
4.6 KiB
TypeScript
169 lines
4.6 KiB
TypeScript
/**
|
||
* S-09 — multi-session CRUD routes.
|
||
*
|
||
* POST /sessions → { id, name, state, lastOutputAt }
|
||
* 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 });
|
||
// Include state + lastOutputAt to match the GET /sessions response shape
|
||
// so iOS clients can decode the response with the same type.
|
||
sendJson(res, 201, { id, name, state: "idle", lastOutputAt: "" });
|
||
} 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) });
|
||
}
|
||
}
|