@broberg/lens-daemon 0.1.37 → 0.1.38
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.
- package/package.json +3 -3
- package/src/browser.ts +12 -3
- package/src/ensure-chromium.ts +113 -1
- package/src/flow.ts +5 -1
- package/src/http.ts +3 -0
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"sharp": "^0.35.5",
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
|
-
"@broberg/lens-
|
|
19
|
-
"@broberg/lens-
|
|
18
|
+
"@broberg/lens-svg": "0.1.1",
|
|
19
|
+
"@broberg/lens-engine": "0.14.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/browser.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// context + page are always closed in a finally, even on capture error.
|
|
12
12
|
|
|
13
13
|
import { bindProfileChannel, claimProfile, killOwnBrowsersOn, profileDir, type ProfileChannel } from './profiles.ts';
|
|
14
|
+
import { launchWithRepair, type BrowserRepair } from './ensure-chromium.ts';
|
|
14
15
|
import {
|
|
15
16
|
chromium,
|
|
16
17
|
webkit,
|
|
@@ -80,7 +81,15 @@ const CHROMIUM_ARGS = [
|
|
|
80
81
|
|
|
81
82
|
async function launch(engine: LensEngine): Promise<Browser> {
|
|
82
83
|
const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
|
|
83
|
-
|
|
84
|
+
// F010.1 — a deleted browser is installed and the launch retried, not a fleet-wide outage.
|
|
85
|
+
return launchWithRepair(engine, () => ENGINES[engine].launch({ headless: true, args }), (r) => lastRepairs.push(r));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** F010.1 — repairs made since the last read; a run reads (and clears) them into
|
|
89
|
+
* its report. Read-and-clear, so one repair is reported once. */
|
|
90
|
+
const lastRepairs: BrowserRepair[] = [];
|
|
91
|
+
export function takeBrowserRepairs(): BrowserRepair[] {
|
|
92
|
+
return lastRepairs.splice(0, lastRepairs.length);
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/** Get the warm browser for an engine, launching it on first use. Re-launches
|
|
@@ -353,7 +362,7 @@ async function launchWithExtension(
|
|
|
353
362
|
if (profile) bindProfileChannel(profile, EXTENSION_CHANNEL);
|
|
354
363
|
userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
|
|
355
364
|
try {
|
|
356
|
-
context = await chromium.launchPersistentContext(userDataDir, {
|
|
365
|
+
context = await launchWithRepair('chromium', () => chromium.launchPersistentContext(userDataDir, {
|
|
357
366
|
...contextOptions(opts),
|
|
358
367
|
headless: true,
|
|
359
368
|
channel: EXTENSION_CHANNEL,
|
|
@@ -364,7 +373,7 @@ async function launchWithExtension(
|
|
|
364
373
|
'--disable-dev-shm-usage',
|
|
365
374
|
'--no-sandbox',
|
|
366
375
|
],
|
|
367
|
-
});
|
|
376
|
+
}), (r) => lastRepairs.push(r));
|
|
368
377
|
} catch (err) {
|
|
369
378
|
lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
|
|
370
379
|
if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
package/src/ensure-chromium.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { spawn } from 'node:child_process';
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
|
-
import { chromium } from 'playwright';
|
|
17
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
18
18
|
|
|
19
19
|
export interface ChromiumExpectation {
|
|
20
20
|
installed: boolean;
|
|
@@ -116,3 +116,115 @@ export function ensureChromium(): Promise<{
|
|
|
116
116
|
|
|
117
117
|
return inFlight;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
// ── F010.1 — repair at the moment of need, for every engine ────────────────────
|
|
121
|
+
//
|
|
122
|
+
// ensureChromium() runs once at daemon boot and installs the FULL Chromium only.
|
|
123
|
+
// On 25 Oct the browsers were deleted while the daemon was running, and the warm
|
|
124
|
+
// headless launch uses chromium-headless-shell, which boot never checked: every
|
|
125
|
+
// run fleet-wide failed until a hand reinstalled. Now a launch that fails because
|
|
126
|
+
// its binary is missing installs that engine's browsers (from the daemon's own
|
|
127
|
+
// pinned Playwright, same as boot) and is retried once.
|
|
128
|
+
|
|
129
|
+
export type BrowserEngine = 'chromium' | 'webkit' | 'firefox';
|
|
130
|
+
|
|
131
|
+
/** What `playwright install` must fetch for an engine to launch in every mode Lens uses. */
|
|
132
|
+
const INSTALL_NAMES: Record<BrowserEngine, string[]> = {
|
|
133
|
+
chromium: ['chromium', 'chromium-headless-shell', 'ffmpeg'],
|
|
134
|
+
webkit: ['webkit'],
|
|
135
|
+
firefox: ['firefox'],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function isMissingBrowserError(err: unknown): boolean {
|
|
139
|
+
return /Executable doesn't exist/.test(err instanceof Error ? err.message : String(err));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type Installer = (names: string[]) => Promise<{ ok: boolean; error?: string }>;
|
|
143
|
+
|
|
144
|
+
const spawnInstaller: Installer = (names) =>
|
|
145
|
+
new Promise((resolve) => {
|
|
146
|
+
let cliPath: string;
|
|
147
|
+
try {
|
|
148
|
+
cliPath = resolvePlaywrightCli();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
resolve({ ok: false, error: `cannot resolve playwright CLI: ${err instanceof Error ? err.message : String(err)}` });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(`[lens] browser missing — installing ${names.join(', ')} via ${cliPath} …`);
|
|
154
|
+
const child = spawn(process.execPath, [cliPath, 'install', ...names], { stdio: 'inherit', env: process.env });
|
|
155
|
+
child.on('error', (err) => resolve({ ok: false, error: err.message }));
|
|
156
|
+
child.on('exit', (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
let installer: Installer = spawnInstaller;
|
|
160
|
+
/** Test seam: replace the installer (null restores the real one). */
|
|
161
|
+
export function setBrowserInstaller(fn: Installer | null): void {
|
|
162
|
+
installer = fn ?? spawnInstaller;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One install per engine at a time: two runs that hit the gap together share it.
|
|
166
|
+
const engineInFlight = new Map<BrowserEngine, Promise<{ ok: boolean; error?: string }>>();
|
|
167
|
+
|
|
168
|
+
export function installBrowsers(engine: BrowserEngine): Promise<{ ok: boolean; error?: string }> {
|
|
169
|
+
let p = engineInFlight.get(engine);
|
|
170
|
+
if (!p) {
|
|
171
|
+
p = installer(INSTALL_NAMES[engine]).finally(() => engineInFlight.delete(engine));
|
|
172
|
+
engineInFlight.set(engine, p);
|
|
173
|
+
}
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface BrowserRepair {
|
|
178
|
+
engine: BrowserEngine;
|
|
179
|
+
ms: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Run a launch; if it fails ONLY because the browser binary is missing, install
|
|
183
|
+
* that engine's browsers and retry once. Any other error propagates untouched. */
|
|
184
|
+
export async function launchWithRepair<T>(
|
|
185
|
+
engine: BrowserEngine,
|
|
186
|
+
launch: () => Promise<T>,
|
|
187
|
+
onRepaired?: (r: BrowserRepair) => void,
|
|
188
|
+
): Promise<T> {
|
|
189
|
+
try {
|
|
190
|
+
return await launch();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (!isMissingBrowserError(err)) throw err;
|
|
193
|
+
const t0 = Date.now();
|
|
194
|
+
const inst = await installBrowsers(engine);
|
|
195
|
+
if (!inst.ok) {
|
|
196
|
+
throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
|
|
197
|
+
}
|
|
198
|
+
const out = await launch();
|
|
199
|
+
onRepaired?.({ engine, ms: Date.now() - t0 });
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** For /health: does each engine's browser binary exist where this daemon looks?
|
|
205
|
+
* chromium is reported for both the full build and the headless shell, because
|
|
206
|
+
* the warm headless launch needs the shell (the 25 Oct gap boot never checked). */
|
|
207
|
+
export function browsersPresent(): Record<string, boolean> {
|
|
208
|
+
const has = (f: () => string) => {
|
|
209
|
+
try {
|
|
210
|
+
return existsSync(f());
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const full = (() => {
|
|
216
|
+
try {
|
|
217
|
+
return chromium.executablePath();
|
|
218
|
+
} catch {
|
|
219
|
+
return '';
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
const m = /^(.*[\\/])chromium-(\d+)[\\/]/.exec(full);
|
|
223
|
+
const shellDir = m ? join(m[1]!, `chromium_headless_shell-${m[2]}`) : '';
|
|
224
|
+
return {
|
|
225
|
+
chromium: has(() => full),
|
|
226
|
+
'chromium-headless-shell': shellDir ? existsSync(shellDir) : false,
|
|
227
|
+
webkit: has(() => webkit.executablePath()),
|
|
228
|
+
firefox: has(() => firefox.executablePath()),
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/flow.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type AssertOutcome,
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
|
-
import { withPage, type LensEngine, type RunBrowser } from './browser.ts';
|
|
26
|
+
import { takeBrowserRepairs, withPage, type LensEngine, type RunBrowser } from './browser.ts';
|
|
27
27
|
import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
|
|
28
28
|
import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
29
29
|
import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
@@ -424,6 +424,8 @@ export interface FlowResult {
|
|
|
424
424
|
/** F009.131 — set when auth.persist was asked for: whether the renewed login was
|
|
425
425
|
* written back to statePath, and why not when it was not. Never a value. */
|
|
426
426
|
auth_persisted?: { ok: boolean; reason?: string };
|
|
427
|
+
/** F010.1 — browsers the daemon reinstalled because their binary was missing. */
|
|
428
|
+
browser_repaired?: Array<{ engine: string; ms: number }>;
|
|
427
429
|
/** F009.137 — mails this run consumed for a code, and how many were removed
|
|
428
430
|
* after. Present only when a step read mail. Never a code or a body. */
|
|
429
431
|
mail?: { consumed: number; cleaned: number; reason?: string };
|
|
@@ -3508,6 +3510,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3508
3510
|
reduced_motion: opts.reducedMotion ?? 'reduce',
|
|
3509
3511
|
...(authPersisted ? { auth_persisted: authPersisted } : {}),
|
|
3510
3512
|
...(mailReport ? { mail: mailReport } : {}),
|
|
3513
|
+
// F010.1 — a missing browser this run had to reinstall, said out loud.
|
|
3514
|
+
...((() => { const r = takeBrowserRepairs(); return r.length ? { browser_repaired: r } : {}; })()),
|
|
3511
3515
|
...(extensionInfo ? { extension: extensionInfo } : {}),
|
|
3512
3516
|
...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
|
|
3513
3517
|
downloads,
|
package/src/http.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// all hit these. Artifact bytes are served here too, so an <img> in the
|
|
6
6
|
// dashboard points straight at the daemon.
|
|
7
7
|
|
|
8
|
+
import { browsersPresent } from './ensure-chromium.ts';
|
|
8
9
|
import { Hono } from 'hono';
|
|
9
10
|
import { setSandboxResolver } from './sandbox.ts';
|
|
10
11
|
import { registerDaemonRoutes } from './daemon-routes.ts';
|
|
@@ -79,6 +80,8 @@ export function createLensRouter(deps: LensDeps): Hono {
|
|
|
79
80
|
|
|
80
81
|
// F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
|
|
81
82
|
r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
|
|
83
|
+
// F010.1 — does each engine's browser binary exist where this daemon looks?
|
|
84
|
+
r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent() }));
|
|
82
85
|
r.post('/uploads/drain', handle(async (body) => {
|
|
83
86
|
const b = (body ?? {}) as { batch?: number };
|
|
84
87
|
return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { durabilityOf, type EvidenceDurability } from './durability.ts';
|
|
|
16
16
|
export { setSandboxResolver, type SandboxResolver } from './sandbox.ts';
|
|
17
17
|
// Lifecycle the daemon owns: provision Chromium at boot, drain the evidence
|
|
18
18
|
// upload queue on its timer, close the warm browser on shutdown.
|
|
19
|
-
export { ensureChromium } from './ensure-chromium.ts';
|
|
19
|
+
export { ensureChromium, browsersPresent } from './ensure-chromium.ts';
|
|
20
20
|
export { drainUploads } from './artifact-uploads.ts';
|
|
21
21
|
export { closeBrowser } from './browser.ts';
|
|
22
22
|
|