@apifuse/provider-sdk 2.1.0-beta.8 → 2.2.0-beta.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.
Files changed (133) hide show
  1. package/AUTHORING.md +134 -0
  2. package/CHANGELOG.md +61 -0
  3. package/README.md +21 -9
  4. package/SUBMISSION.md +10 -11
  5. package/bin/apifuse-pack-check.ts +22 -0
  6. package/bin/apifuse-perf.ts +18 -9
  7. package/bin/apifuse-submit-check.ts +1747 -388
  8. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  9. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  10. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  13. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  14. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  15. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  16. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  17. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  18. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  19. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  20. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  21. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  22. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  23. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  24. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  25. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  26. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  27. package/dist/auth-turn/index.d.ts +195 -0
  28. package/dist/auth-turn/index.js +133 -0
  29. package/dist/auth.d.ts +76 -0
  30. package/dist/auth.js +436 -0
  31. package/dist/ceremonies/index.js +7 -31
  32. package/dist/cli/create.js +45 -30
  33. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  34. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  35. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  36. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  37. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  38. package/dist/cli/templates/provider/README.md.tpl +163 -0
  39. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  40. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  41. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  42. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  43. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  44. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  45. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  46. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  48. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  49. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  50. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  51. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  52. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  53. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  54. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  55. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  56. package/dist/contract.js +1 -0
  57. package/dist/define.d.ts +6 -1
  58. package/dist/define.js +140 -70
  59. package/dist/index.d.ts +3 -2
  60. package/dist/index.js +2 -1
  61. package/dist/lint.d.ts +1 -0
  62. package/dist/lint.js +27 -0
  63. package/dist/provider.d.ts +4 -2
  64. package/dist/provider.js +2 -1
  65. package/dist/runtime/auth-flow.js +2 -0
  66. package/dist/runtime/browser.js +203 -0
  67. package/dist/runtime/http.js +28 -8
  68. package/dist/runtime/stealth.d.ts +5 -2
  69. package/dist/runtime/stealth.js +157 -4
  70. package/dist/server/index.d.ts +4 -0
  71. package/dist/server/index.js +4 -0
  72. package/dist/server/self-test-input-tokens.d.ts +1 -0
  73. package/dist/server/self-test-input-tokens.js +37 -0
  74. package/dist/server/self-test-redaction.d.ts +20 -0
  75. package/dist/server/self-test-redaction.js +70 -0
  76. package/dist/server/self-test-token.d.ts +30 -0
  77. package/dist/server/self-test-token.js +50 -0
  78. package/dist/server/self-test.d.ts +98 -0
  79. package/dist/server/self-test.js +555 -0
  80. package/dist/server/serve.d.ts +6 -0
  81. package/dist/server/serve.js +33 -8
  82. package/dist/testing/run.js +5 -1
  83. package/dist/types.d.ts +152 -0
  84. package/package.json +10 -3
  85. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  86. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  87. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  88. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  89. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  90. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  91. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  92. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  93. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  94. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  95. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  96. package/src/auth-turn/fixtures/valid/form.json +14 -0
  97. package/src/auth-turn/fixtures/valid/message.json +13 -0
  98. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  99. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  100. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  101. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  102. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  103. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  104. package/src/auth-turn/index.ts +177 -0
  105. package/src/auth.ts +786 -0
  106. package/src/ceremonies/index.ts +9 -43
  107. package/src/cli/create.ts +60 -97
  108. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  109. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  110. package/src/cli/templates/provider/README.md.tpl +7 -4
  111. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  112. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  113. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  114. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  115. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  116. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  117. package/src/contract.ts +1 -0
  118. package/src/define.ts +198 -71
  119. package/src/index.ts +16 -0
  120. package/src/lint.ts +33 -0
  121. package/src/provider.ts +27 -0
  122. package/src/runtime/auth-flow.ts +2 -0
  123. package/src/runtime/browser.ts +293 -1
  124. package/src/runtime/http.ts +48 -7
  125. package/src/runtime/stealth.ts +190 -6
  126. package/src/server/index.ts +36 -0
  127. package/src/server/self-test-input-tokens.ts +46 -0
  128. package/src/server/self-test-redaction.ts +97 -0
  129. package/src/server/self-test-token.ts +70 -0
  130. package/src/server/self-test.ts +725 -0
  131. package/src/server/serve.ts +67 -4
  132. package/src/testing/run.ts +9 -1
  133. package/src/types.ts +188 -0
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // @apifuse/provider-sdk
2
2
 
