@effect-agent/platform-cloudflare 0.1.0-beta.6 → 0.1.0-beta.60

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 (95) hide show
  1. package/dist/Alarm.d.mts +168 -0
  2. package/dist/Alarm.mjs +415 -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/CloudflareBindings.d.mts +105 -0
  11. package/dist/CloudflareBindings.mjs +98 -0
  12. package/dist/CloudflareBindings.mjs.map +1 -0
  13. package/dist/CloudflareBrowser-Bj22nNUT.mjs +466 -0
  14. package/dist/CloudflareBrowser-Bj22nNUT.mjs.map +1 -0
  15. package/dist/CloudflareBrowser-DJq53niJ.d.mts +80 -0
  16. package/dist/CloudflareBrowser.d.mts +2 -0
  17. package/dist/CloudflareBrowser.mjs +2 -0
  18. package/dist/CloudflareCodeMode.d.mts +44 -0
  19. package/dist/CloudflareCodeMode.mjs +611 -0
  20. package/dist/CloudflareCodeMode.mjs.map +1 -0
  21. package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
  22. package/dist/CloudflareConfig.d.mts +2 -0
  23. package/dist/CloudflareConfig.mjs +122 -0
  24. package/dist/CloudflareConfig.mjs.map +1 -0
  25. package/dist/CloudflareMemory.d.mts +140 -0
  26. package/dist/CloudflareMemory.mjs +168 -0
  27. package/dist/CloudflareMemory.mjs.map +1 -0
  28. package/dist/CloudflareScheduling.d.mts +52 -0
  29. package/dist/CloudflareScheduling.mjs +389 -0
  30. package/dist/CloudflareScheduling.mjs.map +1 -0
  31. package/dist/CloudflareSubscriptions.d.mts +87 -0
  32. package/dist/CloudflareSubscriptions.mjs +524 -0
  33. package/dist/CloudflareSubscriptions.mjs.map +1 -0
  34. package/dist/CloudflareThreadClient.d.mts +1214 -0
  35. package/dist/CloudflareThreadClient.mjs +386 -0
  36. package/dist/CloudflareThreadClient.mjs.map +1 -0
  37. package/dist/InteractiveBrowser.d.mts +144 -0
  38. package/dist/InteractiveBrowser.mjs +1090 -0
  39. package/dist/InteractiveBrowser.mjs.map +1 -0
  40. package/dist/ProtectedBrowser.d.mts +65 -0
  41. package/dist/ProtectedBrowser.mjs +859 -0
  42. package/dist/ProtectedBrowser.mjs.map +1 -0
  43. package/dist/ThreadObject-CvnEnVQH.d.mts +772 -0
  44. package/dist/ThreadObject-s5w3V_co.mjs +675 -0
  45. package/dist/ThreadObject-s5w3V_co.mjs.map +1 -0
  46. package/dist/ThreadObject.d.mts +2 -0
  47. package/dist/ThreadObject.mjs +3 -0
  48. package/dist/WakeScheduler.d.mts +23 -0
  49. package/dist/WakeScheduler.mjs +60 -0
  50. package/dist/WakeScheduler.mjs.map +1 -0
  51. package/dist/boundary-BguazVkh.mjs +42 -0
  52. package/dist/boundary-BguazVkh.mjs.map +1 -0
  53. package/dist/browser-session-lifecycle-CUbVfEgP.mjs +84 -0
  54. package/dist/browser-session-lifecycle-CUbVfEgP.mjs.map +1 -0
  55. package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +21 -0
  56. package/dist/index.d.mts +12 -1543
  57. package/dist/index.mjs +12 -1636
  58. package/dist/prepared-admission-G7N4DDqS.mjs +73 -0
  59. package/dist/prepared-admission-G7N4DDqS.mjs.map +1 -0
  60. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  61. package/package.json +1 -53
  62. package/src/Alarm.ts +862 -0
  63. package/src/BrowserRestCapture.ts +477 -0
  64. package/src/BrowserRestCrawl.ts +540 -0
  65. package/src/CloudflareBindings.ts +171 -0
  66. package/src/CloudflareBrowser.ts +15 -0
  67. package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +367 -204
  68. package/src/{config.ts → CloudflareConfig.ts} +9 -8
  69. package/src/CloudflareMemory.ts +359 -0
  70. package/src/CloudflareScheduling.ts +733 -0
  71. package/src/CloudflareSubscriptions.ts +1045 -0
  72. package/src/CloudflareThreadClient.ts +807 -0
  73. package/src/InteractiveBrowser.ts +2357 -0
  74. package/src/ProtectedBrowser.ts +2 -0
  75. package/src/{conversation-object.ts → ThreadObject.ts} +423 -239
  76. package/src/{wake-scheduler.ts → WakeScheduler.ts} +23 -23
  77. package/src/index.ts +11 -23
  78. package/src/internal/boundary.ts +55 -0
  79. package/src/internal/browser-quick-action.ts +818 -0
  80. package/src/internal/browser-session-lifecycle.ts +160 -0
  81. package/src/internal/layers.ts +615 -0
  82. package/src/internal/message-delivery.ts +90 -0
  83. package/src/internal/prepared-admission.ts +116 -0
  84. package/src/internal/progress-wait.ts +121 -0
  85. package/src/internal/transport.ts +42 -0
  86. package/src/protected-browser/binding.ts +187 -0
  87. package/src/protected-browser/inspect-frame.ts +125 -0
  88. package/src/protected-browser/native.ts +429 -0
  89. package/src/protected-browser/policy.ts +719 -0
  90. package/dist/index.mjs.map +0 -1
  91. package/src/alarm.ts +0 -334
  92. package/src/bindings.ts +0 -145
  93. package/src/client.ts +0 -646
  94. package/src/layers.ts +0 -376
  95. 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
+ );