@agent-flow-board/bridge 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,275 @@
1
+ // THE BROWSER OF A CHECK: ONE CHROMIUM SERVER STARTED BY THE PARENT, WHICH THE SANDBOXED CHILD ONLY CONNECTS TO.
2
+ // Playwright is not a dependency of the bridge (it is large and most people never run a check). It is found next to the bridge when it is there,
3
+ // and otherwise installed once into ~/.agent-flow-board/checks/runtime. Nothing in this file imports Playwright at load time.
4
+ // -- DEP-MODULES -- //
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { acquireLock } from './lock.js';
11
+ import { checksHome, ensureDir } from './paths.js';
12
+ // -- CONSTANTS -- //
13
+ /** The Playwright version the runtime install pins, so a check behaves the same on every machine. */
14
+ export const PLAYWRIGHT_VERSION = '1.64.0';
15
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
16
+ /** Written when the runtime and the Chromium download both finished. Only this note says the install is whole (a download that was cut leaves files behind). */
17
+ const COMPLETE_NOTE = '.complete';
18
+ /** One machine installs once: the lock outlives the longest install, and a waiter gives up after a little longer than that. */
19
+ const INSTALL_LOCK_STALE_MS = 2 * INSTALL_TIMEOUT_MS + 60_000;
20
+ const INSTALL_LOCK_WAIT_MS = 2 * INSTALL_TIMEOUT_MS + 120_000;
21
+ // -- FUNCTIONS -- //
22
+ /** Where the runtime install lives. It is a folder of the bridge's own state, never a repo. */
23
+ const runtimeDir = () => join(checksHome(), 'runtime');
24
+ const looksInstalled = (nodeModules) => existsSync(join(nodeModules, 'playwright-core', 'package.json'));
25
+ /** The version of the playwright-core in a node_modules folder, or '' when it cannot be read. */
26
+ function versionIn(nodeModules) {
27
+ try {
28
+ const v = JSON.parse(readFileSync(join(nodeModules, 'playwright-core', 'package.json'), 'utf8'));
29
+ return v && typeof v === 'object' ? String(v.version ?? '') : '';
30
+ }
31
+ catch {
32
+ return '';
33
+ }
34
+ }
35
+ /**
36
+ * Which of two Playwright folders to use. The one next to the bridge is used when it is the pinned version; when it is another version and the runtime
37
+ * install holds the pinned one, the runtime copy wins (the browser pool relies on the isolation of that exact version). With neither pinned, the one next to
38
+ * the bridge is returned (assertPlaywrightVersion then says what is wrong), else the runtime copy, else null.
39
+ */
40
+ export function choosePlaywrightDir(beside, runtime, versionOf = versionIn) {
41
+ if (beside && versionOf(beside) === PLAYWRIGHT_VERSION)
42
+ return beside;
43
+ if (runtime && versionOf(runtime) === PLAYWRIGHT_VERSION)
44
+ return runtime;
45
+ return beside ?? runtime;
46
+ }
47
+ /**
48
+ * The node_modules folder that holds playwright-core (and @playwright/test when it is there): next to the bridge first (when it is the pinned version),
49
+ * then the runtime install. The sandboxed child is allowed to read this folder and nothing else outside its own. Null when Playwright is not installed anywhere.
50
+ */
51
+ export function playwrightRuntimeDir() {
52
+ let beside = null;
53
+ try {
54
+ const pkg = createRequire(import.meta.url).resolve('playwright-core/package.json');
55
+ // .../node_modules/playwright-core/package.json -> .../node_modules
56
+ beside = dirname(dirname(pkg));
57
+ }
58
+ catch {
59
+ /* Not next to the bridge: try the runtime install */
60
+ }
61
+ const own = join(runtimeDir(), 'node_modules');
62
+ return choosePlaywrightDir(beside, looksInstalled(own) ? own : null);
63
+ }
64
+ /** The folders whose playwright-core version was already compared with the pin. */
65
+ const versionChecked = new Set();
66
+ /**
67
+ * Throws when the playwright-core in `dir` is not the pinned version. The browser pool relies on the isolation of that exact version
68
+ * (every connection to a launched server gets contexts of its own), so another version is refused rather than trusted.
69
+ */
70
+ export function assertPlaywrightVersion(dir) {
71
+ if (versionChecked.has(dir))
72
+ return;
73
+ let found = '';
74
+ try {
75
+ const v = JSON.parse(readFileSync(join(dir, 'playwright-core', 'package.json'), 'utf8'));
76
+ found = v && typeof v === 'object' ? String(v.version ?? '') : '';
77
+ }
78
+ catch {
79
+ /* Unreadable: reported below */
80
+ }
81
+ if (found !== PLAYWRIGHT_VERSION)
82
+ throw new Error(`The check tool needs Playwright ${PLAYWRIGHT_VERSION} but found ${found || 'no readable version'} in ${dir}. Run "agent-flow-board check setup".`);
83
+ versionChecked.add(dir);
84
+ }
85
+ /** Loads playwright-core from the folder playwrightRuntimeDir names, by absolute path. */
86
+ export async function loadPlaywright() {
87
+ const dir = playwrightRuntimeDir();
88
+ if (!dir)
89
+ throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
90
+ assertPlaywrightVersion(dir);
91
+ const req = createRequire(join(dir, 'package.json'));
92
+ return req(join(dir, 'playwright-core'));
93
+ }
94
+ /** The Chromium rule that makes every host unresolvable except the preview's, localhost and the hosts named. */
95
+ export function hostResolverRule(hosts = []) {
96
+ const exclude = [...new Set(['127.0.0.1', 'localhost', ...hosts])]
97
+ .map((h) => `EXCLUDE ${h}`)
98
+ .join(', ');
99
+ return `--host-resolver-rules=MAP * ~NOTFOUND, ${exclude}`;
100
+ }
101
+ /**
102
+ * A browser the parent starts, so a check's process only connects to it and never has to start a program itself.
103
+ * Every host except the preview's (and the hosts named) is unresolvable, whatever a check does with routes.
104
+ */
105
+ export async function launchBrowserServer({ headed = false, hosts = [] } = {}) {
106
+ const { chromium } = await loadPlaywright();
107
+ // Never pass the shared-browser option: without it every connection gets contexts of its own and sees none of another connection's
108
+ const server = await chromium.launchServer({
109
+ headless: !headed,
110
+ args: [hostResolverRule(hosts), '--disable-background-networking', '--no-first-run']
111
+ });
112
+ return {
113
+ wsEndpoint: () => server.wsEndpoint(),
114
+ close: () => server.close(),
115
+ onClose: (callback) => void server.on('close', callback)
116
+ };
117
+ }
118
+ /** Says whether an installer failure looks like no network or a proxy, so the one-line error can tell the person what to look at. */
119
+ export function describeInstallFailure(output) {
120
+ if (/proxy|407|tunnel|ECONNRESET|self[- ]signed|certificate/i.test(output))
121
+ return 'it looks like a proxy problem (check HTTPS_PROXY and the proxy settings)';
122
+ if (/ENOTFOUND|EAI_AGAIN|ENETUNREACH|ECONNREFUSED|ETIMEDOUT|getaddrinfo|offline|network/i.test(output))
123
+ return 'it looks like this machine is offline';
124
+ return 'see the last lines above';
125
+ }
126
+ /** Runs a program with the exact arguments (no shell, no stdin) and sends every output line to `log`. Resolves with the exit code and the last output. */
127
+ function runLogged(file, args, log, cwd) {
128
+ return new Promise((resolve, reject) => {
129
+ const child = spawn(file, args, {
130
+ cwd,
131
+ shell: false,
132
+ windowsHide: true,
133
+ stdio: ['ignore', 'pipe', 'pipe']
134
+ });
135
+ let tail = '';
136
+ const feed = (chunk) => {
137
+ for (const line of chunk.toString('utf8').split(/\r?\n/)) {
138
+ const t = line.trim();
139
+ if (!t)
140
+ continue;
141
+ tail = `${tail}\n${t}`.slice(-2000);
142
+ log(t);
143
+ }
144
+ };
145
+ child.stdout.on('data', feed);
146
+ child.stderr.on('data', feed);
147
+ const timer = setTimeout(() => child.kill(), INSTALL_TIMEOUT_MS);
148
+ child.on('error', (e) => {
149
+ clearTimeout(timer);
150
+ reject(e);
151
+ });
152
+ child.on('close', (code) => {
153
+ clearTimeout(timer);
154
+ resolve({ code, tail });
155
+ });
156
+ });
157
+ }
158
+ /** The npm that belongs to this Node: its npm-cli.js, so no shell is needed. Null when it is not where Node keeps it. */
159
+ function npmCli() {
160
+ const root = dirname(process.execPath);
161
+ const candidates = [
162
+ join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
163
+ join(root, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
164
+ ];
165
+ return candidates.find((c) => existsSync(c)) ?? null;
166
+ }
167
+ /**
168
+ * Installs Playwright into the runtime folder of the bridge's state.
169
+ * This is the ONE place npm is used: it installs a runtime for the installed bridge into ~/.agent-flow-board, never into a repo,
170
+ * so the Yarn-only rule of the repos does not apply here.
171
+ */
172
+ async function installRuntime(log) {
173
+ const dir = ensureDir(runtimeDir());
174
+ const manifest = join(dir, 'package.json');
175
+ if (!existsSync(manifest))
176
+ writeFileSync(manifest, JSON.stringify({ private: true }, null, 2));
177
+ log(`Installing Playwright ${PLAYWRIGHT_VERSION} into ${dir} (first use only)...`);
178
+ // @playwright/test brings the expect that checks use; playwright-core is what the parent drives
179
+ const npmArgs = [
180
+ 'install',
181
+ '--no-audit',
182
+ '--no-fund',
183
+ '--prefix',
184
+ dir,
185
+ `playwright-core@${PLAYWRIGHT_VERSION}`,
186
+ `@playwright/test@${PLAYWRIGHT_VERSION}`
187
+ ];
188
+ const cli = npmCli();
189
+ const out = await new Promise((resolve) => {
190
+ const done = (error, stdout, stderr) => resolve({ error, text: `${stdout}\n${stderr}` });
191
+ if (cli)
192
+ execFile(process.execPath, [cli, ...npmArgs], { windowsHide: true, timeout: INSTALL_TIMEOUT_MS, shell: false }, (e, so, se) => done(e, so, se));
193
+ else
194
+ execFile('npm', npmArgs, { windowsHide: true, timeout: INSTALL_TIMEOUT_MS, shell: process.platform === 'win32' }, (e, so, se) => done(e, so, se));
195
+ });
196
+ if (out.error || !looksInstalled(join(dir, 'node_modules'))) {
197
+ const last = out.text.trim().split('\n').slice(-3).join(' ').slice(0, 300);
198
+ throw new Error(`Could not install Playwright: ${describeInstallFailure(out.text)}. ${last}`);
199
+ }
200
+ }
201
+ /** The pinned Playwright folder, or null: the runtime has to be installed (again) when the only copy is another version. */
202
+ function pinnedDir() {
203
+ const dir = playwrightRuntimeDir();
204
+ return dir && versionIn(dir) === PLAYWRIGHT_VERSION ? dir : null;
205
+ }
206
+ async function installChromium(dir, log) {
207
+ log('Installing the Chromium browser for Playwright (first use only)...');
208
+ const cli = join(dir, 'playwright-core', 'cli.js');
209
+ if (!existsSync(cli))
210
+ throw new Error('Playwright is installed but its installer (cli.js) is missing. Remove the runtime folder and try again.');
211
+ const { code, tail } = await runLogged(process.execPath, [cli, 'install', 'chromium'], log).catch((e) => ({ code: -1, tail: String(e) }));
212
+ if (code !== 0)
213
+ throw new Error(`Could not install the Chromium browser: ${describeInstallFailure(tail)}. ${tail.split('\n').slice(-2).join(' ').trim().slice(0, 300)}`);
214
+ }
215
+ const realInstallIo = {
216
+ pinnedDir,
217
+ chromiumPresent: async () => {
218
+ try {
219
+ const { chromium } = await loadPlaywright();
220
+ return existsSync(chromium.executablePath());
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ },
226
+ installRuntime,
227
+ installChromium,
228
+ notePath: () => join(runtimeDir(), COMPLETE_NOTE),
229
+ lockName: 'browser-install',
230
+ pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
231
+ };
232
+ /** Takes the install lock without blocking the event loop. Null when another install kept it for longer than a whole install takes. */
233
+ async function takeInstallLock(io, waitMs, staleMs) {
234
+ const until = Date.now() + waitMs;
235
+ for (;;) {
236
+ const lock = acquireLock(io.lockName, { staleMs });
237
+ if (lock)
238
+ return lock;
239
+ if (Date.now() > until)
240
+ return null;
241
+ await io.pause(500);
242
+ }
243
+ }
244
+ /**
245
+ * Makes sure Playwright and its Chromium are there. The first use installs them (a few hundred megabytes, with progress lines in `log`);
246
+ * later uses return at once. Never prompts. A failure is one Error that says whether it looks like offline or a proxy.
247
+ * Two processes never install at once: a lock covers the runtime install and the Chromium download, and a note (.complete) is written only after both finished, so a
248
+ * download that was cut short is done again instead of being trusted.
249
+ */
250
+ export async function ensureBrowser(log = () => { }, io = realInstallIo, limits = {}) {
251
+ const whole = async () => existsSync(io.notePath()) && io.pinnedDir() !== null && (await io.chromiumPresent());
252
+ if (await whole())
253
+ return;
254
+ const lock = await takeInstallLock(io, limits.waitMs ?? INSTALL_LOCK_WAIT_MS, limits.staleMs ?? INSTALL_LOCK_STALE_MS);
255
+ if (!lock)
256
+ throw new Error('Another process has been installing the browser for a long time. Wait for it, or run "agent-flow-board check doctor".');
257
+ try {
258
+ // Whoever held the lock before may have finished the whole install
259
+ if (await whole())
260
+ return;
261
+ rmSync(io.notePath(), { force: true });
262
+ if (!io.pinnedDir())
263
+ await io.installRuntime(log);
264
+ const dir = io.pinnedDir() ?? playwrightRuntimeDir();
265
+ if (!dir)
266
+ throw new Error('Playwright was installed but cannot be found.');
267
+ if (!(await io.chromiumPresent()))
268
+ await io.installChromium(dir, log);
269
+ ensureDir(dirname(io.notePath()));
270
+ writeFileSync(io.notePath(), new Date().toISOString() + '\n');
271
+ }
272
+ finally {
273
+ lock.release();
274
+ }
275
+ }
@@ -0,0 +1,221 @@
1
+ // THE BROWSER POOL: RUNS THAT NEED THE SAME REACHABLE HOSTS SHARE ONE CHROMIUM SERVER, EACH RUN WITH A FRESH CONTEXT OF ITS OWN.
2
+ // Why sharing is safe: the server is started with Playwright's launchServer and never with the shared-browser option, so every connection to its endpoint
3
+ // gets contexts of its own and sees none of another connection's (proved by the opt-in real browser test). Why the key holds the hosts: the host
4
+ // resolver rule is a launch argument of the browser, so a browser started for one set of hosts can never reach the hosts of another set.
5
+ // A registration for a live site (`dedicated: true`) always gets a browser of its own.
6
+ //
7
+ // FILE LAYOUT (poolDir()): browser-<id>.json, one per launched browser, written whole when the browser is up and removed when it closes or crashes:
8
+ // { kind: 'browser', id, pid (the process that owns the pool), key, headed, hosts, dedicated, startedAt }
9
+ // The websocket endpoint is NOT written: it is a capability. Sharing one browser across processes is the machine broker's job; until it exists a pool
10
+ // lives in one process, and the marker only lets doctor see (and sweep, when the owner is dead) what was left.
11
+ // -- DEP-MODULES -- //
12
+ import { randomBytes } from 'node:crypto';
13
+ import { readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ // -- MODULES -- //
16
+ import { launchBrowserServer } from './browser.js';
17
+ import { pidAlive } from './lock.js';
18
+ import { ensureDir, poolDir } from './paths.js';
19
+ // -- CONSTANTS -- //
20
+ /** A shared browser with no runs on it stays up this long, so the next run of a burst does not pay for a new launch. */
21
+ const DEFAULT_IDLE_MS = 15_000;
22
+ const MARKER_PREFIX = 'browser-';
23
+ // -- FUNCTIONS -- //
24
+ /** The pool key: the sorted, lower-cased host set and the headed flag. Two runs share a browser only when this is equal. */
25
+ export function browserKey(hosts, headed) {
26
+ const set = [...new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
27
+ return `${headed ? 'headed' : 'headless'}|${set.join(',')}`;
28
+ }
29
+ function writeWhole(file, text) {
30
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
31
+ writeFileSync(tmp, text, { mode: 0o600 });
32
+ renameSync(tmp, file);
33
+ }
34
+ const markerFile = (id) => join(poolDir(), `${MARKER_PREFIX}${id}.json`);
35
+ /** Every browser marker on this machine, with whether the process that owns it is alive. For doctor. */
36
+ export function listBrowserMarkers() {
37
+ const out = [];
38
+ let names;
39
+ try {
40
+ names = readdirSync(poolDir());
41
+ }
42
+ catch {
43
+ return out;
44
+ }
45
+ for (const n of names) {
46
+ if (!n.startsWith(MARKER_PREFIX) || !n.endsWith('.json'))
47
+ continue;
48
+ try {
49
+ const v = JSON.parse(readFileSync(join(poolDir(), n), 'utf8'));
50
+ const m = v;
51
+ if (m && m.kind === 'browser' && typeof m.id === 'string' && typeof m.pid === 'number')
52
+ out.push({ marker: m, ownerAlive: pidAlive(m.pid) });
53
+ }
54
+ catch {
55
+ /* A damaged marker is not listed */
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /** Removes the markers of browsers whose owner process is gone (the browser died with it). Returns how many. */
61
+ export function sweepBrowserMarkers() {
62
+ let removed = 0;
63
+ for (const { marker, ownerAlive } of listBrowserMarkers()) {
64
+ if (ownerAlive || !/^[a-f0-9]{6,16}$/.test(marker.id))
65
+ continue;
66
+ rmSync(markerFile(marker.id), { force: true });
67
+ removed++;
68
+ }
69
+ return removed;
70
+ }
71
+ export class BrowserPool {
72
+ entries = new Map();
73
+ all = new Set();
74
+ launcher;
75
+ idleMs;
76
+ markers;
77
+ constructor(opts = {}) {
78
+ this.launcher = opts.launcher ?? ((o) => launchBrowserServer(o));
79
+ this.idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
80
+ this.markers = opts.markers ?? true;
81
+ }
82
+ /** How many browsers are up (or starting), counting dedicated ones. */
83
+ get size() {
84
+ return this.all.size;
85
+ }
86
+ launch(entry) {
87
+ entry.server = this.launcher({
88
+ headed: entry.request.headed,
89
+ hosts: entry.request.hosts
90
+ }).then((server) => {
91
+ if (this.markers)
92
+ this.writeMarker(entry);
93
+ // A crash takes only this entry out: the next acquire of its key launches a new browser, runs that hold this one fail on their own
94
+ server.onClose?.(() => this.markGone(entry));
95
+ return server;
96
+ }, (e) => {
97
+ this.markGone(entry);
98
+ throw e;
99
+ });
100
+ // The rejection is handled by the acquire that awaits it
101
+ entry.server.catch(() => { });
102
+ }
103
+ writeMarker(entry) {
104
+ try {
105
+ ensureDir(poolDir());
106
+ const marker = {
107
+ kind: 'browser',
108
+ id: entry.id,
109
+ pid: process.pid,
110
+ key: entry.key,
111
+ headed: entry.request.headed,
112
+ hosts: entry.request.hosts,
113
+ dedicated: entry.request.dedicated,
114
+ startedAt: new Date().toISOString()
115
+ };
116
+ writeWhole(markerFile(entry.id), JSON.stringify(marker) + '\n');
117
+ }
118
+ catch {
119
+ /* The marker is for doctor only: a browser still works without it */
120
+ }
121
+ }
122
+ markGone(entry) {
123
+ if (entry.gone)
124
+ return;
125
+ entry.gone = true;
126
+ if (entry.idleTimer)
127
+ clearTimeout(entry.idleTimer);
128
+ if (this.entries.get(entry.key) === entry)
129
+ this.entries.delete(entry.key);
130
+ this.all.delete(entry);
131
+ if (this.markers)
132
+ rmSync(markerFile(entry.id), { force: true });
133
+ }
134
+ async closeEntry(entry) {
135
+ this.markGone(entry);
136
+ try {
137
+ await (await entry.server).close();
138
+ }
139
+ catch {
140
+ /* Already closed or never started */
141
+ }
142
+ }
143
+ async acquire(req = {}) {
144
+ const headed = req.headed === true;
145
+ const hosts = [
146
+ ...new Set((req.hosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean))
147
+ ].sort();
148
+ const dedicated = req.dedicated === true;
149
+ const key = browserKey(hosts, headed);
150
+ let entry = dedicated ? undefined : this.entries.get(key);
151
+ if (!entry || entry.gone) {
152
+ entry = {
153
+ id: randomBytes(4).toString('hex'),
154
+ key,
155
+ request: { headed, hosts, dedicated },
156
+ server: Promise.resolve(null),
157
+ refs: 0,
158
+ gone: false
159
+ };
160
+ if (!dedicated)
161
+ this.entries.set(key, entry);
162
+ this.all.add(entry);
163
+ this.launch(entry);
164
+ }
165
+ const mine = entry;
166
+ mine.refs++;
167
+ if (mine.idleTimer) {
168
+ clearTimeout(mine.idleTimer);
169
+ mine.idleTimer = undefined;
170
+ }
171
+ let server;
172
+ try {
173
+ server = await mine.server;
174
+ }
175
+ catch (e) {
176
+ mine.refs--;
177
+ throw e;
178
+ }
179
+ let released = false;
180
+ return {
181
+ wsEndpoint: () => server.wsEndpoint(),
182
+ release: async () => {
183
+ if (released)
184
+ return;
185
+ released = true;
186
+ mine.refs--;
187
+ if (mine.refs > 0 || mine.gone)
188
+ return;
189
+ if (dedicated || this.idleMs <= 0)
190
+ return this.closeEntry(mine);
191
+ // The grace period: a run that starts a moment later reuses this browser
192
+ mine.idleTimer = setTimeout(() => {
193
+ if (mine.refs === 0)
194
+ void this.closeEntry(mine);
195
+ }, this.idleMs);
196
+ mine.idleTimer.unref();
197
+ }
198
+ };
199
+ }
200
+ /** Closes every browser at once, whatever holds it. For the end of a worker or a run. */
201
+ async closeAll() {
202
+ await Promise.all([...this.all].map((e) => this.closeEntry(e)));
203
+ }
204
+ }
205
+ let shared = null;
206
+ /** The pool of this process. */
207
+ export function browserPool() {
208
+ shared ??= new BrowserPool();
209
+ return shared;
210
+ }
211
+ /**
212
+ * A browser for one run. The same sorted host set and headed flag share one Chromium; `dedicated` (a live site) never shares. Call `release()` when the
213
+ * run ends; the browser closes after a short idle grace once nobody holds it.
214
+ */
215
+ export function acquireBrowser(req = {}) {
216
+ return browserPool().acquire(req);
217
+ }
218
+ /** Closes every browser of this process's pool. */
219
+ export async function closeAllBrowsers() {
220
+ await shared?.closeAll();
221
+ }