3
+ export * from "./auth";
3
4
  export * from "./ceremonies";
4
5
  export * from "./choice-token";
5
6
  export type {
@@ -20,6 +21,8 @@ export {
20
21
  type ProviderContractSnapshot,
21
22
  } from "./contract";
22
23
  export {
24
+ centered,
25
+ delayed,
23
26
  defineHealthJourney,
24
27
  defineOperation,
25
28
  defineProvider,
@@ -122,8 +125,15 @@ export type {
122
125
  Bcp47Locale,
123
126
  BrowserEngine,
124
127
  BrowserOptions,
128
+ BrowserResourceBody,
129
+ BrowserResourceDecision,
130
+ BrowserResourceMethod,
131
+ BrowserResourcePolicy,
132
+ BrowserResourceRequest,
133
+ BrowserResourceRoute,
125
134
  ConnectionMode,
126
135
  ContextDeclaration,
136
+ CookieJar,
127
137
  ContextScratchpad,
128
138
  CredentialContext,
129
139
  CredentialDeclaration,
@@ -144,6 +154,7 @@ export type {
144
154
  HealthJourneyRunContext,
145
155
  HealthJourneyRunResult,
146
156
  HealthJourneySchedule,
157
+ HealthScheduleRandomization,
147
158
  HealthJourneySmsContext,
148
159
  HealthJourneyStep,
149
160
  HttpClient,
@@ -191,6 +202,7 @@ export type {
191
202
  ProviderContext,
192
203
  ProviderDefinition,
193
204
  ProviderHealthMonitorConfig,
205
+ ProviderHealthProbeConfig,
194
206
  ProviderLocale,
195
207
  ProviderLocaleKey,
196
208
  ProviderLocaleKeyInput,
@@ -229,8 +241,12 @@ export type {
229
241
  StealthFetchOptions,
230
242
  StealthPlatform,
231
243
  StealthProfile,
244
+ StealthRedirectHop,
245
+ StealthRedirectRunOptions,
246
+ StealthRedirectRunResult,
232
247
  StealthResponse,
233
248
  StealthSession,
249
+ StealthSessionCookies,
234
250
  SttAudioInput,
235
251
  SttContext,
236
252
  SttPromptPolicy,
package/src/lint.ts CHANGED
@@ -22,8 +22,12 @@ type ProviderAuthLike = {
22
22
  abort?: unknown;
23
23
  refresh?: unknown;
24
24
  };
25
+ exchange?: unknown;
25
26
  };
26
27
 
28
+ const AUTH_OPERATION_ID_PATTERN =
29
+ /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
30
+
27
31
  type ProviderContractMetaLike = {
28
32
  publicSchemaFieldNames?: "normalized";
29
33
  };
@@ -127,6 +131,10 @@ function lintReviewed(
127
131
  ];
128
132
  }
129
133
 
134
+ function isProviderAuthLike(value: unknown): value is ProviderAuthLike {
135
+ return !!value && typeof value === "object" && !Array.isArray(value);
136
+ }
137
+
130
138
  function hasReusableSecretKeys(keys: readonly string[] | undefined): boolean {
131
139
  if (!keys) {
132
140
  return false;
@@ -215,6 +223,15 @@ function lintAuthModel(provider: {
215
223
  });
216
224
  }
217
225
 
226
+ if (isProviderAuthLike(provider.auth) && "exchange" in provider.auth) {
227
+ diagnostics.push({
228
+ rule: "auth-exchange-unsupported",
229
+ level: "error",
230
+ field: "auth.exchange",
231
+ message: `${providerLabel} must not define auth.exchange. The Provider SDK has one auth interface: auth.flow. Gateway only calls auth.flow.start/continue/poll/abort/refresh and persists complete turn data.credential as-is; put login/token/session exchange inside auth.flow.continue.`,
232
+ });
233
+ }
234
+
218
235
  if (authMode === "credentials" && credentialKeys.length === 0) {
219
236
  diagnostics.push({
220
237
  rule: "credential-keys-required-when-credentials-mode",
@@ -1006,6 +1023,22 @@ export function lintProvider(
1006
1023
  ...lintSelfHostedBrowserPatterns(provider, options),
1007
1024
  ];
1008
1025
 
1026
+ if (provider.operations) {
1027
+ const authMode = provider.auth?.mode;
1028
+ if (authMode === "credentials" || authMode === "oauth2") {
1029
+ for (const operationKey of Object.keys(provider.operations)) {
1030
+ if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1031
+ diagnostics.push({
1032
+ rule: "auth-operation-unsupported",
1033
+ level: "error",
1034
+ field: `operations.${operationKey}`,
1035
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
1009
1042
  if (!provider.operations) {
1010
1043
  return diagnostics;
1011
1044
  }
package/src/provider.ts CHANGED
@@ -1,3 +1,22 @@
1
+ export {
2
+ AuthAbortError,
3
+ credentialsAuthChallenge,
4
+ createAuthFlowHelpers,
5
+ defineCredentialsAuth,
6
+ } from "./auth";
7
+ export type {
8
+ CredentialsAuthChallengeDefinition,
9
+ CredentialsAuthChallengeRequest,
10
+ CredentialsAuthCompleteResult,
11
+ CredentialsAuthCredential,
12
+ CredentialsAuthField,
13
+ CredentialsAuthFields,
14
+ CredentialsAuthFieldType,
15
+ CredentialsAuthInput,
16
+ CredentialsAuthLoginResult,
17
+ DefineCredentialsAuthOptions,
18
+ DefinedCredentialsAuth,
19
+ } from "./auth";
1
20
  export { createFormCeremony } from "./ceremonies";
2
21
  export {
3
22
  assertFreshProviderChoiceIssuedAt,
@@ -8,6 +27,8 @@ export {
8
27
  parseProviderChoiceToken,
9
28
  } from "./choice-token";
10
29
  export {
30
+ centered,
31
+ delayed,
11
32
  defineHealthJourney,
12
33
  defineOperation,
13
34
  defineProvider,
@@ -50,7 +71,12 @@ export {
50
71
  z,
51
72
  } from "./schema";
52
73
  export type {
74
+ AuthAbortData,
75
+ AuthAbortRetry,
76
+ AuthFlowTerminalContext,
53
77
  AuthMode,
78
+ AuthSafeData,
79
+ AuthSafeJson,
54
80
  FlowContext,
55
81
  HealthCheckAssertionContext,
56
82
  HealthCheckCase,
@@ -61,6 +87,7 @@ export type {
61
87
  HealthJourneyManualTriggerPolicy,
62
88
  HealthJourneyRunContext,
63
89
  HealthJourneyRunResult,
90
+ HealthScheduleRandomization,
64
91
  HttpRetryOptions,
65
92
  HttpRetrySummary,
66
93
  InferSchemaOutput,
@@ -1,4 +1,5 @@
1
1
  import { ContextAccessError } from "../errors";
2
+ import { createAuthFlowHelpers } from "../auth";
2
3
  import type {
3
4
  ContextScratchpad,
4
5
  EnvContext,
@@ -70,5 +71,6 @@ export function createFlowContext(options: {
70
71
  env: options.env,
71
72
  context: createScratchpad(options.allowedKeys, options.initialContext),
72
73
  stt: options.stt ?? createUnsupportedSttClient(),
74
+ auth: createAuthFlowHelpers(),
73
75
  };
74
76
  }
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import type { Frame, LaunchOptions, Locator, Page } from "playwright";
2
+ import type { Frame, LaunchOptions, Locator, Page, Request, Route } from "playwright";
3
3
 
4
4
  import { ProviderError } from "../errors";
5
5
  import type {
@@ -11,11 +11,18 @@ import type {
11
11
  BrowserLocator,
12
12
  BrowserOptions,
13
13
  BrowserPage,
14
+ BrowserResourceBody,
15
+ BrowserResourceDecision,
16
+ BrowserResourceMethod,
17
+ BrowserResourcePolicy,
18
+ BrowserResourceRequest,
14
19
  } from "../types";
15
20
 
16
21
  const require = createRequire(import.meta.url);
17
22
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
18
23
  const SELECTOR_POLL_INTERVAL_MS = 100;
24
+ const RESOURCE_POLICY_ROUTE_PATTERN = "**/*";
25
+ const DEFAULT_RESOURCE_METHODS = ["GET", "HEAD"] as const;
19
26
 
20
27
  type PlaywrightModule = typeof import("playwright");
21
28
  type PlaywrightExtraModule = {
@@ -62,8 +69,153 @@ type CdpFrameTreeNode = {
62
69
  };
63
70
  };
64
71
 
72
+ type CdpFetchFulfillParams = {
73
+ readonly requestId: string;
74
+ readonly responseCode: number;
75
+ readonly responseHeaders?: readonly {
76
+ readonly name: string;
77
+ readonly value: string;
78
+ }[];
79
+ readonly body?: string;
80
+ };
81
+
65
82
  type BrowserPageContract = BrowserPage;
66
83
 
84
+ function toResourceBody(
85
+ body: BrowserResourceBody | undefined,
86
+ ): Buffer | string | undefined {
87
+ if (body === undefined || typeof body === "string" || Buffer.isBuffer(body)) {
88
+ return body;
89
+ }
90
+
91
+ if (body instanceof ArrayBuffer) {
92
+ return Buffer.from(new Uint8Array(body));
93
+ }
94
+
95
+ return Buffer.from(body);
96
+ }
97
+
98
+ function isResourceMethod(method: string): method is BrowserResourceMethod {
99
+ return method === "GET" || method === "HEAD";
100
+ }
101
+
102
+ async function toResourceRequest(
103
+ request: Request,
104
+ ): Promise<BrowserResourceRequest | null> {
105
+ const method = request.method().toUpperCase();
106
+ if (!isResourceMethod(method)) {
107
+ return null;
108
+ }
109
+
110
+ return {
111
+ headers: await request.allHeaders(),
112
+ method,
113
+ resourceType: request.resourceType(),
114
+ url: request.url(),
115
+ };
116
+ }
117
+
118
+ function toCdpResourceRequest(
119
+ params: unknown,
120
+ ): { requestId: string; request: BrowserResourceRequest } | null {
121
+ if (!isRecord(params)) {
122
+ return null;
123
+ }
124
+
125
+ const requestId = params.requestId;
126
+ const rawRequest = params.request;
127
+ if (typeof requestId !== "string" || !isRecord(rawRequest)) {
128
+ return null;
129
+ }
130
+
131
+ const url = rawRequest.url;
132
+ const method = String(rawRequest.method ?? "").toUpperCase();
133
+ if (typeof url !== "string" || !isResourceMethod(method)) {
134
+ return null;
135
+ }
136
+
137
+ return {
138
+ requestId,
139
+ request: {
140
+ headers: toCdpResourceHeaders(rawRequest.headers),
141
+ method,
142
+ resourceType:
143
+ typeof params.resourceType === "string" ? params.resourceType : undefined,
144
+ url,
145
+ },
146
+ };
147
+ }
148
+
149
+ function getCdpPausedRequestId(params: unknown): string | null {
150
+ if (!isRecord(params) || typeof params.requestId !== "string") {
151
+ return null;
152
+ }
153
+
154
+ return params.requestId;
155
+ }
156
+
157
+ function toCdpResourceHeaders(value: unknown): Record<string, string> {
158
+ if (!isRecord(value)) {
159
+ return {};
160
+ }
161
+
162
+ const headers: Record<string, string> = {};
163
+ for (const [name, headerValue] of Object.entries(value)) {
164
+ if (typeof headerValue === "string") {
165
+ headers[name] = headerValue;
166
+ }
167
+ }
168
+
169
+ return headers;
170
+ }
171
+
172
+ function matchesResourceRoute(
173
+ match: BrowserResourcePolicy["routes"][number]["match"],
174
+ request: BrowserResourceRequest,
175
+ ): boolean {
176
+ if (typeof match === "string") {
177
+ return request.url === match;
178
+ }
179
+ if (match instanceof RegExp) {
180
+ return match.test(request.url);
181
+ }
182
+
183
+ return match(request);
184
+ }
185
+
186
+ function toCdpFulfillParams(
187
+ requestId: string,
188
+ decision: Extract<BrowserResourceDecision, { readonly action: "fulfill" }>,
189
+ ): CdpFetchFulfillParams {
190
+ const body = toResourceBody(decision.body);
191
+ return {
192
+ ...(body === undefined
193
+ ? {}
194
+ : { body: Buffer.from(body).toString("base64") }),
195
+ ...(decision.headers === undefined
196
+ ? {}
197
+ : {
198
+ responseHeaders: Object.entries(decision.headers).map(
199
+ ([name, value]) => ({ name, value }),
200
+ ),
201
+ }),
202
+ requestId,
203
+ responseCode: decision.status ?? 200,
204
+ };
205
+ }
206
+
207
+ async function fulfillResourceRoute(
208
+ route: Route,
209
+ decision: Extract<BrowserResourceDecision, { readonly action: "fulfill" }>,
210
+ ): Promise<void> {
211
+ const body = toResourceBody(decision.body);
212
+ await route.fulfill({
213
+ ...(body === undefined ? {} : { body }),
214
+ ...(decision.headers === undefined ? {} : { headers: decision.headers }),
215
+ status: decision.status ?? 200,
216
+ });
217
+ }
218
+
67
219
  export type BrowserClientOptions = BrowserOptions & {
68
220
  allowedHosts?: string[];
69
221
  cdpUrl?: string;
@@ -398,6 +550,47 @@ class PlaywrightBrowserPage implements BrowserPageContract {
398
550
  async close(): Promise<void> {
399
551
  await this.page.close();
400
552
  }
553
+
554
+ async withResourcePolicy<T>(
555
+ policy: BrowserResourcePolicy,
556
+ run: () => Promise<T>,
557
+ ): Promise<T> {
558
+ const allowedMethods = new Set(
559
+ policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS,
560
+ );
561
+ 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");
565
+ return;
566
+ }
567
+
568
+ for (const resourceRoute of policy.routes) {
569
+ if (!matchesResourceRoute(resourceRoute.match, request)) {
570
+ continue;
571
+ }
572
+
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;
581
+ }
582
+ }
583
+
584
+ await route.abort("blockedbyclient");
585
+ };
586
+
587
+ await this.page.route(RESOURCE_POLICY_ROUTE_PATTERN, handler);
588
+ try {
589
+ return await run();
590
+ } finally {
591
+ await this.page.unroute(RESOURCE_POLICY_ROUTE_PATTERN, handler);
592
+ }
593
+ }
401
594
  }
402
595
 
403
596
  class PlaywrightBrowserClient implements SupportedBrowserClient {
@@ -1055,6 +1248,105 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1055
1248
  }
1056
1249
  }
1057
1250
 
1251
+ async withResourcePolicy<T>(
1252
+ policy: BrowserResourcePolicy,
1253
+ run: () => Promise<T>,
1254
+ ): Promise<T> {
1255
+ const allowedMethods = new Set(
1256
+ policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS,
1257
+ );
1258
+ const handlePausedRequest = (params: unknown): void => {
1259
+ void this.handleResourcePolicyPausedRequest(
1260
+ params,
1261
+ policy,
1262
+ allowedMethods,
1263
+ );
1264
+ };
1265
+
1266
+ const unsubscribe = this.pageClient.on(
1267
+ "Fetch.requestPaused",
1268
+ handlePausedRequest,
1269
+ );
1270
+
1271
+ try {
1272
+ await this.pageClient.send("Fetch.enable", {
1273
+ patterns: [{ requestStage: "Request", urlPattern: "*" }],
1274
+ });
1275
+ } catch (error) {
1276
+ unsubscribe();
1277
+ throw new ProviderError(
1278
+ "CDP browser target does not support BrowserPage.withResourcePolicy()",
1279
+ {
1280
+ cause: error instanceof Error ? error : undefined,
1281
+ code: "BROWSER_RUNTIME_UNSUPPORTED",
1282
+ fix: "Use a Chromium CDP target with the Fetch domain enabled, or use the local Playwright browser runtime.",
1283
+ },
1284
+ );
1285
+ }
1286
+
1287
+ try {
1288
+ return await run();
1289
+ } finally {
1290
+ unsubscribe();
1291
+ await this.pageClient.send("Fetch.disable");
1292
+ }
1293
+ }
1294
+
1295
+ private async handleResourcePolicyPausedRequest(
1296
+ params: unknown,
1297
+ policy: BrowserResourcePolicy,
1298
+ allowedMethods: ReadonlySet<BrowserResourceMethod>,
1299
+ ): Promise<void> {
1300
+ const requestId = getCdpPausedRequestId(params);
1301
+ if (requestId === null) {
1302
+ return;
1303
+ }
1304
+
1305
+ try {
1306
+ const parsed = toCdpResourceRequest(params);
1307
+ if (!parsed || !allowedMethods.has(parsed.request.method)) {
1308
+ await this.failCdpResourceRequest(requestId);
1309
+ return;
1310
+ }
1311
+
1312
+ for (const resourceRoute of policy.routes) {
1313
+ if (!matchesResourceRoute(resourceRoute.match, parsed.request)) {
1314
+ continue;
1315
+ }
1316
+
1317
+ const decision = await resourceRoute.handle(parsed.request);
1318
+ switch (decision.action) {
1319
+ case "fulfill":
1320
+ await this.pageClient.send(
1321
+ "Fetch.fulfillRequest",
1322
+ toCdpFulfillParams(parsed.requestId, decision),
1323
+ );
1324
+ return;
1325
+ case "block":
1326
+ await this.failCdpResourceRequest(parsed.requestId);
1327
+ return;
1328
+ }
1329
+ }
1330
+
1331
+ await this.failCdpResourceRequest(parsed.requestId);
1332
+ } catch {
1333
+ await this.failCdpResourceRequest(requestId);
1334
+ }
1335
+ }
1336
+
1337
+ private async failCdpResourceRequest(requestId: string): Promise<void> {
1338
+ try {
1339
+ await this.pageClient.send("Fetch.failRequest", {
1340
+ errorReason: "BlockedByClient",
1341
+ requestId,
1342
+ });
1343
+ } catch (error) {
1344
+ if (error instanceof Error) {
1345
+ return;
1346
+ }
1347
+ }
1348
+ }
1349
+
1058
1350
  private async initialize(): Promise<void> {
1059
1351
  if (this.initialized) {
1060
1352
  return;
@@ -58,8 +58,13 @@ type HttpStatusOutcome = {
58
58
  status: number;
59
59
  headers: Record<string, string>;
60
60
  retryable: boolean;
61
+ proxyUsed: boolean;
61
62
  };
62
63
 
64
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
65
+
66
+ type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
67
+
63
68
  function isHttpStatusOutcome(
64
69
  outcome: HttpResponse | HttpStatusOutcome,
65
70
  ): outcome is HttpStatusOutcome {
@@ -582,7 +587,8 @@ function toHttpTransportError(error: unknown): TransportError {
582
587
 
583
588
  async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
584
589
  const headers = Object.fromEntries(response.headers.entries());
585
- const rawText = await response.text();
590
+ const bodyBytes = new Uint8Array(await response.arrayBuffer());
591
+ const rawText = new TextDecoder().decode(bodyBytes);
586
592
  const data = parseHttpData(rawText, headers);
587
593
 
588
594
  return {
@@ -602,6 +608,12 @@ async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
602
608
  ok: response.status >= 200 && response.status < 300,
603
609
  status: response.status,
604
610
  text: async () => rawText,
611
+ arrayBuffer: async () =>
612
+ bodyBytes.buffer.slice(
613
+ bodyBytes.byteOffset,
614
+ bodyBytes.byteOffset + bodyBytes.byteLength,
615
+ ),
616
+ bytes: async () => bodyBytes.slice(0),
605
617
  };
606
618
  }
607
619
 
@@ -678,12 +690,20 @@ async function resolveNativeProxy(
678
690
  options: RequestOptions,
679
691
  clientOptions: HttpClientOptions,
680
692
  warn: (message: string) => void,
693
+ proxyAttemptOffset = 0,
681
694
  ): Promise<string | undefined> {
695
+ const baseProxyAttempt =
696
+ clientOptions.proxyAttempt === undefined ||
697
+ !Number.isFinite(clientOptions.proxyAttempt)
698
+ ? 0
699
+ : Math.max(0, Math.floor(clientOptions.proxyAttempt));
682
700
  const resolvedProxy = await resolveProxyConfigAsync({
683
701
  proxy: options.proxy ?? clientOptions.proxy,
684
702
  upstream: clientOptions.upstream,
685
703
  apifuseConfig: clientOptions.apifuseConfig,
704
+ proxyPolicy: clientOptions.proxyPolicy,
686
705
  affinityKey: clientOptions.affinityKey,
706
+ proxyAttempt: baseProxyAttempt + proxyAttemptOffset,
687
707
  telemetry: clientOptions.telemetry,
688
708
  });
689
709
  if (resolvedProxy.shouldWarn) {
@@ -723,7 +743,8 @@ async function fetchNativeHttp(
723
743
  clientOptions: HttpClientOptions,
724
744
  warn: (message: string) => void,
725
745
  statusRetryCodes?: readonly number[],
726
- ): Promise<HttpResponse | HttpStatusOutcome> {
746
+ proxyAttemptOffset = 0,
747
+ ): Promise<NativeHttpAttemptOutcome> {
727
748
  const requestUrl = appendQueryParams(
728
749
  resolveHttpUrl(baseUrl, url),
729
750
  options.params,
@@ -733,8 +754,14 @@ async function fetchNativeHttp(
733
754
  ? setTimeout(() => controller?.abort(), options.timeout)
734
755
  : undefined;
735
756
 
757
+ let proxy: string | undefined;
736
758
  try {
737
- const proxy = await resolveNativeProxy(options, clientOptions, warn);
759
+ proxy = await resolveNativeProxy(
760
+ options,
761
+ clientOptions,
762
+ warn,
763
+ proxyAttemptOffset,
764
+ );
738
765
  const requestInit: NativeFetchInit = {
739
766
  headers: options.headers,
740
767
  method,
@@ -756,6 +783,7 @@ async function fetchNativeHttp(
756
783
  status: response.status,
757
784
  headers,
758
785
  retryable: statusRetryCodes.includes(response.status),
786
+ proxyUsed: Boolean(proxy),
759
787
  };
760
788
  }
761
789
 
@@ -775,7 +803,9 @@ async function fetchNativeHttp(
775
803
  if (error instanceof SyntaxError) {
776
804
  throw error;
777
805
  }
778
- throw toHttpTransportError(error);
806
+ const transportError = toHttpTransportError(error) as NativeHttpAttemptError;
807
+ transportError.proxyUsed = Boolean(proxy);
808
+ throw transportError;
779
809
  } finally {
780
810
  if (timeoutHandle) clearTimeout(timeoutHandle);
781
811
  }
@@ -867,7 +897,12 @@ export function createHttpClient(
867
897
  options.body,
868
898
  );
869
899
  const methodName = normalizeHttpMethod(method);
870
- const retryOptions = normalizeRetryOptions(headersOptions.retry);
900
+ const explicitRetry = headersOptions.retry !== undefined;
901
+ const retryOptions =
902
+ normalizeRetryOptions(headersOptions.retry) ??
903
+ (explicitRetry
904
+ ? undefined
905
+ : createRetryOptions(HttpRetryPreset.TransportTransient));
871
906
  if (retryOptions) validateUnsafeRetryMethods(retryOptions);
872
907
  const retryEnabled = Boolean(
873
908
  retryOptions &&
@@ -876,6 +911,7 @@ export function createHttpClient(
876
911
  );
877
912
  const statusRetryEnabled = Boolean(
878
913
  retryEnabled &&
914
+ explicitRetry &&
879
915
  retryOptions &&
880
916
  retryOptions.statusCodes.length > 0 &&
881
917
  headersOptions.throwOnHttpError !== false,
@@ -885,7 +921,9 @@ export function createHttpClient(
885
921
  ? { ...headersOptions, throwOnHttpError: false }
886
922
  : headersOptions;
887
923
 
888
- const executeOnce = (): Promise<HttpResponse | HttpStatusOutcome> =>
924
+ const executeOnce = (
925
+ proxyAttemptOffset = 0,
926
+ ): Promise<NativeHttpAttemptOutcome> =>
889
927
  fetchNativeHttp(
890
928
  baseUrl,
891
929
  url,
@@ -894,6 +932,7 @@ export function createHttpClient(
894
932
  clientOptions,
895
933
  warnOnce,
896
934
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
935
+ proxyAttemptOffset,
897
936
  );
898
937
 
899
938
  if (!retryEnabled || !retryOptions) {
@@ -908,7 +947,7 @@ export function createHttpClient(
908
947
  let lastStatus: number | undefined;
909
948
  for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
910
949
  try {
911
- const outcome = await executeOnce();
950
+ const outcome = await executeOnce(attempt - 1);
912
951
  if (isHttpStatusOutcome(outcome)) {
913
952
  lastStatus = outcome.status;
914
953
  if (outcome.retryable && attempt < retryOptions.attempts) {
@@ -943,8 +982,10 @@ export function createHttpClient(
943
982
  } catch (error) {
944
983
  lastErrorCode = retryErrorCode(error);
945
984
  lastStatus = retryErrorStatus(error);
985
+ const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
946
986
  if (
947
987
  attempt < retryOptions.attempts &&
988
+ (explicitRetry || proxyUsed) &&
948
989
  shouldRetryTransportError(error, retryOptions)
949
990
  ) {
950
991
  await sleep(computeRetryDelayMs(retryOptions, attempt));