@yeaft/webchat-agent 1.0.416 → 1.0.418
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/browser-runtime/browser-install.js +39 -0
- package/browser-runtime/chromium.js +192 -0
- package/browser-runtime/cli.js +1 -1
- package/browser-runtime/extension/manifest.json +3 -0
- package/browser-runtime/extension/offscreen.js +201 -25
- package/browser-runtime/extension/popup.js +4 -1
- package/browser-runtime/extension/service-worker.js +47 -7
- package/browser-runtime/extension.js +1 -1
- package/browser-runtime/index.js +3 -0
- package/browser-runtime/local-bridge.js +194 -0
- package/browser-runtime/messages.js +123 -0
- package/browser-runtime/service.js +769 -30
- package/connection/index.js +2 -0
- package/connection/message-router.js +2 -0
- package/index.js +13 -0
- package/local-runtime/server/.env.example +10 -0
- package/local-runtime/server/browser-runtime-routes.js +284 -0
- package/local-runtime/server/client-protocol.js +8 -0
- package/local-runtime/server/config.js +28 -0
- package/local-runtime/server/handlers/agent-browser.js +393 -0
- package/local-runtime/server/handlers/agent-sync.js +14 -0
- package/local-runtime/server/handlers/client-browser.js +337 -0
- package/local-runtime/server/ws-agent.js +9 -1
- package/local-runtime/server/ws-client.js +41 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +220 -98
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
|
@@ -1,48 +1,250 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { normaliseBrowserRuntimeSection } from './config.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
defaultBrowserCacheDir,
|
|
5
|
+
installManagedBrowser,
|
|
6
|
+
managedBrowserDownloadInfo,
|
|
7
|
+
resolveBrowserExecutable,
|
|
8
|
+
} from './browser-install.js';
|
|
4
9
|
import { probeBrowserRuntime } from './probe.js';
|
|
5
10
|
import { BrowserRuntimeError } from './errors.js';
|
|
11
|
+
import { BrowserExtensionBridge } from './local-bridge.js';
|
|
12
|
+
import { launchBrowserSession } from './chromium.js';
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
const REQUEST_TTL_MS = 10 * 60_000;
|
|
15
|
+
const MAX_INSTALL_PROGRESS_LISTENERS = 8;
|
|
16
|
+
|
|
17
|
+
function clean(value, max = 512) {
|
|
18
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function positiveInteger(value) {
|
|
22
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeInitialUrl(value) {
|
|
26
|
+
const raw = clean(value, 4096) || 'about:blank';
|
|
27
|
+
if (raw === 'about:blank') return raw;
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(raw);
|
|
30
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
31
|
+
throw new BrowserRuntimeError('browser_url_invalid');
|
|
32
|
+
}
|
|
33
|
+
return url.href;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof BrowserRuntimeError) throw error;
|
|
36
|
+
throw new BrowserRuntimeError('browser_url_invalid');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stableDigest(value) {
|
|
41
|
+
return createHash('sha256').update(JSON.stringify(value || null)).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeIdentity(value) {
|
|
45
|
+
const identity = value && typeof value === 'object' ? value : {};
|
|
46
|
+
const result = Object.freeze({
|
|
47
|
+
ownerUserId: clean(identity.ownerUserId),
|
|
48
|
+
clientId: clean(identity.clientId),
|
|
49
|
+
webConnectionId: clean(identity.webConnectionId),
|
|
50
|
+
webConnectionGeneration: clean(identity.webConnectionGeneration),
|
|
51
|
+
});
|
|
52
|
+
if (!result.ownerUserId || !result.clientId || !result.webConnectionId || !result.webConnectionGeneration) {
|
|
53
|
+
throw new BrowserRuntimeError('browser_identity_required');
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameOwner(a, b) {
|
|
59
|
+
return a?.ownerUserId === b?.ownerUserId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sameConnection(a, b) {
|
|
63
|
+
return sameOwner(a, b)
|
|
64
|
+
&& a?.clientId === b?.clientId
|
|
65
|
+
&& a?.webConnectionId === b?.webConnectionId
|
|
66
|
+
&& a?.webConnectionGeneration === b?.webConnectionGeneration;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function publicIceServers(value) {
|
|
70
|
+
if (!Array.isArray(value)) return [];
|
|
71
|
+
return value.slice(0, 8).map(server => ({
|
|
72
|
+
urls: Array.isArray(server?.urls)
|
|
73
|
+
? server.urls.slice(0, 8).map(url => clean(url, 2048)).filter(Boolean)
|
|
74
|
+
: clean(server?.urls, 2048),
|
|
75
|
+
...(clean(server?.username, 512) ? { username: clean(server.username, 512) } : {}),
|
|
76
|
+
...(clean(server?.credential, 1024) ? { credential: clean(server.credential, 1024) } : {}),
|
|
77
|
+
})).filter(server => Array.isArray(server.urls) ? server.urls.length > 0 : !!server.urls);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sourceRef(value) {
|
|
81
|
+
if (!value || typeof value !== 'object') return null;
|
|
82
|
+
const kind = ['yeaft-session', 'chat-conversation', 'work-item'].includes(value.kind) ? value.kind : null;
|
|
83
|
+
if (!kind) return null;
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
kind,
|
|
86
|
+
...(clean(value.sessionId) ? { sessionId: clean(value.sessionId) } : {}),
|
|
87
|
+
...(clean(value.conversationId) ? { conversationId: clean(value.conversationId) } : {}),
|
|
88
|
+
...(clean(value.workItemId) ? { workItemId: clean(value.workItemId) } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Agent-local owner for Browser Session, Chromium and WebRTC peer lifecycles. */
|
|
12
93
|
export class BrowserRuntimeService {
|
|
13
|
-
constructor({
|
|
94
|
+
constructor({
|
|
95
|
+
yeaftDir,
|
|
96
|
+
config,
|
|
97
|
+
probe = probeBrowserRuntime,
|
|
98
|
+
resolveBrowser = resolveBrowserExecutable,
|
|
99
|
+
installBrowser = installManagedBrowser,
|
|
100
|
+
downloadInfo = managedBrowserDownloadInfo,
|
|
101
|
+
saveSettings = null,
|
|
102
|
+
onCapabilitiesChanged = null,
|
|
103
|
+
bridge = new BrowserExtensionBridge(),
|
|
104
|
+
launchSession = launchBrowserSession,
|
|
105
|
+
send = null,
|
|
106
|
+
platform = process.platform,
|
|
107
|
+
arch = process.arch,
|
|
108
|
+
} = {}) {
|
|
14
109
|
if (!yeaftDir) throw new Error('yeaftDir required');
|
|
15
110
|
this.yeaftDir = yeaftDir;
|
|
16
111
|
this.config = normaliseBrowserRuntimeSection(config);
|
|
17
112
|
this.config.cacheDir ||= defaultBrowserCacheDir(yeaftDir);
|
|
18
113
|
this.probe = probe;
|
|
114
|
+
this.resolveBrowser = resolveBrowser;
|
|
115
|
+
this.installBrowser = installBrowser;
|
|
116
|
+
this.downloadInfo = downloadInfo;
|
|
117
|
+
this.saveSettings = typeof saveSettings === 'function' ? saveSettings : null;
|
|
118
|
+
this.onCapabilitiesChanged = typeof onCapabilitiesChanged === 'function' ? onCapabilitiesChanged : null;
|
|
119
|
+
this.bridge = bridge;
|
|
120
|
+
this.launchSession = launchSession;
|
|
121
|
+
this.send = typeof send === 'function' ? send : () => 'dropped';
|
|
122
|
+
this.platform = platform;
|
|
123
|
+
this.arch = arch;
|
|
124
|
+
const download = this.downloadInfo({ platform, arch });
|
|
125
|
+
this.setupInfo = Object.freeze({
|
|
126
|
+
...download,
|
|
127
|
+
// Phase 1 advertises only the real Linux tab-capture data plane. The CLI
|
|
128
|
+
// may manage pinned binaries on more platforms, but Web must not offer an
|
|
129
|
+
// install that can never produce a ready capability.
|
|
130
|
+
supported: download.supported === true && platform === 'linux' && arch === 'x64',
|
|
131
|
+
});
|
|
19
132
|
this.sessions = new Map();
|
|
133
|
+
this.requests = new Map();
|
|
20
134
|
this.probeResult = null;
|
|
135
|
+
this.installProgress = null;
|
|
136
|
+
this.lastSetupError = null;
|
|
21
137
|
this.state = this.config.enabled ? 'unprobed' : 'disabled';
|
|
22
138
|
this.#probePromise = null;
|
|
23
139
|
this.#probeAbort = null;
|
|
140
|
+
this.#installPromise = null;
|
|
141
|
+
this.#installAbort = null;
|
|
142
|
+
this.#installListeners = new Set();
|
|
143
|
+
this.#lastInstallProgressAt = 0;
|
|
24
144
|
this.#shutdownPromise = null;
|
|
25
145
|
}
|
|
26
146
|
|
|
27
147
|
#probePromise;
|
|
28
148
|
#probeAbort;
|
|
149
|
+
#installPromise;
|
|
150
|
+
#installAbort;
|
|
151
|
+
#installListeners;
|
|
152
|
+
#lastInstallProgressAt;
|
|
29
153
|
#shutdownPromise;
|
|
30
154
|
|
|
31
155
|
get enabled() { return this.config.enabled === true; }
|
|
32
156
|
get ready() { return this.state === 'ready' && this.probeResult?.ok === true; }
|
|
33
157
|
|
|
158
|
+
setupCapabilities() {
|
|
159
|
+
return this.setupInfo.supported ? ['browser_runtime_setup'] : [];
|
|
160
|
+
}
|
|
161
|
+
|
|
34
162
|
capabilities() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return [];
|
|
163
|
+
if (!this.ready || this.platform !== 'linux' || this.probeResult?.captureMode !== 'tab') return [];
|
|
164
|
+
return ['browser_runtime', 'browser_webrtc', 'browser_capture_tab'];
|
|
38
165
|
}
|
|
39
166
|
|
|
40
|
-
async
|
|
167
|
+
async #notifyCapabilitiesChanged() {
|
|
168
|
+
try {
|
|
169
|
+
await this.onCapabilitiesChanged?.();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn(`[BrowserRuntime] capability refresh failed: ${error?.message || error}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async #persistEnabled({ managed = false } = {}) {
|
|
176
|
+
const update = {
|
|
177
|
+
enabled: true,
|
|
178
|
+
...(managed ? { executablePath: null } : {}),
|
|
179
|
+
};
|
|
180
|
+
if (this.saveSettings) {
|
|
181
|
+
const saved = await this.saveSettings(update);
|
|
182
|
+
if (saved?.error) {
|
|
183
|
+
throw new BrowserRuntimeError('browser_config_update_failed', saved.error);
|
|
184
|
+
}
|
|
185
|
+
this.config = {
|
|
186
|
+
...this.config,
|
|
187
|
+
...normaliseBrowserRuntimeSection(saved),
|
|
188
|
+
cacheDir: this.config.cacheDir,
|
|
189
|
+
};
|
|
190
|
+
} else {
|
|
191
|
+
this.config = { ...this.config, ...update };
|
|
192
|
+
}
|
|
193
|
+
this.config.enabled = true;
|
|
194
|
+
if (managed) this.config.executablePath = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async setupStatus() {
|
|
198
|
+
let executablePath = null;
|
|
199
|
+
if (this.setupInfo.supported) {
|
|
200
|
+
try {
|
|
201
|
+
executablePath = await this.resolveBrowser({
|
|
202
|
+
executablePath: this.config.executablePath,
|
|
203
|
+
cacheDir: this.config.cacheDir,
|
|
204
|
+
});
|
|
205
|
+
if (this.lastSetupError?.source === 'status') this.lastSetupError = null;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!this.lastSetupError || this.lastSetupError.source === 'status') {
|
|
208
|
+
this.lastSetupError = {
|
|
209
|
+
source: 'status',
|
|
210
|
+
code: error?.code || 'browser_status_failed',
|
|
211
|
+
safeError: String(error?.message || error).slice(0, 500),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const installed = !!executablePath;
|
|
217
|
+
if (this.ready) this.lastSetupError = null;
|
|
218
|
+
const state = !this.setupInfo.supported ? 'unsupported'
|
|
219
|
+
: this.ready ? 'ready'
|
|
220
|
+
: this.#installPromise ? 'installing'
|
|
221
|
+
: this.state === 'probing' ? 'probing'
|
|
222
|
+
: this.probeResult && this.config.enabled ? 'unavailable'
|
|
223
|
+
: installed ? 'disabled'
|
|
224
|
+
: 'not_installed';
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
supported: this.setupInfo.supported,
|
|
227
|
+
state,
|
|
228
|
+
installed,
|
|
229
|
+
enabled: this.enabled,
|
|
230
|
+
ready: this.ready,
|
|
231
|
+
buildId: this.setupInfo.buildId,
|
|
232
|
+
platform: this.setupInfo.platform,
|
|
233
|
+
downloadBytes: this.setupInfo.downloadBytes,
|
|
234
|
+
downloadedBytes: Number(this.installProgress?.downloadedBytes) || 0,
|
|
235
|
+
totalBytes: Number(this.installProgress?.totalBytes) || this.setupInfo.downloadBytes,
|
|
236
|
+
probeCode: this.probeResult?.code || null,
|
|
237
|
+
safeError: this.lastSetupError?.safeError || this.probeResult?.safeError || null,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async startupProbe({ force = false } = {}) {
|
|
41
242
|
if (!this.enabled) return { ok: false, code: 'browser_runtime_disabled' };
|
|
42
243
|
if (this.#probePromise) return this.#probePromise;
|
|
244
|
+
if (!force && this.ready) return this.probeResult;
|
|
43
245
|
this.state = 'probing';
|
|
44
246
|
this.#probeAbort = new AbortController();
|
|
45
|
-
|
|
247
|
+
const probePromise = this.probe({
|
|
46
248
|
executablePath: this.config.executablePath,
|
|
47
249
|
cacheDir: this.config.cacheDir,
|
|
48
250
|
headless: this.config.headless,
|
|
@@ -61,33 +263,559 @@ export class BrowserRuntimeService {
|
|
|
61
263
|
});
|
|
62
264
|
this.state = 'unavailable';
|
|
63
265
|
return this.probeResult;
|
|
266
|
+
}).finally(() => {
|
|
267
|
+
if (this.#probePromise === probePromise) this.#probePromise = null;
|
|
268
|
+
this.#probeAbort = null;
|
|
64
269
|
});
|
|
65
|
-
|
|
270
|
+
this.#probePromise = probePromise;
|
|
271
|
+
return probePromise;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async enableAndProbe() {
|
|
275
|
+
if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
|
|
276
|
+
this.lastSetupError = null;
|
|
277
|
+
try {
|
|
278
|
+
const executablePath = await this.resolveBrowser({
|
|
279
|
+
executablePath: this.config.executablePath,
|
|
280
|
+
cacheDir: this.config.cacheDir,
|
|
281
|
+
});
|
|
282
|
+
if (!executablePath) throw new BrowserRuntimeError('browser_executable_missing');
|
|
283
|
+
await this.#persistEnabled();
|
|
284
|
+
this.probeResult = null;
|
|
285
|
+
const probe = await this.startupProbe({ force: true });
|
|
286
|
+
await this.#notifyCapabilitiesChanged();
|
|
287
|
+
return { ...(await this.setupStatus()), probeCode: probe.code || null };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
this.lastSetupError = {
|
|
290
|
+
source: 'enable',
|
|
291
|
+
code: error?.code || 'browser_enable_failed',
|
|
292
|
+
safeError: String(error?.message || error).slice(0, 500),
|
|
293
|
+
};
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async installAndEnable({ confirmedBuildId, confirmedDownloadBytes, onProgress = null } = {}) {
|
|
299
|
+
if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
|
|
300
|
+
if (confirmedBuildId !== this.setupInfo.buildId
|
|
301
|
+
|| Number(confirmedDownloadBytes) !== this.setupInfo.downloadBytes) {
|
|
302
|
+
throw new BrowserRuntimeError('browser_install_confirmation_stale');
|
|
303
|
+
}
|
|
304
|
+
if (typeof onProgress === 'function') {
|
|
305
|
+
if (this.#installListeners.size >= MAX_INSTALL_PROGRESS_LISTENERS) {
|
|
306
|
+
throw new BrowserRuntimeError('browser_install_observer_limit');
|
|
307
|
+
}
|
|
308
|
+
this.#installListeners.add(onProgress);
|
|
309
|
+
}
|
|
310
|
+
if (!this.#installPromise) {
|
|
311
|
+
this.state = 'installing';
|
|
312
|
+
this.lastSetupError = null;
|
|
313
|
+
this.installProgress = Object.freeze({
|
|
314
|
+
downloadedBytes: 0,
|
|
315
|
+
totalBytes: this.setupInfo.downloadBytes,
|
|
316
|
+
});
|
|
317
|
+
this.#installAbort = new AbortController();
|
|
318
|
+
const installPromise = (async () => {
|
|
319
|
+
await this.installBrowser({
|
|
320
|
+
cacheDir: this.config.cacheDir,
|
|
321
|
+
signal: this.#installAbort.signal,
|
|
322
|
+
onProgress: (downloadedBytes, totalBytes) => {
|
|
323
|
+
const total = Number(totalBytes) || this.setupInfo.downloadBytes;
|
|
324
|
+
this.installProgress = Object.freeze({
|
|
325
|
+
downloadedBytes: Number(downloadedBytes) || 0,
|
|
326
|
+
totalBytes: total,
|
|
327
|
+
});
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
if (now - this.#lastInstallProgressAt < 250 && downloadedBytes < total) return;
|
|
330
|
+
this.#lastInstallProgressAt = now;
|
|
331
|
+
for (const listener of this.#installListeners) {
|
|
332
|
+
try {
|
|
333
|
+
Promise.resolve(listener(this.installProgress)).catch(() => {});
|
|
334
|
+
} catch {}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
await this.#persistEnabled({ managed: true });
|
|
339
|
+
this.probeResult = null;
|
|
340
|
+
await this.startupProbe({ force: true });
|
|
341
|
+
await this.#notifyCapabilitiesChanged();
|
|
342
|
+
return this.setupStatus();
|
|
343
|
+
})().catch(error => {
|
|
344
|
+
this.lastSetupError = {
|
|
345
|
+
source: 'install',
|
|
346
|
+
code: error?.code || 'browser_install_failed',
|
|
347
|
+
safeError: String(error?.message || error).slice(0, 500),
|
|
348
|
+
};
|
|
349
|
+
this.state = 'unavailable';
|
|
350
|
+
throw error;
|
|
351
|
+
}).finally(() => {
|
|
352
|
+
if (this.#installPromise === installPromise) {
|
|
353
|
+
this.#installPromise = null;
|
|
354
|
+
this.#installAbort = null;
|
|
355
|
+
}
|
|
356
|
+
this.#installListeners.clear();
|
|
357
|
+
});
|
|
358
|
+
this.#installPromise = installPromise;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
return await this.#installPromise;
|
|
362
|
+
} finally {
|
|
363
|
+
if (typeof onProgress === 'function') this.#installListeners.delete(onProgress);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#emit(message) {
|
|
368
|
+
return this.send(message);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#pruneRequests(now = Date.now()) {
|
|
372
|
+
for (const [key, request] of this.requests) {
|
|
373
|
+
if (request.expiresAt <= now) this.requests.delete(key);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#requestKey(identity, requestId) {
|
|
378
|
+
return `${identity.ownerUserId}\0${identity.webConnectionId}\0${clean(requestId)}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#snapshot(session, extra = {}) {
|
|
382
|
+
return {
|
|
383
|
+
browserSessionId: session.browserSessionId,
|
|
384
|
+
revision: session.revision,
|
|
385
|
+
state: session.state,
|
|
386
|
+
activeUrl: session.activeUrl,
|
|
387
|
+
title: session.title,
|
|
388
|
+
pageRevision: session.pageRevision,
|
|
389
|
+
captureMode: session.captureMode,
|
|
390
|
+
viewport: session.viewport,
|
|
391
|
+
viewerCount: session.peers.size,
|
|
392
|
+
interactivePeerCount: 0,
|
|
393
|
+
authorizedProducerCount: 0,
|
|
394
|
+
expiresAt: session.expiresAt,
|
|
395
|
+
terminalReason: session.terminalReason || null,
|
|
396
|
+
safeError: session.safeError || null,
|
|
397
|
+
sourceRef: session.sourceRef,
|
|
398
|
+
...extra,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#emitSnapshot(session, extra = {}) {
|
|
403
|
+
return this.#emit({ type: 'browser_session_snapshot', ...this.#snapshot(session, extra) });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#dropPeer(session, peer, reason = 'peer_closed') {
|
|
407
|
+
if (!peer || session.peers.get(peer.peerId) !== peer) return false;
|
|
408
|
+
clearTimeout(peer.expiryTimer);
|
|
409
|
+
peer.expiryTimer = null;
|
|
410
|
+
session.peers.delete(peer.peerId);
|
|
411
|
+
this.bridge.send(session.browserSessionId, {
|
|
412
|
+
type: 'peer_close',
|
|
413
|
+
peerId: peer.peerId,
|
|
414
|
+
connectionGeneration: peer.connectionGeneration,
|
|
415
|
+
reason,
|
|
416
|
+
});
|
|
417
|
+
session.revision += 1;
|
|
418
|
+
this.#scheduleNoViewerCleanup(session);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#scheduleNoViewerCleanup(session) {
|
|
423
|
+
clearTimeout(session.noViewerTimer);
|
|
424
|
+
session.noViewerTimer = null;
|
|
425
|
+
if (session.state !== 'ready' || session.peers.size > 0) return;
|
|
426
|
+
const delay = Math.max(10_000, Number(this.config.noViewerIdleMs) || 120_000);
|
|
427
|
+
session.expiresAt = Date.now() + delay;
|
|
428
|
+
session.noViewerTimer = setTimeout(() => {
|
|
429
|
+
if (this.sessions.get(session.browserSessionId) === session && session.peers.size === 0) {
|
|
430
|
+
void this.closeSessionRecord(session, 'no_viewer_timeout');
|
|
431
|
+
}
|
|
432
|
+
}, delay);
|
|
433
|
+
session.noViewerTimer.unref?.();
|
|
66
434
|
}
|
|
67
435
|
|
|
68
436
|
assertCanCreateSession() {
|
|
69
437
|
if (!this.ready) throw new BrowserRuntimeError('browser_runtime_unavailable');
|
|
70
|
-
if (this.sessions.size >= this.config.maxSessions)
|
|
71
|
-
throw new BrowserRuntimeError('browser_session_limit');
|
|
72
|
-
}
|
|
438
|
+
if (this.sessions.size >= this.config.maxSessions) throw new BrowserRuntimeError('browser_session_limit');
|
|
73
439
|
}
|
|
74
440
|
|
|
75
|
-
|
|
76
|
-
|
|
441
|
+
async createSession(message) {
|
|
442
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
443
|
+
const requestId = clean(message?.requestId);
|
|
444
|
+
if (!requestId) throw new BrowserRuntimeError('browser_request_id_required');
|
|
445
|
+
const options = {
|
|
446
|
+
initialUrl: safeInitialUrl(message?.options?.initialUrl),
|
|
447
|
+
viewport: message?.options?.viewport || null,
|
|
448
|
+
locale: clean(message?.options?.locale, 32) || 'en-US',
|
|
449
|
+
capturePreference: clean(message?.options?.capturePreference, 16) || 'auto',
|
|
450
|
+
};
|
|
451
|
+
if (!['auto', 'tab'].includes(options.capturePreference)) {
|
|
452
|
+
throw new BrowserRuntimeError('browser_capture_mode_unsupported');
|
|
453
|
+
}
|
|
454
|
+
this.#pruneRequests();
|
|
455
|
+
const requestKey = this.#requestKey(identity, requestId);
|
|
456
|
+
const digest = stableDigest({ options, sourceRef: sourceRef(message?.sourceRef) });
|
|
457
|
+
const existing = this.requests.get(requestKey);
|
|
458
|
+
if (existing) {
|
|
459
|
+
if (existing.digest !== digest) throw new BrowserRuntimeError('browser_request_conflict');
|
|
460
|
+
return existing.promise;
|
|
461
|
+
}
|
|
77
462
|
this.assertCanCreateSession();
|
|
78
|
-
|
|
463
|
+
|
|
79
464
|
const browserSessionId = randomUUID();
|
|
80
|
-
|
|
465
|
+
const session = {
|
|
81
466
|
browserSessionId,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
467
|
+
owner: identity,
|
|
468
|
+
sourceRef: sourceRef(message?.sourceRef),
|
|
469
|
+
revision: 1,
|
|
470
|
+
pageRevision: 1,
|
|
471
|
+
state: 'starting',
|
|
472
|
+
activeUrl: options.initialUrl,
|
|
473
|
+
title: '',
|
|
474
|
+
captureMode: 'tab',
|
|
475
|
+
viewport: null,
|
|
476
|
+
peers: new Map(),
|
|
477
|
+
runtime: null,
|
|
478
|
+
bridgeRegistration: null,
|
|
479
|
+
noViewerTimer: null,
|
|
480
|
+
expiresAt: null,
|
|
481
|
+
terminalReason: null,
|
|
482
|
+
safeError: null,
|
|
483
|
+
closingPromise: null,
|
|
484
|
+
startupAbort: new AbortController(),
|
|
485
|
+
};
|
|
486
|
+
this.sessions.set(browserSessionId, session);
|
|
487
|
+
const promise = this.#startSession(session, options, requestId);
|
|
488
|
+
this.requests.set(requestKey, { digest, promise, expiresAt: Date.now() + REQUEST_TTL_MS });
|
|
489
|
+
return promise;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async #startSession(session, options, requestId) {
|
|
493
|
+
try {
|
|
494
|
+
session.bridgeRegistration = await this.bridge.registerSession(session.browserSessionId, {
|
|
495
|
+
onMessage: message => this.#handleBridgeMessage(session, message),
|
|
496
|
+
onDisconnect: () => {
|
|
497
|
+
if (session.state === 'ready') void this.closeSessionRecord(session, 'extension_disconnected');
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
session.runtime = await this.launchSession({
|
|
501
|
+
browserSessionId: session.browserSessionId,
|
|
502
|
+
bridgeUrl: session.bridgeRegistration.bridgeUrl,
|
|
503
|
+
config: this.config,
|
|
504
|
+
initialUrl: options.initialUrl,
|
|
505
|
+
viewport: options.viewport,
|
|
506
|
+
locale: options.locale,
|
|
507
|
+
signal: session.startupAbort.signal,
|
|
508
|
+
});
|
|
509
|
+
if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
|
|
510
|
+
await session.runtime.close?.().catch(() => {});
|
|
511
|
+
session.runtime = null;
|
|
512
|
+
throw new BrowserRuntimeError('browser_session_cancelled');
|
|
513
|
+
}
|
|
514
|
+
await session.bridgeRegistration.waitUntilReady(this.config.startupProbeTimeoutMs);
|
|
515
|
+
if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
|
|
516
|
+
throw new BrowserRuntimeError('browser_session_cancelled');
|
|
517
|
+
}
|
|
518
|
+
session.viewport = session.runtime.viewport;
|
|
519
|
+
session.captureMode = session.runtime.captureMode;
|
|
520
|
+
session.activeUrl = session.runtime.page.url();
|
|
521
|
+
session.title = await session.runtime.page.title().catch(() => '');
|
|
522
|
+
session.state = 'ready';
|
|
523
|
+
session.revision += 1;
|
|
524
|
+
session.runtime.page.on('framenavigated', frame => {
|
|
525
|
+
if (frame !== session.runtime?.page?.mainFrame?.()) return;
|
|
526
|
+
session.pageRevision += 1;
|
|
527
|
+
session.revision += 1;
|
|
528
|
+
session.activeUrl = frame.url();
|
|
529
|
+
void session.runtime.page.title().then(title => { session.title = title; }).catch(() => {});
|
|
530
|
+
this.#emitSnapshot(session);
|
|
531
|
+
});
|
|
532
|
+
session.runtime.page.on('close', () => {
|
|
533
|
+
if (session.state === 'ready') void this.closeSessionRecord(session, 'page_closed');
|
|
534
|
+
});
|
|
535
|
+
this.#scheduleNoViewerCleanup(session);
|
|
536
|
+
const created = {
|
|
537
|
+
type: 'browser_session_created',
|
|
538
|
+
requestId,
|
|
539
|
+
...this.#snapshot(session),
|
|
540
|
+
};
|
|
541
|
+
await this.#emit(created);
|
|
542
|
+
return created;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
session.state = 'failed';
|
|
545
|
+
session.revision += 1;
|
|
546
|
+
session.terminalReason = error?.code || 'browser_session_start_failed';
|
|
547
|
+
session.safeError = String(error?.message || error).slice(0, 500);
|
|
548
|
+
await this.#emit({
|
|
549
|
+
type: 'browser_session_error',
|
|
550
|
+
requestId,
|
|
551
|
+
browserSessionId: session.browserSessionId,
|
|
552
|
+
code: session.terminalReason,
|
|
553
|
+
safeError: session.safeError,
|
|
554
|
+
});
|
|
555
|
+
await this.closeSessionRecord(session, session.terminalReason, { emit: false });
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#sessionFor(message, { exactConnection = false } = {}) {
|
|
561
|
+
const session = this.sessions.get(clean(message?.browserSessionId));
|
|
562
|
+
if (!session) throw new BrowserRuntimeError('browser_session_not_found');
|
|
563
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
564
|
+
if (!sameOwner(session.owner, identity)) throw new BrowserRuntimeError('browser_owner_mismatch');
|
|
565
|
+
if (exactConnection && !sameConnection(session.owner, identity)) {
|
|
566
|
+
// Peer messages are checked against the peer's connection below. Session
|
|
567
|
+
// creation ownership alone must not grant a sibling browser tab control.
|
|
568
|
+
}
|
|
569
|
+
return { session, identity };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async preparePeer(message) {
|
|
573
|
+
const { session, identity } = this.#sessionFor(message);
|
|
574
|
+
if (session.state !== 'ready') throw new BrowserRuntimeError('browser_session_unavailable');
|
|
575
|
+
const peerId = clean(message?.peerId);
|
|
576
|
+
const connectionGeneration = Number(message?.connectionGeneration);
|
|
577
|
+
if (!peerId || !positiveInteger(connectionGeneration)) throw new BrowserRuntimeError('browser_peer_invalid');
|
|
578
|
+
const existing = session.peers.get(peerId);
|
|
579
|
+
if (existing) {
|
|
580
|
+
if (existing.connectionGeneration !== connectionGeneration || !sameConnection(existing.identity, identity)) {
|
|
581
|
+
throw new BrowserRuntimeError('browser_peer_conflict');
|
|
582
|
+
}
|
|
583
|
+
if (existing.state === 'prepared' || existing.state === 'offered' || existing.state === 'connected') {
|
|
584
|
+
return this.#emit({
|
|
585
|
+
type: 'browser_peer_prepared',
|
|
586
|
+
browserSessionId: session.browserSessionId,
|
|
587
|
+
peerId,
|
|
588
|
+
connectionGeneration,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (session.peers.size >= this.config.maxPeersPerSession) throw new BrowserRuntimeError('browser_peer_limit');
|
|
594
|
+
const peer = {
|
|
595
|
+
peerId,
|
|
596
|
+
connectionGeneration,
|
|
597
|
+
identity,
|
|
598
|
+
state: 'preparing',
|
|
599
|
+
webCandidateCount: 0,
|
|
600
|
+
agentCandidateCount: 0,
|
|
601
|
+
expiresAt: Number(message?.routeExpiresAt) || Date.now() + 10 * 60_000,
|
|
602
|
+
expiryTimer: null,
|
|
603
|
+
};
|
|
604
|
+
session.peers.set(peerId, peer);
|
|
605
|
+
peer.expiryTimer = setTimeout(() => {
|
|
606
|
+
if (this.#dropPeer(session, peer, 'peer_route_expired')) this.#emitSnapshot(session);
|
|
607
|
+
}, Math.max(1, peer.expiresAt - Date.now()));
|
|
608
|
+
peer.expiryTimer.unref?.();
|
|
609
|
+
clearTimeout(session.noViewerTimer);
|
|
610
|
+
session.noViewerTimer = null;
|
|
611
|
+
session.expiresAt = null;
|
|
612
|
+
const sent = this.bridge.send(session.browserSessionId, {
|
|
613
|
+
type: 'peer_prepare',
|
|
614
|
+
peerId,
|
|
615
|
+
connectionGeneration,
|
|
616
|
+
iceServers: publicIceServers(message?.agentIceServers),
|
|
617
|
+
iceTransportPolicy: message?.iceTransportPolicy === 'relay' ? 'relay' : 'all',
|
|
618
|
+
maxBitrate: this.config.maxBitrate,
|
|
619
|
+
maxFps: this.config.maxFps,
|
|
620
|
+
});
|
|
621
|
+
if (!sent) {
|
|
622
|
+
clearTimeout(peer.expiryTimer);
|
|
623
|
+
session.peers.delete(peerId);
|
|
624
|
+
this.#scheduleNoViewerCleanup(session);
|
|
625
|
+
throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#peerFor(message) {
|
|
631
|
+
const { session, identity } = this.#sessionFor(message);
|
|
632
|
+
const peer = session.peers.get(clean(message?.peerId));
|
|
633
|
+
if (!peer || peer.connectionGeneration !== Number(message?.connectionGeneration)) {
|
|
634
|
+
throw new BrowserRuntimeError('browser_peer_stale');
|
|
635
|
+
}
|
|
636
|
+
if (!sameConnection(peer.identity, identity)) throw new BrowserRuntimeError('browser_peer_owner_mismatch');
|
|
637
|
+
return { session, peer };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
answerPeer(message) {
|
|
641
|
+
const { session, peer } = this.#peerFor(message);
|
|
642
|
+
const description = message?.description;
|
|
643
|
+
if (description?.type !== 'answer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
|
|
644
|
+
throw new BrowserRuntimeError('browser_sdp_invalid');
|
|
645
|
+
}
|
|
646
|
+
if (!this.bridge.send(session.browserSessionId, {
|
|
647
|
+
type: 'peer_answer',
|
|
648
|
+
peerId: peer.peerId,
|
|
649
|
+
connectionGeneration: peer.connectionGeneration,
|
|
650
|
+
description: { type: 'answer', sdp: description.sdp },
|
|
651
|
+
})) throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
addPeerIceCandidate(message) {
|
|
655
|
+
const { session, peer } = this.#peerFor(message);
|
|
656
|
+
if (peer.webCandidateCount >= 128) throw new BrowserRuntimeError('browser_candidate_limit');
|
|
657
|
+
const candidate = message?.candidate;
|
|
658
|
+
if (candidate != null && (typeof candidate !== 'object'
|
|
659
|
+
|| typeof candidate.candidate !== 'string'
|
|
660
|
+
|| candidate.candidate.length > 4096)) {
|
|
661
|
+
throw new BrowserRuntimeError('browser_candidate_invalid');
|
|
662
|
+
}
|
|
663
|
+
peer.webCandidateCount += 1;
|
|
664
|
+
if (!this.bridge.send(session.browserSessionId, {
|
|
665
|
+
type: 'peer_ice_candidate',
|
|
666
|
+
peerId: peer.peerId,
|
|
667
|
+
connectionGeneration: peer.connectionGeneration,
|
|
668
|
+
candidate: candidate == null ? null : {
|
|
669
|
+
candidate: candidate.candidate,
|
|
670
|
+
sdpMid: clean(candidate.sdpMid, 256) || null,
|
|
671
|
+
sdpMLineIndex: Number.isInteger(candidate.sdpMLineIndex) ? candidate.sdpMLineIndex : null,
|
|
672
|
+
usernameFragment: clean(candidate.usernameFragment, 256) || null,
|
|
673
|
+
},
|
|
674
|
+
})) throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
detachPeer(message, reason = 'peer_detached') {
|
|
678
|
+
const { session, peer } = this.#peerFor(message);
|
|
679
|
+
const dropped = this.#dropPeer(session, peer, reason);
|
|
680
|
+
if (dropped) this.#emitSnapshot(session);
|
|
681
|
+
return dropped;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async closeSession(message) {
|
|
685
|
+
const { session } = this.#sessionFor(message);
|
|
686
|
+
if (message?.expectedRevision != null && Number(message.expectedRevision) !== session.revision) {
|
|
687
|
+
throw new BrowserRuntimeError('browser_revision_conflict');
|
|
688
|
+
}
|
|
689
|
+
await this.closeSessionRecord(session, 'user_closed', { emit: false });
|
|
690
|
+
return this.#emit({
|
|
691
|
+
type: 'browser_session_snapshot',
|
|
692
|
+
requestId: clean(message?.requestId) || null,
|
|
693
|
+
...this.#snapshot(session),
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async closeSessionRecord(session, reason = 'closed', { emit = true } = {}) {
|
|
698
|
+
if (!session || this.sessions.get(session.browserSessionId) !== session) return false;
|
|
699
|
+
if (session.closingPromise) return session.closingPromise;
|
|
700
|
+
session.closingPromise = (async () => {
|
|
701
|
+
session.state = 'closing';
|
|
702
|
+
session.revision += 1;
|
|
703
|
+
session.startupAbort?.abort(new BrowserRuntimeError('browser_session_cancelled'));
|
|
704
|
+
session.terminalReason = reason;
|
|
705
|
+
clearTimeout(session.noViewerTimer);
|
|
706
|
+
session.noViewerTimer = null;
|
|
707
|
+
for (const peer of session.peers.values()) clearTimeout(peer.expiryTimer);
|
|
708
|
+
session.peers.clear();
|
|
709
|
+
this.bridge.send(session.browserSessionId, { type: 'session_close', reason });
|
|
710
|
+
this.bridge.unregisterSession(session.browserSessionId, reason);
|
|
711
|
+
try { await session.runtime?.close?.(); } catch {}
|
|
712
|
+
session.runtime = null;
|
|
713
|
+
if (this.sessions.get(session.browserSessionId) === session) {
|
|
714
|
+
this.sessions.delete(session.browserSessionId);
|
|
715
|
+
}
|
|
716
|
+
session.state = 'closed';
|
|
717
|
+
session.revision += 1;
|
|
718
|
+
session.expiresAt = null;
|
|
719
|
+
if (emit) await this.#emitSnapshot(session);
|
|
720
|
+
return true;
|
|
721
|
+
})();
|
|
722
|
+
return session.closingPromise;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
getSession(message) {
|
|
726
|
+
const { session } = this.#sessionFor(message);
|
|
727
|
+
return this.#emit({
|
|
728
|
+
type: 'browser_session_snapshot',
|
|
729
|
+
requestId: clean(message?.requestId) || null,
|
|
730
|
+
...this.#snapshot(session),
|
|
731
|
+
});
|
|
87
732
|
}
|
|
88
733
|
|
|
89
|
-
|
|
90
|
-
|
|
734
|
+
listSessions(message) {
|
|
735
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
736
|
+
const sessions = [...this.sessions.values()]
|
|
737
|
+
.filter(session => sameOwner(session.owner, identity))
|
|
738
|
+
.map(session => this.#snapshot(session));
|
|
739
|
+
return this.#emit({
|
|
740
|
+
type: 'browser_session_list_result',
|
|
741
|
+
requestId: clean(message?.requestId) || null,
|
|
742
|
+
sessions,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#handleBridgeMessage(session, message) {
|
|
747
|
+
if (this.sessions.get(session.browserSessionId) !== session) return;
|
|
748
|
+
if (message.type === 'peer_prepared' || message.type === 'peer_offer'
|
|
749
|
+
|| message.type === 'peer_ice_candidate' || message.type === 'peer_state'
|
|
750
|
+
|| message.type === 'peer_error') {
|
|
751
|
+
const peer = session.peers.get(clean(message.peerId));
|
|
752
|
+
if (!peer || peer.connectionGeneration !== Number(message.connectionGeneration)) return;
|
|
753
|
+
if (message.type === 'peer_prepared') {
|
|
754
|
+
peer.state = 'prepared';
|
|
755
|
+
void this.#emit({
|
|
756
|
+
type: 'browser_peer_prepared',
|
|
757
|
+
browserSessionId: session.browserSessionId,
|
|
758
|
+
peerId: peer.peerId,
|
|
759
|
+
connectionGeneration: peer.connectionGeneration,
|
|
760
|
+
});
|
|
761
|
+
} else if (message.type === 'peer_offer') {
|
|
762
|
+
const description = message.description;
|
|
763
|
+
if (description?.type !== 'offer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
|
|
764
|
+
void this.closeSessionRecord(session, 'invalid_extension_offer');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
peer.state = 'offered';
|
|
768
|
+
void this.#emit({
|
|
769
|
+
type: 'browser_peer_offer',
|
|
770
|
+
browserSessionId: session.browserSessionId,
|
|
771
|
+
peerId: peer.peerId,
|
|
772
|
+
connectionGeneration: peer.connectionGeneration,
|
|
773
|
+
description: { type: 'offer', sdp: description.sdp },
|
|
774
|
+
});
|
|
775
|
+
} else if (message.type === 'peer_ice_candidate') {
|
|
776
|
+
if (peer.agentCandidateCount >= 128) return;
|
|
777
|
+
peer.agentCandidateCount += 1;
|
|
778
|
+
void this.#emit({
|
|
779
|
+
type: 'browser_peer_ice_candidate',
|
|
780
|
+
browserSessionId: session.browserSessionId,
|
|
781
|
+
peerId: peer.peerId,
|
|
782
|
+
connectionGeneration: peer.connectionGeneration,
|
|
783
|
+
candidate: message.candidate || null,
|
|
784
|
+
});
|
|
785
|
+
} else if (message.type === 'peer_error') {
|
|
786
|
+
this.#dropPeer(session, peer, 'peer_failed');
|
|
787
|
+
void this.#emit({
|
|
788
|
+
type: 'browser_peer_error',
|
|
789
|
+
browserSessionId: session.browserSessionId,
|
|
790
|
+
peerId: peer.peerId,
|
|
791
|
+
connectionGeneration: peer.connectionGeneration,
|
|
792
|
+
code: clean(message.code, 128) || 'peer_failed',
|
|
793
|
+
safeError: clean(message.safeError, 500) || 'Browser peer failed',
|
|
794
|
+
});
|
|
795
|
+
void this.#emitSnapshot(session);
|
|
796
|
+
} else {
|
|
797
|
+
const nextState = clean(message.state, 32) || peer.state;
|
|
798
|
+
if (nextState === 'connected') {
|
|
799
|
+
clearTimeout(peer.expiryTimer);
|
|
800
|
+
peer.expiryTimer = null;
|
|
801
|
+
peer.expiresAt = null;
|
|
802
|
+
peer.state = nextState;
|
|
803
|
+
} else if (['failed', 'disconnected', 'closed'].includes(nextState)) {
|
|
804
|
+
this.#dropPeer(session, peer, `peer_${nextState}`);
|
|
805
|
+
} else {
|
|
806
|
+
peer.state = nextState;
|
|
807
|
+
}
|
|
808
|
+
void this.#emit({
|
|
809
|
+
type: 'browser_peer_state',
|
|
810
|
+
browserSessionId: session.browserSessionId,
|
|
811
|
+
peerId: peer.peerId,
|
|
812
|
+
connectionGeneration: peer.connectionGeneration,
|
|
813
|
+
state: nextState,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (message.type === 'capture_ended') void this.closeSessionRecord(session, 'capture_ended');
|
|
91
819
|
}
|
|
92
820
|
|
|
93
821
|
snapshot() {
|
|
@@ -101,11 +829,22 @@ export class BrowserRuntimeService {
|
|
|
101
829
|
});
|
|
102
830
|
}
|
|
103
831
|
|
|
832
|
+
async handleTransportDisconnect() {
|
|
833
|
+
await Promise.allSettled([...this.sessions.values()]
|
|
834
|
+
.map(session => this.closeSessionRecord(session, 'agent_transport_disconnected', { emit: false })));
|
|
835
|
+
}
|
|
836
|
+
|
|
104
837
|
async shutdown() {
|
|
105
838
|
if (this.#shutdownPromise) return this.#shutdownPromise;
|
|
106
839
|
this.#probeAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
|
|
107
|
-
this.#
|
|
108
|
-
|
|
840
|
+
this.#installAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
|
|
841
|
+
this.#shutdownPromise = Promise.allSettled([
|
|
842
|
+
Promise.resolve(this.#probePromise),
|
|
843
|
+
Promise.resolve(this.#installPromise),
|
|
844
|
+
]).then(async () => {
|
|
845
|
+
await Promise.allSettled([...this.sessions.values()]
|
|
846
|
+
.map(session => this.closeSessionRecord(session, 'browser_runtime_shutdown', { emit: false })));
|
|
847
|
+
await this.bridge.close();
|
|
109
848
|
this.state = 'closed';
|
|
110
849
|
});
|
|
111
850
|
return this.#shutdownPromise;
|