@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
@@ -0,0 +1,786 @@
1
+ import { BackoffDefaults, stream as durableStream, FetchError } from '@durable-streams/client';
2
+ import { Duration } from 'effect';
3
+ import { ExpandClientApiError, ExpandClientConnectionError, ExpandClientError, ExpandClientTimeoutError, } from './Error.js';
4
+ import { isRetryable } from './Internal.js';
5
+ import { MAX_RETRY_AFTER } from './RetryAfter.js';
6
+ import { readStreamPage } from './StreamPage.js';
7
+ export class CrawlClientError extends ExpandClientError {
8
+ code;
9
+ status;
10
+ retryable;
11
+ name = 'CrawlClientError';
12
+ constructor(message, code, status, retryable = false) {
13
+ super(message);
14
+ this.code = code;
15
+ this.status = status;
16
+ this.retryable = retryable;
17
+ }
18
+ }
19
+ /** API refusal with the Crawl error code and original response envelope. */
20
+ export class CrawlClientApiError extends ExpandClientApiError {
21
+ code;
22
+ retryable;
23
+ constructor(message, code, status, retryable = false, body) {
24
+ super(message, { status, body });
25
+ this.code = code;
26
+ this.retryable = retryable;
27
+ this.name = 'CrawlClientApiError';
28
+ }
29
+ }
30
+ export class CrawlOffsetExpiredError extends CrawlClientApiError {
31
+ name = 'CrawlOffsetExpiredError';
32
+ }
33
+ function transportSignal(transport, signal) {
34
+ return transport.signal === undefined
35
+ ? signal
36
+ : AbortSignal.any([transport.signal, ...(signal === undefined ? [] : [signal])]);
37
+ }
38
+ /** Await one page, iterate individual items, or use pages() for bounded batches. */
39
+ export class CrawlList {
40
+ fetchPage;
41
+ cursor;
42
+ signal;
43
+ firstPage;
44
+ constructor(fetchPage, cursor, signal) {
45
+ this.fetchPage = fetchPage;
46
+ this.cursor = cursor;
47
+ this.signal = signal;
48
+ }
49
+ first() {
50
+ if (this.signal?.aborted === true)
51
+ return Promise.reject(abortError(this.signal));
52
+ this.firstPage ??= this.fetchPage(this.cursor);
53
+ return this.firstPage;
54
+ }
55
+ then(onfulfilled, onrejected) {
56
+ return this.first().then(onfulfilled, onrejected);
57
+ }
58
+ async *pages() {
59
+ let page = await this.first();
60
+ const visited = new Set(this.cursor === undefined ? [] : [this.cursor]);
61
+ while (true) {
62
+ if (signalIsAborted(this.signal))
63
+ throw abortError(this.signal);
64
+ yield page;
65
+ if (page.nextCursor === undefined)
66
+ return;
67
+ if (visited.has(page.nextCursor))
68
+ throw new CrawlClientError('The server repeated a pagination cursor.');
69
+ visited.add(page.nextCursor);
70
+ page = await this.fetchPage(page.nextCursor);
71
+ }
72
+ }
73
+ async *[Symbol.asyncIterator]() {
74
+ for await (const page of this.pages())
75
+ for (const item of page.items) {
76
+ if (signalIsAborted(this.signal))
77
+ throw abortError(this.signal);
78
+ yield item;
79
+ }
80
+ }
81
+ async collect(options) {
82
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1)
83
+ throw new CrawlClientError('collect limit must be positive.');
84
+ const items = [];
85
+ for await (const item of this) {
86
+ items.push(item);
87
+ if (items.length >= options.limit)
88
+ break;
89
+ }
90
+ return items;
91
+ }
92
+ }
93
+ function projectionQuery(path, values) {
94
+ const query = new URLSearchParams();
95
+ for (const [key, value] of Object.entries(values))
96
+ if (value !== undefined)
97
+ query.set(key, String(value));
98
+ return query.size === 0 ? path : `${path}?${query}`;
99
+ }
100
+ async function projectionResponse(transport, path, options) {
101
+ const target = new URL(absoluteUrl(transport.baseUrl, path));
102
+ const base = new URL(transport.baseUrl);
103
+ if (target.origin !== base.origin ||
104
+ target.username !== '' ||
105
+ target.password !== '' ||
106
+ !target.pathname.startsWith('/v1/')) {
107
+ throw new CrawlClientError('Projection URLs must belong to the configured Expand API.');
108
+ }
109
+ const timeoutMs = options.timeoutMs ?? transport.timeoutMs ?? 60_000;
110
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
111
+ throw new CrawlClientError('Projection timeoutMs must be a positive integer.');
112
+ const deadline = AbortSignal.timeout(timeoutMs);
113
+ const callerSignal = transportSignal(transport, options.signal);
114
+ const signal = callerSignal === undefined ? deadline : AbortSignal.any([callerSignal, deadline]);
115
+ if (signal.aborted)
116
+ throw abortError(signal);
117
+ const response = await (transport.fetch ?? globalThis.fetch)(target, {
118
+ headers: { 'x-expand-api-key': transport.apiKey },
119
+ signal,
120
+ redirect: 'error',
121
+ });
122
+ if (!response.ok)
123
+ throw await apiError(response);
124
+ return response;
125
+ }
126
+ async function projectionJson(transport, path, options) {
127
+ // Mirrors the typed API contract, like requestJson; arbitrary body reads use unknown.
128
+ return (await (await projectionResponse(transport, path, options)).json());
129
+ }
130
+ export class CrawlCaptureResource {
131
+ id;
132
+ transport;
133
+ constructor(id, transport) {
134
+ this.id = id;
135
+ this.transport = transport;
136
+ }
137
+ get(options = {}) {
138
+ return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}`, options);
139
+ }
140
+ stateJson(options = {}) {
141
+ return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/state-json`, options);
142
+ }
143
+ evidence(key, options = {}) {
144
+ return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/state-json/${encodeURIComponent(key)}`, options);
145
+ }
146
+ async markdown(options = {}) {
147
+ return (await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/markdown`, options)).text();
148
+ }
149
+ async html(options = {}) {
150
+ return (await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/html`, options)).text();
151
+ }
152
+ playgroundUrl() {
153
+ return `https://expand.land/s/${encodeURIComponent(this.id)}`;
154
+ }
155
+ }
156
+ export class CrawlPageResource {
157
+ id;
158
+ transport;
159
+ constructor(id, transport) {
160
+ this.id = id;
161
+ this.transport = transport;
162
+ }
163
+ versions(options = {}) {
164
+ return new CrawlList(async (before) => {
165
+ const result = await projectionJson(this.transport, projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/versions`, {
166
+ before,
167
+ at: options.at,
168
+ limit: options.limit,
169
+ }), options);
170
+ return { items: result.versions, nextCursor: options.at === undefined ? result.nextCursor : undefined };
171
+ }, options.before, transportSignal(this.transport, options.signal));
172
+ }
173
+ diff(options) {
174
+ return projectionJson(this.transport, projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/diff`, { from: options.from, to: options.to }), options);
175
+ }
176
+ }
177
+ /** Shares the Crawl transport without exposing its API key to arbitrary body URLs. */
178
+ export function makeCrawlProjectionsClient(transport) {
179
+ return {
180
+ captures(id) {
181
+ if (id.length === 0)
182
+ throw new CrawlClientError('Capture id must not be empty.');
183
+ return new CrawlCaptureResource(id, transport);
184
+ },
185
+ pages(id) {
186
+ if (id.length === 0)
187
+ throw new CrawlClientError('Page id must not be empty.');
188
+ return new CrawlPageResource(id, transport);
189
+ },
190
+ fetchJson(url, options = {}) {
191
+ return projectionJson(transport, url, options);
192
+ },
193
+ };
194
+ }
195
+ function requireIdempotencyKey(value, operation) {
196
+ if (value === undefined || value.length < 1 || value.length > 255) {
197
+ throw new CrawlClientError(`${operation} requires an idempotencyKey between 1 and 255 characters.`);
198
+ }
199
+ return value;
200
+ }
201
+ function isRecord(value) {
202
+ return typeof value === 'object' && value !== null;
203
+ }
204
+ async function apiError(response) {
205
+ let body;
206
+ try {
207
+ body = await response.json();
208
+ }
209
+ catch {
210
+ // A status and status text are still useful when a proxy returned non-JSON.
211
+ }
212
+ const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined;
213
+ const code = typeof error?.['code'] === 'string' ? error['code'] : undefined;
214
+ const message = typeof error?.['message'] === 'string' ? error['message'] : response.statusText;
215
+ const retryable = error?.['retryable'] === true;
216
+ return response.status === 410
217
+ ? new CrawlOffsetExpiredError(message, code, response.status, retryable, body)
218
+ : new CrawlClientApiError(message, code, response.status, retryable, body);
219
+ }
220
+ function absoluteUrl(baseUrl, path) {
221
+ return new URL(path, `${baseUrl.replace(/\/+$/u, '')}/`).toString();
222
+ }
223
+ const CRAWL_STREAM_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
224
+ // durable-streams 0.2.6 cannot interrupt its retry timer. Retries belong to
225
+ // the transport below, which keeps both setup and backoff tied to the caller.
226
+ const CRAWL_STREAM_BACKOFF = { ...BackoffDefaults, maxRetries: 0 };
227
+ // The same ceiling the Effect client applies to `Retry-After`: a live iterator must not sleep
228
+ // for an hour because one response asked for it.
229
+ const MAX_RETRY_AFTER_MS = Duration.toMillis(MAX_RETRY_AFTER);
230
+ function retryAfterMilliseconds(error) {
231
+ if (!(error instanceof FetchError))
232
+ return 0;
233
+ const value = error.headers['retry-after'];
234
+ if (value === undefined)
235
+ return 0;
236
+ const seconds = Number(value);
237
+ if (Number.isFinite(seconds) && seconds > 0)
238
+ return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
239
+ const date = Date.parse(value);
240
+ return Number.isFinite(date) ? Math.max(0, Math.min(date - Date.now(), MAX_RETRY_AFTER_MS)) : 0;
241
+ }
242
+ function isDeadlineReason(reason) {
243
+ return reason instanceof Error && reason.name === 'TimeoutError';
244
+ }
245
+ function crawlTimeoutMs(transport) {
246
+ const timeoutMs = transport.timeoutMs ?? 60_000;
247
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
248
+ throw new CrawlClientError('Crawl timeoutMs must be a positive integer no greater than 2147483647.');
249
+ return timeoutMs;
250
+ }
251
+ function streamError(error, timeoutMs) {
252
+ if (error instanceof ExpandClientError)
253
+ return error;
254
+ if (error instanceof FetchError) {
255
+ const body = error.json ?? error.text;
256
+ const envelope = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined;
257
+ const code = typeof envelope?.['code'] === 'string' ? envelope['code'] : undefined;
258
+ const message = typeof envelope?.['message'] === 'string' ? envelope['message'] : error.message;
259
+ const retryable = envelope?.['retryable'] === true ||
260
+ (envelope?.['retryable'] === undefined && (error.status === 408 || error.status === 429 || error.status >= 500));
261
+ return error.status === 410
262
+ ? new CrawlOffsetExpiredError(message, code ?? 'offset_expired', error.status, retryable, body)
263
+ : new CrawlClientApiError(message, code, error.status, retryable, body);
264
+ }
265
+ if (error instanceof Error && error.name === 'TimeoutError')
266
+ return new ExpandClientTimeoutError(`Request timed out after ${timeoutMs}ms`, { timeoutMs, cause: error });
267
+ return new ExpandClientConnectionError(error instanceof Error ? error.message : 'Crawl request failed.', {
268
+ cause: error,
269
+ });
270
+ }
271
+ function createCrawlStreamRequest(transport, callerSignal, live = false) {
272
+ const redirect = new AbortController();
273
+ const execute = transport.fetch ?? globalThis.fetch;
274
+ const timeoutMs = crawlTimeoutMs(transport);
275
+ const maxRetries = transport.maxRetries ?? 2;
276
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0)
277
+ throw new CrawlClientError('Crawl maxRetries must be a non-negative integer.');
278
+ const signal = AbortSignal.any([
279
+ redirect.signal,
280
+ ...(callerSignal === undefined ? [] : [callerSignal]),
281
+ ...(live ? [] : [AbortSignal.timeout(timeoutMs)]),
282
+ ]);
283
+ return {
284
+ signal,
285
+ redirectSignal: redirect.signal,
286
+ fetch: async (input, init) => {
287
+ const requestSignal = init?.signal == null ? signal : AbortSignal.any([signal, init.signal]);
288
+ let delay = BackoffDefaults.initialDelay;
289
+ let retries = 0;
290
+ while (true) {
291
+ if (requestSignal.aborted)
292
+ throw abortError(requestSignal);
293
+ try {
294
+ const response = await execute(input, { ...init, signal: requestSignal, redirect: 'manual' });
295
+ if (CRAWL_STREAM_REDIRECT_STATUSES.has(response.status) || response.type === 'opaqueredirect') {
296
+ const error = new CrawlClientError('Authenticated Crawl requests cannot redirect.', 'redirect_not_allowed', response.status || undefined, false);
297
+ // Keep policy failures out of transport and iterator reconnect loops.
298
+ redirect.abort(error);
299
+ try {
300
+ await response.body?.cancel();
301
+ }
302
+ catch {
303
+ // Aborting the request may already have canceled its body.
304
+ }
305
+ throw error;
306
+ }
307
+ if (!response.ok)
308
+ throw await FetchError.fromResponse(response, String(input));
309
+ return response;
310
+ }
311
+ catch (error) {
312
+ if (requestSignal.aborted)
313
+ throw abortError(requestSignal);
314
+ if (!isRetryable(streamError(error, timeoutMs), timeoutMs))
315
+ throw error;
316
+ if (!live && retries >= maxRetries)
317
+ throw error;
318
+ retries += 1;
319
+ try {
320
+ await abortableDelay(Math.max(retryAfterMilliseconds(error), Math.random() * delay), requestSignal);
321
+ }
322
+ catch (abort) {
323
+ // The request deadline expired while waiting to retry. The refusal being retried
324
+ // explains the failure; a timeout is reserved for an attempt that was in flight.
325
+ if (!live && isDeadlineReason(requestSignal.reason))
326
+ throw streamError(error, timeoutMs);
327
+ throw abort;
328
+ }
329
+ delay = Math.min(delay * BackoffDefaults.multiplier, BackoffDefaults.maxDelay);
330
+ }
331
+ }
332
+ },
333
+ };
334
+ }
335
+ async function requestJson(transport, path, init = {}) {
336
+ const timeoutMs = crawlTimeoutMs(transport);
337
+ const callerSignal = transportSignal(transport, init.signal ?? undefined);
338
+ const request = createCrawlStreamRequest(transport, callerSignal);
339
+ const signal = request.signal;
340
+ if (signal.aborted)
341
+ throw abortError(signal);
342
+ try {
343
+ const response = await request.fetch(absoluteUrl(transport.baseUrl, path), {
344
+ ...init,
345
+ redirect: 'error',
346
+ signal,
347
+ headers: {
348
+ 'x-expand-api-key': transport.apiKey,
349
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
350
+ ...init.headers,
351
+ },
352
+ });
353
+ if (!response.ok)
354
+ throw await apiError(response);
355
+ // The SDK's public response interfaces mirror the versioned HTTP contract.
356
+ return (await response.json());
357
+ }
358
+ catch (error) {
359
+ // A typed transport failure already names the cause, even when the deadline has since fired.
360
+ if (error instanceof ExpandClientError)
361
+ throw error;
362
+ throw streamError(signal.aborted ? abortError(signal) : error, timeoutMs);
363
+ }
364
+ }
365
+ function abortError(signal) {
366
+ return signal?.reason instanceof Error ? signal.reason : new CrawlClientError('The Crawl operation was aborted.');
367
+ }
368
+ function abortableDelay(milliseconds, signal) {
369
+ return new Promise((resolve, reject) => {
370
+ if (signal?.aborted === true) {
371
+ reject(abortError(signal));
372
+ return;
373
+ }
374
+ const onAbort = () => {
375
+ clearTimeout(timer);
376
+ signal?.removeEventListener('abort', onAbort);
377
+ reject(abortError(signal));
378
+ };
379
+ const timer = setTimeout(() => {
380
+ signal?.removeEventListener('abort', onAbort);
381
+ resolve();
382
+ }, milliseconds);
383
+ signal?.addEventListener('abort', onAbort, { once: true });
384
+ });
385
+ }
386
+ function signalIsAborted(signal) {
387
+ return signal?.aborted === true;
388
+ }
389
+ const CRAWL_ITERATOR_RETURN = Symbol('crawl-iterator-return');
390
+ const CRAWL_ITERATOR_ABORT = Symbol('crawl-iterator-abort');
391
+ function iteratorWasCanceled(signal) {
392
+ return signal?.reason === CRAWL_ITERATOR_RETURN || signal?.reason === CRAWL_ITERATOR_ABORT;
393
+ }
394
+ class CrawlIterable {
395
+ factory;
396
+ externalSignal;
397
+ constructor(factory, externalSignal) {
398
+ this.factory = factory;
399
+ this.externalSignal = externalSignal;
400
+ }
401
+ [Symbol.asyncIterator]() {
402
+ const controller = new AbortController();
403
+ const abort = () => controller.abort(CRAWL_ITERATOR_ABORT);
404
+ if (this.externalSignal?.aborted === true)
405
+ abort();
406
+ else
407
+ this.externalSignal?.addEventListener('abort', abort, { once: true });
408
+ const iterator = this.factory(controller.signal);
409
+ const cleanup = () => this.externalSignal?.removeEventListener('abort', abort);
410
+ return {
411
+ next: async () => {
412
+ try {
413
+ const result = await iterator.next();
414
+ if (result.done)
415
+ cleanup();
416
+ return result;
417
+ }
418
+ catch (error) {
419
+ cleanup();
420
+ if (iteratorWasCanceled(controller.signal))
421
+ return { done: true, value: undefined };
422
+ throw error;
423
+ }
424
+ },
425
+ return: async (value) => {
426
+ controller.abort(CRAWL_ITERATOR_RETURN);
427
+ cleanup();
428
+ return (await iterator.return?.(value)) ?? { done: true, value: undefined };
429
+ },
430
+ throw: async (error) => {
431
+ controller.abort(error);
432
+ cleanup();
433
+ if (iterator.throw === undefined)
434
+ throw error;
435
+ return iterator.throw(error);
436
+ },
437
+ };
438
+ }
439
+ async collect(options) {
440
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1)
441
+ throw new CrawlClientError('collect limit must be positive.');
442
+ const events = [];
443
+ for await (const event of this) {
444
+ events.push(event);
445
+ if (events.length >= options.limit)
446
+ break;
447
+ }
448
+ return events;
449
+ }
450
+ }
451
+ export class CrawlStreamPageIterable extends CrawlIterable {
452
+ then(onfulfilled, onrejected) {
453
+ const first = async () => {
454
+ const iterator = this[Symbol.asyncIterator]();
455
+ try {
456
+ const result = await iterator.next();
457
+ return result.done ? undefined : result.value;
458
+ }
459
+ finally {
460
+ await iterator.return?.();
461
+ }
462
+ };
463
+ return first().then(onfulfilled, onrejected);
464
+ }
465
+ }
466
+ export class CrawlStreamIterable extends CrawlIterable {
467
+ pageFactory;
468
+ constructor(factory, pageFactory, signal) {
469
+ super(factory, signal);
470
+ this.pageFactory = pageFactory;
471
+ }
472
+ pages() {
473
+ return new CrawlStreamPageIterable(this.pageFactory, this.externalSignal);
474
+ }
475
+ then(onfulfilled, onrejected) {
476
+ return this.pages().then(onfulfilled, onrejected);
477
+ }
478
+ }
479
+ export class CrawlEventPageIterable extends CrawlStreamPageIterable {
480
+ }
481
+ export class CrawlEventIterable extends CrawlStreamIterable {
482
+ }
483
+ export class CrawlActivityPageIterable extends CrawlStreamPageIterable {
484
+ }
485
+ export class CrawlActivityIterable extends CrawlStreamIterable {
486
+ }
487
+ const PAGE_STREAM = { path: 'events' };
488
+ const ACTIVITY_STREAM = { path: 'activity' };
489
+ export class CrawlResource {
490
+ id;
491
+ transport;
492
+ constructor(id, transport) {
493
+ this.id = id;
494
+ this.transport = transport;
495
+ }
496
+ events(options = {}) {
497
+ return new CrawlEventIterable((signal) => this.readStream(PAGE_STREAM, { ...options, signal }), (signal) => this.readStreamPages(PAGE_STREAM, { ...options, signal }), transportSignal(this.transport, options.signal));
498
+ }
499
+ /**
500
+ * The activity stream: batches of URL transitions without content, for progress and
501
+ * operator views. Same shape as `events`; a one-off's iteration ends on stream closure.
502
+ */
503
+ activity(options = {}) {
504
+ return new CrawlActivityIterable((signal) => this.readStream(ACTIVITY_STREAM, { ...options, signal }), (signal) => this.readStreamPages(ACTIVITY_STREAM, { ...options, signal }), transportSignal(this.transport, options.signal));
505
+ }
506
+ /** A pinned live-catalog snapshot. Resume /events from the final page's nextOffset. */
507
+ snapshot(options = {}) {
508
+ return new CrawlEventIterable((signal) => this.readSnapshotItems({ ...options, signal }), (signal) => this.readSnapshotPages({ ...options, signal }), transportSignal(this.transport, options.signal));
509
+ }
510
+ async *readSnapshotItems(options) {
511
+ for await (const page of this.readSnapshotPages(options)) {
512
+ for (const event of page.items)
513
+ yield event;
514
+ }
515
+ }
516
+ async *readSnapshotPages(options) {
517
+ let offset = options.from;
518
+ while (!signalIsAborted(options.signal)) {
519
+ const page = await projectionJson(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/snapshot`, { offset }), options);
520
+ yield {
521
+ crawlId: this.id,
522
+ items: page.events,
523
+ nextOffset: page.nextOffset,
524
+ upToDate: page.upToDate,
525
+ streamClosed: false,
526
+ };
527
+ if (page.upToDate)
528
+ return;
529
+ if (page.nextOffset === offset)
530
+ throw new CrawlClientError('Snapshot cursor did not advance.');
531
+ offset = page.nextOffset;
532
+ }
533
+ }
534
+ async *readStreamPages(spec, options) {
535
+ let offset = options.from ?? '-1';
536
+ let caughtUp = false;
537
+ let cursor;
538
+ let recurring;
539
+ while (!signalIsAborted(options.signal)) {
540
+ const url = new URL(absoluteUrl(this.transport.baseUrl, `/v1/crawls/${encodeURIComponent(this.id)}/${spec.path}`));
541
+ if (caughtUp)
542
+ url.searchParams.set('live', 'long-poll');
543
+ if (cursor !== undefined)
544
+ url.searchParams.set('cursor', cursor);
545
+ const request = createCrawlStreamRequest(this.transport, options.signal, options.live === true);
546
+ let page;
547
+ try {
548
+ const response = await durableStream({
549
+ url: url.toString(),
550
+ headers: { 'x-expand-api-key': this.transport.apiKey },
551
+ offset,
552
+ live: false,
553
+ json: true,
554
+ signal: request.signal,
555
+ fetch: request.fetch,
556
+ backoffOptions: CRAWL_STREAM_BACKOFF,
557
+ });
558
+ page = await readStreamPage(response);
559
+ if (request.redirectSignal.aborted)
560
+ throw abortError(request.redirectSignal);
561
+ }
562
+ catch (error) {
563
+ if (iteratorWasCanceled(options.signal))
564
+ return;
565
+ if (request.redirectSignal.aborted)
566
+ throw abortError(request.redirectSignal);
567
+ const retryable = isRetryable(streamError(error, crawlTimeoutMs(this.transport)), crawlTimeoutMs(this.transport));
568
+ if (options.live === true && retryable) {
569
+ await abortableDelay(500, options.signal);
570
+ continue;
571
+ }
572
+ throw streamError(request.signal.aborted ? abortError(request.signal) : error, crawlTimeoutMs(this.transport));
573
+ }
574
+ if (signalIsAborted(options.signal))
575
+ return;
576
+ offset = page.offset;
577
+ cursor = page.cursor;
578
+ caughtUp ||= page.upToDate;
579
+ yield {
580
+ crawlId: this.id,
581
+ items: page.items,
582
+ nextOffset: offset,
583
+ upToDate: page.upToDate,
584
+ streamClosed: page.streamClosed,
585
+ };
586
+ if (page.streamClosed)
587
+ return;
588
+ if (!page.upToDate || options.live === true)
589
+ continue;
590
+ // Recurring reads default to catch-up. One-off completion is signaled only by EOF;
591
+ // offsets are opaque and are never compared to an out-of-band watermark.
592
+ if (recurring === undefined)
593
+ recurring = (await this.status({ signal: options.signal })).sync !== undefined;
594
+ if (recurring)
595
+ return;
596
+ }
597
+ }
598
+ pages(options = {}) {
599
+ return new CrawlList(async (cursor) => {
600
+ const result = await projectionJson(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages`, {
601
+ cursor,
602
+ limit: options.limit,
603
+ prefix: options.prefix,
604
+ status: options.status,
605
+ changedSince: options.changedSince,
606
+ }), options);
607
+ return { items: result.pages, nextCursor: result.nextCursor };
608
+ }, options.cursor, transportSignal(this.transport, options.signal));
609
+ }
610
+ /**
611
+ * Every discovered URL in any admission state, cursor-paginated and filterable by state,
612
+ * source and a URL substring (`search`, matched literally and case-insensitively across the
613
+ * whole crawl). The first page's `activityOffset` is where to start an activity tail that
614
+ * keeps the snapshot current: apply an event to a URL only when `event.offset > url.activitySeq`.
615
+ */
616
+ urls(options = {}) {
617
+ return new CrawlList(async (cursor) => {
618
+ const result = await projectionJson(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
619
+ cursor,
620
+ limit: options.limit,
621
+ state: options.state,
622
+ source: options.source,
623
+ search: options.search,
624
+ }), options);
625
+ return { items: result.urls, nextCursor: result.nextCursor };
626
+ }, options.cursor, transportSignal(this.transport, options.signal));
627
+ }
628
+ /** One page of the discovered-URLs view with its counts and activity offset, for callers that need the envelope. */
629
+ urlsPage(options = {}) {
630
+ return projectionJson(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
631
+ cursor: options.cursor,
632
+ limit: options.limit,
633
+ state: options.state,
634
+ source: options.source,
635
+ search: options.search,
636
+ }), options);
637
+ }
638
+ page(id, options = {}) {
639
+ return projectionJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/pages/${encodeURIComponent(id)}`, options);
640
+ }
641
+ pageByUrl(url, options = {}) {
642
+ return projectionJson(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages/by-url`, { url }), options);
643
+ }
644
+ async *readStream(spec, options) {
645
+ for await (const page of this.readStreamPages(spec, options)) {
646
+ for (const event of page.items) {
647
+ if (signalIsAborted(options.signal))
648
+ return;
649
+ yield event;
650
+ }
651
+ }
652
+ }
653
+ status(options = {}) {
654
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
655
+ signal: options.signal,
656
+ });
657
+ }
658
+ async done(options = {}) {
659
+ if (options.pollIntervalMs !== undefined &&
660
+ (!Number.isFinite(options.pollIntervalMs) || options.pollIntervalMs > 2_147_483_647))
661
+ throw new CrawlClientError('Crawl pollIntervalMs must be finite and no greater than 2147483647.');
662
+ const interval = Math.max(50, options.pollIntervalMs ?? 500);
663
+ const signal = transportSignal(this.transport, options.signal);
664
+ while (!signalIsAborted(signal)) {
665
+ const status = await this.status({ signal });
666
+ if (signalIsAborted(signal))
667
+ break;
668
+ if (status.state === 'done' ||
669
+ status.state === 'failed' ||
670
+ status.state === 'canceled' ||
671
+ (status.sync !== undefined && status.progress.queued === 0 && status.progress.inFlight === 0)) {
672
+ return { lastOffset: status.offset, status };
673
+ }
674
+ await abortableDelay(interval, signal);
675
+ }
676
+ const reason = signal?.reason;
677
+ throw reason instanceof Error ? reason : new CrawlClientError('Crawl.done was aborted.');
678
+ }
679
+ update(patch) {
680
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
681
+ method: 'PATCH',
682
+ body: JSON.stringify(patch),
683
+ });
684
+ }
685
+ cancel() {
686
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/cancel`, {
687
+ method: 'POST',
688
+ });
689
+ }
690
+ /** Cancels the crawl if it is live and hides it from `list()`. It is deleted 90 days later unless unarchived. */
691
+ archive() {
692
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/archive`, {
693
+ method: 'POST',
694
+ });
695
+ }
696
+ /** Returns an archived crawl to `list()`, still canceled, and keeps it for at least 90 more days. */
697
+ unarchive() {
698
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/unarchive`, {
699
+ method: 'POST',
700
+ });
701
+ }
702
+ run(options) {
703
+ const idempotencyKey = requireIdempotencyKey(options?.idempotencyKey, 'Crawl.run');
704
+ return requestJson(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/runs${options.replayOnly ? '/replay' : ''}`, {
705
+ method: 'POST',
706
+ headers: {
707
+ 'x-idempotency-key': idempotencyKey,
708
+ },
709
+ });
710
+ }
711
+ }
712
+ export class MergedCrawlResource {
713
+ crawls;
714
+ constructor(crawls) {
715
+ this.crawls = crawls;
716
+ }
717
+ events(options = {}) {
718
+ if (typeof options.from === 'string')
719
+ throw new CrawlClientError('Merged Crawl reads require an offset map keyed by Crawl id.');
720
+ return new CrawlEventIterable((signal) => this.readItems({ ...options, signal }, (events) => events), (signal) => this.readItems({ ...options, signal }, (events) => events.pages()), options.signal);
721
+ }
722
+ async *readItems(options, select) {
723
+ if (signalIsAborted(options.signal))
724
+ return;
725
+ const controllers = this.crawls.map(() => new AbortController());
726
+ const abortChildren = () => {
727
+ for (const controller of controllers)
728
+ controller.abort(options.signal?.reason);
729
+ };
730
+ options.signal?.addEventListener('abort', abortChildren, { once: true });
731
+ const iterators = this.crawls.map((crawl, index) => select(crawl.events({ from: options.from?.[crawl.id], live: options.live, signal: controllers[index]?.signal }))[Symbol.asyncIterator]());
732
+ const pending = new Map();
733
+ for (const [index, iterator] of iterators.entries()) {
734
+ pending.set(index, iterator.next().then((result) => ({ index, result })));
735
+ }
736
+ try {
737
+ while (pending.size > 0) {
738
+ const next = await Promise.race(pending.values());
739
+ if (next.result.done) {
740
+ pending.delete(next.index);
741
+ continue;
742
+ }
743
+ yield next.result.value;
744
+ const iterator = iterators[next.index];
745
+ if (iterator !== undefined)
746
+ pending.set(next.index, iterator.next().then((result) => ({ index: next.index, result })));
747
+ }
748
+ }
749
+ finally {
750
+ abortChildren();
751
+ options.signal?.removeEventListener('abort', abortChildren);
752
+ await Promise.allSettled(iterators.map((iterator) => iterator.return?.(undefined)));
753
+ }
754
+ }
755
+ }
756
+ export function makeCrawlsClient(options) {
757
+ function byId(id) {
758
+ if (id.length === 0)
759
+ throw new CrawlClientError('Crawl id must not be empty.');
760
+ return new CrawlResource(id, options);
761
+ }
762
+ return Object.assign(byId, {
763
+ async create(request, createOptions) {
764
+ const idempotencyKey = requireIdempotencyKey(createOptions?.idempotencyKey, 'Crawls.create');
765
+ const created = await requestJson(options, '/v1/crawls', {
766
+ method: 'POST',
767
+ headers: { 'x-idempotency-key': idempotencyKey },
768
+ body: JSON.stringify(request),
769
+ });
770
+ return created.map(({ id }) => new CrawlResource(id, options));
771
+ },
772
+ list(listOptions = {}) {
773
+ const params = new URLSearchParams();
774
+ if (listOptions.limit !== undefined)
775
+ params.set('limit', String(listOptions.limit));
776
+ if (listOptions.archived !== undefined)
777
+ params.set('archived', String(listOptions.archived));
778
+ const query = params.size === 0 ? '' : `?${params.toString()}`;
779
+ return requestJson(options, `/v1/crawls${query}`, { signal: listOptions.signal });
780
+ },
781
+ merge(crawls) {
782
+ return new MergedCrawlResource(crawls);
783
+ },
784
+ });
785
+ }
786
+ //# sourceMappingURL=Crawls.js.map