char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somtests: keep browser profiles and built bundles out of shared test statee880b1f

main
7.4 KiB222 linesraw
1// One headless firefox per load, driven over webdriver bidi: started on first use, one tab per call, closed when idle.
2
3import { mkdtemp, rm } from "node:fs/promises";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6
7type BidiMessage = {
8  id?: number;
9  type?: string;
10  result?: unknown;
11  error?: string;
12  message?: string;
13};
14
15type Session = {
16  request<R>(method: string, params: object): Promise<R>;
17  readonly closed: Promise<void>;
18  close(): Promise<void>;
19};
20
21const COMMAND_TIMEOUT_MS = 30_000;
22
23async function launch(profiles: string): Promise<Session> {
24  const profile = await mkdtemp(join(profiles, "berry-firefox-"));
25  const child = new Deno.Command("firefox", {
26    args: [
27      "--headless",
28      "--no-remote",
29      "--profile",
30      profile,
31      "--remote-debugging-port",
32      "0",
33      "about:blank",
34    ],
35    stdin: "null",
36    stdout: "null",
37    stderr: "piped",
38  }).spawn();
39  const exited = child.status.then(() => rm(profile, { recursive: true, force: true }));
40
41  // keep draining stderr, or firefox blocks on a full pipe
42  const endpoint = await new Promise<string>((resolve, reject) => {
43    const timeout = setTimeout(
44      () => reject(new Error("Firefox did not start within 10 seconds")),
45      10_000,
46    );
47    let seen = "";
48    void (async () => {
49      for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream())) {
50        seen = (seen + chunk).slice(-16_384);
51        const match = seen.match(/WebDriver BiDi listening on (ws:\/\/\S+)/);
52        if (match) {
53          clearTimeout(timeout);
54          resolve(`${match[1]}/session`);
55        }
56      }
57      clearTimeout(timeout);
58      reject(new Error(seen.trim() || "Firefox exited before starting"));
59    })();
60  });
61
62  const socket = new WebSocket(endpoint);
63  await new Promise<void>((resolve, reject) => {
64    socket.addEventListener("open", () => resolve(), { once: true });
65    socket.addEventListener("error", () => reject(new Error("Could not connect to Firefox")), {
66      once: true,
67    });
68  });
69
70  let nextId = 0;
71  const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
72  socket.addEventListener("message", (event) => {
73    const message = JSON.parse(String(event.data)) as BidiMessage;
74    const request = message.id === undefined ? undefined : pending.get(message.id);
75    if (request === undefined) return;
76    pending.delete(message.id!);
77    if (message.type === "success") request.resolve(message.result);
78    else request.reject(new Error(message.message || message.error || "Firefox command failed"));
79  });
80  const closed = new Promise<void>((resolve) =>
81    socket.addEventListener("close", () => {
82      for (const request of pending.values()) {
83        request.reject(new Error("Firefox connection closed"));
84      }
85      pending.clear();
86      resolve();
87    })
88  );
89
90  const session: Session = {
91    request<R>(method: string, params: object) {
92      const id = ++nextId;
93      return new Promise<R>((resolve, reject) => {
94        const timeout = setTimeout(() => {
95          pending.delete(id);
96          reject(new Error(`Firefox command ${method} timed out`));
97        }, COMMAND_TIMEOUT_MS);
98        pending.set(id, {
99          resolve: (value) => (clearTimeout(timeout), resolve(value as R)),
100          reject: (error) => (clearTimeout(timeout), reject(error)),
101        });
102        socket.send(JSON.stringify({ id, method, params }));
103      });
104    },
105    closed,
106    async close() {
107      socket.close();
108      try {
109        child.kill();
110      } catch {
111        // already gone
112      }
113      const killer = setTimeout(() => {
114        try {
115          child.kill("SIGKILL");
116        } catch {
117          // already gone
118        }
119      }, 2_000);
120      await exited;
121      clearTimeout(killer);
122    },
123  };
124  await session.request("session.new", {
125    capabilities: { alwaysMatch: { timeouts: { pageLoad: 25_000, script: 25_000 } } },
126  });
127  return session;
128}
129
130/** `work`, unless `signal` aborts first; then the abort's reason, at once. */
131function raced<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
132  if (signal === undefined) return work;
133  return new Promise((resolve, reject) => {
134    const abort = () => reject(signal.reason);
135    // it may have fired already, while the browser was starting
136    if (signal.aborted) abort();
137    signal.addEventListener("abort", abort, { once: true });
138    work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
139  });
140}
141
142export type Browser = {
143  /** Load `url` in a fresh tab and return the json value `expression` resolves to there. */
144  evaluate<T>(url: string, expression: string, signal?: AbortSignal): Promise<T>;
145  close(): Promise<void>;
146};
147
148/** `profiles` is where each browser keeps its profile while it runs. */
149export function createBrowser({ idleMs = 60_000, profiles = tmpdir() } = {}): Browser {
150  let session: Promise<Session> | undefined;
151  let active = 0;
152  let idle: ReturnType<typeof setTimeout> | undefined;
153
154  function acquire(): Promise<Session> {
155    if (session === undefined) {
156      const started = launch(profiles);
157      session = started;
158      // a crashed or closed browser starts afresh on the next call
159      started.then((s) => s.closed.then(() => session === started && (session = undefined)), () => {
160        if (session === started) session = undefined;
161      });
162    }
163    return session;
164  }
165
166  let closing = Promise.resolve();
167  /** Close the running browser; a later call waits for an earlier one instead of finding nothing to do. */
168  function shutdown() {
169    const current = session;
170    session = undefined;
171    closing = closing.then(async () => await (await current?.catch(() => undefined))?.close());
172    return closing;
173  }
174
175  return {
176    async evaluate<T>(url: string, expression: string, signal?: AbortSignal): Promise<T> {
177      signal?.throwIfAborted();
178      active++;
179      clearTimeout(idle);
180      try {
181        const browser = await acquire();
182        const { context } = await browser.request<{ context: string }>("browsingContext.create", {
183          type: "tab",
184        });
185        const work = (async () => {
186          await browser.request("browsingContext.navigate", { context, url, wait: "complete" });
187          const evaluation = await browser.request<
188            | { type: "success"; result: { type: string; value?: unknown } }
189            | { type: "exception"; exceptionDetails: { text: string } }
190          >("script.evaluate", {
191            expression,
192            target: { context },
193            awaitPromise: true,
194            userActivation: false,
195            resultOwnership: "none",
196          });
197          if (evaluation.type === "exception") throw new Error(evaluation.exceptionDetails.text);
198          if (evaluation.result.type !== "string") {
199            throw new Error(`Firefox returned ${evaluation.result.type}, not text`);
200          }
201          return JSON.parse(evaluation.result.value as string) as T;
202        })();
203        try {
204          return await raced(work, signal);
205        } finally {
206          // closing the tab also stops whatever an abandoned call is still doing in it
207          const closing = browser.request("browsingContext.close", { context }).catch(() => {});
208          if (!signal?.aborted) await closing;
209        }
210      } finally {
211        if (--active === 0) {
212          idle = setTimeout(shutdown, idleMs);
213          Deno.unrefTimer(idle);
214        }
215      }
216    },
217    async close() {
218      clearTimeout(idle);
219      await shutdown();
220    },
221  };
222}