// The extension's way to Spectra (spec ยง3): `spectra bridge` signs every // request as the owner, so no key is ever handled here. import * as cp from "node:child_process"; import * as vscode from "vscode"; import { binary } from "./cli"; import { SseParser } from "./pure"; export class SpectraError extends Error { constructor(public status: number, message: string) { super(message); } } export class Bridge implements vscode.Disposable { private child: cp.ChildProcess | undefined; private ready: Promise<{ url: string; token: string }> | undefined; private disposed = false; private streams = new Set(); private readonly log: vscode.OutputChannel; constructor(log: vscode.OutputChannel) { this.log = log; } /** Start the bridge if it is not running. */ private start(): Promise<{ url: string; token: string }> { if (this.ready) return this.ready; this.ready = new Promise((resolve, reject) => { const bin = binary(); if (!bin) return reject(new Error("spectra is not installed")); const child = cp.spawn(bin, ["bridge"], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" } }); this.child = child; let out = ""; let err = ""; child.stdout!.on("data", (d: Buffer) => { out += d.toString(); const nl = out.indexOf("\n"); if (nl >= 0) { try { resolve(JSON.parse(out.slice(0, nl))); } catch { reject(new Error("the bridge said something unexpected: " + out.slice(0, nl))); } } }); child.stderr!.on("data", (d: Buffer) => { err += d.toString(); this.log.append(d.toString()); }); child.on("exit", (code) => { this.log.appendLine(`spectra bridge exited (${code})`); reject(new Error(err.trim().split("\n").pop()?.replace(/^error:\s*/, "") || `spectra bridge exited (${code})`)); this.child = undefined; this.ready = undefined; }); }); return this.ready; } /** Stop, so the next request starts afresh (after setup changes what it reaches). */ restart(): void { for (const s of this.streams) s.abort(); this.streams.clear(); this.child?.stdin?.end(); this.child = undefined; this.ready = undefined; } async request(method: string, path: string, body?: unknown): Promise { const { url, token } = await this.start(); const r = await fetch(url + path, { method, headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await r.text(); if (!r.ok) { let why = text; try { const p = JSON.parse(text); why = p.detail || p.title || text; } catch { /* not JSON */ } throw new SpectraError(r.status, why); } return (text ? JSON.parse(text) : undefined) as T; } /** Follow an SSE path; each event's data goes to `onData`. Reconnects until disposed. */ follow(path: string, onData: (data: string) => void, onUp: () => void): vscode.Disposable { const ctrl = new AbortController(); this.streams.add(ctrl); const loop = async () => { let wait = 1000; while (!ctrl.signal.aborted && !this.disposed) { try { const { url, token } = await this.start(); const r = await fetch(url + path, { headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal }); if (!r.ok || !r.body) throw new Error(`the stream answered ${r.status}`); onUp(); wait = 1000; const parser = new SseParser(); const dec = new TextDecoder(); for await (const chunk of r.body as unknown as AsyncIterable) { for (const d of parser.push(dec.decode(chunk, { stream: true }))) onData(d); } } catch (e) { if (ctrl.signal.aborted) return; this.log.appendLine(`stream: ${(e as Error).message}; again in ${wait / 1000} s`); } await new Promise((r) => setTimeout(r, wait)); wait = Math.min(wait * 2, 30000); } }; void loop(); return new vscode.Disposable(() => { ctrl.abort(); this.streams.delete(ctrl); }); } dispose(): void { this.disposed = true; this.restart(); } }