@agent-native/creative-context 0.5.12 → 0.6.2

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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
 
3
4
  import {
4
5
  isBlockedExtensionUrlWithDns,
@@ -29,6 +30,36 @@ const MAX_TYPOGRAPHY = 100;
29
30
  const MAX_SPACING = 100;
30
31
  const MAX_RADII = 64;
31
32
  const MAX_CSS_VARIABLES = 128;
33
+ const MAX_COMPONENT_STYLES = 24;
34
+ const FONT_READY_TIMEOUT_MS = 4_000;
35
+ const MAX_BROWSER_RESOURCE_BYTES = 12 * 1024 * 1024;
36
+ const MAX_BROWSER_RESOURCE_COUNT = 400;
37
+ const MAX_BROWSER_RESOURCE_BYTES_TOTAL = 64 * 1024 * 1024;
38
+ const BROWSER_RESOURCE_TIMEOUT_MS = 15_000;
39
+ const BROWSER_REQUEST_HEADERS = new Set([
40
+ "accept",
41
+ "accept-language",
42
+ "if-modified-since",
43
+ "if-none-match",
44
+ "origin",
45
+ "range",
46
+ "referer",
47
+ "user-agent",
48
+ ]);
49
+ const BROWSER_RESPONSE_HEADERS = new Set([
50
+ "connection",
51
+ "content-encoding",
52
+ "content-length",
53
+ "keep-alive",
54
+ "proxy-authenticate",
55
+ "proxy-authorization",
56
+ "set-cookie",
57
+ "set-cookie2",
58
+ "te",
59
+ "trailer",
60
+ "transfer-encoding",
61
+ "upgrade",
62
+ ]);
32
63
 
33
64
  export interface RenderedPageRequest {
34
65
  url: string;
@@ -71,12 +102,19 @@ interface PlaywrightRequestLike {
71
102
  url(): string;
72
103
  isNavigationRequest(): boolean;
73
104
  resourceType(): string;
105
+ method?(): string;
106
+ headers?(): Record<string, string>;
74
107
  }
75
108
 
76
109
  interface PlaywrightRouteLike {
77
110
  request(): PlaywrightRequestLike;
78
111
  continue(): Promise<void>;
79
112
  abort(errorCode?: string): Promise<void>;
113
+ fulfill(options: {
114
+ status: number;
115
+ headers: Record<string, string>;
116
+ body: Uint8Array;
117
+ }): Promise<void>;
80
118
  }
81
119
 
82
120
  interface PlaywrightPageLike {
@@ -88,6 +126,10 @@ interface PlaywrightPageLike {
88
126
  url: string,
89
127
  options: { timeout: number; waitUntil: string },
90
128
  ): Promise<unknown>;
129
+ waitForLoadState?(
130
+ state: "load" | "domcontentloaded" | "networkidle",
131
+ options?: { timeout: number },
132
+ ): Promise<void>;
91
133
  title(): Promise<string>;
92
134
  url(): string;
93
135
  locator(selector: string): { innerText(): Promise<string> };
@@ -99,18 +141,23 @@ interface PlaywrightPageLike {
99
141
  interface PlaywrightContextLike {
100
142
  pages(): PlaywrightPageLike[];
101
143
  newPage(): Promise<PlaywrightPageLike>;
144
+ close?(): Promise<void>;
102
145
  }
103
146
 
104
147
  interface PlaywrightBrowserLike {
105
148
  contexts(): PlaywrightContextLike[];
106
- newContext(): Promise<PlaywrightContextLike>;
149
+ newContext?(): Promise<PlaywrightContextLike>;
107
150
  close(): Promise<void>;
108
151
  }
109
152
 
110
153
  interface PlaywrightLike {
111
154
  chromium: {
112
155
  connectOverCDP(endpoint: string): Promise<PlaywrightBrowserLike>;
113
- launch(options: { headless: boolean }): Promise<PlaywrightBrowserLike>;
156
+ launch(options: {
157
+ headless: boolean;
158
+ args?: string[];
159
+ executablePath?: string;
160
+ }): Promise<PlaywrightBrowserLike>;
114
161
  };
115
162
  }
116
163
 
@@ -158,7 +205,8 @@ export class LayeredRenderedPageProvider implements RenderedPageProvider {
158
205
  const connection = await this.#requestBuilderBrowserConnection({
159
206
  sessionId: `creative-context-${randomUUID()}`,
160
207
  });
161
- const wsUrl = stringValue(connection.wsUrl);
208
+ const wsUrl =
209
+ typeof connection.wsUrl === "string" ? connection.wsUrl.trim() : "";
162
210
  if (!wsUrl) throw new Error("Builder Browser did not return wsUrl.");
163
211
  return await renderWithPlaywright(
164
212
  playwright,
@@ -214,7 +262,7 @@ export class LayeredRenderedPageProvider implements RenderedPageProvider {
214
262
  }
215
263
  }
216
264
 
217
- async function renderWithPlaywright(
265
+ export async function renderWithPlaywright(
218
266
  playwright: PlaywrightLike,
219
267
  request: RenderedPageRequest,
220
268
  warnings: string[],
@@ -223,37 +271,98 @@ async function renderWithPlaywright(
223
271
  ): Promise<RenderedPageResult> {
224
272
  const browser = wsUrl
225
273
  ? await playwright.chromium.connectOverCDP(wsUrl)
226
- : await playwright.chromium.launch({ headless: true });
274
+ : await launchChromium(playwright.chromium);
275
+ let isolatedContext: PlaywrightContextLike | undefined;
227
276
  try {
228
- const context = browser.contexts()[0] ?? (await browser.newContext());
229
- const page = context.pages()[0] ?? (await context.newPage());
230
- await installNavigationGuard(page);
277
+ // Never reuse a connected browser's ambient context: it can carry cookies,
278
+ // extensions, or tabs from another workflow. The safe proxy below is only
279
+ // useful when the page itself is isolated from that state.
280
+ if (!browser.newContext) {
281
+ throw new Error("Browser did not provide isolated context support.");
282
+ }
283
+ isolatedContext = await browser.newContext();
284
+ const page = await isolatedContext.newPage();
285
+ const getFinalNavigationUrl = await installNavigationGuard(
286
+ page,
287
+ request,
288
+ warnings,
289
+ );
231
290
  await page.setViewportSize({ width: 1440, height: 900 });
232
291
  await page.goto(request.url, {
233
292
  timeout: boundedTimeout(request.timeoutMs),
234
293
  waitUntil: request.waitUntil ?? "domcontentloaded",
235
294
  });
236
- const finalUrl = page.url();
295
+ await page
296
+ .waitForLoadState?.("load", {
297
+ timeout: Math.min(8_000, boundedTimeout(request.timeoutMs)),
298
+ })
299
+ .catch((error) => {
300
+ warnings.push(
301
+ `Browser load stabilization unavailable: ${errorMessage(error)}`,
302
+ );
303
+ });
304
+ // React hydration, CSS-in-JS insertion, and web fonts commonly finish just
305
+ // after `load`. Give those layers a bounded chance to settle, then capture
306
+ // the computed cascade rather than the server HTML.
307
+ await page
308
+ .waitForLoadState?.("networkidle", { timeout: 4_000 })
309
+ .catch((error) => {
310
+ warnings.push(
311
+ `Browser network-idle stabilization unavailable: ${errorMessage(error)}`,
312
+ );
313
+ });
314
+ await waitForFontReadiness(
315
+ page,
316
+ Math.min(FONT_READY_TIMEOUT_MS, boundedTimeout(request.timeoutMs)),
317
+ ).catch((error) => {
318
+ warnings.push(
319
+ `Browser font readiness unavailable: ${errorMessage(error)}`,
320
+ );
321
+ });
322
+ await new Promise((resolve) => setTimeout(resolve, 150));
323
+ await page.evaluate(dismissConsentOverlays).catch(() => undefined);
324
+ const finalUrl = getFinalNavigationUrl() ?? page.url();
237
325
  await assertPublicBrowserUrl(finalUrl);
238
326
  const [title, text, desktopScreenshot, extraction] = await Promise.all([
239
- page.title().catch(() => ""),
327
+ page.title().catch((error) => {
328
+ warnings.push(
329
+ `Browser title extraction unavailable: ${errorMessage(error)}`,
330
+ );
331
+ return "";
332
+ }),
240
333
  page
241
334
  .locator("body")
242
335
  .innerText()
243
- .catch(() => ""),
336
+ .catch((error) => {
337
+ warnings.push(
338
+ `Browser text extraction unavailable: ${errorMessage(error)}`,
339
+ );
340
+ return "";
341
+ }),
244
342
  page
245
343
  .screenshot({ type: "png", fullPage: false })
246
344
  .then(boundedScreenshot)
247
- .catch(() => undefined),
248
- page
249
- .evaluate(captureRenderedWebsiteContext)
250
- .catch(() => emptyExtraction()),
345
+ .catch((error) => {
346
+ warnings.push(
347
+ `Desktop screenshot unavailable: ${errorMessage(error)}`,
348
+ );
349
+ return undefined;
350
+ }),
351
+ page.evaluate(captureRenderedWebsiteContext).catch((error) => {
352
+ warnings.push(
353
+ `Browser style extraction unavailable: ${errorMessage(error)}`,
354
+ );
355
+ return emptyExtraction();
356
+ }),
251
357
  ]);
252
358
  await page.setViewportSize({ width: 390, height: 844 });
253
359
  const mobileScreenshot = await page
254
360
  .screenshot({ type: "png", fullPage: false })
255
361
  .then(boundedScreenshot)
256
- .catch(() => undefined);
362
+ .catch((error) => {
363
+ warnings.push(`Mobile screenshot unavailable: ${errorMessage(error)}`);
364
+ return undefined;
365
+ });
257
366
  const unboundedText = normalizeWhitespace(text);
258
367
  const textTruncated = unboundedText.length > MAX_RENDERED_TEXT_CHARS;
259
368
  const normalizedText = unboundedText.slice(0, MAX_RENDERED_TEXT_CHARS);
@@ -317,21 +426,88 @@ async function renderWithPlaywright(
317
426
  },
318
427
  };
319
428
  } finally {
429
+ await isolatedContext?.close?.().catch((error) => {
430
+ warnings.push(
431
+ `Browser context cleanup unavailable: ${errorMessage(error)}`,
432
+ );
433
+ });
320
434
  await browser.close().catch(() => undefined);
321
435
  }
322
436
  }
323
437
 
324
- async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
438
+ async function waitForFontReadiness(
439
+ page: PlaywrightPageLike,
440
+ timeoutMs: number,
441
+ ): Promise<void> {
442
+ let timeout: ReturnType<typeof setTimeout> | undefined;
443
+ try {
444
+ await Promise.race([
445
+ page.evaluate(async () => {
446
+ if (document.fonts?.ready) await document.fonts.ready;
447
+ }),
448
+ new Promise<never>((_, reject) => {
449
+ timeout = setTimeout(() => {
450
+ reject(new Error(`font readiness timed out after ${timeoutMs}ms`));
451
+ }, timeoutMs);
452
+ }),
453
+ ]);
454
+ } finally {
455
+ if (timeout !== undefined) clearTimeout(timeout);
456
+ }
457
+ }
458
+
459
+ async function installNavigationGuard(
460
+ page: PlaywrightPageLike,
461
+ renderRequest: RenderedPageRequest,
462
+ warnings: string[],
463
+ ): Promise<() => string | undefined> {
464
+ let finalNavigationUrl: string | undefined;
465
+ let resourceCount = 0;
466
+ let resourceBytes = 0;
467
+ let reservedResourceBytes = 0;
468
+ const bodyBudgetWaiters: Array<() => void> = [];
469
+ let resourceLimitWarningAdded = false;
470
+ let blockedResourceWarningAdded = false;
471
+ let failedResourceWarningAdded = false;
472
+
473
+ const reserveBodyBudget = async (): Promise<() => void> => {
474
+ while (
475
+ reservedResourceBytes + MAX_BROWSER_RESOURCE_BYTES >
476
+ MAX_BROWSER_RESOURCE_BYTES_TOTAL
477
+ ) {
478
+ await new Promise<void>((resolve) => {
479
+ bodyBudgetWaiters.push(resolve);
480
+ });
481
+ }
482
+ reservedResourceBytes += MAX_BROWSER_RESOURCE_BYTES;
483
+ let released = false;
484
+ return () => {
485
+ if (released) return;
486
+ released = true;
487
+ reservedResourceBytes -= MAX_BROWSER_RESOURCE_BYTES;
488
+ bodyBudgetWaiters.shift()?.();
489
+ };
490
+ };
491
+
492
+ const addWarning = (value: string): void => {
493
+ if (!warnings.includes(value)) warnings.push(value);
494
+ };
495
+
325
496
  await page.route("**/*", async (route) => {
326
- const request = route.request();
497
+ const browserRequest = route.request();
327
498
  let parsed: URL;
328
499
  try {
329
- parsed = new URL(request.url());
500
+ parsed = new URL(browserRequest.url());
330
501
  } catch {
502
+ // coercion-ok: an absent optional package means the next compatible probe should run.
331
503
  await route.abort("blockedbyclient");
332
504
  return;
333
505
  }
334
- if (parsed.protocol === "data:" || parsed.protocol === "blob:") {
506
+ if (
507
+ parsed.protocol === "about:" ||
508
+ parsed.protocol === "data:" ||
509
+ parsed.protocol === "blob:"
510
+ ) {
335
511
  await route.continue();
336
512
  return;
337
513
  }
@@ -339,14 +515,294 @@ async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
339
515
  await route.abort("blockedbyclient");
340
516
  return;
341
517
  }
518
+
519
+ const method = (browserRequest.method?.() ?? "GET").toUpperCase();
520
+ if (method !== "GET" && method !== "HEAD") {
521
+ await route.abort("blockedbyclient");
522
+ if (!resourceLimitWarningAdded) {
523
+ resourceLimitWarningAdded = true;
524
+ addWarning(
525
+ "Browser blocked a non-read-only resource request during extraction.",
526
+ );
527
+ }
528
+ return;
529
+ }
530
+ if (resourceCount >= MAX_BROWSER_RESOURCE_COUNT) {
531
+ await route.abort("blockedbyclient");
532
+ if (!resourceLimitWarningAdded) {
533
+ resourceLimitWarningAdded = true;
534
+ addWarning(
535
+ `Browser resource budget reached (${MAX_BROWSER_RESOURCE_COUNT} requests).`,
536
+ );
537
+ }
538
+ return;
539
+ }
540
+
541
+ // Reserve the request slot before the first await. Browser route handlers
542
+ // overlap, so incrementing only after the proxy response arrives lets a
543
+ // burst of requests all pass the limit check.
544
+ resourceCount += 1;
545
+ let bodyBudgetRelease: (() => void) | undefined;
546
+ let committedBytes = 0;
547
+ let fulfilled = false;
548
+
342
549
  try {
343
550
  await assertPublicBrowserUrl(parsed.href);
344
- } catch {
551
+ const response = await ssrfSafeFetch(
552
+ parsed.href,
553
+ {
554
+ method,
555
+ headers: browserRequestHeaders(browserRequest),
556
+ signal: AbortSignal.timeout(
557
+ Math.min(
558
+ BROWSER_RESOURCE_TIMEOUT_MS,
559
+ boundedTimeout(renderRequest.timeoutMs),
560
+ ),
561
+ ),
562
+ },
563
+ { maxRedirects: 5 },
564
+ );
565
+ bodyBudgetRelease = await reserveBodyBudget();
566
+ const body = await readBoundedResponseBytes(
567
+ response,
568
+ MAX_BROWSER_RESOURCE_BYTES,
569
+ );
570
+ bodyBudgetRelease();
571
+ bodyBudgetRelease = undefined;
572
+ if (resourceBytes + body.byteLength > MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
573
+ await route.abort("blockedbyclient");
574
+ if (!resourceLimitWarningAdded) {
575
+ resourceLimitWarningAdded = true;
576
+ addWarning(
577
+ `Browser resource budget reached (${MAX_BROWSER_RESOURCE_BYTES_TOTAL} bytes).`,
578
+ );
579
+ }
580
+ return;
581
+ }
582
+ resourceBytes += body.byteLength;
583
+ committedBytes = body.byteLength;
584
+ if (browserRequest.isNavigationRequest()) {
585
+ finalNavigationUrl = response.url || parsed.href;
586
+ }
587
+ await route.fulfill({
588
+ status: response.status,
589
+ headers: browserResponseHeaders(response),
590
+ body: Buffer.from(body),
591
+ });
592
+ fulfilled = true;
593
+ } catch (error) {
594
+ if (committedBytes > 0) {
595
+ resourceBytes -= committedBytes;
596
+ committedBytes = 0;
597
+ }
345
598
  await route.abort("blockedbyclient");
346
- return;
599
+ if (isSsrfError(error)) {
600
+ if (!blockedResourceWarningAdded) {
601
+ blockedResourceWarningAdded = true;
602
+ addWarning(
603
+ "Some browser resources were blocked by the SSRF safety policy.",
604
+ );
605
+ }
606
+ } else if (!failedResourceWarningAdded) {
607
+ failedResourceWarningAdded = true;
608
+ addWarning(
609
+ "Some browser resources could not be fetched through the safe network proxy.",
610
+ );
611
+ }
612
+ } finally {
613
+ bodyBudgetRelease?.();
614
+ if (!fulfilled) resourceCount -= 1;
347
615
  }
348
- await route.continue();
349
616
  });
617
+ return () => finalNavigationUrl;
618
+ }
619
+
620
+ function browserRequestHeaders(
621
+ request: PlaywrightRequestLike,
622
+ ): Record<string, string> {
623
+ const source = request.headers?.() ?? {};
624
+ return Object.fromEntries(
625
+ Object.entries(source).filter(([name, value]) => {
626
+ return BROWSER_REQUEST_HEADERS.has(name.toLowerCase()) && Boolean(value);
627
+ }),
628
+ );
629
+ }
630
+
631
+ function browserResponseHeaders(response: Response): Record<string, string> {
632
+ const headers: Record<string, string> = {};
633
+ response.headers.forEach((value, name) => {
634
+ if (!BROWSER_RESPONSE_HEADERS.has(name.toLowerCase())) {
635
+ headers[name] = value;
636
+ }
637
+ });
638
+ return headers;
639
+ }
640
+
641
+ function isSsrfError(error: unknown): boolean {
642
+ return /ssrf blocked|private\/internal|connect blocked/i.test(
643
+ errorMessage(error),
644
+ );
645
+ }
646
+
647
+ const SYSTEM_CHROME_EXECUTABLES = [
648
+ "/usr/bin/google-chrome-stable",
649
+ "/usr/bin/google-chrome",
650
+ "/usr/bin/chromium",
651
+ "/usr/bin/chromium-browser",
652
+ ];
653
+
654
+ async function launchChromium(
655
+ chromium: PlaywrightLike["chromium"],
656
+ ): Promise<PlaywrightBrowserLike> {
657
+ const launchOptions = {
658
+ headless: true,
659
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
660
+ };
661
+ let missingBrowserError: unknown;
662
+ try {
663
+ return await chromium.launch(launchOptions);
664
+ } catch (error) {
665
+ if (!isMissingBrowserError(error)) throw error;
666
+ missingBrowserError = error;
667
+ }
668
+
669
+ const serverlessChromium = await loadOptionalServerlessChromium();
670
+ if (serverlessChromium) {
671
+ try {
672
+ const executablePath = await serverlessChromium.executablePath();
673
+ if (executablePath) {
674
+ return await chromium.launch({
675
+ ...launchOptions,
676
+ args: [...launchOptions.args, ...(serverlessChromium.args ?? [])],
677
+ executablePath,
678
+ });
679
+ }
680
+ } catch (error) {
681
+ if (!isMissingBrowserError(error)) throw error;
682
+ missingBrowserError = error;
683
+ }
684
+ }
685
+
686
+ for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
687
+ if (!existsSync(executablePath)) continue;
688
+ try {
689
+ return await chromium.launch({ ...launchOptions, executablePath });
690
+ } catch {
691
+ continue;
692
+ }
693
+ }
694
+ if (missingBrowserError) {
695
+ throw missingBrowserError;
696
+ }
697
+ throw new Error(
698
+ "No Chromium executable is available for browser extraction.",
699
+ );
700
+ }
701
+
702
+ interface ServerlessChromiumLike {
703
+ args?: string[];
704
+ executablePath(): Promise<string>;
705
+ }
706
+
707
+ async function loadOptionalServerlessChromium(): Promise<ServerlessChromiumLike | null> {
708
+ const specifier = "@sparticuz/chromium";
709
+ try {
710
+ const module = (await import(/* @vite-ignore */ specifier)) as unknown as {
711
+ default?: Partial<ServerlessChromiumLike>;
712
+ } & Partial<ServerlessChromiumLike>;
713
+ const chromium = module.default ?? module;
714
+ return typeof chromium.executablePath === "function"
715
+ ? (chromium as ServerlessChromiumLike)
716
+ : null;
717
+ } catch {
718
+ // coercion-ok: this optional capability is absent in non-serverless installs.
719
+ return null;
720
+ }
721
+ }
722
+
723
+ /*
724
+ * Kept separate from Playwright loading so a deployment can omit the large
725
+ * serverless Chromium package and still use Builder Browser or system Chrome.
726
+ */
727
+ async function loadOptionalPlaywright(): Promise<PlaywrightLike | null> {
728
+ for (const specifier of [
729
+ "playwright",
730
+ "@playwright/test",
731
+ "playwright-core",
732
+ ]) {
733
+ try {
734
+ const module = (await import(
735
+ /* @vite-ignore */ specifier
736
+ )) as unknown as {
737
+ default?: Partial<PlaywrightLike>;
738
+ } & Partial<PlaywrightLike>;
739
+ for (const candidate of [module.default, module]) {
740
+ if (typeof candidate?.chromium?.launch === "function") {
741
+ return candidate as PlaywrightLike;
742
+ }
743
+ }
744
+ // coercion-ok: an absent optional package means the next compatible probe should run.
745
+ } catch {
746
+ // Try the next compatible browser package before falling back to HTML.
747
+ }
748
+ }
749
+
750
+ // coercion-ok: browser packages are optional in non-Node runtimes.
751
+ return null;
752
+ }
753
+
754
+ async function defaultBuilderBrowserRequest(input: {
755
+ sessionId: string;
756
+ }): Promise<Record<string, unknown>> {
757
+ const server = (await import("@agent-native/core/server")) as unknown as {
758
+ requestBuilderBrowserConnection?: (value: {
759
+ sessionId: string;
760
+ }) => Promise<Record<string, unknown>>;
761
+ };
762
+ if (!server.requestBuilderBrowserConnection) {
763
+ throw new Error(
764
+ "@agent-native/core/server does not export requestBuilderBrowserConnection.",
765
+ );
766
+ }
767
+ return server.requestBuilderBrowserConnection(input);
768
+ }
769
+
770
+ function isMissingBrowserError(error: unknown): boolean {
771
+ const message = error instanceof Error ? error.message : String(error);
772
+ return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(
773
+ message,
774
+ );
775
+ }
776
+
777
+ /** Close common consent banners without accepting tracking or changing page data. */
778
+ function dismissConsentOverlays(): void {
779
+ const selectors = [
780
+ '[aria-label*="reject" i]',
781
+ '[aria-label*="decline" i]',
782
+ '[aria-label*="close" i]',
783
+ '[data-testid*="reject" i]',
784
+ '[data-testid*="decline" i]',
785
+ '[data-testid*="close" i]',
786
+ 'button[id*="reject" i]',
787
+ 'button[id*="decline" i]',
788
+ 'button[class*="reject" i]',
789
+ 'button[class*="decline" i]',
790
+ ];
791
+ for (const selector of selectors) {
792
+ const element = document.querySelector<HTMLElement>(selector);
793
+ if (!element) continue;
794
+ const rect = element.getBoundingClientRect();
795
+ const style = getComputedStyle(element);
796
+ if (
797
+ rect.width > 0 &&
798
+ rect.height > 0 &&
799
+ style.visibility !== "hidden" &&
800
+ style.display !== "none"
801
+ ) {
802
+ element.click();
803
+ return;
804
+ }
805
+ }
350
806
  }
351
807
 
352
808
  async function renderStatic(
@@ -423,31 +879,6 @@ async function assertPublicBrowserUrl(value: string): Promise<void> {
423
879
  }
424
880
  }
425
881
 
426
- async function defaultBuilderBrowserRequest(input: {
427
- sessionId: string;
428
- }): Promise<Record<string, unknown>> {
429
- const server = (await import("@agent-native/core/server")) as unknown as {
430
- requestBuilderBrowserConnection?: (value: {
431
- sessionId: string;
432
- }) => Promise<Record<string, unknown>>;
433
- };
434
- if (!server.requestBuilderBrowserConnection) {
435
- throw new Error(
436
- "@agent-native/core/server does not export requestBuilderBrowserConnection.",
437
- );
438
- }
439
- return server.requestBuilderBrowserConnection(input);
440
- }
441
-
442
- async function loadOptionalPlaywright(): Promise<PlaywrightLike | null> {
443
- const specifier = "playwright";
444
- try {
445
- return (await import(specifier)) as PlaywrightLike;
446
- } catch {
447
- return null;
448
- }
449
- }
450
-
451
882
  function captureRenderedWebsiteContext(): WebsiteExtraction {
452
883
  const MAX_ASSETS = 500;
453
884
  const MAX_LINKS = 500;
@@ -455,8 +886,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
455
886
  const MAX_TYPE_STYLES = 100;
456
887
  const MAX_SPACING_VALUES = 100;
457
888
  const MAX_RADIUS_VALUES = 64;
889
+ const MAX_SHADOWS = 32;
890
+ const MAX_BACKGROUNDS = 32;
458
891
  const MAX_VARIABLES = 128;
459
892
  const MAX_TEXT = 2_000_000;
893
+ const MAX_COMPONENT_STYLES = 24;
460
894
  const assets = new Map<string, WebsiteExtraction["assets"][number]>();
461
895
  const links = new Set<string>();
462
896
  const colors: string[] = [];
@@ -466,7 +900,161 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
466
900
  >();
467
901
  const spacing = new Set<string>();
468
902
  const radii = new Set<string>();
903
+ const shadows = new Set<string>();
904
+ const backgrounds = new Set<string>();
905
+ const components: NonNullable<
906
+ WebsiteExtraction["designTokens"]["components"]
907
+ > = [];
469
908
  const cssVariables: Record<string, string> = {};
909
+ const semanticColors: NonNullable<
910
+ WebsiteExtraction["designTokens"]["semanticColors"]
911
+ > = {};
912
+
913
+ const isOpaque = (value: string): boolean => {
914
+ const normalized = value.trim().toLowerCase();
915
+ if (!normalized || normalized === "transparent") return false;
916
+ const functionBody = normalized.match(/^[a-z-]+\((.*)\)$/)?.[1];
917
+ if (!functionBody) return true;
918
+ const alphaValue = functionBody.includes("/")
919
+ ? functionBody.split("/").at(-1)?.trim()
920
+ : functionBody.split(",").length === 4
921
+ ? functionBody.split(",").at(-1)?.trim()
922
+ : undefined;
923
+ if (!alphaValue) return true;
924
+ const alpha = alphaValue.endsWith("%")
925
+ ? Number.parseFloat(alphaValue) / 100
926
+ : Number.parseFloat(alphaValue);
927
+ return Number.isNaN(alpha) || alpha > 0.02;
928
+ };
929
+
930
+ const addColor = (value: string): void => {
931
+ if (colors.length >= MAX_COLOR_VALUES || !isOpaque(value)) return;
932
+ const normalized = value.trim();
933
+ if (!colors.includes(normalized)) colors.push(normalized);
934
+ };
935
+
936
+ const visible = (element: Element): boolean => {
937
+ const style = getComputedStyle(element);
938
+ const rect = element.getBoundingClientRect();
939
+ return (
940
+ rect.width > 0 &&
941
+ rect.height > 0 &&
942
+ style.display !== "none" &&
943
+ style.visibility !== "hidden" &&
944
+ Number(style.opacity || 1) > 0.02
945
+ );
946
+ };
947
+
948
+ const firstVisible = (selector: string): Element | undefined =>
949
+ Array.from(document.querySelectorAll(selector)).find(visible);
950
+
951
+ const opaqueValue = (value: string): string | undefined =>
952
+ isOpaque(value) ? value.trim() : undefined;
953
+
954
+ const recordComputedStyle = (
955
+ element: Element,
956
+ role?: NonNullable<
957
+ WebsiteExtraction["designTokens"]["components"]
958
+ >[number]["role"],
959
+ ) => {
960
+ const style = getComputedStyle(element);
961
+ const values = [
962
+ style.color,
963
+ style.backgroundColor,
964
+ style.borderTopColor,
965
+ style.borderRightColor,
966
+ style.borderBottomColor,
967
+ style.borderLeftColor,
968
+ ];
969
+ values.forEach(addColor);
970
+ if (style.boxShadow && style.boxShadow !== "none") {
971
+ if (shadows.size < MAX_SHADOWS) shadows.add(style.boxShadow);
972
+ }
973
+ if (style.backgroundImage && style.backgroundImage !== "none") {
974
+ if (backgrounds.size < MAX_BACKGROUNDS) {
975
+ backgrounds.add(style.backgroundImage);
976
+ }
977
+ }
978
+ for (const value of [
979
+ style.marginTop,
980
+ style.marginRight,
981
+ style.marginBottom,
982
+ style.marginLeft,
983
+ style.paddingTop,
984
+ style.paddingRight,
985
+ style.paddingBottom,
986
+ style.paddingLeft,
987
+ style.gap,
988
+ style.rowGap,
989
+ style.columnGap,
990
+ ]) {
991
+ if (
992
+ spacing.size < MAX_SPACING_VALUES &&
993
+ value &&
994
+ value !== "0px" &&
995
+ value !== "normal"
996
+ ) {
997
+ spacing.add(value);
998
+ }
999
+ }
1000
+ for (const value of [
1001
+ style.borderTopLeftRadius,
1002
+ style.borderTopRightRadius,
1003
+ style.borderBottomRightRadius,
1004
+ style.borderBottomLeftRadius,
1005
+ ]) {
1006
+ if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
1007
+ radii.add(value);
1008
+ }
1009
+ }
1010
+ const type = {
1011
+ family: style.fontFamily,
1012
+ size: style.fontSize,
1013
+ weight: style.fontWeight,
1014
+ lineHeight: style.lineHeight,
1015
+ letterSpacing: style.letterSpacing,
1016
+ };
1017
+ if (typography.size < MAX_TYPE_STYLES && type.family) {
1018
+ typography.set(JSON.stringify(type), type);
1019
+ }
1020
+ if (!role || components.length >= MAX_COMPONENT_STYLES) return style;
1021
+ components.push({
1022
+ role,
1023
+ fontFamily: style.fontFamily,
1024
+ fontSize: style.fontSize,
1025
+ fontWeight: style.fontWeight,
1026
+ lineHeight: style.lineHeight,
1027
+ letterSpacing: style.letterSpacing,
1028
+ color: opaqueValue(style.color),
1029
+ backgroundColor: opaqueValue(style.backgroundColor),
1030
+ backgroundImage:
1031
+ style.backgroundImage !== "none" ? style.backgroundImage : undefined,
1032
+ border:
1033
+ style.borderStyle !== "none" && style.borderWidth !== "0px"
1034
+ ? style.border
1035
+ : undefined,
1036
+ borderRadius:
1037
+ style.borderTopLeftRadius !== "0px" ? style.borderRadius : undefined,
1038
+ boxShadow: style.boxShadow !== "none" ? style.boxShadow : undefined,
1039
+ padding: style.padding !== "0px" ? style.padding : undefined,
1040
+ gap: style.gap !== "normal" ? style.gap : undefined,
1041
+ textTransform:
1042
+ style.textTransform !== "none" ? style.textTransform : undefined,
1043
+ });
1044
+ return style;
1045
+ };
1046
+
1047
+ const styleFor = (
1048
+ selector: string,
1049
+ role?: NonNullable<
1050
+ WebsiteExtraction["designTokens"]["components"]
1051
+ >[number]["role"],
1052
+ ): CSSStyleDeclaration | undefined => {
1053
+ const element = firstVisible(selector);
1054
+ if (!element) return undefined;
1055
+ return recordComputedStyle(element, role);
1056
+ };
1057
+
470
1058
  const addAsset = (
471
1059
  raw: string | null | undefined,
472
1060
  kind: WebsiteExtraction["assets"][number]["kind"],
@@ -548,64 +1136,99 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
548
1136
  }
549
1137
  }
550
1138
  }
551
- const elements = Array.from(document.querySelectorAll("body *")).slice(
552
- 0,
553
- 500,
1139
+
1140
+ const bodyStyle = document.body
1141
+ ? recordComputedStyle(document.body)
1142
+ : undefined;
1143
+ const rootBackground = opaqueValue(rootStyle.backgroundColor);
1144
+ const bodyBackground = bodyStyle
1145
+ ? opaqueValue(bodyStyle.backgroundColor)
1146
+ : undefined;
1147
+ const headingStyle = styleFor("h1, h2, h3", "heading");
1148
+ const textStyle = styleFor("p, li, label, body", "body");
1149
+ const buttonStyle = styleFor(
1150
+ 'button, [role="button"], input[type="submit"], a[class*="button" i], a[class*="cta" i]',
1151
+ "button",
1152
+ );
1153
+ const linkStyle = styleFor("a[href]", "link");
1154
+ const cardStyle = styleFor(
1155
+ 'article, [class*="card" i], [class*="panel" i], [class*="surface" i], section',
1156
+ "card",
1157
+ );
1158
+ styleFor("input, textarea, select", "input");
1159
+ styleFor("nav, header", "nav");
1160
+ styleFor('main, [class*="hero" i]', "hero");
1161
+
1162
+ const setSemantic = (
1163
+ role: keyof NonNullable<
1164
+ WebsiteExtraction["designTokens"]["semanticColors"]
1165
+ >,
1166
+ value: string | undefined,
1167
+ ): void => {
1168
+ if (value) semanticColors[role] = value;
1169
+ };
1170
+ setSemantic("background", bodyBackground ?? rootBackground);
1171
+ setSemantic(
1172
+ "surface",
1173
+ cardStyle ? opaqueValue(cardStyle.backgroundColor) : undefined,
1174
+ );
1175
+ setSemantic("text", textStyle ? opaqueValue(textStyle.color) : undefined);
1176
+ const mutedElement = firstVisible("small, figcaption, [class*='muted' i]");
1177
+ setSemantic(
1178
+ "textMuted",
1179
+ mutedElement
1180
+ ? opaqueValue(getComputedStyle(mutedElement).color)
1181
+ : undefined,
1182
+ );
1183
+ setSemantic(
1184
+ "accent",
1185
+ linkStyle
1186
+ ? opaqueValue(linkStyle.color)
1187
+ : buttonStyle
1188
+ ? opaqueValue(buttonStyle.backgroundColor)
1189
+ : undefined,
1190
+ );
1191
+ setSemantic(
1192
+ "primary",
1193
+ buttonStyle
1194
+ ? (opaqueValue(buttonStyle.backgroundColor) ??
1195
+ opaqueValue(buttonStyle.color))
1196
+ : colors[0],
554
1197
  );
1198
+ setSemantic(
1199
+ "secondary",
1200
+ semanticColors.surface ?? colors[1] ?? semanticColors.background,
1201
+ );
1202
+
1203
+ const layoutElement = firstVisible("main, [role='main'], body > div");
1204
+ const layoutStyle = layoutElement
1205
+ ? getComputedStyle(layoutElement)
1206
+ : undefined;
1207
+ const layoutRect = layoutElement?.getBoundingClientRect();
1208
+ const layout = {
1209
+ contentWidth:
1210
+ layoutStyle?.maxWidth && layoutStyle.maxWidth !== "none"
1211
+ ? layoutStyle.maxWidth
1212
+ : layoutRect && layoutRect.width > 0
1213
+ ? `${Math.round(layoutRect.width)}px`
1214
+ : undefined,
1215
+ pagePadding:
1216
+ bodyStyle?.padding && bodyStyle.padding !== "0px"
1217
+ ? bodyStyle.padding
1218
+ : undefined,
1219
+ sectionGap:
1220
+ firstVisible("section, article") &&
1221
+ getComputedStyle(firstVisible("section, article")!).gap !== "normal" &&
1222
+ getComputedStyle(firstVisible("section, article")!).gap !== "0px"
1223
+ ? getComputedStyle(firstVisible("section, article")!).gap
1224
+ : undefined,
1225
+ };
1226
+
1227
+ const elements = Array.from(document.querySelectorAll("body *"))
1228
+ .filter(visible)
1229
+ .slice(0, 700);
555
1230
  for (const element of elements) {
556
- const style = getComputedStyle(element);
557
- if (colors.length < MAX_COLOR_VALUES) {
558
- colors.push(
559
- style.color,
560
- style.backgroundColor,
561
- style.borderTopColor,
562
- style.borderRightColor,
563
- style.borderBottomColor,
564
- style.borderLeftColor,
565
- );
566
- }
567
- const type = {
568
- family: style.fontFamily,
569
- size: style.fontSize,
570
- weight: style.fontWeight,
571
- lineHeight: style.lineHeight,
572
- letterSpacing: style.letterSpacing,
573
- };
574
- if (typography.size < MAX_TYPE_STYLES) {
575
- typography.set(JSON.stringify(type), type);
576
- }
577
- for (const value of [
578
- style.marginTop,
579
- style.marginRight,
580
- style.marginBottom,
581
- style.marginLeft,
582
- style.paddingTop,
583
- style.paddingRight,
584
- style.paddingBottom,
585
- style.paddingLeft,
586
- style.gap,
587
- style.rowGap,
588
- style.columnGap,
589
- ]) {
590
- if (
591
- spacing.size < MAX_SPACING_VALUES &&
592
- value &&
593
- value !== "0px" &&
594
- value !== "normal"
595
- ) {
596
- spacing.add(value);
597
- }
598
- }
599
- for (const value of [
600
- style.borderTopLeftRadius,
601
- style.borderTopRightRadius,
602
- style.borderBottomRightRadius,
603
- style.borderBottomLeftRadius,
604
- ]) {
605
- if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
606
- radii.add(value);
607
- }
608
- }
1231
+ recordComputedStyle(element);
609
1232
  }
610
1233
  return {
611
1234
  title: document.title,
@@ -618,6 +1241,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
618
1241
  spacing: [...spacing],
619
1242
  radii: [...radii],
620
1243
  cssVariables,
1244
+ semanticColors,
1245
+ shadows: [...shadows],
1246
+ backgrounds: [...backgrounds],
1247
+ components,
1248
+ layout,
621
1249
  },
622
1250
  };
