iframer-toolkit 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,7 @@ var __export = (target, all) => {
41
41
  set: __exportSetter.bind(all, name)
42
42
  });
43
43
  };
44
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
44
45
 
45
46
  // src/lib/session/persistence.ts
46
47
  var exports_persistence = {};
@@ -99,17 +100,31 @@ async function injectStorage(page, sessionData) {
99
100
  }
100
101
  }
101
102
 
103
+ // src/lib/paths.ts
104
+ var exports_paths = {};
105
+ __export(exports_paths, {
106
+ getDataDir: () => getDataDir
107
+ });
108
+ function getDataDir() {
109
+ return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
110
+ }
111
+ var import_path, import_os;
112
+ var init_paths = __esm(() => {
113
+ import_path = __toESM(require("path"));
114
+ import_os = __toESM(require("os"));
115
+ });
116
+
102
117
  // index.ts
103
118
  var import_express = __toESM(require("express"));
104
- var import_path10 = __toESM(require("path"));
105
- var import_fs11 = __toESM(require("fs"));
119
+ var import_path12 = __toESM(require("path"));
120
+ var import_fs13 = __toESM(require("fs"));
106
121
  var import_url2 = require("url");
107
122
 
108
123
  // src/api/routes.ts
109
124
  var import_patchright3 = require("patchright");
110
125
 
111
126
  // src/lib/iframer.ts
112
- var import_path9 = __toESM(require("path"));
127
+ var import_path11 = __toESM(require("path"));
113
128
  var import_url = require("url");
114
129
 
115
130
  // src/lib/browser/session-manager.ts
@@ -661,7 +676,10 @@ var TIMEOUTS = {
661
676
  TOTP_INPUT: 5000,
662
677
  API_REQUEST: 180000,
663
678
  HEALTH_CHECK: 3000,
664
- CHALLENGE_FRAME_WAIT: 5000
679
+ CHALLENGE_FRAME_WAIT: 5000,
680
+ TAB_FOLLOW_SETTLE: 400,
681
+ TAB_LOAD: 15000,
682
+ TAB_BLANK_RESOLVE: 3000
665
683
  };
666
684
  var CHROME_MIN_VERSION = 130;
667
685
 
@@ -821,19 +839,11 @@ async function cleanupAllSessions() {
821
839
  }
822
840
 
823
841
  // src/lib/auth/crypto.ts
842
+ init_paths();
824
843
  var import_crypto = __toESM(require("crypto"));
825
844
  var import_fs3 = __toESM(require("fs"));
826
845
  var import_os2 = __toESM(require("os"));
827
846
  var import_path2 = __toESM(require("path"));
828
-
829
- // src/lib/paths.ts
830
- var import_path = __toESM(require("path"));
831
- var import_os = __toESM(require("os"));
832
- function getDataDir() {
833
- return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
834
- }
835
-
836
- // src/lib/auth/crypto.ts
837
847
  var SALT = "iframer-session";
838
848
  var INFO = "encryption";
839
849
  var KEY_LENGTH = 32;
@@ -1077,6 +1087,7 @@ class SqliteStore {
1077
1087
  }
1078
1088
 
1079
1089
  // src/lib/storage.ts
