@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.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.
Files changed (138) 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-submit-check.ts +1747 -388
  7. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  8. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  9. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  10. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  13. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  14. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  15. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  16. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  17. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  18. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  19. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  20. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  21. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  22. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  23. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  24. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  25. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  26. package/dist/auth-turn/index.d.ts +195 -0
  27. package/dist/auth-turn/index.js +133 -0
  28. package/dist/auth.d.ts +76 -0
  29. package/dist/auth.js +436 -0
  30. package/dist/ceremonies/index.js +7 -31
  31. package/dist/cli/create.js +45 -30
  32. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  33. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  34. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  35. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  36. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  37. package/dist/cli/templates/provider/README.md.tpl +163 -0
  38. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  39. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  40. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  41. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  42. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  43. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  44. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  46. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  48. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  49. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  50. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  51. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  52. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  53. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  54. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  55. package/dist/contract.js +1 -0
  56. package/dist/define.d.ts +6 -1
  57. package/dist/define.js +140 -70
  58. package/dist/index.d.ts +3 -2
  59. package/dist/index.js +2 -1
  60. package/dist/lint.d.ts +1 -0
  61. package/dist/lint.js +27 -0
  62. package/dist/provider.d.ts +4 -2
  63. package/dist/provider.js +2 -1
  64. package/dist/runtime/auth-flow.js +2 -0
  65. package/dist/runtime/browser.js +203 -0
  66. package/dist/runtime/http.js +46 -336
  67. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  68. package/dist/runtime/proxy-retry-policy.js +326 -0
  69. package/dist/runtime/stealth.d.ts +5 -2
  70. package/dist/runtime/stealth.js +206 -206
  71. package/dist/server/index.d.ts +4 -0
  72. package/dist/server/index.js +4 -0
  73. package/dist/server/self-test-input-tokens.d.ts +1 -0
  74. package/dist/server/self-test-input-tokens.js +37 -0
  75. package/dist/server/self-test-redaction.d.ts +20 -0
  76. package/dist/server/self-test-redaction.js +70 -0
  77. package/dist/server/self-test-token.d.ts +30 -0
  78. package/dist/server/self-test-token.js +50 -0
  79. package/dist/server/self-test.d.ts +98 -0
  80. package/dist/server/self-test.js +555 -0
  81. package/dist/server/serve.d.ts +6 -0
  82. package/dist/server/serve.js +38 -10
  83. package/dist/server/types.d.ts +1 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/testing/run.js +5 -1
  86. package/dist/types.d.ts +150 -0
  87. package/package.json +9 -2
  88. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  89. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  90. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  91. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  92. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  93. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  94. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  95. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  96. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  97. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  98. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  99. package/src/auth-turn/fixtures/valid/form.json +14 -0
  100. package/src/auth-turn/fixtures/valid/message.json +13 -0
  101. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  102. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  103. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  104. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  105. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  106. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  107. package/src/auth-turn/index.ts +177 -0
  108. package/src/auth.ts +786 -0
  109. package/src/ceremonies/index.ts +9 -43
  110. package/src/cli/create.ts +60 -97
  111. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  112. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  113. package/src/cli/templates/provider/README.md.tpl +7 -4
  114. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  115. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  116. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  117. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  118. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  119. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  120. package/src/contract.ts +1 -0
  121. package/src/define.ts +198 -71
  122. package/src/index.ts +16 -0
  123. package/src/lint.ts +33 -0
  124. package/src/provider.ts +27 -0
  125. package/src/runtime/auth-flow.ts +2 -0
  126. package/src/runtime/browser.ts +293 -1
  127. package/src/runtime/http.ts +84 -530
  128. package/src/runtime/proxy-retry-policy.ts +469 -0
  129. package/src/runtime/stealth.ts +269 -353
  130. package/src/server/index.ts +36 -0
  131. package/src/server/self-test-input-tokens.ts +46 -0
  132. package/src/server/self-test-redaction.ts +97 -0
  133. package/src/server/self-test-token.ts +70 -0
  134. package/src/server/self-test.ts +725 -0
  135. package/src/server/serve.ts +75 -6
  136. package/src/server/types.ts +1 -0
  137. package/src/testing/run.ts +9 -1
  138. package/src/types.ts +186 -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;