@expandai/sdk 0.19.0 → 0.20.0

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 (72) hide show
  1. package/README.md +130 -4
  2. package/build/dts/AppendixMarkers.d.ts +30 -0
  3. package/build/dts/AppendixMarkers.d.ts.map +1 -0
  4. package/build/dts/Client.d.ts +41 -6
  5. package/build/dts/Client.d.ts.map +1 -1
  6. package/build/dts/Crawls.d.ts +568 -0
  7. package/build/dts/Crawls.d.ts.map +1 -0
  8. package/build/dts/EnvProxyHttpClient.d.ts +28 -0
  9. package/build/dts/EnvProxyHttpClient.d.ts.map +1 -0
  10. package/build/dts/FetchJson.d.ts +444 -37
  11. package/build/dts/FetchJson.d.ts.map +1 -1
  12. package/build/dts/Generated.d.ts +1545 -788
  13. package/build/dts/Generated.d.ts.map +1 -1
  14. package/build/dts/HttpClientHelpers.d.ts +13 -0
  15. package/build/dts/HttpClientHelpers.d.ts.map +1 -1
  16. package/build/dts/Internal.d.ts +6 -4
  17. package/build/dts/Internal.d.ts.map +1 -1
  18. package/build/dts/ParseErrorBody.d.ts +26 -0
  19. package/build/dts/ParseErrorBody.d.ts.map +1 -0
  20. package/build/dts/Playground.d.ts +20 -0
  21. package/build/dts/Playground.d.ts.map +1 -1
  22. package/build/dts/RetryAfter.d.ts +40 -0
  23. package/build/dts/RetryAfter.d.ts.map +1 -0
  24. package/build/dts/Service.d.ts +94 -45
  25. package/build/dts/Service.d.ts.map +1 -1
  26. package/build/dts/StreamPage.d.ts +10 -0
  27. package/build/dts/StreamPage.d.ts.map +1 -0
  28. package/build/dts/index.d.ts +9 -2
  29. package/build/dts/index.d.ts.map +1 -1
  30. package/build/esm/AppendixMarkers.js +307 -0
  31. package/build/esm/AppendixMarkers.js.map +1 -0
  32. package/build/esm/Client.js +96 -15
  33. package/build/esm/Client.js.map +1 -1
  34. package/build/esm/Crawls.js +786 -0
  35. package/build/esm/Crawls.js.map +1 -0
  36. package/build/esm/EnvProxyHttpClient.js +55 -0
  37. package/build/esm/EnvProxyHttpClient.js.map +1 -0
  38. package/build/esm/FetchJson.js +48 -22
  39. package/build/esm/FetchJson.js.map +1 -1
  40. package/build/esm/Generated.js +540 -112
  41. package/build/esm/Generated.js.map +1 -1
  42. package/build/esm/HttpClientHelpers.js +36 -1
  43. package/build/esm/HttpClientHelpers.js.map +1 -1
  44. package/build/esm/Internal.js +140 -33
  45. package/build/esm/Internal.js.map +1 -1
  46. package/build/esm/ParseErrorBody.js +41 -0
  47. package/build/esm/ParseErrorBody.js.map +1 -0
  48. package/build/esm/Playground.js +38 -0
  49. package/build/esm/Playground.js.map +1 -1
  50. package/build/esm/RetryAfter.js +104 -0
  51. package/build/esm/RetryAfter.js.map +1 -0
  52. package/build/esm/Service.js +96 -17
  53. package/build/esm/Service.js.map +1 -1
  54. package/build/esm/StreamPage.js +46 -0
  55. package/build/esm/StreamPage.js.map +1 -0
  56. package/build/esm/index.js +5 -1
  57. package/build/esm/index.js.map +1 -1
  58. package/package.json +8 -2
  59. package/src/AppendixMarkers.ts +370 -0
  60. package/src/Client.ts +131 -16
  61. package/src/Crawls.ts +1387 -0
  62. package/src/EnvProxyHttpClient.ts +71 -0
  63. package/src/FetchJson.ts +63 -25
  64. package/src/Generated.ts +738 -162
  65. package/src/HttpClientHelpers.ts +48 -1
  66. package/src/Internal.ts +163 -105
  67. package/src/ParseErrorBody.ts +42 -0
  68. package/src/Playground.ts +48 -0
  69. package/src/RetryAfter.ts +138 -0
  70. package/src/Service.ts +208 -25
  71. package/src/StreamPage.ts +47 -0
  72. package/src/index.ts +29 -3
