OXYGENHUB
# 06699bcc6e2e spectra-vscode / src / bridge.ts
4.3 KiB · last changed Spectra for VS Code 0.1: set up and chat without the command line (#6) 1 day ago source blame history raw
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
}