@effect-agent/platform-cloudflare 0.1.0-beta.8 → 0.1.0-beta.81
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 +184 -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 +112 -0
- package/dist/CloudflareBindings.mjs +101 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs +495 -0
- package/dist/CloudflareBrowser-D4YqaTAl.mjs.map +1 -0
- package/dist/CloudflareBrowser-DJq53niJ.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 +195 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +389 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +524 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1286 -0
- package/dist/CloudflareThreadClient.mjs +386 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +144 -0
- package/dist/InteractiveBrowser.mjs +1090 -0
- package/dist/InteractiveBrowser.mjs.map +1 -0
- package/dist/ProtectedBrowser.d.mts +65 -0
- package/dist/ProtectedBrowser.mjs +859 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-BpwkEc5E.mjs +836 -0
- package/dist/ThreadObject-BpwkEc5E.mjs.map +1 -0
- package/dist/ThreadObject-NbFOWK1I.d.mts +816 -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 +60 -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/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
- package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
- package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
- package/dist/index.d.mts +13 -1547
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DSi55W-7.mjs +73 -0
- package/dist/prepared-admission-DSi55W-7.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +967 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +180 -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 +406 -0
- package/src/CloudflareScheduling.ts +733 -0
- package/src/CloudflareSubscriptions.ts +1045 -0
- package/src/CloudflareThreadClient.ts +807 -0
- package/src/InteractiveBrowser.ts +2357 -0
- package/src/ProtectedBrowser.ts +2 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
- 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 +148 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +42 -0
- package/src/protected-browser/binding.ts +187 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +429 -0
- package/src/protected-browser/policy.ts +719 -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,540 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PageCrawl,
|
|
3
|
+
PageCrawlLimitError,
|
|
4
|
+
PageCrawlProtocolError,
|
|
5
|
+
PageCrawlRateLimitedError,
|
|
6
|
+
PageCrawlRecord,
|
|
7
|
+
PageCrawlTerminalError,
|
|
8
|
+
type PageCrawlCrawl,
|
|
9
|
+
type PageCrawlError,
|
|
10
|
+
type PageCrawlRequest,
|
|
11
|
+
} from "@effect-agent/sandbox/PageCrawl";
|
|
12
|
+
import { SandboxImplementation } from "@effect-agent/sandbox/Sandbox";
|
|
13
|
+
import {
|
|
14
|
+
Duration,
|
|
15
|
+
Effect,
|
|
16
|
+
Layer,
|
|
17
|
+
Option,
|
|
18
|
+
Redacted,
|
|
19
|
+
Ref,
|
|
20
|
+
Schedule,
|
|
21
|
+
Schema,
|
|
22
|
+
Scope,
|
|
23
|
+
Stream,
|
|
24
|
+
} from "effect";
|
|
25
|
+
import {
|
|
26
|
+
HttpClient,
|
|
27
|
+
HttpClientRequest,
|
|
28
|
+
type HttpClientError,
|
|
29
|
+
type HttpClientResponse,
|
|
30
|
+
} from "effect/unstable/http";
|
|
31
|
+
|
|
32
|
+
/** Node-safe REST crawl implementation. It never exposes or persists provider job identity. */
|
|
33
|
+
export const browserRestCrawlImplementation = SandboxImplementation.make({
|
|
34
|
+
isolation: "isolated",
|
|
35
|
+
identity: "cloudflare-browser-rest-crawl",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const API_ORIGIN = "https://api.cloudflare.com";
|
|
39
|
+
const POLL_INTERVAL = Duration.seconds(1);
|
|
40
|
+
const CANCEL_TIMEOUT = Duration.seconds(30);
|
|
41
|
+
const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024;
|
|
42
|
+
const MAX_RESULTS_RESPONSE_BYTES = 12 * 1024 * 1024;
|
|
43
|
+
const MAX_DIAGNOSTIC_LENGTH = 8_000;
|
|
44
|
+
const MAX_CURSOR_LENGTH = 1_024;
|
|
45
|
+
const MAX_RECORDS_PER_RESPONSE = 10_000;
|
|
46
|
+
const BoundedJobId = Schema.NonEmptyString.check(Schema.isMaxLength(256));
|
|
47
|
+
const BoundedCursorString = Schema.NonEmptyString.check(Schema.isMaxLength(MAX_CURSOR_LENGTH));
|
|
48
|
+
// The generated API response Schema says string; the current product example returns a number.
|
|
49
|
+
const ProviderCursor = Schema.Union([BoundedCursorString, Schema.Natural]);
|
|
50
|
+
|
|
51
|
+
const ProviderJobStatus = Schema.Literals([
|
|
52
|
+
"running",
|
|
53
|
+
"completed",
|
|
54
|
+
"errored",
|
|
55
|
+
"cancelled_by_user",
|
|
56
|
+
"cancelled_due_to_timeout",
|
|
57
|
+
"cancelled_due_to_limits",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const ProviderResult = Schema.Struct({
|
|
61
|
+
id: BoundedJobId,
|
|
62
|
+
status: ProviderJobStatus,
|
|
63
|
+
browserSecondsUsed: Schema.optionalKey(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
64
|
+
total: Schema.optionalKey(Schema.Natural),
|
|
65
|
+
finished: Schema.optionalKey(Schema.Natural),
|
|
66
|
+
skipped: Schema.optionalKey(Schema.Natural),
|
|
67
|
+
records: Schema.Array(PageCrawlRecord).check(Schema.isMaxLength(MAX_RECORDS_PER_RESPONSE)),
|
|
68
|
+
cursor: Schema.optionalKey(ProviderCursor),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const CreateEnvelope = Schema.Struct({
|
|
72
|
+
success: Schema.Literal(true),
|
|
73
|
+
result: BoundedJobId,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const ResultEnvelope = Schema.Struct({
|
|
77
|
+
success: Schema.Literal(true),
|
|
78
|
+
result: ProviderResult,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const DeleteEnvelope = Schema.Struct({
|
|
82
|
+
success: Schema.Literal(true),
|
|
83
|
+
result: Schema.Struct({
|
|
84
|
+
job_id: BoundedJobId,
|
|
85
|
+
message: Schema.String.check(Schema.isMaxLength(MAX_DIAGNOSTIC_LENGTH)),
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Explicit credentials. The token is projected only into fixed-origin Authorization headers. */
|
|
90
|
+
export interface BrowserRestCrawlOptions {
|
|
91
|
+
readonly accountId: string;
|
|
92
|
+
readonly apiToken: Redacted.Redacted<string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const boundedDiagnostic = (message: string): string => message.slice(0, MAX_DIAGNOSTIC_LENGTH);
|
|
96
|
+
|
|
97
|
+
const privateCause = (value: unknown, apiToken: Redacted.Redacted<string>): Error | undefined => {
|
|
98
|
+
const raw = boundedDiagnostic(String(value));
|
|
99
|
+
|
|
100
|
+
if (raw.length === 0) return undefined;
|
|
101
|
+
const token = Redacted.value(apiToken);
|
|
102
|
+
|
|
103
|
+
return new Error(token.length === 0 ? raw : raw.replaceAll(token, "[REDACTED]"));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const protocolError = (message: string, cause?: unknown): PageCrawlProtocolError =>
|
|
107
|
+
PageCrawlProtocolError.make({
|
|
108
|
+
implementation: browserRestCrawlImplementation,
|
|
109
|
+
message: boundedDiagnostic(message),
|
|
110
|
+
...(cause === undefined ? {} : { cause }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const limitError = (
|
|
114
|
+
request: PageCrawlRequest,
|
|
115
|
+
limit: PageCrawlLimitError["limit"],
|
|
116
|
+
observed: number,
|
|
117
|
+
): PageCrawlLimitError => {
|
|
118
|
+
const maximum =
|
|
119
|
+
limit === "pages"
|
|
120
|
+
? request.limits.maxPages
|
|
121
|
+
: limit === "page-bytes"
|
|
122
|
+
? request.limits.maxPageBytes
|
|
123
|
+
: limit === "total-bytes"
|
|
124
|
+
? request.limits.maxTotalBytes
|
|
125
|
+
: request.limits.deadlineMillis;
|
|
126
|
+
|
|
127
|
+
return PageCrawlLimitError.make({
|
|
128
|
+
implementation: browserRestCrawlImplementation,
|
|
129
|
+
limit,
|
|
130
|
+
maximum,
|
|
131
|
+
observed,
|
|
132
|
+
message: `The crawl exceeded its ${limit} limit`,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const retryAfterMillis = (
|
|
137
|
+
headers: Readonly<Record<string, string | undefined>>,
|
|
138
|
+
): number | undefined => {
|
|
139
|
+
const seconds = Number(headers["retry-after"]);
|
|
140
|
+
|
|
141
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0) return undefined;
|
|
142
|
+
const millis = seconds * 1_000;
|
|
143
|
+
|
|
144
|
+
return Number.isSafeInteger(millis) ? millis : undefined;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const isJsonResponse = (headers: Readonly<Record<string, string | undefined>>): boolean => {
|
|
148
|
+
const contentType = headers["content-type"];
|
|
149
|
+
|
|
150
|
+
if (contentType === undefined) return false;
|
|
151
|
+
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase();
|
|
152
|
+
|
|
153
|
+
return mediaType === "application/json" || mediaType?.endsWith("+json") === true;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const isQuotaMessage = (bodyText: string): boolean => /time limit|daily|quota/i.test(bodyText);
|
|
157
|
+
|
|
158
|
+
const endpoint = (options: BrowserRestCrawlOptions, jobId?: string): string =>
|
|
159
|
+
`${API_ORIGIN}/client/v4/accounts/${encodeURIComponent(options.accountId)}/browser-rendering/crawl${
|
|
160
|
+
jobId === undefined ? "" : `/${encodeURIComponent(jobId)}`
|
|
161
|
+
}`;
|
|
162
|
+
|
|
163
|
+
const readBoundedResponse = Effect.fn("BrowserRestCrawl.readBoundedResponse")(function* (
|
|
164
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
165
|
+
maximum: number,
|
|
166
|
+
): Effect.fn.Return<string, PageCrawlProtocolError> {
|
|
167
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
168
|
+
|
|
169
|
+
const state = yield* Stream.runFoldEffect<
|
|
170
|
+
Uint8Array,
|
|
171
|
+
HttpClientError.HttpClientError,
|
|
172
|
+
never,
|
|
173
|
+
{ readonly observed: number; readonly text: string },
|
|
174
|
+
PageCrawlProtocolError,
|
|
175
|
+
never
|
|
176
|
+
>(
|
|
177
|
+
response.stream,
|
|
178
|
+
() => ({ observed: 0, text: "" }),
|
|
179
|
+
(current, chunk) => {
|
|
180
|
+
const observed = current.observed + chunk.byteLength;
|
|
181
|
+
|
|
182
|
+
if (observed > maximum) {
|
|
183
|
+
return Effect.fail(
|
|
184
|
+
protocolError(
|
|
185
|
+
`The Browser Run crawl response exceeded ${String(maximum)} transport bytes`,
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return Effect.try({
|
|
191
|
+
try: () => ({ observed, text: current.text + decoder.decode(chunk, { stream: true }) }),
|
|
192
|
+
catch: (cause) => protocolError("Decoding the Browser Run crawl response failed", cause),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
).pipe(
|
|
196
|
+
Effect.mapError((cause) =>
|
|
197
|
+
Schema.is(PageCrawlProtocolError)(cause)
|
|
198
|
+
? cause
|
|
199
|
+
: protocolError("Reading the Browser Run crawl response failed", cause),
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return yield* Effect.try({
|
|
204
|
+
try: () => state.text + decoder.decode(),
|
|
205
|
+
catch: (cause) => protocolError("Decoding the Browser Run crawl response failed", cause),
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const deadlineFailure = Effect.fn("BrowserRestCrawl.deadlineFailure")(function* (
|
|
210
|
+
request: PageCrawlRequest,
|
|
211
|
+
startedAt: number,
|
|
212
|
+
) {
|
|
213
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
214
|
+
|
|
215
|
+
return yield* limitError(request, "deadline", Math.max(0, now - startedAt));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const withinDeadline = Effect.fn("BrowserRestCrawl.withinDeadline")(function* <A, E, R>(
|
|
219
|
+
effect: Effect.Effect<A, E, R>,
|
|
220
|
+
request: PageCrawlRequest,
|
|
221
|
+
startedAt: number,
|
|
222
|
+
): Effect.fn.Return<A, E | PageCrawlLimitError, R> {
|
|
223
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
224
|
+
const elapsed = Math.max(0, now - startedAt);
|
|
225
|
+
const remaining = request.limits.deadlineMillis - elapsed;
|
|
226
|
+
|
|
227
|
+
if (remaining <= 0) return yield* limitError(request, "deadline", elapsed);
|
|
228
|
+
|
|
229
|
+
return yield* effect.pipe(
|
|
230
|
+
Effect.timeoutOrElse({
|
|
231
|
+
duration: Duration.millis(remaining),
|
|
232
|
+
orElse: () => deadlineFailure(request, startedAt),
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const checkDeadline = Effect.fn("BrowserRestCrawl.checkDeadline")(function* (
|
|
238
|
+
request: PageCrawlRequest,
|
|
239
|
+
startedAt: number,
|
|
240
|
+
) {
|
|
241
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
242
|
+
const elapsed = Math.max(0, now - startedAt);
|
|
243
|
+
|
|
244
|
+
if (elapsed >= request.limits.deadlineMillis) {
|
|
245
|
+
return yield* limitError(request, "deadline", elapsed);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const normalizedCursor = (cursor: typeof ProviderCursor.Type): string => String(cursor);
|
|
250
|
+
|
|
251
|
+
interface PaginationState {
|
|
252
|
+
readonly cursor: Option.Option<string>;
|
|
253
|
+
readonly seen: ReadonlyArray<string>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface RecordLimitsState {
|
|
257
|
+
readonly pages: number;
|
|
258
|
+
readonly totalBytes: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const sameHost = (expectedHost: string, url: string): boolean => new URL(url).host === expectedHost;
|
|
262
|
+
|
|
263
|
+
const makeCrawl =
|
|
264
|
+
(client: HttpClient.HttpClient, options: BrowserRestCrawlOptions): PageCrawlCrawl =>
|
|
265
|
+
(input) =>
|
|
266
|
+
Stream.unwrap(
|
|
267
|
+
Effect.gen(function* () {
|
|
268
|
+
const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
269
|
+
const startHost = new URL(input.startUrl).host;
|
|
270
|
+
|
|
271
|
+
const executeJson = Effect.fn("BrowserRestCrawl.executeJson")(function* <
|
|
272
|
+
S extends Schema.Top,
|
|
273
|
+
>(request: HttpClientRequest.HttpClientRequest, schema: S, maximum: number) {
|
|
274
|
+
const authorized = HttpClientRequest.bearerToken(request, options.apiToken).pipe(
|
|
275
|
+
HttpClientRequest.acceptJson,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const response = yield* client
|
|
279
|
+
.execute(authorized)
|
|
280
|
+
.pipe(
|
|
281
|
+
Effect.mapError((cause) =>
|
|
282
|
+
protocolError(
|
|
283
|
+
"Calling the Browser Run crawl REST API failed",
|
|
284
|
+
privateCause(cause, options.apiToken),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const bodyText = yield* readBoundedResponse(response, maximum);
|
|
290
|
+
|
|
291
|
+
if (response.status === 429) {
|
|
292
|
+
const reason = isQuotaMessage(bodyText) ? "quota" : "rate";
|
|
293
|
+
|
|
294
|
+
return yield* PageCrawlRateLimitedError.make({
|
|
295
|
+
implementation: browserRestCrawlImplementation,
|
|
296
|
+
reason,
|
|
297
|
+
...(retryAfterMillis(response.headers) === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { retryAfterMillis: retryAfterMillis(response.headers) }),
|
|
300
|
+
message:
|
|
301
|
+
reason === "quota"
|
|
302
|
+
? "Browser Run exceeded its browser quota"
|
|
303
|
+
: "Browser Run crawl was rate limited",
|
|
304
|
+
...(privateCause(bodyText, options.apiToken) === undefined
|
|
305
|
+
? {}
|
|
306
|
+
: { cause: privateCause(bodyText, options.apiToken) }),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (response.status < 200 || response.status >= 300) {
|
|
310
|
+
return yield* protocolError(
|
|
311
|
+
`Browser Run crawl answered HTTP ${String(response.status)}`,
|
|
312
|
+
privateCause(bodyText, options.apiToken),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (!isJsonResponse(response.headers)) {
|
|
316
|
+
return yield* protocolError(
|
|
317
|
+
"The Browser Run crawl success response was not JSON",
|
|
318
|
+
privateCause(bodyText, options.apiToken),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(bodyText).pipe(
|
|
323
|
+
Effect.mapError((cause) =>
|
|
324
|
+
protocolError(
|
|
325
|
+
"Browser Run returned a malformed crawl response",
|
|
326
|
+
privateCause(cause, options.apiToken),
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const createJob = Effect.gen(function* () {
|
|
333
|
+
const requestWithBody = yield* HttpClientRequest.post(endpoint(options)).pipe(
|
|
334
|
+
HttpClientRequest.bodyJson({
|
|
335
|
+
url: input.startUrl,
|
|
336
|
+
crawlPurposes: [...input.purposes],
|
|
337
|
+
limit: input.limits.maxPages,
|
|
338
|
+
depth: input.limits.maxDepth,
|
|
339
|
+
formats: ["markdown"],
|
|
340
|
+
render: true,
|
|
341
|
+
options: {
|
|
342
|
+
includeExternalLinks: false,
|
|
343
|
+
includeSubdomains: false,
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
Effect.mapError((cause) =>
|
|
347
|
+
protocolError("Encoding the Browser Run crawl request failed", cause),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const created = yield* withinDeadline(
|
|
352
|
+
executeJson(requestWithBody, CreateEnvelope, MAX_CONTROL_RESPONSE_BYTES),
|
|
353
|
+
input,
|
|
354
|
+
startedAt,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
return created.result;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const job = yield* Effect.uninterruptibleMask((restore) =>
|
|
361
|
+
restore(createJob).pipe(
|
|
362
|
+
Effect.flatMap((id) =>
|
|
363
|
+
Effect.gen(function* () {
|
|
364
|
+
const state = yield* Ref.make<"running" | "terminal">("running");
|
|
365
|
+
const scope = yield* Effect.scope;
|
|
366
|
+
|
|
367
|
+
const cancelIfRunning = Ref.modify(state, (current) =>
|
|
368
|
+
current === "running"
|
|
369
|
+
? [true, "terminal" as const]
|
|
370
|
+
: [false, "terminal" as const],
|
|
371
|
+
).pipe(
|
|
372
|
+
Effect.flatMap((shouldCancel) => {
|
|
373
|
+
if (!shouldCancel) return Effect.void;
|
|
374
|
+
|
|
375
|
+
const cancellation = executeJson(
|
|
376
|
+
HttpClientRequest.delete(endpoint(options, id)),
|
|
377
|
+
DeleteEnvelope,
|
|
378
|
+
MAX_CONTROL_RESPONSE_BYTES,
|
|
379
|
+
).pipe(
|
|
380
|
+
Effect.flatMap((deleted) =>
|
|
381
|
+
deleted.result.job_id === id
|
|
382
|
+
? Effect.void
|
|
383
|
+
: protocolError("Browser Run cancelled a different crawl job"),
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return cancellation.pipe(
|
|
388
|
+
Effect.timeoutOrElse({
|
|
389
|
+
duration: CANCEL_TIMEOUT,
|
|
390
|
+
orElse: () =>
|
|
391
|
+
protocolError("Cancelling the Browser Run crawl exceeded 30 seconds"),
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
yield* Scope.addFinalizer(
|
|
398
|
+
scope,
|
|
399
|
+
cancelIfRunning.pipe(
|
|
400
|
+
Effect.catchCause(() =>
|
|
401
|
+
Effect.logWarning("Browser Run crawl cancellation failed"),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
return { id, state } as const;
|
|
407
|
+
}),
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const fetchResult = Effect.fn("BrowserRestCrawl.fetchResult")(function* (
|
|
413
|
+
cursor: Option.Option<string>,
|
|
414
|
+
statusOnly: boolean,
|
|
415
|
+
) {
|
|
416
|
+
let request = HttpClientRequest.get(endpoint(options, job.id));
|
|
417
|
+
|
|
418
|
+
if (statusOnly) request = HttpClientRequest.setUrlParam(request, "limit", "1");
|
|
419
|
+
if (Option.isSome(cursor)) {
|
|
420
|
+
request = HttpClientRequest.setUrlParam(request, "cursor", cursor.value);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// `limit=1` still permits one complete record, so poll GETs use the bounded result cap.
|
|
424
|
+
const envelope = yield* withinDeadline(
|
|
425
|
+
executeJson(request, ResultEnvelope, MAX_RESULTS_RESPONSE_BYTES),
|
|
426
|
+
input,
|
|
427
|
+
startedAt,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (envelope.result.id !== job.id) {
|
|
431
|
+
return yield* protocolError("Browser Run returned a different crawl job identity");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return envelope.result;
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const terminal = yield* Effect.uninterruptibleMask((restore) =>
|
|
438
|
+
restore(
|
|
439
|
+
withinDeadline(
|
|
440
|
+
Effect.repeat(fetchResult(Option.none(), true), {
|
|
441
|
+
schedule: Schedule.spaced(POLL_INTERVAL),
|
|
442
|
+
until: (result) => result.status !== "running",
|
|
443
|
+
}),
|
|
444
|
+
input,
|
|
445
|
+
startedAt,
|
|
446
|
+
),
|
|
447
|
+
).pipe(Effect.tap(() => Ref.set(job.state, "terminal"))),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
if (terminal.status === "running") {
|
|
451
|
+
return yield* protocolError("Browser Run polling stopped before a terminal status");
|
|
452
|
+
}
|
|
453
|
+
if (terminal.status !== "completed") {
|
|
454
|
+
return yield* PageCrawlTerminalError.make({
|
|
455
|
+
implementation: browserRestCrawlImplementation,
|
|
456
|
+
status: terminal.status,
|
|
457
|
+
message: `Browser Run crawl ended with status ${terminal.status}`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const pages = Stream.paginate<PaginationState, PageCrawlRecord, PageCrawlError>(
|
|
462
|
+
{ cursor: Option.none(), seen: [] },
|
|
463
|
+
(state) =>
|
|
464
|
+
Effect.gen(function* () {
|
|
465
|
+
const result = yield* fetchResult(state.cursor, false);
|
|
466
|
+
|
|
467
|
+
if (result.status !== "completed") {
|
|
468
|
+
return yield* protocolError(
|
|
469
|
+
"Browser Run changed crawl status during result pagination",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (result.cursor === undefined) {
|
|
473
|
+
return [result.records, Option.none()] as const;
|
|
474
|
+
}
|
|
475
|
+
const cursor = normalizedCursor(result.cursor);
|
|
476
|
+
|
|
477
|
+
if (state.seen.includes(cursor)) {
|
|
478
|
+
return yield* protocolError("Browser Run repeated a crawl result cursor");
|
|
479
|
+
}
|
|
480
|
+
if (state.seen.length >= input.limits.maxPages) {
|
|
481
|
+
return yield* protocolError("Browser Run returned too many crawl result cursors");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return [
|
|
485
|
+
result.records,
|
|
486
|
+
Option.some({ cursor: Option.some(cursor), seen: [...state.seen, cursor] }),
|
|
487
|
+
] as const;
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
return pages.pipe(
|
|
492
|
+
Stream.rechunk(1),
|
|
493
|
+
Stream.mapAccumEffect(
|
|
494
|
+
(): RecordLimitsState => ({ pages: 0, totalBytes: 0 }),
|
|
495
|
+
(state, record) =>
|
|
496
|
+
Effect.gen(function* () {
|
|
497
|
+
yield* checkDeadline(input, startedAt);
|
|
498
|
+
const pages = state.pages + 1;
|
|
499
|
+
|
|
500
|
+
if (pages > input.limits.maxPages) {
|
|
501
|
+
return yield* limitError(input, "pages", pages);
|
|
502
|
+
}
|
|
503
|
+
if (!sameHost(startHost, record.url)) {
|
|
504
|
+
return yield* protocolError("Browser Run returned an off-host crawl record");
|
|
505
|
+
}
|
|
506
|
+
if (record.metadata !== undefined && !sameHost(startHost, record.metadata.url)) {
|
|
507
|
+
return yield* protocolError("Browser Run returned off-host crawl metadata");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const pageBytes =
|
|
511
|
+
record.markdown === undefined
|
|
512
|
+
? 0
|
|
513
|
+
: new TextEncoder().encode(record.markdown).byteLength;
|
|
514
|
+
|
|
515
|
+
if (pageBytes > input.limits.maxPageBytes) {
|
|
516
|
+
return yield* limitError(input, "page-bytes", pageBytes);
|
|
517
|
+
}
|
|
518
|
+
const totalBytes = state.totalBytes + pageBytes;
|
|
519
|
+
|
|
520
|
+
if (totalBytes > input.limits.maxTotalBytes) {
|
|
521
|
+
return yield* limitError(input, "total-bytes", totalBytes);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return [{ pages, totalBytes }, [record]] as const;
|
|
525
|
+
}),
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
/** Cloudflare REST PageCrawl Layer for Node and other non-Worker composition roots. */
|
|
532
|
+
export const browserRestCrawlLayer = (
|
|
533
|
+
options: BrowserRestCrawlOptions,
|
|
534
|
+
): Layer.Layer<PageCrawl, never, HttpClient.HttpClient> =>
|
|
535
|
+
Layer.effect(
|
|
536
|
+
PageCrawl,
|
|
537
|
+
Effect.map(HttpClient.HttpClient, (client) =>
|
|
538
|
+
PageCrawl.of({ crawl: makeCrawl(client, options) }),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Redactable, Redacted, Schema } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
FetchHttpClient,
|
|
4
|
+
Headers,
|
|
5
|
+
HttpClient,
|
|
6
|
+
HttpClientError,
|
|
7
|
+
HttpClientRequest,
|
|
8
|
+
} from "effect/unstable/http";
|
|
9
|
+
|
|
10
|
+
const Segment = Schema.NonEmptyString.check(
|
|
11
|
+
Schema.isMaxLength(256),
|
|
12
|
+
Schema.isPattern(/^[a-zA-Z0-9_-]+$/),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const decodeSegment = Schema.decodeUnknownSync(Segment);
|
|
16
|
+
|
|
17
|
+
/** Options understood by upstream Effect AI clients; no model or provider wrapper is created. */
|
|
18
|
+
export interface ClientOptions {
|
|
19
|
+
readonly apiUrl: string;
|
|
20
|
+
readonly transformClient: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderOptions {
|
|
24
|
+
readonly accountId: string;
|
|
25
|
+
readonly gatewayId: string;
|
|
26
|
+
/** Cloudflare's provider path, such as openai, anthropic, google-ai-studio, or perplexity-ai. */
|
|
27
|
+
readonly provider: string;
|
|
28
|
+
/** Omit only for an unauthenticated gateway with a separately supplied provider key. */
|
|
29
|
+
readonly apiToken?: Redacted.Redacted<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RestOptions {
|
|
33
|
+
readonly accountId: string;
|
|
34
|
+
readonly gatewayId: string;
|
|
35
|
+
/** Cloudflare API token with Workers AI Read permission. */
|
|
36
|
+
readonly apiToken: Redacted.Redacted<string>;
|
|
37
|
+
/** Matches the paths appended by the upstream Effect client. */
|
|
38
|
+
readonly protocol: "responses" | "chat-completions" | "messages";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Choose exactly one route: a native provider path or an account REST protocol. */
|
|
42
|
+
export type RouteOptions =
|
|
43
|
+
| (ProviderOptions & { readonly protocol?: never })
|
|
44
|
+
| (RestOptions & { readonly provider?: never });
|
|
45
|
+
|
|
46
|
+
const redactGatewayToken = (headers: Headers.Headers, tokenHeader: string): void => {
|
|
47
|
+
// HTTP tracing can copy headers after preprocessing. Attach the public redaction
|
|
48
|
+
// protocol to the final request as well, before a provider builds error details.
|
|
49
|
+
Object.defineProperty(headers, Redactable.symbolRedactable, {
|
|
50
|
+
configurable: true,
|
|
51
|
+
value: (context: Context.Context<never>) =>
|
|
52
|
+
Headers.redact(headers, [...Context.get(context, Headers.CurrentRedactedNames), tokenHeader]),
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const clientOptions = (
|
|
57
|
+
apiUrl: string,
|
|
58
|
+
tokenHeader: "authorization" | "cf-aig-authorization",
|
|
59
|
+
apiToken: Redacted.Redacted<string> | undefined,
|
|
60
|
+
gatewayId?: string,
|
|
61
|
+
): ClientOptions =>
|
|
62
|
+
Object.freeze({
|
|
63
|
+
apiUrl,
|
|
64
|
+
transformClient: (client: HttpClient.HttpClient) =>
|
|
65
|
+
client.pipe(
|
|
66
|
+
HttpClient.mapRequestEffect((request) => {
|
|
67
|
+
const url = URL.parse(request.url);
|
|
68
|
+
|
|
69
|
+
// Check the normalized URL too: dot segments must not escape this account or gateway.
|
|
70
|
+
if (
|
|
71
|
+
url === null ||
|
|
72
|
+
url.username !== "" ||
|
|
73
|
+
url.password !== "" ||
|
|
74
|
+
!(url.href === apiUrl || url.href.startsWith(`${apiUrl}/`))
|
|
75
|
+
) {
|
|
76
|
+
return Effect.fail(
|
|
77
|
+
new HttpClientError.HttpClientError({
|
|
78
|
+
reason: new HttpClientError.InvalidUrlError({
|
|
79
|
+
request,
|
|
80
|
+
description: "Request is outside the configured Cloudflare AI Gateway endpoint",
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
let prepared = request;
|
|
86
|
+
|
|
87
|
+
if (apiToken !== undefined) {
|
|
88
|
+
prepared = HttpClientRequest.setHeader(
|
|
89
|
+
prepared,
|
|
90
|
+
tokenHeader,
|
|
91
|
+
`Bearer ${Redacted.value(apiToken)}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (gatewayId !== undefined) {
|
|
95
|
+
prepared = HttpClientRequest.setHeader(prepared, "cf-aig-gateway-id", gatewayId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Effect.succeed(prepared);
|
|
99
|
+
}),
|
|
100
|
+
HttpClient.transformResponse((effect) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const defaults = yield* Effect.serviceOption(FetchHttpClient.RequestInit);
|
|
103
|
+
|
|
104
|
+
return yield* effect.pipe(
|
|
105
|
+
Effect.provideService(FetchHttpClient.RequestInit, {
|
|
106
|
+
...Option.getOrElse(defaults, () => ({})),
|
|
107
|
+
redirect: "error",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
HttpClient.transformResponse((effect) =>
|
|
113
|
+
effect.pipe(
|
|
114
|
+
Effect.tap((response) =>
|
|
115
|
+
Effect.sync(() => redactGatewayToken(response.request.headers, tokenHeader)),
|
|
116
|
+
),
|
|
117
|
+
Effect.tapError((error) =>
|
|
118
|
+
Effect.sync(() => redactGatewayToken(error.reason.request.headers, tokenHeader)),
|
|
119
|
+
),
|
|
120
|
+
Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, tokenHeader]),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Provider-native proxy for model calls, streaming, embeddings, and hosted tools supported
|
|
128
|
+
* by that provider. Pass the result to the upstream client's layer along with its apiKey
|
|
129
|
+
* for BYOK-in-request, or omit apiKey for Gateway stored keys / Unified Billing.
|
|
130
|
+
* Provider model names and request bodies pass through unchanged. This module is Node-safe.
|
|
131
|
+
* Custom transforms and redirect policies must retain this endpoint and credential boundary.
|
|
132
|
+
*/
|
|
133
|
+
export const provider = (options: ProviderOptions): ClientOptions =>
|
|
134
|
+
clientOptions(
|
|
135
|
+
`https://gateway.ai.cloudflare.com/v1/${decodeSegment(options.accountId)}/${decodeSegment(options.gatewayId)}/${decodeSegment(options.provider)}`,
|
|
136
|
+
"cf-aig-authorization",
|
|
137
|
+
options.apiToken,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cloudflare's account REST API (not the deprecated /compat API). Use provider-qualified
|
|
142
|
+
* model names, e.g. openai/gpt-4.1 or anthropic/claude-haiku-4.5, and omit provider apiKey.
|
|
143
|
+
* OpenAI clients append /responses or /chat/completions; Anthropic appends /v1/messages.
|
|
144
|
+
* Compatibility remains the responsibility of the selected upstream client and model.
|
|
145
|
+
*/
|
|
146
|
+
export const rest = (options: RestOptions): ClientOptions =>
|
|
147
|
+
clientOptions(
|
|
148
|
+
`https://api.cloudflare.com/client/v4/accounts/${decodeSegment(options.accountId)}/ai${options.protocol === "messages" ? "" : "/v1"}`,
|
|
149
|
+
"authorization",
|
|
150
|
+
options.apiToken,
|
|
151
|
+
decodeSegment(options.gatewayId),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Provide a Gateway-configured upstream client directly in a Layer pipeline.
|
|
156
|
+
* Pass the client's `layer` factory, or a callback adding client-specific options.
|
|
157
|
+
* The factory's errors and remaining services (such as HttpClient) stay visible.
|
|
158
|
+
* Model selection and resource ownership remain with the supplied upstream Layers.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* AnthropicLanguageModel.model("claude-haiku-4-5").pipe(
|
|
163
|
+
* Gateway.provide(AnthropicClient.layer, { ...options, provider: "anthropic" }),
|
|
164
|
+
* )
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export const provide = <Client, E, R>(
|
|
168
|
+
clientLayer: (options: ClientOptions) => Layer.Layer<Client, E, R>,
|
|
169
|
+
options: RouteOptions,
|
|
170
|
+
) => Layer.provide(clientLayer(options.provider !== undefined ? provider(options) : rest(options)));
|