@yibie/pi-jev-browser 0.1.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.
package/src/runtime.ts ADDED
@@ -0,0 +1,588 @@
1
+ import { observe, waitForDocument } from "./jev-browser.ts";
2
+ import { randomUUID } from "node:crypto";
3
+ import { appendFile, mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { type Browser, chromium, type Page } from "playwright";
6
+ import { executeActions } from "./actions.ts";
7
+ import { ensureChromium } from "./browser-setup.ts";
8
+ import { isUrlAllowed, readConfig } from "./config.ts";
9
+ import { type RunInput, type RunMemory, type RunStep, runJev } from "./jev-run.ts";
10
+ import type { JevPolicy } from "./jev-model.ts";
11
+ import { installRecordingOverlay } from "./recording-overlay.ts";
12
+ import { startStream } from "./stream.ts";
13
+ import type {
14
+ ActiveBrowserSession,
15
+ BrowserAction,
16
+ BrowserLogEntry,
17
+ BrowserState,
18
+ JevBrowserConfig,
19
+ } from "./types.ts";
20
+
21
+ export interface LaunchInput {
22
+ url?: string;
23
+ headless?: boolean;
24
+ recordVideo?: boolean;
25
+ showCursor?: boolean;
26
+ showClickIndicators?: boolean;
27
+ }
28
+
29
+ export interface Screenshot {
30
+ state: BrowserState;
31
+ artifactPath: string;
32
+ png: Buffer;
33
+ }
34
+
35
+ export interface RunResult {
36
+ failure?: { stage: string; category: string; detail?: string };
37
+ status: string;
38
+ message: string;
39
+ steps: RunStep[];
40
+ elapsedMs: number;
41
+ tracePath: string;
42
+ initialScreenshot: { artifactPath: string; state: BrowserState };
43
+ finalScreenshot: { artifactPath: string; state: BrowserState } | null;
44
+ finalPng?: Buffer;
45
+ screenshotWarning?: string;
46
+ /**
47
+ * Text evidence of the page the run stopped on. Verification must not depend
48
+ * on vision: a model that cannot receive images still needs something it can
49
+ * check a done_unverified claim against.
50
+ */
51
+ finalPage?: {
52
+ url: string;
53
+ title: string;
54
+ targets: number;
55
+ offscreen: number;
56
+ scrolled: boolean;
57
+ text: string;
58
+ };
59
+ }
60
+
61
+ export class JevBrowserManager {
62
+ private session?: ActiveBrowserSession;
63
+ private memory?: RunMemory;
64
+ private running?: AbortController;
65
+
66
+ private async start(input: LaunchInput, signal?: AbortSignal) {
67
+ await this.closeSession().catch(() => undefined);
68
+ const config = readConfig();
69
+ const url = input.url?.trim() || "about:blank";
70
+ assertUrlAllowed(url, config);
71
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`;
72
+ const outputDir = join(config.outputDir, id);
73
+ await mkdir(join(outputDir, "screenshots"), { recursive: true });
74
+ if (input.recordVideo ?? config.recordVideo)
75
+ await mkdir(join(outputDir, "videos"), { recursive: true });
76
+
77
+ signal?.throwIfAborted();
78
+ await ensureChromium();
79
+
80
+ const requestedHeadless = input.headless ?? config.headless;
81
+ const launchOptions = {
82
+ // On macOS, forcing Chromium's Linux sandbox can deadlock or crash when
83
+ // the host runs from an app-bundle subprocess. The full Chromium
84
+ // channel keeps its native platform sandbox and supports modern headless.
85
+ ...(process.platform === "darwin"
86
+ ? { channel: "chromium" as const }
87
+ : { chromiumSandbox: true }),
88
+ timeout: 20_000,
89
+ env: {},
90
+ args: [
91
+ "--disable-extensions",
92
+ "--disable-file-system",
93
+ `--window-size=${config.viewport.width},${config.viewport.height}`,
94
+ ],
95
+ };
96
+ let actualHeadless = requestedHeadless;
97
+ let launchWarning: string | undefined;
98
+ let browser: Browser;
99
+ try {
100
+ browser = await chromium.launch({
101
+ ...launchOptions,
102
+ headless: requestedHeadless,
103
+ });
104
+ } catch (error) {
105
+ if (process.platform !== "darwin" || !requestedHeadless) throw error;
106
+ // Headless Chromium may be rejected by the macOS app sandbox even though
107
+ // a normal browser window is allowed. Fall back instead of consuming the
108
+ // entire tool timeout.
109
+ browser = await chromium.launch({ ...launchOptions, headless: false });
110
+ actualHeadless = false;
111
+ launchWarning =
112
+ "Headless Chromium was unavailable in this process, so Jev Browser started a visible browser window.";
113
+ }
114
+ const browserContext = await browser.newContext({
115
+ viewport: config.viewport,
116
+ acceptDownloads: false,
117
+ serviceWorkers: "block",
118
+ ...((input.recordVideo ?? config.recordVideo)
119
+ ? {
120
+ recordVideo: {
121
+ dir: join(outputDir, "videos"),
122
+ size: config.viewport,
123
+ },
124
+ }
125
+ : {}),
126
+ });
127
+ await installRecordingOverlay(browserContext, {
128
+ showCursor: input.showCursor ?? config.showCursor,
129
+ showClickIndicators:
130
+ input.showClickIndicators ?? config.showClickIndicators,
131
+ });
132
+ const page = await browserContext.newPage();
133
+ const session: ActiveBrowserSession = {
134
+ browser,
135
+ context: browserContext,
136
+ page,
137
+ video: page.video() ?? undefined,
138
+ id,
139
+ outputDir,
140
+ startedAt: new Date().toISOString(),
141
+ logs: [],
142
+ nextLogId: 1,
143
+ };
144
+ this.session = session;
145
+ this.memory = undefined;
146
+ this.attachObservability(session, config);
147
+
148
+ try {
149
+ if (url !== "about:blank")
150
+ await page.goto(url, {
151
+ waitUntil: "domcontentloaded",
152
+ timeout: 20_000,
153
+ });
154
+ if (config.stream.enabled)
155
+ await this.startStreamForSession(session, config.stream.intervalMs);
156
+ } catch (error) {
157
+ await this.stop().catch(() => undefined);
158
+ throw error;
159
+ }
160
+
161
+ const state = await this.stateForSession(session, config);
162
+ return {
163
+ ...state,
164
+ outputDir,
165
+ streamUrl: session.stream?.url,
166
+ actualHeadless,
167
+ launchWarning,
168
+ message: "Browser started.",
169
+ };
170
+ }
171
+
172
+ async screenshot(
173
+ input: { label?: string },
174
+ signal?: AbortSignal,
175
+ ): Promise<Screenshot> {
176
+ const session = this.requireSession();
177
+ const config = readConfig();
178
+ const label = sanitizeLabel(input.label ?? "screenshot");
179
+ const path = join(
180
+ session.outputDir,
181
+ "screenshots",
182
+ `${Date.now()}-${label}.png`,
183
+ );
184
+ signal?.throwIfAborted();
185
+ await waitForDocument(session.page);
186
+ const png = await session.page.screenshot({
187
+ path,
188
+ type: "png",
189
+ timeout: 5000,
190
+ });
191
+ const state = await this.stateForSession(session, config);
192
+ return { state, artifactPath: path, png };
193
+ }
194
+
195
+ async actions(
196
+ input: { actions: BrowserAction[]; includeScreenshot?: boolean },
197
+ options: { signal?: AbortSignal } = {},
198
+ ) {
199
+ const session = this.requireSession();
200
+ const config = readConfig();
201
+ const end = this.beginWork(options.signal);
202
+ try {
203
+ await executeActions(session.page, input.actions, {
204
+ assertUrlAllowed: (url) => assertUrlAllowed(url, config),
205
+ signal: AbortSignal.any([
206
+ this.running!.signal,
207
+ AbortSignal.timeout(115_000),
208
+ ]),
209
+ });
210
+ const state = await this.stateForSession(session, config);
211
+ if (input.includeScreenshot === false)
212
+ return {
213
+ state,
214
+ executed: input.actions.map((action) => action.type),
215
+ screenshot: undefined as Screenshot | undefined,
216
+ };
217
+ return {
218
+ state,
219
+ executed: input.actions.map((action) => action.type),
220
+ screenshot: await this.screenshot({ label: "after-actions" }, options.signal),
221
+ };
222
+ } finally {
223
+ end();
224
+ }
225
+ }
226
+
227
+ async run(
228
+ input: RunInput & LaunchInput,
229
+ options: {
230
+ signal?: AbortSignal;
231
+ onStep?: (step: RunStep) => Promise<void>;
232
+ policy: JevPolicy;
233
+ } ,
234
+ ): Promise<RunResult> {
235
+ const end = this.beginWork(options.signal);
236
+ try {
237
+ if (!this.session) await this.start(input, options.signal);
238
+ else if (input.url) {
239
+ assertUrlAllowed(input.url, readConfig());
240
+ await this.requireSession().page.goto(input.url, {
241
+ waitUntil: "domcontentloaded",
242
+ timeout: 20_000,
243
+ });
244
+ }
245
+ this.running!.signal.throwIfAborted();
246
+ const session = this.requireSession();
247
+ const tracePath = join(session.outputDir, `jev-${randomUUID()}.jsonl`);
248
+ const initial = await this.screenshot(
249
+ { label: "jev-initial" },
250
+ options.signal,
251
+ );
252
+ const memory = this.memory ?? { goal: input.goal, actions: [] };
253
+ this.memory = memory;
254
+ const result = await runJev(input, {
255
+ memory,
256
+ page: () => session.page,
257
+ signal: this.running!.signal,
258
+ policy: options.policy,
259
+ onStep: async (step) => {
260
+ await appendFile(tracePath, `${JSON.stringify(step)}\n`);
261
+ await options.onStep?.(step);
262
+ },
263
+ });
264
+ await appendFile(
265
+ tracePath,
266
+ `${JSON.stringify({ type: "result", ...result })}\n`,
267
+ );
268
+ let final: Screenshot | undefined;
269
+ try {
270
+ final = await this.screenshot({ label: "jev-final" }, options.signal);
271
+ } catch {
272
+ /* Browser may have been stopped during cancellation. */
273
+ }
274
+ return {
275
+ ...result,
276
+ tracePath,
277
+ initialScreenshot: {
278
+ artifactPath: initial.artifactPath,
279
+ state: initial.state,
280
+ },
281
+ finalScreenshot: final
282
+ ? { artifactPath: final.artifactPath, state: final.state }
283
+ : null,
284
+ finalPng: final?.png,
285
+ finalPage: await this.finalPage(options.signal),
286
+ screenshotWarning: final
287
+ ? undefined
288
+ : "Final screenshot unavailable; the browser may have closed. Outcome is unverified.",
289
+ };
290
+ } finally {
291
+ end();
292
+ }
293
+ }
294
+
295
+ /** Read-only text evidence of the page the run stopped on. Never fatal. */
296
+ private async finalPage(signal?: AbortSignal) {
297
+ const session = this.session;
298
+ if (!session || session.page.isClosed() || signal?.aborted) return undefined;
299
+ try {
300
+ const snapshot = await observe(session.page, signal);
301
+ try {
302
+ return {
303
+ url: snapshot.data.url,
304
+ title: snapshot.data.title,
305
+ targets: snapshot.data.targets.length,
306
+ offscreen:
307
+ (snapshot.data.offscreenControls?.above.length ?? 0) +
308
+ (snapshot.data.offscreenControls?.below.length ?? 0),
309
+ scrolled: snapshot.data.scrollUp,
310
+ // The observation layer already bounds this at 6,000 chars, which is
311
+ // what the model sees on every step. A second, tighter cap here
312
+ // silently drops the very content a goal gets verified against.
313
+ text: snapshot.data.text,
314
+ };
315
+ } finally {
316
+ await snapshot.dispose().catch(() => undefined);
317
+ }
318
+ } catch {
319
+ return undefined;
320
+ }
321
+ }
322
+
323
+ async state(): Promise<BrowserState> {
324
+ return this.session
325
+ ? this.stateForSession(this.session, readConfig())
326
+ : ({
327
+ active: false,
328
+ pages: [],
329
+ viewport: readConfig().viewport,
330
+ } satisfies BrowserState);
331
+ }
332
+
333
+ logs(input: { afterId?: number; limit?: number }) {
334
+ const session = this.requireSession();
335
+ const afterId = Number.isFinite(input.afterId) ? Number(input.afterId) : 0;
336
+ const limit = Math.min(1000, Math.max(1, Number(input.limit) || 200));
337
+ const logs = session.logs
338
+ .filter((entry) => entry.id > afterId)
339
+ .slice(-limit);
340
+ return {
341
+ logs,
342
+ lastId: logs.at(-1)?.id ?? afterId,
343
+ total: session.logs.length,
344
+ };
345
+ }
346
+
347
+ async stream(input: { action: "start" | "status" | "stop"; intervalMs?: number }) {
348
+ const session = this.requireSession();
349
+ if (input.action === "stop") {
350
+ await session.stream?.stop();
351
+ session.stream = undefined;
352
+ return { active: false };
353
+ }
354
+ if (input.action === "start" && !session.stream) {
355
+ await this.startStreamForSession(
356
+ session,
357
+ Math.min(
358
+ 10_000,
359
+ Math.max(250, input.intervalMs ?? readConfig().stream.intervalMs),
360
+ ),
361
+ );
362
+ }
363
+ return { active: Boolean(session.stream), url: session.stream?.url };
364
+ }
365
+
366
+ /** Cancels any in-flight run, then closes the browser and finalizes video. */
367
+ async stop() {
368
+ this.running?.abort();
369
+ return this.closeSession();
370
+ }
371
+
372
+ /**
373
+ * Closes the browser without touching the caller's in-flight controller:
374
+ * start() runs inside that controller and must not abort itself.
375
+ */
376
+ private async closeSession() {
377
+ const session = this.session;
378
+ if (!session)
379
+ return {
380
+ active: false as const,
381
+ message: "No browser is active.",
382
+ };
383
+ this.session = undefined;
384
+ this.memory = undefined;
385
+ await settleWithin(session.stream?.stop(), 3_000);
386
+ await settleWithin(session.context.close(), 8_000);
387
+ let videoPath: string | undefined;
388
+ try {
389
+ videoPath = await withTimeout(
390
+ session.video?.path(),
391
+ 8_000,
392
+ "Video finalization",
393
+ );
394
+ } catch {
395
+ videoPath = undefined;
396
+ }
397
+ await settleWithin(session.browser.close(), 3_000);
398
+ return { active: false as const, outputDir: session.outputDir, videoPath };
399
+ }
400
+
401
+ private beginWork(hostSignal?: AbortSignal) {
402
+ if (this.running)
403
+ throw new Error(
404
+ "A browser operation is active. Wait for it, or cancel with jev_stop.",
405
+ );
406
+ const controller = new AbortController();
407
+ const relay = () => controller.abort();
408
+ if (hostSignal?.aborted) controller.abort();
409
+ else hostSignal?.addEventListener("abort", relay, { once: true });
410
+ this.running = controller;
411
+ return () => {
412
+ hostSignal?.removeEventListener("abort", relay);
413
+ this.running = undefined;
414
+ };
415
+ }
416
+
417
+ private requireSession() {
418
+ if (!this.session)
419
+ throw new Error(
420
+ "No browser is active. Call jev_run with a goal and initial URL first.",
421
+ );
422
+ return this.session;
423
+ }
424
+
425
+ private async stateForSession(
426
+ session: ActiveBrowserSession,
427
+ config: JevBrowserConfig,
428
+ ): Promise<BrowserState> {
429
+ const pages = await Promise.all(
430
+ session.context.pages().map(async (page, index) => ({
431
+ index,
432
+ title: await page.title().catch(() => ""),
433
+ url: page.url(),
434
+ })),
435
+ );
436
+ return {
437
+ active: true,
438
+ currentUrl: session.page.url(),
439
+ pageTitle: await session.page.title().catch(() => ""),
440
+ pages,
441
+ startedAt: session.startedAt,
442
+ viewport: config.viewport,
443
+ };
444
+ }
445
+
446
+ private attachObservability(
447
+ session: ActiveBrowserSession,
448
+ config: JevBrowserConfig,
449
+ ) {
450
+ void session.context.route("**/*", async (route) => {
451
+ const request = route.request();
452
+ if (
453
+ request.isNavigationRequest() &&
454
+ !isUrlAllowed(request.url(), config.allowedOrigins)
455
+ ) {
456
+ this.addLog(session, {
457
+ type: "security",
458
+ level: "blocked",
459
+ text: "Blocked navigation outside allowedOrigins.",
460
+ url: request.url(),
461
+ });
462
+ await route.abort("blockedbyclient");
463
+ return;
464
+ }
465
+ await route.continue();
466
+ });
467
+
468
+ const attachPage = (page: Page) => {
469
+ page.on("console", (message) =>
470
+ this.addLog(session, {
471
+ type: "console",
472
+ level: message.type(),
473
+ text: message.text(),
474
+ url: page.url(),
475
+ }),
476
+ );
477
+ page.on("pageerror", (error) =>
478
+ this.addLog(session, {
479
+ type: "pageerror",
480
+ level: "error",
481
+ text: error.message,
482
+ url: page.url(),
483
+ }),
484
+ );
485
+ page.on("requestfailed", (request) =>
486
+ this.addLog(session, {
487
+ type: "requestfailed",
488
+ level: "error",
489
+ text: request.failure()?.errorText ?? "Request failed",
490
+ url: request.url(),
491
+ }),
492
+ );
493
+ page.on("download", (download) =>
494
+ this.addLog(session, {
495
+ type: "download",
496
+ level: "blocked",
497
+ text: `Download blocked: ${download.suggestedFilename()}`,
498
+ url: page.url(),
499
+ }),
500
+ );
501
+ page.on("framenavigated", (frame) => {
502
+ if (frame === page.mainFrame())
503
+ this.addLog(session, {
504
+ type: "navigation",
505
+ level: "info",
506
+ text: frame.url(),
507
+ url: frame.url(),
508
+ });
509
+ });
510
+ };
511
+ attachPage(session.page);
512
+ session.context.on("page", (page) => {
513
+ attachPage(page);
514
+ session.page = page;
515
+ });
516
+ }
517
+
518
+ private addLog(
519
+ session: ActiveBrowserSession,
520
+ input: Omit<BrowserLogEntry, "id" | "timestamp">,
521
+ ) {
522
+ const entry: BrowserLogEntry = {
523
+ id: session.nextLogId++,
524
+ timestamp: new Date().toISOString(),
525
+ ...input,
526
+ };
527
+ session.logs.push(entry);
528
+ if (session.logs.length > 5000)
529
+ session.logs.splice(0, session.logs.length - 5000);
530
+ }
531
+
532
+ private async startStreamForSession(
533
+ session: ActiveBrowserSession,
534
+ intervalMs: number,
535
+ ) {
536
+ session.stream = await startStream(session, { intervalMs });
537
+ }
538
+ }
539
+
540
+ function assertUrlAllowed(url: string, config: JevBrowserConfig) {
541
+ if (!isUrlAllowed(url, config.allowedOrigins)) {
542
+ throw new Error(
543
+ `Navigation blocked by pi-jev-browser.config.json: ${url}`,
544
+ );
545
+ }
546
+ }
547
+
548
+ function sanitizeLabel(value: string) {
549
+ return (
550
+ value
551
+ .toLowerCase()
552
+ .replace(/[^a-z0-9]+/g, "-")
553
+ .replace(/^-|-$/g, "")
554
+ .slice(0, 64) || "screenshot"
555
+ );
556
+ }
557
+
558
+ async function settleWithin(
559
+ promise: Promise<unknown> | undefined,
560
+ timeoutMs: number,
561
+ ) {
562
+ if (!promise) return;
563
+ await withTimeout(promise, timeoutMs, "Browser cleanup").catch(
564
+ () => undefined,
565
+ );
566
+ }
567
+
568
+ async function withTimeout<T>(
569
+ promise: Promise<T> | undefined,
570
+ timeoutMs: number,
571
+ label: string,
572
+ ): Promise<T | undefined> {
573
+ if (!promise) return undefined;
574
+ let timer: ReturnType<typeof setTimeout> | undefined;
575
+ try {
576
+ return await Promise.race([
577
+ promise,
578
+ new Promise<never>((_resolve, reject) => {
579
+ timer = setTimeout(
580
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)),
581
+ timeoutMs,
582
+ );
583
+ }),
584
+ ]);
585
+ } finally {
586
+ if (timer) clearTimeout(timer);
587
+ }
588
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer, type ServerResponse } from "node:http";
3
+ import type { ActiveBrowserSession, StreamController } from "./types.ts";
4
+
5
+ export async function startStream(
6
+ session: ActiveBrowserSession,
7
+ options: { intervalMs: number },
8
+ ): Promise<StreamController> {
9
+ if (session.stream) return session.stream;
10
+
11
+ const token = randomBytes(18).toString("base64url");
12
+ const clients = new Set<ServerResponse>();
13
+ let latestImage: Buffer | null = null;
14
+ let capturing = false;
15
+ const server = createServer(async (request, response) => {
16
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
17
+ const prefix = `/${token}`;
18
+ if (!url.pathname.startsWith(prefix)) {
19
+ response.writeHead(404).end("Not found");
20
+ return;
21
+ }
22
+
23
+ if (url.pathname === `${prefix}/events`) {
24
+ response.writeHead(200, {
25
+ "cache-control": "no-cache, no-transform",
26
+ connection: "keep-alive",
27
+ "content-type": "text/event-stream",
28
+ "x-content-type-options": "nosniff",
29
+ });
30
+ clients.add(response);
31
+ request.on("close", () => clients.delete(response));
32
+ return;
33
+ }
34
+
35
+ if (url.pathname === `${prefix}/screenshot`) {
36
+ if (!latestImage) {
37
+ response.writeHead(503).end("Screenshot is not ready");
38
+ return;
39
+ }
40
+ response.writeHead(200, {
41
+ "cache-control": "no-store",
42
+ "content-type": "image/png",
43
+ "content-length": latestImage.byteLength,
44
+ "x-content-type-options": "nosniff",
45
+ });
46
+ response.end(latestImage);
47
+ return;
48
+ }
49
+
50
+ if (url.pathname === `${prefix}/logs`) {
51
+ response.writeHead(200, {
52
+ "cache-control": "no-store",
53
+ "content-type": "application/json; charset=utf-8",
54
+ "x-content-type-options": "nosniff",
55
+ });
56
+ response.end(JSON.stringify({ logs: session.logs.slice(-500) }));
57
+ return;
58
+ }
59
+
60
+ if (url.pathname === prefix || url.pathname === `${prefix}/`) {
61
+ response.writeHead(200, {
62
+ "cache-control": "no-store",
63
+ "content-security-policy":
64
+ "default-src 'none'; img-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'",
65
+ "content-type": "text/html; charset=utf-8",
66
+ "x-content-type-options": "nosniff",
67
+ });
68
+ response.end(viewerHtml(token));
69
+ return;
70
+ }
71
+
72
+ response.writeHead(404).end("Not found");
73
+ });
74
+
75
+ await new Promise<void>((resolve, reject) => {
76
+ server.once("error", reject);
77
+ server.listen(0, "127.0.0.1", () => {
78
+ server.off("error", reject);
79
+ resolve();
80
+ });
81
+ });
82
+ const address = server.address();
83
+ if (!address || typeof address === "string")
84
+ throw new Error("Unable to bind stream server.");
85
+ const url = `http://127.0.0.1:${address.port}/${token}/`;
86
+
87
+ const capture = async () => {
88
+ if (capturing || session.page.isClosed()) return;
89
+ capturing = true;
90
+ try {
91
+ latestImage = await session.page.screenshot({ type: "png" });
92
+ const payload = JSON.stringify({
93
+ timestamp: new Date().toISOString(),
94
+ url: session.page.url(),
95
+ logs: session.logs.slice(-20),
96
+ });
97
+ for (const client of clients) client.write(`data: ${payload}\n\n`);
98
+ } catch {
99
+ // A close may race the periodic capture.
100
+ } finally {
101
+ capturing = false;
102
+ }
103
+ };
104
+ await capture();
105
+ const timer = setInterval(() => void capture(), options.intervalMs);
106
+
107
+ const controller: StreamController = {
108
+ url,
109
+ async stop() {
110
+ clearInterval(timer);
111
+ for (const client of clients) client.end();
112
+ clients.clear();
113
+ await new Promise<void>((resolve) => server.close(() => resolve()));
114
+ },
115
+ };
116
+ session.stream = controller;
117
+ return controller;
118
+ }
119
+
120
+ function viewerHtml(token: string) {
121
+ return `<!doctype html>
122
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
123
+ <title>Jev Browser</title><style>
124
+ html,body{margin:0;background:#09090b;color:#e4e4e7;font:14px system-ui;height:100%}main{display:grid;grid-template-rows:auto 1fr auto;height:100%}
125
+ header{padding:10px 14px;border-bottom:1px solid #27272a;display:flex;gap:12px}#url{color:#a1a1aa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
126
+ #screen{width:100%;height:100%;object-fit:contain;min-height:0}pre{height:120px;overflow:auto;margin:0;padding:10px 14px;border-top:1px solid #27272a;color:#a1a1aa;font:12px ui-monospace}
127
+ </style></head><body><main><header><strong>Live Jev browser</strong><span id="url"></span></header>
128
+ <img id="screen" alt="Live browser screenshot"><pre id="logs"></pre></main><script>
129
+ const base='/${token}'; const image=document.querySelector('#screen'); const logs=document.querySelector('#logs'); const url=document.querySelector('#url');
130
+ new EventSource(base+'/events').onmessage=(event)=>{const data=JSON.parse(event.data);image.src=base+'/screenshot?t='+Date.now();url.textContent=data.url;logs.textContent=data.logs.map(x=>'['+x.level+'] '+x.text).join('\n');};
131
+ </script></body></html>`;
132
+ }