opendevbrowser 0.0.12 → 0.0.15
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/LICENSE +21 -0
- package/README.md +216 -28
- package/dist/chunk-JVBMT2O5.js +7173 -0
- package/dist/chunk-JVBMT2O5.js.map +1 -0
- package/dist/cli/index.js +2486 -589
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +1057 -194
- package/dist/index.js.map +1 -1
- package/dist/opendevbrowser.js +1057 -194
- package/dist/opendevbrowser.js.map +1 -1
- package/extension/dist/annotate-content.css +237 -0
- package/extension/dist/annotate-content.js +934 -0
- package/extension/dist/background.js +1194 -32
- package/extension/dist/logging.js +50 -0
- package/extension/dist/ops/dom-bridge.js +355 -0
- package/extension/dist/ops/ops-runtime.js +1249 -0
- package/extension/dist/ops/ops-session-store.js +189 -0
- package/extension/dist/ops/redaction.js +52 -0
- package/extension/dist/ops/snapshot-builder.js +4 -0
- package/extension/dist/ops/snapshot-shared.js +220 -0
- package/extension/dist/popup.js +370 -25
- package/extension/dist/relay-settings.js +1 -0
- package/extension/dist/services/CDPRouter.js +501 -103
- package/extension/dist/services/ConnectionManager.js +464 -57
- package/extension/dist/services/NativePortManager.js +182 -0
- package/extension/dist/services/RelayClient.js +227 -26
- package/extension/dist/services/TabManager.js +81 -0
- package/extension/dist/services/TargetSessionMap.js +146 -0
- package/extension/dist/services/cdp-router-commands.js +203 -0
- package/extension/dist/services/url-restrictions.js +41 -0
- package/extension/dist/types.js +3 -1
- package/extension/manifest.json +17 -3
- package/extension/popup.html +144 -0
- package/package.json +2 -2
- package/skills/AGENTS.md +34 -62
- package/skills/data-extraction/SKILL.md +95 -103
- package/skills/form-testing/SKILL.md +75 -82
- package/skills/login-automation/SKILL.md +76 -66
- package/skills/opendevbrowser-best-practices/SKILL.md +90 -49
- package/skills/opendevbrowser-continuity-ledger/SKILL.md +57 -23
- package/dist/chunk-WTFSMBVH.js +0 -2815
- package/dist/chunk-WTFSMBVH.js.map +0 -1
- package/extension/dist/popup.jsx +0 -150
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
import { MAX_OPS_PAYLOAD_BYTES, MAX_SNAPSHOT_BYTES, OPS_PROTOCOL_VERSION } from "../types.js";
|
|
2
|
+
import { TabManager } from "../services/TabManager.js";
|
|
3
|
+
import { getRestrictionMessage, isRestrictedUrl } from "../services/url-restrictions.js";
|
|
4
|
+
import { logError } from "../logging.js";
|
|
5
|
+
import { DomBridge } from "./dom-bridge.js";
|
|
6
|
+
import { buildSnapshot } from "./snapshot-builder.js";
|
|
7
|
+
import { OpsSessionStore } from "./ops-session-store.js";
|
|
8
|
+
import { redactConsoleText, redactUrl } from "./redaction.js";
|
|
9
|
+
const MAX_CONSOLE_EVENTS = 200;
|
|
10
|
+
const MAX_NETWORK_EVENTS = 300;
|
|
11
|
+
const SESSION_TTL_MS = 20_000;
|
|
12
|
+
const SCREENSHOT_TIMEOUT_MS = 8000;
|
|
13
|
+
const TAB_CLOSE_TIMEOUT_MS = 5000;
|
|
14
|
+
export class OpsRuntime {
|
|
15
|
+
sendEnvelope;
|
|
16
|
+
cdp;
|
|
17
|
+
tabs = new TabManager();
|
|
18
|
+
dom = new DomBridge();
|
|
19
|
+
sessions = new OpsSessionStore();
|
|
20
|
+
encoder = new TextEncoder();
|
|
21
|
+
closingTimers = new Map();
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.sendEnvelope = options.send;
|
|
24
|
+
this.cdp = options.cdp;
|
|
25
|
+
chrome.tabs.onRemoved.addListener(this.handleTabRemoved);
|
|
26
|
+
chrome.debugger.onEvent.addListener(this.handleDebuggerEvent);
|
|
27
|
+
chrome.debugger.onDetach.addListener(this.handleDebuggerDetach);
|
|
28
|
+
}
|
|
29
|
+
handleMessage(message) {
|
|
30
|
+
if (message.type === "ops_hello") {
|
|
31
|
+
this.handleHello(message);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (message.type === "ops_ping") {
|
|
35
|
+
this.handlePing(message);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (message.type === "ops_event" && message.event === "ops_client_disconnected") {
|
|
39
|
+
this.handleClientDisconnected(message);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (message.type === "ops_request") {
|
|
43
|
+
void this.handleRequest(message).catch((error) => {
|
|
44
|
+
logError("ops.handle_request", error, { code: "ops_request_failed" });
|
|
45
|
+
this.sendError(message, {
|
|
46
|
+
code: "execution_failed",
|
|
47
|
+
message: error instanceof Error ? error.message : "Ops request failed",
|
|
48
|
+
retryable: false
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
handleHello(message) {
|
|
54
|
+
if (message.version !== OPS_PROTOCOL_VERSION) {
|
|
55
|
+
const error = {
|
|
56
|
+
type: "ops_error",
|
|
57
|
+
requestId: "ops_hello",
|
|
58
|
+
clientId: message.clientId,
|
|
59
|
+
error: {
|
|
60
|
+
code: "not_supported",
|
|
61
|
+
message: "Unsupported ops protocol version.",
|
|
62
|
+
retryable: false,
|
|
63
|
+
details: { supported: [OPS_PROTOCOL_VERSION], received: message.version }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
this.sendEnvelope(error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const ack = {
|
|
70
|
+
type: "ops_hello_ack",
|
|
71
|
+
version: OPS_PROTOCOL_VERSION,
|
|
72
|
+
clientId: message.clientId,
|
|
73
|
+
maxPayloadBytes: MAX_OPS_PAYLOAD_BYTES,
|
|
74
|
+
capabilities: []
|
|
75
|
+
};
|
|
76
|
+
this.sendEnvelope(ack);
|
|
77
|
+
}
|
|
78
|
+
handlePing(message) {
|
|
79
|
+
const pong = {
|
|
80
|
+
type: "ops_pong",
|
|
81
|
+
id: message.id,
|
|
82
|
+
clientId: message.clientId
|
|
83
|
+
};
|
|
84
|
+
this.sendEnvelope(pong);
|
|
85
|
+
}
|
|
86
|
+
handleClientDisconnected(message) {
|
|
87
|
+
const clientId = message.clientId;
|
|
88
|
+
if (!clientId)
|
|
89
|
+
return;
|
|
90
|
+
const sessions = this.sessions.listOwnedBy(clientId);
|
|
91
|
+
for (const session of sessions) {
|
|
92
|
+
this.markSessionClosing(session, "ops_session_expired");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
handleTabRemoved = (tabId) => {
|
|
96
|
+
this.handleClosedTarget(tabId, "ops_tab_closed");
|
|
97
|
+
};
|
|
98
|
+
handleDebuggerDetach = (source) => {
|
|
99
|
+
if (typeof source.tabId !== "number")
|
|
100
|
+
return;
|
|
101
|
+
void this.handleDebuggerDetachForTab(source.tabId);
|
|
102
|
+
};
|
|
103
|
+
handleDebuggerEvent = (source, method, params) => {
|
|
104
|
+
if (typeof source.tabId !== "number")
|
|
105
|
+
return;
|
|
106
|
+
const session = this.sessions.getByTabId(source.tabId);
|
|
107
|
+
if (!session)
|
|
108
|
+
return;
|
|
109
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
110
|
+
const payload = params;
|
|
111
|
+
const parts = Array.isArray(payload?.args)
|
|
112
|
+
? payload.args.map((arg) => {
|
|
113
|
+
if (typeof arg.value === "string")
|
|
114
|
+
return arg.value;
|
|
115
|
+
if (typeof arg.value === "number" || typeof arg.value === "boolean")
|
|
116
|
+
return String(arg.value);
|
|
117
|
+
if (typeof arg.description === "string")
|
|
118
|
+
return arg.description;
|
|
119
|
+
return "";
|
|
120
|
+
})
|
|
121
|
+
: [];
|
|
122
|
+
const text = redactConsoleText(parts.filter(Boolean).join(" "));
|
|
123
|
+
const event = {
|
|
124
|
+
seq: ++session.consoleSeq,
|
|
125
|
+
level: payload?.type ?? "log",
|
|
126
|
+
text,
|
|
127
|
+
ts: Date.now()
|
|
128
|
+
};
|
|
129
|
+
session.consoleEvents.push(event);
|
|
130
|
+
if (session.consoleEvents.length > MAX_CONSOLE_EVENTS) {
|
|
131
|
+
session.consoleEvents.shift();
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (method === "Network.requestWillBeSent") {
|
|
136
|
+
const payload = params;
|
|
137
|
+
const requestId = payload.requestId;
|
|
138
|
+
if (requestId && payload.request) {
|
|
139
|
+
const methodValue = payload.request.method ?? "GET";
|
|
140
|
+
const urlValue = payload.request.url ?? "";
|
|
141
|
+
session.networkRequests.set(requestId, {
|
|
142
|
+
method: methodValue,
|
|
143
|
+
url: urlValue,
|
|
144
|
+
resourceType: payload.type
|
|
145
|
+
});
|
|
146
|
+
const event = {
|
|
147
|
+
seq: ++session.networkSeq,
|
|
148
|
+
method: methodValue,
|
|
149
|
+
url: redactUrl(urlValue),
|
|
150
|
+
resourceType: payload.type,
|
|
151
|
+
ts: Date.now()
|
|
152
|
+
};
|
|
153
|
+
session.networkEvents.push(event);
|
|
154
|
+
if (session.networkEvents.length > MAX_NETWORK_EVENTS) {
|
|
155
|
+
session.networkEvents.shift();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (method === "Network.responseReceived") {
|
|
161
|
+
const payload = params;
|
|
162
|
+
const requestId = payload.requestId;
|
|
163
|
+
if (requestId) {
|
|
164
|
+
const pending = session.networkRequests.get(requestId);
|
|
165
|
+
const urlValue = payload.response?.url ?? pending?.url ?? "";
|
|
166
|
+
const methodValue = pending?.method ?? "GET";
|
|
167
|
+
const event = {
|
|
168
|
+
seq: ++session.networkSeq,
|
|
169
|
+
method: methodValue,
|
|
170
|
+
url: redactUrl(urlValue),
|
|
171
|
+
status: payload.response?.status,
|
|
172
|
+
resourceType: pending?.resourceType,
|
|
173
|
+
ts: Date.now()
|
|
174
|
+
};
|
|
175
|
+
session.networkEvents.push(event);
|
|
176
|
+
if (session.networkEvents.length > MAX_NETWORK_EVENTS) {
|
|
177
|
+
session.networkEvents.shift();
|
|
178
|
+
}
|
|
179
|
+
session.networkRequests.delete(requestId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
async handleRequest(message) {
|
|
184
|
+
const clientId = message.clientId;
|
|
185
|
+
if (!clientId) {
|
|
186
|
+
this.sendError(message, buildError("invalid_request", "Missing clientId", false));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
switch (message.command) {
|
|
190
|
+
case "session.launch":
|
|
191
|
+
case "session.connect":
|
|
192
|
+
await this.handleSessionLaunch(message, clientId);
|
|
193
|
+
return;
|
|
194
|
+
case "session.disconnect":
|
|
195
|
+
await this.handleSessionDisconnect(message, clientId);
|
|
196
|
+
return;
|
|
197
|
+
case "session.status":
|
|
198
|
+
await this.handleSessionStatus(message, clientId);
|
|
199
|
+
return;
|
|
200
|
+
case "targets.list":
|
|
201
|
+
await this.withSession(message, clientId, (session) => this.handleTargetsList(message, session));
|
|
202
|
+
return;
|
|
203
|
+
case "targets.use":
|
|
204
|
+
await this.withSession(message, clientId, (session) => this.handleTargetsUse(message, session));
|
|
205
|
+
return;
|
|
206
|
+
case "targets.new":
|
|
207
|
+
await this.withSession(message, clientId, (session) => this.handleTargetsNew(message, session));
|
|
208
|
+
return;
|
|
209
|
+
case "targets.close":
|
|
210
|
+
await this.withSession(message, clientId, (session) => this.handleTargetsClose(message, session));
|
|
211
|
+
return;
|
|
212
|
+
case "page.open":
|
|
213
|
+
await this.withSession(message, clientId, (session) => this.handlePageOpen(message, session));
|
|
214
|
+
return;
|
|
215
|
+
case "page.list":
|
|
216
|
+
await this.withSession(message, clientId, (session) => this.handlePageList(message, session));
|
|
217
|
+
return;
|
|
218
|
+
case "page.close":
|
|
219
|
+
await this.withSession(message, clientId, (session) => this.handlePageClose(message, session));
|
|
220
|
+
return;
|
|
221
|
+
case "nav.goto":
|
|
222
|
+
await this.withSession(message, clientId, (session) => this.handleGoto(message, session));
|
|
223
|
+
return;
|
|
224
|
+
case "nav.wait":
|
|
225
|
+
await this.withSession(message, clientId, (session) => this.handleWait(message, session));
|
|
226
|
+
return;
|
|
227
|
+
case "nav.snapshot":
|
|
228
|
+
await this.withSession(message, clientId, (session) => this.handleSnapshot(message, session));
|
|
229
|
+
return;
|
|
230
|
+
case "interact.click":
|
|
231
|
+
await this.withSession(message, clientId, (session) => this.handleClick(message, session));
|
|
232
|
+
return;
|
|
233
|
+
case "interact.hover":
|
|
234
|
+
await this.withSession(message, clientId, (session) => this.handleHover(message, session));
|
|
235
|
+
return;
|
|
236
|
+
case "interact.press":
|
|
237
|
+
await this.withSession(message, clientId, (session) => this.handlePress(message, session));
|
|
238
|
+
return;
|
|
239
|
+
case "interact.check":
|
|
240
|
+
await this.withSession(message, clientId, (session) => this.handleCheck(message, session, true));
|
|
241
|
+
return;
|
|
242
|
+
case "interact.uncheck":
|
|
243
|
+
await this.withSession(message, clientId, (session) => this.handleCheck(message, session, false));
|
|
244
|
+
return;
|
|
245
|
+
case "interact.type":
|
|
246
|
+
await this.withSession(message, clientId, (session) => this.handleType(message, session));
|
|
247
|
+
return;
|
|
248
|
+
case "interact.select":
|
|
249
|
+
await this.withSession(message, clientId, (session) => this.handleSelect(message, session));
|
|
250
|
+
return;
|
|
251
|
+
case "interact.scroll":
|
|
252
|
+
await this.withSession(message, clientId, (session) => this.handleScroll(message, session));
|
|
253
|
+
return;
|
|
254
|
+
case "interact.scrollIntoView":
|
|
255
|
+
await this.withSession(message, clientId, (session) => this.handleScrollIntoView(message, session));
|
|
256
|
+
return;
|
|
257
|
+
case "dom.getHtml":
|
|
258
|
+
await this.withSession(message, clientId, (session) => this.handleDomGetHtml(message, session));
|
|
259
|
+
return;
|
|
260
|
+
case "dom.getText":
|
|
261
|
+
await this.withSession(message, clientId, (session) => this.handleDomGetText(message, session));
|
|
262
|
+
return;
|
|
263
|
+
case "dom.getAttr":
|
|
264
|
+
await this.withSession(message, clientId, (session) => this.handleDomGetAttr(message, session));
|
|
265
|
+
return;
|
|
266
|
+
case "dom.getValue":
|
|
267
|
+
await this.withSession(message, clientId, (session) => this.handleDomGetValue(message, session));
|
|
268
|
+
return;
|
|
269
|
+
case "dom.isVisible":
|
|
270
|
+
await this.withSession(message, clientId, (session) => this.handleDomIsVisible(message, session));
|
|
271
|
+
return;
|
|
272
|
+
case "dom.isEnabled":
|
|
273
|
+
await this.withSession(message, clientId, (session) => this.handleDomIsEnabled(message, session));
|
|
274
|
+
return;
|
|
275
|
+
case "dom.isChecked":
|
|
276
|
+
await this.withSession(message, clientId, (session) => this.handleDomIsChecked(message, session));
|
|
277
|
+
return;
|
|
278
|
+
case "export.clonePage":
|
|
279
|
+
await this.withSession(message, clientId, (session) => this.handleClonePage(message, session));
|
|
280
|
+
return;
|
|
281
|
+
case "export.cloneComponent":
|
|
282
|
+
await this.withSession(message, clientId, (session) => this.handleCloneComponent(message, session));
|
|
283
|
+
return;
|
|
284
|
+
case "devtools.perf":
|
|
285
|
+
await this.withSession(message, clientId, (session) => this.handlePerf(message, session));
|
|
286
|
+
return;
|
|
287
|
+
case "page.screenshot":
|
|
288
|
+
await this.withSession(message, clientId, (session) => this.handleScreenshot(message, session));
|
|
289
|
+
return;
|
|
290
|
+
case "devtools.consolePoll":
|
|
291
|
+
await this.withSession(message, clientId, (session) => this.handleConsolePoll(message, session));
|
|
292
|
+
return;
|
|
293
|
+
case "devtools.networkPoll":
|
|
294
|
+
await this.withSession(message, clientId, (session) => this.handleNetworkPoll(message, session));
|
|
295
|
+
return;
|
|
296
|
+
default:
|
|
297
|
+
this.sendError(message, buildError("invalid_request", `Unknown ops command: ${message.command}`, false));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async handleSessionLaunch(message, clientId) {
|
|
301
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
302
|
+
const startUrl = typeof payload.startUrl === "string" ? payload.startUrl : undefined;
|
|
303
|
+
if (startUrl) {
|
|
304
|
+
try {
|
|
305
|
+
const restriction = getRestrictionMessage(new URL(startUrl));
|
|
306
|
+
if (restriction) {
|
|
307
|
+
this.sendError(message, buildError("restricted_url", restriction, false));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
this.sendError(message, buildError("invalid_request", "Invalid startUrl", false));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const activeTab = startUrl
|
|
317
|
+
? await this.tabs.createTab(startUrl, true)
|
|
318
|
+
: await this.tabs.getActiveTab();
|
|
319
|
+
if (!activeTab || typeof activeTab.id !== "number") {
|
|
320
|
+
this.sendError(message, buildError("ops_unavailable", "No active tab to attach.", true));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (activeTab.url) {
|
|
324
|
+
const restriction = isRestrictedUrl(activeTab.url);
|
|
325
|
+
if (restriction.restricted) {
|
|
326
|
+
this.sendError(message, buildError("restricted_url", restriction.message ?? "Restricted tab.", false));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
await this.cdp.attach(activeTab.id);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
const detail = error instanceof Error ? error.message : "Debugger attach failed";
|
|
335
|
+
this.sendError(message, buildError("cdp_attach_failed", detail, false));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await this.tabs.waitForTabComplete(activeTab.id).catch(() => undefined);
|
|
339
|
+
const leaseId = typeof message.leaseId === "string" && message.leaseId.trim().length > 0
|
|
340
|
+
? message.leaseId.trim()
|
|
341
|
+
: createId();
|
|
342
|
+
const session = this.sessions.createSession(clientId, activeTab.id, leaseId, {
|
|
343
|
+
url: activeTab.url ?? undefined,
|
|
344
|
+
title: activeTab.title ?? undefined
|
|
345
|
+
});
|
|
346
|
+
await this.enableSessionDomains(session);
|
|
347
|
+
this.sendEvent({
|
|
348
|
+
type: "ops_event",
|
|
349
|
+
clientId,
|
|
350
|
+
opsSessionId: session.id,
|
|
351
|
+
event: "ops_session_created",
|
|
352
|
+
payload: { tabId: session.tabId, targetId: session.targetId }
|
|
353
|
+
});
|
|
354
|
+
this.sendResponse(message, {
|
|
355
|
+
opsSessionId: session.id,
|
|
356
|
+
activeTargetId: session.activeTargetId,
|
|
357
|
+
url: activeTab.url ?? undefined,
|
|
358
|
+
title: activeTab.title ?? undefined,
|
|
359
|
+
leaseId: session.leaseId
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async handleSessionDisconnect(message, clientId) {
|
|
363
|
+
const session = this.getSessionForMessage(message, clientId);
|
|
364
|
+
if (!session)
|
|
365
|
+
return;
|
|
366
|
+
this.sendResponse(message, { ok: true });
|
|
367
|
+
this.scheduleSessionCleanup(session.id, "ops_session_closed");
|
|
368
|
+
}
|
|
369
|
+
async handleSessionStatus(message, clientId) {
|
|
370
|
+
const session = this.getSessionForMessage(message, clientId);
|
|
371
|
+
if (!session)
|
|
372
|
+
return;
|
|
373
|
+
const tab = await this.tabs.getTab(session.tabId);
|
|
374
|
+
this.sendResponse(message, {
|
|
375
|
+
mode: "extension",
|
|
376
|
+
activeTargetId: session.activeTargetId || null,
|
|
377
|
+
url: tab?.url ?? undefined,
|
|
378
|
+
title: tab?.title ?? undefined,
|
|
379
|
+
leaseId: session.leaseId,
|
|
380
|
+
state: session.state
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async handleTargetsList(message, session) {
|
|
384
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
385
|
+
const includeUrls = payload.includeUrls === true;
|
|
386
|
+
const targets = await Promise.all(Array.from(session.targets.values()).map(async (target) => {
|
|
387
|
+
const tab = await this.tabs.getTab(target.tabId);
|
|
388
|
+
return {
|
|
389
|
+
targetId: target.targetId,
|
|
390
|
+
type: "page",
|
|
391
|
+
title: tab?.title ?? target.title,
|
|
392
|
+
url: includeUrls ? tab?.url ?? target.url : undefined
|
|
393
|
+
};
|
|
394
|
+
}));
|
|
395
|
+
this.sendResponse(message, { activeTargetId: session.activeTargetId || null, targets });
|
|
396
|
+
}
|
|
397
|
+
async handleTargetsUse(message, session) {
|
|
398
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
399
|
+
const targetId = typeof payload.targetId === "string" ? payload.targetId : null;
|
|
400
|
+
if (!targetId || !session.targets.has(targetId)) {
|
|
401
|
+
this.sendError(message, buildError("invalid_request", "Unknown targetId", false));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
session.activeTargetId = targetId;
|
|
405
|
+
const target = session.targets.get(targetId) ?? null;
|
|
406
|
+
if (target) {
|
|
407
|
+
await this.tabs.activateTab(target.tabId).catch(() => undefined);
|
|
408
|
+
}
|
|
409
|
+
const tab = target ? await this.tabs.getTab(target.tabId) : null;
|
|
410
|
+
this.sendResponse(message, {
|
|
411
|
+
activeTargetId: targetId,
|
|
412
|
+
url: tab?.url ?? target?.url,
|
|
413
|
+
title: tab?.title ?? target?.title
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
async handleTargetsNew(message, session) {
|
|
417
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
418
|
+
const url = typeof payload.url === "string" ? payload.url : undefined;
|
|
419
|
+
const tab = await this.tabs.createTab(url, true);
|
|
420
|
+
if (!tab?.id) {
|
|
421
|
+
this.sendError(message, buildError("execution_failed", "Target creation failed", false));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await this.tabs.waitForTabComplete(tab.id).catch(() => undefined);
|
|
425
|
+
try {
|
|
426
|
+
await this.cdp.attach(tab.id);
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
const detail = error instanceof Error ? error.message : "Debugger attach failed";
|
|
430
|
+
this.sendError(message, buildError("cdp_attach_failed", detail, false));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const target = this.sessions.addTarget(session.id, tab.id, { url: tab.url ?? undefined, title: tab.title ?? undefined });
|
|
434
|
+
session.activeTargetId = target.targetId;
|
|
435
|
+
this.sendResponse(message, { targetId: target.targetId });
|
|
436
|
+
}
|
|
437
|
+
async handleTargetsClose(message, session) {
|
|
438
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
439
|
+
const targetId = typeof payload.targetId === "string" ? payload.targetId : null;
|
|
440
|
+
if (!targetId) {
|
|
441
|
+
this.sendError(message, buildError("invalid_request", "Missing targetId", false));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const target = session.targets.get(targetId);
|
|
445
|
+
if (!target) {
|
|
446
|
+
this.sendError(message, buildError("invalid_request", "Unknown targetId", false));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.sessions.removeTarget(session.id, targetId);
|
|
450
|
+
await this.closeTabBestEffort(target.tabId);
|
|
451
|
+
if (target.targetId === session.targetId || session.targets.size === 0) {
|
|
452
|
+
this.sendResponse(message, { ok: true });
|
|
453
|
+
this.scheduleSessionCleanup(session.id, "ops_session_closed");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.sendResponse(message, { ok: true });
|
|
457
|
+
}
|
|
458
|
+
async handlePageOpen(message, session) {
|
|
459
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
460
|
+
const name = typeof payload.name === "string" ? payload.name : null;
|
|
461
|
+
if (!name) {
|
|
462
|
+
this.sendError(message, buildError("invalid_request", "Missing name", false));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const existingTargetId = this.sessions.getTargetIdByName(session.id, name);
|
|
466
|
+
if (existingTargetId) {
|
|
467
|
+
const target = session.targets.get(existingTargetId) ?? null;
|
|
468
|
+
this.sendResponse(message, { targetId: existingTargetId, created: false, url: target?.url, title: target?.title });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const url = typeof payload.url === "string" ? payload.url : undefined;
|
|
472
|
+
const tab = await this.tabs.createTab(url, true);
|
|
473
|
+
if (!tab?.id) {
|
|
474
|
+
this.sendError(message, buildError("execution_failed", "Target creation failed", false));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await this.tabs.waitForTabComplete(tab.id).catch(() => undefined);
|
|
478
|
+
try {
|
|
479
|
+
await this.cdp.attach(tab.id);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const detail = error instanceof Error ? error.message : "Debugger attach failed";
|
|
483
|
+
this.sendError(message, buildError("cdp_attach_failed", detail, false));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const target = this.sessions.addTarget(session.id, tab.id, { url: tab.url ?? undefined, title: tab.title ?? undefined });
|
|
487
|
+
this.sessions.setName(session.id, target.targetId, name);
|
|
488
|
+
session.activeTargetId = target.targetId;
|
|
489
|
+
this.sendResponse(message, { targetId: target.targetId, created: true, url: target.url, title: target.title });
|
|
490
|
+
}
|
|
491
|
+
async handlePageList(message, session) {
|
|
492
|
+
const pages = await Promise.all(this.sessions.listNamedTargets(session.id).map(async ({ name, targetId }) => {
|
|
493
|
+
const target = session.targets.get(targetId);
|
|
494
|
+
const tab = target ? await this.tabs.getTab(target.tabId) : null;
|
|
495
|
+
return {
|
|
496
|
+
name,
|
|
497
|
+
targetId,
|
|
498
|
+
url: tab?.url ?? target?.url,
|
|
499
|
+
title: tab?.title ?? target?.title
|
|
500
|
+
};
|
|
501
|
+
}));
|
|
502
|
+
this.sendResponse(message, { pages });
|
|
503
|
+
}
|
|
504
|
+
async handlePageClose(message, session) {
|
|
505
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
506
|
+
const name = typeof payload.name === "string" ? payload.name : null;
|
|
507
|
+
if (!name) {
|
|
508
|
+
this.sendError(message, buildError("invalid_request", "Missing name", false));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const targetId = this.sessions.getTargetIdByName(session.id, name);
|
|
512
|
+
if (!targetId) {
|
|
513
|
+
this.sendError(message, buildError("invalid_request", "Unknown page name", false));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const target = session.targets.get(targetId);
|
|
517
|
+
if (target) {
|
|
518
|
+
this.sessions.removeTarget(session.id, targetId);
|
|
519
|
+
await this.closeTabBestEffort(target.tabId);
|
|
520
|
+
if (target.targetId === session.targetId || session.targets.size === 0) {
|
|
521
|
+
this.sendResponse(message, { ok: true });
|
|
522
|
+
this.scheduleSessionCleanup(session.id, "ops_session_closed");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
this.sendResponse(message, { ok: true });
|
|
527
|
+
}
|
|
528
|
+
async handleGoto(message, session) {
|
|
529
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
530
|
+
const url = typeof payload.url === "string" ? payload.url : null;
|
|
531
|
+
if (!url) {
|
|
532
|
+
this.sendError(message, buildError("invalid_request", "Missing url", false));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const restriction = getRestrictionMessage(new URL(url));
|
|
537
|
+
if (restriction) {
|
|
538
|
+
this.sendError(message, buildError("restricted_url", restriction, false));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
this.sendError(message, buildError("invalid_request", "Invalid url", false));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const timeoutMs = typeof payload.timeoutMs === "number" ? payload.timeoutMs : 30000;
|
|
547
|
+
const start = Date.now();
|
|
548
|
+
const target = this.requireActiveTarget(session, message);
|
|
549
|
+
if (!target)
|
|
550
|
+
return;
|
|
551
|
+
await this.tabs.activateTab(target.tabId).catch(() => undefined);
|
|
552
|
+
const updated = await new Promise((resolve) => {
|
|
553
|
+
chrome.tabs.update(target.tabId, { url }, (tab) => {
|
|
554
|
+
resolve(tab ?? null);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
await this.tabs.waitForTabComplete(target.tabId, timeoutMs).catch(() => undefined);
|
|
558
|
+
const refreshed = await this.tabs.getTab(target.tabId);
|
|
559
|
+
const targetRecord = session.targets.get(target.targetId);
|
|
560
|
+
if (targetRecord) {
|
|
561
|
+
targetRecord.url = refreshed?.url ?? updated?.url ?? url;
|
|
562
|
+
targetRecord.title = refreshed?.title ?? updated?.title ?? targetRecord.title;
|
|
563
|
+
}
|
|
564
|
+
this.sendResponse(message, {
|
|
565
|
+
finalUrl: refreshed?.url ?? updated?.url ?? url,
|
|
566
|
+
status: undefined,
|
|
567
|
+
timingMs: Date.now() - start
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async handleWait(message, session) {
|
|
571
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
572
|
+
const timeoutMs = typeof payload.timeoutMs === "number" ? payload.timeoutMs : 30000;
|
|
573
|
+
const start = Date.now();
|
|
574
|
+
const target = this.requireActiveTarget(session, message);
|
|
575
|
+
if (!target)
|
|
576
|
+
return;
|
|
577
|
+
if (typeof payload.ref === "string") {
|
|
578
|
+
const state = payload.state === "visible" || payload.state === "hidden" ? payload.state : "attached";
|
|
579
|
+
const selector = this.resolveSelector(session, payload.ref, message);
|
|
580
|
+
if (!selector)
|
|
581
|
+
return;
|
|
582
|
+
try {
|
|
583
|
+
await this.waitForSelector(target.tabId, selector, state, timeoutMs);
|
|
584
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
this.sendError(message, buildError("timeout", error instanceof Error ? error.message : "Timeout", true));
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
await this.tabs.waitForTabComplete(target.tabId, timeoutMs);
|
|
593
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
this.sendError(message, buildError("timeout", error instanceof Error ? error.message : "Timeout", true));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async handleSnapshot(message, session) {
|
|
600
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
601
|
+
const mode = payload.mode === "actionables" ? "actionables" : "outline";
|
|
602
|
+
const maxChars = typeof payload.maxChars === "number" ? payload.maxChars : 16000;
|
|
603
|
+
const cursor = typeof payload.cursor === "string" ? payload.cursor : undefined;
|
|
604
|
+
const maxNodes = typeof payload.maxNodes === "number" ? payload.maxNodes : undefined;
|
|
605
|
+
const target = this.requireActiveTarget(session, message);
|
|
606
|
+
if (!target)
|
|
607
|
+
return;
|
|
608
|
+
const start = Date.now();
|
|
609
|
+
const entriesData = await buildSnapshot((method, params) => this.cdp.sendCommand({ tabId: target.tabId }, method, params), mode, true, maxNodes);
|
|
610
|
+
const snapshot = session.refStore.setSnapshot(target.targetId, entriesData.entries);
|
|
611
|
+
const startIndex = parseCursor(cursor);
|
|
612
|
+
const { content, truncated, nextCursor } = paginate(entriesData.lines, startIndex, maxChars);
|
|
613
|
+
const contentBytes = this.encoder.encode(content).length;
|
|
614
|
+
if (contentBytes > MAX_SNAPSHOT_BYTES) {
|
|
615
|
+
this.sendError(message, buildError("snapshot_too_large", "Snapshot exceeded max size.", false, {
|
|
616
|
+
maxSnapshotBytes: MAX_SNAPSHOT_BYTES,
|
|
617
|
+
actualBytes: contentBytes
|
|
618
|
+
}));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const tab = await this.tabs.getTab(target.tabId);
|
|
622
|
+
this.sendResponse(message, {
|
|
623
|
+
snapshotId: snapshot.snapshotId,
|
|
624
|
+
url: tab?.url ?? undefined,
|
|
625
|
+
title: tab?.title ?? undefined,
|
|
626
|
+
content,
|
|
627
|
+
truncated,
|
|
628
|
+
nextCursor,
|
|
629
|
+
refCount: snapshot.count,
|
|
630
|
+
timingMs: Date.now() - start,
|
|
631
|
+
warnings: entriesData.warnings
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async handleClick(message, session) {
|
|
635
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
636
|
+
if (!selector)
|
|
637
|
+
return;
|
|
638
|
+
const target = this.requireActiveTarget(session, message);
|
|
639
|
+
if (!target)
|
|
640
|
+
return;
|
|
641
|
+
const start = Date.now();
|
|
642
|
+
const before = await this.tabs.getTab(target.tabId);
|
|
643
|
+
await this.dom.click(target.tabId, selector);
|
|
644
|
+
const after = await this.tabs.getTab(target.tabId);
|
|
645
|
+
const navigated = Boolean(before?.url && after?.url && before.url !== after.url);
|
|
646
|
+
this.sendResponse(message, { timingMs: Date.now() - start, navigated });
|
|
647
|
+
}
|
|
648
|
+
async handleHover(message, session) {
|
|
649
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
650
|
+
if (!selector)
|
|
651
|
+
return;
|
|
652
|
+
const target = this.requireActiveTarget(session, message);
|
|
653
|
+
if (!target)
|
|
654
|
+
return;
|
|
655
|
+
const start = Date.now();
|
|
656
|
+
await this.dom.hover(target.tabId, selector);
|
|
657
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
658
|
+
}
|
|
659
|
+
async handlePress(message, session) {
|
|
660
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
661
|
+
const key = typeof payload.key === "string" ? payload.key : null;
|
|
662
|
+
if (!key) {
|
|
663
|
+
this.sendError(message, buildError("invalid_request", "Missing key", false));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const target = this.requireActiveTarget(session, message);
|
|
667
|
+
if (!target)
|
|
668
|
+
return;
|
|
669
|
+
const selector = typeof payload.ref === "string" ? this.resolveSelector(session, payload.ref, message) : null;
|
|
670
|
+
if (payload.ref && !selector)
|
|
671
|
+
return;
|
|
672
|
+
const start = Date.now();
|
|
673
|
+
await this.dom.press(target.tabId, selector, key);
|
|
674
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
675
|
+
}
|
|
676
|
+
async handleCheck(message, session, checked) {
|
|
677
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
678
|
+
if (!selector)
|
|
679
|
+
return;
|
|
680
|
+
const target = this.requireActiveTarget(session, message);
|
|
681
|
+
if (!target)
|
|
682
|
+
return;
|
|
683
|
+
const start = Date.now();
|
|
684
|
+
await this.dom.setChecked(target.tabId, selector, checked);
|
|
685
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
686
|
+
}
|
|
687
|
+
async handleType(message, session) {
|
|
688
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
689
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
690
|
+
const text = typeof payload.text === "string" ? payload.text : null;
|
|
691
|
+
if (!ref || text === null) {
|
|
692
|
+
this.sendError(message, buildError("invalid_request", "Missing ref or text", false));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
696
|
+
if (!selector)
|
|
697
|
+
return;
|
|
698
|
+
const target = this.requireActiveTarget(session, message);
|
|
699
|
+
if (!target)
|
|
700
|
+
return;
|
|
701
|
+
const start = Date.now();
|
|
702
|
+
await this.dom.type(target.tabId, selector, text, payload.clear === true, payload.submit === true);
|
|
703
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
704
|
+
}
|
|
705
|
+
async handleSelect(message, session) {
|
|
706
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
707
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
708
|
+
const values = Array.isArray(payload.values) ? payload.values.filter((val) => typeof val === "string") : null;
|
|
709
|
+
if (!ref || !values) {
|
|
710
|
+
this.sendError(message, buildError("invalid_request", "Missing ref or values", false));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
714
|
+
if (!selector)
|
|
715
|
+
return;
|
|
716
|
+
const target = this.requireActiveTarget(session, message);
|
|
717
|
+
if (!target)
|
|
718
|
+
return;
|
|
719
|
+
await this.dom.select(target.tabId, selector, values);
|
|
720
|
+
this.sendResponse(message, {});
|
|
721
|
+
}
|
|
722
|
+
async handleScroll(message, session) {
|
|
723
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
724
|
+
const dy = typeof payload.dy === "number" ? payload.dy : 0;
|
|
725
|
+
const ref = typeof payload.ref === "string" ? payload.ref : undefined;
|
|
726
|
+
const selector = ref ? this.resolveSelector(session, ref, message) ?? undefined : undefined;
|
|
727
|
+
if (ref && !selector)
|
|
728
|
+
return;
|
|
729
|
+
const target = this.requireActiveTarget(session, message);
|
|
730
|
+
if (!target)
|
|
731
|
+
return;
|
|
732
|
+
await this.dom.scroll(target.tabId, dy, selector);
|
|
733
|
+
this.sendResponse(message, {});
|
|
734
|
+
}
|
|
735
|
+
async handleScrollIntoView(message, session) {
|
|
736
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
737
|
+
if (!selector)
|
|
738
|
+
return;
|
|
739
|
+
const target = this.requireActiveTarget(session, message);
|
|
740
|
+
if (!target)
|
|
741
|
+
return;
|
|
742
|
+
const start = Date.now();
|
|
743
|
+
await this.dom.scrollIntoView(target.tabId, selector);
|
|
744
|
+
this.sendResponse(message, { timingMs: Date.now() - start });
|
|
745
|
+
}
|
|
746
|
+
async handleDomGetHtml(message, session) {
|
|
747
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
748
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
749
|
+
const maxChars = typeof payload.maxChars === "number" ? payload.maxChars : 8000;
|
|
750
|
+
if (!ref) {
|
|
751
|
+
this.sendError(message, buildError("invalid_request", "Missing ref", false));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
755
|
+
if (!selector)
|
|
756
|
+
return;
|
|
757
|
+
const target = this.requireActiveTarget(session, message);
|
|
758
|
+
if (!target)
|
|
759
|
+
return;
|
|
760
|
+
const html = await this.dom.getOuterHtml(target.tabId, selector);
|
|
761
|
+
const truncated = html.length > maxChars;
|
|
762
|
+
const outerHTML = truncated ? html.slice(0, maxChars) : html;
|
|
763
|
+
this.sendResponse(message, { outerHTML, truncated });
|
|
764
|
+
}
|
|
765
|
+
async handleDomGetText(message, session) {
|
|
766
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
767
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
768
|
+
const maxChars = typeof payload.maxChars === "number" ? payload.maxChars : 8000;
|
|
769
|
+
if (!ref) {
|
|
770
|
+
this.sendError(message, buildError("invalid_request", "Missing ref", false));
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
774
|
+
if (!selector)
|
|
775
|
+
return;
|
|
776
|
+
const target = this.requireActiveTarget(session, message);
|
|
777
|
+
if (!target)
|
|
778
|
+
return;
|
|
779
|
+
const text = await this.dom.getInnerText(target.tabId, selector);
|
|
780
|
+
const truncated = text.length > maxChars;
|
|
781
|
+
this.sendResponse(message, { text: truncated ? text.slice(0, maxChars) : text, truncated });
|
|
782
|
+
}
|
|
783
|
+
async handleDomGetAttr(message, session) {
|
|
784
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
785
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
786
|
+
const name = typeof payload.name === "string" ? payload.name : null;
|
|
787
|
+
if (!ref || !name) {
|
|
788
|
+
this.sendError(message, buildError("invalid_request", "Missing ref or name", false));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
792
|
+
if (!selector)
|
|
793
|
+
return;
|
|
794
|
+
const target = this.requireActiveTarget(session, message);
|
|
795
|
+
if (!target)
|
|
796
|
+
return;
|
|
797
|
+
const value = await this.dom.getAttr(target.tabId, selector, name);
|
|
798
|
+
this.sendResponse(message, { value });
|
|
799
|
+
}
|
|
800
|
+
async handleDomGetValue(message, session) {
|
|
801
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
802
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
803
|
+
if (!ref) {
|
|
804
|
+
this.sendError(message, buildError("invalid_request", "Missing ref", false));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
808
|
+
if (!selector)
|
|
809
|
+
return;
|
|
810
|
+
const target = this.requireActiveTarget(session, message);
|
|
811
|
+
if (!target)
|
|
812
|
+
return;
|
|
813
|
+
const value = await this.dom.getValue(target.tabId, selector);
|
|
814
|
+
this.sendResponse(message, { value });
|
|
815
|
+
}
|
|
816
|
+
async handleDomIsVisible(message, session) {
|
|
817
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
818
|
+
if (!selector)
|
|
819
|
+
return;
|
|
820
|
+
const target = this.requireActiveTarget(session, message);
|
|
821
|
+
if (!target)
|
|
822
|
+
return;
|
|
823
|
+
const visible = await this.dom.isVisible(target.tabId, selector);
|
|
824
|
+
this.sendResponse(message, { value: visible });
|
|
825
|
+
}
|
|
826
|
+
async handleDomIsEnabled(message, session) {
|
|
827
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
828
|
+
if (!selector)
|
|
829
|
+
return;
|
|
830
|
+
const target = this.requireActiveTarget(session, message);
|
|
831
|
+
if (!target)
|
|
832
|
+
return;
|
|
833
|
+
const enabled = await this.dom.isEnabled(target.tabId, selector);
|
|
834
|
+
this.sendResponse(message, { value: enabled });
|
|
835
|
+
}
|
|
836
|
+
async handleDomIsChecked(message, session) {
|
|
837
|
+
const selector = this.resolveSelector(session, message.payload, message);
|
|
838
|
+
if (!selector)
|
|
839
|
+
return;
|
|
840
|
+
const target = this.requireActiveTarget(session, message);
|
|
841
|
+
if (!target)
|
|
842
|
+
return;
|
|
843
|
+
const checked = await this.dom.isChecked(target.tabId, selector);
|
|
844
|
+
this.sendResponse(message, { value: checked });
|
|
845
|
+
}
|
|
846
|
+
async handleClonePage(message, session) {
|
|
847
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
848
|
+
const target = this.requireActiveTarget(session, message);
|
|
849
|
+
if (!target)
|
|
850
|
+
return;
|
|
851
|
+
const capture = await this.dom.captureDom(target.tabId, "body", {
|
|
852
|
+
sanitize: payload.sanitize !== false,
|
|
853
|
+
maxNodes: typeof payload.maxNodes === "number" ? payload.maxNodes : undefined,
|
|
854
|
+
inlineStyles: payload.inlineStyles !== false,
|
|
855
|
+
styleAllowlist: Array.isArray(payload.styleAllowlist) ? payload.styleAllowlist.filter((item) => typeof item === "string") : [],
|
|
856
|
+
skipStyleValues: Array.isArray(payload.skipStyleValues) ? payload.skipStyleValues.filter((item) => typeof item === "string") : []
|
|
857
|
+
});
|
|
858
|
+
this.sendResponse(message, { capture });
|
|
859
|
+
}
|
|
860
|
+
async handleCloneComponent(message, session) {
|
|
861
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
862
|
+
const ref = typeof payload.ref === "string" ? payload.ref : null;
|
|
863
|
+
if (!ref) {
|
|
864
|
+
this.sendError(message, buildError("invalid_request", "Missing ref", false));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const selector = this.resolveSelector(session, ref, message);
|
|
868
|
+
if (!selector)
|
|
869
|
+
return;
|
|
870
|
+
const target = this.requireActiveTarget(session, message);
|
|
871
|
+
if (!target)
|
|
872
|
+
return;
|
|
873
|
+
const capture = await this.dom.captureDom(target.tabId, selector, {
|
|
874
|
+
sanitize: payload.sanitize !== false,
|
|
875
|
+
maxNodes: typeof payload.maxNodes === "number" ? payload.maxNodes : undefined,
|
|
876
|
+
inlineStyles: payload.inlineStyles !== false,
|
|
877
|
+
styleAllowlist: Array.isArray(payload.styleAllowlist) ? payload.styleAllowlist.filter((item) => typeof item === "string") : [],
|
|
878
|
+
skipStyleValues: Array.isArray(payload.skipStyleValues) ? payload.skipStyleValues.filter((item) => typeof item === "string") : []
|
|
879
|
+
});
|
|
880
|
+
this.sendResponse(message, { capture });
|
|
881
|
+
}
|
|
882
|
+
async handlePerf(message, session) {
|
|
883
|
+
const target = this.requireActiveTarget(session, message);
|
|
884
|
+
if (!target)
|
|
885
|
+
return;
|
|
886
|
+
const result = await this.cdp.sendCommand({ tabId: target.tabId }, "Performance.getMetrics", {});
|
|
887
|
+
this.sendResponse(message, { metrics: Array.isArray(result.metrics) ? result.metrics : [] });
|
|
888
|
+
}
|
|
889
|
+
async handleScreenshot(message, session) {
|
|
890
|
+
const target = this.requireActiveTarget(session, message);
|
|
891
|
+
if (!target)
|
|
892
|
+
return;
|
|
893
|
+
try {
|
|
894
|
+
const result = await withTimeout(this.cdp.sendCommand({ tabId: target.tabId }, "Page.captureScreenshot", { format: "png" }), SCREENSHOT_TIMEOUT_MS, "Ops screenshot timed out");
|
|
895
|
+
if (result?.data) {
|
|
896
|
+
this.sendResponse(message, { base64: result.data });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
logError("ops.screenshot", error, { code: "screenshot_failed" });
|
|
902
|
+
}
|
|
903
|
+
const fallback = await this.captureVisibleTab(target.tabId);
|
|
904
|
+
if (fallback) {
|
|
905
|
+
this.sendResponse(message, { base64: fallback, warning: "visible_only_fallback" });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
this.sendError(message, buildError("execution_failed", "Screenshot failed", false));
|
|
909
|
+
}
|
|
910
|
+
async handleConsolePoll(message, session) {
|
|
911
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
912
|
+
const sinceSeq = typeof payload.sinceSeq === "number" ? payload.sinceSeq : 0;
|
|
913
|
+
const max = typeof payload.max === "number" ? payload.max : 50;
|
|
914
|
+
const events = session.consoleEvents.filter((event) => event.seq > sinceSeq).slice(0, max);
|
|
915
|
+
const lastEvent = events.at(-1);
|
|
916
|
+
const nextSeq = lastEvent ? lastEvent.seq : sinceSeq;
|
|
917
|
+
this.sendResponse(message, { events, nextSeq });
|
|
918
|
+
}
|
|
919
|
+
async handleNetworkPoll(message, session) {
|
|
920
|
+
const payload = isRecord(message.payload) ? message.payload : {};
|
|
921
|
+
const sinceSeq = typeof payload.sinceSeq === "number" ? payload.sinceSeq : 0;
|
|
922
|
+
const max = typeof payload.max === "number" ? payload.max : 50;
|
|
923
|
+
const events = session.networkEvents.filter((event) => event.seq > sinceSeq).slice(0, max);
|
|
924
|
+
const lastEvent = events.at(-1);
|
|
925
|
+
const nextSeq = lastEvent ? lastEvent.seq : sinceSeq;
|
|
926
|
+
this.sendResponse(message, { events, nextSeq });
|
|
927
|
+
}
|
|
928
|
+
async enableSessionDomains(session) {
|
|
929
|
+
try {
|
|
930
|
+
await this.cdp.sendCommand({ tabId: session.tabId }, "Runtime.enable", {});
|
|
931
|
+
await this.cdp.sendCommand({ tabId: session.tabId }, "Network.enable", {});
|
|
932
|
+
await this.cdp.sendCommand({ tabId: session.tabId }, "Performance.enable", {});
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
logError("ops.enable_domains", error, { code: "enable_domains_failed" });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
async withSession(message, clientId, handler) {
|
|
939
|
+
const session = this.getSessionForMessage(message, clientId);
|
|
940
|
+
if (!session)
|
|
941
|
+
return;
|
|
942
|
+
session.queue = session.queue.then(() => handler(session), () => handler(session));
|
|
943
|
+
await session.queue;
|
|
944
|
+
}
|
|
945
|
+
getSessionForMessage(message, clientId) {
|
|
946
|
+
const opsSessionId = message.opsSessionId;
|
|
947
|
+
if (!opsSessionId) {
|
|
948
|
+
this.sendError(message, buildError("invalid_request", "Missing opsSessionId", false));
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
const session = this.sessions.get(opsSessionId);
|
|
952
|
+
if (!session) {
|
|
953
|
+
this.sendError(message, buildError("invalid_session", "Unknown ops session", false));
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
if (session.state === "closing") {
|
|
957
|
+
const leaseId = typeof message.leaseId === "string" ? message.leaseId : "";
|
|
958
|
+
if (leaseId && leaseId === session.leaseId) {
|
|
959
|
+
this.reclaimSession(session, clientId);
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
this.sendError(message, buildError("not_owner", "Client does not own session", false));
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (session.ownerClientId !== clientId) {
|
|
967
|
+
this.sendError(message, buildError("not_owner", "Client does not own session", false));
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (typeof message.leaseId !== "string" || message.leaseId !== session.leaseId) {
|
|
971
|
+
this.sendError(message, buildError("not_owner", "Lease does not match session owner", false));
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
session.lastUsedAt = Date.now();
|
|
975
|
+
return session;
|
|
976
|
+
}
|
|
977
|
+
requireActiveTarget(session, message) {
|
|
978
|
+
const targetId = session.activeTargetId;
|
|
979
|
+
if (!targetId) {
|
|
980
|
+
this.sendError(message, buildError("invalid_request", "No active target", false));
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
const target = session.targets.get(targetId);
|
|
984
|
+
if (!target) {
|
|
985
|
+
this.sendError(message, buildError("invalid_request", "Active target missing", false));
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
if (target.url) {
|
|
989
|
+
const restriction = isRestrictedUrl(target.url);
|
|
990
|
+
if (restriction.restricted) {
|
|
991
|
+
this.sendError(message, buildError("restricted_url", restriction.message ?? "Restricted tab.", false));
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return { tabId: target.tabId, targetId: target.targetId };
|
|
996
|
+
}
|
|
997
|
+
resolveSelector(session, refOrPayload, message) {
|
|
998
|
+
const ref = typeof refOrPayload === "string"
|
|
999
|
+
? refOrPayload
|
|
1000
|
+
: (isRecord(refOrPayload) && typeof refOrPayload.ref === "string" ? refOrPayload.ref : null);
|
|
1001
|
+
if (!ref) {
|
|
1002
|
+
this.sendError(message, buildError("invalid_request", "Missing ref", false));
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
const entry = session.refStore.resolve(session.activeTargetId, ref);
|
|
1006
|
+
if (!entry) {
|
|
1007
|
+
this.sendError(message, buildError("invalid_request", `Unknown ref: ${ref}`, false));
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
return entry.selector;
|
|
1011
|
+
}
|
|
1012
|
+
async waitForSelector(tabId, selector, state, timeoutMs) {
|
|
1013
|
+
const start = Date.now();
|
|
1014
|
+
while (Date.now() - start < timeoutMs) {
|
|
1015
|
+
const snapshot = await this.dom.getSelectorState(tabId, selector);
|
|
1016
|
+
if (state === "attached" && snapshot.attached)
|
|
1017
|
+
return;
|
|
1018
|
+
if (state === "visible" && snapshot.visible)
|
|
1019
|
+
return;
|
|
1020
|
+
if (state === "hidden" && (!snapshot.attached || !snapshot.visible))
|
|
1021
|
+
return;
|
|
1022
|
+
await delay(200);
|
|
1023
|
+
}
|
|
1024
|
+
throw new Error("Wait for selector timed out");
|
|
1025
|
+
}
|
|
1026
|
+
cleanupSession(session, event) {
|
|
1027
|
+
this.clearClosingTimer(session.id);
|
|
1028
|
+
this.sessions.delete(session.id);
|
|
1029
|
+
for (const target of session.targets.values()) {
|
|
1030
|
+
void this.cdp.detachTab(target.tabId).catch(() => undefined);
|
|
1031
|
+
}
|
|
1032
|
+
this.sendEvent({
|
|
1033
|
+
type: "ops_event",
|
|
1034
|
+
clientId: session.ownerClientId,
|
|
1035
|
+
opsSessionId: session.id,
|
|
1036
|
+
event,
|
|
1037
|
+
payload: { tabId: session.tabId, targetId: session.targetId }
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
handleClosedTarget(tabId, event) {
|
|
1041
|
+
const session = this.sessions.getByTabId(tabId);
|
|
1042
|
+
if (!session)
|
|
1043
|
+
return;
|
|
1044
|
+
const targetId = this.sessions.getTargetIdByTabId(session.id, tabId);
|
|
1045
|
+
if (!targetId)
|
|
1046
|
+
return;
|
|
1047
|
+
const removedTarget = this.sessions.removeTarget(session.id, targetId);
|
|
1048
|
+
if (!removedTarget)
|
|
1049
|
+
return;
|
|
1050
|
+
if (targetId === session.targetId || session.targets.size === 0) {
|
|
1051
|
+
this.cleanupSession(session, event);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
handleDebuggerDetachForTab(tabId) {
|
|
1055
|
+
const session = this.sessions.getByTabId(tabId);
|
|
1056
|
+
if (!session)
|
|
1057
|
+
return;
|
|
1058
|
+
if (tabId === session.tabId) {
|
|
1059
|
+
// Root tab detach can be transient during child-target shutdown; tab removal handler owns root teardown.
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
this.handleClosedTarget(tabId, "ops_session_closed");
|
|
1063
|
+
}
|
|
1064
|
+
async closeTabBestEffort(tabId) {
|
|
1065
|
+
try {
|
|
1066
|
+
await withTimeout(this.tabs.closeTab(tabId), TAB_CLOSE_TIMEOUT_MS, "Ops tab close timed out");
|
|
1067
|
+
}
|
|
1068
|
+
catch (error) {
|
|
1069
|
+
logError("ops.close_tab", error, {
|
|
1070
|
+
code: "close_tab_failed",
|
|
1071
|
+
extra: { tabId }
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
scheduleSessionCleanup(sessionId, event) {
|
|
1076
|
+
setTimeout(() => {
|
|
1077
|
+
const session = this.sessions.get(sessionId);
|
|
1078
|
+
if (!session) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
this.cleanupSession(session, event);
|
|
1082
|
+
}, 0);
|
|
1083
|
+
}
|
|
1084
|
+
sendResponse(message, payload) {
|
|
1085
|
+
const response = {
|
|
1086
|
+
type: "ops_response",
|
|
1087
|
+
requestId: message.requestId,
|
|
1088
|
+
clientId: message.clientId,
|
|
1089
|
+
opsSessionId: message.opsSessionId,
|
|
1090
|
+
payload
|
|
1091
|
+
};
|
|
1092
|
+
const serialized = JSON.stringify(payload ?? null);
|
|
1093
|
+
if (this.encoder.encode(serialized).length <= MAX_OPS_PAYLOAD_BYTES) {
|
|
1094
|
+
this.sendEnvelope(response);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const payloadId = createId();
|
|
1098
|
+
const chunkSize = Math.max(1024, MAX_OPS_PAYLOAD_BYTES - 1024);
|
|
1099
|
+
const chunks = [];
|
|
1100
|
+
for (let i = 0; i < serialized.length; i += chunkSize) {
|
|
1101
|
+
chunks.push(serialized.slice(i, i + chunkSize));
|
|
1102
|
+
}
|
|
1103
|
+
this.sendEnvelope({
|
|
1104
|
+
type: "ops_response",
|
|
1105
|
+
requestId: message.requestId,
|
|
1106
|
+
clientId: message.clientId,
|
|
1107
|
+
opsSessionId: message.opsSessionId,
|
|
1108
|
+
chunked: true,
|
|
1109
|
+
payloadId,
|
|
1110
|
+
totalChunks: chunks.length
|
|
1111
|
+
});
|
|
1112
|
+
chunks.forEach((data, index) => {
|
|
1113
|
+
const chunk = {
|
|
1114
|
+
type: "ops_chunk",
|
|
1115
|
+
requestId: message.requestId,
|
|
1116
|
+
clientId: message.clientId,
|
|
1117
|
+
opsSessionId: message.opsSessionId,
|
|
1118
|
+
payloadId,
|
|
1119
|
+
chunkIndex: index,
|
|
1120
|
+
totalChunks: chunks.length,
|
|
1121
|
+
data
|
|
1122
|
+
};
|
|
1123
|
+
this.sendEnvelope(chunk);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
sendError(message, error) {
|
|
1127
|
+
const payload = {
|
|
1128
|
+
type: "ops_error",
|
|
1129
|
+
requestId: message.requestId,
|
|
1130
|
+
clientId: message.clientId,
|
|
1131
|
+
opsSessionId: message.opsSessionId,
|
|
1132
|
+
error
|
|
1133
|
+
};
|
|
1134
|
+
this.sendEnvelope(payload);
|
|
1135
|
+
}
|
|
1136
|
+
sendEvent(event) {
|
|
1137
|
+
this.sendEnvelope(event);
|
|
1138
|
+
}
|
|
1139
|
+
markSessionClosing(session, reason) {
|
|
1140
|
+
if (session.state === "closing")
|
|
1141
|
+
return;
|
|
1142
|
+
session.state = "closing";
|
|
1143
|
+
session.closingReason = reason;
|
|
1144
|
+
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
1145
|
+
const timeoutId = setTimeout(() => {
|
|
1146
|
+
this.closingTimers.delete(session.id);
|
|
1147
|
+
const current = this.sessions.get(session.id);
|
|
1148
|
+
if (current && current.state === "closing") {
|
|
1149
|
+
this.cleanupSession(current, "ops_session_expired");
|
|
1150
|
+
}
|
|
1151
|
+
}, SESSION_TTL_MS);
|
|
1152
|
+
this.closingTimers.set(session.id, timeoutId);
|
|
1153
|
+
}
|
|
1154
|
+
reclaimSession(session, clientId) {
|
|
1155
|
+
session.ownerClientId = clientId;
|
|
1156
|
+
session.state = "active";
|
|
1157
|
+
session.expiresAt = undefined;
|
|
1158
|
+
session.closingReason = undefined;
|
|
1159
|
+
this.clearClosingTimer(session.id);
|
|
1160
|
+
}
|
|
1161
|
+
clearClosingTimer(sessionId) {
|
|
1162
|
+
const timer = this.closingTimers.get(sessionId);
|
|
1163
|
+
if (timer) {
|
|
1164
|
+
clearTimeout(timer);
|
|
1165
|
+
this.closingTimers.delete(sessionId);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async captureVisibleTab(tabId) {
|
|
1169
|
+
const tab = await this.tabs.getTab(tabId);
|
|
1170
|
+
const windowId = tab?.windowId ?? chrome.windows.WINDOW_ID_CURRENT;
|
|
1171
|
+
return await new Promise((resolve) => {
|
|
1172
|
+
chrome.tabs.captureVisibleTab(windowId, { format: "png" }, (dataUrl) => {
|
|
1173
|
+
if (chrome.runtime.lastError) {
|
|
1174
|
+
resolve(null);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (!dataUrl) {
|
|
1178
|
+
resolve(null);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const match = dataUrl.match(/^data:image\/png;base64,(.+)$/);
|
|
1182
|
+
const base64 = match?.[1] ?? null;
|
|
1183
|
+
resolve(base64);
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const buildError = (code, message, retryable, details) => ({
|
|
1189
|
+
code,
|
|
1190
|
+
message,
|
|
1191
|
+
retryable,
|
|
1192
|
+
details
|
|
1193
|
+
});
|
|
1194
|
+
const isRecord = (value) => {
|
|
1195
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1196
|
+
};
|
|
1197
|
+
const parseCursor = (cursor) => {
|
|
1198
|
+
if (!cursor)
|
|
1199
|
+
return 0;
|
|
1200
|
+
const value = Number(cursor);
|
|
1201
|
+
if (!Number.isFinite(value) || value < 0)
|
|
1202
|
+
return 0;
|
|
1203
|
+
return Math.floor(value);
|
|
1204
|
+
};
|
|
1205
|
+
const paginate = (lines, startIndex, maxChars) => {
|
|
1206
|
+
let total = 0;
|
|
1207
|
+
const parts = [];
|
|
1208
|
+
let idx = startIndex;
|
|
1209
|
+
while (idx < lines.length) {
|
|
1210
|
+
const line = lines[idx];
|
|
1211
|
+
if (line === undefined) {
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
if (total + line.length + 1 > maxChars && parts.length > 0) {
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
parts.push(line);
|
|
1218
|
+
total += line.length + 1;
|
|
1219
|
+
idx += 1;
|
|
1220
|
+
}
|
|
1221
|
+
const truncated = idx < lines.length;
|
|
1222
|
+
const nextCursor = truncated ? String(idx) : undefined;
|
|
1223
|
+
return {
|
|
1224
|
+
content: parts.join("\n"),
|
|
1225
|
+
truncated,
|
|
1226
|
+
nextCursor
|
|
1227
|
+
};
|
|
1228
|
+
};
|
|
1229
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1230
|
+
const withTimeout = async (promise, timeoutMs, message) => {
|
|
1231
|
+
return await new Promise((resolve, reject) => {
|
|
1232
|
+
const timeoutId = setTimeout(() => {
|
|
1233
|
+
reject(new Error(message));
|
|
1234
|
+
}, timeoutMs);
|
|
1235
|
+
promise.then((value) => {
|
|
1236
|
+
clearTimeout(timeoutId);
|
|
1237
|
+
resolve(value);
|
|
1238
|
+
}).catch((error) => {
|
|
1239
|
+
clearTimeout(timeoutId);
|
|
1240
|
+
reject(error);
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
};
|
|
1244
|
+
const createId = () => {
|
|
1245
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1246
|
+
return crypto.randomUUID();
|
|
1247
|
+
}
|
|
1248
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1249
|
+
};
|