@apifuse/provider-sdk 2.2.0-beta.31 → 2.2.0-beta.33

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,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import type { Frame, LaunchOptions, Locator, Page, Request, Route } from "playwright";
2
+ import type { CDPSession, Frame, LaunchOptions, Locator, Page, Request, Route } from "playwright";
3
3
 
4
4
  import { ProviderError } from "../errors.js";
5
5
  import type {
@@ -87,13 +87,28 @@ type CdpFrameTreeNode = {
87
87
  type CdpFetchFulfillParams = {
88
88
  readonly requestId: string;
89
89
  readonly responseCode: number;
90
- readonly responseHeaders?: readonly {
90
+ readonly responseHeaders?: {
91
91
  readonly name: string;
92
92
  readonly value: string;
93
93
  }[];
94
94
  readonly body?: string;
95
95
  };
96
96
 
97
+ type CdpPausedDocumentResponse = {
98
+ readonly hasBody: boolean;
99
+ readonly requestId: string;
100
+ readonly responseCode: number;
101
+ readonly responseHeaders: readonly {
102
+ readonly name: string;
103
+ readonly value: string;
104
+ }[];
105
+ };
106
+
107
+ type CdpResponseBody = {
108
+ readonly base64Encoded?: boolean;
109
+ readonly body?: string;
110
+ };
111
+
97
112
  type BrowserPageContract = BrowserPage;
98
113
 
99
114
  function toResourceBody(body: BrowserResourceBody | undefined): Buffer | string | undefined {
@@ -109,7 +124,7 @@ function toResourceBody(body: BrowserResourceBody | undefined): Buffer | string
109
124
  }
110
125
 
111
126
  function isResourceMethod(method: string): method is BrowserResourceMethod {
112
- return method === "GET" || method === "HEAD";
127
+ return method === "GET" || method === "HEAD" || method === "POST";
113
128
  }
114
129
 
115
130
  async function toResourceRequest(request: Request): Promise<BrowserResourceRequest | null> {
@@ -164,6 +179,85 @@ function getCdpPausedRequestId(params: unknown): string | null {
164
179
  return params.requestId;
165
180
  }
166
181
 
182
+ function isCdpResponseStage(params: unknown): boolean {
183
+ return (
184
+ isRecord(params) &&
185
+ (typeof params.responseStatusCode === "number" ||
186
+ typeof params.responseErrorReason === "string")
187
+ );
188
+ }
189
+
190
+ function toCdpPausedDocumentResponse(params: unknown): CdpPausedDocumentResponse | null {
191
+ if (
192
+ !isRecord(params) ||
193
+ typeof params.requestId !== "string" ||
194
+ typeof params.responseStatusCode !== "number" ||
195
+ params.resourceType !== "Document" ||
196
+ !isRecord(params.request)
197
+ ) {
198
+ return null;
199
+ }
200
+
201
+ const responseCode = params.responseStatusCode;
202
+ const responseHeaders = Array.isArray(params.responseHeaders)
203
+ ? params.responseHeaders.flatMap((header) => {
204
+ if (
205
+ !isRecord(header) ||
206
+ typeof header.name !== "string" ||
207
+ typeof header.value !== "string"
208
+ ) {
209
+ return [];
210
+ }
211
+ return [{ name: header.name, value: header.value }];
212
+ })
213
+ : [];
214
+
215
+ return {
216
+ hasBody:
217
+ String(params.request.method ?? "").toUpperCase() !== "HEAD" &&
218
+ responseCode >= 200 &&
219
+ (responseCode < 300 || responseCode >= 400) &&
220
+ responseCode !== 204 &&
221
+ responseCode !== 205 &&
222
+ responseCode !== 304,
223
+ requestId: params.requestId,
224
+ responseCode,
225
+ responseHeaders,
226
+ };
227
+ }
228
+
229
+ function toCdpDocumentFulfillParams(
230
+ response: CdpPausedDocumentResponse,
231
+ body: CdpResponseBody,
232
+ contentSecurityPolicy: string,
233
+ ): CdpFetchFulfillParams {
234
+ const responseBody = typeof body.body === "string" ? body.body : "";
235
+ return {
236
+ body: body.base64Encoded ? responseBody : Buffer.from(responseBody).toString("base64"),
237
+ requestId: response.requestId,
238
+ responseCode: response.responseCode,
239
+ responseHeaders: [
240
+ ...response.responseHeaders,
241
+ { name: "Content-Security-Policy", value: contentSecurityPolicy },
242
+ ],
243
+ };
244
+ }
245
+
246
+ function resourcePolicyFetchPatterns(policy: BrowserResourcePolicy) {
247
+ return [
248
+ { requestStage: "Request" as const, urlPattern: "*" },
249
+ ...(policy.documentContentSecurityPolicy
250
+ ? [
251
+ {
252
+ requestStage: "Response" as const,
253
+ resourceType: "Document" as const,
254
+ urlPattern: "*",
255
+ },
256
+ ]
257
+ : []),
258
+ ];
259
+ }
260
+
167
261
  function toCdpResourceHeaders(value: unknown): Record<string, string> {
168
262
  if (!isRecord(value)) {
169
263
  return {};
@@ -225,11 +319,171 @@ async function fulfillResourceRoute(
225
319
  });
226
320
  }
227
321
 
322
+ async function decideResourceRequest(
323
+ policy: BrowserResourcePolicy,
324
+ request: BrowserResourceRequest,
325
+ ): Promise<BrowserResourceDecision> {
326
+ for (const resourceRoute of policy.routes) {
327
+ if (matchesResourceRoute(resourceRoute.match, request)) {
328
+ return await resourceRoute.handle(request);
329
+ }
330
+ }
331
+
332
+ return { action: "block" };
333
+ }
334
+
335
+ async function abortResourceRoute(route: Route): Promise<void> {
336
+ await route.abort("blockedbyclient");
337
+ }
338
+
339
+ async function handleCdpResourceRequest(
340
+ session: CDPSession,
341
+ policy: BrowserResourcePolicy,
342
+ allowedMethods: ReadonlySet<BrowserResourceMethod>,
343
+ params: unknown,
344
+ ): Promise<void> {
345
+ const paused = toCdpResourceRequest(params);
346
+ const requestId = paused?.requestId ?? getCdpPausedRequestId(params);
347
+ if (!requestId) return;
348
+
349
+ try {
350
+ if (!paused || !allowedMethods.has(paused.request.method)) {
351
+ await session.send("Fetch.failRequest", { errorReason: "BlockedByClient", requestId });
352
+ return;
353
+ }
354
+
355
+ const decision = await decideResourceRequest(policy, paused.request);
356
+ switch (decision.action) {
357
+ case "continue":
358
+ await session.send("Fetch.continueRequest", { requestId });
359
+ return;
360
+ case "fulfill":
361
+ await session.send("Fetch.fulfillRequest", toCdpFulfillParams(requestId, decision));
362
+ return;
363
+ case "block":
364
+ await session.send("Fetch.failRequest", { errorReason: "BlockedByClient", requestId });
365
+ return;
366
+ }
367
+ } catch {
368
+ await session
369
+ .send("Fetch.failRequest", { errorReason: "BlockedByClient", requestId })
370
+ .catch(() => undefined);
371
+ }
372
+ }
373
+
374
+ async function handleCdpDocumentResponse(
375
+ session: CDPSession,
376
+ contentSecurityPolicy: string,
377
+ params: unknown,
378
+ ): Promise<void> {
379
+ const requestId = getCdpPausedRequestId(params);
380
+ if (!requestId) return;
381
+
382
+ try {
383
+ const response = toCdpPausedDocumentResponse(params);
384
+ if (!response) throw new Error("CDP document response metadata is unavailable");
385
+ if (!response.hasBody) {
386
+ await session.send("Fetch.continueResponse", {
387
+ requestId,
388
+ responseCode: response.responseCode,
389
+ responseHeaders: [...response.responseHeaders],
390
+ });
391
+ return;
392
+ }
393
+
394
+ const body = await session.send("Fetch.getResponseBody", {
395
+ requestId: response.requestId,
396
+ });
397
+ await session.send(
398
+ "Fetch.fulfillRequest",
399
+ toCdpDocumentFulfillParams(response, body, contentSecurityPolicy),
400
+ );
401
+ } catch {
402
+ await session
403
+ .send("Fetch.failRequest", { errorReason: "BlockedByClient", requestId })
404
+ .catch(() => undefined);
405
+ }
406
+ }
407
+
408
+ function getCdpAuthRequiredRequestId(params: unknown): string | null {
409
+ if (!isRecord(params) || typeof params.requestId !== "string") {
410
+ return null;
411
+ }
412
+
413
+ return params.requestId;
414
+ }
415
+
416
+ function isCdpProxyAuthChallenge(params: unknown): boolean {
417
+ return (
418
+ isRecord(params) &&
419
+ isRecord(params.authChallenge) &&
420
+ params.authChallenge.source === "Proxy"
421
+ );
422
+ }
423
+
424
+ async function handleCdpAuthRequired(
425
+ session: CDPSession,
426
+ proxy: PlaywrightProxy & { username: string; password: string },
427
+ authAttempts: Set<string>,
428
+ params: unknown,
429
+ ): Promise<void> {
430
+ const requestId = getCdpAuthRequiredRequestId(params);
431
+ if (!requestId) return;
432
+
433
+ // Chromium can emit another challenge after rejected credentials. Cancel a
434
+ // retry so incorrect credentials fail promptly instead of looping forever.
435
+ const response =
436
+ isCdpProxyAuthChallenge(params) && !authAttempts.has(requestId)
437
+ ? (() => {
438
+ authAttempts.add(requestId);
439
+ return {
440
+ authChallengeResponse: {
441
+ password: proxy.password,
442
+ response: "ProvideCredentials" as const,
443
+ username: proxy.username,
444
+ },
445
+ requestId,
446
+ };
447
+ })()
448
+ : {
449
+ authChallengeResponse: { response: "CancelAuth" as const },
450
+ requestId,
451
+ };
452
+
453
+ await session.send("Fetch.continueWithAuth", response).catch(() => undefined);
454
+ }
455
+
456
+ function createResourcePolicyHandler(
457
+ policy: BrowserResourcePolicy,
458
+ allowedMethods: ReadonlySet<BrowserResourceMethod>,
459
+ ): (route: Route) => Promise<void> {
460
+ return async (route: Route): Promise<void> => {
461
+ const request = await toResourceRequest(route.request());
462
+ if (!request || !allowedMethods.has(request.method)) {
463
+ await abortResourceRoute(route);
464
+ return;
465
+ }
466
+
467
+ const decision = await decideResourceRequest(policy, request);
468
+ switch (decision.action) {
469
+ case "continue":
470
+ await route.continue();
471
+ return;
472
+ case "fulfill":
473
+ await fulfillResourceRoute(route, decision);
474
+ return;
475
+ case "block":
476
+ await abortResourceRoute(route);
477
+ }
478
+ };
479
+ }
480
+
228
481
  export type BrowserClientOptions = BrowserOptions & {
229
482
  allowedHosts?: string[];
230
483
  cdpUrl?: string;
231
484
  executablePath?: string;
232
485
  extraArgs?: string[];
486
+ serviceWorkers?: "allow" | "block";
233
487
  };
234
488
 
235
489
  type SupportedBrowserClient = {
@@ -288,7 +542,10 @@ function formatExpression<T>(fn: string | (() => T)): string {
288
542
  return `(${fn.toString()})()`;
289
543
  }
290
544
 
291
- function toLaunchOptions(options: BrowserClientOptions): LaunchOptions {
545
+ function toLaunchOptions(
546
+ options: BrowserClientOptions,
547
+ proxy: LaunchOptions["proxy"],
548
+ ): LaunchOptions {
292
549
  return {
293
550
  // `extraArgs` is optional, but playwright-extra's stealth evasions mutate
294
551
  // `options.args` unguarded (navigator.webdriver does
@@ -299,7 +556,56 @@ function toLaunchOptions(options: BrowserClientOptions): LaunchOptions {
299
556
  args: options.extraArgs ?? [],
300
557
  executablePath: options.executablePath,
301
558
  headless: options.headless ?? true,
302
- proxy: options.proxy ? { server: options.proxy } : undefined,
559
+ proxy,
560
+ };
561
+ }
562
+
563
+ type PlaywrightProxy = NonNullable<LaunchOptions["proxy"]>;
564
+
565
+ function hasProxyCredentials(
566
+ proxy: LaunchOptions["proxy"] | undefined,
567
+ ): proxy is PlaywrightProxy & { username: string; password: string } {
568
+ return proxy?.username !== undefined && proxy.password !== undefined;
569
+ }
570
+
571
+ function toPlaywrightProxy(proxy: string | undefined): LaunchOptions["proxy"] {
572
+ if (!proxy) return undefined;
573
+
574
+ const schemeEnd = proxy.indexOf("://");
575
+ const authorityStart = schemeEnd >= 0 ? schemeEnd + 3 : 0;
576
+ const remainder = proxy.slice(authorityStart);
577
+ const authorityEnd = remainder.search(/[/?#]/);
578
+ const authority = remainder.slice(0, authorityEnd >= 0 ? authorityEnd : undefined);
579
+ if (!authority.includes("@")) {
580
+ return { server: proxy };
581
+ }
582
+
583
+ let parsed: URL;
584
+ try {
585
+ parsed = new URL(proxy);
586
+ } catch {
587
+ throw new ProviderError("Browser proxy URL is invalid", {
588
+ code: "BROWSER_PROXY_INVALID",
589
+ fix: "Use a valid proxy URL.",
590
+ });
591
+ }
592
+
593
+ let username: string;
594
+ let password: string;
595
+ try {
596
+ username = decodeURIComponent(parsed.username);
597
+ password = decodeURIComponent(parsed.password);
598
+ } catch {
599
+ throw new ProviderError("Browser proxy credentials use invalid percent-encoding", {
600
+ code: "BROWSER_PROXY_INVALID",
601
+ fix: "Percent-encode the proxy username and password as URL userinfo.",
602
+ });
603
+ }
604
+
605
+ return {
606
+ server: `${parsed.protocol}//${parsed.host}`,
607
+ username,
608
+ password,
303
609
  };
304
610
  }
305
611
 
@@ -493,7 +799,10 @@ class PlaywrightBrowserPage implements BrowserPageContract {
493
799
  readonly id = "main";
494
800
  readonly pageId?: string;
495
801
 
496
- constructor(private readonly page: Page) {}
802
+ constructor(
803
+ private readonly page: Page,
804
+ private readonly proxy: LaunchOptions["proxy"] = undefined,
805
+ ) {}
497
806
 
498
807
  async goto(url: string): Promise<void> {
499
808
  await this.page.goto(url);
@@ -558,43 +867,102 @@ class PlaywrightBrowserPage implements BrowserPageContract {
558
867
 
559
868
  async withResourcePolicy<T>(policy: BrowserResourcePolicy, run: () => Promise<T>): Promise<T> {
560
869
  const allowedMethods = new Set(policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS);
870
+ const policyHandler = createResourcePolicyHandler(policy, allowedMethods);
871
+ const context = this.page.context();
561
872
  const handler = async (route: Route): Promise<void> => {
562
- const request = await toResourceRequest(route.request());
563
- if (!request || !allowedMethods.has(request.method)) {
564
- await route.abort("blockedbyclient");
873
+ try {
874
+ // Context routing sees a popup's first request before the late `page`
875
+ // event. Fail every secondary page closed so no redirect can escape
876
+ // the per-page CDP interception while that session is being attached.
877
+ if (route.request().frame().page() !== this.page) {
878
+ await abortResourceRoute(route);
879
+ return;
880
+ }
881
+ } catch {
882
+ await abortResourceRoute(route);
565
883
  return;
566
884
  }
567
885
 
568
- for (const resourceRoute of policy.routes) {
569
- if (!matchesResourceRoute(resourceRoute.match, request)) {
570
- continue;
571
- }
886
+ await policyHandler(route);
887
+ };
888
+ const sessions = new Set<CDPSession>();
889
+ const pendingAttachments = new Set<Promise<void>>();
890
+ const proxy = hasProxyCredentials(this.proxy) ? this.proxy : undefined;
891
+ let active = true;
892
+ const attachPage = async (page: Page): Promise<void> => {
893
+ const session = await context.newCDPSession(page);
894
+ if (!active) {
895
+ await session.detach();
896
+ return;
897
+ }
572
898
 
573
- const decision = await resourceRoute.handle(request);
574
- switch (decision.action) {
575
- case "fulfill":
576
- await fulfillResourceRoute(route, decision);
577
- return;
578
- case "block":
579
- await route.abort("blockedbyclient");
580
- return;
899
+ const onRequestPaused = (params: unknown) => {
900
+ if (isCdpResponseStage(params) && policy.documentContentSecurityPolicy) {
901
+ void handleCdpDocumentResponse(session, policy.documentContentSecurityPolicy, params);
902
+ return;
581
903
  }
904
+ void handleCdpResourceRequest(session, policy, allowedMethods, params);
905
+ };
906
+ const authAttempts = new Set<string>();
907
+ const onAuthRequired = (params: unknown) => {
908
+ if (!proxy) return;
909
+ void handleCdpAuthRequired(session, proxy, authAttempts, params);
910
+ };
911
+ session.on("Fetch.requestPaused", onRequestPaused);
912
+ if (proxy) {
913
+ session.on("Fetch.authRequired", onAuthRequired);
582
914
  }
583
-
584
- await route.abort("blockedbyclient");
915
+ try {
916
+ await session.send("Fetch.enable", {
917
+ ...(proxy ? { handleAuthRequests: true } : {}),
918
+ patterns: resourcePolicyFetchPatterns(policy),
919
+ });
920
+ sessions.add(session);
921
+ } catch (error) {
922
+ session.off("Fetch.requestPaused", onRequestPaused);
923
+ if (proxy) {
924
+ session.off("Fetch.authRequired", onAuthRequired);
925
+ }
926
+ await session.detach().catch(() => undefined);
927
+ throw error;
928
+ }
929
+ };
930
+ const onPage = (page: Page) => {
931
+ const attachment = attachPage(page)
932
+ .catch(async () => {
933
+ await page.close().catch(() => undefined);
934
+ })
935
+ .finally(() => pendingAttachments.delete(attachment));
936
+ pendingAttachments.add(attachment);
585
937
  };
586
938
 
587
- await this.page.route(RESOURCE_POLICY_ROUTE_PATTERN, handler);
939
+ await context.route(RESOURCE_POLICY_ROUTE_PATTERN, handler);
588
940
  try {
589
- return await run();
941
+ context.on("page", onPage);
942
+ try {
943
+ await attachPage(this.page);
944
+ return await run();
945
+ } finally {
946
+ context.off("page", onPage);
947
+ }
590
948
  } finally {
591
- await this.page.unroute(RESOURCE_POLICY_ROUTE_PATTERN, handler);
949
+ active = false;
950
+ await context.unroute(RESOURCE_POLICY_ROUTE_PATTERN, handler);
951
+ await Promise.allSettled(pendingAttachments);
952
+ await Promise.all(
953
+ [...sessions].map(async (session) => {
954
+ await session.send("Fetch.disable").catch(() => undefined);
955
+ await session.detach().catch(() => undefined);
956
+ }),
957
+ );
592
958
  }
593
959
  }
594
960
  }
595
961
 
596
962
  class PlaywrightBrowserClient implements SupportedBrowserClient {
597
963
  private browser: import("playwright").Browser | null = null;
964
+ private parsedProxy: LaunchOptions["proxy"] | undefined;
965
+ private proxyParsed = false;
598
966
  readonly engine = "playwright-stealth" satisfies BrowserEngine;
599
967
 
600
968
  constructor(private readonly options: BrowserClientOptions = {}) {}
@@ -605,7 +973,11 @@ class PlaywrightBrowserClient implements SupportedBrowserClient {
605
973
  }
606
974
 
607
975
  const chromium = await loadChromiumLauncher(this.options);
608
- this.browser = await chromium.launch(toLaunchOptions(this.options));
976
+ if (!this.proxyParsed) {
977
+ this.parsedProxy = toPlaywrightProxy(this.options.proxy);
978
+ this.proxyParsed = true;
979
+ }
980
+ this.browser = await chromium.launch(toLaunchOptions(this.options, this.parsedProxy));
609
981
  return this.browser;
610
982
  }
611
983
 
@@ -613,7 +985,7 @@ class PlaywrightBrowserClient implements SupportedBrowserClient {
613
985
  const browser = await this.ensureBrowser();
614
986
  const page = await browser.newPage();
615
987
 
616
- return new PlaywrightBrowserPage(page);
988
+ return new PlaywrightBrowserPage(page, this.parsedProxy);
617
989
  }
618
990
 
619
991
  async rawPage(): Promise<BrowserPageContract> {
@@ -625,9 +997,9 @@ class PlaywrightBrowserClient implements SupportedBrowserClient {
625
997
 
626
998
  async withIsolatedContext<T>(handler: (page: BrowserPageContract) => Promise<T>): Promise<T> {
627
999
  const browser = await this.ensureBrowser();
628
- const context = await browser.newContext();
1000
+ const context = await browser.newContext({ serviceWorkers: this.options.serviceWorkers });
629
1001
  const page = await context.newPage();
630
- const browserPage = new PlaywrightBrowserPage(page);
1002
+ const browserPage = new PlaywrightBrowserPage(page, this.parsedProxy);
631
1003
 
632
1004
  try {
633
1005
  return await handler(browserPage);
@@ -1329,7 +1701,7 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1329
1701
 
1330
1702
  try {
1331
1703
  await this.pageClient.send("Fetch.enable", {
1332
- patterns: [{ requestStage: "Request", urlPattern: "*" }],
1704
+ patterns: resourcePolicyFetchPatterns(policy),
1333
1705
  });
1334
1706
  } catch (error) {
1335
1707
  unsubscribe();
@@ -1362,6 +1734,28 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1362
1734
  }
1363
1735
 
1364
1736
  try {
1737
+ if (isCdpResponseStage(params) && policy.documentContentSecurityPolicy) {
1738
+ const response = toCdpPausedDocumentResponse(params);
1739
+ if (!response) throw new Error("CDP document response metadata is unavailable");
1740
+ if (!response.hasBody) {
1741
+ await this.pageClient.send("Fetch.continueResponse", {
1742
+ requestId,
1743
+ responseCode: response.responseCode,
1744
+ responseHeaders: [...response.responseHeaders],
1745
+ });
1746
+ return;
1747
+ }
1748
+
1749
+ const body = await this.pageClient.send("Fetch.getResponseBody", {
1750
+ requestId: response.requestId,
1751
+ });
1752
+ await this.pageClient.send(
1753
+ "Fetch.fulfillRequest",
1754
+ toCdpDocumentFulfillParams(response, body, policy.documentContentSecurityPolicy),
1755
+ );
1756
+ return;
1757
+ }
1758
+
1365
1759
  const parsed = toCdpResourceRequest(params);
1366
1760
  if (!parsed || !allowedMethods.has(parsed.request.method)) {
1367
1761
  await this.failCdpResourceRequest(requestId);
@@ -1375,6 +1769,11 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1375
1769
 
1376
1770
  const decision = await resourceRoute.handle(parsed.request);
1377
1771
  switch (decision.action) {
1772
+ case "continue":
1773
+ await this.pageClient.send("Fetch.continueRequest", {
1774
+ requestId: parsed.requestId,
1775
+ });
1776
+ return;
1378
1777
  case "fulfill":
1379
1778
  await this.pageClient.send(
1380
1779
  "Fetch.fulfillRequest",