@agent-native/creative-context 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/list-context-connections.js +1 -1
- package/dist/actions/list-context-connections.js.map +1 -1
- package/dist/client/CreativeContextChip.d.ts.map +1 -1
- package/dist/client/CreativeContextChip.js +1 -1
- package/dist/client/CreativeContextChip.js.map +1 -1
- package/dist/client/CreativeContextPanel.js +1 -1
- package/dist/client/CreativeContextPanel.js.map +1 -1
- package/dist/client/CreativeContextSettingsLink.d.ts.map +1 -1
- package/dist/client/CreativeContextSettingsLink.js +1 -1
- package/dist/client/CreativeContextSettingsLink.js.map +1 -1
- package/dist/connectors/rendered-page.d.ts +7 -11
- package/dist/connectors/rendered-page.d.ts.map +1 -1
- package/dist/connectors/rendered-page.js +298 -37
- package/dist/connectors/rendered-page.js.map +1 -1
- package/dist/jobs/server-worker.d.ts.map +1 -1
- package/dist/jobs/server-worker.js +9 -1
- package/dist/jobs/server-worker.js.map +1 -1
- package/dist/server/resource-paths.d.ts +1 -1
- package/dist/server/resource-paths.d.ts.map +1 -1
- package/dist/server/resource-paths.js +1 -1
- package/dist/server/resource-paths.js.map +1 -1
- package/package.json +4 -3
- package/src/actions/list-context-connections.spec.ts +1 -1
- package/src/actions/list-context-connections.ts +1 -1
- package/src/client/CreativeContextChip.tsx +1 -1
- package/src/client/CreativeContextPanel.tsx +1 -1
- package/src/client/CreativeContextSettingsLink.tsx +1 -1
- package/src/connectors/rendered-page.spec.ts +287 -1
- package/src/connectors/rendered-page.ts +393 -46
- package/src/connectors/website.spec.ts +3 -3
- package/src/jobs/server-worker.ts +13 -6
- package/src/server/resource-paths.spec.ts +1 -1
- package/src/server/resource-paths.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -31,8 +32,34 @@ const MAX_RADII = 64;
|
|
|
31
32
|
const MAX_CSS_VARIABLES = 128;
|
|
32
33
|
const MAX_COMPONENT_STYLES = 24;
|
|
33
34
|
const FONT_READY_TIMEOUT_MS = 4_000;
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
+
]);
|
|
36
63
|
|
|
37
64
|
export interface RenderedPageRequest {
|
|
38
65
|
url: string;
|
|
@@ -75,12 +102,19 @@ interface PlaywrightRequestLike {
|
|
|
75
102
|
url(): string;
|
|
76
103
|
isNavigationRequest(): boolean;
|
|
77
104
|
resourceType(): string;
|
|
105
|
+
method?(): string;
|
|
106
|
+
headers?(): Record<string, string>;
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
interface PlaywrightRouteLike {
|
|
81
110
|
request(): PlaywrightRequestLike;
|
|
82
111
|
continue(): Promise<void>;
|
|
83
112
|
abort(errorCode?: string): Promise<void>;
|
|
113
|
+
fulfill(options: {
|
|
114
|
+
status: number;
|
|
115
|
+
headers: Record<string, string>;
|
|
116
|
+
body: Uint8Array;
|
|
117
|
+
}): Promise<void>;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
interface PlaywrightPageLike {
|
|
@@ -128,16 +162,10 @@ interface PlaywrightLike {
|
|
|
128
162
|
}
|
|
129
163
|
|
|
130
164
|
export interface LayeredRenderedPageProviderOptions {
|
|
131
|
-
/**
|
|
132
|
-
* Retained for compatibility with the previous browser renderer. Arbitrary
|
|
133
|
-
* Chromium navigation is disabled until a connect-time SSRF guard exists.
|
|
134
|
-
*/
|
|
135
165
|
requestBuilderBrowserConnection?: (input: {
|
|
136
166
|
sessionId: string;
|
|
137
167
|
}) => Promise<Record<string, unknown>>;
|
|
138
|
-
/** @deprecated Browser navigation is disabled until it is connect-time guarded. */
|
|
139
168
|
loadPlaywright?: () => Promise<PlaywrightLike | null>;
|
|
140
|
-
/** @deprecated Browser navigation is disabled until it is connect-time guarded. */
|
|
141
169
|
requestAttachedBrowserConnection?: (input: {
|
|
142
170
|
sessionId: string;
|
|
143
171
|
url: string;
|
|
@@ -146,27 +174,94 @@ export interface LayeredRenderedPageProviderOptions {
|
|
|
146
174
|
}
|
|
147
175
|
|
|
148
176
|
export class LayeredRenderedPageProvider implements RenderedPageProvider {
|
|
177
|
+
readonly #requestBuilderBrowserConnection: (input: {
|
|
178
|
+
sessionId: string;
|
|
179
|
+
}) => Promise<Record<string, unknown>>;
|
|
180
|
+
readonly #loadPlaywright: () => Promise<PlaywrightLike | null>;
|
|
181
|
+
readonly #requestAttachedBrowserConnection?: NonNullable<
|
|
182
|
+
LayeredRenderedPageProviderOptions["requestAttachedBrowserConnection"]
|
|
183
|
+
>;
|
|
149
184
|
readonly #staticFetch: typeof ssrfSafeFetch;
|
|
150
185
|
|
|
151
186
|
constructor(options: LayeredRenderedPageProviderOptions = {}) {
|
|
187
|
+
this.#requestBuilderBrowserConnection =
|
|
188
|
+
options.requestBuilderBrowserConnection ?? defaultBuilderBrowserRequest;
|
|
189
|
+
this.#loadPlaywright = options.loadPlaywright ?? loadOptionalPlaywright;
|
|
190
|
+
this.#requestAttachedBrowserConnection =
|
|
191
|
+
options.requestAttachedBrowserConnection;
|
|
152
192
|
this.#staticFetch = options.staticFetch ?? ssrfSafeFetch;
|
|
153
193
|
}
|
|
154
194
|
|
|
155
195
|
async render(request: RenderedPageRequest): Promise<RenderedPageResult> {
|
|
156
196
|
await assertPublicBrowserUrl(request.url);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
197
|
+
const warnings: string[] = [];
|
|
198
|
+
const playwright = await this.#loadPlaywright().catch((error) => {
|
|
199
|
+
warnings.push(`Playwright unavailable: ${errorMessage(error)}`);
|
|
200
|
+
return null;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (request.preferHosted !== false && playwright) {
|
|
204
|
+
try {
|
|
205
|
+
const connection = await this.#requestBuilderBrowserConnection({
|
|
206
|
+
sessionId: `creative-context-${randomUUID()}`,
|
|
207
|
+
});
|
|
208
|
+
const wsUrl =
|
|
209
|
+
typeof connection.wsUrl === "string" ? connection.wsUrl.trim() : "";
|
|
210
|
+
if (!wsUrl) throw new Error("Builder Browser did not return wsUrl.");
|
|
211
|
+
return await renderWithPlaywright(
|
|
212
|
+
playwright,
|
|
213
|
+
request,
|
|
214
|
+
warnings,
|
|
215
|
+
"builder-browser",
|
|
216
|
+
wsUrl,
|
|
217
|
+
);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
warnings.push(`Builder Browser unavailable: ${errorMessage(error)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (playwright) {
|
|
224
|
+
try {
|
|
225
|
+
return await renderWithPlaywright(
|
|
226
|
+
playwright,
|
|
227
|
+
request,
|
|
228
|
+
warnings,
|
|
229
|
+
"local-playwright",
|
|
230
|
+
);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
warnings.push(`Local Playwright unavailable: ${errorMessage(error)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (playwright && this.#requestAttachedBrowserConnection) {
|
|
237
|
+
try {
|
|
238
|
+
const connection = await this.#requestAttachedBrowserConnection({
|
|
239
|
+
sessionId: `creative-context-attached-${randomUUID()}`,
|
|
240
|
+
url: request.url,
|
|
241
|
+
});
|
|
242
|
+
if (!connection.wsUrl?.trim()) {
|
|
243
|
+
throw new Error("Approved attached browser did not return wsUrl.");
|
|
244
|
+
}
|
|
245
|
+
return await renderWithPlaywright(
|
|
246
|
+
playwright,
|
|
247
|
+
request,
|
|
248
|
+
warnings,
|
|
249
|
+
"attached-chrome",
|
|
250
|
+
connection.wsUrl,
|
|
251
|
+
);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
warnings.push(`Attached Chrome unavailable: ${errorMessage(error)}`);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
warnings.push(
|
|
257
|
+
"Attached Chrome unavailable: no approved browser connection adapter is configured.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return renderStatic(request, warnings, this.#staticFetch);
|
|
162
262
|
}
|
|
163
263
|
}
|
|
164
264
|
|
|
165
|
-
/**
|
|
166
|
-
* Internal browser renderer retained for a future connect-time-guarded
|
|
167
|
-
* adapter. `LayeredRenderedPageProvider` must not call this for arbitrary
|
|
168
|
-
* caller URLs until Chromium's socket connection is guarded as well.
|
|
169
|
-
*/
|
|
170
265
|
export async function renderWithPlaywright(
|
|
171
266
|
playwright: PlaywrightLike,
|
|
172
267
|
request: RenderedPageRequest,
|
|
@@ -179,20 +274,19 @@ export async function renderWithPlaywright(
|
|
|
179
274
|
: await launchChromium(playwright.chromium);
|
|
180
275
|
let isolatedContext: PlaywrightContextLike | undefined;
|
|
181
276
|
try {
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
isolatedContext = await browser.newContext?.();
|
|
188
|
-
} catch {
|
|
189
|
-
isolatedContext = undefined;
|
|
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.");
|
|
190
282
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
283
|
+
isolatedContext = await browser.newContext();
|
|
284
|
+
const page = await isolatedContext.newPage();
|
|
285
|
+
const getFinalNavigationUrl = await installNavigationGuard(
|
|
286
|
+
page,
|
|
287
|
+
request,
|
|
288
|
+
warnings,
|
|
289
|
+
);
|
|
196
290
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
197
291
|
await page.goto(request.url, {
|
|
198
292
|
timeout: boundedTimeout(request.timeoutMs),
|
|
@@ -227,7 +321,7 @@ export async function renderWithPlaywright(
|
|
|
227
321
|
});
|
|
228
322
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
229
323
|
await page.evaluate(dismissConsentOverlays).catch(() => undefined);
|
|
230
|
-
const finalUrl = page.url();
|
|
324
|
+
const finalUrl = getFinalNavigationUrl() ?? page.url();
|
|
231
325
|
await assertPublicBrowserUrl(finalUrl);
|
|
232
326
|
const [title, text, desktopScreenshot, extraction] = await Promise.all([
|
|
233
327
|
page.title().catch((error) => {
|
|
@@ -362,17 +456,58 @@ async function waitForFontReadiness(
|
|
|
362
456
|
}
|
|
363
457
|
}
|
|
364
458
|
|
|
365
|
-
async function installNavigationGuard(
|
|
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
|
+
|
|
366
496
|
await page.route("**/*", async (route) => {
|
|
367
|
-
const
|
|
497
|
+
const browserRequest = route.request();
|
|
368
498
|
let parsed: URL;
|
|
369
499
|
try {
|
|
370
|
-
parsed = new URL(
|
|
500
|
+
parsed = new URL(browserRequest.url());
|
|
371
501
|
} catch {
|
|
502
|
+
// coercion-ok: an absent optional package means the next compatible probe should run.
|
|
372
503
|
await route.abort("blockedbyclient");
|
|
373
504
|
return;
|
|
374
505
|
}
|
|
375
|
-
if (
|
|
506
|
+
if (
|
|
507
|
+
parsed.protocol === "about:" ||
|
|
508
|
+
parsed.protocol === "data:" ||
|
|
509
|
+
parsed.protocol === "blob:"
|
|
510
|
+
) {
|
|
376
511
|
await route.continue();
|
|
377
512
|
return;
|
|
378
513
|
}
|
|
@@ -380,14 +515,133 @@ async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
|
|
|
380
515
|
await route.abort("blockedbyclient");
|
|
381
516
|
return;
|
|
382
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
|
+
|
|
383
549
|
try {
|
|
384
550
|
await assertPublicBrowserUrl(parsed.href);
|
|
385
|
-
|
|
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
|
+
}
|
|
386
598
|
await route.abort("blockedbyclient");
|
|
387
|
-
|
|
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;
|
|
388
615
|
}
|
|
389
|
-
await route.continue();
|
|
390
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
|
+
);
|
|
391
645
|
}
|
|
392
646
|
|
|
393
647
|
const SYSTEM_CHROME_EXECUTABLES = [
|
|
@@ -404,20 +658,113 @@ async function launchChromium(
|
|
|
404
658
|
headless: true,
|
|
405
659
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
406
660
|
};
|
|
661
|
+
let missingBrowserError: unknown;
|
|
407
662
|
try {
|
|
408
663
|
return await chromium.launch(launchOptions);
|
|
409
664
|
} catch (error) {
|
|
410
665
|
if (!isMissingBrowserError(error)) throw error;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|
|
417
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.
|
|
418
747
|
}
|
|
419
|
-
throw error;
|
|
420
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);
|
|
421
768
|
}
|
|
422
769
|
|
|
423
770
|
function isMissingBrowserError(error: unknown): boolean {
|
|
@@ -155,7 +155,7 @@ describe("website context connector", () => {
|
|
|
155
155
|
).rejects.toThrow(/SSRF blocked/i);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
it("
|
|
158
|
+
it("falls back explicitly when the browser capability is unavailable", async () => {
|
|
159
159
|
const staticFetch = vi.fn(async (url: string) =>
|
|
160
160
|
response(
|
|
161
161
|
url,
|
|
@@ -176,7 +176,7 @@ describe("website context connector", () => {
|
|
|
176
176
|
preferHosted: false,
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
expect(loadPlaywright).
|
|
179
|
+
expect(loadPlaywright).toHaveBeenCalledTimes(1);
|
|
180
180
|
expect(staticFetch).toHaveBeenCalledTimes(1);
|
|
181
181
|
expect(result).toMatchObject({
|
|
182
182
|
method: "static-html",
|
|
@@ -185,7 +185,7 @@ describe("website context connector", () => {
|
|
|
185
185
|
});
|
|
186
186
|
expect(result.warnings).toEqual(
|
|
187
187
|
expect.arrayContaining([
|
|
188
|
-
expect.stringContaining("
|
|
188
|
+
expect.stringContaining("Playwright unavailable"),
|
|
189
189
|
expect.stringContaining("SSRF-safe static HTML fallback"),
|
|
190
190
|
]),
|
|
191
191
|
);
|
|
@@ -202,16 +202,23 @@ export function startCreativeContextImportSweep(input: {
|
|
|
202
202
|
if (!appId) throw new Error("appId is required.");
|
|
203
203
|
const existing = sweepTimers.get(appId);
|
|
204
204
|
if (!existing) {
|
|
205
|
+
let running = false;
|
|
205
206
|
const run = () => {
|
|
207
|
+
if (running) return;
|
|
208
|
+
running = true;
|
|
206
209
|
void Promise.all([
|
|
207
210
|
processDueCreativeContextImportJobs({ appId }),
|
|
208
211
|
processDueCreativeContextBackgroundJobs({ appId }),
|
|
209
|
-
])
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
])
|
|
213
|
+
.catch((error) => {
|
|
214
|
+
console.error(
|
|
215
|
+
"[creative-context] due job sweep failed:",
|
|
216
|
+
error instanceof Error ? error.message : error,
|
|
217
|
+
);
|
|
218
|
+
})
|
|
219
|
+
.finally(() => {
|
|
220
|
+
running = false;
|
|
221
|
+
});
|
|
215
222
|
};
|
|
216
223
|
run();
|
|
217
224
|
const timer = setInterval(
|
|
@@ -4,6 +4,6 @@ import { getCreativeContextResourcePath } from "./resource-paths.js";
|
|
|
4
4
|
|
|
5
5
|
describe("creative context shareable resource paths", () => {
|
|
6
6
|
it("opens the Library tab exposed by each app", () => {
|
|
7
|
-
expect(getCreativeContextResourcePath()).toBe("/
|
|
7
|
+
expect(getCreativeContextResourcePath()).toBe("/settings/library");
|
|
8
8
|
});
|
|
9
9
|
});
|