1090
+ init_paths();
1080
1091
  function createStore(options = {}) {
1081
1092
  const dataDir = options.dataDir || getDataDir();
1082
1093
  return new SqliteStore(dataDir);
@@ -1225,7 +1236,12 @@ async function ensureChrome() {
1225
1236
  // src/lib/browser/cloak-browser.ts
1226
1237
  var log5 = createLogger("cloak");
1227
1238
  var _available = null;
1239
+ function cloakEnabled() {
1240
+ return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
1241
+ }
1228
1242
  async function tryImport() {
1243
+ if (!cloakEnabled())
1244
+ return null;
1229
1245
  try {
1230
1246
  return await import("cloakbrowser");
1231
1247
  } catch {
@@ -1272,6 +1288,7 @@ async function launchCloakBrowser(options) {
1272
1288
  }
1273
1289
 
1274
1290
  // src/lib/browser/registry.ts
1291
+ init_paths();
1275
1292
  var import_fs7 = __toESM(require("fs"));
1276
1293
  var import_path6 = __toESM(require("path"));
1277
1294
  var import_child_process3 = require("child_process");
@@ -1616,6 +1633,7 @@ class BrowserDaemon {
1616
1633
  // src/lib/domain-modes.ts
1617
1634
  var import_fs8 = __toESM(require("fs"));
1618
1635
  var import_path7 = __toESM(require("path"));
1636
+ init_paths();
1619
1637
  var log8 = createLogger("domain-modes");
1620
1638
  function defaultFile() {
1621
1639
  return import_path7.default.join(getDataDir(), "domain-modes.json");
@@ -1771,11 +1789,56 @@ class RefStore {
1771
1789
  }
1772
1790
  }
1773
1791
 
1792
+ // src/lib/execution/pipeline-executor.ts
1793
+ var import_playwright_core = require("playwright-core");
1794
+
1774
1795
  // src/lib/actions/types.ts
1775
1796
  function failedStepResult(step, error, durationMs, stepIndex = -1) {
1776
1797
  return { stepIndex, step, ok: false, error, durationMs };
1777
1798
  }
1778
1799
 
1800
+ // src/lib/clipboard.ts
1801
+ var import_child_process4 = require("child_process");
1802
+ function platformTools(mode) {
1803
+ if (process.platform === "darwin")
1804
+ return [[mode === "read" ? "pbpaste" : "pbcopy"]];
1805
+ if (mode === "read")
1806
+ return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
1807
+ return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
1808
+ }
1809
+ function run(cmd, input) {
1810
+ return new Promise((resolve) => {
1811
+ let child;
1812
+ try {
1813
+ child = import_child_process4.spawn(cmd[0], cmd.slice(1));
1814
+ } catch {
1815
+ resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
1816
+ return;
1817
+ }
1818
+ let out = "";
1819
+ let errOut = "";
1820
+ child.stdout?.on("data", (d) => out += d);
1821
+ child.stderr?.on("data", (d) => errOut += d);
1822
+ child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
1823
+ child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
1824
+ if (input !== undefined) {
1825
+ child.stdin?.write(input);
1826
+ child.stdin?.end();
1827
+ }
1828
+ });
1829
+ }
1830
+ async function clipboardRead() {
1831
+ const tools = platformTools("read");
1832
+ let lastErr = "";
1833
+ for (const cmd of tools) {
1834
+ const r = await run(cmd);
1835
+ if (r.ok)
1836
+ return r.out;
1837
+ lastErr = r.err;
1838
+ }
1839
+ throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
1840
+ }
1841
+
1779
1842
  // src/lib/browser/humanize.ts
1780
1843
  function rand(min, max) {
1781
1844
  return Math.random() * (max - min) + min;
@@ -1980,6 +2043,15 @@ function sleep3(ms) {
1980
2043
  }
1981
2044
  // src/lib/actions/resolve-selector.ts
1982
2045
  function resolveSelector(selector, ctx) {
2046
+ if (selector.startsWith("@a:")) {
2047
+ const name = selector.slice(3);
2048
+ const anchor = ctx.anchors?.get(name);
2049
+ if (!anchor) {
2050
+ const available = ctx.anchors ? Array.from(ctx.anchors.keys()).join(", ") : "";
2051
+ throw new Error(`Unknown anchor: @a:${name}${ctx.anchorDomain ? ` for ${ctx.anchorDomain}` : ""}. ` + `${available ? `Known anchors: ${available}. ` : "This domain has no saved anchors yet. "}` + `Run a snapshot/find to locate the element, act on it, then save it with the ` + `\`remember\` tool so future runs skip the search.`);
2052
+ }
2053
+ return anchor.selector;
2054
+ }
1983
2055
  if (selector.startsWith("@e")) {
1984
2056
  const ref = ctx.refMap.get(selector);
1985
2057
  if (!ref) {
@@ -1990,6 +2062,9 @@ function resolveSelector(selector, ctx) {
1990
2062
  }
1991
2063
  return selector;
1992
2064
  }
2065
+ function anchorNameOf(selector) {
2066
+ return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2067
+ }
1993
2068
 
1994
2069
  // src/lib/actions/handlers/navigation.ts
1995
2070
  var log9 = createLogger("actions");
@@ -2016,7 +2091,42 @@ async function click(page, step, ctx) {
2016
2091
  await page.click(resolveSelector(step.selector, ctx));
2017
2092
  }
2018
2093
  async function fill(page, step, ctx) {
2019
- await page.fill(resolveSelector(step.selector, ctx), step.value);
2094
+ const selector = resolveSelector(step.selector, ctx);
2095
+ const value = step.value;
2096
+ await page.fill(selector, value);
2097
+ const stuck = await page.evaluate(({ sel, val }) => {
2098
+ const el = document.querySelector(sel);
2099
+ if (!el)
2100
+ return false;
2101
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
2102
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
2103
+ try {
2104
+ el.focus();
2105
+ } catch {}
2106
+ try {
2107
+ setter ? setter.call(el, val) : el.value = val;
2108
+ } catch {
2109
+ el.value = val;
2110
+ }
2111
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2112
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2113
+ try {
2114
+ el.blur();
2115
+ } catch {}
2116
+ return el.value === val;
2117
+ }, { sel: selector, val: value });
2118
+ if (!stuck) {
2119
+ const loc = page.locator(selector);
2120
+ try {
2121
+ await loc.click();
2122
+ await loc.fill("");
2123
+ await loc.pressSequentially(value, { delay: 15 });
2124
+ await page.evaluate((sel) => {
2125
+ const el = document.querySelector(sel);
2126
+ el?.blur?.();
2127
+ }, selector);
2128
+ } catch {}
2129
+ }
2020
2130
  }
2021
2131
  async function humanClickStep(page, step, ctx) {
2022
2132
  if (step.selector) {
@@ -2051,11 +2161,82 @@ async function wait(page, step) {
2051
2161
  async function waitFor(page, step, ctx) {
2052
2162
  await page.waitForSelector(resolveSelector(step.selector, ctx), { timeout: step.timeout || TIMEOUTS.SELECTOR_WAIT });
2053
2163
  }
2054
- async function scroll(page, step) {
2055
- await page.evaluate((dy) => window.scrollBy(0, dy || document.body.scrollHeight), step.deltaY ?? 0);
2164
+ async function scroll(page, step, ctx) {
2165
+ const selector = step.selector ? resolveSelector(step.selector, ctx) : null;
2166
+ await page.evaluate(({ dy, sel }) => {
2167
+ if (sel) {
2168
+ const el = document.querySelector(sel);
2169
+ if (!el)
2170
+ throw new Error(`scroll: no element for selector ${sel}`);
2171
+ el.scrollBy(0, dy || el.scrollHeight);
2172
+ } else {
2173
+ window.scrollBy(0, dy || document.body.scrollHeight);
2174
+ }
2175
+ }, { dy: step.deltaY ?? 0, sel: selector });
2056
2176
  }
2057
2177
  async function keyboard(page, step) {
2058
- await page.keyboard.press(step.key);
2178
+ const mods = [
2179
+ step.meta ? "Meta" : null,
2180
+ step.ctrl ? "Control" : null,
2181
+ step.shift ? "Shift" : null,
2182
+ step.alt ? "Alt" : null
2183
+ ].filter(Boolean);
2184
+ await page.keyboard.press(mods.length ? `${mods.join("+")}+${step.key}` : step.key);
2185
+ }
2186
+ async function read(page, step, ctx) {
2187
+ const selector = step.selector ? resolveSelector(step.selector, ctx) : "body";
2188
+ const raw = await page.evaluate((sel) => {
2189
+ const el = sel === "body" ? document.body : document.querySelector(sel);
2190
+ if (!el)
2191
+ return null;
2192
+ return el.innerText || el.textContent || "";
2193
+ }, selector);
2194
+ if (raw == null)
2195
+ throw new Error(`read: no element for selector ${selector}`);
2196
+ const text = raw.replace(/\n{3,}/g, `
2197
+
2198
+ `).trim();
2199
+ const max = step.maxChars || 6000;
2200
+ return { text: text.slice(0, max), truncated: text.length > max };
2201
+ }
2202
+ async function upload(page, step, ctx) {
2203
+ if (!step.files || step.files.length === 0)
2204
+ throw new Error("upload: `files` must be a non-empty array of local paths");
2205
+ await page.setInputFiles(resolveSelector(step.selector, ctx), step.files);
2206
+ return { uploaded: step.files.length, files: step.files };
2207
+ }
2208
+ async function paste(page, step, ctx) {
2209
+ const text = await clipboardRead();
2210
+ if (step.selector) {
2211
+ await page.click(resolveSelector(step.selector, ctx));
2212
+ }
2213
+ await page.keyboard.insertText(text);
2214
+ return { pasted: text.length };
2215
+ }
2216
+ async function download(page, step) {
2217
+ if (!step.url)
2218
+ throw new Error("download: `url` is required");
2219
+ const resp = await page.request.get(step.url);
2220
+ const status = resp.status();
2221
+ if (!resp.ok())
2222
+ throw new Error(`download: HTTP ${status} for ${step.url}`);
2223
+ const buf = await resp.body();
2224
+ const fs9 = await import("fs");
2225
+ const pathMod = await import("path");
2226
+ const { getDataDir: getDataDir2 } = await Promise.resolve().then(() => (init_paths(), exports_paths));
2227
+ let outPath;
2228
+ if (step.path && pathMod.isAbsolute(step.path)) {
2229
+ outPath = step.path;
2230
+ } else {
2231
+ let name = "download";
2232
+ try {
2233
+ name = decodeURIComponent(new URL(step.url).pathname.split("/").pop() || "") || "download";
2234
+ } catch {}
2235
+ outPath = pathMod.join(getDataDir2(), "downloads", step.path || name);
2236
+ }
2237
+ fs9.mkdirSync(pathMod.dirname(outPath), { recursive: true });
2238
+ fs9.writeFileSync(outPath, buf);
2239
+ return { path: outPath, size: buf.length, status };
2059
2240
  }
2060
2241
  async function typeCode(page, step, ctx) {
2061
2242
  const code = String(step.value || "");
@@ -3083,6 +3264,7 @@ async function findSubmitButton(page, opts) {
3083
3264
  // src/lib/knowledge.ts
3084
3265
  var import_fs9 = __toESM(require("fs"));
3085
3266
  var import_path8 = __toESM(require("path"));
3267
+ init_paths();
3086
3268
  var log13 = createLogger("knowledge");
3087
3269
  function getKnowledgeDir() {
3088
3270
  return import_path8.default.join(getDataDir(), "knowledge");
@@ -3637,6 +3819,10 @@ var registry = {
3637
3819
  "wait-for": waitFor,
3638
3820
  scroll,
3639
3821
  keyboard,
3822
+ read,
3823
+ upload,
3824
+ paste,
3825
+ download,
3640
3826
  "type-code": typeCode,
3641
3827
  find,
3642
3828
  screenshot,
@@ -4236,8 +4422,13 @@ class ApiCapture {
4236
4422
  currentStep = 0;
4237
4423
  requestHandler;
4238
4424
  responseHandler;
4425
+ context;
4426
+ hookedPages = new Set;
4427
+ pageHandler;
4239
4428
  constructor(page) {
4240
4429
  this.page = page;
4430
+ this.context = page.context();
4431
+ this.pageHandler = (p) => this.hookPage(p);
4241
4432
  this.requestHandler = (req) => {
4242
4433
  const resourceType = req.resourceType();
4243
4434
  if (SKIP_RESOURCE_TYPES.has(resourceType))
@@ -4304,16 +4495,29 @@ class ApiCapture {
4304
4495
  } catch {}
4305
4496
  };
4306
4497
  }
4498
+ hookPage(p) {
4499
+ if (this.hookedPages.has(p))
4500
+ return;
4501
+ this.hookedPages.add(p);
4502
+ p.on("request", this.requestHandler);
4503
+ p.on("response", this.responseHandler);
4504
+ }
4307
4505
  start() {
4308
- this.page.on("request", this.requestHandler);
4309
- this.page.on("response", this.responseHandler);
4506
+ this.hookPage(this.page);
4507
+ this.context.on("page", this.pageHandler);
4310
4508
  }
4311
4509
  setStep(index) {
4312
4510
  this.currentStep = index;
4313
4511
  }
4314
4512
  stop() {
4315
- this.page.off("request", this.requestHandler);
4316
- this.page.off("response", this.responseHandler);
4513
+ this.context.off("page", this.pageHandler);
4514
+ for (const p of this.hookedPages) {
4515
+ try {
4516
+ p.off("request", this.requestHandler);
4517
+ p.off("response", this.responseHandler);
4518
+ } catch {}
4519
+ }
4520
+ this.hookedPages.clear();
4317
4521
  }
4318
4522
  async drain(ms = 3000, pendingTimeoutMs = 5000) {
4319
4523
  await new Promise((r) => setTimeout(r, ms));
@@ -4322,99 +4526,241 @@ class ApiCapture {
4322
4526
  await new Promise((r) => setTimeout(r, 100));
4323
4527
  }
4324
4528
  }
4325
- extractAuth(requests) {
4326
- const auth = { cookies: {}, tokens: {} };
4327
- for (const req of requests) {
4328
- for (const [key, value] of Object.entries(req.requestHeaders)) {
4329
- const lower = key.toLowerCase();
4330
- if (lower === "authorization" && !auth.authorization) {
4331
- auth.authorization = value;
4332
- } else if (lower === "cookie") {
4333
- const cookies = parseCookies(value);
4334
- Object.assign(auth.cookies, cookies);
4335
- } else if (isAuthHeader(key) && lower !== "authorization" && lower !== "cookie") {
4336
- auth.tokens[key] = value;
4529
+ getResults() {
4530
+ return buildCapturedApi(this.requests);
4531
+ }
4532
+ }
4533
+ function extractAuth(requests) {
4534
+ const auth = { cookies: {}, tokens: {} };
4535
+ for (const req of requests) {
4536
+ for (const [key, value] of Object.entries(req.requestHeaders)) {
4537
+ const lower = key.toLowerCase();
4538
+ if (lower === "authorization" && !auth.authorization) {
4539
+ auth.authorization = value;
4540
+ } else if (lower === "cookie") {
4541
+ const cookies = parseCookies(value);
4542
+ Object.assign(auth.cookies, cookies);
4543
+ } else if (isAuthHeader(key) && lower !== "authorization" && lower !== "cookie") {
4544
+ auth.tokens[key] = value;
4545
+ }
4546
+ }
4547
+ }
4548
+ return auth;
4549
+ }
4550
+ function splitHeaders(headers) {
4551
+ const endpointHeaders = {};
4552
+ for (const [key, value] of Object.entries(headers)) {
4553
+ const lower = key.toLowerCase();
4554
+ if (BROWSER_NOISE_HEADERS.has(lower))
4555
+ continue;
4556
+ if (isAuthHeader(key))
4557
+ continue;
4558
+ if (lower === "user-agent")
4559
+ continue;
4560
+ endpointHeaders[key] = value;
4561
+ }
4562
+ return endpointHeaders;
4563
+ }
4564
+ function buildCapturedApi(requests) {
4565
+ const byDomain = new Map;
4566
+ for (const req of requests) {
4567
+ try {
4568
+ const host = new URL(req.url).origin;
4569
+ if (!byDomain.has(host))
4570
+ byDomain.set(host, []);
4571
+ byDomain.get(host)?.push(req);
4572
+ } catch {}
4573
+ }
4574
+ const apis = [];
4575
+ for (const [baseUrl, domainRequests] of byDomain) {
4576
+ const auth = extractAuth(domainRequests);
4577
+ const endpointMap = new Map;
4578
+ for (const req of domainRequests) {
4579
+ const paramPath = parameterizePath(req.path);
4580
+ const { protocol, action } = classifyRequest(req);
4581
+ const key = `${protocol}:${action}`;
4582
+ const endpointHeaders = splitHeaders(req.requestHeaders);
4583
+ if (!endpointMap.has(key)) {
4584
+ const verb = inferVerb(protocol, action, req.method, req.responseBody);
4585
+ const functionName = buildFunctionName(protocol, action, req.method, verb);
4586
+ endpointMap.set(key, {
4587
+ method: req.method,
4588
+ path: paramPath,
4589
+ rawPaths: [req.path],
4590
+ queryParams: req.queryParams,
4591
+ headers: endpointHeaders,
4592
+ requestBody: req.requestBody,
4593
+ responseStatus: req.responseStatus,
4594
+ responseBody: req.responseBody,
4595
+ triggeredAtStep: req.triggeredAtStep,
4596
+ curl: buildCurl(req.method, req.url, endpointHeaders, auth, req.requestBody),
4597
+ protocol,
4598
+ action,
4599
+ verb,
4600
+ functionName
4601
+ });
4602
+ } else {
4603
+ const existing = endpointMap.get(key);
4604
+ if (!existing)
4605
+ continue;
4606
+ if (!existing.rawPaths.includes(req.path)) {
4607
+ existing.rawPaths.push(req.path);
4337
4608
  }
4338
4609
  }
4339
4610
  }
4340
- return auth;
4611
+ const domain = new URL(baseUrl).hostname.replace(/\./g, "_");
4612
+ apis.push({
4613
+ domain,
4614
+ baseUrl,
4615
+ auth,
4616
+ endpoints: Array.from(endpointMap.values()).sort((a, b) => a.triggeredAtStep - b.triggeredAtStep),
4617
+ capturedAt: new Date().toISOString()
4618
+ });
4341
4619
  }
4342
- splitHeaders(headers) {
4343
- const endpointHeaders = {};
4344
- for (const [key, value] of Object.entries(headers)) {
4345
- const lower = key.toLowerCase();
4346
- if (BROWSER_NOISE_HEADERS.has(lower))
4347
- continue;
4348
- if (isAuthHeader(key))
4349
- continue;
4350
- if (lower === "user-agent")
4351
- continue;
4352
- endpointHeaders[key] = value;
4620
+ return apis.sort((a, b) => b.endpoints.length - a.endpoints.length);
4621
+ }
4622
+
4623
+ // src/lib/browser/tab-tracker.ts
4624
+ var log15 = createLogger("tabs");
4625
+
4626
+ class TabTracker {
4627
+ context;
4628
+ pages;
4629
+ activePage;
4630
+ newlyOpened = [];
4631
+ disposed = false;
4632
+ constructor(context, initial) {
4633
+ this.context = context;
4634
+ this.activePage = initial;
4635
+ this.pages = [...context.pages()];
4636
+ if (!this.pages.includes(initial))
4637
+ this.pages.push(initial);
4638
+ context.on("page", this.onNewPage);
4639
+ }
4640
+ onNewPage = (p) => {
4641
+ if (this.disposed)
4642
+ return;
4643
+ this.pages.push(p);
4644
+ this.newlyOpened.push(p);
4645
+ p.on("close", () => this.onClose(p));
4646
+ log15.debug(`new tab opened: ${safeUrl(p)}`);
4647
+ };
4648
+ onClose = (p) => {
4649
+ this.pages = this.pages.filter((x) => x !== p);
4650
+ this.newlyOpened = this.newlyOpened.filter((x) => x !== p);
4651
+ if (this.activePage === p) {
4652
+ this.activePage = this.pages[this.pages.length - 1] ?? p;
4653
+ log15.debug(`active tab closed, fell back to: ${safeUrl(this.activePage)}`);
4353
4654
  }
4354
- return endpointHeaders;
4655
+ };
4656
+ active() {
4657
+ return this.activePage;
4355
4658
  }
4356
- getResults() {
4357
- const byDomain = new Map;
4358
- for (const req of this.requests) {
4359
- try {
4360
- const host = new URL(req.url).origin;
4361
- if (!byDomain.has(host))
4362
- byDomain.set(host, []);
4363
- byDomain.get(host)?.push(req);
4364
- } catch {}
4659
+ count() {
4660
+ return this.pages.length;
4661
+ }
4662
+ async settle(opts) {
4663
+ if (this.newlyOpened.length === 0 && opts.waitForPendingMs > 0) {
4664
+ await this.context.waitForEvent("page", { timeout: opts.waitForPendingMs }).catch(() => null);
4365
4665
  }
4366
- const apis = [];
4367
- for (const [baseUrl, requests] of byDomain) {
4368
- const auth = this.extractAuth(requests);
4369
- const endpointMap = new Map;
4370
- for (const req of requests) {
4371
- const paramPath = parameterizePath(req.path);
4372
- const { protocol, action } = classifyRequest(req);
4373
- const key = `${protocol}:${action}`;
4374
- const endpointHeaders = this.splitHeaders(req.requestHeaders);
4375
- if (!endpointMap.has(key)) {
4376
- const verb = inferVerb(protocol, action, req.method, req.responseBody);
4377
- const functionName = buildFunctionName(protocol, action, req.method, verb);
4378
- endpointMap.set(key, {
4379
- method: req.method,
4380
- path: paramPath,
4381
- rawPaths: [req.path],
4382
- queryParams: req.queryParams,
4383
- headers: endpointHeaders,
4384
- requestBody: req.requestBody,
4385
- responseStatus: req.responseStatus,
4386
- responseBody: req.responseBody,
4387
- triggeredAtStep: req.triggeredAtStep,
4388
- curl: buildCurl(req.method, req.url, endpointHeaders, auth, req.requestBody),
4389
- protocol,
4390
- action,
4391
- verb,
4392
- functionName
4393
- });
4394
- } else {
4395
- const existing = endpointMap.get(key);
4396
- if (!existing)
4397
- continue;
4398
- if (!existing.rawPaths.includes(req.path)) {
4399
- existing.rawPaths.push(req.path);
4400
- }
4401
- }
4402
- }
4403
- const domain = new URL(baseUrl).hostname.replace(/\./g, "_");
4404
- apis.push({
4405
- domain,
4406
- baseUrl,
4407
- auth,
4408
- endpoints: Array.from(endpointMap.values()).sort((a, b) => a.triggeredAtStep - b.triggeredAtStep),
4409
- capturedAt: new Date().toISOString()
4410
- });
4666
+ if (this.newlyOpened.length === 0)
4667
+ return null;
4668
+ const target = this.newlyOpened[this.newlyOpened.length - 1];
4669
+ this.newlyOpened = [];
4670
+ if (target.isClosed())
4671
+ return null;
4672
+ await target.waitForLoadState("domcontentloaded", { timeout: opts.loadTimeoutMs }).catch(() => {});
4673
+ if (safeUrl(target) === "about:blank" || safeUrl(target) === "") {
4674
+ await target.waitForURL((u) => {
4675
+ const s = u.toString();
4676
+ return !!s && s !== "about:blank";
4677
+ }, { timeout: opts.blankResolveMs }).catch(() => {});
4678
+ await target.waitForLoadState("domcontentloaded", { timeout: opts.loadTimeoutMs }).catch(() => {});
4679
+ }
4680
+ const url = safeUrl(target);
4681
+ if (!url || url === "about:blank") {
4682
+ return null;
4411
4683
  }
4412
- return apis.sort((a, b) => b.endpoints.length - a.endpoints.length);
4684
+ await target.bringToFront().catch(() => {});
4685
+ this.activePage = target;
4686
+ const sw = { url, title: await target.title().catch(() => "") };
4687
+ log15.info(`followed new tab → ${sw.url}`);
4688
+ return sw;
4689
+ }
4690
+ discardPending() {
4691
+ this.newlyOpened = [];
4692
+ }
4693
+ dispose() {
4694
+ this.disposed = true;
4695
+ try {
4696
+ this.context.off("page", this.onNewPage);
4697
+ } catch {}
4698
+ }
4699
+ }
4700
+ function safeUrl(p) {
4701
+ try {
4702
+ return p.url();
4703
+ } catch {
4704
+ return "unknown";
4705
+ }
4706
+ }
4707
+
4708
+ // src/lib/knowledge/component-map.ts
4709
+ var import_fs10 = __toESM(require("fs"));
4710
+ var import_path9 = __toESM(require("path"));
4711
+ function anchorsPath(domain) {
4712
+ return import_path9.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
4713
+ }
4714
+ function loadComponentMap(domain) {
4715
+ const norm = normalizeDomain(domain);
4716
+ try {
4717
+ const raw = import_fs10.default.readFileSync(anchorsPath(norm), "utf8");
4718
+ const parsed = JSON.parse(raw);
4719
+ return {
4720
+ domain: parsed.domain || norm,
4721
+ anchors: parsed.anchors || {},
4722
+ quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
4723
+ };
4724
+ } catch {
4725
+ return { domain: norm, anchors: {}, quirks: [] };
4413
4726
  }
4414
4727
  }
4728
+ function loadAnchors(domain) {
4729
+ const map = new Map;
4730
+ const cm = loadComponentMap(domain);
4731
+ for (const [name, a] of Object.entries(cm.anchors))
4732
+ map.set(name, a);
4733
+ return map;
4734
+ }
4735
+ function write(cm) {
4736
+ import_fs10.default.mkdirSync(getKnowledgeDir(), { recursive: true });
4737
+ import_fs10.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
4738
+ }
4739
+ function recordAnchorResult(domain, name, ok, now) {
4740
+ try {
4741
+ const cm = loadComponentMap(domain);
4742
+ const a = cm.anchors[name];
4743
+ if (!a)
4744
+ return;
4745
+ if (ok) {
4746
+ a.uses += 1;
4747
+ a.lastVerified = now;
4748
+ } else {
4749
+ a.fails += 1;
4750
+ }
4751
+ write(cm);
4752
+ } catch {}
4753
+ }
4415
4754
 
4416
4755
  // src/lib/pipeline.ts
4417
4756
  var DEFAULT_STALE_TIMEOUT_MS2 = 20000;
4757
+ function safePageUrl(page) {
4758
+ try {
4759
+ return page.url();
4760
+ } catch {
4761
+ return "";
4762
+ }
4763
+ }
4418
4764
  function classifyError(err, step) {
4419
4765
  if (err instanceof StaleStateError)
4420
4766
  return "stale-state";
@@ -4457,7 +4803,15 @@ class PipelineRunner {
4457
4803
  constructor(ctx) {
4458
4804
  this.ctx = ctx;
4459
4805
  }
4460
- async run(page, pipeline) {
4806
+ async run(initialPage, pipeline) {
4807
+ const tracker = new TabTracker(initialPage.context(), initialPage);
4808
+ try {
4809
+ return await this.runSteps(initialPage, tracker, pipeline);
4810
+ } finally {
4811
+ tracker.dispose();
4812
+ }
4813
+ }
4814
+ async runSteps(initialPage, tracker, pipeline) {
4461
4815
  const startTime = Date.now();
4462
4816
  const opts = pipeline.options || {};
4463
4817
  const staleTimeoutMs = opts.staleTimeoutMs ?? this.ctx.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS2;
@@ -4466,7 +4820,20 @@ class PipelineRunner {
4466
4820
  const continueOnError = opts.continueOnError ?? false;
4467
4821
  const results = [];
4468
4822
  const obstacles = [];
4469
- const capture = opts.captureApi ? new ApiCapture(page) : null;
4823
+ const navStep = pipeline.steps.find((s) => s.type === "navigate");
4824
+ try {
4825
+ const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
4826
+ if (host && host !== "x") {
4827
+ this.ctx.anchors = loadAnchors(host);
4828
+ this.ctx.anchorDomain = host;
4829
+ }
4830
+ } catch {}
4831
+ const recordAnchor = (step, ok) => {
4832
+ const name = anchorNameOf(step.selector);
4833
+ if (name && this.ctx.anchorDomain)
4834
+ recordAnchorResult(this.ctx.anchorDomain, name, ok, new Date().toISOString());
4835
+ };
4836
+ const capture = opts.captureApi ? new ApiCapture(initialPage) : null;
4470
4837
  if (capture)
4471
4838
  capture.start();
4472
4839
  const finishCapture = async () => {
@@ -4479,6 +4846,8 @@ class PipelineRunner {
4479
4846
  if (capture)
4480
4847
  capture.setStep(i);
4481
4848
  const step = pipeline.steps[i];
4849
+ const page = tracker.active();
4850
+ const urlBefore = safePageUrl(page);
4482
4851
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
4483
4852
  let stepResult;
4484
4853
  try {
@@ -4490,9 +4859,10 @@ class PipelineRunner {
4490
4859
  } catch (err) {
4491
4860
  const asError = err instanceof Error ? err : new Error(String(err));
4492
4861
  const errorType = classifyError(asError, step);
4493
- const pageState = await capturePageState(page, this.ctx, { screenshot: true });
4862
+ const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
4494
4863
  stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
4495
4864
  results.push(stepResult);
4865
+ recordAnchor(step, false);
4496
4866
  return {
4497
4867
  ok: false,
4498
4868
  completedSteps: i,
@@ -4513,15 +4883,29 @@ class PipelineRunner {
4513
4883
  capturedApi: await finishCapture()
4514
4884
  };
4515
4885
  }
4886
+ recordAnchor(step, true);
4887
+ const canOpenTab = step.type === "click" || step.type === "human-click";
4888
+ const currentPageNavigated = safePageUrl(page) !== urlBefore;
4889
+ if (canOpenTab && !currentPageNavigated) {
4890
+ const switched = await tracker.settle({
4891
+ waitForPendingMs: TIMEOUTS.TAB_FOLLOW_SETTLE,
4892
+ loadTimeoutMs: TIMEOUTS.TAB_LOAD,
4893
+ blankResolveMs: TIMEOUTS.TAB_BLANK_RESOLVE
4894
+ });
4895
+ if (switched)
4896
+ stepResult.tabSwitchedTo = switched.url;
4897
+ } else {
4898
+ tracker.discardPending();
4899
+ }
4516
4900
  if (screenshotAfterEach && stepResult.ok) {
4517
4901
  try {
4518
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
4902
+ const buf = await tracker.active().screenshot({ type: "jpeg", quality: 50, fullPage: false });
4519
4903
  stepResult.screenshotUrl = saveScreenshot(buf, `step-${i}-${Date.now()}.jpg`, this.ctx.screenshotDir, this.ctx.publicUrl);
4520
4904
  } catch {}
4521
4905
  }
4522
4906
  results.push(stepResult);
4523
4907
  if (!stepResult.ok && !continueOnError) {
4524
- const pageState = await capturePageState(page, this.ctx, { screenshot: true });
4908
+ const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
4525
4909
  const errorType = classifyError(new Error(stepResult.error || ""), step);
4526
4910
  return {
4527
4911
  ok: false,
@@ -4545,9 +4929,10 @@ class PipelineRunner {
4545
4929
  }
4546
4930
  if (step.type === "navigate" && continueOnObstacle) {
4547
4931
  const obstacleStart = Date.now();
4548
- const obstacle = await detectObstacles(page);
4932
+ const obstaclePage = tracker.active();
4933
+ const obstacle = await detectObstacles(obstaclePage);
4549
4934
  if (obstacle) {
4550
- const resolution = await resolveObstacle(page, obstacle, this.ctx, monitor);
4935
+ const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
4551
4936
  obstacles.push({
4552
4937
  type: obstacle.type,
4553
4938
  detectedAtStep: i,
@@ -4556,7 +4941,7 @@ class PipelineRunner {
4556
4941
  durationMs: Date.now() - obstacleStart
4557
4942
  });
4558
4943
  if (!resolution.resolved && obstacle.type === "captcha") {
4559
- const pageState = await capturePageState(page, this.ctx, { screenshot: true });
4944
+ const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
4560
4945
  return {
4561
4946
  ok: false,
4562
4947
  completedSteps: i,
@@ -4581,7 +4966,7 @@ class PipelineRunner {
4581
4966
  }
4582
4967
  }
4583
4968
  const capturedApi = await finishCapture();
4584
- const finalState = await capturePageState(page, this.ctx, { screenshot: true });
4969
+ const finalState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
4585
4970
  return {
4586
4971
  ok: true,
4587
4972
  completedSteps: pipeline.steps.length,
@@ -4595,7 +4980,7 @@ class PipelineRunner {
4595
4980
  }
4596
4981
  }
4597
4982
  // src/lib/block-detection.ts
4598
- var log15 = createLogger("block-detection");
4983
+ var log16 = createLogger("block-detection");
4599
4984
  async function detectBlock(page) {
4600
4985
  try {
4601
4986
  const [title, url, bodyText] = await Promise.all([
@@ -4615,7 +5000,7 @@ async function detectBlock(page) {
4615
5000
  const hasCfChallenge = await page.evaluate(() => {
4616
5001
  return !!document.querySelector('iframe[src*="challenges.cloudflare.com"]');
4617
5002
  }).catch((err) => {
4618
- log15.warn(`CF challenge check failed, assuming blocked: ${err}`);
5003
+ log16.warn(`CF challenge check failed, assuming blocked: ${err}`);
4619
5004
  return true;
4620
5005
  });
4621
5006
  if (hasCfChallenge) {
@@ -4634,7 +5019,7 @@ async function detectBlock(page) {
4634
5019
  const hasCaptchaIframe = await page.evaluate(() => {
4635
5020
  return !!(document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('iframe[src*="hcaptcha"]'));
4636
5021
  }).catch((err) => {
4637
- log15.warn(`captcha iframe check failed, assuming blocked: ${err}`);
5022
+ log16.warn(`captcha iframe check failed, assuming blocked: ${err}`);
4638
5023
  return true;
4639
5024
  });
4640
5025
  if (hasCaptchaIframe) {
@@ -4647,7 +5032,7 @@ async function detectBlock(page) {
4647
5032
  if (!url.includes("about:blank") && bodyText.trim().length < 20 && title.length < 5) {}
4648
5033
  return { blocked: false };
4649
5034
  } catch (err) {
4650
- log15.warn(`page evaluation failed, assuming blocked: ${err}`);
5035
+ log16.warn(`page evaluation failed, assuming blocked: ${err}`);
4651
5036
  return { blocked: true, reason: "evaluation-failed" };
4652
5037
  }
4653
5038
  }
@@ -4744,12 +5129,583 @@ function extractKnowledgeFromRun(pipeline, result, sessionData, mode) {
4744
5129
  });
4745
5130
  }
4746
5131
 
5132
+ // src/lib/extension/cdp-relay.ts
5133
+ var import_http = __toESM(require("http"));
5134
+ var import_ws2 = require("ws");
5135
+ var import_crypto7 = require("crypto");
5136
+
5137
+ // src/lib/extension/bridge.ts
5138
+ var import_ws = require("ws");
5139
+ var import_crypto5 = __toESM(require("crypto"));
5140
+
5141
+ // src/lib/version.ts
5142
+ var import_fs11 = __toESM(require("fs"));
5143
+ var import_path10 = __toESM(require("path"));
5144
+ var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/lib";
5145
+ var cached = null;
5146
+ function getVersion() {
5147
+ if (cached)
5148
+ return cached;
5149
+ if (process.env.IFRAMER_VERSION)
5150
+ return cached = process.env.IFRAMER_VERSION;
5151
+ const candidates = [
5152
+ import_path10.default.join(__dirname, "..", "..", "package.json"),
5153
+ import_path10.default.join(__dirname, "..", "package.json"),
5154
+ import_path10.default.join(process.cwd(), "package.json")
5155
+ ];
5156
+ for (const p of candidates) {
5157
+ try {
5158
+ const v = JSON.parse(import_fs11.default.readFileSync(p, "utf8")).version;
5159
+ if (v)
5160
+ return cached = v;
5161
+ } catch {}
5162
+ }
5163
+ return cached = "0.0.0";
5164
+ }
5165
+
5166
+ // src/lib/extension/bridge.ts
5167
+ var REQUEST_TIMEOUT_MS = 180000;
5168
+ var HEARTBEAT_MS = 15000;
5169
+
5170
+ class ExtensionBridge {
5171
+ wss = null;
5172
+ clients = new Map;
5173
+ pending = new Map;
5174
+ nextReqId = 1;
5175
+ tabOwner = new Map;
5176
+ collidingTabs = new Set;
5177
+ cdpListeners = new Map;
5178
+ attach(server) {
5179
+ if (this.wss)
5180
+ return;
5181
+ this.wss = new import_ws.WebSocketServer({ server, path: "/extension/ws" });
5182
+ this.wss.on("connection", (ws, req) => {
5183
+ let expected = "";
5184
+ try {
5185
+ expected = getLocalToken();
5186
+ } catch {
5187
+ expected = "";
5188
+ }
5189
+ if (!expected) {
5190
+ ws.close(4001, "unauthorized");
5191
+ return;
5192
+ }
5193
+ const queryToken = new URL(req.url || "", "http://127.0.0.1").searchParams.get("token");
5194
+ if (queryToken !== null) {
5195
+ if (queryToken !== expected) {
5196
+ ws.close(4001, "unauthorized");
5197
+ return;
5198
+ }
5199
+ this.acceptClient(ws);
5200
+ return;
5201
+ }
5202
+ const timer = setTimeout(() => ws.close(4001, "auth timeout"), 3000);
5203
+ ws.once("message", (data) => {
5204
+ clearTimeout(timer);
5205
+ try {
5206
+ const m = JSON.parse(data.toString());
5207
+ if (m?.type === "auth" && m.token === expected) {
5208
+ this.acceptClient(ws);
5209
+ return;
5210
+ }
5211
+ } catch {}
5212
+ ws.close(4001, "unauthorized");
5213
+ });
5214
+ });
5215
+ }
5216
+ acceptClient(ws) {
5217
+ const client = {
5218
+ clientId: import_crypto5.default.randomUUID(),
5219
+ socket: ws,
5220
+ connectedAt: Date.now(),
5221
+ tabs: [],
5222
+ heartbeat: null
5223
+ };
5224
+ this.clients.set(client.clientId, client);
5225
+ this.startHeartbeat(client);
5226
+ try {
5227
+ ws.send(JSON.stringify({ type: "server_info", version: getVersion() }));
5228
+ } catch {}
5229
+ ws.on("message", (data) => this.onMessage(client, data));
5230
+ ws.on("close", () => this.dropClient(client, "socket closed"));
5231
+ ws.on("error", () => {});
5232
+ }
5233
+ startHeartbeat(client) {
5234
+ client.heartbeat = setInterval(() => {
5235
+ this.send(client, "ping", {}).catch(() => {});
5236
+ }, HEARTBEAT_MS);
5237
+ client.heartbeat.unref?.();
5238
+ }
5239
+ dropClient(client, _reason) {
5240
+ if (client.heartbeat)
5241
+ clearInterval(client.heartbeat);
5242
+ if (this.clients.get(client.clientId) === client) {
5243
+ this.clients.delete(client.clientId);
5244
+ }
5245
+ for (const [tabId, owner] of this.tabOwner) {
5246
+ if (owner === client.clientId)
5247
+ this.tabOwner.delete(tabId);
5248
+ }
5249
+ for (const [id, p] of this.pending) {
5250
+ if (p.clientId === client.clientId) {
5251
+ clearTimeout(p.timer);
5252
+ p.reject(new Error("Extension disconnected before responding."));
5253
+ this.pending.delete(id);
5254
+ }
5255
+ }
5256
+ }
5257
+ onMessage(client, data) {
5258
+ let msg;
5259
+ try {
5260
+ msg = JSON.parse(data.toString());
5261
+ } catch {
5262
+ return;
5263
+ }
5264
+ if (msg.type === "hello") {
5265
+ client.profileId = msg.profileId;
5266
+ client.profileName = msg.profileName;
5267
+ client.extVersion = msg.extVersion;
5268
+ if (msg.profileId) {
5269
+ for (const other of this.clients.values()) {
5270
+ if (other !== client && other.profileId === msg.profileId) {
5271
+ try {
5272
+ other.socket.close(4002, "replaced by same profile reconnect");
5273
+ } catch {}
5274
+ }
5275
+ }
5276
+ }
5277
+ return;
5278
+ }
5279
+ if (msg.type === "cdp_event") {
5280
+ const ev = msg;
5281
+ if (typeof ev.tabId === "number") {
5282
+ const fn = this.cdpListeners.get(cdpKey(client.clientId, ev.tabId));
5283
+ if (fn)
5284
+ fn(ev);
5285
+ }
5286
+ return;
5287
+ }
5288
+ if (typeof msg.id !== "number")
5289
+ return;
5290
+ const p = this.pending.get(msg.id);
5291
+ if (!p || p.clientId !== client.clientId)
5292
+ return;
5293
+ this.pending.delete(msg.id);
5294
+ clearTimeout(p.timer);
5295
+ if (msg.ok)
5296
+ p.resolve(msg.result);
5297
+ else
5298
+ p.reject(new Error(msg.error || "Extension reported an error."));
5299
+ }
5300
+ send(client, type, payload) {
5301
+ const ws = client.socket;
5302
+ if (!ws || ws.readyState !== import_ws.WebSocket.OPEN) {
5303
+ return Promise.reject(new Error("Extension client is not connected."));
5304
+ }
5305
+ const id = this.nextReqId++;
5306
+ return new Promise((resolve, reject) => {
5307
+ const timer = setTimeout(() => {
5308
+ this.pending.delete(id);
5309
+ reject(new Error(`Extension did not respond within ${REQUEST_TIMEOUT_MS}ms (${type}).`));
5310
+ }, REQUEST_TIMEOUT_MS);
5311
+ this.pending.set(id, { clientId: client.clientId, resolve, reject, timer });
5312
+ try {
5313
+ ws.send(JSON.stringify({ id, type, ...payload }));
5314
+ } catch (e) {
5315
+ clearTimeout(timer);
5316
+ this.pending.delete(id);
5317
+ reject(e instanceof Error ? e : new Error(String(e)));
5318
+ }
5319
+ });
5320
+ }
5321
+ hasClients() {
5322
+ return this.clients.size > 0;
5323
+ }
5324
+ status() {
5325
+ return {
5326
+ connected: this.clients.size > 0,
5327
+ clients: [...this.clients.values()].map((c) => ({
5328
+ clientId: c.clientId,
5329
+ profileId: c.profileId,
5330
+ profileName: c.profileName,
5331
+ extVersion: c.extVersion,
5332
+ connectedAt: new Date(c.connectedAt).toISOString(),
5333
+ tabCount: c.tabs.length
5334
+ }))
5335
+ };
5336
+ }
5337
+ async listTabs() {
5338
+ const all = [];
5339
+ this.tabOwner.clear();
5340
+ this.collidingTabs.clear();
5341
+ await Promise.all([...this.clients.values()].map(async (client) => {
5342
+ try {
5343
+ const res = await this.send(client, "list_tabs", {}) || { tabs: [] };
5344
+ const tagged = (res.tabs || []).map((t) => ({
5345
+ ...t,
5346
+ clientId: client.clientId,
5347
+ profileId: client.profileId,
5348
+ profileName: client.profileName
5349
+ }));
5350
+ client.tabs = tagged;
5351
+ for (const t of tagged) {
5352
+ const prev = this.tabOwner.get(t.id);
5353
+ if (prev !== undefined && prev !== client.clientId)
5354
+ this.collidingTabs.add(t.id);
5355
+ this.tabOwner.set(t.id, client.clientId);
5356
+ }
5357
+ all.push(...tagged);
5358
+ } catch {
5359
+ client.tabs = [];
5360
+ }
5361
+ }));
5362
+ return { tabs: all, clients: this.status().clients };
5363
+ }
5364
+ async resolveClient(tabId, clientId) {
5365
+ if (clientId) {
5366
+ const c = this.clients.get(clientId);
5367
+ if (!c)
5368
+ throw new Error(`No connected extension with clientId ${clientId}.`);
5369
+ return c;
5370
+ }
5371
+ if (this.clients.size === 0) {
5372
+ throw new Error("No iframer extension is connected. Open Chrome, install/enable the iframer " + "extension, and pair it (paste the token, dot goes green).");
5373
+ }
5374
+ if (this.clients.size === 1) {
5375
+ return [...this.clients.values()][0];
5376
+ }
5377
+ let owner = this.tabOwner.get(tabId);
5378
+ if (!owner || this.collidingTabs.has(tabId)) {
5379
+ await this.listTabs();
5380
+ owner = this.tabOwner.get(tabId);
5381
+ }
5382
+ if (this.collidingTabs.has(tabId)) {
5383
+ throw new Error(`Tab id ${tabId} exists in more than one connected browser (separate browsers ` + `have independent tab-id spaces). Call \`tabs\` and pass the tab's clientId ` + `alongside tabId to pick the right one.`);
5384
+ }
5385
+ if (owner) {
5386
+ const c = this.clients.get(owner);
5387
+ if (c)
5388
+ return c;
5389
+ }
5390
+ throw new Error(`Could not determine which browser profile owns tab ${tabId}. Call \`tabs\` to ` + `refresh the list, then pass the tab's clientId alongside tabId.`);
5391
+ }
5392
+ resolveClientNoTab(clientId) {
5393
+ if (clientId) {
5394
+ const c = this.clients.get(clientId);
5395
+ if (!c)
5396
+ throw new Error(`No connected extension with clientId ${clientId}.`);
5397
+ return c;
5398
+ }
5399
+ if (this.clients.size === 0) {
5400
+ throw new Error("No iframer extension is connected. Open Chrome, install/enable the iframer " + "extension, and pair it (paste the token, dot goes green).");
5401
+ }
5402
+ if (this.clients.size === 1)
5403
+ return [...this.clients.values()][0];
5404
+ throw new Error("Multiple browser profiles are connected — pass clientId to say which one to act in. " + "Call `tabs` to see the profiles and their clientIds.");
5405
+ }
5406
+ async reloadAll() {
5407
+ const clients = [...this.clients.values()];
5408
+ await Promise.all(clients.map((c) => this.send(c, "reload", {}).catch(() => {})));
5409
+ return { reloaded: clients.length };
5410
+ }
5411
+ async groupTabs(tabIds, opts = {}, clientId) {
5412
+ const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5413
+ return this.send(client, "group_tabs", {
5414
+ tabIds,
5415
+ title: opts.title,
5416
+ color: opts.color,
5417
+ collapsed: opts.collapsed,
5418
+ groupId: opts.groupId
5419
+ });
5420
+ }
5421
+ async ungroupTabs(tabIds, clientId) {
5422
+ const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5423
+ return this.send(client, "ungroup_tabs", { tabIds });
5424
+ }
5425
+ async updateGroup(groupId, opts, clientId) {
5426
+ const client = this.resolveClientNoTab(clientId);
5427
+ return this.send(client, "update_group", { groupId, ...opts });
5428
+ }
5429
+ async listGroups(clientId) {
5430
+ const client = this.resolveClientNoTab(clientId);
5431
+ return this.send(client, "list_groups", {});
5432
+ }
5433
+ async createTab(url, opts = {}, clientId) {
5434
+ const client = this.resolveClientNoTab(clientId);
5435
+ const res = await this.send(client, "create_tab", {
5436
+ url,
5437
+ active: opts.active,
5438
+ windowId: opts.windowId
5439
+ });
5440
+ return { tab: { ...res.tab, clientId: client.clientId, profileId: client.profileId, profileName: client.profileName }, clientId: client.clientId };
5441
+ }
5442
+ addCdpListener(clientId, tabId, fn) {
5443
+ const key = cdpKey(clientId, tabId);
5444
+ if (this.cdpListeners.has(key)) {
5445
+ throw new Error(`Tab ${tabId} is already being driven by another pipeline. Retry when it finishes.`);
5446
+ }
5447
+ this.cdpListeners.set(key, fn);
5448
+ }
5449
+ removeCdpListener(clientId, tabId) {
5450
+ this.cdpListeners.delete(cdpKey(clientId, tabId));
5451
+ }
5452
+ async cdpAttach(tabId, clientId, focus) {
5453
+ const client = await this.resolveClient(tabId, clientId);
5454
+ const res = await this.send(client, "cdp_attach", { tabId, focus: !!focus }) || {
5455
+ targetInfo: null
5456
+ };
5457
+ return { targetInfo: res.targetInfo, clientId: client.clientId };
5458
+ }
5459
+ async cdpCommand(clientId, tabId, sessionId, method, params) {
5460
+ const client = this.clients.get(clientId);
5461
+ if (!client)
5462
+ throw new Error(`CDP: client ${clientId} is gone.`);
5463
+ return this.send(client, "cdp_command", { tabId, sessionId, method, params });
5464
+ }
5465
+ async cdpDetach(clientId, tabId) {
5466
+ const client = this.clients.get(clientId);
5467
+ if (!client)
5468
+ return;
5469
+ try {
5470
+ await this.send(client, "cdp_detach", { tabId });
5471
+ } catch {}
5472
+ }
5473
+ }
5474
+ function cdpKey(clientId, tabId) {
5475
+ return `${clientId}:${tabId}`;
5476
+ }
5477
+ var extensionBridge = new ExtensionBridge;
5478
+
5479
+ // src/lib/extension/cdp-relay.ts
5480
+ var log17 = createLogger("cdp-relay");
5481
+
5482
+ class CdpRelay {
5483
+ tabId;
5484
+ clientId;
5485
+ focus;
5486
+ httpServer = null;
5487
+ wss = null;
5488
+ pw = null;
5489
+ port = 0;
5490
+ path = `/cdp/${import_crypto7.randomUUID()}`;
5491
+ tabSessionId = "pw-tab-1";
5492
+ targetInfo = null;
5493
+ ownerClientId = "";
5494
+ listenerRegistered = false;
5495
+ constructor(tabId, clientId, focus) {
5496
+ this.tabId = tabId;
5497
+ this.clientId = clientId;
5498
+ this.focus = focus;
5499
+ }
5500
+ async start() {
5501
+ const { targetInfo, clientId } = await extensionBridge.cdpAttach(this.tabId, this.clientId, this.focus);
5502
+ this.ownerClientId = clientId;
5503
+ this.targetInfo = targetInfo || {
5504
+ targetId: `iframer-${this.tabId}`,
5505
+ type: "page",
5506
+ title: "",
5507
+ url: ""
5508
+ };
5509
+ extensionBridge.addCdpListener(this.ownerClientId, this.tabId, (ev) => {
5510
+ this.sendToPw({
5511
+ method: ev.method,
5512
+ params: ev.params,
5513
+ sessionId: ev.sessionId || this.tabSessionId
5514
+ });
5515
+ });
5516
+ this.listenerRegistered = true;
5517
+ await new Promise((resolve, reject) => {
5518
+ this.httpServer = import_http.default.createServer((req, res) => {
5519
+ if (req.url === "/json/version" || req.url === "/json/version/") {
5520
+ res.setHeader("content-type", "application/json");
5521
+ res.end(JSON.stringify({
5522
+ Browser: "Chrome/iframer-extension",
5523
+ "Protocol-Version": "1.3",
5524
+ "User-Agent": "iframer-cdp-relay/1.0",
5525
+ "V8-Version": "",
5526
+ "WebKit-Version": "",
5527
+ webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}${this.path}`
5528
+ }));
5529
+ return;
5530
+ }
5531
+ res.writeHead(404);
5532
+ res.end();
5533
+ });
5534
+ this.httpServer.on("upgrade", (req) => {
5535
+ if (process.env.IFRAMER_RELAY_DEBUG)
5536
+ log17.info(`[relay] upgrade request url=${req.url}`);
5537
+ });
5538
+ this.wss = new import_ws2.WebSocketServer({ server: this.httpServer, path: this.path });
5539
+ this.wss.on("connection", (ws) => {
5540
+ if (process.env.IFRAMER_RELAY_DEBUG)
5541
+ log17.info(`[relay] playwright connected`);
5542
+ if (this.pw) {
5543
+ ws.close(4000, "relay already has a client");
5544
+ return;
5545
+ }
5546
+ this.pw = ws;
5547
+ ws.on("message", (data) => this.onPwMessage(data));
5548
+ ws.on("close", () => {
5549
+ if (this.pw === ws)
5550
+ this.pw = null;
5551
+ });
5552
+ ws.on("error", () => {});
5553
+ });
5554
+ this.httpServer.on("error", reject);
5555
+ this.httpServer.listen(0, "127.0.0.1", () => {
5556
+ const addr = this.httpServer.address();
5557
+ this.port = typeof addr === "object" && addr ? addr.port : 0;
5558
+ resolve();
5559
+ });
5560
+ });
5561
+ }
5562
+ cdpEndpoint() {
5563
+ return `ws://127.0.0.1:${this.port}${this.path}`;
5564
+ }
5565
+ httpEndpoint() {
5566
+ return `http://127.0.0.1:${this.port}`;
5567
+ }
5568
+ sendToPw(msg) {
5569
+ if (this.pw && this.pw.readyState === import_ws2.WebSocket.OPEN) {
5570
+ try {
5571
+ this.pw.send(JSON.stringify(msg));
5572
+ } catch {}
5573
+ }
5574
+ }
5575
+ async onPwMessage(data) {
5576
+ if (process.env.IFRAMER_RELAY_DEBUG)
5577
+ log17.info(`[relay] raw pw msg (${data?.length ?? 0} bytes): ${data?.toString().slice(0, 120)}`);
5578
+ let msg;
5579
+ try {
5580
+ msg = JSON.parse(data.toString());
5581
+ } catch {
5582
+ return;
5583
+ }
5584
+ const { id, sessionId, method, params } = msg;
5585
+ if (process.env.IFRAMER_RELAY_DEBUG)
5586
+ log17.info(`[relay] pw→ ${method} (id=${id}, sess=${sessionId || "-"})`);
5587
+ if (!method)
5588
+ return;
5589
+ try {
5590
+ const result = await this.handleCdpCommand(method, params, sessionId);
5591
+ if (typeof id === "number")
5592
+ this.sendToPw({ id, sessionId, result });
5593
+ } catch (e) {
5594
+ if (typeof id === "number") {
5595
+ this.sendToPw({ id, sessionId, error: { message: e instanceof Error ? e.message : String(e) } });
5596
+ }
5597
+ }
5598
+ }
5599
+ async handleCdpCommand(method, params, sessionId) {
5600
+ switch (method) {
5601
+ case "Browser.getVersion":
5602
+ return { protocolVersion: "1.3", product: "Chrome/iframer-extension", userAgent: "iframer-cdp-relay/1.0" };
5603
+ case "Browser.setDownloadBehavior":
5604
+ return {};
5605
+ case "Browser.close":
5606
+ return {};
5607
+ case "Target.setDiscoverTargets":
5608
+ return {};
5609
+ case "Target.getTargets":
5610
+ return { targetInfos: this.targetInfo ? [{ ...this.targetInfo, attached: true }] : [] };
5611
+ case "Target.setAutoAttach":
5612
+ if (!sessionId) {
5613
+ this.sendToPw({
5614
+ method: "Target.attachedToTarget",
5615
+ params: {
5616
+ sessionId: this.tabSessionId,
5617
+ targetInfo: { ...this.targetInfo, attached: true },
5618
+ waitingForDebugger: false
5619
+ }
5620
+ });
5621
+ return {};
5622
+ }
5623
+ break;
5624
+ case "Target.getTargetInfo":
5625
+ if (!sessionId)
5626
+ return { targetInfo: this.targetInfo };
5627
+ break;
5628
+ }
5629
+ const realSessionId = sessionId === this.tabSessionId ? undefined : sessionId;
5630
+ if (method === "Page.captureScreenshot") {
5631
+ return this.captureScreenshotWithFallback(params, realSessionId);
5632
+ }
5633
+ return extensionBridge.cdpCommand(this.ownerClientId, this.tabId, realSessionId, method, params);
5634
+ }
5635
+ async captureScreenshotWithFallback(params, sessionId) {
5636
+ const base = params && typeof params === "object" ? { ...params } : {};
5637
+ const attempt = (p) => extensionBridge.cdpCommand(this.ownerClientId, this.tabId, sessionId, "Page.captureScreenshot", p);
5638
+ const first = attempt(base);
5639
+ first.catch(() => {
5640
+ return;
5641
+ });
5642
+ try {
5643
+ return await Promise.race([
5644
+ first,
5645
+ new Promise((_, reject) => {
5646
+ const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 1e4);
5647
+ t.unref?.();
5648
+ })
5649
+ ]);
5650
+ } catch {
5651
+ return attempt({ ...base, fromSurface: false });
5652
+ }
5653
+ }
5654
+ async stop() {
5655
+ const ownedTab = this.listenerRegistered;
5656
+ if (ownedTab) {
5657
+ extensionBridge.removeCdpListener(this.ownerClientId, this.tabId);
5658
+ this.listenerRegistered = false;
5659
+ }
5660
+ try {
5661
+ this.wss?.clients.forEach((c) => {
5662
+ try {
5663
+ c.terminate();
5664
+ } catch {}
5665
+ });
5666
+ } catch {}
5667
+ try {
5668
+ this.pw?.terminate();
5669
+ } catch {}
5670
+ this.pw = null;
5671
+ const withTimeout = (fn) => new Promise((resolve) => {
5672
+ let done = false;
5673
+ const finish = () => {
5674
+ if (!done) {
5675
+ done = true;
5676
+ resolve();
5677
+ }
5678
+ };
5679
+ try {
5680
+ fn(finish);
5681
+ } catch {
5682
+ finish();
5683
+ }
5684
+ setTimeout(finish, 1000).unref?.();
5685
+ });
5686
+ if (this.wss)
5687
+ await withTimeout((cb) => this.wss.close(cb));
5688
+ if (this.httpServer)
5689
+ await withTimeout((cb) => this.httpServer.close(cb));
5690
+ this.wss = null;
5691
+ this.httpServer = null;
5692
+ if (ownedTab) {
5693
+ try {
5694
+ await extensionBridge.cdpDetach(this.ownerClientId, this.tabId);
5695
+ } catch (e) {
5696
+ log17.warn(`cdp detach failed: ${e}`);
5697
+ }
5698
+ }
5699
+ }
5700
+ }
5701
+
4747
5702
  // src/lib/execution/pipeline-executor.ts
4748
- var log16 = createLogger("iframer");
5703
+ var log18 = createLogger("iframer");
4749
5704
 
4750
5705
  class PipelineExecutor {
4751
5706
  deps;
4752
5707
  pendingElicitOtp;
5708
+ extensionTabLocks = new Map;
4753
5709
  constructor(deps) {
4754
5710
  this.deps = deps;
4755
5711
  }
@@ -4763,6 +5719,22 @@ class PipelineExecutor {
4763
5719
  }
4764
5720
  async executeInner(userId, token, pipeline) {
4765
5721
  const opts = pipeline.options || {};
5722
+ if (typeof opts.extensionTabId === "number") {
5723
+ const tabId = opts.extensionTabId;
5724
+ const lockKey = `${opts.clientId || "auto"}:${tabId}`;
5725
+ const prev = this.extensionTabLocks.get(lockKey);
5726
+ const run2 = (prev ? prev.catch(() => {
5727
+ return;
5728
+ }) : Promise.resolve()).then(() => this.executeExtension(userId, token, pipeline, tabId, opts.clientId));
5729
+ this.extensionTabLocks.set(lockKey, run2);
5730
+ run2.catch(() => {
5731
+ return;
5732
+ }).finally(() => {
5733
+ if (this.extensionTabLocks.get(lockKey) === run2)
5734
+ this.extensionTabLocks.delete(lockKey);
5735
+ });
5736
+ return run2;
5737
+ }
4766
5738
  const forcedMode = opts.mode;
4767
5739
  const autoEscalate = opts.autoEscalate !== false;
4768
5740
  const instanceId = opts.instanceId || DEFAULT_INSTANCE;
@@ -4784,7 +5756,7 @@ class PipelineExecutor {
4784
5756
  this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
4785
5757
  const nextMode = this.deps.domainModes.getNextMode(failedMode, availableModes);
4786
5758
  if (nextMode) {
4787
- log16.info(`Auto-escalating from ${failedMode} to ${nextMode} for ${domain}`);
5759
+ log18.info(`Auto-escalating from ${failedMode} to ${nextMode} for ${domain}`);
4788
5760
  if (failedMode !== "docker-headful") {
4789
5761
  await this.deps.daemon.stopMode(failedMode, instanceId);
4790
5762
  }
@@ -4797,7 +5769,7 @@ class PipelineExecutor {
4797
5769
  this.deps.domainModes.recordFailure(domain, nextMode, result.error?.message || "blocked");
4798
5770
  const thirdMode = this.deps.domainModes.getNextMode(nextMode, availableModes);
4799
5771
  if (thirdMode) {
4800
- log16.info(`Auto-escalating from ${nextMode} to ${thirdMode} for ${domain}`);
5772
+ log18.info(`Auto-escalating from ${nextMode} to ${thirdMode} for ${domain}`);
4801
5773
  if (nextMode !== "docker-headful") {
4802
5774
  await this.deps.daemon.stopMode(nextMode, instanceId);
4803
5775
  }
@@ -4821,6 +5793,89 @@ class PipelineExecutor {
4821
5793
  }
4822
5794
  return this.executeLocal(userId, token, pipeline, mode, instanceId);
4823
5795
  }
5796
+ async executeExtension(userId, token, pipeline, tabId, clientId) {
5797
+ const startTime = Date.now();
5798
+ const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
5799
+ let browser;
5800
+ try {
5801
+ await relay.start();
5802
+ browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 30000 });
5803
+ const context = browser.contexts()[0];
5804
+ if (!context)
5805
+ throw new Error("no CDP browser context for the tab");
5806
+ let page = context.pages()[0];
5807
+ if (!page) {
5808
+ page = await context.waitForEvent("page", { timeout: 5000 }).catch(() => {
5809
+ return;
5810
+ });
5811
+ }
5812
+ if (!page)
5813
+ throw new Error("no page available for the tab (is it still open?)");
5814
+ const ctx = this.deps.refStore.makeContext(userId, token);
5815
+ if (this.pendingElicitOtp)
5816
+ ctx.elicitOtp = this.pendingElicitOtp;
5817
+ const runner = new PipelineRunner(ctx);
5818
+ const capMs = Math.min(60000 + pipeline.steps.length * 15000, 150000);
5819
+ let watchdog;
5820
+ let result;
5821
+ try {
5822
+ const runPromise = runner.run(page, pipeline);
5823
+ runPromise.catch(() => {
5824
+ return;
5825
+ });
5826
+ result = await Promise.race([
5827
+ runPromise,
5828
+ new Promise((_, reject) => {
5829
+ watchdog = setTimeout(() => reject(new Error(`pipeline exceeded ${Math.round(capMs / 1000)}s — the tab may have stopped ` + `rendering (minimized window?). Un-minimize the Chrome window or retry ` + `with options.focus=true.`)), capMs);
5830
+ watchdog.unref?.();
5831
+ })
5832
+ ]);
5833
+ } finally {
5834
+ if (watchdog)
5835
+ clearTimeout(watchdog);
5836
+ }
5837
+ this.deps.refStore.sync(userId, ctx);
5838
+ result.modeUsed = "extension";
5839
+ if (result.ok) {
5840
+ try {
5841
+ extractKnowledgeFromRun(pipeline, result, null, "extension");
5842
+ } catch (e) {
5843
+ log18.warn(`knowledge update failed: ${getErrorMessage(e)}`);
5844
+ }
5845
+ }
5846
+ return result;
5847
+ } catch (err) {
5848
+ const msg = getErrorMessage(err);
5849
+ const stalled = msg.includes("pipeline exceeded");
5850
+ return {
5851
+ ok: false,
5852
+ completedSteps: 0,
5853
+ totalSteps: pipeline.steps.length,
5854
+ results: [],
5855
+ finalState: { url: "", title: "" },
5856
+ obstacles: [],
5857
+ durationMs: Date.now() - startTime,
5858
+ modeUsed: "extension",
5859
+ error: {
5860
+ failedAtStep: 0,
5861
+ failedStep: pipeline.steps[0],
5862
+ errorType: "action-failed",
5863
+ message: `Extension mode failed: ${msg}`,
5864
+ pageState: { url: "", title: "" },
5865
+ suggestion: stalled ? "STOP retrying and tell the user what happened: the Chrome tab being driven stopped " + "responding — its window is likely minimized or the page is wedged. Ask them to " + "un-minimize the Chrome window (leaving it behind other windows is fine), or ask " + "permission to rerun with options.focus=true to bring it to the front." : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
5866
+ retryable: !stalled
5867
+ }
5868
+ };
5869
+ } finally {
5870
+ try {
5871
+ if (browser)
5872
+ await browser.close();
5873
+ } catch {}
5874
+ try {
5875
+ await relay.stop();
5876
+ } catch {}
5877
+ }
5878
+ }
4824
5879
  async executeDocker(userId, token, pipeline) {
4825
5880
  let session = getSession(userId);
4826
5881
  if (!session) {
@@ -4912,7 +5967,7 @@ class PipelineExecutor {
4912
5967
  try {
4913
5968
  extractKnowledgeFromRun(pipeline, result, updatedSession, mode);
4914
5969
  } catch (err) {
4915
- log16.warn(`knowledge update failed: ${getErrorMessage(err)}`);
5970
+ log18.warn(`knowledge update failed: ${getErrorMessage(err)}`);
4916
5971
  }
4917
5972
  }
4918
5973
  this.deps.refStore.sync(userId, ctx);
@@ -5160,8 +6215,8 @@ class CredentialStore {
5160
6215
  }
5161
6216
 
5162
6217
  // src/lib/iframer.ts
5163
- var log17 = createLogger("iframer");
5164
- var DEFAULT_SCREENSHOT_DIR = import_path9.default.join(import_path9.default.dirname(import_url.fileURLToPath("file:///Users/eduardoverona/tools/iframer-toolkit/src/lib/iframer.ts")), "../../.screenshots");
6218
+ var log19 = createLogger("iframer");
6219
+ var DEFAULT_SCREENSHOT_DIR = import_path11.default.join(import_path11.default.dirname(import_url.fileURLToPath("file:///Users/eduardoverona/tools/iframer-toolkit/src/lib/iframer.ts")), "../../.screenshots");
5165
6220
  var DEFAULT_PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3021}`;
5166
6221
  var DEFAULT_STALE_TIMEOUT_MS3 = 20000;
5167
6222
 
@@ -5272,7 +6327,7 @@ class Iframer {
5272
6327
  sessionSaved = true;
5273
6328
  }
5274
6329
  } catch (err) {
5275
- log17.warn(`stopSession: failed to extract daemon state for ${inst.mode}::${inst.instanceId}: ${getErrorMessage(err)}`);
6330
+ log19.warn(`stopSession: failed to extract daemon state for ${inst.mode}::${inst.instanceId}: ${getErrorMessage(err)}`);
5276
6331
  }
5277
6332
  }
5278
6333
  }
@@ -5375,7 +6430,7 @@ function errorHandler(err, _req, res, _next) {
5375
6430
  }
5376
6431
 
5377
6432
  // src/api/routes.ts
5378
- var import_fs10 = __toESM(require("fs"));
6433
+ var import_fs12 = __toESM(require("fs"));
5379
6434
  function auth(req) {
5380
6435
  return req;
5381
6436
  }
@@ -5388,7 +6443,7 @@ function registerRoutes(app) {
5388
6443
  const browsers = [];
5389
6444
  try {
5390
6445
  const execPath = import_patchright3.chromium.executablePath();
5391
- browsers.push({ name: "chromium", installed: import_fs10.default.existsSync(execPath), executablePath: execPath });
6446
+ browsers.push({ name: "chromium", installed: import_fs12.default.existsSync(execPath), executablePath: execPath });
5392
6447
  } catch {
5393
6448
  browsers.push({ name: "chromium", installed: false, executablePath: null });
5394
6449
  }
@@ -5557,6 +6612,66 @@ function registerRoutes(app) {
5557
6612
  const { ok: _ok, ...resultRest } = result;
5558
6613
  res.json({ ok: true, message: "Login attempted", ...resultRest });
5559
6614
  }));
6615
+ app.get("/extension/status", (_req, res) => {
6616
+ res.json({ ok: true, version: getVersion(), ...extensionBridge.status() });
6617
+ });
6618
+ app.post("/extension/tabs", asyncHandler(async (_req, res) => {
6619
+ const result = await extensionBridge.listTabs();
6620
+ res.json({ ok: true, ...result });
6621
+ }));
6622
+ app.post("/extension/reload", asyncHandler(async (_req, res) => {
6623
+ const result = await extensionBridge.reloadAll();
6624
+ res.json({ ok: true, ...result });
6625
+ }));
6626
+ app.post("/extension/tab/create", asyncHandler(async (req, res) => {
6627
+ const { url, active, windowId, clientId } = req.body || {};
6628
+ if (url !== undefined && typeof url !== "string")
6629
+ throw new AppError(400, "url must be a string");
6630
+ const result = await extensionBridge.createTab(url || "", { active, windowId }, clientId);
6631
+ res.json({ ok: true, ...result });
6632
+ }));
6633
+ app.post("/extension/tab/group", asyncHandler(async (req, res) => {
6634
+ const { tabIds, title, color, collapsed, groupId, clientId } = req.body || {};
6635
+ if (!Array.isArray(tabIds) || tabIds.length === 0 || !tabIds.every((t) => typeof t === "number")) {
6636
+ throw new AppError(400, "tabIds must be a non-empty array of numbers");
6637
+ }
6638
+ const result = await extensionBridge.groupTabs(tabIds, { title, color, collapsed, groupId }, clientId);
6639
+ res.json({ ok: true, group: result });
6640
+ }));
6641
+ app.post("/extension/tab/ungroup", asyncHandler(async (req, res) => {
6642
+ const { tabIds, clientId } = req.body || {};
6643
+ if (!Array.isArray(tabIds) || tabIds.length === 0 || !tabIds.every((t) => typeof t === "number")) {
6644
+ throw new AppError(400, "tabIds must be a non-empty array of numbers");
6645
+ }
6646
+ const result = await extensionBridge.ungroupTabs(tabIds, clientId);
6647
+ res.json({ ok: true, ...result });
6648
+ }));
6649
+ app.post("/extension/group/update", asyncHandler(async (req, res) => {
6650
+ const { groupId, title, color, collapsed, clientId } = req.body || {};
6651
+ if (typeof groupId !== "number")
6652
+ throw new AppError(400, "groupId (number) is required");
6653
+ const result = await extensionBridge.updateGroup(groupId, { title, color, collapsed }, clientId);
6654
+ res.json({ ok: true, group: result });
6655
+ }));
6656
+ app.post("/extension/groups", asyncHandler(async (req, res) => {
6657
+ const { clientId } = req.body || {};
6658
+ const result = await extensionBridge.listGroups(clientId);
6659
+ res.json({ ok: true, ...result });
6660
+ }));
6661
+ app.post("/extension/execute", asyncHandler(async (req, res) => {
6662
+ const { tabId, steps, options, clientId } = req.body || {};
6663
+ if (typeof tabId !== "number")
6664
+ throw new AppError(400, "tabId (number) is required");
6665
+ if (!Array.isArray(steps) || steps.length === 0) {
6666
+ throw new AppError(400, "steps must be a non-empty array");
6667
+ }
6668
+ const r = auth(req);
6669
+ const result = await iframer.execute(r.userId, r.token, {
6670
+ steps,
6671
+ options: { ...options || {}, extensionTabId: tabId, clientId }
6672
+ });
6673
+ res.json(result);
6674
+ }));
5560
6675
  app.post("/fetch", asyncHandler(async (req, res) => {
5561
6676
  const { url } = req.body || {};
5562
6677
  if (!url)
@@ -5589,8 +6704,8 @@ var PORT = parseInt(process.env.PORT || "3021", 10);
5589
6704
  var REAP_INTERVAL_MS = 60000;
5590
6705
  var IDLE_EXIT_MS = parseInt(process.env.IFRAMER_SERVER_IDLE_EXIT_MS || String(30 * 60 * 1000), 10);
5591
6706
  var SHUTDOWN_DEADLINE_MS = 1e4;
5592
- var SCREENSHOT_DIR = import_path10.default.join(import_path10.default.dirname(import_url2.fileURLToPath("file:///Users/eduardoverona/tools/iframer-toolkit/index.ts")), ".screenshots");
5593
- import_fs11.default.mkdirSync(SCREENSHOT_DIR, { recursive: true });
6707
+ var SCREENSHOT_DIR = import_path12.default.join(import_path12.default.dirname(import_url2.fileURLToPath("file:///Users/eduardoverona/tools/iframer-toolkit/index.ts")), ".screenshots");
6708
+ import_fs13.default.mkdirSync(SCREENSHOT_DIR, { recursive: true });
5594
6709
  app.use("/screenshots", import_express.default.static(SCREENSHOT_DIR));
5595
6710
  app.use(import_express.default.json());
5596
6711
  var lastActivity = Date.now();
@@ -5607,10 +6722,11 @@ process.on("uncaughtException", (err) => {
5607
6722
  process.on("unhandledRejection", (reason) => {
5608
6723
  console.error(`[local-server] unhandledRejection (survived): ${reason}`);
5609
6724
  });
5610
- var server = app.listen(PORT, () => {
5611
- console.log(`iframer listening on ${PORT}`);
6725
+ var server = app.listen(PORT, "127.0.0.1", () => {
6726
+ console.log(`iframer listening on 127.0.0.1:${PORT}`);
5612
6727
  writeServerInfo({ pid: process.pid, port: PORT, startedAt: new Date().toISOString() });
5613
6728
  });
6729
+ extensionBridge.attach(server);
5614
6730
  var shutdownStarted = false;
5615
6731
  async function gracefulShutdown(reason) {
5616
6732
  if (shutdownStarted)
@@ -5653,7 +6769,7 @@ var reapTimer = setInterval(async () => {
5653
6769
  await reapOrphanBrowsers();
5654
6770
  } catch {}
5655
6771
  const idleMs = Date.now() - lastActivity;
5656
- if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive) {
6772
+ if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
5657
6773
  gracefulShutdown(`idle for ${Math.round(idleMs / 60000)}min with no browsers`);
5658
6774
  }
5659
6775
  }, REAP_INTERVAL_MS);