@effect-agent/platform-cloudflare 0.1.0-beta.8 → 0.1.0-beta.80

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 (100) hide show
  1. package/dist/Alarm.d.mts +183 -0
  2. package/dist/Alarm.mjs +454 -0
  3. package/dist/Alarm.mjs.map +1 -0
  4. package/dist/BrowserRestCapture.d.mts +35 -0
  5. package/dist/BrowserRestCapture.mjs +238 -0
  6. package/dist/BrowserRestCapture.mjs.map +1 -0
  7. package/dist/BrowserRestCrawl.d.mts +17 -0
  8. package/dist/BrowserRestCrawl.mjs +244 -0
  9. package/dist/BrowserRestCrawl.mjs.map +1 -0
  10. package/dist/CloudflareAiGateway.d.mts +64 -0
  11. package/dist/CloudflareAiGateway.mjs +70 -0
  12. package/dist/CloudflareAiGateway.mjs.map +1 -0
  13. package/dist/CloudflareBindings.d.mts +112 -0
  14. package/dist/CloudflareBindings.mjs +101 -0
  15. package/dist/CloudflareBindings.mjs.map +1 -0
  16. package/dist/CloudflareBrowser-D4YqaTAl.mjs +495 -0
  17. package/dist/CloudflareBrowser-D4YqaTAl.mjs.map +1 -0
  18. package/dist/CloudflareBrowser-DJq53niJ.d.mts +80 -0
  19. package/dist/CloudflareBrowser.d.mts +2 -0
  20. package/dist/CloudflareBrowser.mjs +2 -0
  21. package/dist/CloudflareCodeMode.d.mts +44 -0
  22. package/dist/CloudflareCodeMode.mjs +616 -0
  23. package/dist/CloudflareCodeMode.mjs.map +1 -0
  24. package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
  25. package/dist/CloudflareConfig.d.mts +2 -0
  26. package/dist/CloudflareConfig.mjs +122 -0
  27. package/dist/CloudflareConfig.mjs.map +1 -0
  28. package/dist/CloudflareMemory.d.mts +142 -0
  29. package/dist/CloudflareMemory.mjs +195 -0
  30. package/dist/CloudflareMemory.mjs.map +1 -0
  31. package/dist/CloudflareScheduling.d.mts +52 -0
  32. package/dist/CloudflareScheduling.mjs +389 -0
  33. package/dist/CloudflareScheduling.mjs.map +1 -0
  34. package/dist/CloudflareSubscriptions.d.mts +87 -0
  35. package/dist/CloudflareSubscriptions.mjs +524 -0
  36. package/dist/CloudflareSubscriptions.mjs.map +1 -0
  37. package/dist/CloudflareThreadClient.d.mts +1286 -0
  38. package/dist/CloudflareThreadClient.mjs +386 -0
  39. package/dist/CloudflareThreadClient.mjs.map +1 -0
  40. package/dist/InteractiveBrowser.d.mts +144 -0
  41. package/dist/InteractiveBrowser.mjs +1090 -0
  42. package/dist/InteractiveBrowser.mjs.map +1 -0
  43. package/dist/ProtectedBrowser.d.mts +65 -0
  44. package/dist/ProtectedBrowser.mjs +859 -0
  45. package/dist/ProtectedBrowser.mjs.map +1 -0
  46. package/dist/ThreadObject-3uDpKfB3.d.mts +816 -0
  47. package/dist/ThreadObject-DGkXv41C.mjs +836 -0
  48. package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
  49. package/dist/ThreadObject.d.mts +2 -0
  50. package/dist/ThreadObject.mjs +3 -0
  51. package/dist/WakeScheduler.d.mts +23 -0
  52. package/dist/WakeScheduler.mjs +60 -0
  53. package/dist/WakeScheduler.mjs.map +1 -0
  54. package/dist/boundary-BguazVkh.mjs +42 -0
  55. package/dist/boundary-BguazVkh.mjs.map +1 -0
  56. package/dist/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
  57. package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
  58. package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
  59. package/dist/index.d.mts +13 -1547
  60. package/dist/index.mjs +13 -1636
  61. package/dist/prepared-admission-DSi55W-7.mjs +73 -0
  62. package/dist/prepared-admission-DSi55W-7.mjs.map +1 -0
  63. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  64. package/package.json +1 -53
  65. package/src/Alarm.ts +953 -0
  66. package/src/BrowserRestCapture.ts +477 -0
  67. package/src/BrowserRestCrawl.ts +540 -0
  68. package/src/CloudflareAiGateway.ts +170 -0
  69. package/src/CloudflareBindings.ts +180 -0
  70. package/src/CloudflareBrowser.ts +15 -0
  71. package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
  72. package/src/{config.ts → CloudflareConfig.ts} +9 -8
  73. package/src/CloudflareMemory.ts +406 -0
  74. package/src/CloudflareScheduling.ts +733 -0
  75. package/src/CloudflareSubscriptions.ts +1045 -0
  76. package/src/CloudflareThreadClient.ts +807 -0
  77. package/src/InteractiveBrowser.ts +2357 -0
  78. package/src/ProtectedBrowser.ts +2 -0
  79. package/src/ThreadObject.ts +1179 -0
  80. package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
  81. package/src/index.ts +12 -23
  82. package/src/internal/boundary.ts +55 -0
  83. package/src/internal/browser-quick-action.ts +857 -0
  84. package/src/internal/browser-session-lifecycle.ts +160 -0
  85. package/src/internal/layers.ts +751 -0
  86. package/src/internal/message-delivery.ts +148 -0
  87. package/src/internal/prepared-admission.ts +116 -0
  88. package/src/internal/progress-wait.ts +121 -0
  89. package/src/internal/transport.ts +42 -0
  90. package/src/protected-browser/binding.ts +187 -0
  91. package/src/protected-browser/inspect-frame.ts +125 -0
  92. package/src/protected-browser/native.ts +429 -0
  93. package/src/protected-browser/policy.ts +719 -0
  94. package/dist/index.mjs.map +0 -1
  95. package/src/alarm.ts +0 -334
  96. package/src/bindings.ts +0 -145
  97. package/src/client.ts +0 -646
  98. package/src/conversation-object.ts +0 -768
  99. package/src/layers.ts +0 -376
  100. 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)));