unbrowse 2.12.2 → 2.12.4
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/README.md +86 -5
- package/SKILL.md +754 -0
- package/bin/unbrowse-update-hint.mjs +22 -0
- package/bin/unbrowse-wrapper.mjs +84 -16
- package/bin/unbrowse.js +0 -1
- package/dist/cli.js +1899 -19159
- package/dist/mcp.js +1796 -0
- package/package.json +6 -3
- package/runtime-src/agent-outcome.ts +166 -0
- package/runtime-src/analytics-session.ts +28 -6
- package/runtime-src/api/browse-session.ts +520 -51
- package/runtime-src/api/browse-submit-prereqs.ts +48 -0
- package/runtime-src/api/browse-submit.ts +746 -17
- package/runtime-src/api/routes.ts +950 -427
- package/runtime-src/auth/index.ts +160 -7
- package/runtime-src/browser/index.ts +17 -9
- package/runtime-src/build-info.generated.ts +4 -0
- package/runtime-src/capture/index.ts +30 -22
- package/runtime-src/cli.ts +412 -83
- package/runtime-src/client/index.ts +97 -24
- package/runtime-src/execution/index.ts +351 -60
- package/runtime-src/indexer/index.ts +208 -247
- package/runtime-src/kuri/client.ts +774 -267
- package/runtime-src/mcp.ts +1522 -0
- package/runtime-src/orchestrator/first-pass-action.ts +69 -28
- package/runtime-src/orchestrator/index.ts +603 -133
- package/runtime-src/orchestrator/passive-publish.ts +33 -3
- package/runtime-src/payments/wallet.ts +76 -11
- package/runtime-src/publish/sanitize.ts +197 -0
- package/runtime-src/publish-admission.ts +279 -0
- package/runtime-src/reverse-engineer/description-prompt.ts +83 -2
- package/runtime-src/reverse-engineer/index.ts +29 -10
- package/runtime-src/routing-telemetry.ts +395 -0
- package/runtime-src/runtime/browser-auth.ts +12 -0
- package/runtime-src/runtime/local-server.ts +107 -24
- package/runtime-src/runtime/setup.ts +11 -7
- package/runtime-src/runtime/update-hints.ts +351 -0
- package/runtime-src/server.ts +5 -0
- package/runtime-src/settings.ts +221 -0
- package/runtime-src/site-policy.ts +54 -0
- package/runtime-src/stale-cleanup-runner.ts +144 -0
- package/runtime-src/stale-cleanup.ts +133 -0
- package/runtime-src/telemetry-attribution.ts +120 -0
- package/runtime-src/types/skill.ts +439 -0
- package/runtime-src/verification/auth-gate.ts +8 -0
- package/runtime-src/verification/candidates.ts +27 -0
- package/runtime-src/verification/index.ts +21 -15
- package/runtime-src/version.ts +73 -13
- package/runtime-src/workflow/artifact.ts +161 -0
- package/runtime-src/workflow/compile.ts +808 -0
- package/runtime-src/workflow/publish.ts +205 -0
- package/runtime-src/workflow/runtime.ts +213 -0
- package/scripts/postinstall.mjs +43 -19
- package/scripts/release-assets.mjs +24 -0
- package/scripts/verify-release-assets.mjs +39 -0
- package/vendor/kuri/darwin-arm64/kuri +0 -0
- package/vendor/kuri/darwin-x64/kuri +0 -0
- package/vendor/kuri/linux-arm64/kuri +0 -0
- package/vendor/kuri/linux-x64/kuri +0 -0
- package/vendor/kuri/manifest.json +24 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { getKuriErrorMessage } from "../kuri/client.js";
|
|
2
3
|
|
|
3
4
|
export interface BrowseSession {
|
|
5
|
+
sessionId: string;
|
|
4
6
|
tabId: string;
|
|
5
7
|
url: string;
|
|
6
8
|
harActive: boolean;
|
|
7
9
|
domain: string;
|
|
10
|
+
brokerPort?: number;
|
|
11
|
+
client?: BrowseSessionClient;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
export interface BrowseTabRef {
|
|
@@ -19,6 +23,25 @@ export interface BrowseSessionClient {
|
|
|
19
23
|
closeTab(tabId: string): Promise<void>;
|
|
20
24
|
discoverTabs(): Promise<BrowseTabRef[]>;
|
|
21
25
|
getCurrentUrl(tabId: string): Promise<string>;
|
|
26
|
+
getPort?(): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type BrowseSessionErrorCode =
|
|
30
|
+
| "no_active_session"
|
|
31
|
+
| "session_id_required"
|
|
32
|
+
| "session_not_found"
|
|
33
|
+
| "session_expired";
|
|
34
|
+
|
|
35
|
+
export class BrowseSessionError extends Error {
|
|
36
|
+
code: BrowseSessionErrorCode;
|
|
37
|
+
statusCode: number;
|
|
38
|
+
|
|
39
|
+
constructor(code: BrowseSessionErrorCode, message?: string) {
|
|
40
|
+
super(message ?? code);
|
|
41
|
+
this.name = "BrowseSessionError";
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.statusCode = browseSessionErrorStatus(code);
|
|
44
|
+
}
|
|
22
45
|
}
|
|
23
46
|
|
|
24
47
|
const RECOVERABLE_BROWSE_FAILURES = [
|
|
@@ -30,8 +53,28 @@ const RECOVERABLE_BROWSE_FAILURES = [
|
|
|
30
53
|
"execution context was destroyed",
|
|
31
54
|
"cannot find context with specified id",
|
|
32
55
|
"no such target",
|
|
56
|
+
"socket connection was closed unexpectedly",
|
|
57
|
+
"econnreset",
|
|
33
58
|
];
|
|
34
59
|
|
|
60
|
+
const LIVE_CHECK_RETRIES = 8;
|
|
61
|
+
const LIVE_CHECK_RETRY_DELAY_MS = 250;
|
|
62
|
+
|
|
63
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
64
|
+
|
|
65
|
+
function browseSessionErrorStatus(code: BrowseSessionErrorCode): number {
|
|
66
|
+
switch (code) {
|
|
67
|
+
case "session_not_found":
|
|
68
|
+
return 404;
|
|
69
|
+
case "no_active_session":
|
|
70
|
+
return 404;
|
|
71
|
+
case "session_id_required":
|
|
72
|
+
return 409;
|
|
73
|
+
case "session_expired":
|
|
74
|
+
return 409;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
export function extractBrowseFailureMessage(value: unknown): string | null {
|
|
36
79
|
return typeof value === "string" ? value : getKuriErrorMessage(value);
|
|
37
80
|
}
|
|
@@ -43,19 +86,8 @@ export function isRecoverableBrowseFailure(value: unknown): boolean {
|
|
|
43
86
|
return RECOVERABLE_BROWSE_FAILURES.some((needle) => normalized.includes(needle));
|
|
44
87
|
}
|
|
45
88
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
client: BrowseSessionClient,
|
|
49
|
-
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
50
|
-
): Promise<BrowseSession> {
|
|
51
|
-
await client.start().catch(() => {});
|
|
52
|
-
const tabId = await client.newTab();
|
|
53
|
-
if (!tabId) throw new Error("Failed to create browser tab");
|
|
54
|
-
await client.harStart(tabId).catch(() => {});
|
|
55
|
-
await injectInterceptor(tabId);
|
|
56
|
-
const session: BrowseSession = { tabId, url: "about:blank", harActive: true, domain: "" };
|
|
57
|
-
sessions.set("default", session);
|
|
58
|
-
return session;
|
|
89
|
+
function normalizeSessionId(sessionId?: string): string {
|
|
90
|
+
return sessionId?.trim() || randomUUID();
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
function extractDomain(url: string | undefined): string {
|
|
@@ -67,49 +99,255 @@ function extractDomain(url: string | undefined): string {
|
|
|
67
99
|
}
|
|
68
100
|
}
|
|
69
101
|
|
|
102
|
+
function normalizeBrowseUrl(url: string | undefined): URL | null {
|
|
103
|
+
if (!url) return null;
|
|
104
|
+
try {
|
|
105
|
+
return new URL(url);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeBrowsePathname(pathname: string): string {
|
|
112
|
+
if (!pathname) return "/";
|
|
113
|
+
return pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function matchesPreferredBrowseTab(tabUrl: string | undefined, preferredUrl: string | undefined): boolean {
|
|
117
|
+
const candidate = normalizeBrowseUrl(tabUrl);
|
|
118
|
+
const preferred = normalizeBrowseUrl(preferredUrl);
|
|
119
|
+
if (!candidate || !preferred) return false;
|
|
120
|
+
if (candidate.hostname.replace(/^www\./, "") !== preferred.hostname.replace(/^www\./, "")) return false;
|
|
121
|
+
return normalizeBrowsePathname(candidate.pathname) === normalizeBrowsePathname(preferred.pathname);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function matchesPreferredBrowseDomain(tabUrl: string | undefined, preferredUrl: string | undefined): boolean {
|
|
125
|
+
const candidate = normalizeBrowseUrl(tabUrl);
|
|
126
|
+
const preferred = normalizeBrowseUrl(preferredUrl);
|
|
127
|
+
if (!candidate || !preferred) return false;
|
|
128
|
+
return candidate.hostname.replace(/^www\./, "") === preferred.hostname.replace(/^www\./, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isPlaceholderBrowseUrl(url: string | undefined): boolean {
|
|
132
|
+
if (!url) return true;
|
|
133
|
+
const normalized = url.trim().toLowerCase();
|
|
134
|
+
return normalized === "about:blank"
|
|
135
|
+
|| normalized.startsWith("chrome://newtab")
|
|
136
|
+
|| normalized.startsWith("chrome://new-tab-page")
|
|
137
|
+
|| normalized.startsWith("edge://newtab");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasMeaningfulBrowseUrl(url: string | undefined): boolean {
|
|
141
|
+
return hasKnownBrowseUrl(url) && !isPlaceholderBrowseUrl(url);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pickLiveBrowseTab(
|
|
145
|
+
tabs: BrowseTabRef[],
|
|
146
|
+
sessionTabId: string,
|
|
147
|
+
preferredUrl: string | undefined,
|
|
148
|
+
fallbackUrl: string | undefined,
|
|
149
|
+
): BrowseTabRef | undefined {
|
|
150
|
+
const exact = tabs.find((tab) => tab.id === sessionTabId);
|
|
151
|
+
if (exact && !isPlaceholderBrowseUrl(exact.url)) return exact;
|
|
152
|
+
|
|
153
|
+
const preferredReal = tabs.find((tab) => {
|
|
154
|
+
if (isPlaceholderBrowseUrl(tab.url)) return false;
|
|
155
|
+
return matchesPreferredBrowseTab(tab.url, preferredUrl)
|
|
156
|
+
|| matchesPreferredBrowseTab(tab.url, fallbackUrl);
|
|
157
|
+
});
|
|
158
|
+
if (preferredReal) return preferredReal;
|
|
159
|
+
|
|
160
|
+
const sameDomainReal = tabs.filter((tab) => {
|
|
161
|
+
if (isPlaceholderBrowseUrl(tab.url)) return false;
|
|
162
|
+
return matchesPreferredBrowseDomain(tab.url, preferredUrl)
|
|
163
|
+
|| matchesPreferredBrowseDomain(tab.url, fallbackUrl);
|
|
164
|
+
});
|
|
165
|
+
if (exact && isPlaceholderBrowseUrl(exact.url) && sameDomainReal.length === 1) {
|
|
166
|
+
return sameDomainReal[0];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (exact) return exact;
|
|
170
|
+
|
|
171
|
+
return tabs.find((tab) => matchesPreferredBrowseTab(tab.url, preferredUrl)
|
|
172
|
+
|| matchesPreferredBrowseTab(tab.url, fallbackUrl));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function closeStalePlaceholderBrowseTab(
|
|
176
|
+
client: BrowseSessionClient,
|
|
177
|
+
staleTab: BrowseTabRef | undefined,
|
|
178
|
+
activeTabId: string,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
if (!staleTab?.id || staleTab.id === activeTabId) return;
|
|
181
|
+
if (!isPlaceholderBrowseUrl(staleTab.url)) return;
|
|
182
|
+
await client.closeTab(staleTab.id).catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function cleanupSessionQueue(sessionId: string): void {
|
|
186
|
+
sessionQueues.delete(sessionId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sleep(ms: number): Promise<void> {
|
|
190
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasKnownBrowseUrl(value: string | undefined): boolean {
|
|
194
|
+
return typeof value === "string" && value.length > 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveSessionClient(session: BrowseSession | undefined, fallback: BrowseSessionClient): BrowseSessionClient {
|
|
198
|
+
const fallbackPort = fallback.getPort?.();
|
|
199
|
+
if (session?.brokerPort !== undefined && fallbackPort === session.brokerPort) return fallback;
|
|
200
|
+
return session?.client ?? fallback;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function createRegisteredBrowseSession(
|
|
204
|
+
sessions: Map<string, BrowseSession>,
|
|
205
|
+
input: {
|
|
206
|
+
sessionId?: string;
|
|
207
|
+
tabId: string;
|
|
208
|
+
url: string;
|
|
209
|
+
domain: string;
|
|
210
|
+
harActive?: boolean;
|
|
211
|
+
brokerPort?: number;
|
|
212
|
+
client?: BrowseSessionClient;
|
|
213
|
+
},
|
|
214
|
+
): BrowseSession {
|
|
215
|
+
const existing = [...sessions.values()].find((session) => session.tabId === input.tabId);
|
|
216
|
+
if (existing) {
|
|
217
|
+
existing.url = input.url;
|
|
218
|
+
existing.domain = input.domain;
|
|
219
|
+
existing.harActive = input.harActive ?? existing.harActive;
|
|
220
|
+
existing.brokerPort = input.brokerPort ?? existing.brokerPort;
|
|
221
|
+
existing.client = input.client ?? existing.client;
|
|
222
|
+
return existing;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sessionId = normalizeSessionId(input.sessionId);
|
|
226
|
+
const session: BrowseSession = {
|
|
227
|
+
sessionId,
|
|
228
|
+
tabId: input.tabId,
|
|
229
|
+
url: input.url,
|
|
230
|
+
domain: input.domain,
|
|
231
|
+
harActive: input.harActive ?? true,
|
|
232
|
+
brokerPort: input.brokerPort,
|
|
233
|
+
client: input.client,
|
|
234
|
+
};
|
|
235
|
+
sessions.set(sessionId, session);
|
|
236
|
+
return session;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function removeBrowseSession(
|
|
240
|
+
sessions: Map<string, BrowseSession>,
|
|
241
|
+
sessionId: string,
|
|
242
|
+
): BrowseSession | undefined {
|
|
243
|
+
const existing = sessions.get(sessionId);
|
|
244
|
+
sessions.delete(sessionId);
|
|
245
|
+
cleanupSessionQueue(sessionId);
|
|
246
|
+
return existing;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function ownedTabIds(
|
|
250
|
+
sessions: Map<string, BrowseSession>,
|
|
251
|
+
excludeSessionId?: string,
|
|
252
|
+
): Set<string> {
|
|
253
|
+
return new Set(
|
|
254
|
+
[...sessions.values()]
|
|
255
|
+
.filter((session) => session.sessionId !== excludeSessionId)
|
|
256
|
+
.map((session) => session.tabId),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function createBrowseSession(
|
|
261
|
+
sessions: Map<string, BrowseSession>,
|
|
262
|
+
client: BrowseSessionClient,
|
|
263
|
+
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
264
|
+
sessionId?: string,
|
|
265
|
+
): Promise<BrowseSession> {
|
|
266
|
+
await client.start();
|
|
267
|
+
const tabId = await client.newTab();
|
|
268
|
+
if (!tabId) throw new Error("Failed to create browser tab");
|
|
269
|
+
await client.harStart(tabId).catch(() => {});
|
|
270
|
+
await injectInterceptor(tabId);
|
|
271
|
+
return createRegisteredBrowseSession(sessions, {
|
|
272
|
+
sessionId,
|
|
273
|
+
tabId,
|
|
274
|
+
url: "about:blank",
|
|
275
|
+
domain: "",
|
|
276
|
+
harActive: true,
|
|
277
|
+
brokerPort: client.getPort?.(),
|
|
278
|
+
client,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
70
282
|
async function adoptExistingBrowseTab(
|
|
71
283
|
sessions: Map<string, BrowseSession>,
|
|
72
284
|
client: BrowseSessionClient,
|
|
73
285
|
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
74
|
-
|
|
286
|
+
preferredUrl?: string,
|
|
287
|
+
sessionId?: string,
|
|
75
288
|
): Promise<BrowseSession | null> {
|
|
76
289
|
try {
|
|
290
|
+
await client.start();
|
|
77
291
|
const tabs = await client.discoverTabs();
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
tabs.find((tab) => /^(about:blank|chrome:\/\/newtab\/?)$/i.test(tab.url ?? "")) ??
|
|
85
|
-
(!normalizedPreferred ? tabs.find((tab) => /^https?:\/\//.test(tab.url ?? "")) : undefined);
|
|
292
|
+
if (!preferredUrl) return null;
|
|
293
|
+
const reservedTabs = ownedTabIds(sessions, sessionId);
|
|
294
|
+
const candidate = tabs.find((tab) => {
|
|
295
|
+
if (!tab.id || reservedTabs.has(tab.id)) return false;
|
|
296
|
+
return matchesPreferredBrowseTab(tab.url, preferredUrl);
|
|
297
|
+
});
|
|
86
298
|
|
|
87
299
|
if (!candidate?.id) return null;
|
|
88
300
|
await client.harStart(candidate.id).catch(() => {});
|
|
89
301
|
await injectInterceptor(candidate.id);
|
|
90
|
-
|
|
302
|
+
return createRegisteredBrowseSession(sessions, {
|
|
303
|
+
sessionId,
|
|
91
304
|
tabId: candidate.id,
|
|
92
305
|
url: candidate.url ?? "about:blank",
|
|
93
|
-
harActive: true,
|
|
94
306
|
domain: extractDomain(candidate.url),
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
307
|
+
harActive: true,
|
|
308
|
+
brokerPort: client.getPort?.(),
|
|
309
|
+
client,
|
|
310
|
+
});
|
|
98
311
|
} catch {
|
|
99
312
|
return null;
|
|
100
313
|
}
|
|
101
314
|
}
|
|
102
315
|
|
|
316
|
+
export async function rebindBrowseSessionToMatchingTab(
|
|
317
|
+
sessions: Map<string, BrowseSession>,
|
|
318
|
+
client: BrowseSessionClient,
|
|
319
|
+
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
320
|
+
sessionId: string,
|
|
321
|
+
preferredUrl?: string,
|
|
322
|
+
): Promise<BrowseSession | null> {
|
|
323
|
+
const existing = sessions.get(sessionId);
|
|
324
|
+
if (!existing) return null;
|
|
325
|
+
const rebound = await adoptExistingBrowseTab(
|
|
326
|
+
sessions,
|
|
327
|
+
resolveSessionClient(existing, client),
|
|
328
|
+
injectInterceptor,
|
|
329
|
+
preferredUrl ?? existing.url,
|
|
330
|
+
sessionId,
|
|
331
|
+
);
|
|
332
|
+
if (!rebound) return null;
|
|
333
|
+
existing.tabId = rebound.tabId;
|
|
334
|
+
existing.url = rebound.url;
|
|
335
|
+
existing.domain = rebound.domain;
|
|
336
|
+
existing.harActive = rebound.harActive;
|
|
337
|
+
existing.brokerPort = rebound.brokerPort;
|
|
338
|
+
existing.client = rebound.client;
|
|
339
|
+
sessions.set(sessionId, existing);
|
|
340
|
+
return existing;
|
|
341
|
+
}
|
|
342
|
+
|
|
103
343
|
async function dropBrowseSession(
|
|
104
344
|
sessions: Map<string, BrowseSession>,
|
|
105
345
|
client: BrowseSessionClient,
|
|
106
346
|
session: BrowseSession | undefined,
|
|
107
347
|
): Promise<void> {
|
|
108
348
|
if (!session) return;
|
|
109
|
-
await client.closeTab(session.tabId).catch(() => {});
|
|
110
|
-
|
|
111
|
-
sessions.delete("default");
|
|
112
|
-
}
|
|
349
|
+
await resolveSessionClient(session, client).closeTab(session.tabId).catch(() => {});
|
|
350
|
+
removeBrowseSession(sessions, session.sessionId);
|
|
113
351
|
}
|
|
114
352
|
|
|
115
353
|
export async function isBrowseSessionLive(
|
|
@@ -117,28 +355,116 @@ export async function isBrowseSessionLive(
|
|
|
117
355
|
client: BrowseSessionClient,
|
|
118
356
|
): Promise<boolean> {
|
|
119
357
|
if (!session.tabId) return false;
|
|
358
|
+
const sessionClient = resolveSessionClient(session, client);
|
|
359
|
+
let tabSeen = false;
|
|
360
|
+
let lastKnownUrl = session.url;
|
|
120
361
|
|
|
121
362
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
363
|
+
await sessionClient.start();
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (isRecoverableBrowseFailure(error)) return false;
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (let attempt = 0; attempt < LIVE_CHECK_RETRIES; attempt += 1) {
|
|
370
|
+
try {
|
|
371
|
+
const tabs = await sessionClient.discoverTabs();
|
|
372
|
+
const exactTab = tabs.find((tab) => tab.id === session.tabId);
|
|
373
|
+
const liveTab = pickLiveBrowseTab(tabs, session.tabId, session.url, lastKnownUrl);
|
|
374
|
+
if (!liveTab) {
|
|
375
|
+
if (attempt < LIVE_CHECK_RETRIES - 1) {
|
|
376
|
+
await sleep(LIVE_CHECK_RETRY_DELAY_MS);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (liveTab.id !== session.tabId) {
|
|
382
|
+
session.tabId = liveTab.id;
|
|
383
|
+
session.url = liveTab.url ?? session.url;
|
|
384
|
+
session.domain = extractDomain(session.url);
|
|
385
|
+
session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
|
|
386
|
+
session.client = sessionClient;
|
|
387
|
+
await closeStalePlaceholderBrowseTab(sessionClient, exactTab, liveTab.id);
|
|
388
|
+
}
|
|
389
|
+
tabSeen = true;
|
|
390
|
+
if (hasMeaningfulBrowseUrl(liveTab.url)) lastKnownUrl = liveTab.url!;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const currentUrl = await sessionClient.getCurrentUrl(session.tabId);
|
|
394
|
+
if (hasMeaningfulBrowseUrl(currentUrl)) {
|
|
395
|
+
session.url = currentUrl;
|
|
396
|
+
session.domain = extractDomain(currentUrl);
|
|
397
|
+
session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
|
|
398
|
+
session.client = sessionClient;
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
if (
|
|
402
|
+
liveTab.id === session.tabId
|
|
403
|
+
&& isPlaceholderBrowseUrl(currentUrl)
|
|
404
|
+
&& isPlaceholderBrowseUrl(liveTab.url)
|
|
405
|
+
&& isPlaceholderBrowseUrl(session.url)
|
|
406
|
+
) {
|
|
407
|
+
session.url = currentUrl || liveTab.url || session.url;
|
|
408
|
+
session.domain = extractDomain(session.url);
|
|
409
|
+
session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
|
|
410
|
+
session.client = sessionClient;
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (!isRecoverableBrowseFailure(error)) return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (hasMeaningfulBrowseUrl(lastKnownUrl)) {
|
|
418
|
+
session.url = lastKnownUrl;
|
|
419
|
+
session.domain = extractDomain(lastKnownUrl);
|
|
420
|
+
session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
|
|
421
|
+
session.client = sessionClient;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!isRecoverableBrowseFailure(error)) return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (attempt < LIVE_CHECK_RETRIES - 1) await sleep(LIVE_CHECK_RETRY_DELAY_MS);
|
|
128
429
|
}
|
|
430
|
+
|
|
431
|
+
return tabSeen && hasMeaningfulBrowseUrl(lastKnownUrl);
|
|
129
432
|
}
|
|
130
433
|
|
|
131
|
-
|
|
434
|
+
async function listLiveBrowseSessions(
|
|
132
435
|
sessions: Map<string, BrowseSession>,
|
|
133
436
|
client: BrowseSessionClient,
|
|
134
|
-
|
|
437
|
+
): Promise<BrowseSession[]> {
|
|
438
|
+
const live: BrowseSession[] = [];
|
|
439
|
+
const stale: string[] = [];
|
|
440
|
+
|
|
441
|
+
for (const session of sessions.values()) {
|
|
442
|
+
if (await isBrowseSessionLive(session, client)) {
|
|
443
|
+
live.push(session);
|
|
444
|
+
} else {
|
|
445
|
+
stale.push(session.sessionId);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const sessionId of stale) removeBrowseSession(sessions, sessionId);
|
|
450
|
+
return live;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function resolveRequestedBrowseSession(
|
|
454
|
+
sessions: Map<string, BrowseSession>,
|
|
455
|
+
client: BrowseSessionClient,
|
|
456
|
+
requestedSessionId?: string,
|
|
135
457
|
): Promise<BrowseSession> {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
458
|
+
if (requestedSessionId) {
|
|
459
|
+
const session = sessions.get(requestedSessionId);
|
|
460
|
+
if (!session) throw new BrowseSessionError("session_not_found");
|
|
461
|
+
return session;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const live = await listLiveBrowseSessions(sessions, client);
|
|
465
|
+
if (live.length === 0) throw new BrowseSessionError("no_active_session");
|
|
466
|
+
if (live.length > 1) throw new BrowseSessionError("session_id_required");
|
|
467
|
+
return live[0];
|
|
142
468
|
}
|
|
143
469
|
|
|
144
470
|
export async function getOrCreateBrowseSession(
|
|
@@ -146,13 +472,67 @@ export async function getOrCreateBrowseSession(
|
|
|
146
472
|
client: BrowseSessionClient,
|
|
147
473
|
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
148
474
|
): Promise<BrowseSession> {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
475
|
+
await client.start();
|
|
476
|
+
const existingSessions = [...sessions.values()];
|
|
477
|
+
if (existingSessions.length === 1) {
|
|
478
|
+
const existing = existingSessions[0];
|
|
479
|
+
if (await isBrowseSessionLive(existing, client)) return existing;
|
|
480
|
+
const preferredUrl = existing.url;
|
|
481
|
+
const targetSessionId = existing.sessionId;
|
|
482
|
+
const existingClient = resolveSessionClient(existing, client);
|
|
483
|
+
await dropBrowseSession(sessions, existingClient, existing);
|
|
484
|
+
const adopted = await adoptExistingBrowseTab(sessions, existingClient, injectInterceptor, preferredUrl, targetSessionId);
|
|
485
|
+
if (adopted) return adopted;
|
|
486
|
+
return createBrowseSession(sessions, existingClient, injectInterceptor, targetSessionId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const live = await listLiveBrowseSessions(sessions, client);
|
|
490
|
+
if (live.length === 1) return live[0];
|
|
491
|
+
if (live.length > 1) throw new BrowseSessionError("session_id_required");
|
|
492
|
+
|
|
493
|
+
const existing = existingSessions[0];
|
|
494
|
+
const targetSessionId = existing?.sessionId;
|
|
495
|
+
const preferredUrl = existing?.url;
|
|
152
496
|
if (existing) await dropBrowseSession(sessions, client, existing);
|
|
153
|
-
const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor,
|
|
497
|
+
const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredUrl, targetSessionId);
|
|
154
498
|
if (adopted) return adopted;
|
|
155
|
-
return createBrowseSession(sessions, client, injectInterceptor);
|
|
499
|
+
return createBrowseSession(sessions, client, injectInterceptor, targetSessionId);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function resetBrowseSession(
|
|
503
|
+
sessions: Map<string, BrowseSession>,
|
|
504
|
+
client: BrowseSessionClient,
|
|
505
|
+
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
506
|
+
sessionId?: string,
|
|
507
|
+
): Promise<BrowseSession> {
|
|
508
|
+
await client.start();
|
|
509
|
+
const existing = sessionId
|
|
510
|
+
? sessions.get(sessionId)
|
|
511
|
+
: [...sessions.values()][0];
|
|
512
|
+
const targetSessionId = sessionId ?? existing?.sessionId;
|
|
513
|
+
const preferredUrl = existing?.url;
|
|
514
|
+
const existingClient = resolveSessionClient(existing, client);
|
|
515
|
+
await dropBrowseSession(sessions, existingClient, existing);
|
|
516
|
+
const adopted = await adoptExistingBrowseTab(sessions, existingClient, injectInterceptor, preferredUrl, targetSessionId);
|
|
517
|
+
if (adopted) return adopted;
|
|
518
|
+
return createBrowseSession(sessions, existingClient, injectInterceptor, targetSessionId);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function withSessionQueue<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
|
|
522
|
+
const prev = sessionQueues.get(sessionId) ?? Promise.resolve();
|
|
523
|
+
let release!: () => void;
|
|
524
|
+
const waitTurn = new Promise<void>((resolve) => {
|
|
525
|
+
release = resolve;
|
|
526
|
+
});
|
|
527
|
+
const gate = prev.catch(() => {}).then(() => waitTurn);
|
|
528
|
+
sessionQueues.set(sessionId, gate);
|
|
529
|
+
await prev.catch(() => {});
|
|
530
|
+
try {
|
|
531
|
+
return await fn();
|
|
532
|
+
} finally {
|
|
533
|
+
release();
|
|
534
|
+
if (sessionQueues.get(sessionId) === gate) cleanupSessionQueue(sessionId);
|
|
535
|
+
}
|
|
156
536
|
}
|
|
157
537
|
|
|
158
538
|
export async function withRecoveredBrowseSession<T>(
|
|
@@ -173,7 +553,96 @@ export async function withRecoveredBrowseSession<T>(
|
|
|
173
553
|
if (!isRecoverableBrowseFailure(error)) throw error;
|
|
174
554
|
}
|
|
175
555
|
|
|
176
|
-
session = await resetBrowseSession(sessions, client, injectInterceptor);
|
|
556
|
+
session = await resetBrowseSession(sessions, client, injectInterceptor, session.sessionId);
|
|
177
557
|
const result = await run(session);
|
|
178
558
|
return { session, result, recovered: true };
|
|
179
559
|
}
|
|
560
|
+
|
|
561
|
+
export async function withSerializedRecoveredBrowseSession<T>(
|
|
562
|
+
sessions: Map<string, BrowseSession>,
|
|
563
|
+
client: BrowseSessionClient,
|
|
564
|
+
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
565
|
+
requestedSessionId: string | undefined,
|
|
566
|
+
run: (session: BrowseSession) => Promise<T>,
|
|
567
|
+
shouldReset?: (result: T) => boolean,
|
|
568
|
+
): Promise<{ session: BrowseSession; result: T; recovered: boolean }> {
|
|
569
|
+
const resolved = await resolveRequestedBrowseSession(sessions, client, requestedSessionId);
|
|
570
|
+
return withSessionQueue(resolved.sessionId, async () => {
|
|
571
|
+
let session = sessions.get(resolved.sessionId);
|
|
572
|
+
if (!session) throw new BrowseSessionError("session_expired");
|
|
573
|
+
const sessionClient = resolveSessionClient(session, client);
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
const result = await run(session);
|
|
577
|
+
if (!shouldReset || !shouldReset(result)) {
|
|
578
|
+
return { session, result, recovered: false };
|
|
579
|
+
}
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (!isRecoverableBrowseFailure(error)) throw error;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
session = await resetBrowseSession(sessions, sessionClient, injectInterceptor, resolved.sessionId);
|
|
585
|
+
const result = await run(session);
|
|
586
|
+
return { session, result, recovered: true };
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function withSerializedStrictBrowseSession<T>(
|
|
591
|
+
sessions: Map<string, BrowseSession>,
|
|
592
|
+
client: BrowseSessionClient,
|
|
593
|
+
requestedSessionId: string | undefined,
|
|
594
|
+
run: (session: BrowseSession) => Promise<T>,
|
|
595
|
+
shouldExpire?: (result: T) => boolean,
|
|
596
|
+
): Promise<{ session: BrowseSession; result: T; recovered: false }> {
|
|
597
|
+
const resolved = await resolveRequestedBrowseSession(sessions, client, requestedSessionId);
|
|
598
|
+
return withSessionQueue(resolved.sessionId, async () => {
|
|
599
|
+
const session = sessions.get(resolved.sessionId);
|
|
600
|
+
if (!session) throw new BrowseSessionError("session_expired");
|
|
601
|
+
|
|
602
|
+
const live = await isBrowseSessionLive(session, client);
|
|
603
|
+
if (!live) {
|
|
604
|
+
removeBrowseSession(sessions, resolved.sessionId);
|
|
605
|
+
throw new BrowseSessionError("session_expired");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const result = await run(session);
|
|
610
|
+
if (shouldExpire?.(result)) {
|
|
611
|
+
const stillLive = await isBrowseSessionLive(session, client);
|
|
612
|
+
if (!stillLive) {
|
|
613
|
+
removeBrowseSession(sessions, resolved.sessionId);
|
|
614
|
+
throw new BrowseSessionError("session_expired");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { session, result, recovered: false };
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (error instanceof BrowseSessionError) throw error;
|
|
620
|
+
if (isRecoverableBrowseFailure(error)) {
|
|
621
|
+
const stillLive = await isBrowseSessionLive(session, client);
|
|
622
|
+
if (!stillLive) {
|
|
623
|
+
removeBrowseSession(sessions, resolved.sessionId);
|
|
624
|
+
throw new BrowseSessionError("session_expired");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function getOrCreateNavigateBrowseSession(
|
|
633
|
+
sessions: Map<string, BrowseSession>,
|
|
634
|
+
client: BrowseSessionClient,
|
|
635
|
+
injectInterceptor: (tabId: string) => Promise<unknown>,
|
|
636
|
+
requestedSessionId?: string,
|
|
637
|
+
): Promise<BrowseSession> {
|
|
638
|
+
if (requestedSessionId) {
|
|
639
|
+
const session = sessions.get(requestedSessionId);
|
|
640
|
+
if (!session) throw new BrowseSessionError("session_not_found");
|
|
641
|
+
return session;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const live = await listLiveBrowseSessions(sessions, client);
|
|
645
|
+
if (live.length === 0) return createBrowseSession(sessions, client, injectInterceptor);
|
|
646
|
+
if (live.length > 1) throw new BrowseSessionError("session_id_required");
|
|
647
|
+
return live[0];
|
|
648
|
+
}
|