import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import type { Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; const DEFAULT_DISCOVERY_TTL_MS = 60_000; const DEFAULT_DISCOVERY_DIR = join(tmpdir(), "pi-remote-tailscale", "discovery"); export interface DiscoveryRecord { id: string; instanceId: string; cwd: string; pid: number; startedAt: number; lastSeenAt: number; connectUrl?: string; localUrl?: string; lanUrl?: string; tunnelUrl?: string; remoteMode?: boolean; } export interface DiscoveryServiceOptions { directory?: string; now?: () => number; ttlMs?: number; } function escapeHtml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } function safeJsonParse(text: string): DiscoveryRecord | undefined { try { return JSON.parse(text) as DiscoveryRecord; } catch { return undefined; } } export class DiscoveryService { private readonly directory: string; private readonly now: () => number; private readonly ttlMs: number; constructor(options: DiscoveryServiceOptions = {}) { this.directory = options.directory ?? DEFAULT_DISCOVERY_DIR; this.now = options.now ?? Date.now; this.ttlMs = options.ttlMs ?? DEFAULT_DISCOVERY_TTL_MS; } async register( record: Omit & Partial>, ): Promise { await mkdir(this.directory, { recursive: true }); const now = this.now(); const nextRecord: DiscoveryRecord = { ...record, id: record.id ?? `${record.instanceId}-${record.pid}`, lastSeenAt: now, startedAt: record.startedAt ?? now, }; await writeFile(this.filePath(nextRecord.id), `${JSON.stringify(nextRecord, null, 2)}\n`, "utf8"); return nextRecord; } async get(id: string): Promise { try { const content = await readFile(this.filePath(id), "utf8"); return safeJsonParse(content); } catch { return undefined; } } async heartbeat(id: string, patch: Partial> = {}): Promise { const existing = await this.get(id); if (!existing) { return undefined; } return this.register({ ...existing, ...patch, id, startedAt: existing.startedAt, }); } async list(): Promise { await mkdir(this.directory, { recursive: true }); const names = await readdir(this.directory); const records: DiscoveryRecord[] = []; for (const name of names) { if (!name.endsWith(".json")) { continue; } const content = await readFile(join(this.directory, name), "utf8").catch(() => {}); if (!content) { continue; } const parsed = safeJsonParse(content); if (parsed) { records.push(parsed); } } records.sort((left, right) => right.lastSeenAt - left.lastSeenAt); return records; } async prune(): Promise { const now = this.now(); const staleIds: string[] = []; for (const record of await this.list()) { if (now - record.lastSeenAt > this.ttlMs) { staleIds.push(record.id); } } for (const id of staleIds) { await this.unregister(id); } return staleIds; } async unregister(id: string): Promise { await rm(this.filePath(id), { force: true }); } private filePath(id: string): string { return join(this.directory, `${id}.json`); } } export function createDiscoveryService(options: DiscoveryServiceOptions = {}): DiscoveryService { return new DiscoveryService(options); } export function renderDiscoveryHtml(records: DiscoveryRecord[]): string { const cards = records.length === 0 ? '
No active remote sessions.
' : records .map((record) => { const badges = [ record.remoteMode ? 'child mode' : 'direct', record.tunnelUrl ? 'tailscale' : "", ] .filter(Boolean) .join(""); return `

${escapeHtml(record.instanceId)}

${badges}

CWD: ${escapeHtml(record.cwd)}

PID: ${record.pid}

Connect: ${escapeHtml(record.connectUrl ?? record.tunnelUrl ?? record.lanUrl ?? record.localUrl ?? "unavailable")}

`; }) .join("\n"); return ` pi remote discovery

Active pi remote sessions

Discovery metadata is local-only and never includes auth tokens.

${cards}
`; } export async function startDiscoveryHttpServer( service: Pick, options: { host?: string; port?: number } = {}, ): Promise<{ host: string; port: number; server: Server; stop: () => Promise }> { const host = options.host ?? "127.0.0.1"; const port = options.port ?? 7008; const server = createServer(async (request, response) => { const url = new URL(request.url ?? "/", `http://${host}:${port}`); const records = await service.list(); if (url.pathname === "/sessions.json") { response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ sessions: records })); return; } response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(renderDiscoveryHtml(records)); }); await new Promise((resolve, reject) => { server.listen(port, host, () => resolve()); server.once("error", reject); }); const address = server.address(); /* V8 ignore next -- Node returns an object for bound TCP listeners in this package. */ const resolvedPort = typeof address === "object" && address ? address.port : port; return { host, port: resolvedPort, server, stop: async () => { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); }, }; }