@omercnet/paseo-shared-browser 0.3.1-next.72.1

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.
@@ -0,0 +1,836 @@
1
+ import type { RpcInput } from "@getpaseo/plugin";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ DEVICE_PRESETS,
5
+ DEFAULT_VIEWPORT,
6
+ FRAME_MAX_BYTES,
7
+ MAX_VIEWPORT,
8
+ MIN_VIEWPORT,
9
+ mapDisplayedPoint,
10
+ applyDevicePresetRpc,
11
+ navigateBrowserRpc,
12
+ resizeBrowserRpc,
13
+ sendBrowserInputRpc,
14
+ type BrowserFrame,
15
+ type BrowserInputEvent,
16
+ type BrowserState,
17
+ type DevicePresetId,
18
+ type Viewport,
19
+ } from "../shared/browser";
20
+ import type { JsonValue } from "./runtime-protocol";
21
+ export type WorkspaceValidator = (workspaceId: string) => Promise<void | boolean>;
22
+ type NavigateInput = RpcInput<typeof navigateBrowserRpc>;
23
+ type ResizeInput = RpcInput<typeof resizeBrowserRpc>;
24
+ type ApplyDevicePresetInput = RpcInput<typeof applyDevicePresetRpc>;
25
+ type SendInput = RpcInput<typeof sendBrowserInputRpc>;
26
+ type CaptureQuality = "low" | "medium" | "high";
27
+ type InputTarget = SendInput["target"];
28
+
29
+ const VIEWER_TTL_MS = 45_000;
30
+ const CONTROL_LEASE_MS = 30_000;
31
+ const FRAME_CACHE_MS = 100;
32
+ const FRAME_TOKEN_TTL_MS = 5_000;
33
+ const MAX_RECENT_FRAMES = 32;
34
+ const MAX_VIEWERS_PER_SESSION = 16;
35
+ const MAX_SESSIONS = 8;
36
+ const SCREENCAST_QUALITY = 65;
37
+ const RUNTIME_FRAME_MAX_BYTES = 750_000;
38
+ const SCREENCAST_WAIT_MS = 500;
39
+ const JPEG_QUALITY = { low: 40, medium: 65, high: 85 } as const;
40
+
41
+ export interface BrowserRuntimeClient {
42
+ connect(): Promise<{ epoch: number }>;
43
+ ensureWorkspace(workspaceId: string): Promise<{
44
+ workspaceId: string;
45
+ runtimeId: string;
46
+ createdAt: number;
47
+ }>;
48
+ requestWorkspace(workspaceId: string, operation: string, input: JsonValue): Promise<JsonValue>;
49
+ archiveWorkspace(workspaceId: string): Promise<void>;
50
+ disconnect(): void;
51
+ }
52
+
53
+ export interface SessionManagerOptions {
54
+ validateWorkspace: WorkspaceValidator;
55
+ client: BrowserRuntimeClient;
56
+ now?: () => number;
57
+ issueToken?: () => string;
58
+ viewerTtlMs?: number;
59
+ controlLeaseMs?: number;
60
+ frameCacheMs?: number;
61
+ maxSessions?: number;
62
+ }
63
+
64
+ interface Viewer {
65
+ label: string;
66
+ expiresAt: number;
67
+ }
68
+ interface Controller {
69
+ viewerToken: string;
70
+ controlToken: string;
71
+ expiresAt: number;
72
+ }
73
+ interface BrowserSession {
74
+ workspaceId: string;
75
+ sessionId: string;
76
+ runtimeId: string;
77
+ runtimeCreatedAt: number;
78
+ bridgeEpoch: number;
79
+ viewport: Viewport;
80
+ navigationGeneration: number;
81
+ viewportGeneration: number;
82
+ devicePresetId: DevicePresetId | null;
83
+ userAgent: string;
84
+ defaultUserAgent: string;
85
+ viewers: Map<string, Viewer>;
86
+ controller: Controller | null;
87
+ mutationTail: Promise<void>;
88
+ frameCache: Map<CaptureQuality, { frame: BrowserFrame; cachedAt: number }>;
89
+ recentFrames: Map<
90
+ string,
91
+ { navigationGeneration: number; viewportGeneration: number; expiresAt: number }
92
+ >;
93
+ lastUrl: string;
94
+ lastTitle: string;
95
+ canGoBack: boolean;
96
+ canGoForward: boolean;
97
+ error: string | null;
98
+ archived: boolean;
99
+ }
100
+
101
+ function defaultToken(): string {
102
+ return randomBytes(32).toString("base64url");
103
+ }
104
+
105
+ function boundedText(value: string, maxLength: number): string {
106
+ return value.length <= maxLength ? value : value.slice(0, maxLength);
107
+ }
108
+
109
+ function assertViewport(viewport: Viewport): void {
110
+ if (
111
+ !Number.isInteger(viewport.width) ||
112
+ !Number.isInteger(viewport.height) ||
113
+ viewport.width < MIN_VIEWPORT.width ||
114
+ viewport.width > MAX_VIEWPORT.width ||
115
+ viewport.height < MIN_VIEWPORT.height ||
116
+ viewport.height > MAX_VIEWPORT.height
117
+ ) {
118
+ throw new Error("Viewport is outside the supported bounds");
119
+ }
120
+ }
121
+
122
+ function asRecord(value: JsonValue): Record<string, JsonValue> {
123
+ if (!value || typeof value !== "object" || Array.isArray(value))
124
+ throw new Error("Runtime returned an invalid response");
125
+ return value;
126
+ }
127
+
128
+ export function normalizeBrowserUrl(input: string): string {
129
+ const value = input.trim();
130
+ if (value === "about:blank") return value;
131
+ const hasScheme = /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
132
+ const looksLikeHostWithPort = /^[^/?#:\s]+:\d+(?:[/?#]|$)/.test(value);
133
+ const candidate = hasScheme && !looksLikeHostWithPort ? value : `https://${value}`;
134
+ let parsed: URL;
135
+ try {
136
+ parsed = new URL(candidate);
137
+ } catch {
138
+ throw new Error("Enter a valid HTTP or HTTPS URL");
139
+ }
140
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
141
+ throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported");
142
+ return parsed.href;
143
+ }
144
+
145
+ export class SessionManager {
146
+ private readonly validateWorkspace: WorkspaceValidator;
147
+ private readonly client: BrowserRuntimeClient;
148
+ private readonly now: () => number;
149
+ private readonly issueToken: () => string;
150
+ private readonly viewerTtlMs: number;
151
+ private readonly controlLeaseMs: number;
152
+ private readonly frameCacheMs: number;
153
+ private readonly maxSessions: number;
154
+ private readonly sessions = new Map<string, BrowserSession>();
155
+ private readonly viewerSessions = new Map<string, BrowserSession>();
156
+ private readonly sessionCreations = new Map<string, Promise<BrowserSession>>();
157
+ private readonly archived = new Set<string>();
158
+ private bridgeEpoch = 0;
159
+ private lifecycleGeneration = 0;
160
+ private closed = false;
161
+
162
+ constructor(options: SessionManagerOptions) {
163
+ this.validateWorkspace = options.validateWorkspace;
164
+ this.client = options.client;
165
+ this.now = options.now ?? Date.now;
166
+ this.issueToken = options.issueToken ?? defaultToken;
167
+ this.viewerTtlMs = options.viewerTtlMs ?? VIEWER_TTL_MS;
168
+ this.controlLeaseMs = options.controlLeaseMs ?? CONTROL_LEASE_MS;
169
+ this.frameCacheMs = options.frameCacheMs ?? FRAME_CACHE_MS;
170
+ this.maxSessions = options.maxSessions ?? MAX_SESSIONS;
171
+ if (!Number.isInteger(this.maxSessions) || this.maxSessions < 1)
172
+ throw new Error("maxSessions must be a positive integer");
173
+ }
174
+
175
+ async connect(): Promise<void> {
176
+ const lease = await this.client.connect();
177
+ this.bridgeEpoch = lease.epoch;
178
+ }
179
+
180
+ setBridgeEpoch(epoch: number): void {
181
+ this.bridgeEpoch = epoch;
182
+ for (const session of this.sessions.values()) session.bridgeEpoch = epoch;
183
+ }
184
+
185
+ async attach(
186
+ workspaceId: string,
187
+ viewerLabel: string,
188
+ ): Promise<{ viewerToken: string; state: BrowserState }> {
189
+ this.assertOpen();
190
+ const label = viewerLabel.trim();
191
+ if (!label || label.length > 64) throw new Error("Viewer label is invalid");
192
+ const validation = await this.validateWorkspace(workspaceId);
193
+ if (validation === false) throw new Error("Workspace not found");
194
+ this.assertOpen();
195
+ const session = await this.getOrCreateSession(workspaceId);
196
+ return this.serialize(session, async () => {
197
+ this.pruneExpired(session);
198
+ if (session.viewers.size >= MAX_VIEWERS_PER_SESSION)
199
+ throw new Error(`Shared browser viewer limit (${MAX_VIEWERS_PER_SESSION}) reached`);
200
+ const viewerToken = this.issueUniqueToken();
201
+ session.viewers.set(viewerToken, { label, expiresAt: this.now() + this.viewerTtlMs });
202
+ this.viewerSessions.set(viewerToken, session);
203
+ await this.request(session, "screencast.start", { quality: SCREENCAST_QUALITY }).catch(
204
+ () => undefined,
205
+ );
206
+ try {
207
+ return { viewerToken, state: await this.snapshotState(session, viewerToken) };
208
+ } catch (error) {
209
+ session.viewers.delete(viewerToken);
210
+ this.viewerSessions.delete(viewerToken);
211
+ throw error;
212
+ }
213
+ });
214
+ }
215
+
216
+ async detach(viewerToken: string): Promise<{ detached: boolean }> {
217
+ const session = this.viewerSessions.get(viewerToken);
218
+ if (!session) return { detached: false };
219
+ return this.serialize(session, async () => {
220
+ this.pruneExpired(session);
221
+ const detached = session.viewers.delete(viewerToken);
222
+ this.viewerSessions.delete(viewerToken);
223
+ if (session.controller?.viewerToken === viewerToken) session.controller = null;
224
+ if (session.viewers.size === 0)
225
+ await this.request(session, "screencast.stop", null).catch(() => undefined);
226
+ return { detached };
227
+ });
228
+ }
229
+
230
+ async archiveWorkspace(workspaceId: string): Promise<void> {
231
+ this.assertOpen();
232
+ this.archived.add(workspaceId);
233
+ await this.client.archiveWorkspace(workspaceId);
234
+ const pending = this.sessionCreations.get(workspaceId);
235
+ if (pending) await pending.catch(() => undefined);
236
+ const session = this.sessions.get(workspaceId);
237
+ if (!session) return;
238
+ session.archived = true;
239
+ for (const token of session.viewers.keys()) this.viewerSessions.delete(token);
240
+ session.viewers.clear();
241
+ session.controller = null;
242
+ this.sessions.delete(workspaceId);
243
+ }
244
+
245
+ async listOpenWorkspaceIds(): Promise<string[]> {
246
+ this.assertOpen();
247
+ const result: string[] = [];
248
+ for (const session of this.sessions.values()) {
249
+ await this.serialize(session, async () => {
250
+ this.pruneExpired(session);
251
+ if (session.viewers.size > 0) result.push(session.workspaceId);
252
+ });
253
+ }
254
+ return result;
255
+ }
256
+ async status(viewerToken: string): Promise<{ state: BrowserState }> {
257
+ const session = this.requireViewer(viewerToken);
258
+ return this.serialize(session, async () => {
259
+ this.pruneExpired(session);
260
+ this.heartbeatViewer(session, viewerToken);
261
+ return { state: await this.snapshotState(session, viewerToken) };
262
+ });
263
+ }
264
+
265
+ async capture(
266
+ viewerToken: string,
267
+ quality: CaptureQuality = "medium",
268
+ knownFrameId: string | null = null,
269
+ ): Promise<{ state: BrowserState; frame: BrowserFrame | null }> {
270
+ const session = this.requireViewer(viewerToken);
271
+ return this.serialize(session, async () => {
272
+ this.pruneExpired(session);
273
+ this.heartbeatViewer(session, viewerToken);
274
+ const frame = await this.frameForQuality(session, quality);
275
+ this.rememberFrame(session, frame);
276
+ return {
277
+ state: await this.snapshotState(session, viewerToken),
278
+ frame: knownFrameId === frame.frameId ? null : frame,
279
+ };
280
+ });
281
+ }
282
+
283
+ async acquireControl(
284
+ viewerToken: string,
285
+ takeover = false,
286
+ ): Promise<{ controlToken: string; state: BrowserState }> {
287
+ const session = this.requireViewer(viewerToken);
288
+ return this.serialize(session, async () => {
289
+ this.heartbeat(session, viewerToken);
290
+ const current = session.controller;
291
+ if (current && current.viewerToken !== viewerToken && !takeover)
292
+ throw new Error("Browser control is held by another viewer");
293
+ const controller =
294
+ current?.viewerToken === viewerToken
295
+ ? current
296
+ : { viewerToken, controlToken: this.issueUniqueToken(), expiresAt: 0 };
297
+ controller.expiresAt = this.now() + this.controlLeaseMs;
298
+ session.controller = controller;
299
+ return {
300
+ controlToken: controller.controlToken,
301
+ state: await this.snapshotState(session, viewerToken),
302
+ };
303
+ });
304
+ }
305
+
306
+ async releaseControl(
307
+ viewerToken: string,
308
+ controlToken: string,
309
+ ): Promise<{ state: BrowserState }> {
310
+ const session = this.requireViewer(viewerToken);
311
+ return this.serialize(session, async () => {
312
+ this.requireController(session, viewerToken, controlToken);
313
+ session.controller = null;
314
+ this.heartbeatViewer(session, viewerToken);
315
+ return { state: await this.snapshotState(session, viewerToken) };
316
+ });
317
+ }
318
+
319
+ async navigate(input: NavigateInput): Promise<{ state: BrowserState }> {
320
+ const session = this.requireViewer(input.viewerToken);
321
+ return this.serialize(session, async () => {
322
+ this.requireMutationAccess(session, input);
323
+ const navigationGeneration = session.navigationGeneration;
324
+ if (input.action.kind === "goto")
325
+ await this.request(session, "navigate", { url: normalizeBrowserUrl(input.action.url) });
326
+ else if (input.action.kind === "back") {
327
+ await this.refreshPageMetadata(session);
328
+ if (!session.canGoBack) throw new Error("Browser cannot go back");
329
+ await this.request(session, "back", null);
330
+ } else if (input.action.kind === "forward") {
331
+ await this.refreshPageMetadata(session);
332
+ if (!session.canGoForward) throw new Error("Browser cannot go forward");
333
+ await this.request(session, "forward", null);
334
+ } else await this.request(session, "reload", null);
335
+ await this.refreshPageMetadata(session);
336
+ if (session.navigationGeneration === navigationGeneration) session.navigationGeneration += 1;
337
+ this.invalidateFrames(session);
338
+ this.renewController(session, input.viewerToken);
339
+ return { state: await this.snapshotState(session, input.viewerToken) };
340
+ });
341
+ }
342
+
343
+ async resize(input: ResizeInput): Promise<{ state: BrowserState }> {
344
+ const session = this.requireViewer(input.viewerToken);
345
+ return this.serialize(session, async () => {
346
+ this.requireMutationAccess(session, input);
347
+ assertViewport(input.viewport);
348
+ if (
349
+ session.viewport.width !== input.viewport.width ||
350
+ session.viewport.height !== input.viewport.height ||
351
+ session.devicePresetId !== null
352
+ ) {
353
+ await this.request(session, "emulate", {
354
+ ...input.viewport,
355
+ deviceScaleFactor: 1,
356
+ mobile: false,
357
+ touch: false,
358
+ userAgent: session.defaultUserAgent,
359
+ platform: "",
360
+ });
361
+ session.viewport = { ...input.viewport };
362
+ session.devicePresetId = null;
363
+ session.userAgent = session.defaultUserAgent;
364
+ session.viewportGeneration += 1;
365
+ this.invalidateFrames(session);
366
+ }
367
+ this.renewController(session, input.viewerToken);
368
+ return { state: await this.snapshotState(session, input.viewerToken) };
369
+ });
370
+ }
371
+
372
+ async applyDevicePreset(input: ApplyDevicePresetInput): Promise<{ state: BrowserState }> {
373
+ const session = this.requireViewer(input.viewerToken);
374
+ return this.serialize(session, async () => {
375
+ this.requireMutationAccess(session, input);
376
+ const preset = DEVICE_PRESETS.find(({ id }) => id === input.presetId);
377
+ if (!preset) throw new Error("Unknown device preset");
378
+ await this.request(session, "emulate", {
379
+ width: preset.viewport.width,
380
+ height: preset.viewport.height,
381
+ deviceScaleFactor: preset.deviceScaleFactor,
382
+ mobile: preset.isMobile,
383
+ touch: preset.hasTouch,
384
+ userAgent: preset.userAgent,
385
+ platform: preset.platform,
386
+ });
387
+ session.viewport = { ...preset.viewport };
388
+ session.devicePresetId = preset.id;
389
+ session.userAgent = preset.userAgent;
390
+ session.viewportGeneration += 1;
391
+ session.navigationGeneration += 1;
392
+ this.invalidateFrames(session);
393
+ await this.request(session, "reload", null);
394
+ this.renewController(session, input.viewerToken);
395
+ return { state: await this.snapshotState(session, input.viewerToken) };
396
+ });
397
+ }
398
+
399
+ async sendInput(input: SendInput): Promise<{ state: BrowserState }> {
400
+ const session = this.requireViewer(input.viewerToken);
401
+ return this.serialize(session, async () => {
402
+ this.requireMutationAccess(session, input);
403
+ this.requireRecentFrame(session, input.target);
404
+ await this.dispatchInput(session, input.event, input.target);
405
+ this.invalidateFrames(session);
406
+ this.renewController(session, input.viewerToken);
407
+ return { state: await this.snapshotState(session, input.viewerToken) };
408
+ });
409
+ }
410
+
411
+ reset(): void {
412
+ this.lifecycleGeneration += 1;
413
+ this.sessions.clear();
414
+ this.sessionCreations.clear();
415
+ this.viewerSessions.clear();
416
+ }
417
+
418
+ disconnect(): void {
419
+ if (this.closed) return;
420
+ this.closed = true;
421
+ this.client.disconnect();
422
+ this.reset();
423
+ }
424
+
425
+ private async getOrCreateSession(workspaceId: string): Promise<BrowserSession> {
426
+ if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
427
+ const existing = this.sessions.get(workspaceId);
428
+ if (existing) return existing;
429
+ const pending = this.sessionCreations.get(workspaceId);
430
+ if (pending) return pending;
431
+ if (this.sessions.size + this.sessionCreations.size >= this.maxSessions)
432
+ throw new Error(`Shared browser session limit (${this.maxSessions}) reached`);
433
+ const lifecycleGeneration = this.lifecycleGeneration;
434
+ const creation = this.createSession(workspaceId);
435
+ this.sessionCreations.set(workspaceId, creation);
436
+ try {
437
+ const session = await creation;
438
+ if (
439
+ this.closed ||
440
+ this.archived.has(workspaceId) ||
441
+ lifecycleGeneration !== this.lifecycleGeneration
442
+ )
443
+ throw new Error(
444
+ this.closed
445
+ ? "Shared browser manager is closed"
446
+ : this.archived.has(workspaceId)
447
+ ? "Workspace was archived"
448
+ : "Shared browser manager was reset",
449
+ );
450
+ this.sessions.set(workspaceId, session);
451
+ return session;
452
+ } catch (error) {
453
+ if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
454
+ throw error;
455
+ } finally {
456
+ if (this.sessionCreations.get(workspaceId) === creation)
457
+ this.sessionCreations.delete(workspaceId);
458
+ }
459
+ }
460
+
461
+ private async createSession(workspaceId: string): Promise<BrowserSession> {
462
+ let ensured = false;
463
+ try {
464
+ const descriptor = await this.client.ensureWorkspace(workspaceId);
465
+ ensured = true;
466
+ const identity = asRecord(await this.client.requestWorkspace(workspaceId, "identity", null));
467
+ const userAgent = boundedText(String(identity.userAgent ?? "Chromium"), 512);
468
+ await this.client.requestWorkspace(workspaceId, "emulate", {
469
+ ...DEFAULT_VIEWPORT,
470
+ deviceScaleFactor: 1,
471
+ mobile: false,
472
+ touch: false,
473
+ userAgent,
474
+ platform: "",
475
+ });
476
+ const state = asRecord(await this.client.requestWorkspace(workspaceId, "state", null));
477
+ return {
478
+ workspaceId,
479
+ sessionId: this.issueUniqueToken(),
480
+ runtimeId: descriptor.runtimeId,
481
+ runtimeCreatedAt: descriptor.createdAt,
482
+ bridgeEpoch: this.bridgeEpoch,
483
+ viewport: { ...DEFAULT_VIEWPORT },
484
+ navigationGeneration: 0,
485
+ viewportGeneration: 0,
486
+ devicePresetId: null,
487
+ userAgent,
488
+ defaultUserAgent: userAgent,
489
+ viewers: new Map(),
490
+ controller: null,
491
+ mutationTail: Promise.resolve(),
492
+ frameCache: new Map(),
493
+ recentFrames: new Map(),
494
+ lastUrl: boundedText(String(state.url ?? ""), 8192),
495
+ lastTitle: boundedText(String(state.title ?? ""), 1024),
496
+ canGoBack: Boolean(state.canGoBack),
497
+ canGoForward: Boolean(state.canGoForward),
498
+ error: null,
499
+ archived: false,
500
+ };
501
+ } catch (error) {
502
+ if (ensured) await this.client.archiveWorkspace(workspaceId).catch(() => undefined);
503
+ throw error;
504
+ }
505
+ }
506
+
507
+ private request(
508
+ session: BrowserSession,
509
+ operation: string,
510
+ input: JsonValue,
511
+ ): Promise<JsonValue> {
512
+ if (session.archived) throw new Error("Workspace was archived");
513
+ return this.client.requestWorkspace(session.workspaceId, operation, input);
514
+ }
515
+
516
+ private requireViewer(token: string): BrowserSession {
517
+ const session = this.viewerSessions.get(token);
518
+ if (!session || !session.viewers.has(token))
519
+ throw new Error("Viewer token is invalid or expired");
520
+ return session;
521
+ }
522
+ private heartbeat(session: BrowserSession, token: string): void {
523
+ this.pruneExpired(session);
524
+ this.heartbeatViewer(session, token);
525
+ if (session.controller?.viewerToken === token)
526
+ session.controller.expiresAt = this.now() + this.controlLeaseMs;
527
+ }
528
+ private heartbeatViewer(session: BrowserSession, token: string): void {
529
+ const viewer = session.viewers.get(token);
530
+ if (!viewer) throw new Error("Viewer token is invalid or expired");
531
+ viewer.expiresAt = this.now() + this.viewerTtlMs;
532
+ }
533
+ private pruneExpired(session: BrowserSession): void {
534
+ const now = this.now();
535
+ for (const [token, viewer] of session.viewers)
536
+ if (viewer.expiresAt <= now) {
537
+ session.viewers.delete(token);
538
+ this.viewerSessions.delete(token);
539
+ }
540
+ if (
541
+ session.controller &&
542
+ (session.controller.expiresAt <= now || !session.viewers.has(session.controller.viewerToken))
543
+ )
544
+ session.controller = null;
545
+ }
546
+ private requireController(
547
+ session: BrowserSession,
548
+ viewerToken: string,
549
+ controlToken: string,
550
+ ): Controller {
551
+ this.pruneExpired(session);
552
+ const value = session.controller;
553
+ if (!value || value.viewerToken !== viewerToken || value.controlToken !== controlToken)
554
+ throw new Error("Browser control lease is invalid or expired");
555
+ return value;
556
+ }
557
+ private requireMutationAccess(
558
+ session: BrowserSession,
559
+ input: {
560
+ viewerToken: string;
561
+ controlToken: string;
562
+ expected: {
563
+ sessionId: string;
564
+ navigationGeneration: number;
565
+ viewportGeneration: number;
566
+ runtimeId?: string | undefined;
567
+ bridgeEpoch?: number | undefined;
568
+ };
569
+ },
570
+ ): void {
571
+ this.requireController(session, input.viewerToken, input.controlToken);
572
+ if (input.expected.sessionId !== session.sessionId) throw new Error("Browser session is stale");
573
+ if (input.expected.navigationGeneration !== session.navigationGeneration)
574
+ throw new Error("Browser navigation state is stale");
575
+ if (input.expected.viewportGeneration !== session.viewportGeneration)
576
+ throw new Error("Browser viewport state is stale");
577
+ if (input.expected.runtimeId && input.expected.runtimeId !== session.runtimeId)
578
+ throw new Error("Browser runtime is stale");
579
+ if (
580
+ input.expected.bridgeEpoch !== undefined &&
581
+ input.expected.bridgeEpoch !== session.bridgeEpoch
582
+ )
583
+ throw new Error("Browser bridge state is stale");
584
+ }
585
+ private requireRecentFrame(
586
+ session: BrowserSession,
587
+ target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
588
+ ): void {
589
+ this.pruneRecentFrames(session);
590
+ const frame = session.recentFrames.get(target.frameId);
591
+ if (
592
+ !frame ||
593
+ target.navigationGeneration !== session.navigationGeneration ||
594
+ target.viewportGeneration !== session.viewportGeneration ||
595
+ frame.navigationGeneration !== session.navigationGeneration ||
596
+ frame.viewportGeneration !== session.viewportGeneration
597
+ )
598
+ throw new Error("Browser frame is stale");
599
+ }
600
+ private renewController(session: BrowserSession, token: string): void {
601
+ this.heartbeatViewer(session, token);
602
+ const controller = session.controller;
603
+ if (!controller || controller.viewerToken !== token)
604
+ throw new Error("Browser control lease is invalid or expired");
605
+ controller.expiresAt = this.now() + this.controlLeaseMs;
606
+ }
607
+ private assertOpen(): void {
608
+ if (this.closed) throw new Error("Shared browser manager is closed");
609
+ }
610
+ private issueUniqueToken(): string {
611
+ for (let attempt = 0; attempt < 8; attempt += 1) {
612
+ const token = this.issueToken();
613
+ if (token.length < 32 || token.length > 128 || !/^[A-Za-z0-9_-]+$/.test(token))
614
+ throw new Error("Token issuer returned an invalid opaque token");
615
+ if (
616
+ !this.viewerSessions.has(token) &&
617
+ ![...this.sessions.values()].some(
618
+ (session) =>
619
+ session.sessionId === token ||
620
+ session.controller?.controlToken === token ||
621
+ session.recentFrames.has(token),
622
+ )
623
+ )
624
+ return token;
625
+ }
626
+ throw new Error("Could not issue a unique opaque token");
627
+ }
628
+ private pruneRecentFrames(session: BrowserSession): void {
629
+ const now = this.now();
630
+ for (const [id, frame] of session.recentFrames)
631
+ if (frame.expiresAt <= now) session.recentFrames.delete(id);
632
+ }
633
+ private serialize<T>(session: BrowserSession, operation: () => Promise<T>): Promise<T> {
634
+ const result = session.mutationTail.then(operation, operation);
635
+ session.mutationTail = result.then(
636
+ () => undefined,
637
+ () => undefined,
638
+ );
639
+ return result;
640
+ }
641
+ private invalidateFrames(session: BrowserSession): void {
642
+ session.frameCache.clear();
643
+ session.recentFrames.clear();
644
+ }
645
+
646
+ private async frameForQuality(
647
+ session: BrowserSession,
648
+ quality: CaptureQuality,
649
+ ): Promise<BrowserFrame> {
650
+ const cached = session.frameCache.get(quality);
651
+ if (
652
+ cached &&
653
+ cached.frame.navigationGeneration === session.navigationGeneration &&
654
+ cached.frame.viewportGeneration === session.viewportGeneration &&
655
+ this.now() - cached.cachedAt <= this.frameCacheMs
656
+ )
657
+ return cached.frame;
658
+ const generation = {
659
+ navigationGeneration: session.navigationGeneration,
660
+ viewportGeneration: session.viewportGeneration,
661
+ };
662
+ const raw = asRecord(
663
+ await this.request(session, "frame", {
664
+ maxBytes: RUNTIME_FRAME_MAX_BYTES,
665
+ quality: JPEG_QUALITY[quality],
666
+ waitMs: SCREENCAST_WAIT_MS,
667
+ }),
668
+ );
669
+ if (
670
+ generation.navigationGeneration !== session.navigationGeneration ||
671
+ generation.viewportGeneration !== session.viewportGeneration
672
+ )
673
+ throw new Error("Browser changed during capture; request another frame");
674
+ const byteLength = Number(raw.byteLength);
675
+ const dataBase64 = String(raw.dataBase64 ?? "");
676
+ if (
677
+ !Number.isInteger(byteLength) ||
678
+ byteLength < 1 ||
679
+ byteLength > FRAME_MAX_BYTES ||
680
+ Buffer.byteLength(dataBase64, "base64") !== byteLength
681
+ )
682
+ throw new Error("Browser returned an invalid frame");
683
+ const frame: BrowserFrame = {
684
+ sessionId: session.sessionId,
685
+ frameId: this.issueUniqueToken(),
686
+ mimeType: "image/jpeg",
687
+ transport: raw.transport === "cdp-screencast" ? "cdp-screencast" : "screenshot",
688
+ dataBase64,
689
+ byteLength,
690
+ width: Number(raw.width),
691
+ height: Number(raw.height),
692
+ ...generation,
693
+ runtimeId: session.runtimeId,
694
+ captureEpoch: session.bridgeEpoch,
695
+ capturedAt: String(raw.capturedAt),
696
+ };
697
+ if (frame.width !== session.viewport.width || frame.height !== session.viewport.height)
698
+ throw new Error("Browser returned a frame for a stale viewport");
699
+ session.frameCache.set(quality, { frame, cachedAt: this.now() });
700
+ return frame;
701
+ }
702
+
703
+ private rememberFrame(session: BrowserSession, frame: BrowserFrame): void {
704
+ this.pruneRecentFrames(session);
705
+ session.recentFrames.set(frame.frameId, {
706
+ navigationGeneration: frame.navigationGeneration,
707
+ viewportGeneration: frame.viewportGeneration,
708
+ expiresAt: this.now() + FRAME_TOKEN_TTL_MS,
709
+ });
710
+ while (session.recentFrames.size > MAX_RECENT_FRAMES) {
711
+ const oldest = session.recentFrames.keys().next().value;
712
+ if (!oldest) break;
713
+ session.recentFrames.delete(oldest);
714
+ }
715
+ }
716
+
717
+ private async dispatchInput(
718
+ session: BrowserSession,
719
+ event: BrowserInputEvent,
720
+ target: InputTarget,
721
+ ): Promise<void> {
722
+ const assertTargetCurrent = async () => {
723
+ await this.refreshPageMetadata(session);
724
+ this.requireRecentFrame(session, target);
725
+ };
726
+ if (event.kind === "type") {
727
+ await this.request(session, "text.insert", { text: event.text });
728
+ return;
729
+ }
730
+ if (event.kind === "key") {
731
+ await this.request(session, "key.down", { key: event.key });
732
+ try {
733
+ await assertTargetCurrent();
734
+ } finally {
735
+ await this.request(session, "key.up", { key: event.key });
736
+ }
737
+ return;
738
+ }
739
+ if (event.kind === "move") {
740
+ const point = mapDisplayedPoint(event.point, session.viewport);
741
+ await this.request(session, "mouse.move", { x: point.x, y: point.y });
742
+ return;
743
+ }
744
+ if (event.kind === "scroll") {
745
+ const point = mapDisplayedPoint(event.point, session.viewport);
746
+ await this.request(session, "mouse.move", { x: point.x, y: point.y });
747
+ await assertTargetCurrent();
748
+ await this.request(session, "mouse.wheel", {
749
+ x: point.x,
750
+ y: point.y,
751
+ deltaX: event.deltaX,
752
+ deltaY: event.deltaY,
753
+ });
754
+ return;
755
+ }
756
+ const start = mapDisplayedPoint(
757
+ event.kind === "drag" ? event.start : event.point,
758
+ session.viewport,
759
+ );
760
+ await this.request(session, "mouse.move", { x: start.x, y: start.y });
761
+ await assertTargetCurrent();
762
+ const count = event.kind === "click" ? event.clickCount : 1;
763
+ for (let clickCount = 1; clickCount <= count; clickCount += 1) {
764
+ await this.request(session, "mouse.down", {
765
+ x: start.x,
766
+ y: start.y,
767
+ button: event.button,
768
+ clickCount,
769
+ });
770
+ try {
771
+ await assertTargetCurrent();
772
+ if (event.kind === "drag") {
773
+ const end = mapDisplayedPoint(event.end, session.viewport);
774
+ await this.request(session, "mouse.move", { x: end.x, y: end.y });
775
+ await assertTargetCurrent();
776
+ }
777
+ } finally {
778
+ const end = event.kind === "drag" ? mapDisplayedPoint(event.end, session.viewport) : start;
779
+ await this.request(session, "mouse.up", {
780
+ x: end.x,
781
+ y: end.y,
782
+ button: event.button,
783
+ clickCount,
784
+ });
785
+ }
786
+ }
787
+ }
788
+
789
+ private async refreshPageMetadata(session: BrowserSession): Promise<void> {
790
+ const raw = asRecord(await this.request(session, "state", null));
791
+ const url = boundedText(String(raw.url ?? ""), 8192);
792
+ if (session.lastUrl && session.lastUrl !== url) {
793
+ session.navigationGeneration += 1;
794
+ this.invalidateFrames(session);
795
+ }
796
+ session.lastUrl = url;
797
+ session.lastTitle = boundedText(String(raw.title ?? ""), 1024);
798
+ session.canGoBack = Boolean(raw.canGoBack);
799
+ session.canGoForward = Boolean(raw.canGoForward);
800
+ }
801
+
802
+ private async snapshotState(session: BrowserSession, viewerToken: string): Promise<BrowserState> {
803
+ this.pruneExpired(session);
804
+ if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
805
+ try {
806
+ await this.refreshPageMetadata(session);
807
+ session.error = null;
808
+ } catch (error) {
809
+ session.error = boundedText(error instanceof Error ? error.message : String(error), 2048);
810
+ }
811
+ const controller = session.controller;
812
+ const controllerViewer = controller ? session.viewers.get(controller.viewerToken) : null;
813
+ return {
814
+ sessionId: session.sessionId,
815
+ workspaceId: session.workspaceId,
816
+ runtimeId: session.runtimeId,
817
+ runtimeCreatedAt: session.runtimeCreatedAt,
818
+ bridgeEpoch: session.bridgeEpoch,
819
+ status: session.error ? "error" : "ready",
820
+ url: session.lastUrl,
821
+ title: session.lastTitle,
822
+ canGoBack: session.canGoBack,
823
+ canGoForward: session.canGoForward,
824
+ viewport: { ...session.viewport },
825
+ navigationGeneration: session.navigationGeneration,
826
+ viewportGeneration: session.viewportGeneration,
827
+ devicePresetId: session.devicePresetId,
828
+ userAgent: session.userAgent,
829
+ controller: !controller ? "none" : controller.viewerToken === viewerToken ? "self" : "other",
830
+ controllerLabel: controllerViewer?.label ?? null,
831
+ controllerExpiresAt: controller ? new Date(controller.expiresAt).toISOString() : null,
832
+ viewerCount: session.viewers.size,
833
+ error: session.error,
834
+ };
835
+ }
836
+ }