unbrowse 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/cli.js +455 -96
  2. package/dist/index.js +2 -6
  3. package/dist/mcp.js +695 -46
  4. package/dist/server.js +25811 -0
  5. package/package.json +1 -2
  6. package/vendor/kuri/darwin-arm64/kuri +0 -0
  7. package/vendor/kuri/darwin-x64/kuri +0 -0
  8. package/vendor/kuri/linux-arm64/kuri +0 -0
  9. package/vendor/kuri/linux-x64/kuri +0 -0
  10. package/vendor/kuri/manifest.json +7 -10
  11. package/runtime-src/agent-outcome.ts +0 -166
  12. package/runtime-src/analytics-session.ts +0 -55
  13. package/runtime-src/api/browse-index.ts +0 -317
  14. package/runtime-src/api/browse-session.ts +0 -572
  15. package/runtime-src/api/browse-submit-prereqs.ts +0 -48
  16. package/runtime-src/api/browse-submit.ts +0 -1184
  17. package/runtime-src/api/routes.ts +0 -1823
  18. package/runtime-src/auth/browser-cookies.ts +0 -423
  19. package/runtime-src/auth/index.ts +0 -535
  20. package/runtime-src/auth/runtime.ts +0 -116
  21. package/runtime-src/browser/index.ts +0 -659
  22. package/runtime-src/browser/types.ts +0 -41
  23. package/runtime-src/build-info.generated.ts +0 -6
  24. package/runtime-src/capture/index.ts +0 -1794
  25. package/runtime-src/capture/prefetch.ts +0 -95
  26. package/runtime-src/capture/rsc.ts +0 -45
  27. package/runtime-src/cli/shortcuts.ts +0 -273
  28. package/runtime-src/cli.ts +0 -1572
  29. package/runtime-src/client/graph-client.ts +0 -100
  30. package/runtime-src/client/index.ts +0 -1425
  31. package/runtime-src/debug-trace.ts +0 -18
  32. package/runtime-src/domain.ts +0 -38
  33. package/runtime-src/execution/index.ts +0 -3397
  34. package/runtime-src/execution/retry.ts +0 -46
  35. package/runtime-src/execution/robots.ts +0 -167
  36. package/runtime-src/execution/search-forms.ts +0 -188
  37. package/runtime-src/extraction/index.ts +0 -1507
  38. package/runtime-src/foundry/publish-bundle.ts +0 -392
  39. package/runtime-src/graph/agent-augment.ts +0 -315
  40. package/runtime-src/graph/index.ts +0 -1524
  41. package/runtime-src/graph/local-fixtures.ts +0 -393
  42. package/runtime-src/graph/local-harness.ts +0 -646
  43. package/runtime-src/graph/planner.ts +0 -411
  44. package/runtime-src/graph/session.ts +0 -294
  45. package/runtime-src/graph/trace-store.ts +0 -136
  46. package/runtime-src/index.ts +0 -24
  47. package/runtime-src/indexer/index.ts +0 -465
  48. package/runtime-src/intent-match.ts +0 -1515
  49. package/runtime-src/kuri/client.ts +0 -1839
  50. package/runtime-src/logger.ts +0 -30
  51. package/runtime-src/marketplace/index.ts +0 -103
  52. package/runtime-src/mcp.ts +0 -1747
  53. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  54. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  55. package/runtime-src/orchestrator/dag-feedback.ts +0 -257
  56. package/runtime-src/orchestrator/first-pass-action.ts +0 -403
  57. package/runtime-src/orchestrator/index.ts +0 -4480
  58. package/runtime-src/orchestrator/passive-publish.ts +0 -187
  59. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  60. package/runtime-src/payments/cascade.ts +0 -137
  61. package/runtime-src/payments/index.ts +0 -270
  62. package/runtime-src/payments/lobster-pay.ts +0 -182
  63. package/runtime-src/payments/wallet.ts +0 -98
  64. package/runtime-src/publish/review-context.ts +0 -93
  65. package/runtime-src/publish/sanitize.ts +0 -197
  66. package/runtime-src/publish/schema-review.ts +0 -192
  67. package/runtime-src/publish-admission.ts +0 -388
  68. package/runtime-src/ratelimit/index.ts +0 -23
  69. package/runtime-src/reverse-engineer/bundle-scanner.ts +0 -127
  70. package/runtime-src/reverse-engineer/description-prompt.ts +0 -213
  71. package/runtime-src/reverse-engineer/index.ts +0 -1551
  72. package/runtime-src/router.ts +0 -17
  73. package/runtime-src/routing-telemetry.ts +0 -395
  74. package/runtime-src/runtime/browser-access.ts +0 -11
  75. package/runtime-src/runtime/browser-auth.ts +0 -12
  76. package/runtime-src/runtime/browser-host.ts +0 -48
  77. package/runtime-src/runtime/lifecycle.ts +0 -17
  78. package/runtime-src/runtime/local-server.ts +0 -311
  79. package/runtime-src/runtime/paths.ts +0 -99
  80. package/runtime-src/runtime/setup.ts +0 -251
  81. package/runtime-src/runtime/supervisor.ts +0 -69
  82. package/runtime-src/runtime/update-hints.ts +0 -351
  83. package/runtime-src/server.ts +0 -100
  84. package/runtime-src/session-logs.ts +0 -142
  85. package/runtime-src/settings.ts +0 -221
  86. package/runtime-src/single-binary.ts +0 -143
  87. package/runtime-src/site-policy.ts +0 -54
  88. package/runtime-src/stale-cleanup-runner.ts +0 -144
  89. package/runtime-src/stale-cleanup.ts +0 -133
  90. package/runtime-src/telemetry-attribution.ts +0 -120
  91. package/runtime-src/telemetry.ts +0 -253
  92. package/runtime-src/template-params.ts +0 -141
  93. package/runtime-src/transform/drift.ts +0 -60
  94. package/runtime-src/transform/index.ts +0 -277
  95. package/runtime-src/types/index.ts +0 -1
  96. package/runtime-src/types/skill.ts +0 -912
  97. package/runtime-src/vault/index.ts +0 -196
  98. package/runtime-src/verification/auth-gate.ts +0 -8
  99. package/runtime-src/verification/candidates.ts +0 -27
  100. package/runtime-src/verification/index.ts +0 -120
  101. package/runtime-src/verification/matrix.ts +0 -30
  102. package/runtime-src/version.ts +0 -148
  103. package/runtime-src/workflow/artifact.ts +0 -161
  104. package/runtime-src/workflow/compile.ts +0 -808
  105. package/runtime-src/workflow/publish.ts +0 -225
  106. package/runtime-src/workflow/runtime.ts +0 -213
  107. package/vendor/kuri/win-x64/kuri.exe +0 -0
