@effect-agent/platform-cloudflare 0.1.0-beta.9 → 0.1.0-beta.91
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/Alarm.d.mts +188 -0
- package/dist/Alarm.mjs +451 -0
- package/dist/Alarm.mjs.map +1 -0
- package/dist/BrowserRestCapture.d.mts +35 -0
- package/dist/BrowserRestCapture.mjs +238 -0
- package/dist/BrowserRestCapture.mjs.map +1 -0
- package/dist/BrowserRestCrawl.d.mts +17 -0
- package/dist/BrowserRestCrawl.mjs +244 -0
- package/dist/BrowserRestCrawl.mjs.map +1 -0
- package/dist/CloudflareAiGateway.d.mts +64 -0
- package/dist/CloudflareAiGateway.mjs +70 -0
- package/dist/CloudflareAiGateway.mjs.map +1 -0
- package/dist/CloudflareBindings.d.mts +115 -0
- package/dist/CloudflareBindings.mjs +113 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs +495 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs.map +1 -0
- package/dist/CloudflareBrowser-Doe1M7R5.d.mts +80 -0
- package/dist/CloudflareBrowser.d.mts +2 -0
- package/dist/CloudflareBrowser.mjs +2 -0
- package/dist/CloudflareCodeMode.d.mts +44 -0
- package/dist/CloudflareCodeMode.mjs +616 -0
- package/dist/CloudflareCodeMode.mjs.map +1 -0
- package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
- package/dist/CloudflareConfig.d.mts +2 -0
- package/dist/CloudflareConfig.mjs +122 -0
- package/dist/CloudflareConfig.mjs.map +1 -0
- package/dist/CloudflareMemory.d.mts +142 -0
- package/dist/CloudflareMemory.mjs +202 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +396 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +530 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1496 -0
- package/dist/CloudflareThreadClient.mjs +382 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser-DUE_m12-.d.mts +161 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs +1169 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +2 -0
- package/dist/InteractiveBrowser.mjs +2 -0
- package/dist/ProtectedBrowser.d.mts +123 -0
- package/dist/ProtectedBrowser.mjs +1135 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-CIbmXZCY.d.mts +812 -0
- package/dist/ThreadObject-DvRG0dDz.mjs +835 -0
- package/dist/ThreadObject-DvRG0dDz.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -0
- package/dist/ThreadObject.mjs +3 -0
- package/dist/WakeScheduler.d.mts +23 -0
- package/dist/WakeScheduler.mjs +57 -0
- package/dist/WakeScheduler.mjs.map +1 -0
- package/dist/boundary-BguazVkh.mjs +42 -0
- package/dist/boundary-BguazVkh.mjs.map +1 -0
- package/dist/index.d.mts +13 -1548
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DU0_T-ev.mjs +73 -0
- package/dist/prepared-admission-DU0_T-ev.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +973 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +199 -0
- package/src/CloudflareBrowser.ts +15 -0
- package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
- package/src/{config.ts → CloudflareConfig.ts} +9 -8
- package/src/CloudflareMemory.ts +420 -0
- package/src/CloudflareScheduling.ts +747 -0
- package/src/CloudflareSubscriptions.ts +1041 -0
- package/src/CloudflareThreadClient.ts +809 -0
- package/src/InteractiveBrowser.ts +2354 -0
- package/src/ProtectedBrowser.ts +9 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +32 -26
- package/src/index.ts +12 -23
- package/src/internal/boundary.ts +55 -0
- package/src/internal/browser-quick-action.ts +857 -0
- package/src/internal/browser-session-lifecycle.ts +160 -0
- package/src/internal/layers.ts +751 -0
- package/src/internal/message-delivery.ts +152 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +44 -0
- package/src/protected-browser/binding.ts +313 -0
- package/src/protected-browser/host.ts +344 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +445 -0
- package/src/protected-browser/policy.ts +821 -0
- package/dist/index.mjs.map +0 -1
- package/src/alarm.ts +0 -334
- package/src/bindings.ts +0 -145
- package/src/client.ts +0 -646
- package/src/conversation-object.ts +0 -768
- package/src/layers.ts +0 -376
- package/src/transport.ts +0 -38
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
import {
|
|
5
|
+
PageCapture,
|
|
6
|
+
PageCaptureInferenceUse,
|
|
7
|
+
PageCaptureInferencePolicyError,
|
|
8
|
+
PageCaptureNavigationError,
|
|
9
|
+
PageCaptureOutputLimitError,
|
|
10
|
+
PageCaptureProtocolError,
|
|
11
|
+
PageCaptureRateLimitedError,
|
|
12
|
+
PageCaptureResourceUse,
|
|
13
|
+
PageCaptureResult,
|
|
14
|
+
PageCaptureUnsupportedError,
|
|
15
|
+
PageContentCaptured,
|
|
16
|
+
PageLinksCaptured,
|
|
17
|
+
PageMarkdownCaptured,
|
|
18
|
+
PageScrapeCaptured,
|
|
19
|
+
PageStructuredCaptured,
|
|
20
|
+
type PageCaptureAction,
|
|
21
|
+
type PageCaptureCapture,
|
|
22
|
+
type PageCaptureError,
|
|
23
|
+
type PageCaptureOutput,
|
|
24
|
+
type PageCaptureRequest,
|
|
25
|
+
} from "effect-agent/page-capture";
|
|
26
|
+
import {
|
|
27
|
+
PageScreenshot,
|
|
28
|
+
PageScreenshotOutputLimitError,
|
|
29
|
+
PageScreenshotResult,
|
|
30
|
+
type PageScreenshotCapture,
|
|
31
|
+
type PageScreenshotError,
|
|
32
|
+
type PageScreenshotRequest,
|
|
33
|
+
} from "effect-agent/page-screenshot";
|
|
34
|
+
import { SandboxImplementation } from "effect-agent/sandbox";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The Cloudflare Browser Run Quick Action `PageCapture` adapter (capability
|
|
38
|
+
* spec §9.2). Each capture is one stateless `quickAction()` RPC on the
|
|
39
|
+
* Wrangler `browser` binding: the platform renders the target in a managed
|
|
40
|
+
* headless browser and returns one bounded output; the adapter holds no
|
|
41
|
+
* session and no state between passes. The binding requires a Worker
|
|
42
|
+
* compatibility date of `2026-03-24` or later, and local `wrangler dev` needs
|
|
43
|
+
* remote mode (`"remote": true` on the binding) because `quickAction` has no
|
|
44
|
+
* local implementation.
|
|
45
|
+
*
|
|
46
|
+
* Rendered output is untrusted, attacker-influenced content; this adapter
|
|
47
|
+
* only bounds and types it. Deployment class `E` only: no durability claim.
|
|
48
|
+
*/
|
|
49
|
+
export const browserQuickActionImplementation = SandboxImplementation.make({
|
|
50
|
+
isolation: "isolated",
|
|
51
|
+
identity: "cloudflare-browser-quick-action",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Effect-native client captured by the binding service. Its option types come
|
|
56
|
+
* directly from the pinned Workers declarations rather than a local copy.
|
|
57
|
+
*/
|
|
58
|
+
export interface BrowserQuickActionClient {
|
|
59
|
+
readonly screenshot: (
|
|
60
|
+
options: BrowserRunScreenshotOptions,
|
|
61
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
62
|
+
readonly content: (
|
|
63
|
+
options: BrowserRunContentOptions,
|
|
64
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
65
|
+
readonly markdown: (
|
|
66
|
+
options: BrowserRunMarkdownOptions,
|
|
67
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
68
|
+
readonly links: (
|
|
69
|
+
options: BrowserRunLinksOptions,
|
|
70
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
71
|
+
readonly scrape: (
|
|
72
|
+
options: BrowserRunScrapeOptions,
|
|
73
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
74
|
+
readonly json: (
|
|
75
|
+
options: BrowserRunJsonOptions,
|
|
76
|
+
) => Effect.Effect<Response, BrowserQuickActionRpcError>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A native binding RPC rejected before it returned an HTTP response. */
|
|
80
|
+
export class BrowserQuickActionRpcError extends Schema.TaggedError<BrowserQuickActionRpcError>()(
|
|
81
|
+
"BrowserQuickActionRpcError",
|
|
82
|
+
{
|
|
83
|
+
action: Schema.Literals(["screenshot", "content", "markdown", "links", "scrape", "json"]),
|
|
84
|
+
cause: Schema.Defect(),
|
|
85
|
+
},
|
|
86
|
+
) {}
|
|
87
|
+
|
|
88
|
+
export interface BrowserQuickActionCaptureOptions {
|
|
89
|
+
/** The resolved Wrangler `browser` binding (DEPLOY-014: supplied, never ambient). */
|
|
90
|
+
readonly browser: BrowserRun;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Host-owned browser binding authority, supplied explicitly at the composition root. */
|
|
94
|
+
export class BrowserQuickActionBrowserBinding extends Context.Service<
|
|
95
|
+
BrowserQuickActionBrowserBinding,
|
|
96
|
+
BrowserQuickActionClient
|
|
97
|
+
>()("@effect-agent/platform-cloudflare/BrowserQuickActionBrowserBinding") {
|
|
98
|
+
static layer(
|
|
99
|
+
options: BrowserQuickActionCaptureOptions,
|
|
100
|
+
): Layer.Layer<BrowserQuickActionBrowserBinding> {
|
|
101
|
+
const browser = options.browser;
|
|
102
|
+
|
|
103
|
+
const invoke = Effect.fn("BrowserQuickActionBrowserBinding.invoke")(function* (
|
|
104
|
+
action: "screenshot" | "content" | "markdown" | "links" | "scrape" | "json",
|
|
105
|
+
evaluate: () => Promise<Response>,
|
|
106
|
+
): Effect.fn.Return<Response, BrowserQuickActionRpcError> {
|
|
107
|
+
return yield* Effect.tryPromise({
|
|
108
|
+
try: evaluate,
|
|
109
|
+
catch: (cause) => BrowserQuickActionRpcError.make({ action, cause }),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return Layer.succeed(BrowserQuickActionBrowserBinding)({
|
|
114
|
+
screenshot: (request) =>
|
|
115
|
+
invoke("screenshot", () => browser.quickAction("screenshot", request)),
|
|
116
|
+
content: (request) => invoke("content", () => browser.quickAction("content", request)),
|
|
117
|
+
markdown: (request) => invoke("markdown", () => browser.quickAction("markdown", request)),
|
|
118
|
+
links: (request) => invoke("links", () => browser.quickAction("links", request)),
|
|
119
|
+
scrape: (request) => invoke("scrape", () => browser.quickAction("scrape", request)),
|
|
120
|
+
json: (request) => invoke("json", () => browser.quickAction("json", request)),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Host-owned authorization and accounting for one Workers AI extraction. */
|
|
126
|
+
export interface BrowserQuickActionWorkersAiPolicy {
|
|
127
|
+
readonly authorizeAndAccount: (
|
|
128
|
+
request: PageCaptureRequest,
|
|
129
|
+
) => Effect.Effect<void, BrowserQuickActionWorkersAiPolicyError>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Host-only diagnostic for a denied or unaccounted Workers AI extraction. */
|
|
133
|
+
export class BrowserQuickActionWorkersAiPolicyError extends Schema.TaggedError<BrowserQuickActionWorkersAiPolicyError>()(
|
|
134
|
+
"BrowserQuickActionWorkersAiPolicyError",
|
|
135
|
+
{
|
|
136
|
+
reason: Schema.Literals(["authorization", "accounting"]),
|
|
137
|
+
message: Schema.String.check(Schema.isMaxLength(8_000)),
|
|
138
|
+
cause: Schema.optionalKey(Schema.Defect()),
|
|
139
|
+
},
|
|
140
|
+
) {}
|
|
141
|
+
|
|
142
|
+
/** Explicit host-owned authority and accounting for separately billed Workers AI extraction. */
|
|
143
|
+
export class BrowserQuickActionWorkersAi extends Context.Service<
|
|
144
|
+
BrowserQuickActionWorkersAi,
|
|
145
|
+
BrowserQuickActionWorkersAiPolicy
|
|
146
|
+
>()("@effect-agent/platform-cloudflare/BrowserQuickActionWorkersAi") {
|
|
147
|
+
static layer(
|
|
148
|
+
policy: BrowserQuickActionWorkersAiPolicy,
|
|
149
|
+
): Layer.Layer<BrowserQuickActionWorkersAi> {
|
|
150
|
+
return Layer.succeed(BrowserQuickActionWorkersAi)(policy);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MAX_DIAGNOSTIC_LENGTH = 8_000;
|
|
155
|
+
const boundedDiagnostic = (message: string): string => message.slice(0, MAX_DIAGNOSTIC_LENGTH);
|
|
156
|
+
|
|
157
|
+
const QuickActionSuccessEnvelope = Schema.Struct({
|
|
158
|
+
success: Schema.Literal(true),
|
|
159
|
+
result: Schema.Json,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const QuickActionErrorEnvelope = Schema.Struct({
|
|
163
|
+
success: Schema.Literal(false),
|
|
164
|
+
errors: Schema.Array(
|
|
165
|
+
Schema.Struct({
|
|
166
|
+
message: Schema.String,
|
|
167
|
+
code: Schema.optionalKey(Schema.Number),
|
|
168
|
+
detail: Schema.optionalKey(Schema.String),
|
|
169
|
+
path: Schema.optionalKey(Schema.String),
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
rawAiResponse: Schema.optionalKey(Schema.String),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const QuickActionEnvelope = Schema.Union([QuickActionSuccessEnvelope, QuickActionErrorEnvelope]);
|
|
176
|
+
const decodeEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(QuickActionEnvelope));
|
|
177
|
+
|
|
178
|
+
/** Recognize only the provider's exact timeout grammar; never forward arbitrary error text. */
|
|
179
|
+
const navigationFailureMessage = (bodyText: string, response: Response): string => {
|
|
180
|
+
const fallback = `The Browser Run Quick Action API answered HTTP ${response.status}; the destination status is unknown`;
|
|
181
|
+
|
|
182
|
+
if (!isJsonResponse(response)) return fallback;
|
|
183
|
+
const envelope = decodeEnvelope(bodyText);
|
|
184
|
+
|
|
185
|
+
if (Option.isNone(envelope) || envelope.value.success) return fallback;
|
|
186
|
+
for (const error of envelope.value.errors) {
|
|
187
|
+
if (error.code !== 6002) continue;
|
|
188
|
+
|
|
189
|
+
const timeout = /^Navigation timeout of ([1-9][0-9]{0,5}) ms exceeded$/.exec(
|
|
190
|
+
error.detail ?? "",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (timeout !== null)
|
|
194
|
+
return `Browser Run navigation timed out after ${timeout[1]} ms (Quick Action API HTTP ${response.status}); the destination status is unknown`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return fallback;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Project the schema-validated request onto Cloudflare's native common options. */
|
|
201
|
+
const quickActionCommonOptions = (request: PageCaptureRequest): BrowserRunCommonOptions => {
|
|
202
|
+
const options: BrowserRunBaseOptions = {};
|
|
203
|
+
const navigation = request.navigation;
|
|
204
|
+
|
|
205
|
+
if (navigation !== undefined) {
|
|
206
|
+
const goto: NonNullable<BrowserRunBaseOptions["gotoOptions"]> = {};
|
|
207
|
+
|
|
208
|
+
if (navigation.waitUntil !== undefined) goto.waitUntil = navigation.waitUntil;
|
|
209
|
+
if (navigation.timeoutMillis !== undefined) goto.timeout = navigation.timeoutMillis;
|
|
210
|
+
if (Object.keys(goto).length > 0) options.gotoOptions = goto;
|
|
211
|
+
if (navigation.waitForSelector !== undefined) {
|
|
212
|
+
options.waitForSelector = {
|
|
213
|
+
selector: navigation.waitForSelector.selector,
|
|
214
|
+
...(navigation.waitForSelector.timeoutMillis === undefined
|
|
215
|
+
? {}
|
|
216
|
+
: { timeout: navigation.waitForSelector.timeoutMillis }),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (request.viewport !== undefined) {
|
|
221
|
+
options.viewport = { width: request.viewport.width, height: request.viewport.height };
|
|
222
|
+
}
|
|
223
|
+
if (request.resourcePolicy !== undefined) {
|
|
224
|
+
if (request.resourcePolicy.rejectResourceTypes !== undefined) {
|
|
225
|
+
options.rejectResourceTypes = [...request.resourcePolicy.rejectResourceTypes];
|
|
226
|
+
}
|
|
227
|
+
if (request.resourcePolicy.allowRequestPatterns !== undefined) {
|
|
228
|
+
options.allowRequestPattern = [...request.resourcePolicy.allowRequestPatterns];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return request.target._tag === "PageUrlTarget"
|
|
233
|
+
? { ...options, url: request.target.url }
|
|
234
|
+
: { ...options, html: request.target.html };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Dispatch through Cloudflare's native action-specific overloads. */
|
|
238
|
+
const executeQuickAction = (
|
|
239
|
+
browser: BrowserQuickActionClient,
|
|
240
|
+
request: PageCaptureRequest,
|
|
241
|
+
): Effect.Effect<Response, BrowserQuickActionRpcError> => {
|
|
242
|
+
const options = quickActionCommonOptions(request);
|
|
243
|
+
|
|
244
|
+
switch (request.action._tag) {
|
|
245
|
+
case "CapturePageContent": {
|
|
246
|
+
return browser.content(options);
|
|
247
|
+
}
|
|
248
|
+
case "CapturePageMarkdown": {
|
|
249
|
+
return browser.markdown(options);
|
|
250
|
+
}
|
|
251
|
+
case "CapturePageLinks": {
|
|
252
|
+
return browser.links({
|
|
253
|
+
...options,
|
|
254
|
+
...(request.action.visibleLinksOnly === undefined
|
|
255
|
+
? {}
|
|
256
|
+
: { visibleLinksOnly: request.action.visibleLinksOnly }),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
case "CapturePageScrape": {
|
|
260
|
+
return browser.scrape({
|
|
261
|
+
...options,
|
|
262
|
+
elements: request.action.selectors.map((selector) => ({ selector })),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
case "CapturePageStructured": {
|
|
266
|
+
return browser.json({
|
|
267
|
+
...options,
|
|
268
|
+
response_format: {
|
|
269
|
+
type: "json_schema",
|
|
270
|
+
json_schema: request.action.responseFormat,
|
|
271
|
+
},
|
|
272
|
+
...(request.action.prompt === undefined ? {} : { prompt: request.action.prompt }),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const protocolError = (message: string, cause?: unknown): PageCaptureProtocolError =>
|
|
279
|
+
PageCaptureProtocolError.make({
|
|
280
|
+
implementation: browserQuickActionImplementation,
|
|
281
|
+
message: boundedDiagnostic(message),
|
|
282
|
+
...(cause === undefined ? {} : { cause }),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const navigationError = (message: string, cause?: unknown): PageCaptureNavigationError =>
|
|
286
|
+
PageCaptureNavigationError.make({
|
|
287
|
+
implementation: browserQuickActionImplementation,
|
|
288
|
+
message: boundedDiagnostic(message),
|
|
289
|
+
...(cause === undefined ? {} : { cause }),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/** Preserve bounded remote diagnostics for the host without exposing their text to a model. */
|
|
293
|
+
const privateResponseCause = (bodyText: string, response: Response): Error =>
|
|
294
|
+
new Error(boundedDiagnostic(bodyText), {
|
|
295
|
+
cause: {
|
|
296
|
+
provider: "cloudflare-browser-run",
|
|
297
|
+
httpStatus: response.status,
|
|
298
|
+
httpStatusSource: "browser-api",
|
|
299
|
+
headers: Object.fromEntries(
|
|
300
|
+
["content-type", "cf-ray", "x-request-id", "retry-after", "x-browser-ms-used"].flatMap(
|
|
301
|
+
(name) => {
|
|
302
|
+
const value = response.headers.get(name);
|
|
303
|
+
|
|
304
|
+
return value === null ? [] : [[name, boundedDiagnostic(value)]];
|
|
305
|
+
},
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
bodyCharacters: bodyText.length,
|
|
309
|
+
bodyTruncated: bodyText.length > MAX_DIAGNOSTIC_LENGTH,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/** Foreign cancellation must not keep a response Scope open indefinitely. */
|
|
314
|
+
const cancelResponse = (cancel: () => Promise<void>, warning: string): Effect.Effect<void> =>
|
|
315
|
+
Effect.tryPromise({ try: cancel, catch: () => undefined }).pipe(
|
|
316
|
+
Effect.interruptible,
|
|
317
|
+
Effect.timeoutOrElse({
|
|
318
|
+
duration: "1 second",
|
|
319
|
+
orElse: () => Effect.fail(undefined),
|
|
320
|
+
}),
|
|
321
|
+
Effect.catch(() => Effect.logWarning(warning)),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const releaseResponseReader = (
|
|
325
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
326
|
+
): Effect.Effect<void> =>
|
|
327
|
+
cancelResponse(() => reader.cancel(), "Canceling the Quick Action response failed").pipe(
|
|
328
|
+
Effect.ensuring(
|
|
329
|
+
Effect.try({
|
|
330
|
+
try: () => reader.releaseLock(),
|
|
331
|
+
catch: (cause) => protocolError("Releasing the Quick Action response failed", cause),
|
|
332
|
+
}).pipe(Effect.catch((error) => Effect.logWarning(error.message))),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const readBoundedResponse = Effect.fn("BrowserQuickActionCapture.readResponse")(function* (
|
|
337
|
+
response: Response,
|
|
338
|
+
request: PageCaptureRequest,
|
|
339
|
+
) {
|
|
340
|
+
const body = response.body;
|
|
341
|
+
|
|
342
|
+
if (body === null) return "";
|
|
343
|
+
|
|
344
|
+
const reader = yield* Effect.acquireRelease(
|
|
345
|
+
Effect.try({
|
|
346
|
+
try: () => body.getReader(),
|
|
347
|
+
catch: (cause) => protocolError("Opening the Quick Action response failed", cause),
|
|
348
|
+
}),
|
|
349
|
+
releaseResponseReader,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
353
|
+
let observedBytes = 0;
|
|
354
|
+
let bodyText = "";
|
|
355
|
+
|
|
356
|
+
while (true) {
|
|
357
|
+
const chunk = yield* Effect.tryPromise({
|
|
358
|
+
try: () => reader.read(),
|
|
359
|
+
catch: (cause) => protocolError("Reading the Quick Action response failed", cause),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (chunk.done) break;
|
|
363
|
+
|
|
364
|
+
observedBytes += chunk.value.byteLength;
|
|
365
|
+
if (observedBytes > request.limits.maxOutputBytes) {
|
|
366
|
+
return yield* PageCaptureOutputLimitError.make({
|
|
367
|
+
implementation: browserQuickActionImplementation,
|
|
368
|
+
limit: request.limits.maxOutputBytes,
|
|
369
|
+
observed: observedBytes,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
bodyText += yield* Effect.try({
|
|
374
|
+
try: () => decoder.decode(chunk.value, { stream: true }),
|
|
375
|
+
catch: (cause) => protocolError("Decoding the Quick Action response failed", cause),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
bodyText +
|
|
381
|
+
(yield* Effect.try({
|
|
382
|
+
try: () => decoder.decode(),
|
|
383
|
+
catch: (cause) => protocolError("Decoding the Quick Action response failed", cause),
|
|
384
|
+
}))
|
|
385
|
+
);
|
|
386
|
+
}, Effect.scoped);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Retry-After arrives in whole seconds; a non-integer form (an HTTP date) is
|
|
390
|
+
* dropped rather than guessed at.
|
|
391
|
+
*/
|
|
392
|
+
const retryAfterMillis = (response: Response): number | undefined => {
|
|
393
|
+
const header = response.headers.get("Retry-After");
|
|
394
|
+
|
|
395
|
+
if (header === null) return undefined;
|
|
396
|
+
const seconds = Number(header);
|
|
397
|
+
|
|
398
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0) return undefined;
|
|
399
|
+
const millis = seconds * 1_000;
|
|
400
|
+
|
|
401
|
+
return Number.isSafeInteger(millis) ? millis : undefined;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const browserMillis = (response: Response): number | undefined => {
|
|
405
|
+
const header = response.headers.get("X-Browser-Ms-Used");
|
|
406
|
+
|
|
407
|
+
if (header === null) return undefined;
|
|
408
|
+
const millis = Number(header);
|
|
409
|
+
|
|
410
|
+
return Number.isSafeInteger(millis) && millis >= 0 ? millis : undefined;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Only trusted response metadata chooses transport framing; page text never does. */
|
|
414
|
+
const isJsonResponse = (response: Response): boolean => {
|
|
415
|
+
const contentType = response.headers.get("Content-Type");
|
|
416
|
+
|
|
417
|
+
if (contentType === null) return false;
|
|
418
|
+
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase();
|
|
419
|
+
|
|
420
|
+
return mediaType === "application/json" || mediaType?.endsWith("+json") === true;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const parseOutput = (
|
|
424
|
+
action: PageCaptureAction,
|
|
425
|
+
bodyText: string,
|
|
426
|
+
response: Response,
|
|
427
|
+
): PageCaptureOutput | PageCaptureNavigationError | PageCaptureProtocolError => {
|
|
428
|
+
if (!isJsonResponse(response)) {
|
|
429
|
+
return protocolError(
|
|
430
|
+
"The Quick Action success response was not a JSON response envelope",
|
|
431
|
+
privateResponseCause(bodyText, response),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const envelope = decodeEnvelope(bodyText);
|
|
435
|
+
|
|
436
|
+
if (Option.isNone(envelope)) {
|
|
437
|
+
return protocolError(
|
|
438
|
+
"The JSON Quick Action response did not carry a valid response envelope",
|
|
439
|
+
privateResponseCause(bodyText, response),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (!envelope.value.success) {
|
|
443
|
+
return navigationError(
|
|
444
|
+
navigationFailureMessage(bodyText, response),
|
|
445
|
+
privateResponseCause(bodyText, response),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
switch (action._tag) {
|
|
449
|
+
case "CapturePageContent":
|
|
450
|
+
case "CapturePageMarkdown": {
|
|
451
|
+
if (typeof envelope.value.result !== "string") {
|
|
452
|
+
return protocolError("The Quick Action envelope carried a non-text result");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return action._tag === "CapturePageContent"
|
|
456
|
+
? PageContentCaptured.make({ html: envelope.value.result })
|
|
457
|
+
: PageMarkdownCaptured.make({ markdown: envelope.value.result });
|
|
458
|
+
}
|
|
459
|
+
case "CapturePageLinks": {
|
|
460
|
+
const decoded = Schema.decodeUnknownOption(PageLinksCaptured)({
|
|
461
|
+
_tag: "PageLinksCaptured",
|
|
462
|
+
links: envelope.value.result,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (Option.isNone(decoded)) {
|
|
466
|
+
return protocolError("The links Quick Action did not return a bounded array of valid URLs");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return decoded.value;
|
|
470
|
+
}
|
|
471
|
+
case "CapturePageScrape": {
|
|
472
|
+
const decoded = Schema.decodeUnknownOption(PageScrapeCaptured)({
|
|
473
|
+
_tag: "PageScrapeCaptured",
|
|
474
|
+
groups: envelope.value.result,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (Option.isNone(decoded)) {
|
|
478
|
+
return protocolError(
|
|
479
|
+
"The scrape Quick Action did not return bounded grouped element records",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return decoded.value;
|
|
484
|
+
}
|
|
485
|
+
case "CapturePageStructured": {
|
|
486
|
+
return PageStructuredCaptured.make({ value: envelope.value.result });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const isQuotaMessage = (text: string): boolean => /time limit|daily|quota/i.test(text);
|
|
492
|
+
|
|
493
|
+
const makeCapture = (
|
|
494
|
+
browser: BrowserQuickActionClient,
|
|
495
|
+
workersAi?: BrowserQuickActionWorkersAiPolicy,
|
|
496
|
+
): PageCaptureCapture =>
|
|
497
|
+
Effect.fn("BrowserQuickActionCapture.capture")(function* (
|
|
498
|
+
request: PageCaptureRequest,
|
|
499
|
+
): Effect.fn.Return<PageCaptureResult, PageCaptureError> {
|
|
500
|
+
if (request.engine !== "chromium") {
|
|
501
|
+
return yield* PageCaptureUnsupportedError.make({
|
|
502
|
+
implementation: browserQuickActionImplementation,
|
|
503
|
+
feature: "engine",
|
|
504
|
+
message:
|
|
505
|
+
"The browser binding's quickAction() exposes no engine selector; kitesurf requires the REST or CDP surface",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const usesWorkersAi = request.action._tag === "CapturePageStructured";
|
|
510
|
+
|
|
511
|
+
if (usesWorkersAi) {
|
|
512
|
+
if (workersAi === undefined) {
|
|
513
|
+
return yield* PageCaptureUnsupportedError.make({
|
|
514
|
+
implementation: browserQuickActionImplementation,
|
|
515
|
+
feature: "action",
|
|
516
|
+
message:
|
|
517
|
+
"Structured capture invokes separately billed Workers AI and requires an explicit authorization and accounting policy",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
yield* workersAi.authorizeAndAccount(request).pipe(
|
|
521
|
+
Effect.mapError((cause) =>
|
|
522
|
+
PageCaptureInferencePolicyError.make({
|
|
523
|
+
implementation: browserQuickActionImplementation,
|
|
524
|
+
provider: "cloudflare-workers-ai",
|
|
525
|
+
reason: cause.reason,
|
|
526
|
+
message:
|
|
527
|
+
cause.reason === "authorization"
|
|
528
|
+
? "Workers AI extraction was not authorized"
|
|
529
|
+
: "Workers AI extraction could not be accounted for",
|
|
530
|
+
cause,
|
|
531
|
+
}),
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const response = yield* executeQuickAction(browser, request).pipe(
|
|
537
|
+
Effect.mapError((error) =>
|
|
538
|
+
protocolError("The browser binding rejected the Quick Action", error.cause),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const bodyText = yield* readBoundedResponse(response, request);
|
|
543
|
+
|
|
544
|
+
if (response.status === 429) {
|
|
545
|
+
const retryAfter = retryAfterMillis(response);
|
|
546
|
+
const reason = isQuotaMessage(bodyText) ? "quota" : "rate";
|
|
547
|
+
const cause = privateResponseCause(bodyText, response);
|
|
548
|
+
|
|
549
|
+
return yield* PageCaptureRateLimitedError.make({
|
|
550
|
+
implementation: browserQuickActionImplementation,
|
|
551
|
+
reason,
|
|
552
|
+
...(retryAfter === undefined ? {} : { retryAfterMillis: retryAfter }),
|
|
553
|
+
...(cause === undefined ? {} : { cause }),
|
|
554
|
+
message:
|
|
555
|
+
reason === "quota"
|
|
556
|
+
? "The Quick Action exceeded its browser quota"
|
|
557
|
+
: "The Quick Action was rate limited",
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (!response.ok) {
|
|
561
|
+
const message = navigationFailureMessage(bodyText, response);
|
|
562
|
+
const cause = privateResponseCause(bodyText, response);
|
|
563
|
+
|
|
564
|
+
if (response.status >= 500) {
|
|
565
|
+
return yield* protocolError(message, cause);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return yield* navigationError(message, cause);
|
|
569
|
+
}
|
|
570
|
+
const output = parseOutput(request.action, bodyText, response);
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
output._tag === "PageCaptureNavigationError" ||
|
|
574
|
+
output._tag === "PageCaptureProtocolError"
|
|
575
|
+
) {
|
|
576
|
+
return yield* output;
|
|
577
|
+
}
|
|
578
|
+
const millis = browserMillis(response);
|
|
579
|
+
|
|
580
|
+
return PageCaptureResult.make({
|
|
581
|
+
implementation: browserQuickActionImplementation,
|
|
582
|
+
output,
|
|
583
|
+
resourceUse: PageCaptureResourceUse.make({
|
|
584
|
+
...(millis === undefined ? {} : { browserMillis: millis }),
|
|
585
|
+
...(usesWorkersAi
|
|
586
|
+
? {
|
|
587
|
+
inference: PageCaptureInferenceUse.make({
|
|
588
|
+
provider: "cloudflare-workers-ai",
|
|
589
|
+
modelCalls: 1,
|
|
590
|
+
}),
|
|
591
|
+
}
|
|
592
|
+
: {}),
|
|
593
|
+
}),
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Ordinary Quick Actions require host-owned browser binding authority. Workers
|
|
599
|
+
* AI stays unavailable unless the host deliberately selects its separate Layer.
|
|
600
|
+
*/
|
|
601
|
+
export const browserQuickActionCaptureLayer = (): Layer.Layer<
|
|
602
|
+
PageCapture,
|
|
603
|
+
never,
|
|
604
|
+
BrowserQuickActionBrowserBinding
|
|
605
|
+
> =>
|
|
606
|
+
Layer.effect(
|
|
607
|
+
PageCapture,
|
|
608
|
+
Effect.map(BrowserQuickActionBrowserBinding, (browser) =>
|
|
609
|
+
PageCapture.of({ capture: makeCapture(browser) }),
|
|
610
|
+
),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
/** Structured Quick Actions require host-owned browser and Workers AI authority. */
|
|
614
|
+
export const browserQuickActionWorkersAiCaptureLayer = (): Layer.Layer<
|
|
615
|
+
PageCapture,
|
|
616
|
+
never,
|
|
617
|
+
BrowserQuickActionBrowserBinding | BrowserQuickActionWorkersAi
|
|
618
|
+
> =>
|
|
619
|
+
Layer.effect(
|
|
620
|
+
PageCapture,
|
|
621
|
+
Effect.gen(function* () {
|
|
622
|
+
const browser = yield* BrowserQuickActionBrowserBinding;
|
|
623
|
+
const workersAi = yield* BrowserQuickActionWorkersAi;
|
|
624
|
+
|
|
625
|
+
return PageCapture.of({ capture: makeCapture(browser, workersAi) });
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
/** Host-owned Quick Action binding and optional, separately billed extraction authority. */
|
|
630
|
+
export interface CloudflareBrowserOptions extends BrowserQuickActionCaptureOptions {
|
|
631
|
+
readonly workersAi?: BrowserQuickActionWorkersAiPolicy;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Compose WebCapture handlers with the Cloudflare Quick Action adapter. */
|
|
635
|
+
export const CloudflareBrowser = {
|
|
636
|
+
/**
|
|
637
|
+
* Supply a WebCapture definition and the resolved Worker browser binding.
|
|
638
|
+
* Supports capture, scrape, and extraction definitions without importing capabilities.
|
|
639
|
+
* Only PageCapture is provided; other handler requirements and errors stay visible.
|
|
640
|
+
* Extraction fails closed unless workersAi explicitly authorizes and accounts for it.
|
|
641
|
+
* Capture limits, typed failures, tracing, and scoped response cleanup are unchanged.
|
|
642
|
+
*/
|
|
643
|
+
layer: <A, E, R>(
|
|
644
|
+
definition: { readonly handlers: Layer.Layer<A, E, R> },
|
|
645
|
+
options: CloudflareBrowserOptions,
|
|
646
|
+
): Layer.Layer<A, E, Exclude<R, PageCapture>> => {
|
|
647
|
+
const capture =
|
|
648
|
+
options.workersAi === undefined
|
|
649
|
+
? browserQuickActionCaptureLayer()
|
|
650
|
+
: browserQuickActionWorkersAiCaptureLayer().pipe(
|
|
651
|
+
Layer.provide(BrowserQuickActionWorkersAi.layer(options.workersAi)),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
return definition.handlers.pipe(
|
|
655
|
+
Layer.provide(capture.pipe(Layer.provide(BrowserQuickActionBrowserBinding.layer(options)))),
|
|
656
|
+
);
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const screenshotOptions = (request: PageScreenshotRequest): BrowserRunScreenshotOptions => {
|
|
661
|
+
const options: BrowserRunBaseOptions = {};
|
|
662
|
+
|
|
663
|
+
if (
|
|
664
|
+
request.navigation?.waitUntil !== undefined ||
|
|
665
|
+
request.navigation?.timeoutMillis !== undefined
|
|
666
|
+
) {
|
|
667
|
+
options.gotoOptions = {
|
|
668
|
+
...(request.navigation.waitUntil === undefined
|
|
669
|
+
? {}
|
|
670
|
+
: { waitUntil: request.navigation.waitUntil }),
|
|
671
|
+
...(request.navigation.timeoutMillis === undefined
|
|
672
|
+
? {}
|
|
673
|
+
: { timeout: request.navigation.timeoutMillis }),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (request.navigation?.waitForSelector !== undefined) {
|
|
677
|
+
options.waitForSelector = {
|
|
678
|
+
selector: request.navigation.waitForSelector.selector,
|
|
679
|
+
...(request.navigation.waitForSelector.timeoutMillis === undefined
|
|
680
|
+
? {}
|
|
681
|
+
: { timeout: request.navigation.waitForSelector.timeoutMillis }),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (request.viewport !== undefined) {
|
|
685
|
+
options.viewport = { width: request.viewport.width, height: request.viewport.height };
|
|
686
|
+
}
|
|
687
|
+
if (request.resourcePolicy?.rejectResourceTypes !== undefined) {
|
|
688
|
+
options.rejectResourceTypes = [...request.resourcePolicy.rejectResourceTypes];
|
|
689
|
+
}
|
|
690
|
+
if (request.resourcePolicy?.allowRequestPatterns !== undefined) {
|
|
691
|
+
options.allowRequestPattern = [...request.resourcePolicy.allowRequestPatterns];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
...options,
|
|
696
|
+
...(request.target._tag === "PageUrlTarget"
|
|
697
|
+
? { url: request.target.url }
|
|
698
|
+
: { html: request.target.html }),
|
|
699
|
+
screenshotOptions: { type: "png", encoding: "binary", fullPage: request.fullPage },
|
|
700
|
+
};
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const cancelBody = (body: ReadableStream<Uint8Array>): Effect.Effect<void> =>
|
|
704
|
+
cancelResponse(() => body.cancel(), "Canceling the screenshot response failed");
|
|
705
|
+
|
|
706
|
+
const releaseScreenshotReader = (
|
|
707
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
708
|
+
): Effect.Effect<void> =>
|
|
709
|
+
cancelResponse(() => reader.cancel(), "Canceling the screenshot response failed").pipe(
|
|
710
|
+
Effect.ensuring(
|
|
711
|
+
Effect.try({
|
|
712
|
+
try: () => reader.releaseLock(),
|
|
713
|
+
catch: () => undefined,
|
|
714
|
+
}).pipe(Effect.catch(() => Effect.logWarning("Releasing the screenshot response failed"))),
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
const pngResponse = (response: Response): boolean =>
|
|
719
|
+
response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase() === "image/png";
|
|
720
|
+
|
|
721
|
+
const declaredLength = (response: Response): number | undefined => {
|
|
722
|
+
const raw = response.headers.get("Content-Length");
|
|
723
|
+
|
|
724
|
+
if (raw === null || !/^(0|[1-9][0-9]*)$/.test(raw)) return undefined;
|
|
725
|
+
const length = Number(raw);
|
|
726
|
+
|
|
727
|
+
return Number.isSafeInteger(length) ? length : undefined;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const readScreenshot = Effect.fn("BrowserQuickActionScreenshot.read")(function* (
|
|
731
|
+
response: Response,
|
|
732
|
+
request: PageScreenshotRequest,
|
|
733
|
+
) {
|
|
734
|
+
const body = response.body;
|
|
735
|
+
|
|
736
|
+
if (body === null) {
|
|
737
|
+
return yield* protocolError("The screenshot response had no body");
|
|
738
|
+
}
|
|
739
|
+
if (!pngResponse(response)) {
|
|
740
|
+
yield* cancelBody(body);
|
|
741
|
+
|
|
742
|
+
return yield* protocolError("The screenshot response was not image/png");
|
|
743
|
+
}
|
|
744
|
+
const length = declaredLength(response);
|
|
745
|
+
|
|
746
|
+
if (length !== undefined && length > request.limits.maxOutputBytes) {
|
|
747
|
+
yield* cancelBody(body);
|
|
748
|
+
|
|
749
|
+
return yield* PageScreenshotOutputLimitError.make({
|
|
750
|
+
implementation: browserQuickActionImplementation,
|
|
751
|
+
limit: request.limits.maxOutputBytes,
|
|
752
|
+
observed: length,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const reader = yield* Effect.acquireRelease(
|
|
757
|
+
Effect.try({
|
|
758
|
+
try: () => body.getReader(),
|
|
759
|
+
catch: (cause) => protocolError("Opening the screenshot response failed", cause),
|
|
760
|
+
}),
|
|
761
|
+
releaseScreenshotReader,
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
const chunks: Array<Uint8Array> = [];
|
|
765
|
+
let observed = 0;
|
|
766
|
+
|
|
767
|
+
while (true) {
|
|
768
|
+
const next = yield* Effect.tryPromise({
|
|
769
|
+
try: () => reader.read(),
|
|
770
|
+
catch: (cause) => protocolError("Reading the screenshot response failed", cause),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (next.done) break;
|
|
774
|
+
observed += next.value.byteLength;
|
|
775
|
+
if (observed > request.limits.maxOutputBytes) {
|
|
776
|
+
return yield* PageScreenshotOutputLimitError.make({
|
|
777
|
+
implementation: browserQuickActionImplementation,
|
|
778
|
+
limit: request.limits.maxOutputBytes,
|
|
779
|
+
observed,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
chunks.push(next.value);
|
|
783
|
+
}
|
|
784
|
+
const bytes = new Uint8Array(observed);
|
|
785
|
+
let offset = 0;
|
|
786
|
+
|
|
787
|
+
for (const chunk of chunks) {
|
|
788
|
+
bytes.set(chunk, offset);
|
|
789
|
+
offset += chunk.byteLength;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return bytes;
|
|
793
|
+
}, Effect.scoped);
|
|
794
|
+
|
|
795
|
+
const makeScreenshot = (browser: BrowserQuickActionClient): PageScreenshotCapture =>
|
|
796
|
+
Effect.fn("BrowserQuickActionScreenshot.capture")(function* (
|
|
797
|
+
request: PageScreenshotRequest,
|
|
798
|
+
): Effect.fn.Return<PageScreenshotResult, PageScreenshotError> {
|
|
799
|
+
if (request.engine !== "chromium") {
|
|
800
|
+
return yield* PageCaptureUnsupportedError.make({
|
|
801
|
+
implementation: browserQuickActionImplementation,
|
|
802
|
+
feature: "engine",
|
|
803
|
+
message: "The browser binding's screenshot action exposes no engine selector",
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const response = yield* browser
|
|
808
|
+
.screenshot(screenshotOptions(request))
|
|
809
|
+
.pipe(
|
|
810
|
+
Effect.mapError((error) =>
|
|
811
|
+
protocolError("The browser binding rejected the screenshot", error.cause),
|
|
812
|
+
),
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
if (response.status === 429) {
|
|
816
|
+
const body = response.body;
|
|
817
|
+
|
|
818
|
+
if (body !== null) yield* cancelBody(body);
|
|
819
|
+
|
|
820
|
+
return yield* PageCaptureRateLimitedError.make({
|
|
821
|
+
implementation: browserQuickActionImplementation,
|
|
822
|
+
reason: "rate",
|
|
823
|
+
...(retryAfterMillis(response) === undefined
|
|
824
|
+
? {}
|
|
825
|
+
: { retryAfterMillis: retryAfterMillis(response) }),
|
|
826
|
+
message: "The screenshot Quick Action was rate limited",
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
const body = response.body;
|
|
831
|
+
|
|
832
|
+
if (body !== null) yield* cancelBody(body);
|
|
833
|
+
const message = `The screenshot Quick Action answered HTTP ${response.status}`;
|
|
834
|
+
|
|
835
|
+
return yield* response.status >= 500 ? protocolError(message) : navigationError(message);
|
|
836
|
+
}
|
|
837
|
+
const bytes = yield* readScreenshot(response, request);
|
|
838
|
+
|
|
839
|
+
return PageScreenshotResult.make({
|
|
840
|
+
implementation: browserQuickActionImplementation,
|
|
841
|
+
mediaType: "image/png",
|
|
842
|
+
bytes,
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
/** Native Browser Run screenshot adapter. It retains PNG bytes only for the caller's result. */
|
|
847
|
+
export const browserQuickActionScreenshotLayer = (): Layer.Layer<
|
|
848
|
+
PageScreenshot,
|
|
849
|
+
never,
|
|
850
|
+
BrowserQuickActionBrowserBinding
|
|
851
|
+
> =>
|
|
852
|
+
Layer.effect(
|
|
853
|
+
PageScreenshot,
|
|
854
|
+
Effect.map(BrowserQuickActionBrowserBinding, (browser) =>
|
|
855
|
+
PageScreenshot.of({ capture: makeScreenshot(browser) }),
|
|
856
|
+
),
|
|
857
|
+
);
|