| ff39a5e8 Andy McCall ↶ |
1 |
// The extension's way to Spectra (spec §3): `spectra bridge` signs every |
| 2 |
// request as the owner, so no key is ever handled here. |
| 3 |
import * as cp from "node:child_process"; |
| 4 |
import * as vscode from "vscode"; |
| 5 |
import { binary } from "./cli"; |
| 6 |
import { SseParser } from "./pure"; |
| 7 |
|
| 8 |
export class SpectraError extends Error { |
| 9 |
constructor(public status: number, message: string) { |
| 10 |
super(message); |
| 11 |
} |
| 12 |
} |
| 13 |
|
| 14 |
export class Bridge implements vscode.Disposable { |
| 15 |
private child: cp.ChildProcess | undefined; |
| 16 |
private ready: Promise<{ url: string; token: string }> | undefined; |
| 17 |
private disposed = false; |
| 18 |
private streams = new Set<AbortController>(); |
| 19 |
private readonly log: vscode.OutputChannel; |
| 20 |
|
| 21 |
constructor(log: vscode.OutputChannel) { |
| 22 |
this.log = log; |
| 23 |
} |
| 24 |
|
| 25 |
/** Start the bridge if it is not running. */ |
| 26 |
private start(): Promise<{ url: string; token: string }> { |
| 27 |
if (this.ready) return this.ready; |
| 28 |
this.ready = new Promise((resolve, reject) => { |
| 29 |
const bin = binary(); |
| 30 |
if (!bin) return reject(new Error("spectra is not installed")); |
| 31 |
const child = cp.spawn(bin, ["bridge"], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" } }); |
| 32 |
this.child = child; |
| 33 |
let out = ""; |
| 34 |
let err = ""; |
| 35 |
child.stdout!.on("data", (d: Buffer) => { |
| 36 |
out += d.toString(); |
| 37 |
const nl = out.indexOf("\n"); |
| 38 |
if (nl >= 0) { |
| 39 |
try { |
| 40 |
resolve(JSON.parse(out.slice(0, nl))); |
| 41 |
} catch { |
| 42 |
reject(new Error("the bridge said something unexpected: " + out.slice(0, nl))); |
| 43 |
} |
| 44 |
} |
| 45 |
}); |
| 46 |
child.stderr!.on("data", (d: Buffer) => { |
| 47 |
err += d.toString(); |
| 48 |
this.log.append(d.toString()); |
| 49 |
}); |
| 50 |
child.on("exit", (code) => { |
| 51 |
this.log.appendLine(`spectra bridge exited (${code})`); |
| 52 |
reject(new Error(err.trim().split("\n").pop()?.replace(/^error:\s*/, "") || `spectra bridge exited (${code})`)); |
| 53 |
this.child = undefined; |
| 54 |
this.ready = undefined; |
| 55 |
}); |
| 56 |
}); |
| 57 |
return this.ready; |
| 58 |
} |
| 59 |
|
| 60 |
/** Stop, so the next request starts afresh (after setup changes what it reaches). */ |
| 61 |
restart(): void { |
| 62 |
for (const s of this.streams) s.abort(); |
| 63 |
this.streams.clear(); |
| 64 |
this.child?.stdin?.end(); |
| 65 |
this.child = undefined; |
| 66 |
this.ready = undefined; |
| 67 |
} |
| 68 |
|
| 69 |
async request<T>(method: string, path: string, body?: unknown): Promise<T> { |
| 70 |
const { url, token } = await this.start(); |
| 71 |
const r = await fetch(url + path, { |
| 72 |
method, |
| 73 |
headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, |
| 74 |
body: body !== undefined ? JSON.stringify(body) : undefined, |
| 75 |
}); |
| 76 |
const text = await r.text(); |
| 77 |
if (!r.ok) { |
| 78 |
let why = text; |
| 79 |
try { |
| 80 |
const p = JSON.parse(text); |
| 81 |
why = p.detail || p.title || text; |
| 82 |
} catch { |
| 83 |
/* not JSON */ |
| 84 |
} |
| 85 |
throw new SpectraError(r.status, why); |
| 86 |
} |
| 87 |
return (text ? JSON.parse(text) : undefined) as T; |
| 88 |
} |
| 89 |
|
| 90 |
/** Follow an SSE path; each event's data goes to `onData`. Reconnects until disposed. */ |
| 91 |
follow(path: string, onData: (data: string) => void, onUp: () => void): vscode.Disposable { |
| 92 |
const ctrl = new AbortController(); |
| 93 |
this.streams.add(ctrl); |
| 94 |
const loop = async () => { |
| 95 |
let wait = 1000; |
| 96 |
while (!ctrl.signal.aborted && !this.disposed) { |
| 97 |
try { |
| 98 |
const { url, token } = await this.start(); |
| 99 |
const r = await fetch(url + path, { headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal }); |
| 100 |
if (!r.ok || !r.body) throw new Error(`the stream answered ${r.status}`); |
| 101 |
onUp(); |
| 102 |
wait = 1000; |
| 103 |
const parser = new SseParser(); |
| 104 |
const dec = new TextDecoder(); |
| 105 |
for await (const chunk of r.body as unknown as AsyncIterable<Uint8Array>) { |
| 106 |
for (const d of parser.push(dec.decode(chunk, { stream: true }))) onData(d); |
| 107 |
} |
| 108 |
} catch (e) { |
| 109 |
if (ctrl.signal.aborted) return; |
| 110 |
this.log.appendLine(`stream: ${(e as Error).message}; again in ${wait / 1000} s`); |
| 111 |
} |
| 112 |
await new Promise((r) => setTimeout(r, wait)); |
| 113 |
wait = Math.min(wait * 2, 30000); |
| 114 |
} |
| 115 |
}; |
| 116 |
void loop(); |
| 117 |
return new vscode.Disposable(() => { |
| 118 |
ctrl.abort(); |
| 119 |
this.streams.delete(ctrl); |
| 120 |
}); |
| 121 |
} |
| 122 |
|
| 123 |
dispose(): void { |
| 124 |
this.disposed = true; |
| 125 |
this.restart(); |
| 126 |
} |
| 127 |
} |