package/src/Crawls.ts ADDED
@@ -0,0 +1,1387 @@
1
+ import { BackoffDefaults, stream as durableStream, FetchError, type JsonBatch } from '@durable-streams/client'
2
+ import { Duration } from 'effect'
3
+ import {
4
+ ExpandClientApiError,
5
+ ExpandClientConnectionError,
6
+ ExpandClientError,
7
+ ExpandClientTimeoutError,
8
+ } from './Error.js'
9
+ import { isRetryable } from './Internal.js'
10
+ import { MAX_RETRY_AFTER } from './RetryAfter.js'
11
+ import { readStreamPage } from './StreamPage.js'
12
+
13
+ export type CrawlOffset = string
14
+ export type CrawlId = string
15
+ export type CrawlPageId = string
16
+
17
+ export type CrawlJsonValue =
18
+ | string
19
+ | number
20
+ | boolean
21
+ | null
22
+ | readonly CrawlJsonValue[]
23
+ | { readonly [key: string]: CrawlJsonValue }
24
+
25
+ export interface CrawlSource {
26
+ readonly url: string
27
+ readonly kind?: 'seed' | 'sitemap'
28
+ /** Which pages end up in the results. Non-matching pages may still be opened to follow their links. */
29
+ readonly include?: readonly string[]
30
+ /** Pages the crawler never opens. */
31
+ readonly exclude?: readonly string[]
32
+ readonly maxPages?: number
33
+ /**
34
+ * Clicks from `url`: 0 is the URL alone, 1 adds the pages it links to, the site's sitemap and
35
+ * search results. A sitemap source's own listings count as depth 0. Defaults to 50.
36
+ */
37
+ readonly maxDepth?: number
38
+ }
39
+
40
+ export interface CrawlChangePolicy {
41
+ readonly ignore?: readonly string[]
42
+ readonly volatile?: readonly string[]
43
+ }
44
+
45
+ export interface CrawlSyncPolicy {
46
+ /** How often every page is checked for changes, such as `24h` or `7d`. */
47
+ readonly freshness: string
48
+ readonly budget?: { readonly rendersPerDay?: number }
49
+ }
50
+
51
+ export interface CrawlRequest {
52
+ readonly sources: readonly (string | CrawlSource)[]
53
+ readonly formats?: readonly ('markdown' | 'stateJson' | 'html')[]
54
+ readonly maxPages?: number
55
+ readonly changePolicy?: CrawlChangePolicy
56
+ readonly sync?: CrawlSyncPolicy
57
+ /** A label for the crawl, 1–120 characters. Every crawl the request creates gets it. */
58
+ readonly name?: string
59
+ }
60
+
61
+ export interface CrawlPageMeta {
62
+ readonly title?: string
63
+ readonly description?: string
64
+ readonly canonicalUrl?: string
65
+ readonly language?: string
66
+ readonly pageType: string
67
+ readonly author?: string
68
+ readonly publishedAt?: string
69
+ readonly updatedAt?: string
70
+ readonly image?: string
71
+ readonly openGraph?: Readonly<Record<string, string>>
72
+ readonly twitter?: Readonly<Record<string, string>>
73
+ /**
74
+ * Present when the capture behind this page ran at reduced fidelity, naming what was
75
+ * reduced (`bestEffort`, `skipped-modelInputTooLarge`, `hfc-local-fallback`, …). The
76
+ * page and its content are real; the field says how much of the extraction ran.
77
+ */
78
+ readonly reducedCapture?: string
79
+ }
80
+
81
+ export interface CrawlEvidence {
82
+ readonly key: string
83
+ readonly kind: 'inline' | 'network'
84
+ readonly role: string
85
+ /** Evidence relevance weight. Higher means more relevant; not a probability, so it can exceed 1. */
86
+ readonly score: number
87
+ readonly url?: string
88
+ readonly method?: string
89
+ readonly body?: CrawlJsonValue
90
+ readonly bodyUrl?: string
91
+ readonly approxTokens?: number
92
+ readonly keys?: readonly string[]
93
+ }
94
+
95
+ export interface CrawlStateJson {
96
+ readonly captureId: string
97
+ readonly evidence: readonly CrawlEvidence[]
98
+ }
99
+
100
+ export interface CrawlPageContent {
101
+ readonly markdown?: string
102
+ readonly stateJson?: CrawlStateJson
103
+ readonly html?: string
104
+ readonly truncated: readonly ('markdown' | 'stateJson' | 'html')[]
105
+ }
106
+
107
+ interface CrawlEventBase {
108
+ readonly offset: CrawlOffset
109
+ readonly id: string
110
+ readonly observedAt: string
111
+ readonly crawlId: CrawlId
112
+ }
113
+
114
+ export interface CrawlPageAdded extends CrawlEventBase {
115
+ readonly type: 'page.added'
116
+ readonly pageId: CrawlPageId
117
+ readonly url: string
118
+ readonly captureId: string
119
+ readonly discoveredBy: 'seed' | 'sitemap' | 'link' | 'search'
120
+ readonly depth: number
121
+ readonly meta: CrawlPageMeta
122
+ readonly page: CrawlPageContent
123
+ }
124
+
125
+ export interface CrawlPageChanged extends CrawlEventBase {
126
+ readonly type: 'page.changed'
127
+ readonly pageId: CrawlPageId
128
+ readonly url: string
129
+ readonly captureId: string
130
+ readonly previousCaptureId: string
131
+ readonly changed: { readonly content: boolean; readonly stateJson: boolean; readonly extraction: boolean }
132
+ readonly diff: {
133
+ readonly stateJson: Readonly<Record<string, { readonly from?: CrawlJsonValue; readonly to?: CrawlJsonValue }>>
134
+ readonly markdown: string
135
+ readonly truncated: boolean
136
+ }
137
+ readonly meta: CrawlPageMeta
138
+ readonly page: CrawlPageContent
139
+ }
140
+
141
+ export interface CrawlPageRemoved extends CrawlEventBase {
142
+ readonly type: 'page.removed'
143
+ readonly pageId: CrawlPageId
144
+ readonly url: string
145
+ readonly previousCaptureId: string
146
+ readonly evidence: {
147
+ readonly kind: 'http-404' | 'http-410' | 'content-gone' | 'redirected-away'
148
+ readonly checks: readonly {
149
+ readonly captureId: string
150
+ readonly observedAt: string
151
+ readonly status: number
152
+ readonly finalUrl: string
153
+ }[]
154
+ readonly sitemapDisappeared: boolean
155
+ }
156
+ }
157
+
158
+ export interface CrawlPageFailed extends CrawlEventBase {
159
+ readonly type: 'page.failed'
160
+ readonly url: string
161
+ readonly reason:
162
+ | 'blocked'
163
+ | 'not-found'
164
+ | 'timeout'
165
+ | 'error'
166
+ | 'robots-disallowed'
167
+ | 'robots-unreachable'
168
+ | 'out-of-scope'
169
+ /** The precise cause behind `reason`, when the check knew one (`quality-degraded`, `http-503`, …). */
170
+ readonly detail?: string
171
+ readonly retriesExhausted: boolean
172
+ }
173
+
174
+ export type CrawlEvent = CrawlPageAdded | CrawlPageChanged | CrawlPageRemoved | CrawlPageFailed
175
+
176
+ /**
177
+ * One batch of URL transitions on the activity stream (`GET /v1/crawls/:id/activity`): what was
178
+ * discovered, admitted, claimed, rendered or deferred, which sitemaps and robots files resolved,
179
+ * and the run lifecycle. No content; the page stream (`events`) carries that. `eventsOffset` is a
180
+ * lower bound on the page stream: every page event at or below it committed before this one.
181
+ */
182
+ export interface CrawlActivityEvent {
183
+ readonly offset: CrawlOffset
184
+ readonly id: string
185
+ readonly crawlId: CrawlId
186
+ readonly runId: string | null
187
+ /** ISO date-time. */
188
+ readonly observedAt: string
189
+ /** `discovery.batch`, `admission.batch`, `claim.batch`, `render.settled`, `sitemap.fetched`, `sitemap.failed`, `robots.resolved`, `search.resolved`, `alias.batch`, `run.started`, `run.discovery-closed`, `run.finished`, `run.canceled`, … */
190
+ readonly type: string
191
+ readonly eventsOffset: CrawlOffset
192
+ readonly payload: Record<string, unknown>
193
+ }
194
+
195
+ export type CrawlAdmissionReason = 'no-fleet-snapshot' | 'no-fleet-capacity' | 'billing-blocked' | 'disabled' | 'none'
196
+
197
+ export interface CrawlAdmission {
198
+ readonly paused: boolean
199
+ readonly reason: CrawlAdmissionReason
200
+ }
201
+
202
+ /** The canonical source configuration a crawl was created with. */
203
+ export interface CrawlConfigSummary {
204
+ readonly sources: readonly {
205
+ readonly url: string
206
+ readonly kind: 'seed' | 'sitemap'
207
+ readonly include: readonly string[]
208
+ readonly exclude: readonly string[]
209
+ readonly maxPages?: number
210
+ readonly maxDepth: number
211
+ }[]
212
+ /** How the include and exclude entries are read: RE2 searched in the path, or path globs. */
213
+ readonly patternSyntax: 're2' | 'glob'
214
+ readonly formats: readonly ('markdown' | 'stateJson' | 'html')[]
215
+ readonly maxPages: number
216
+ readonly changePolicy: { readonly ignore: readonly string[]; readonly volatile: readonly string[] }
217
+ }
218
+
219
+ /** The most recent run of a crawl, whatever its outcome. */
220
+ export interface CrawlRunSummary {
221
+ readonly kind: 'INITIAL' | 'MANUAL' | 'DISCOVERY'
222
+ readonly status: 'RUNNING' | 'DONE' | 'FAILED' | 'CANCELED'
223
+ /** ISO date-time. */
224
+ readonly startedAt: string
225
+ /** ISO date-time; absent while the run is still going. */
226
+ readonly completedAt?: string
227
+ /** `stalled` when the run was stopped because nothing settled for the stall timeout (an hour). */
228
+ readonly endReason?: 'stalled'
229
+ }
230
+
231
+ export interface CrawlStatus {
232
+ readonly id: CrawlId
233
+ readonly state: 'crawling' | 'done' | 'syncing' | 'failed' | 'paused' | 'canceled'
234
+ /** ISO date-time the crawl was created. */
235
+ readonly createdAt?: string
236
+ /** Registrable domain the crawl is scoped to. */
237
+ readonly domain?: string
238
+ /** The crawl's label; absent when it is shown by its domain. */
239
+ readonly name?: string
240
+ /** ISO date-time the crawl was archived. Archived crawls are canceled and left out of `list()`. */
241
+ readonly archivedAt?: string
242
+ /** ISO date-time retention deletes an archived crawl, unless it is unarchived first. */
243
+ readonly deletesAt?: string
244
+ readonly pages: number
245
+ readonly failed: number
246
+ /** How many of `failed` were skips: URLs given up after one attempt so their slot could move on. */
247
+ readonly skipped?: number
248
+ readonly config?: CrawlConfigSummary
249
+ readonly run?: CrawlRunSummary
250
+ readonly sync?: CrawlSyncPolicy
251
+ readonly freshness?: {
252
+ readonly pagesPastDue: number
253
+ readonly maxOverdue: string
254
+ readonly probeMisses: number
255
+ readonly causes: readonly { readonly kind: string; readonly pages: number }[]
256
+ }
257
+ readonly coverage: {
258
+ readonly complete: boolean
259
+ readonly pendingAtCap: number
260
+ readonly sourcePartial: number
261
+ readonly causes: readonly string[]
262
+ }
263
+ readonly progress: {
264
+ readonly queued: number
265
+ readonly inFlight: number
266
+ readonly done: number
267
+ /** Page work alone; `queued` and `inFlight` also count robots and sitemap operations. */
268
+ readonly pages?: { readonly queued: number; readonly inFlight: number; readonly skipped?: number }
269
+ }
270
+ /** Only meaningful while work is queued: why nothing is being rendered right now. */
271
+ readonly admission?: CrawlAdmission
272
+ /** Trailing 24 hours; `budgetPerDay` is null when the sync policy sets no render budget. */
273
+ readonly renders?: { readonly usedToday: number; readonly budgetPerDay: number | null }
274
+ /** Renders settled in each of the last 60 minutes, oldest first, and the latest settle time. Single-crawl status only. */
275
+ readonly throughput?: { readonly perMinute: ReadonlyArray<number>; readonly lastSettledAt?: string }
276
+ readonly offset: CrawlOffset
277
+ /** The activity stream's tail; absent for a crawl that has no activity yet. */
278
+ readonly activityOffset?: CrawlOffset
279
+ }
280
+
281
+ export interface CrawlReadOptions {
282
+ readonly from?: CrawlOffset
283
+ readonly live?: boolean
284
+ readonly signal?: AbortSignal
285
+ }
286
+
287
+ export interface MergedCrawlReadOptions {
288
+ readonly from?: Readonly<Record<CrawlId, CrawlOffset>>
289
+ readonly live?: boolean
290
+ readonly signal?: AbortSignal
291
+ }
292
+
293
+ export interface CrawlListOptions {
294
+ /** 1..200, defaults to 50. */
295
+ readonly limit?: number
296
+ /** Lists archived crawls instead of the rest. */
297
+ readonly archived?: boolean
298
+ readonly signal?: AbortSignal
299
+ }
300
+
301
+ export interface CrawlListResponse {
302
+ readonly crawls: CrawlStatus[]
303
+ }
304
+
305
+ export interface CrawlCreateOptions {
306
+ readonly idempotencyKey: string
307
+ }
308
+
309
+ export interface CrawlRunOptions {
310
+ readonly idempotencyKey: string
311
+ /** Recover an existing command, or fail without creating a replacement run. */
312
+ readonly replayOnly?: boolean
313
+ }
314
+
315
+ export interface CrawlDoneResult {
316
+ readonly lastOffset: CrawlOffset
317
+ readonly status: CrawlStatus
318
+ }
319
+
320
+ export class CrawlClientError extends ExpandClientError {
321
+ readonly name: string = 'CrawlClientError'
322
+ constructor(
323
+ message: string,
324
+ readonly code?: string,
325
+ readonly status?: number,
326
+ readonly retryable = false,
327
+ ) {
328
+ super(message)
329
+ }
330
+ }
331
+
332
+ /** API refusal with the Crawl error code and original response envelope. */
333
+ export class CrawlClientApiError extends ExpandClientApiError {
334
+ constructor(
335
+ message: string,
336
+ readonly code: string | undefined,
337
+ status: number,
338
+ readonly retryable = false,
339
+ body?: unknown,
340
+ ) {
341
+ super(message, { status, body })
342
+ this.name = 'CrawlClientApiError'
343
+ }
344
+ }
345
+
346
+ export class CrawlOffsetExpiredError extends CrawlClientApiError {
347
+ readonly name: string = 'CrawlOffsetExpiredError'
348
+ }
349
+
350
+ interface CrawlTransportOptions {
351
+ readonly apiKey: string
352
+ readonly baseUrl: string
353
+ readonly fetch?: typeof globalThis.fetch
354
+ readonly timeoutMs?: number
355
+ readonly maxRetries?: number
356
+ readonly signal?: AbortSignal
357
+ }
358
+
359
+ function transportSignal(transport: CrawlTransportOptions, signal?: AbortSignal): AbortSignal | undefined {
360
+ return transport.signal === undefined
361
+ ? signal
362
+ : AbortSignal.any([transport.signal, ...(signal === undefined ? [] : [signal])])
363
+ }
364
+
365
+ export interface CrawlProjectionOptions {
366
+ readonly signal?: AbortSignal
367
+ readonly timeoutMs?: number
368
+ }
369
+
370
+ export interface CrawlListPage<T> {
371
+ readonly items: readonly T[]
372
+ readonly nextCursor?: string
373
+ }
374
+
375
+ /** Await one page, iterate individual items, or use pages() for bounded batches. */
376
+ export class CrawlList<T> implements AsyncIterable<T>, PromiseLike<CrawlListPage<T>> {
377
+ private firstPage?: Promise<CrawlListPage<T>>
378
+ constructor(
379
+ private readonly fetchPage: (cursor?: string) => Promise<CrawlListPage<T>>,
380
+ private readonly cursor?: string,
381
+ private readonly signal?: AbortSignal,
382
+ ) {}
383
+
384
+ private first(): Promise<CrawlListPage<T>> {
385
+ if (this.signal?.aborted === true) return Promise.reject(abortError(this.signal))
386
+ this.firstPage ??= this.fetchPage(this.cursor)
387
+ return this.firstPage
388
+ }
389
+
390
+ then<TResult1 = CrawlListPage<T>, TResult2 = never>(
391
+ onfulfilled?: ((value: CrawlListPage<T>) => TResult1 | PromiseLike<TResult1>) | null,
392
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
393
+ ): PromiseLike<TResult1 | TResult2> {
394
+ return this.first().then(onfulfilled, onrejected)
395
+ }
396
+
397
+ async *pages(): AsyncGenerator<CrawlListPage<T>> {
398
+ let page = await this.first()
399
+ const visited = new Set<string>(this.cursor === undefined ? [] : [this.cursor])
400
+ while (true) {
401
+ if (signalIsAborted(this.signal)) throw abortError(this.signal)
402
+ yield page
403
+ if (page.nextCursor === undefined) return
404
+ if (visited.has(page.nextCursor)) throw new CrawlClientError('The server repeated a pagination cursor.')
405
+ visited.add(page.nextCursor)
406
+ page = await this.fetchPage(page.nextCursor)
407
+ }
408
+ }
409
+
410
+ async *[Symbol.asyncIterator](): AsyncGenerator<T> {
411
+ for await (const page of this.pages())
412
+ for (const item of page.items) {
413
+ if (signalIsAborted(this.signal)) throw abortError(this.signal)
414
+ yield item
415
+ }
416
+ }
417
+
418
+ async collect(options: { readonly limit: number }): Promise<T[]> {
419
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1)
420
+ throw new CrawlClientError('collect limit must be positive.')
421
+ const items: T[] = []
422
+ for await (const item of this) {
423
+ items.push(item)
424
+ if (items.length >= options.limit) break
425
+ }
426
+ return items
427
+ }
428
+ }
429
+
430
+ /** A discovered URL in any admission state (`GET /v1/crawls/:id/urls`), the snapshot half of the URL view. */
431
+ export interface CrawlUrl {
432
+ readonly id: string
433
+ readonly url: string
434
+ readonly state: 'pending' | 'candidate' | 'reserved' | 'admitted' | 'traversal'
435
+ readonly sourceId: string
436
+ readonly depth: number
437
+ readonly discoveredBy: 'seed' | 'sitemap' | 'link' | 'search'
438
+ /** ISO date-time. */
439
+ readonly discoveredAt: string
440
+ /** Apply an activity event to this row only when `event.offset > activitySeq`. */
441
+ readonly activitySeq: CrawlOffset
442
+ readonly pageId?: string
443
+ /** Why the URL is where it is (`canceled`, `out-of-scope`, `skipped`, …). */
444
+ readonly reason?: string
445
+ readonly excludedReason?: string
446
+ /** What the render told us when `reason` is `skipped` (`http-500`, `timeout`). */
447
+ readonly skipDetail?: string
448
+ /** ISO date-time: when a skipped URL may be rearmed by a later run. */
449
+ readonly availableAt?: string
450
+ }
451
+
452
+ export interface CrawlUrlListPage {
453
+ readonly urls: readonly CrawlUrl[]
454
+ readonly nextCursor?: string
455
+ readonly truncated: boolean
456
+ /** The activity stream's tail, read before the rows: tail `crawl.activity({ from })` from here. */
457
+ readonly activityOffset: CrawlOffset
458
+ /** Rows per state for the whole crawl under the same `source` and `search` filters, not just this page. */
459
+ readonly counts: Readonly<Record<string, number>>
460
+ }
461
+
462
+ export interface CrawlCatalogPage {
463
+ readonly id: string
464
+ readonly url: string
465
+ readonly status: 'live' | 'removed'
466
+ readonly latestCaptureId?: string
467
+ readonly changedAt?: string
468
+ }
469
+
470
+ export interface CrawlCurrentPage {
471
+ readonly id: string
472
+ readonly url: string
473
+ readonly status: 'live' | 'removed'
474
+ readonly captureId?: string
475
+ readonly meta?: CrawlPageMeta
476
+ readonly page?: CrawlPageContent
477
+ }
478
+
479
+ export interface CrawlArtifactLinks {
480
+ readonly markdown: string
481
+ readonly html: string
482
+ readonly stateJson: string
483
+ readonly screenshot?: { readonly url: string; readonly fullPage: boolean }
484
+ readonly replay: string
485
+ readonly playground: string
486
+ readonly assets: string
487
+ }
488
+
489
+ export interface CrawlPageVersion {
490
+ readonly captureId: string
491
+ readonly observedAt: string
492
+ readonly offset: CrawlOffset
493
+ readonly contentSha256: string
494
+ readonly http: { readonly status: number; readonly finalUrl: string }
495
+ readonly quality: 'complete' | 'best-effort' | 'degraded'
496
+ readonly meta: CrawlPageMeta
497
+ readonly artifacts: CrawlArtifactLinks
498
+ }
499
+
500
+ export interface CrawlCaptureContent {
501
+ readonly captureId: string
502
+ readonly meta: CrawlPageMeta
503
+ readonly page: CrawlPageContent
504
+ readonly artifacts: CrawlArtifactLinks
505
+ }
506
+
507
+ function projectionQuery(path: string, values: Readonly<Record<string, string | number | undefined>>): string {
508
+ const query = new URLSearchParams()
509
+ for (const [key, value] of Object.entries(values)) if (value !== undefined) query.set(key, String(value))
510
+ return query.size === 0 ? path : `${path}?${query}`
511
+ }
512
+
513
+ async function projectionResponse(
514
+ transport: CrawlTransportOptions,
515
+ path: string,
516
+ options: CrawlProjectionOptions,
517
+ ): Promise<Response> {
518
+ const target = new URL(absoluteUrl(transport.baseUrl, path))
519
+ const base = new URL(transport.baseUrl)
520
+ if (
521
+ target.origin !== base.origin ||
522
+ target.username !== '' ||
523
+ target.password !== '' ||
524
+ !target.pathname.startsWith('/v1/')
525
+ ) {
526
+ throw new CrawlClientError('Projection URLs must belong to the configured Expand API.')
527
+ }
528
+ const timeoutMs = options.timeoutMs ?? transport.timeoutMs ?? 60_000
529
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
530
+ throw new CrawlClientError('Projection timeoutMs must be a positive integer.')
531
+ const deadline = AbortSignal.timeout(timeoutMs)
532
+ const callerSignal = transportSignal(transport, options.signal)
533
+ const signal = callerSignal === undefined ? deadline : AbortSignal.any([callerSignal, deadline])
534
+ if (signal.aborted) throw abortError(signal)
535
+ const response = await (transport.fetch ?? globalThis.fetch)(target, {
536
+ headers: { 'x-expand-api-key': transport.apiKey },
537
+ signal,
538
+ redirect: 'error',
539
+ })
540
+ if (!response.ok) throw await apiError(response)
541
+ return response
542
+ }
543
+
544
+ async function projectionJson<T>(
545
+ transport: CrawlTransportOptions,
546
+ path: string,
547
+ options: CrawlProjectionOptions,
548
+ ): Promise<T> {
549
+ // Mirrors the typed API contract, like requestJson; arbitrary body reads use unknown.
550
+ return (await (await projectionResponse(transport, path, options)).json()) as unknown as T
551
+ }
552
+
553
+ export class CrawlCaptureResource {
554
+ constructor(
555
+ readonly id: string,
556
+ private readonly transport: CrawlTransportOptions,
557
+ ) {}
558
+ get(options: CrawlProjectionOptions = {}): Promise<CrawlCaptureContent> {
559
+ return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}`, options)
560
+ }
561
+ stateJson(options: CrawlProjectionOptions = {}): Promise<CrawlStateJson> {
562
+ return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/state-json`, options)
563
+ }
564
+ evidence(key: string, options: CrawlProjectionOptions = {}): Promise<CrawlJsonValue> {
565
+ return projectionJson(
566
+ this.transport,
567
+ `/v1/captures/${encodeURIComponent(this.id)}/state-json/${encodeURIComponent(key)}`,
568
+ options,
569
+ )
570
+ }
571
+ async markdown(options: CrawlProjectionOptions = {}): Promise<string> {
572
+ return (
573
+ await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/markdown`, options)
574
+ ).text()
575
+ }
576
+ async html(options: CrawlProjectionOptions = {}): Promise<string> {
577
+ return (
578
+ await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/html`, options)
579
+ ).text()
580
+ }
581
+ playgroundUrl(): string {
582
+ return `https://expand.land/s/${encodeURIComponent(this.id)}`
583
+ }
584
+ }
585
+
586
+ export class CrawlPageResource {
587
+ constructor(
588
+ readonly id: string,
589
+ private readonly transport: CrawlTransportOptions,
590
+ ) {}
591
+ versions(
592
+ options: CrawlProjectionOptions & { readonly limit?: number; readonly before?: string; readonly at?: string } = {},
593
+ ): CrawlList<CrawlPageVersion> {
594
+ return new CrawlList(
595
+ async (before) => {
596
+ const result = await projectionJson<{ readonly versions: CrawlPageVersion[]; readonly nextCursor?: string }>(
597
+ this.transport,
598
+ projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/versions`, {
599
+ before,
600
+ at: options.at,
601
+ limit: options.limit,
602
+ }),
603
+ options,
604
+ )
605
+ return { items: result.versions, nextCursor: options.at === undefined ? result.nextCursor : undefined }
606
+ },
607
+ options.before,
608
+ transportSignal(this.transport, options.signal),
609
+ )
610
+ }
611
+ diff(
612
+ options: CrawlProjectionOptions & { readonly from: string; readonly to: string },
613
+ ): Promise<CrawlPageChanged['diff']> {
614
+ return projectionJson(
615
+ this.transport,
616
+ projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/diff`, { from: options.from, to: options.to }),
617
+ options,
618
+ )
619
+ }
620
+ }
621
+
622
+ /** Shares the Crawl transport without exposing its API key to arbitrary body URLs. */
623
+ export function makeCrawlProjectionsClient(transport: CrawlTransportOptions) {
624
+ return {
625
+ captures(id: string): CrawlCaptureResource {
626
+ if (id.length === 0) throw new CrawlClientError('Capture id must not be empty.')
627
+ return new CrawlCaptureResource(id, transport)
628
+ },
629
+ pages(id: string): CrawlPageResource {
630
+ if (id.length === 0) throw new CrawlClientError('Page id must not be empty.')
631
+ return new CrawlPageResource(id, transport)
632
+ },
633
+ fetchJson(url: string, options: CrawlProjectionOptions = {}): Promise<unknown> {
634
+ return projectionJson(transport, url, options)
635
+ },
636
+ }
637
+ }
638
+
639
+ interface CreatedCrawlResponse {
640
+ readonly id: CrawlId
641
+ }
642
+
643
+ function requireIdempotencyKey(value: string | undefined, operation: string): string {
644
+ if (value === undefined || value.length < 1 || value.length > 255) {
645
+ throw new CrawlClientError(`${operation} requires an idempotencyKey between 1 and 255 characters.`)
646
+ }
647
+ return value
648
+ }
649
+
650
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
651
+ return typeof value === 'object' && value !== null
652
+ }
653
+
654
+ async function apiError(response: Response): Promise<CrawlClientApiError> {
655
+ let body: unknown
656
+ try {
657
+ body = await response.json()
658
+ } catch {
659
+ // A status and status text are still useful when a proxy returned non-JSON.
660
+ }
661
+ const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined
662
+ const code = typeof error?.['code'] === 'string' ? error['code'] : undefined
663
+ const message = typeof error?.['message'] === 'string' ? error['message'] : response.statusText
664
+ const retryable = error?.['retryable'] === true
665
+ return response.status === 410
666
+ ? new CrawlOffsetExpiredError(message, code, response.status, retryable, body)
667
+ : new CrawlClientApiError(message, code, response.status, retryable, body)
668
+ }
669
+
670
+ function absoluteUrl(baseUrl: string, path: string): string {
671
+ return new URL(path, `${baseUrl.replace(/\/+$/u, '')}/`).toString()
672
+ }
673
+
674
+ interface CrawlStreamRequest {
675
+ readonly signal: AbortSignal
676
+ readonly redirectSignal: AbortSignal
677
+ readonly fetch: typeof globalThis.fetch
678
+ }
679
+
680
+ const CRAWL_STREAM_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
681
+ // durable-streams 0.2.6 cannot interrupt its retry timer. Retries belong to
682
+ // the transport below, which keeps both setup and backoff tied to the caller.
683
+ const CRAWL_STREAM_BACKOFF = { ...BackoffDefaults, maxRetries: 0 }
684
+
685
+ // The same ceiling the Effect client applies to `Retry-After`: a live iterator must not sleep
686
+ // for an hour because one response asked for it.
687
+ const MAX_RETRY_AFTER_MS = Duration.toMillis(MAX_RETRY_AFTER)
688
+
689
+ function retryAfterMilliseconds(error: unknown): number {
690
+ if (!(error instanceof FetchError)) return 0
691
+ const value = error.headers['retry-after']
692
+ if (value === undefined) return 0
693
+ const seconds = Number(value)
694
+ if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS)
695
+ const date = Date.parse(value)
696
+ return Number.isFinite(date) ? Math.max(0, Math.min(date - Date.now(), MAX_RETRY_AFTER_MS)) : 0
697
+ }
698
+
699
+ function isDeadlineReason(reason: unknown): boolean {
700
+ return reason instanceof Error && reason.name === 'TimeoutError'
701
+ }
702
+
703
+ function crawlTimeoutMs(transport: CrawlTransportOptions): number {
704
+ const timeoutMs = transport.timeoutMs ?? 60_000
705
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
706
+ throw new CrawlClientError('Crawl timeoutMs must be a positive integer no greater than 2147483647.')
707
+ return timeoutMs
708
+ }
709
+
710
+ function streamError(error: unknown, timeoutMs: number): ExpandClientError {
711
+ if (error instanceof ExpandClientError) return error
712
+ if (error instanceof FetchError) {
713
+ const body: unknown = error.json ?? error.text
714
+ const envelope = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined
715
+ const code = typeof envelope?.['code'] === 'string' ? envelope['code'] : undefined
716
+ const message = typeof envelope?.['message'] === 'string' ? envelope['message'] : error.message
717
+ const retryable =
718
+ envelope?.['retryable'] === true ||
719
+ (envelope?.['retryable'] === undefined && (error.status === 408 || error.status === 429 || error.status >= 500))
720
+ return error.status === 410
721
+ ? new CrawlOffsetExpiredError(message, code ?? 'offset_expired', error.status, retryable, body)
722
+ : new CrawlClientApiError(message, code, error.status, retryable, body)
723
+ }
724
+ if (error instanceof Error && error.name === 'TimeoutError')
725
+ return new ExpandClientTimeoutError(`Request timed out after ${timeoutMs}ms`, { timeoutMs, cause: error })
726
+ return new ExpandClientConnectionError(error instanceof Error ? error.message : 'Crawl request failed.', {
727
+ cause: error,
728
+ })
729
+ }
730
+
731
+ function createCrawlStreamRequest(
732
+ transport: CrawlTransportOptions,
733
+ callerSignal?: AbortSignal,
734
+ live = false,
735
+ ): CrawlStreamRequest {
736
+ const redirect = new AbortController()
737
+ const execute = transport.fetch ?? globalThis.fetch
738
+ const timeoutMs = crawlTimeoutMs(transport)
739
+ const maxRetries = transport.maxRetries ?? 2
740
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0)
741
+ throw new CrawlClientError('Crawl maxRetries must be a non-negative integer.')
742
+ const signal = AbortSignal.any([
743
+ redirect.signal,
744
+ ...(callerSignal === undefined ? [] : [callerSignal]),
745
+ ...(live ? [] : [AbortSignal.timeout(timeoutMs)]),
746
+ ])
747
+ return {
748
+ signal,
749
+ redirectSignal: redirect.signal,
750
+ fetch: async (input, init) => {
751
+ const requestSignal = init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
752
+ let delay = BackoffDefaults.initialDelay
753
+ let retries = 0
754
+ while (true) {
755
+ if (requestSignal.aborted) throw abortError(requestSignal)
756
+ try {
757
+ const response = await execute(input, { ...init, signal: requestSignal, redirect: 'manual' })
758
+ if (CRAWL_STREAM_REDIRECT_STATUSES.has(response.status) || response.type === 'opaqueredirect') {
759
+ const error = new CrawlClientError(
760
+ 'Authenticated Crawl requests cannot redirect.',
761
+ 'redirect_not_allowed',
762
+ response.status || undefined,
763
+ false,
764
+ )
765
+ // Keep policy failures out of transport and iterator reconnect loops.
766
+ redirect.abort(error)
767
+ try {
768
+ await response.body?.cancel()
769
+ } catch {
770
+ // Aborting the request may already have canceled its body.
771
+ }
772
+ throw error
773
+ }
774
+ if (!response.ok) throw await FetchError.fromResponse(response, String(input))
775
+ return response
776
+ } catch (error: unknown) {
777
+ if (requestSignal.aborted) throw abortError(requestSignal)
778
+ if (!isRetryable(streamError(error, timeoutMs), timeoutMs)) throw error
779
+ if (!live && retries >= maxRetries) throw error
780
+ retries += 1
781
+ try {
782
+ await abortableDelay(Math.max(retryAfterMilliseconds(error), Math.random() * delay), requestSignal)
783
+ } catch (abort: unknown) {
784
+ // The request deadline expired while waiting to retry. The refusal being retried
785
+ // explains the failure; a timeout is reserved for an attempt that was in flight.
786
+ if (!live && isDeadlineReason(requestSignal.reason)) throw streamError(error, timeoutMs)
787
+ throw abort
788
+ }
789
+ delay = Math.min(delay * BackoffDefaults.multiplier, BackoffDefaults.maxDelay)
790
+ }
791
+ }
792
+ },
793
+ }
794
+ }
795
+
796
+ async function requestJson<T>(transport: CrawlTransportOptions, path: string, init: RequestInit = {}): Promise<T> {
797
+ const timeoutMs = crawlTimeoutMs(transport)
798
+ const callerSignal = transportSignal(transport, init.signal ?? undefined)
799
+ const request = createCrawlStreamRequest(transport, callerSignal)
800
+ const signal = request.signal
801
+ if (signal.aborted) throw abortError(signal)
802
+ try {
803
+ const response = await request.fetch(absoluteUrl(transport.baseUrl, path), {
804
+ ...init,
805
+ redirect: 'error',
806
+ signal,
807
+ headers: {
808
+ 'x-expand-api-key': transport.apiKey,
809
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
810
+ ...init.headers,
811
+ },
812
+ })
813
+ if (!response.ok) throw await apiError(response)
814
+ // The SDK's public response interfaces mirror the versioned HTTP contract.
815
+ return (await response.json()) as unknown as T
816
+ } catch (error: unknown) {
817
+ // A typed transport failure already names the cause, even when the deadline has since fired.
818
+ if (error instanceof ExpandClientError) throw error
819
+ throw streamError(signal.aborted ? abortError(signal) : error, timeoutMs)
820
+ }
821
+ }
822
+
823
+ function abortError(signal?: AbortSignal): Error {
824
+ return signal?.reason instanceof Error ? signal.reason : new CrawlClientError('The Crawl operation was aborted.')
825
+ }
826
+
827
+ function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise<void> {
828
+ return new Promise((resolve, reject) => {
829
+ if (signal?.aborted === true) {
830
+ reject(abortError(signal))
831
+ return
832
+ }
833
+ const onAbort = () => {
834
+ clearTimeout(timer)
835
+ signal?.removeEventListener('abort', onAbort)
836
+ reject(abortError(signal))
837
+ }
838
+ const timer = setTimeout(() => {
839
+ signal?.removeEventListener('abort', onAbort)
840
+ resolve()
841
+ }, milliseconds)
842
+ signal?.addEventListener('abort', onAbort, { once: true })
843
+ })
844
+ }
845
+
846
+ function signalIsAborted(signal?: AbortSignal): boolean {
847
+ return signal?.aborted === true
848
+ }
849
+
850
+ const CRAWL_ITERATOR_RETURN = Symbol('crawl-iterator-return')
851
+ const CRAWL_ITERATOR_ABORT = Symbol('crawl-iterator-abort')
852
+
853
+ function iteratorWasCanceled(signal?: AbortSignal): boolean {
854
+ return signal?.reason === CRAWL_ITERATOR_RETURN || signal?.reason === CRAWL_ITERATOR_ABORT
855
+ }
856
+
857
+ export interface CrawlStreamPage<T> {
858
+ readonly crawlId: CrawlId
859
+ readonly items: readonly T[]
860
+ readonly nextOffset: CrawlOffset
861
+ readonly upToDate: boolean
862
+ readonly streamClosed: boolean
863
+ }
864
+
865
+ export type CrawlEventPage = CrawlStreamPage<CrawlEvent>
866
+ export type CrawlActivityPage = CrawlStreamPage<CrawlActivityEvent>
867
+
868
+ class CrawlIterable<T> implements AsyncIterable<T> {
869
+ constructor(
870
+ private readonly factory: (signal: AbortSignal) => AsyncGenerator<T>,
871
+ protected readonly externalSignal?: AbortSignal,
872
+ ) {}
873
+
874
+ [Symbol.asyncIterator](): AsyncIterator<T> {
875
+ const controller = new AbortController()
876
+ const abort = () => controller.abort(CRAWL_ITERATOR_ABORT)
877
+ if (this.externalSignal?.aborted === true) abort()
878
+ else this.externalSignal?.addEventListener('abort', abort, { once: true })
879
+ const iterator = this.factory(controller.signal)
880
+ const cleanup = () => this.externalSignal?.removeEventListener('abort', abort)
881
+ return {
882
+ next: async () => {
883
+ try {
884
+ const result = await iterator.next()
885
+ if (result.done) cleanup()
886
+ return result
887
+ } catch (error: unknown) {
888
+ cleanup()
889
+ if (iteratorWasCanceled(controller.signal)) return { done: true, value: undefined }
890
+ throw error
891
+ }
892
+ },
893
+ return: async (value?: unknown) => {
894
+ controller.abort(CRAWL_ITERATOR_RETURN)
895
+ cleanup()
896
+ return (await iterator.return?.(value)) ?? { done: true, value: undefined }
897
+ },
898
+ throw: async (error?: unknown) => {
899
+ controller.abort(error)
900
+ cleanup()
901
+ if (iterator.throw === undefined) throw error
902
+ return iterator.throw(error)
903
+ },
904
+ }
905
+ }
906
+
907
+ async collect(options: { readonly limit: number }): Promise<T[]> {
908
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1)
909
+ throw new CrawlClientError('collect limit must be positive.')
910
+ const events: T[] = []
911
+ for await (const event of this) {
912
+ events.push(event)
913
+ if (events.length >= options.limit) break
914
+ }
915
+ return events
916
+ }
917
+ }
918
+
919
+ export class CrawlStreamPageIterable<T>
920
+ extends CrawlIterable<CrawlStreamPage<T>>
921
+ implements PromiseLike<CrawlStreamPage<T> | undefined>
922
+ {
923
+ then<TResult1 = CrawlStreamPage<T> | undefined, TResult2 = never>(
924
+ onfulfilled?: ((page: CrawlStreamPage<T> | undefined) => TResult1 | PromiseLike<TResult1>) | null,
925
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
926
+ ): PromiseLike<TResult1 | TResult2> {
927
+ const first = async () => {
928
+ const iterator = this[Symbol.asyncIterator]()
929
+ try {
930
+ const result = await iterator.next()
931
+ return result.done ? undefined : result.value
932
+ } finally {
933
+ await iterator.return?.()
934
+ }
935
+ }
936
+ return first().then(onfulfilled, onrejected)
937
+ }
938
+ }
939
+
940
+ export class CrawlStreamIterable<T> extends CrawlIterable<T> implements PromiseLike<CrawlStreamPage<T> | undefined> {
941
+ constructor(
942
+ factory: (signal: AbortSignal) => AsyncGenerator<T>,
943
+ private readonly pageFactory: (signal: AbortSignal) => AsyncGenerator<CrawlStreamPage<T>>,
944
+ signal?: AbortSignal,
945
+ ) {
946
+ super(factory, signal)
947
+ }
948
+ pages(): CrawlStreamPageIterable<T> {
949
+ return new CrawlStreamPageIterable(this.pageFactory, this.externalSignal)
950
+ }
951
+ then<TResult1 = CrawlStreamPage<T> | undefined, TResult2 = never>(
952
+ onfulfilled?: ((page: CrawlStreamPage<T> | undefined) => TResult1 | PromiseLike<TResult1>) | null,
953
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
954
+ ): PromiseLike<TResult1 | TResult2> {
955
+ return this.pages().then(onfulfilled, onrejected)
956
+ }
957
+ }
958
+
959
+ export class CrawlEventPageIterable extends CrawlStreamPageIterable<CrawlEvent> {}
960
+ export class CrawlEventIterable extends CrawlStreamIterable<CrawlEvent> {}
961
+ export class CrawlActivityPageIterable extends CrawlStreamPageIterable<CrawlActivityEvent> {}
962
+ export class CrawlActivityIterable extends CrawlStreamIterable<CrawlActivityEvent> {}
963
+
964
+ /** Which per-crawl stream a read tails. */
965
+ interface CrawlStreamSpec {
966
+ readonly path: 'events' | 'activity'
967
+ }
968
+
969
+ const PAGE_STREAM: CrawlStreamSpec = { path: 'events' }
970
+ const ACTIVITY_STREAM: CrawlStreamSpec = { path: 'activity' }
971
+
972
+ export class CrawlResource {
973
+ constructor(
974
+ readonly id: CrawlId,
975
+ private readonly transport: CrawlTransportOptions,
976
+ ) {}
977
+
978
+ events(options: CrawlReadOptions = {}): CrawlEventIterable {
979
+ return new CrawlEventIterable(
980
+ (signal) => this.readStream<CrawlEvent>(PAGE_STREAM, { ...options, signal }),
981
+ (signal) => this.readStreamPages<CrawlEvent>(PAGE_STREAM, { ...options, signal }),
982
+ transportSignal(this.transport, options.signal),
983
+ )
984
+ }
985
+
986
+ /**
987
+ * The activity stream: batches of URL transitions without content, for progress and
988
+ * operator views. Same shape as `events`; a one-off's iteration ends on stream closure.
989
+ */
990
+ activity(options: CrawlReadOptions = {}): CrawlActivityIterable {
991
+ return new CrawlActivityIterable(
992
+ (signal) => this.readStream<CrawlActivityEvent>(ACTIVITY_STREAM, { ...options, signal }),
993
+ (signal) => this.readStreamPages<CrawlActivityEvent>(ACTIVITY_STREAM, { ...options, signal }),
994
+ transportSignal(this.transport, options.signal),
995
+ )
996
+ }
997
+
998
+ /** A pinned live-catalog snapshot. Resume /events from the final page's nextOffset. */
999
+ snapshot(options: CrawlProjectionOptions & { readonly from?: string } = {}): CrawlEventIterable {
1000
+ return new CrawlEventIterable(
1001
+ (signal) => this.readSnapshotItems({ ...options, signal }),
1002
+ (signal) => this.readSnapshotPages({ ...options, signal }),
1003
+ transportSignal(this.transport, options.signal),
1004
+ )
1005
+ }
1006
+
1007
+ private async *readSnapshotItems(
1008
+ options: CrawlProjectionOptions & { readonly from?: string },
1009
+ ): AsyncGenerator<CrawlEvent> {
1010
+ for await (const page of this.readSnapshotPages(options)) {
1011
+ for (const event of page.items) yield event
1012
+ }
1013
+ }
1014
+
1015
+ private async *readSnapshotPages(
1016
+ options: CrawlProjectionOptions & { readonly from?: string },
1017
+ ): AsyncGenerator<CrawlEventPage> {
1018
+ let offset = options.from
1019
+ while (!signalIsAborted(options.signal)) {
1020
+ const page = await projectionJson<{
1021
+ readonly events: readonly CrawlEvent[]
1022
+ readonly nextOffset: CrawlOffset
1023
+ readonly upToDate: boolean
1024
+ }>(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/snapshot`, { offset }), options)
1025
+ yield {
1026
+ crawlId: this.id,
1027
+ items: page.events,
1028
+ nextOffset: page.nextOffset,
1029
+ upToDate: page.upToDate,
1030
+ streamClosed: false,
1031
+ }
1032
+ if (page.upToDate) return
1033
+ if (page.nextOffset === offset) throw new CrawlClientError('Snapshot cursor did not advance.')
1034
+ offset = page.nextOffset
1035
+ }
1036
+ }
1037
+
1038
+ private async *readStreamPages<T extends { readonly offset: CrawlOffset }>(
1039
+ spec: CrawlStreamSpec,
1040
+ options: CrawlReadOptions,
1041
+ ): AsyncGenerator<CrawlStreamPage<T>> {
1042
+ let offset = options.from ?? '-1'
1043
+ let caughtUp = false
1044
+ let cursor: string | undefined
1045
+ let recurring: boolean | undefined
1046
+ while (!signalIsAborted(options.signal)) {
1047
+ const url = new URL(absoluteUrl(this.transport.baseUrl, `/v1/crawls/${encodeURIComponent(this.id)}/${spec.path}`))
1048
+ if (caughtUp) url.searchParams.set('live', 'long-poll')
1049
+ if (cursor !== undefined) url.searchParams.set('cursor', cursor)
1050
+ const request = createCrawlStreamRequest(this.transport, options.signal, options.live === true)
1051
+ let page: JsonBatch<T>
1052
+ try {
1053
+ const response = await durableStream<T>({
1054
+ url: url.toString(),
1055
+ headers: { 'x-expand-api-key': this.transport.apiKey },
1056
+ offset,
1057
+ live: false,
1058
+ json: true,
1059
+ signal: request.signal,
1060
+ fetch: request.fetch,
1061
+ backoffOptions: CRAWL_STREAM_BACKOFF,
1062
+ })
1063
+ page = await readStreamPage(response)
1064
+ if (request.redirectSignal.aborted) throw abortError(request.redirectSignal)
1065
+ } catch (error: unknown) {
1066
+ if (iteratorWasCanceled(options.signal)) return
1067
+ if (request.redirectSignal.aborted) throw abortError(request.redirectSignal)
1068
+ const retryable = isRetryable(
1069
+ streamError(error, crawlTimeoutMs(this.transport)),
1070
+ crawlTimeoutMs(this.transport),
1071
+ )
1072
+ if (options.live === true && retryable) {
1073
+ await abortableDelay(500, options.signal)
1074
+ continue
1075
+ }
1076
+ throw streamError(request.signal.aborted ? abortError(request.signal) : error, crawlTimeoutMs(this.transport))
1077
+ }
1078
+ if (signalIsAborted(options.signal)) return
1079
+ offset = page.offset
1080
+ cursor = page.cursor
1081
+ caughtUp ||= page.upToDate
1082
+ yield {
1083
+ crawlId: this.id,
1084
+ items: page.items,
1085
+ nextOffset: offset,
1086
+ upToDate: page.upToDate,
1087
+ streamClosed: page.streamClosed,
1088
+ }
1089
+ if (page.streamClosed) return
1090
+ if (!page.upToDate || options.live === true) continue
1091
+ // Recurring reads default to catch-up. One-off completion is signaled only by EOF;
1092
+ // offsets are opaque and are never compared to an out-of-band watermark.
1093
+ if (recurring === undefined) recurring = (await this.status({ signal: options.signal })).sync !== undefined
1094
+ if (recurring) return
1095
+ }
1096
+ }
1097
+
1098
+ pages(
1099
+ options: CrawlProjectionOptions & {
1100
+ readonly limit?: number
1101
+ readonly cursor?: string
1102
+ readonly prefix?: string
1103
+ readonly status?: 'live' | 'removed'
1104
+ readonly changedSince?: string
1105
+ } = {},
1106
+ ): CrawlList<CrawlCatalogPage> {
1107
+ return new CrawlList(
1108
+ async (cursor) => {
1109
+ const result = await projectionJson<{ readonly pages: CrawlCatalogPage[]; readonly nextCursor?: string }>(
1110
+ this.transport,
1111
+ projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages`, {
1112
+ cursor,
1113
+ limit: options.limit,
1114
+ prefix: options.prefix,
1115
+ status: options.status,
1116
+ changedSince: options.changedSince,
1117
+ }),
1118
+ options,
1119
+ )
1120
+ return { items: result.pages, nextCursor: result.nextCursor }
1121
+ },
1122
+ options.cursor,
1123
+ transportSignal(this.transport, options.signal),
1124
+ )
1125
+ }
1126
+
1127
+ /**
1128
+ * Every discovered URL in any admission state, cursor-paginated and filterable by state,
1129
+ * source and a URL substring (`search`, matched literally and case-insensitively across the
1130
+ * whole crawl). The first page's `activityOffset` is where to start an activity tail that
1131
+ * keeps the snapshot current: apply an event to a URL only when `event.offset > url.activitySeq`.
1132
+ */
1133
+ urls(
1134
+ options: CrawlProjectionOptions & {
1135
+ readonly limit?: number
1136
+ readonly cursor?: string
1137
+ readonly state?: CrawlUrl['state']
1138
+ readonly source?: string
1139
+ readonly search?: string
1140
+ } = {},
1141
+ ): CrawlList<CrawlUrl> {
1142
+ return new CrawlList(
1143
+ async (cursor) => {
1144
+ const result = await projectionJson<CrawlUrlListPage>(
1145
+ this.transport,
1146
+ projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
1147
+ cursor,
1148
+ limit: options.limit,
1149
+ state: options.state,
1150
+ source: options.source,
1151
+ search: options.search,
1152
+ }),
1153
+ options,
1154
+ )
1155
+ return { items: result.urls, nextCursor: result.nextCursor }
1156
+ },
1157
+ options.cursor,
1158
+ transportSignal(this.transport, options.signal),
1159
+ )
1160
+ }
1161
+
1162
+ /** One page of the discovered-URLs view with its counts and activity offset, for callers that need the envelope. */
1163
+ urlsPage(
1164
+ options: CrawlProjectionOptions & {
1165
+ readonly limit?: number
1166
+ readonly cursor?: string
1167
+ readonly state?: CrawlUrl['state']
1168
+ readonly source?: string
1169
+ readonly search?: string
1170
+ } = {},
1171
+ ): Promise<CrawlUrlListPage> {
1172
+ return projectionJson<CrawlUrlListPage>(
1173
+ this.transport,
1174
+ projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
1175
+ cursor: options.cursor,
1176
+ limit: options.limit,
1177
+ state: options.state,
1178
+ source: options.source,
1179
+ search: options.search,
1180
+ }),
1181
+ options,
1182
+ )
1183
+ }
1184
+
1185
+ page(id: string, options: CrawlProjectionOptions = {}): Promise<CrawlCurrentPage> {
1186
+ return projectionJson(
1187
+ this.transport,
1188
+ `/v1/crawls/${encodeURIComponent(this.id)}/pages/${encodeURIComponent(id)}`,
1189
+ options,
1190
+ )
1191
+ }
1192
+
1193
+ pageByUrl(url: string, options: CrawlProjectionOptions = {}): Promise<CrawlCurrentPage> {
1194
+ return projectionJson(
1195
+ this.transport,
1196
+ projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages/by-url`, { url }),
1197
+ options,
1198
+ )
1199
+ }
1200
+
1201
+ private async *readStream<T extends { readonly offset: CrawlOffset }>(
1202
+ spec: CrawlStreamSpec,
1203
+ options: CrawlReadOptions,
1204
+ ): AsyncGenerator<T> {
1205
+ for await (const page of this.readStreamPages<T>(spec, options)) {
1206
+ for (const event of page.items) {
1207
+ if (signalIsAborted(options.signal)) return
1208
+ yield event
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ status(options: { readonly signal?: AbortSignal } = {}): Promise<CrawlStatus> {
1214
+ return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
1215
+ signal: options.signal,
1216
+ })
1217
+ }
1218
+
1219
+ async done(
1220
+ options: { readonly signal?: AbortSignal; readonly pollIntervalMs?: number } = {},
1221
+ ): Promise<CrawlDoneResult> {
1222
+ if (
1223
+ options.pollIntervalMs !== undefined &&
1224
+ (!Number.isFinite(options.pollIntervalMs) || options.pollIntervalMs > 2_147_483_647)
1225
+ )
1226
+ throw new CrawlClientError('Crawl pollIntervalMs must be finite and no greater than 2147483647.')
1227
+ const interval = Math.max(50, options.pollIntervalMs ?? 500)
1228
+ const signal = transportSignal(this.transport, options.signal)
1229
+ while (!signalIsAborted(signal)) {
1230
+ const status = await this.status({ signal })
1231
+ if (signalIsAborted(signal)) break
1232
+ if (
1233
+ status.state === 'done' ||
1234
+ status.state === 'failed' ||
1235
+ status.state === 'canceled' ||
1236
+ (status.sync !== undefined && status.progress.queued === 0 && status.progress.inFlight === 0)
1237
+ ) {
1238
+ return { lastOffset: status.offset, status }
1239
+ }
1240
+ await abortableDelay(interval, signal)
1241
+ }
1242
+ const reason: unknown = signal?.reason
1243
+ throw reason instanceof Error ? reason : new CrawlClientError('Crawl.done was aborted.')
1244
+ }
1245
+
1246
+ update(patch: {
1247
+ readonly sync?: CrawlSyncPolicy | null
1248
+ readonly changePolicy?: CrawlChangePolicy
1249
+ readonly maxPages?: number
1250
+ /** `null` clears the name. A name can be changed even on a closed or archived crawl. */
1251
+ readonly name?: string | null
1252
+ }): Promise<CrawlStatus> {
1253
+ return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
1254
+ method: 'PATCH',
1255
+ body: JSON.stringify(patch),
1256
+ })
1257
+ }
1258
+
1259
+ cancel(): Promise<CrawlStatus> {
1260
+ return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/cancel`, {
1261
+ method: 'POST',
1262
+ })
1263
+ }
1264
+
1265
+ /** Cancels the crawl if it is live and hides it from `list()`. It is deleted 90 days later unless unarchived. */
1266
+ archive(): Promise<CrawlStatus> {
1267
+ return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/archive`, {
1268
+ method: 'POST',
1269
+ })
1270
+ }
1271
+
1272
+ /** Returns an archived crawl to `list()`, still canceled, and keeps it for at least 90 more days. */
1273
+ unarchive(): Promise<CrawlStatus> {
1274
+ return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/unarchive`, {
1275
+ method: 'POST',
1276
+ })
1277
+ }
1278
+
1279
+ run(options: CrawlRunOptions): Promise<CrawlStatus> {
1280
+ const idempotencyKey = requireIdempotencyKey(options?.idempotencyKey, 'Crawl.run')
1281
+ return requestJson<CrawlStatus>(
1282
+ this.transport,
1283
+ `/v1/crawls/${encodeURIComponent(this.id)}/runs${options.replayOnly ? '/replay' : ''}`,
1284
+ {
1285
+ method: 'POST',
1286
+ headers: {
1287
+ 'x-idempotency-key': idempotencyKey,
1288
+ },
1289
+ },
1290
+ )
1291
+ }
1292
+ }
1293
+
1294
+ export class MergedCrawlResource {
1295
+ constructor(private readonly crawls: readonly CrawlResource[]) {}
1296
+
1297
+ events(options: MergedCrawlReadOptions = {}): CrawlEventIterable {
1298
+ if (typeof options.from === 'string')
1299
+ throw new CrawlClientError('Merged Crawl reads require an offset map keyed by Crawl id.')
1300
+ return new CrawlEventIterable(
1301
+ (signal) => this.readItems({ ...options, signal }, (events) => events),
1302
+ (signal) => this.readItems({ ...options, signal }, (events) => events.pages()),
1303
+ options.signal,
1304
+ )
1305
+ }
1306
+
1307
+ private async *readItems<T>(
1308
+ options: MergedCrawlReadOptions,
1309
+ select: (events: CrawlEventIterable) => AsyncIterable<T>,
1310
+ ): AsyncGenerator<T> {
1311
+ if (signalIsAborted(options.signal)) return
1312
+ const controllers = this.crawls.map(() => new AbortController())
1313
+ const abortChildren = () => {
1314
+ for (const controller of controllers) controller.abort(options.signal?.reason)
1315
+ }
1316
+ options.signal?.addEventListener('abort', abortChildren, { once: true })
1317
+ const iterators = this.crawls.map((crawl, index) =>
1318
+ select(crawl.events({ from: options.from?.[crawl.id], live: options.live, signal: controllers[index]?.signal }))[
1319
+ Symbol.asyncIterator
1320
+ ](),
1321
+ )
1322
+ const pending = new Map<number, Promise<{ readonly index: number; readonly result: IteratorResult<T> }>>()
1323
+ for (const [index, iterator] of iterators.entries()) {
1324
+ pending.set(
1325
+ index,
1326
+ iterator.next().then((result) => ({ index, result })),
1327
+ )
1328
+ }
1329
+ try {
1330
+ while (pending.size > 0) {
1331
+ const next = await Promise.race(pending.values())
1332
+ if (next.result.done) {
1333
+ pending.delete(next.index)
1334
+ continue
1335
+ }
1336
+ yield next.result.value
1337
+ const iterator = iterators[next.index]
1338
+ if (iterator !== undefined)
1339
+ pending.set(
1340
+ next.index,
1341
+ iterator.next().then((result) => ({ index: next.index, result })),
1342
+ )
1343
+ }
1344
+ } finally {
1345
+ abortChildren()
1346
+ options.signal?.removeEventListener('abort', abortChildren)
1347
+ await Promise.allSettled(iterators.map((iterator) => iterator.return?.(undefined)))
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ /** Create, list and control crawls. */
1353
+ export interface CrawlsClient {
1354
+ (id: string): CrawlResource
1355
+ create(request: CrawlRequest, options: CrawlCreateOptions): Promise<CrawlResource[]>
1356
+ /** The organization's crawls, newest first, each with its current status. */
1357
+ list(options?: CrawlListOptions): Promise<CrawlListResponse>
1358
+ merge(crawls: readonly CrawlResource[]): MergedCrawlResource
1359
+ }
1360
+
1361
+ export function makeCrawlsClient(options: CrawlTransportOptions): CrawlsClient {
1362
+ function byId(id: string): CrawlResource {
1363
+ if (id.length === 0) throw new CrawlClientError('Crawl id must not be empty.')
1364
+ return new CrawlResource(id, options)
1365
+ }
1366
+ return Object.assign(byId, {
1367
+ async create(request: CrawlRequest, createOptions: CrawlCreateOptions): Promise<CrawlResource[]> {
1368
+ const idempotencyKey = requireIdempotencyKey(createOptions?.idempotencyKey, 'Crawls.create')
1369
+ const created = await requestJson<CreatedCrawlResponse[]>(options, '/v1/crawls', {
1370
+ method: 'POST',
1371
+ headers: { 'x-idempotency-key': idempotencyKey },
1372
+ body: JSON.stringify(request),
1373
+ })
1374
+ return created.map(({ id }) => new CrawlResource(id, options))
1375
+ },
1376
+ list(listOptions: CrawlListOptions = {}): Promise<CrawlListResponse> {
1377
+ const params = new URLSearchParams()
1378
+ if (listOptions.limit !== undefined) params.set('limit', String(listOptions.limit))
1379
+ if (listOptions.archived !== undefined) params.set('archived', String(listOptions.archived))
1380
+ const query = params.size === 0 ? '' : `?${params.toString()}`
1381
+ return requestJson<CrawlListResponse>(options, `/v1/crawls${query}`, { signal: listOptions.signal })
1382
+ },
1383
+ merge(crawls: readonly CrawlResource[]): MergedCrawlResource {
1384
+ return new MergedCrawlResource(crawls)
1385
+ },
1386
+ })
1387
+ }