@@ -1,1839 +0,0 @@
1
- /**
2
- * Kuri HTTP client — thin wrapper over Kuri's browser automation API.
3
- *
4
- * Kuri is a Zig-native CDP broker (464KB binary, 3ms cold start).
5
- * This client replaces agent-browser (Playwright, 80-150MB, 1-3s cold start).
6
- *
7
- * Lifecycle: start() launches kuri + Chrome, stop() tears them down.
8
- * All browser ops go through HTTP — no Playwright, no Node CDP bindings.
9
- */
10
-
11
- import { execFileSync, spawn, type ChildProcess } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import net from "node:net";
14
- import path from "node:path";
15
- import { log } from "../logger.js";
16
- import { getPackageRoot } from "../runtime/paths.js";
17
-
18
- const KURI_DEFAULT_PORT = 7700;
19
- const KURI_STARTUP_TIMEOUT_MS = 60_000;
20
- const KURI_REQUEST_TIMEOUT_MS = 30_000;
21
- const KURI_SPAWN_RETRIES = 3;
22
- const KURI_SPAWN_RETRY_DELAY_MS = 1_000;
23
- const KURI_PORT_SEARCH_LIMIT = 10;
24
- const KURI_CDP_READY_TIMEOUT_MS = 5_000;
25
- const KURI_CDP_POLL_INTERVAL_MS = 200;
26
- const KURI_TAB_CREATE_RETRIES = 5;
27
- let kuriCdpPort: number | null = null;
28
-
29
- export interface KuriTab {
30
- id: string;
31
- url: string;
32
- title?: string;
33
- }
34
-
35
- export interface KuriCookie {
36
- name: string;
37
- value: string;
38
- domain: string;
39
- path?: string;
40
- secure?: boolean;
41
- httpOnly?: boolean;
42
- sameSite?: string;
43
- expires?: number;
44
- }
45
-
46
-
47
- /** Action types supported by Kuri's /action endpoint. */
48
- export type KuriActionType =
49
- | "click" | "dblclick" | "fill" | "type" | "select"
50
- | "check" | "uncheck" | "hover" | "focus" | "blur"
51
- | "scroll" | "press";
52
-
53
- export interface KuriWaitResult {
54
- status: "found" | "ready" | "timeout";
55
- selector?: string;
56
- readyState?: string;
57
- polls?: number;
58
- timeout_ms?: number;
59
- }
60
-
61
- export interface KuriDomQueryResult {
62
- nodeId?: number;
63
- nodeIds?: number[];
64
- }
65
- export interface KuriHarEntry {
66
- request: {
67
- method: string;
68
- url: string;
69
- headers: Array<{ name: string; value: string }>;
70
- postData?: { text: string };
71
- };
72
- response: {
73
- status: number;
74
- headers: Array<{ name: string; value: string }>;
75
- content?: { text?: string; mimeType?: string };
76
- };
77
- startedDateTime: string;
78
- }
79
-
80
- export interface KuriPluginRehydrateResult {
81
- attempted: boolean;
82
- loaded: boolean;
83
- nooped?: boolean;
84
- reason?: string;
85
- modules: string[];
86
- config_loaded?: boolean;
87
- }
88
-
89
- export interface KuriLaunchConfig {
90
- headless: boolean;
91
- attachToExistingChrome: boolean;
92
- }
93
-
94
- type BrokerState = {
95
- process: ChildProcess | null;
96
- port: number;
97
- cdpPort: number | null;
98
- managedChrome: boolean;
99
- ready: boolean;
100
- startPromise: Promise<void> | null;
101
- requestedPort: number;
102
- };
103
-
104
- export interface KuriClient {
105
- start(port?: number): Promise<void>;
106
- stop(): Promise<void>;
107
- discoverTabs(): Promise<KuriTab[]>;
108
- getDefaultTab(): Promise<string>;
109
- navigate(tabId: string, url: string): Promise<void>;
110
- evaluate(tabId: string, expression: string): Promise<unknown>;
111
- getCookies(tabId: string): Promise<KuriCookie[]>;
112
- setCookie(tabId: string, cookie: KuriCookie): Promise<void>;
113
- setCookies(tabId: string, cookies: KuriCookie[]): Promise<void>;
114
- setHeaders(tabId: string, headers: Record<string, string>): Promise<void>;
115
- harStart(tabId: string): Promise<void>;
116
- harStop(tabId: string): Promise<{ entries: KuriHarEntry[]; raw: unknown }>;
117
- networkEnable(tabId: string): Promise<void>;
118
- interceptStart(tabId: string): Promise<void>;
119
- getText(tabId: string): Promise<string>;
120
- getMarkdown(tabId: string): Promise<string>;
121
- screenshot(tabId: string): Promise<string>;
122
- snapshot(tabId: string, filter?: string): Promise<string>;
123
- closeTab(tabId: string): Promise<void>;
124
- newTab(url?: string): Promise<string>;
125
- getCurrentUrl(tabId: string): Promise<string>;
126
- getPageHtml(tabId: string): Promise<string>;
127
- bestEffortRehydratePlugins(tabId: string): Promise<KuriPluginRehydrateResult>;
128
- hasCloudflareChallenge(tabId: string): Promise<boolean>;
129
- waitForCloudflare(tabId: string, maxWaitMs?: number): Promise<boolean>;
130
- executeInPageFetch(
131
- tabId: string,
132
- url: string,
133
- method: string,
134
- headers: Record<string, string>,
135
- body?: unknown,
136
- ): Promise<{ status: number; data: unknown }>;
137
- health(): Promise<{ ok: boolean; tabs?: number }>;
138
- getPort(): number;
139
- isReady(): boolean;
140
- action(tabId: string, actionType: KuriActionType, ref: string, value?: string): Promise<unknown>;
141
- click(tabId: string, ref: string): Promise<unknown>;
142
- fill(tabId: string, ref: string, value: string): Promise<unknown>;
143
- select(tabId: string, ref: string, value: string): Promise<unknown>;
144
- scroll(tabId: string, direction?: "up" | "down" | "left" | "right", amount?: number): Promise<unknown>;
145
- press(tabId: string, key: string, ref?: string): Promise<unknown>;
146
- waitForSelector(tabId: string, selector?: string, timeoutMs?: number): Promise<KuriWaitResult>;
147
- waitForLoad(tabId: string, timeoutMs?: number): Promise<KuriWaitResult>;
148
- keyboardType(tabId: string, text: string): Promise<unknown>;
149
- keyboardInsertText(tabId: string, text: string): Promise<unknown>;
150
- keyDown(tabId: string, key: string): Promise<unknown>;
151
- keyUp(tabId: string, key: string): Promise<unknown>;
152
- scrollIntoView(tabId: string, ref: string): Promise<unknown>;
153
- drag(tabId: string, sourceRef: string, targetRef: string): Promise<unknown>;
154
- domQuery(tabId: string, selector: string, all?: boolean): Promise<KuriDomQueryResult>;
155
- domHtml(tabId: string, nodeId: number): Promise<unknown>;
156
- domAttributes(tabId: string, opts: { ref?: string; selector?: string }): Promise<unknown>;
157
- scriptInject(tabId: string, source: string): Promise<unknown>;
158
- setCredentials(tabId: string, username: string, password: string): Promise<unknown>;
159
- setViewport(tabId: string, width: number, height: number): Promise<unknown>;
160
- setUserAgent(tabId: string, ua: string): Promise<unknown>;
161
- sessionSave(): Promise<unknown>;
162
- sessionLoad(state: unknown): Promise<{ imported: number }>;
163
- sessionList(): Promise<unknown>;
164
- goBack(tabId: string): Promise<unknown>;
165
- goForward(tabId: string): Promise<unknown>;
166
- reload(tabId: string): Promise<unknown>;
167
- getNetworkEvents(tabId: string): Promise<unknown>;
168
- getPerfLcp(tabId: string): Promise<unknown>;
169
- findText(tabId: string, query: string): Promise<unknown>;
170
- getLinks(tabId: string): Promise<unknown>;
171
- getConsole(tabId: string): Promise<unknown>;
172
- getErrors(tabId: string): Promise<unknown>;
173
- authProfileSave(tabId: string, name: string): Promise<unknown>;
174
- authProfileLoad(tabId: string, name: string): Promise<unknown>;
175
- authProfileList(tabId: string): Promise<unknown>;
176
- authProfileDelete(name: string): Promise<unknown>;
177
- }
178
-
179
- function createBrokerState(port = KURI_DEFAULT_PORT): BrokerState {
180
- return {
181
- process: null,
182
- port,
183
- cdpPort: null,
184
- managedChrome: false,
185
- ready: false,
186
- startPromise: null,
187
- requestedPort: port,
188
- };
189
- }
190
-
191
- const defaultBrokerState = createBrokerState();
192
- const brokerClients = new Map<string, KuriClient>();
193
-
194
- function brokerCacheKey(port?: number): string {
195
- return port === undefined ? "default" : `port:${port}`;
196
- }
197
-
198
- function rememberBrokerClient(client: KuriClient, state: BrokerState): void {
199
- brokerClients.set(brokerCacheKey(state.requestedPort), client);
200
- brokerClients.set(brokerCacheKey(state.port), client);
201
- }
202
-
203
- function forgetBrokerClient(state: BrokerState): void {
204
- brokerClients.delete(brokerCacheKey(state.requestedPort));
205
- brokerClients.delete(brokerCacheKey(state.port));
206
- }
207
-
208
- function envFlag(value: string | undefined): boolean {
209
- return value === "1" || value?.toLowerCase() === "true";
210
- }
211
-
212
- function falseyEnv(value: string | undefined): boolean {
213
- if (!value) return false;
214
- const normalized = value.trim().toLowerCase();
215
- return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off";
216
- }
217
-
218
- export function resolveKuriLaunchConfig(env: NodeJS.ProcessEnv = process.env): KuriLaunchConfig {
219
- const explicitHeadless = env.KURI_HEADLESS ?? env.HEADLESS;
220
- const headless = explicitHeadless !== undefined
221
- ? envFlag(explicitHeadless)
222
- : (process.platform === "linux" && !env.DISPLAY); // auto-headless when no display on Linux
223
- const cleanRoom = envFlag(env.UNBROWSE_LOCAL_ONLY) || envFlag(env.KURI_CLEAN_ROOM);
224
- const browserCookieOptOut = falseyEnv(env.UNBROWSE_IMPORT_BROWSER_COOKIES);
225
- const explicitAttach = envFlag(env.KURI_ATTACH_EXISTING_CHROME ?? env.UNBROWSE_ATTACH_EXISTING_CHROME);
226
- const disableCdpAttach = envFlag(env.KURI_DISABLE_CDP_ATTACH);
227
- const canAttachToExistingChrome = !headless && !disableCdpAttach && !cleanRoom;
228
- return {
229
- headless,
230
- // Managed Chrome is the safe default when browser-cookie import is enabled:
231
- // it preserves extension-backed capture while still restoring auth into tabs.
232
- // Existing-Chrome attach remains available for explicit opt-in or true cookie-opt-out flows.
233
- attachToExistingChrome: canAttachToExistingChrome && (explicitAttach || browserCookieOptOut),
234
- };
235
- }
236
-
237
- function kuriBinaryName(): string {
238
- return process.platform === "win32" ? "kuri.exe" : "kuri";
239
- }
240
-
241
- function currentBundledKuriTarget(): string | null {
242
- if (process.platform === "darwin" && process.arch === "arm64") return "darwin-arm64";
243
- if (process.platform === "darwin" && process.arch === "x64") return "darwin-x64";
244
- if (process.platform === "linux" && process.arch === "arm64") return "linux-arm64";
245
- if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
246
- if (process.platform === "win32" && process.arch === "x64") return "win-x64";
247
- return null;
248
- }
249
-
250
- function resolveBinaryOnPath(name: string): string | null {
251
- const checker = process.platform === "win32" ? "where" : "which";
252
- try {
253
- const output = execFileSync(checker, [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
254
- const match = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
255
- return match || null;
256
- } catch {
257
- return null;
258
- }
259
- }
260
-
261
- function addCandidate(candidates: string[], candidate?: string | null): void {
262
- if (!candidate) return;
263
- if (!candidates.includes(candidate)) candidates.push(candidate);
264
- }
265
-
266
- export function getKuriSourceCandidates(): string[] {
267
- const packageRoot = getPackageRoot(import.meta.url);
268
- const candidates: string[] = [];
269
- addCandidate(candidates, path.join(packageRoot, "vendor", "kuri-src"));
270
- addCandidate(candidates, path.join(packageRoot, "submodules", "kuri"));
271
- if (process.env.KURI_PATH) addCandidate(candidates, process.env.KURI_PATH);
272
- if (process.env.HOME) addCandidate(candidates, path.join(process.env.HOME, "kuri"));
273
- return candidates;
274
- }
275
-
276
- export function getKuriBinaryCandidates(): string[] {
277
- const packageRoot = getPackageRoot(import.meta.url);
278
- const binaryName = kuriBinaryName();
279
- const target = currentBundledKuriTarget();
280
- const candidates: string[] = [];
281
-
282
- if (target) addCandidate(candidates, path.join(packageRoot, "vendor", "kuri", target, binaryName));
283
- if (target) addCandidate(candidates, path.join(packageRoot, "packages", "skill", "vendor", "kuri", target, binaryName));
284
- for (const sourceDir of getKuriSourceCandidates()) {
285
- addCandidate(candidates, path.join(sourceDir, "zig-out", "bin", binaryName));
286
- }
287
- addCandidate(candidates, resolveBinaryOnPath("kuri"));
288
- return candidates;
289
- }
290
-
291
- /** Try common CDP ports to find where Chrome is listening. */
292
- async function discoverCdpPort(state: BrokerState): Promise<void> {
293
- const portsToTry = [9222, 9223, 9224, 9225];
294
- for (const port of portsToTry) {
295
- try {
296
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
297
- signal: AbortSignal.timeout(500),
298
- });
299
- if (res.ok) {
300
- state.cdpPort = port;
301
- kuriCdpPort = port;
302
- log("kuri", `found Chrome CDP on port ${port}`);
303
- return;
304
- }
305
- } catch {
306
- // Not on this port
307
- }
308
- }
309
- log("kuri", "could not discover CDP port — tab discovery may fail");
310
- }
311
-
312
- /** Find a free port for CDP starting from 9222. */
313
- async function findFreeCdpPort(): Promise<number> {
314
- for (let port = 9222; port < 9230; port++) {
315
- try {
316
- await fetch(`http://127.0.0.1:${port}/json/version`, {
317
- signal: AbortSignal.timeout(300),
318
- });
319
- // Port is in use, try next
320
- } catch {
321
- return port; // Not in use
322
- }
323
- }
324
- return 9222; // Fallback
325
- }
326
-
327
- async function isKuriHealthyOnPort(port: number): Promise<boolean> {
328
- try {
329
- const health = await fetch(`http://127.0.0.1:${port}/health`, {
330
- signal: AbortSignal.timeout(1000),
331
- });
332
- return health.ok;
333
- } catch {
334
- return false;
335
- }
336
- }
337
-
338
- async function isChromeCdpAvailable(port: number): Promise<boolean> {
339
- try {
340
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
341
- signal: AbortSignal.timeout(1000),
342
- });
343
- return res.ok;
344
- } catch {
345
- return false;
346
- }
347
- }
348
-
349
- async function listRegisteredTabs(state: BrokerState): Promise<KuriTab[]> {
350
- try {
351
- const tabs = (await kuriGet(state, "/tabs")) as Array<{ id?: string; url?: string; title?: string }>;
352
- if (!Array.isArray(tabs)) return [];
353
- return tabs
354
- .filter((tab): tab is { id: string; url?: string; title?: string } => typeof tab?.id === "string")
355
- .map((tab) => ({ id: tab.id, url: tab.url ?? "", title: tab.title }));
356
- } catch {
357
- return [];
358
- }
359
- }
360
-
361
- async function waitForChromeCdpReady(
362
- state: Pick<BrokerState, "cdpPort">,
363
- timeoutMs = KURI_CDP_READY_TIMEOUT_MS,
364
- ): Promise<boolean> {
365
- if (typeof state.cdpPort !== "number") return false;
366
- const deadline = Date.now() + timeoutMs;
367
- while (Date.now() < deadline) {
368
- if (await isChromeCdpAvailable(state.cdpPort)) return true;
369
- await new Promise((resolve) => setTimeout(resolve, KURI_CDP_POLL_INTERVAL_MS));
370
- }
371
- return false;
372
- }
373
-
374
- export function shouldReuseManagedChrome(
375
- launchConfig: KuriLaunchConfig,
376
- state: Pick<BrokerState, "cdpPort" | "managedChrome">,
377
- managedChromeAvailable: boolean,
378
- ): boolean {
379
- return !launchConfig.attachToExistingChrome
380
- && state.managedChrome === true
381
- && typeof state.cdpPort === "number"
382
- && managedChromeAvailable;
383
- }
384
-
385
- async function isTcpPortOpen(port: number, timeoutMs = 400): Promise<boolean> {
386
- return await new Promise<boolean>((resolve) => {
387
- const socket = net.createConnection({ host: "127.0.0.1", port });
388
- const finish = (open: boolean) => {
389
- socket.removeAllListeners();
390
- socket.destroy();
391
- resolve(open);
392
- };
393
-
394
- socket.setTimeout(timeoutMs);
395
- socket.once("connect", () => finish(true));
396
- socket.once("timeout", () => finish(false));
397
- socket.once("error", () => finish(false));
398
- });
399
- }
400
-
401
- export async function resolveKuriPort(
402
- preferredPort: number,
403
- deps: {
404
- isHealthyPort?: (port: number) => Promise<boolean>;
405
- isPortOpen?: (port: number) => Promise<boolean>;
406
- searchLimit?: number;
407
- } = {},
408
- ): Promise<number> {
409
- const isHealthyPort = deps.isHealthyPort ?? isKuriHealthyOnPort;
410
- const isPortOpen = deps.isPortOpen ?? isTcpPortOpen;
411
- const searchLimit = deps.searchLimit ?? KURI_PORT_SEARCH_LIMIT;
412
-
413
- if (await isHealthyPort(preferredPort)) return preferredPort;
414
- if (!await isPortOpen(preferredPort)) return preferredPort;
415
-
416
- for (let candidate = preferredPort + 1; candidate <= preferredPort + searchLimit; candidate++) {
417
- if (await isHealthyPort(candidate)) return candidate;
418
- if (!await isPortOpen(candidate)) return candidate;
419
- }
420
-
421
- return preferredPort;
422
- }
423
-
424
- /** Launch the user's real Chrome with CDP debugging if no Chrome is running.
425
- * This gives Kuri access to all the user's existing cookies/sessions. */
426
- async function ensureUserChromeRunning(state: BrokerState): Promise<void> {
427
- // Check if Chrome already has CDP
428
- for (const port of [9222, 9223, 9224, 9225]) {
429
- try {
430
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) });
431
- if (res.ok) {
432
- state.cdpPort = port;
433
- return;
434
- }
435
- } catch { /* not on this port */ }
436
- }
437
-
438
- // No CDP-enabled Chrome found — launch the user's real Chrome with debugging
439
- const chromePaths: Record<string, string> = {
440
- darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
441
- linux: "google-chrome",
442
- win32: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
443
- };
444
- const chromeBin = chromePaths[process.platform];
445
- if (!chromeBin) return;
446
-
447
- const port = await findFreeCdpPort();
448
- state.cdpPort = port;
449
- log("kuri", `launching user Chrome with CDP on port ${port}`);
450
-
451
- try {
452
- const child = spawn(chromeBin, [
453
- `--remote-debugging-port=${port}`,
454
- "--no-first-run",
455
- "--no-default-browser-check",
456
- ], {
457
- stdio: "ignore",
458
- detached: true,
459
- });
460
- child.unref();
461
-
462
- // Wait for CDP to become available
463
- const deadline = Date.now() + 8000;
464
- while (Date.now() < deadline) {
465
- try {
466
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) });
467
- if (res.ok) {
468
- log("kuri", `user Chrome ready with CDP on port ${port}`);
469
- return;
470
- }
471
- } catch { /* not ready */ }
472
- await new Promise(r => setTimeout(r, 300));
473
- }
474
- log("kuri", "user Chrome launched but CDP not responding — Kuri will launch managed Chrome");
475
- state.cdpPort = null;
476
- } catch (err) {
477
- log("kuri", `failed to launch user Chrome: ${err instanceof Error ? err.message : err}`);
478
- state.cdpPort = null;
479
- }
480
- }
481
-
482
- async function terminateBrokerOnPort(port: number): Promise<void> {
483
- try {
484
- const output = execFileSync("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
485
- encoding: "utf8",
486
- stdio: ["ignore", "pipe", "ignore"],
487
- });
488
- const pids = output
489
- .split(/\r?\n/)
490
- .map((line) => Number(line.trim()))
491
- .filter((pid) => Number.isInteger(pid) && pid > 0);
492
- for (const pid of pids) {
493
- try {
494
- process.kill(pid, "SIGTERM");
495
- } catch {
496
- // best effort only
497
- }
498
- }
499
- if (pids.length > 0) {
500
- await new Promise((resolve) => setTimeout(resolve, 500));
501
- }
502
- } catch {
503
- // lsof missing or no listener — best effort only
504
- }
505
- }
506
-
507
- type HealthyBrokerReuseDeps = {
508
- isHealthyPort?: (port: number) => Promise<boolean>;
509
- isChromeCdpAvailable?: (port: number) => Promise<boolean>;
510
- discoverCdpPort?: (state: BrokerState) => Promise<void>;
511
- ensureUserChromeRunning?: (state: BrokerState) => Promise<void>;
512
- ensureTabsDiscovered?: (state: BrokerState) => Promise<void>;
513
- listTabs?: (state: BrokerState) => Promise<KuriTab[]>;
514
- terminateBrokerOnPort?: (port: number) => Promise<void>;
515
- };
516
-
517
- export async function reuseHealthyBrokerIfPossible(
518
- state: BrokerState,
519
- launchConfig: KuriLaunchConfig,
520
- deps: HealthyBrokerReuseDeps = {},
521
- ): Promise<boolean> {
522
- const isHealthyPort = deps.isHealthyPort ?? isKuriHealthyOnPort;
523
- if (!await isHealthyPort(state.port)) return false;
524
-
525
- log("kuri", `already running on port ${state.port}`);
526
- state.ready = true;
527
- const cdpAvailable = deps.isChromeCdpAvailable ?? isChromeCdpAvailable;
528
-
529
- const discover = deps.discoverCdpPort ?? discoverCdpPort;
530
- await discover(state);
531
-
532
- if (!state.cdpPort && launchConfig.attachToExistingChrome) {
533
- const ensureChrome = deps.ensureUserChromeRunning ?? ensureUserChromeRunning;
534
- await ensureChrome(state);
535
- }
536
-
537
- if (typeof state.cdpPort === "number" && !await cdpAvailable(state.cdpPort)) {
538
- state.cdpPort = null;
539
- }
540
-
541
- const syncTabs = deps.ensureTabsDiscovered ?? ensureTabsDiscovered;
542
- await syncTabs(state).catch(() => {});
543
-
544
- const tabs = await (deps.listTabs ?? listRegisteredTabs)(state).catch(() => []);
545
- if (state.cdpPort) {
546
- return true;
547
- }
548
-
549
- if (tabs.length > 0) {
550
- log("kuri", `healthy broker on port ${state.port} has stale registered tabs but no CDP; recycling startup path`);
551
- } else {
552
- log("kuri", `healthy broker on port ${state.port} has no CDP or tabs; recycling startup path`);
553
- }
554
- state.ready = false;
555
-
556
- if (state.process) {
557
- state.process.kill("SIGTERM");
558
- await waitForChildExit(state.process);
559
- state.process = null;
560
- } else {
561
- const terminate = deps.terminateBrokerOnPort ?? terminateBrokerOnPort;
562
- await terminate(state.port);
563
- }
564
-
565
- return false;
566
- }
567
-
568
- function kuriUrl(state: BrokerState, path: string, params?: Record<string, string>): string {
569
- const base = `http://127.0.0.1:${state.port}${path}`;
570
- if (!params || Object.keys(params).length === 0) return base;
571
- // Build query string manually — URLSearchParams encodes values which breaks
572
- // URL parameters (Kuri's getQueryParam doesn't decode percent-encoding).
573
- // We must still encode reserved separators that otherwise corrupt JS expressions
574
- // or URL structure when Kuri reads the raw query string.
575
- const parts = Object.entries(params).map(([k, v]) => `${k}=${
576
- v
577
- .replace(/#/g, "%23")
578
- .replace(/&/g, "%26")
579
- .replace(/\+/g, "%2B")
580
- }`);
581
- return `${base}?${parts.join("&")}`;
582
- }
583
-
584
- function shouldRetryKuriTransportError(error: unknown): boolean {
585
- const message = error instanceof Error ? error.message : String(error ?? "");
586
- return /fetch failed|econnrefused|connection refused|socket hang up|other side closed|transport closed/i.test(message);
587
- }
588
-
589
- async function kuriGet(state: BrokerState, path: string, params?: Record<string, string>, retryOnTransportFailure = true): Promise<unknown> {
590
- const url = kuriUrl(state, path, params);
591
- const controller = new AbortController();
592
- const timeout = setTimeout(() => controller.abort(), KURI_REQUEST_TIMEOUT_MS);
593
- try {
594
- const res = await fetch(url, { signal: controller.signal });
595
- const text = await res.text();
596
- try { return JSON.parse(text); } catch { return text; }
597
- } catch (error) {
598
- if (retryOnTransportFailure && path !== "/health" && shouldRetryKuriTransportError(error)) {
599
- log("kuri", `transport failed for ${path}; restarting Kuri and retrying once on port ${state.port}`);
600
- await stopOn(state);
601
- await startOn(state, state.requestedPort || state.port);
602
- return await kuriGet(state, path, params, false);
603
- }
604
- throw error;
605
- } finally {
606
- clearTimeout(timeout);
607
- }
608
- }
609
-
610
- async function kuriPost(state: BrokerState, path: string, params: Record<string, string>, body: unknown, retryOnTransportFailure = true): Promise<unknown> {
611
- const url = kuriUrl(state, path, params);
612
- const controller = new AbortController();
613
- const timeout = setTimeout(() => controller.abort(), KURI_REQUEST_TIMEOUT_MS);
614
- try {
615
- const res = await fetch(url, {
616
- method: "POST",
617
- headers: { "Content-Type": "application/json" },
618
- body: JSON.stringify(body),
619
- signal: controller.signal,
620
- });
621
- const text = await res.text();
622
- try { return JSON.parse(text); } catch { return text; }
623
- } catch (error) {
624
- if (retryOnTransportFailure && path !== "/health" && shouldRetryKuriTransportError(error)) {
625
- log("kuri", `transport failed for ${path}; restarting Kuri and retrying once on port ${state.port}`);
626
- await stopOn(state);
627
- await startOn(state, state.requestedPort || state.port);
628
- return await kuriPost(state, path, params, body, false);
629
- }
630
- throw error;
631
- } finally {
632
- clearTimeout(timeout);
633
- }
634
- }
635
-
636
- /** Find the kuri binary — check env, then common build locations. */
637
- export function findKuriBinary(): string {
638
- if (process.env.KURI_BIN) return process.env.KURI_BIN;
639
- const candidates = getKuriBinaryCandidates();
640
- return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? kuriBinaryName();
641
- }
642
-
643
- async function waitForChildExit(child: ChildProcess | null | undefined, timeoutMs = 2_000): Promise<void> {
644
- if (!child) return;
645
- if (child.exitCode !== null || child.killed) return;
646
- await new Promise<void>((resolve) => {
647
- const timer = setTimeout(resolve, timeoutMs);
648
- child.once("exit", () => { clearTimeout(timer); resolve(); });
649
- });
650
- }
651
-
652
-
653
- /**
654
- * Start Kuri server + managed Chrome.
655
- * Idempotent — returns immediately if already running.
656
- */
657
- async function startOn(state: BrokerState, port?: number): Promise<void> {
658
- const requestedPort = port ?? Number(process.env.KURI_PORT || KURI_DEFAULT_PORT);
659
- if (state.ready) {
660
- const activePort = state.port || requestedPort;
661
- if (await isKuriHealthyOnPort(activePort)) return;
662
- log("kuri", `cached ready state stale on port ${activePort}; restarting`);
663
- state.ready = false;
664
- state.process = null;
665
- state.startPromise = null;
666
- }
667
- if (state.startPromise) return state.startPromise;
668
-
669
- const startPromise = (async () => {
670
- const launchConfig = resolveKuriLaunchConfig();
671
- state.requestedPort = requestedPort;
672
- state.port = await resolveKuriPort(requestedPort);
673
- const existingClient = brokerClients.get(brokerCacheKey(requestedPort));
674
- if (existingClient) rememberBrokerClient(existingClient, state);
675
- if (state.port !== requestedPort) {
676
- log("kuri", `preferred port ${requestedPort} is occupied but unhealthy; falling back to ${state.port}`);
677
- }
678
-
679
- // Reuse only when the broker is healthy and either CDP or tabs are still reachable.
680
- if (await reuseHealthyBrokerIfPossible(state, launchConfig)) return;
681
-
682
- const binary = findKuriBinary();
683
- log("kuri", `starting: ${binary} on port ${state.port}`);
684
- if (!existsSync(binary)) {
685
- throw new Error(`Kuri binary not found at ${binary}`);
686
- }
687
-
688
- const reusableManagedChrome = shouldReuseManagedChrome(
689
- launchConfig,
690
- state,
691
- typeof state.cdpPort === "number" && await isChromeCdpAvailable(state.cdpPort),
692
- );
693
-
694
- if (launchConfig.attachToExistingChrome) {
695
- // Discover existing Chrome CDP if available
696
- await discoverCdpPort(state);
697
- state.managedChrome = false;
698
- } else if (reusableManagedChrome) {
699
- log("kuri", `reconnecting to surviving managed Chrome on port ${state.cdpPort}`);
700
- } else {
701
- state.cdpPort = null;
702
- state.managedChrome = false;
703
- }
704
-
705
- const env: Record<string, string> = {
706
- ...process.env as Record<string, string>,
707
- PORT: String(state.port),
708
- HOST: "127.0.0.1",
709
- HEADLESS: launchConfig.headless ? "true" : "false",
710
- };
711
- if (state.cdpPort && (launchConfig.attachToExistingChrome || reusableManagedChrome)) {
712
- env.CDP_URL = `ws://127.0.0.1:${state.cdpPort}`;
713
- log("kuri", reusableManagedChrome
714
- ? `connecting to surviving managed Chrome on port ${state.cdpPort}`
715
- : `connecting to existing Chrome on port ${state.cdpPort}`);
716
- } else if (launchConfig.headless) {
717
- log("kuri", "starting in headless mode with managed Chrome");
718
- } else {
719
- log("kuri", "no existing Chrome found — Kuri will launch managed Chrome");
720
- }
721
-
722
- const maxAttempts = KURI_SPAWN_RETRIES + 1;
723
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
724
- if (attempt > 1) {
725
- log("kuri", `spawn retry ${attempt}/${maxAttempts} after ${KURI_SPAWN_RETRY_DELAY_MS}ms`);
726
- await new Promise((r) => setTimeout(r, KURI_SPAWN_RETRY_DELAY_MS));
727
- }
728
-
729
- let exitedBeforeReady = false;
730
- state.process = spawn(binary, [], {
731
- env,
732
- stdio: ["ignore", "pipe", "pipe"],
733
- });
734
- const childPid = state.process.pid;
735
- log("kuri", `spawned pid ${childPid ?? "unknown"} on broker port ${state.port}`);
736
-
737
- // Parse CDP port from stderr output
738
- state.process.stderr?.on("data", (chunk: Buffer) => {
739
- const line = chunk.toString().trim();
740
- if (line) log("kuri", `[stderr] ${line}`);
741
- const cdpMatch = line.match(/CDP port:\s*(\d+)/);
742
- if (cdpMatch) {
743
- state.cdpPort = parseInt(cdpMatch[1], 10);
744
- kuriCdpPort = state.cdpPort;
745
- log("kuri", `discovered CDP port: ${state.cdpPort}`);
746
- }
747
- if (/launched Chrome \(pid=\d+\) on CDP port/i.test(line) || /launching managed Chrome instance/i.test(line)) {
748
- state.managedChrome = true;
749
- }
750
- });
751
-
752
- state.process.on("exit", (code, signal) => {
753
- if (!state.ready) exitedBeforeReady = true;
754
- log(
755
- "kuri",
756
- `process exited pid=${childPid ?? "unknown"} code=${code === null ? "null" : code} signal=${signal ?? "none"} broker_port=${state.port} cdp_port=${state.cdpPort ?? "unknown"}`,
757
- );
758
- state.ready = false;
759
- state.process = null;
760
- forgetBrokerClient(state);
761
- });
762
-
763
- // Wait for health endpoint; break early if process died
764
- const deadline = Date.now() + KURI_STARTUP_TIMEOUT_MS;
765
- while (Date.now() < deadline) {
766
- if (exitedBeforeReady) break;
767
- try {
768
- const res = await fetch(`http://127.0.0.1:${state.port}/health`, {
769
- signal: AbortSignal.timeout(500),
770
- });
771
- if (res.ok) {
772
- state.ready = true;
773
- log("kuri", `ready on port ${state.port}`);
774
- await new Promise((r) => setTimeout(r, 300));
775
- if (!state.cdpPort) await discoverCdpPort(state);
776
- await waitForChromeCdpReady(state).catch(() => false);
777
- // Auto-discover tabs so they're registered for immediate use
778
- await ensureTabsDiscovered(state);
779
- return;
780
- }
781
- } catch {
782
- // Not ready yet
783
- }
784
- await new Promise((r) => setTimeout(r, 200));
785
- }
786
-
787
- if (state.ready) return;
788
-
789
- // Kill any lingering process before next attempt
790
- if (state.process) {
791
- state.process.kill();
792
- await waitForChildExit(state.process);
793
- }
794
- // Also kill any orphaned Chrome processes on the CDP port
795
- try {
796
- execFileSync("pkill", ["-f", `remote-debugging-port=${state.cdpPort ?? 9222}`], { stdio: "ignore" });
797
- await new Promise((r) => setTimeout(r, 1000));
798
- } catch { /* no matching process — fine */ }
799
- }
800
- throw new Error(`Kuri failed to start after ${maxAttempts} attempts`);
801
- })();
802
-
803
- state.startPromise = startPromise.finally(() => {
804
- if (state.startPromise === startPromise) {
805
- state.startPromise = null;
806
- }
807
- });
808
- return state.startPromise;
809
- }
810
-
811
- /** Stop Kuri and managed Chrome. */
812
- async function stopOn(state: BrokerState): Promise<void> {
813
- if (state.startPromise) {
814
- await state.startPromise.catch(() => {});
815
- }
816
- if (state.process) {
817
- state.process.kill("SIGTERM");
818
- state.process = null;
819
- }
820
- state.ready = false;
821
- state.cdpPort = null;
822
- kuriCdpPort = null;
823
- state.managedChrome = false;
824
- state.startPromise = null;
825
- forgetBrokerClient(state);
826
- }
827
-
828
- /** List discovered Chrome tabs. */
829
- async function discoverTabsOn(state: BrokerState): Promise<KuriTab[]> {
830
- // Trigger Kuri's /discover to sync Chrome tabs
831
- await ensureTabsDiscovered(state);
832
-
833
- // List registered tabs
834
- try {
835
- const tabs = (await kuriGet(state, "/tabs")) as Array<{ id: string; url: string; title?: string }>;
836
- if (Array.isArray(tabs) && tabs.length > 0) return tabs;
837
- } catch { /* empty */ }
838
-
839
- return [];
840
- }
841
-
842
- /** Get or discover the first usable tab. */
843
- async function getDefaultTabOn(state: BrokerState): Promise<string> {
844
- // Ensure Kuri's /discover works by triggering it (it registers tabs from Chrome)
845
- await ensureTabsDiscovered(state);
846
-
847
- // Now list Kuri's registered tabs
848
- try {
849
- const tabs = (await kuriGet(state, "/tabs")) as Array<{ id: string; url: string }>;
850
- if (Array.isArray(tabs) && tabs.length > 0) return tabs[0].id;
851
- } catch { /* no tabs registered */ }
852
-
853
- const cdpTabId = await createTabViaChromeCdp("about:blank", state);
854
- if (cdpTabId) return cdpTabId;
855
-
856
- throw new Error("No tabs available and failed to create one");
857
- }
858
-
859
- export async function start(port?: number, state: BrokerState = defaultBrokerState): Promise<void> {
860
- return startOn(state, port);
861
- }
862
-
863
- export async function stop(state: BrokerState = defaultBrokerState): Promise<void> {
864
- return stopOn(state);
865
- }
866
-
867
- export async function discoverTabs(state: BrokerState = defaultBrokerState): Promise<KuriTab[]> {
868
- return discoverTabsOn(state);
869
- }
870
-
871
- export async function getDefaultTab(state: BrokerState = defaultBrokerState): Promise<string> {
872
- return getDefaultTabOn(state);
873
- }
874
-
875
- /** Trigger Kuri's /discover to sync Chrome tabs into Kuri's registry. */
876
- /** Trigger Kuri's /discover to sync Chrome tabs into Kuri's registry. */
877
- async function ensureTabsDiscovered(state: BrokerState): Promise<void> {
878
- try {
879
- if (state.cdpPort) await waitForChromeCdpReady(state).catch(() => false);
880
- // Pass CDP URL as query param so /discover works even if Kuri was started without CDP_URL env
881
- const params: Record<string, string> = {};
882
- if (state.cdpPort) params.cdp_url = `ws://127.0.0.1:${state.cdpPort}`;
883
- await kuriGet(state, "/discover", params);
884
- } catch {
885
- // /discover may fail if no Chrome running — that's OK
886
- }
887
- }
888
-
889
- async function waitForTabRegistration(state: BrokerState, tabId: string, timeoutMs = 2_000): Promise<void> {
890
- const deadline = Date.now() + timeoutMs;
891
- while (Date.now() < deadline) {
892
- await ensureTabsDiscovered(state);
893
- try {
894
- const tabs = (await kuriGet(state, "/tabs")) as Array<{ id?: string }>;
895
- if (Array.isArray(tabs) && tabs.some((tab) => tab?.id === tabId)) return;
896
- } catch {
897
- // keep polling until timeout
898
- }
899
- await new Promise((resolve) => setTimeout(resolve, 100));
900
- }
901
- }
902
-
903
- async function createTabViaChromeCdp(url = "about:blank", state: BrokerState = defaultBrokerState): Promise<string> {
904
- if (!state.cdpPort) return "";
905
- for (let attempt = 0; attempt < KURI_TAB_CREATE_RETRIES; attempt += 1) {
906
- await waitForChromeCdpReady(state).catch(() => false);
907
- try {
908
- const res = await fetch(`http://127.0.0.1:${state.cdpPort}/json/new?${url}`, {
909
- method: "PUT",
910
- signal: AbortSignal.timeout(5000),
911
- });
912
- const target = await res.json() as { id?: string; targetId?: string };
913
- const tabId = target?.id ?? target?.targetId ?? "";
914
- if (tabId) {
915
- log("kuri", `created new Chrome tab: ${tabId}`);
916
- await new Promise((resolve) => setTimeout(resolve, 300));
917
- await ensureTabsDiscovered(state).catch(() => {});
918
- await waitForTabRegistration(state, tabId).catch(() => {});
919
- return tabId;
920
- }
921
- } catch (err) {
922
- if (attempt === KURI_TAB_CREATE_RETRIES - 1) {
923
- log("kuri", `Chrome tab creation failed: ${err instanceof Error ? err.message : err}`);
924
- return "";
925
- }
926
- }
927
- await new Promise((resolve) => setTimeout(resolve, KURI_CDP_POLL_INTERVAL_MS));
928
- }
929
- return "";
930
- }
931
-
932
- async function findReusableIdleTab(state: BrokerState = defaultBrokerState): Promise<string> {
933
- await ensureTabsDiscovered(state);
934
- try {
935
- const tabs = (await kuriGet(state, "/tabs")) as Array<{ id?: string; url?: string }>;
936
- const candidate = tabs.find((tab) => /^(about:blank|chrome:\/\/newtab\/?)$/i.test(tab?.url ?? ""));
937
- return candidate?.id ?? "";
938
- } catch {
939
- return "";
940
- }
941
- }
942
-
943
- /** Navigate tab to URL. */
944
- export async function navigate(tabId: string, url: string, state: BrokerState = defaultBrokerState): Promise<void> {
945
- await kuriGet(state, "/navigate", { tab_id: tabId, url });
946
- }
947
-
948
- /** Evaluate JavaScript in tab context. */
949
- /** Evaluate JavaScript in tab context. */
950
- /** Evaluate JavaScript in tab context. */
951
- /** Evaluate JavaScript in tab context. */
952
- export async function evaluate(tabId: string, expression: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
953
- let raw: {
954
- id?: number;
955
- result?: { result?: { type?: string; value?: unknown; description?: string }; exceptionDetails?: unknown };
956
- };
957
- if (expression.length > 2000) {
958
- // Kuri's /evaluate handler still reads expression from the query string.
959
- // Keep the param in the URL even on POST so large submit scripts work.
960
- const url = kuriUrl(state, "/evaluate", { tab_id: tabId, expression });
961
- const controller = new AbortController();
962
- const timeout = setTimeout(() => controller.abort(), KURI_REQUEST_TIMEOUT_MS);
963
- try {
964
- const res = await fetch(url, {
965
- method: "POST",
966
- headers: { "Content-Type": "text/plain" },
967
- body: expression,
968
- signal: controller.signal,
969
- });
970
- const text = await res.text();
971
- try { raw = JSON.parse(text); } catch { raw = text as never; }
972
- } finally {
973
- clearTimeout(timeout);
974
- }
975
- } else {
976
- raw = (await kuriGet(state, "/evaluate", { tab_id: tabId, expression })) as typeof raw;
977
- }
978
- // CDP Runtime.evaluate response: { id, result: { result: { type, value } } }
979
- const inner = raw?.result?.result;
980
- if (!inner) return raw;
981
- if (inner.type === "undefined") return undefined;
982
- if ("value" in inner) return inner.value;
983
- return inner.description ?? raw;
984
- }
985
-
986
- export function getKuriErrorMessage(value: unknown): string | null {
987
- if (typeof value === "string") return null;
988
- if (!value || typeof value !== "object") return null;
989
-
990
- const record = value as Record<string, unknown>;
991
- if (typeof record.error === "string") return record.error;
992
- if (typeof record.message === "string") return record.message;
993
- if (record.result && typeof record.result === "object") {
994
- const nested = record.result as Record<string, unknown>;
995
- if (typeof nested.error === "string") return nested.error;
996
- if (typeof nested.message === "string") return nested.message;
997
- }
998
- return null;
999
- }
1000
-
1001
- /** Get all cookies for a tab. */
1002
- export async function getCookies(tabId: string, state: BrokerState = defaultBrokerState): Promise<KuriCookie[]> {
1003
- const raw = (await kuriGet(state, "/cookies", { tab_id: tabId })) as {
1004
- id?: number;
1005
- result?: { cookies?: KuriCookie[] };
1006
- };
1007
- return raw?.result?.cookies ?? [];
1008
- }
1009
-
1010
- /** Set a cookie via raw CDP WebSocket — supports all cookie attributes (secure, httpOnly, sameSite, expires). */
1011
- async function setCookieViaCDP(wsUrl: string, cookie: {
1012
- name: string; value: string; domain: string; path: string;
1013
- secure: boolean; httpOnly: boolean; sameSite: string; expires?: number;
1014
- }): Promise<boolean> {
1015
- return new Promise((resolve) => {
1016
- const timer = setTimeout(() => { resolve(false); }, 3000);
1017
- try {
1018
- const ws = new (require("ws") as typeof import("ws"))(wsUrl);
1019
- ws.on("open", () => {
1020
- ws.send(JSON.stringify({
1021
- id: 1,
1022
- method: "Network.setCookie",
1023
- params: {
1024
- ...cookie,
1025
- url: `https://${cookie.domain.replace(/^\./, "")}/`,
1026
- },
1027
- }));
1028
- });
1029
- ws.on("message", (data: Buffer) => {
1030
- clearTimeout(timer);
1031
- try {
1032
- const msg = JSON.parse(data.toString());
1033
- if (msg.id === 1) {
1034
- ws.close();
1035
- resolve(msg.result?.success ?? false);
1036
- }
1037
- } catch { ws.close(); resolve(false); }
1038
- });
1039
- ws.on("error", () => { clearTimeout(timer); resolve(false); });
1040
- } catch { clearTimeout(timer); resolve(false); }
1041
- });
1042
- }
1043
-
1044
- async function resolveCdpDebuggerUrlForTab(tabId: string): Promise<string | null> {
1045
- const portsToTry = Array.from(new Set(
1046
- [kuriCdpPort, 9222, 9223, 9224, 9225].filter((port): port is number => typeof port === "number"),
1047
- ));
1048
-
1049
- for (const port of portsToTry) {
1050
- try {
1051
- const res = await fetch(`http://127.0.0.1:${port}/json`, { signal: AbortSignal.timeout(1000) });
1052
- if (!res.ok) continue;
1053
- const pages = await res.json() as Array<{ id: string; webSocketDebuggerUrl?: string }>;
1054
- const page = pages.find((candidate) => candidate.id === tabId);
1055
- if (page?.webSocketDebuggerUrl) {
1056
- if (kuriCdpPort !== port) {
1057
- kuriCdpPort = port;
1058
- log("kuri", `updated CDP port from tab discovery: ${port}`);
1059
- }
1060
- return page.webSocketDebuggerUrl;
1061
- }
1062
- } catch {
1063
- // Try the next candidate port.
1064
- }
1065
- }
1066
-
1067
- return null;
1068
- }
1069
-
1070
- /** Set a single cookie via raw CDP (Chrome debug port) for full attribute support.
1071
- * Falls back to Kuri's /cookies endpoint if CDP is unavailable. */
1072
- /** Set a single cookie via raw CDP (Chrome debug port) for full attribute support.
1073
- * Falls back to Kuri's /cookies endpoint if CDP is unavailable. */
1074
- export async function setCookie(tabId: string, cookie: KuriCookie, state: BrokerState = defaultBrokerState): Promise<void> {
1075
- // Strip wrapping quotes from cookie values (Chrome stores some values like JSESSIONID with literal quotes)
1076
- const value = cookie.value.replace(/^"|"$/g, "");
1077
-
1078
- // Try raw CDP first — Kuri's /cookies endpoint doesn't pass secure/httpOnly/sameSite/expires
1079
- // which causes auth failures on sites like LinkedIn that require secure cookies.
1080
- if (cookie.secure || cookie.httpOnly) {
1081
- try {
1082
- const debuggerUrl = await resolveCdpDebuggerUrlForTab(tabId);
1083
- if (debuggerUrl) {
1084
- const success = await setCookieViaCDP(debuggerUrl, {
1085
- name: cookie.name,
1086
- value,
1087
- domain: cookie.domain,
1088
- path: cookie.path || "/",
1089
- secure: cookie.secure ?? false,
1090
- httpOnly: cookie.httpOnly ?? false,
1091
- sameSite: cookie.sameSite || "Lax",
1092
- ...(cookie.expires && cookie.expires > 0 ? { expires: cookie.expires } : {}),
1093
- });
1094
- if (success) return;
1095
- log("kuri", `CDP cookie set failed for ${cookie.name} on ${cookie.domain}; falling back to /cookies`);
1096
- } else {
1097
- log("kuri", `no CDP websocket for tab ${tabId}; falling back to /cookies for secure cookie ${cookie.name}`);
1098
- }
1099
- } catch { /* CDP unavailable, fall through to Kuri */ }
1100
- }
1101
-
1102
- // Fallback: Kuri's /cookies endpoint (no secure/httpOnly support)
1103
- await kuriGet(state, "/cookies", {
1104
- tab_id: tabId,
1105
- name: cookie.name,
1106
- value,
1107
- domain: cookie.domain,
1108
- ...(cookie.path ? { path: cookie.path } : {}),
1109
- });
1110
- }
1111
-
1112
- /** Set multiple cookies. */
1113
- export async function setCookies(tabId: string, cookies: KuriCookie[], state: BrokerState = defaultBrokerState): Promise<void> {
1114
- for (const cookie of cookies) {
1115
- await setCookie(tabId, cookie, state);
1116
- }
1117
- }
1118
-
1119
- /** Set extra HTTP headers for a tab. */
1120
- export async function setHeaders(tabId: string, headers: Record<string, string>, state: BrokerState = defaultBrokerState): Promise<void> {
1121
- await kuriPost(state, "/headers", { tab_id: tabId }, headers);
1122
- }
1123
-
1124
- /** Start HAR recording for a tab. */
1125
- export async function harStart(tabId: string, state: BrokerState = defaultBrokerState): Promise<void> {
1126
- await kuriGet(state, "/har/start", { tab_id: tabId });
1127
- }
1128
-
1129
- /** Stop HAR recording and return entries. */
1130
- export async function harStop(tabId: string, state: BrokerState = defaultBrokerState): Promise<{ entries: KuriHarEntry[]; raw: unknown }> {
1131
- const result = (await kuriGet(state, "/har/stop", { tab_id: tabId })) as {
1132
- entries?: number;
1133
- har?: { log?: { entries?: KuriHarEntry[] } };
1134
- };
1135
- return {
1136
- entries: result?.har?.log?.entries ?? [],
1137
- raw: result,
1138
- };
1139
- }
1140
-
1141
- /** Enable Network domain (needed for cookies/interception). */
1142
- export async function networkEnable(tabId: string, state: BrokerState = defaultBrokerState): Promise<void> {
1143
- await kuriGet(state, "/network", { tab_id: tabId, mode: "enable" });
1144
- }
1145
-
1146
- /** Start Fetch interception. */
1147
- export async function interceptStart(tabId: string, state: BrokerState = defaultBrokerState): Promise<void> {
1148
- await kuriGet(state, "/intercept/start", { tab_id: tabId });
1149
- }
1150
-
1151
- /** Get page text content. */
1152
- export async function getText(tabId: string, state: BrokerState = defaultBrokerState): Promise<string> {
1153
- const result = (await kuriGet(state, "/text", { tab_id: tabId })) as
1154
- | string
1155
- | { text?: string; result?: { result?: { value?: unknown } } };
1156
- if (typeof result === "string") return result;
1157
- if (typeof result?.text === "string") return result.text;
1158
- if (typeof result?.result?.result?.value === "string") return result.result.result.value;
1159
- return "";
1160
- }
1161
-
1162
- /** Get page as markdown. */
1163
- export async function getMarkdown(tabId: string, state: BrokerState = defaultBrokerState): Promise<string> {
1164
- const result = (await kuriGet(state, "/markdown", { tab_id: tabId })) as
1165
- | string
1166
- | { markdown?: string; result?: { result?: { value?: unknown } } };
1167
- if (typeof result === "string") return result;
1168
- if (typeof result?.markdown === "string") return result.markdown;
1169
- if (typeof result?.result?.result?.value === "string") return result.result.result.value;
1170
- return "";
1171
- }
1172
-
1173
- /** Take screenshot (returns base64 PNG). */
1174
- export async function screenshot(tabId: string, state: BrokerState = defaultBrokerState): Promise<string> {
1175
- const result = (await kuriGet(state, "/screenshot", { tab_id: tabId })) as { data?: string; screenshot?: string };
1176
- return result?.data ?? result?.screenshot ?? "";
1177
- }
1178
-
1179
- /** Get accessibility tree snapshot. */
1180
- export async function snapshot(tabId: string, filter?: string, state: BrokerState = defaultBrokerState): Promise<string> {
1181
- const params: Record<string, string> = { tab_id: tabId };
1182
- if (filter) params.filter = filter;
1183
- params.format = "text";
1184
- const result = await kuriGet(state, "/snapshot", params);
1185
- if (typeof result === "string") return result;
1186
- if (result && typeof result === "object" && "snapshot" in result && typeof (result as { snapshot?: unknown }).snapshot === "string") {
1187
- return (result as { snapshot: string }).snapshot;
1188
- }
1189
- return "";
1190
- }
1191
-
1192
- /** Close a tab. */
1193
- export async function closeTab(tabId: string, state: BrokerState = defaultBrokerState): Promise<void> {
1194
- await kuriGet(state, "/close", { tab_id: tabId });
1195
- }
1196
-
1197
- /** Create a new tab. */
1198
- export async function newTab(url?: string, state: BrokerState = defaultBrokerState): Promise<string> {
1199
- if (state.cdpPort) await waitForChromeCdpReady(state).catch(() => false);
1200
- const params: Record<string, string> = {};
1201
- if (url) params.url = url;
1202
- const createViaKuri = async (): Promise<{ tab_id?: string; id?: string; targetId?: string; error?: string; message?: string }> =>
1203
- (await kuriGet(state, "/tab/new", params)) as { tab_id?: string; id?: string; targetId?: string; error?: string; message?: string };
1204
-
1205
- let result: { tab_id?: string; id?: string; targetId?: string; error?: string; message?: string } = {};
1206
- try {
1207
- result = await createViaKuri();
1208
- } catch {
1209
- result = {};
1210
- }
1211
- let tabId = result?.tab_id ?? result?.id ?? result?.targetId ?? "";
1212
- if (!tabId) tabId = await findReusableIdleTab(state);
1213
- if (!tabId) tabId = await createTabViaChromeCdp(url ?? "about:blank", state);
1214
- if (tabId) {
1215
- await waitForTabRegistration(state, tabId).catch(() => {});
1216
- return tabId;
1217
- }
1218
-
1219
- const restartPort = state.requestedPort || state.port;
1220
- const errorMessage = getKuriErrorMessage(result) ?? "unknown tab creation failure";
1221
- log("kuri", `tab creation failed via /tab/new (${errorMessage}); restarting Kuri once`);
1222
- await stopOn(state);
1223
- await startOn(state, restartPort);
1224
-
1225
- try {
1226
- result = await createViaKuri();
1227
- } catch {
1228
- result = {};
1229
- }
1230
- tabId = result?.tab_id ?? result?.id ?? result?.targetId ?? "";
1231
- if (!tabId) tabId = await findReusableIdleTab(state);
1232
- if (!tabId) tabId = await createTabViaChromeCdp(url ?? "about:blank", state);
1233
- if (tabId) {
1234
- await waitForTabRegistration(state, tabId).catch(() => {});
1235
- }
1236
- return tabId;
1237
- }
1238
-
1239
- /** Get current page URL via evaluate. */
1240
- export async function getCurrentUrl(tabId: string, state: BrokerState = defaultBrokerState): Promise<string> {
1241
- const result = await evaluate(tabId, "window.location.href", state);
1242
- return typeof result === "string" ? result : "";
1243
- }
1244
-
1245
- /** Get page HTML content via evaluate. */
1246
- export async function getPageHtml(tabId: string, state: BrokerState = defaultBrokerState): Promise<string> {
1247
- const result = await evaluate(tabId, "document.documentElement.outerHTML", state);
1248
- return String(result ?? "");
1249
- }
1250
-
1251
- export function extractLoadPlugins(value: unknown): string[] {
1252
- if (typeof value !== "string") return [];
1253
- return Array.from(new Set(
1254
- value
1255
- .split(/[\s,;]+/)
1256
- .map((part) => part.trim())
1257
- .filter(Boolean),
1258
- ));
1259
- }
1260
-
1261
- export function extractLoadPluginsFromHtml(html: string): string[] {
1262
- const modules: string[] = [];
1263
- const pattern = /data-load-plugins=(["'])(.*?)\1/gi;
1264
- for (const match of html.matchAll(pattern)) {
1265
- modules.push(...extractLoadPlugins(match[2]));
1266
- }
1267
- return Array.from(new Set(modules));
1268
- }
1269
-
1270
- export async function bestEffortRehydratePlugins(tabId: string, state: BrokerState = defaultBrokerState): Promise<KuriPluginRehydrateResult> {
1271
- const result = await evaluate(tabId, `(async function() {
1272
- function splitPlugins(value) {
1273
- return String(value || "")
1274
- .split(/[\\s,;]+/)
1275
- .map(function(part) { return part.trim(); })
1276
- .filter(Boolean);
1277
- }
1278
- function pluginPath(name) {
1279
- if (/^https?:\\/\\//i.test(name) || name.startsWith("/")) return name;
1280
- return "/etc/designs/wrs/footLibs/js/plugins/" + (name.endsWith(".js") ? name : name + ".js");
1281
- }
1282
- var modules = Array.from(new Set(
1283
- Array.from(document.querySelectorAll("[data-load-plugins]"))
1284
- .flatMap(function(node) { return splitPlugins(node.getAttribute("data-load-plugins")); })
1285
- ));
1286
- if (modules.length === 0) {
1287
- return JSON.stringify({ attempted: false, loaded: false, nooped: true, reason: "no_plugins", modules: [] });
1288
- }
1289
- if (!window.WRS || typeof window.WRS.require !== "function") {
1290
- return JSON.stringify({ attempted: false, loaded: false, nooped: true, reason: "missing_wrs_require", modules: modules });
1291
- }
1292
- var requireWrs = window.WRS.require.bind(window.WRS);
1293
- async function loadModules(paths) {
1294
- return await new Promise(function(resolve) {
1295
- var done = false;
1296
- var timer = setTimeout(function() {
1297
- if (done) return;
1298
- done = true;
1299
- resolve({ ok: false, reason: "timeout" });
1300
- }, 1500);
1301
- try {
1302
- requireWrs(paths, function() {
1303
- if (done) return;
1304
- done = true;
1305
- clearTimeout(timer);
1306
- resolve({ ok: true });
1307
- });
1308
- } catch (error) {
1309
- if (done) return;
1310
- done = true;
1311
- clearTimeout(timer);
1312
- resolve({ ok: false, reason: error && error.message ? error.message : String(error) });
1313
- }
1314
- });
1315
- }
1316
- var configResult = await loadModules(["/etc/designs/wrs/footLibs/js/config.js"]);
1317
- var pluginResult = await loadModules(modules.map(pluginPath));
1318
- for (var i = 0; i < 6; i++) {
1319
- await new Promise(function(resolve) { return setTimeout(resolve, 100); });
1320
- }
1321
- return JSON.stringify({
1322
- attempted: true,
1323
- loaded: !!pluginResult.ok,
1324
- nooped: false,
1325
- reason: pluginResult.ok ? undefined : pluginResult.reason,
1326
- config_loaded: !!configResult.ok,
1327
- modules: modules,
1328
- });
1329
- })()`, state);
1330
-
1331
- if (typeof result !== "string") {
1332
- return {
1333
- attempted: false,
1334
- loaded: false,
1335
- nooped: true,
1336
- reason: "invalid_rehydrate_result",
1337
- modules: [],
1338
- };
1339
- }
1340
-
1341
- try {
1342
- return JSON.parse(result) as KuriPluginRehydrateResult;
1343
- } catch {
1344
- return {
1345
- attempted: false,
1346
- loaded: false,
1347
- nooped: true,
1348
- reason: "invalid_rehydrate_result",
1349
- modules: [],
1350
- };
1351
- }
1352
- }
1353
-
1354
- /** Check if page has Cloudflare challenge. */
1355
- export async function hasCloudflareChallenge(tabId: string, state: BrokerState = defaultBrokerState): Promise<boolean> {
1356
- const result = await evaluate(tabId, `(function() {
1357
- var html = document.documentElement.innerHTML;
1358
- return html.indexOf('challenge-platform') !== -1 ||
1359
- html.indexOf('cf_chl_opt') !== -1 ||
1360
- html.indexOf('cf-error-details') !== -1 ||
1361
- html.indexOf('cf.errors.css') !== -1 ||
1362
- document.title === 'Just a moment...' ||
1363
- /Attention Required.*Cloudflare/.test(document.title) ||
1364
- !!document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification');
1365
- })()`, state);
1366
- return result === true;
1367
- }
1368
-
1369
- /** Wait for Cloudflare challenge to clear. */
1370
- export async function waitForCloudflare(tabId: string, maxWaitMs = 15000, state: BrokerState = defaultBrokerState): Promise<boolean> {
1371
- const startTime = Date.now();
1372
- while (Date.now() - startTime < maxWaitMs) {
1373
- const blocked = await hasCloudflareChallenge(tabId, state);
1374
- if (!blocked) return true;
1375
- await new Promise((r) => setTimeout(r, 1500));
1376
- }
1377
- return false;
1378
- }
1379
-
1380
- /**
1381
- * Execute fetch() inside the browser page context.
1382
- * Runs from the page's origin, inheriting cookies/CSRF.
1383
- */
1384
- export async function executeInPageFetch(
1385
- tabId: string,
1386
- url: string,
1387
- method: string,
1388
- headers: Record<string, string>,
1389
- body?: unknown,
1390
- state: BrokerState = defaultBrokerState,
1391
- ): Promise<{ status: number; data: unknown }> {
1392
- const fetchScript = `(async function() {
1393
- try {
1394
- var res = await fetch(${JSON.stringify(url)}, {
1395
- method: ${JSON.stringify(method)},
1396
- headers: ${JSON.stringify(headers)},
1397
- ${body ? `body: ${JSON.stringify(JSON.stringify(body))},` : ""}
1398
- });
1399
- var text = await res.text();
1400
- var data;
1401
- try { data = JSON.parse(text); } catch(e) { data = text; }
1402
- return JSON.stringify({ status: res.status, data: data });
1403
- } catch(e) {
1404
- return JSON.stringify({ status: 0, data: { error: e.message } });
1405
- }
1406
- })()`;
1407
-
1408
- const result = await evaluate(tabId, fetchScript, state);
1409
- try {
1410
- return JSON.parse(String(result)) as { status: number; data: unknown };
1411
- } catch {
1412
- return { status: 0, data: result };
1413
- }
1414
- }
1415
-
1416
- /** Health check. */
1417
- export async function health(state: BrokerState = defaultBrokerState): Promise<{ ok: boolean; tabs?: number }> {
1418
- try {
1419
- const result = (await kuriGet(state, "/health")) as { ok?: boolean; status?: string; tabs?: number };
1420
- return { ok: result?.ok === true || result?.status === "ok", tabs: result?.tabs };
1421
- } catch {
1422
- return { ok: false };
1423
- }
1424
- }
1425
-
1426
- /** Get the currently configured port. */
1427
- export function getPort(state: BrokerState = defaultBrokerState): number {
1428
- return state.port;
1429
- }
1430
-
1431
- export function getCdpPort(): number | null {
1432
- return kuriCdpPort;
1433
- }
1434
-
1435
- export function setCdpPortForTests(port: number | null): void {
1436
- kuriCdpPort = port;
1437
- }
1438
-
1439
- /** Check if kuri is ready. */
1440
- export function isReady(state: BrokerState = defaultBrokerState): boolean {
1441
- return state.ready;
1442
- }
1443
-
1444
- // ---------------------------------------------------------------------------
1445
- // Action primitives (new in Kuri v0.3+)
1446
- // ---------------------------------------------------------------------------
1447
-
1448
- /**
1449
- * Perform a browser action on an element by ref (from /snapshot a11y tree).
1450
- * Requires a prior /snapshot call to populate refs.
1451
- */
1452
- export async function action(
1453
- tabId: string,
1454
- actionType: KuriActionType,
1455
- ref: string,
1456
- value?: string,
1457
- state: BrokerState = defaultBrokerState,
1458
- ): Promise<unknown> {
1459
- const params: Record<string, string> = { tab_id: tabId, action: actionType, ref };
1460
- if (value !== undefined) params.value = value;
1461
- return kuriGet(state, "/action", params);
1462
- }
1463
-
1464
- /** Click an element by ref (scrolls into view first). */
1465
- export async function click(tabId: string, ref: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1466
- await scrollIntoView(tabId, ref, state);
1467
- return action(tabId, "click", ref, undefined, state);
1468
- }
1469
-
1470
- /** Fill an input element by ref (focuses first). */
1471
- export async function fill(tabId: string, ref: string, value: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1472
- await click(tabId, ref, state);
1473
- const result = await action(tabId, "fill", ref, value, state);
1474
- const currentValue = await evaluate(tabId, `(() => {
1475
- const active = document.activeElement;
1476
- return active && "value" in active ? active.value : undefined;
1477
- })()`, state);
1478
- if (currentValue !== value) {
1479
- return evaluate(tabId, `(function() {
1480
- const active = document.activeElement;
1481
- if (!active || !("value" in active)) return false;
1482
- active.value = ${JSON.stringify(value)};
1483
- active.dispatchEvent(new Event("input", { bubbles: true }));
1484
- active.dispatchEvent(new Event("change", { bubbles: true }));
1485
- return active.value;
1486
- })()`, state);
1487
- }
1488
- return result;
1489
- }
1490
-
1491
- /** Select a value in a dropdown by ref. */
1492
- export async function select(tabId: string, ref: string, value: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1493
- await click(tabId, ref, state);
1494
- const result = await action(tabId, "select", ref, value, state);
1495
- const currentValue = await evaluate(tabId, `(() => {
1496
- const active = document.activeElement;
1497
- return active && "value" in active ? active.value : undefined;
1498
- })()`, state);
1499
- if (currentValue !== value) {
1500
- return evaluate(tabId, `(function() {
1501
- const active = document.activeElement;
1502
- if (!active || !("value" in active)) return false;
1503
- active.value = ${JSON.stringify(value)};
1504
- active.dispatchEvent(new Event("input", { bubbles: true }));
1505
- active.dispatchEvent(new Event("change", { bubbles: true }));
1506
- return active.value;
1507
- })()`, state);
1508
- }
1509
- return result;
1510
- }
1511
-
1512
- /**
1513
- * Scroll the page.
1514
- * Direction/amount are accepted for caller compatibility; current Kuri HTTP
1515
- * action only exposes generic page scroll.
1516
- */
1517
- export async function scroll(
1518
- tabId: string,
1519
- _direction: "up" | "down" | "left" | "right" = "down",
1520
- _amount?: number,
1521
- state: BrokerState = defaultBrokerState,
1522
- ): Promise<unknown> {
1523
- return kuriGet(state, "/action", { tab_id: tabId, action: "scroll", ref: "_" });
1524
- }
1525
-
1526
- /** Press a key on a target element (focuses first if ref provided). */
1527
- export async function press(tabId: string, key: string, ref?: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1528
- if (ref && ref !== "_") {
1529
- await click(tabId, ref, state);
1530
- }
1531
- return kuriGet(state, "/action", { tab_id: tabId, action: "press", ref: ref ?? "_", value: key });
1532
- }
1533
-
1534
- // ---------------------------------------------------------------------------
1535
- // Wait primitives
1536
- // ---------------------------------------------------------------------------
1537
-
1538
- /** Wait for a CSS selector to appear, or for page load if no selector given. */
1539
- export async function waitForSelector(
1540
- tabId: string,
1541
- selector?: string,
1542
- timeoutMs?: number,
1543
- state: BrokerState = defaultBrokerState,
1544
- ): Promise<KuriWaitResult> {
1545
- const params: Record<string, string> = { tab_id: tabId };
1546
- if (selector) params.selector = selector;
1547
- if (timeoutMs !== undefined) params.timeout = String(timeoutMs);
1548
- return (await kuriGet(state, "/wait", params)) as KuriWaitResult;
1549
- }
1550
-
1551
- /** Wait for page load (document.readyState === "complete"). */
1552
- export async function waitForLoad(tabId: string, timeoutMs?: number, state: BrokerState = defaultBrokerState): Promise<KuriWaitResult> {
1553
- return waitForSelector(tabId, undefined, timeoutMs, state);
1554
- }
1555
-
1556
- // ---------------------------------------------------------------------------
1557
- // Keyboard input
1558
- // ---------------------------------------------------------------------------
1559
-
1560
- /** Type text character by character via CDP Input.dispatchKeyEvent. */
1561
- export async function keyboardType(tabId: string, text: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1562
- return kuriGet(state, "/keyboard/type", { tab_id: tabId, text });
1563
- }
1564
-
1565
- /** Insert text at cursor (single CDP call, faster than keyboardType). */
1566
- export async function keyboardInsertText(tabId: string, text: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1567
- return kuriGet(state, "/keyboard/inserttext", { tab_id: tabId, text });
1568
- }
1569
-
1570
- /** Dispatch a keydown event. */
1571
- export async function keyDown(tabId: string, key: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1572
- return kuriGet(state, "/keydown", { tab_id: tabId, key });
1573
- }
1574
-
1575
- /** Dispatch a keyup event. */
1576
- export async function keyUp(tabId: string, key: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1577
- return kuriGet(state, "/keyup", { tab_id: tabId, key });
1578
- }
1579
-
1580
- // ---------------------------------------------------------------------------
1581
- // Scroll / drag
1582
- // ---------------------------------------------------------------------------
1583
-
1584
- /** Scroll an element into view by ref. */
1585
- export async function scrollIntoView(tabId: string, ref: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1586
- return kuriGet(state, "/scrollintoview", { tab_id: tabId, ref });
1587
- }
1588
-
1589
- /** Drag from one element to another by ref. */
1590
- export async function drag(
1591
- tabId: string,
1592
- sourceRef: string,
1593
- targetRef: string,
1594
- state: BrokerState = defaultBrokerState,
1595
- ): Promise<unknown> {
1596
- return kuriGet(state, "/drag", { tab_id: tabId, source: sourceRef, target: targetRef });
1597
- }
1598
-
1599
- // ---------------------------------------------------------------------------
1600
- // DOM inspection
1601
- // ---------------------------------------------------------------------------
1602
-
1603
- /** Query DOM by CSS selector. Set all=true to match all elements. */
1604
- export async function domQuery(
1605
- tabId: string,
1606
- selector: string,
1607
- all = false,
1608
- state: BrokerState = defaultBrokerState,
1609
- ): Promise<KuriDomQueryResult> {
1610
- const params: Record<string, string> = { tab_id: tabId, selector };
1611
- if (all) params.all = "true";
1612
- return (await kuriGet(state, "/dom/query", params)) as KuriDomQueryResult;
1613
- }
1614
-
1615
- /** Get outer HTML of a DOM node by nodeId. */
1616
- export async function domHtml(tabId: string, nodeId: number, state: BrokerState = defaultBrokerState): Promise<unknown> {
1617
- return kuriGet(state, "/dom/html", { tab_id: tabId, node_id: String(nodeId) });
1618
- }
1619
-
1620
- /** Get attributes of an element by ref or selector. */
1621
- export async function domAttributes(
1622
- tabId: string,
1623
- opts: { ref?: string; selector?: string },
1624
- state: BrokerState = defaultBrokerState,
1625
- ): Promise<unknown> {
1626
- const params: Record<string, string> = { tab_id: tabId };
1627
- if (opts.ref) params.ref = opts.ref;
1628
- if (opts.selector) params.selector = opts.selector;
1629
- return kuriGet(state, "/dom/attributes", params);
1630
- }
1631
-
1632
- // ---------------------------------------------------------------------------
1633
- // Script injection
1634
- // ---------------------------------------------------------------------------
1635
-
1636
- /** Inject a JavaScript source that runs on every page load (Page.addScriptToEvaluateOnNewDocument). */
1637
- export async function scriptInject(tabId: string, source: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1638
- return kuriPost(state, "/script/inject", { tab_id: tabId }, { source });
1639
- }
1640
-
1641
- // ---------------------------------------------------------------------------
1642
- // Auth / credentials
1643
- // ---------------------------------------------------------------------------
1644
-
1645
- /** Set HTTP Basic auth credentials for a tab (auto-responds to auth challenges). */
1646
- export async function setCredentials(
1647
- tabId: string,
1648
- username: string,
1649
- password: string,
1650
- state: BrokerState = defaultBrokerState,
1651
- ): Promise<unknown> {
1652
- return kuriGet(state, "/set/credentials", { tab_id: tabId, username, password });
1653
- }
1654
-
1655
- /** Set browser viewport size. */
1656
- export async function setViewport(
1657
- tabId: string,
1658
- width: number,
1659
- height: number,
1660
- state: BrokerState = defaultBrokerState,
1661
- ): Promise<unknown> {
1662
- return kuriGet(state, "/set/viewport", { tab_id: tabId, width: String(width), height: String(height) });
1663
- }
1664
-
1665
- /** Set user agent string. */
1666
- export async function setUserAgent(tabId: string, ua: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1667
- return kuriGet(state, "/set/useragent", { tab_id: tabId, ua });
1668
- }
1669
-
1670
- // ---------------------------------------------------------------------------
1671
- // Session persistence
1672
- // ---------------------------------------------------------------------------
1673
-
1674
- /** Export Kuri's current state (tabs, cookies, snapshot cache) as JSON. */
1675
- export async function sessionSave(state: BrokerState = defaultBrokerState): Promise<unknown> {
1676
- return kuriGet(state, "/session/save");
1677
- }
1678
-
1679
- /** Import a previously saved session state. */
1680
- export async function sessionLoad(value: unknown, state: BrokerState = defaultBrokerState): Promise<{ imported: number }> {
1681
- return kuriPost(state, "/session/load", {}, value) as Promise<{ imported: number }>;
1682
- }
1683
-
1684
- /** List saved sessions. */
1685
- export async function sessionList(state: BrokerState = defaultBrokerState): Promise<unknown> {
1686
- return kuriGet(state, "/session/list");
1687
- }
1688
-
1689
- // ---------------------------------------------------------------------------
1690
- // Navigation helpers
1691
- // ---------------------------------------------------------------------------
1692
-
1693
- /** Go back in browser history. */
1694
- export async function goBack(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1695
- return kuriGet(state, "/back", { tab_id: tabId });
1696
- }
1697
-
1698
- /** Go forward in browser history. */
1699
- export async function goForward(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1700
- return kuriGet(state, "/forward", { tab_id: tabId });
1701
- }
1702
-
1703
- /** Reload the current page. */
1704
- export async function reload(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1705
- return kuriGet(state, "/reload", { tab_id: tabId });
1706
- }
1707
-
1708
- // ---------------------------------------------------------------------------
1709
- // Observability
1710
- // ---------------------------------------------------------------------------
1711
-
1712
- /** Get network events for a tab (requires prior /network?mode=enable). */
1713
- export async function getNetworkEvents(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1714
- return kuriGet(state, "/network", { tab_id: tabId });
1715
- }
1716
-
1717
- /** Get Largest Contentful Paint metrics. */
1718
- export async function getPerfLcp(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1719
- return kuriGet(state, "/perf/lcp", { tab_id: tabId });
1720
- }
1721
-
1722
- /** Find text on the page (like Ctrl+F). */
1723
- export async function findText(tabId: string, query: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1724
- return kuriGet(state, "/find", { tab_id: tabId, query });
1725
- }
1726
-
1727
- /** Get page links. */
1728
- export async function getLinks(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1729
- return kuriGet(state, "/links", { tab_id: tabId });
1730
- }
1731
-
1732
- /** Get console log messages. */
1733
- export async function getConsole(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1734
- return kuriGet(state, "/console", { tab_id: tabId });
1735
- }
1736
-
1737
- /** Get JavaScript errors from the page. */
1738
- export async function getErrors(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1739
- return kuriGet(state, "/errors", { tab_id: tabId });
1740
- }
1741
-
1742
- // ── Auth Profiles ─────────────────────────────────────────────────────
1743
-
1744
- /** Save cookies + storage as a named auth profile (persisted in Keychain on macOS). */
1745
- export async function authProfileSave(tabId: string, name: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1746
- return kuriGet(state, "/auth/profile/save", { tab_id: tabId, name });
1747
- }
1748
-
1749
- /** Load a named auth profile into a tab (restores cookies + storage). */
1750
- export async function authProfileLoad(tabId: string, name: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1751
- return kuriGet(state, "/auth/profile/load", { tab_id: tabId, name });
1752
- }
1753
-
1754
- /** List saved auth profiles. */
1755
- export async function authProfileList(tabId: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1756
- return kuriGet(state, "/auth/profile/list", { tab_id: tabId });
1757
- }
1758
-
1759
- /** Delete a saved auth profile. */
1760
- export async function authProfileDelete(name: string, state: BrokerState = defaultBrokerState): Promise<unknown> {
1761
- return kuriGet(state, "/auth/profile/delete", { name });
1762
- }
1763
-
1764
- export function getKuriClient(port?: number): KuriClient {
1765
- const cached = brokerClients.get(brokerCacheKey(port));
1766
- if (cached) return cached;
1767
-
1768
- const state = port === undefined ? defaultBrokerState : createBrokerState(port);
1769
- const client: KuriClient = {
1770
- start: (requestedPort?: number) => start(requestedPort ?? state.requestedPort, state),
1771
- stop: () => stop(state),
1772
- discoverTabs: () => discoverTabs(state),
1773
- getDefaultTab: () => getDefaultTab(state),
1774
- navigate: (tabId, url) => navigate(tabId, url, state),
1775
- evaluate: (tabId, expression) => evaluate(tabId, expression, state),
1776
- getCookies: (tabId) => getCookies(tabId, state),
1777
- setCookie: (tabId, cookie) => setCookie(tabId, cookie, state),
1778
- setCookies: (tabId, cookies) => setCookies(tabId, cookies, state),
1779
- setHeaders: (tabId, headers) => setHeaders(tabId, headers, state),
1780
- harStart: (tabId) => harStart(tabId, state),
1781
- harStop: (tabId) => harStop(tabId, state),
1782
- networkEnable: (tabId) => networkEnable(tabId, state),
1783
- interceptStart: (tabId) => interceptStart(tabId, state),
1784
- getText: (tabId) => getText(tabId, state),
1785
- getMarkdown: (tabId) => getMarkdown(tabId, state),
1786
- screenshot: (tabId) => screenshot(tabId, state),
1787
- snapshot: (tabId, filter) => snapshot(tabId, filter, state),
1788
- closeTab: (tabId) => closeTab(tabId, state),
1789
- newTab: (url?: string) => newTab(url, state),
1790
- getCurrentUrl: (tabId) => getCurrentUrl(tabId, state),
1791
- getPageHtml: (tabId) => getPageHtml(tabId, state),
1792
- bestEffortRehydratePlugins: (tabId) => bestEffortRehydratePlugins(tabId, state),
1793
- hasCloudflareChallenge: (tabId) => hasCloudflareChallenge(tabId, state),
1794
- waitForCloudflare: (tabId, maxWaitMs) => waitForCloudflare(tabId, maxWaitMs, state),
1795
- executeInPageFetch: (tabId, url, method, headers, body) => executeInPageFetch(tabId, url, method, headers, body, state),
1796
- health: () => health(state),
1797
- getPort: () => getPort(state),
1798
- isReady: () => isReady(state),
1799
- action: (tabId, actionType, ref, value) => action(tabId, actionType, ref, value, state),
1800
- click: (tabId, ref) => click(tabId, ref, state),
1801
- fill: (tabId, ref, value) => fill(tabId, ref, value, state),
1802
- select: (tabId, ref, value) => select(tabId, ref, value, state),
1803
- scroll: (tabId, direction, amount) => scroll(tabId, direction, amount, state),
1804
- press: (tabId, key, ref) => press(tabId, key, ref, state),
1805
- waitForSelector: (tabId, selector, timeoutMs) => waitForSelector(tabId, selector, timeoutMs, state),
1806
- waitForLoad: (tabId, timeoutMs) => waitForLoad(tabId, timeoutMs, state),
1807
- keyboardType: (tabId, text) => keyboardType(tabId, text, state),
1808
- keyboardInsertText: (tabId, text) => keyboardInsertText(tabId, text, state),
1809
- keyDown: (tabId, key) => keyDown(tabId, key, state),
1810
- keyUp: (tabId, key) => keyUp(tabId, key, state),
1811
- scrollIntoView: (tabId, ref) => scrollIntoView(tabId, ref, state),
1812
- drag: (tabId, sourceRef, targetRef) => drag(tabId, sourceRef, targetRef, state),
1813
- domQuery: (tabId, selector, all) => domQuery(tabId, selector, all, state),
1814
- domHtml: (tabId, nodeId) => domHtml(tabId, nodeId, state),
1815
- domAttributes: (tabId, opts) => domAttributes(tabId, opts, state),
1816
- scriptInject: (tabId, source) => scriptInject(tabId, source, state),
1817
- setCredentials: (tabId, username, password) => setCredentials(tabId, username, password, state),
1818
- setViewport: (tabId, width, height) => setViewport(tabId, width, height, state),
1819
- setUserAgent: (tabId, ua) => setUserAgent(tabId, ua, state),
1820
- sessionSave: () => sessionSave(state),
1821
- sessionLoad: (value) => sessionLoad(value, state),
1822
- sessionList: () => sessionList(state),
1823
- goBack: (tabId) => goBack(tabId, state),
1824
- goForward: (tabId) => goForward(tabId, state),
1825
- reload: (tabId) => reload(tabId, state),
1826
- getNetworkEvents: (tabId) => getNetworkEvents(tabId, state),
1827
- getPerfLcp: (tabId) => getPerfLcp(tabId, state),
1828
- findText: (tabId, query) => findText(tabId, query, state),
1829
- getLinks: (tabId) => getLinks(tabId, state),
1830
- getConsole: (tabId) => getConsole(tabId, state),
1831
- getErrors: (tabId) => getErrors(tabId, state),
1832
- authProfileSave: (tabId, name) => authProfileSave(tabId, name, state),
1833
- authProfileLoad: (tabId, name) => authProfileLoad(tabId, name, state),
1834
- authProfileList: (tabId) => authProfileList(tabId, state),
1835
- authProfileDelete: (name) => authProfileDelete(name, state),
1836
- };
1837
- rememberBrokerClient(client, state);
1838
- return client;
1839
- }