623
1251
  }
@@ -625,6 +1253,16 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
625
1253
  export function boundWebsiteExtraction(
626
1254
  extraction: WebsiteExtraction,
627
1255
  ): WebsiteExtraction {
1256
+ const boundedComponents = extraction.designTokens.components
1257
+ ?.slice(0, MAX_COMPONENT_STYLES)
1258
+ .map((component) =>
1259
+ Object.fromEntries(
1260
+ Object.entries(component).map(([key, value]) => [
1261
+ key,
1262
+ typeof value === "string" ? value.slice(0, 500) : value,
1263
+ ]),
1264
+ ),
1265
+ ) as WebsiteExtraction["designTokens"]["components"];
628
1266
  return {
629
1267
  title: normalizeWhitespace(extraction.title).slice(0, 500),
630
1268
  text: normalizeWhitespace(extraction.text).slice(
@@ -648,6 +1286,39 @@ export function boundWebsiteExtraction(
648
1286
  .slice(0, MAX_CSS_VARIABLES)
649
1287
  .map(([name, value]) => [name.slice(0, 500), value.slice(0, 4_096)]),
650
1288
  ),
1289
+ ...(extraction.designTokens.semanticColors
1290
+ ? {
1291
+ semanticColors: Object.fromEntries(
1292
+ Object.entries(extraction.designTokens.semanticColors)
1293
+ .filter(([, value]) => typeof value === "string" && value)
1294
+ .map(([name, value]) => [name, value.slice(0, 200)]),
1295
+ ),
1296
+ }
1297
+ : {}),
1298
+ ...(extraction.designTokens.shadows
1299
+ ? {
1300
+ shadows: extraction.designTokens.shadows
1301
+ .slice(0, 32)
1302
+ .map((value) => value.slice(0, 500)),
1303
+ }
1304
+ : {}),
1305
+ ...(extraction.designTokens.backgrounds
1306
+ ? {
1307
+ backgrounds: extraction.designTokens.backgrounds
1308
+ .slice(0, 32)
1309
+ .map((value) => value.slice(0, 500)),
1310
+ }
1311
+ : {}),
1312
+ ...(boundedComponents ? { components: boundedComponents } : {}),
1313
+ ...(extraction.designTokens.layout
1314
+ ? {
1315
+ layout: Object.fromEntries(
1316
+ Object.entries(extraction.designTokens.layout)
1317
+ .filter(([, value]) => typeof value === "string" && value)
1318
+ .map(([name, value]) => [name, value.slice(0, 200)]),
1319
+ ),
1320
+ }
1321
+ : {}),
651
1322
  },
652
1323
  };
653
1324
  }
@@ -696,10 +1367,6 @@ function boundedScreenshot(value: Uint8Array): Uint8Array | undefined {
696
1367
  return value.byteLength <= MAX_SCREENSHOT_BYTES ? value : undefined;
697
1368
  }
698
1369
 
699
- function stringValue(value: unknown): string | undefined {
700
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
701
- }
702
-
703
1370
  function errorMessage(error: unknown): string {
704
1371
  return error instanceof Error ? error.message : String(error);
705
1372
  }