@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,370 @@
1
+ // Appendix omission markers as they appear in Fetch Markdown.
2
+ //
3
+ // Mirrors `packages/core/src/Fetch/AppendixMarkers.ts` (the SDK cannot depend
4
+ // on core). Keep the grammar in sync with the Rust emitter in
5
+ // `native/expand-snapshot/src/markdown/pruner/appendix_markers.rs`.
6
+ //
7
+ // A marker is placed wherever block content renders, so it can arrive with a
8
+ // blockquote or list-item prefix (`> [appendix omitted: …]`). Marker-shaped
9
+ // lines inside fenced code are page text, not markers, and are left alone.
10
+ //
11
+ // [appendix omitted: 14 links · ~320 tokens] {612}
12
+ // [appendix omitted: navigation — Home, News, Sport, +11 more · 14 links · ~320 tokens] {612}
13
+ // [appendix omitted: 7 more blocks · 41 links · ~2100 tokens] (trailing fold; id is the first folded block when bound)
14
+
15
+ import { Schema } from 'effect'
16
+
17
+ export const APPENDIX_MARKER_PREFIX = '[appendix omitted: '
18
+
19
+ export const AppendixMarkerLevel = Schema.Literal('off', 'compact', 'detailed')
20
+ export type AppendixMarkerLevel = typeof AppendixMarkerLevel.Type
21
+
22
+ const MARKER_LINE =
23
+ /^\[appendix omitted: (?:(?<more>\d+) more blocks? · |(?<label>[a-z]+)(?: — (?<preview>.+?))? · )?(?<links>\d+) links? · ~(?<tokens>\d+) tokens\](?: \{(?<id>\d+)\})?$/u
24
+
25
+ /** One blockquote or list-item prefix, as Markdown renders it. */
26
+ const CONTAINER_PREFIX = /^(?:> ?|[*+-][ \t]|\d+[.)][ \t])/u
27
+
28
+ const FENCE_LINE = /^(`{3,}|~{3,})/u
29
+
30
+ /** A list bullet or ordered-list number introducing an item. */
31
+ const LIST_MARKER = /^(?:[*+-]|\d+[.)])(?=[ \t]|$)/u
32
+
33
+ /**
34
+ * A marker rendered inside a blockquote or a list item carries that container's
35
+ * prefix (`> `, `- `, `1. `, and nestings of those). The Rust emitter places
36
+ * markers wherever block content renders, so consumers have to see through
37
+ * those prefixes or the marker reads as page content.
38
+ */
39
+ function markerBody(line: string): string {
40
+ let rest = line.trimStart()
41
+ while (!rest.startsWith(APPENDIX_MARKER_PREFIX)) {
42
+ const prefix = CONTAINER_PREFIX.exec(rest)
43
+ if (!prefix) return rest
44
+ rest = rest.slice(prefix[0].length).trimStart()
45
+ }
46
+ return rest
47
+ }
48
+
49
+ interface OpenFence {
50
+ /** Blockquote nesting the fence opened at. */
51
+ readonly depth: number
52
+ /**
53
+ * Content column of the list item the fence opened inside. A line that
54
+ * dedents out of that item ends the block, closed or not — an unclosed fence
55
+ * must not outlive its container.
56
+ */
57
+ readonly itemContentColumn: number
58
+ /**
59
+ * Column the fence started at. A closing delimiter may be indented up to
60
+ * three columns further.
61
+ */
62
+ readonly indent: number
63
+ readonly fenceChar: string
64
+ readonly run: number
65
+ }
66
+
67
+ interface ContainerPrefix {
68
+ readonly depth: number
69
+ /** Spaces before any container marker on this line. */
70
+ readonly rawIndent: number
71
+ /**
72
+ * Column just after the last container marker, or `undefined` when the line
73
+ * opens no container. Indentation past it is indented code *inside* that
74
+ * container.
75
+ */
76
+ readonly afterMarkers: number | undefined
77
+ /**
78
+ * Column the outermost list marker starts at, or `Number.MAX_SAFE_INTEGER`
79
+ * when the line opens no item. A continuation line may dedent to here
80
+ * without leaving the item.
81
+ */
82
+ readonly markerColumn: number
83
+ /**
84
+ * Every item this line opens, outermost first, as the quote depth in force
85
+ * where its marker stood and the content column it starts. `10. * nested`
86
+ * opens two; `10. > quote` opens one at depth zero even though the line ends
87
+ * inside a quote.
88
+ */
89
+ readonly openedItems: { depth: number; column: number }[]
90
+ /** Columns consumed by indentation and container markers together. */
91
+ readonly column: number
92
+ readonly sawListMarker: boolean
93
+ readonly rest: string
94
+ }
95
+
96
+ /**
97
+ * The container markers a line opens with, and where they leave it.
98
+ *
99
+ * Blockquotes and list items nest in either order (`- > ```rust`), so the two
100
+ * are stripped in turn rather than one after the other. A `>` takes one
101
+ * optional space with it, which is the quote marker rather than indentation.
102
+ * A closing delimiter is read with `allowListMarkers` false: a bullet on a line
103
+ * *inside* a block is code, and reading it as container syntax ends the block.
104
+ */
105
+ function containerPrefix(line: string, allowListMarkers: boolean): ContainerPrefix {
106
+ const rawIndent = leadingIndent(line, 0).columns
107
+ let rest = line
108
+ let depth = 0
109
+ let column = 0
110
+ let markerColumn = Number.MAX_SAFE_INTEGER
111
+ let afterMarkers: number | undefined
112
+ const openedItems: { depth: number; column: number }[] = []
113
+ let sawListMarker = false
114
+ for (;;) {
115
+ const { bytes, columns } = leadingIndent(rest, column)
116
+ rest = rest.slice(bytes)
117
+ column += columns
118
+ if (rest.startsWith('>')) {
119
+ depth += 1
120
+ const after = rest.slice(1).startsWith(' ') ? rest.slice(2) : rest.slice(1)
121
+ column += rest.length - after.length
122
+ rest = after
123
+ afterMarkers = column
124
+ continue
125
+ }
126
+ const { width, content } = allowListMarkers ? listMarkerWidth(rest, column) : { width: 0, content: rest }
127
+ if (width > 0) {
128
+ sawListMarker = true
129
+ markerColumn = Math.min(markerColumn, column)
130
+ column += width
131
+ rest = content
132
+ afterMarkers = column
133
+ openedItems.push({ depth, column })
134
+ continue
135
+ }
136
+ return { depth, rawIndent, afterMarkers, markerColumn, openedItems, column, sawListMarker, rest }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The column a line begins at for container purposes: its own list marker when
142
+ * it opens an item, otherwise its content.
143
+ */
144
+ function startsAt(prefix: ContainerPrefix): number {
145
+ return prefix.sawListMarker ? prefix.markerColumn : prefix.column
146
+ }
147
+
148
+ /**
149
+ * Whether a line's content is an indented code block — four columns past
150
+ * whatever contains it. That is either the enclosing list item, for the
151
+ * indentation before this line's own markers, or the innermost marker this line
152
+ * opens, for the indentation after them.
153
+ */
154
+ function isIndentedCode(prefix: ContainerPrefix, contentColumn: number): boolean {
155
+ const inside = Math.max(contentColumn, prefix.afterMarkers ?? 0)
156
+ return prefix.rawIndent >= contentColumn + 4 || prefix.column >= inside + 4
157
+ }
158
+
159
+ /**
160
+ * Bytes and columns taken by leading whitespace. A tab advances to the next
161
+ * four-column stop, as CommonMark counts it — code bodies are full of them, and
162
+ * reading one as no indentation puts a line outside its own block.
163
+ */
164
+ function leadingIndent(rest: string, startColumn: number): { bytes: number; columns: number } {
165
+ let bytes = 0
166
+ let column = startColumn
167
+ for (const character of rest) {
168
+ if (character === ' ') column += 1
169
+ // A tab stop is measured from where the line already is, not from the
170
+ // start of the run.
171
+ else if (character === '\t') column += 4 - (column % 4)
172
+ else break
173
+ bytes += 1
174
+ }
175
+ return { bytes, columns: column - startColumn }
176
+ }
177
+
178
+ /** A run of three or more backticks or tildes at the start of `content`. */
179
+ function fenceRun(content: string): { fenceChar: string; run: number } | undefined {
180
+ const match = FENCE_LINE.exec(content)?.[1]
181
+ return match === undefined ? undefined : { fenceChar: match[0], run: match.length }
182
+ }
183
+
184
+ /** Columns taken by a leading list marker, and the content after it. */
185
+ function listMarkerWidth(rest: string, startColumn: number): { width: number; content: string } {
186
+ const marker = LIST_MARKER.exec(rest)?.[0]
187
+ if (marker === undefined) return { width: 0, content: rest }
188
+ const afterMarker = rest.slice(marker.length)
189
+ const padding = leadingIndent(afterMarker, startColumn + marker.length)
190
+ // More than four columns of padding is an indented code block inside the
191
+ // item, not a wider marker: the item's content starts one column past it.
192
+ if (padding.columns > 4) return { width: marker.length, content: afterMarker }
193
+ return { width: marker.length + padding.columns, content: rest.slice(marker.length + padding.bytes) }
194
+ }
195
+
196
+ /**
197
+ * The fence a line opens, if any. The caller has already established that the
198
+ * line is not indented code.
199
+ */
200
+ function opensFence(prefix: ContainerPrefix, itemContentColumn: number): OpenFence | undefined {
201
+ const run = fenceRun(prefix.rest)
202
+ if (run === undefined) return undefined
203
+ // A backtick fence's info string may not contain a backtick, so ``` `` ``` is
204
+ // a paragraph with inline code in it, not a block that swallows what follows.
205
+ if (run.fenceChar === '`' && prefix.rest.slice(run.run).includes('`')) return undefined
206
+ return { depth: prefix.depth, indent: prefix.column, itemContentColumn, ...run }
207
+ }
208
+
209
+ /**
210
+ * A fence closes on a run of the same character, at least as long as the open,
211
+ * at the same blockquote depth and no more than three columns further in, with
212
+ * nothing but whitespace after it. Everything else inside the block — an info
213
+ * string, a `>`, a list bullet — is code.
214
+ */
215
+ function closesFence(line: string, open: OpenFence): boolean {
216
+ const prefix = containerPrefix(line, false)
217
+ if (prefix.depth !== open.depth || prefix.column > open.indent + 3) return false
218
+ const run = fenceRun(prefix.rest)
219
+ if (run === undefined || run.fenceChar !== open.fenceChar || run.run < open.run) return false
220
+ return prefix.rest.slice(run.run).trim() === ''
221
+ }
222
+
223
+ /**
224
+ * Whether a line has stepped out of the list item a fence opened inside, which
225
+ * ends the block whether or not a closing delimiter ever arrived.
226
+ */
227
+ function leavesFenceContainer(line: string, open: OpenFence): boolean {
228
+ if (open.itemContentColumn === 0 && open.depth === 0) return false
229
+ const prefix = containerPrefix(line, true)
230
+ if (prefix.rest.trim() === '' && !prefix.sawListMarker) return false
231
+ return prefix.depth < open.depth || startsAt(prefix) < open.itemContentColumn
232
+ }
233
+
234
+ /**
235
+ * Walk a body's lines, reporting each with the marker it carries, if any.
236
+ *
237
+ * The list items a line sits inside are tracked as a stack of their content
238
+ * columns, because that is the whole difference between four spaces meaning an
239
+ * indented code block and four spaces meaning ordinary item content — and a
240
+ * delimiter that is a fence in one is code in the other.
241
+ */
242
+ function eachLine(
243
+ markdown: string,
244
+ visit: (line: string, marker: AppendixMarker | undefined, fenced: boolean) => void,
245
+ ): void {
246
+ // Blockquote depth and content column of each open item, outermost first. The
247
+ // depth matters because an item inside a quote ends when the quote does,
248
+ // however its columns compare.
249
+ const openItems: { depth: number; column: number }[] = []
250
+ let fence: OpenFence | undefined
251
+ for (const raw of markdown.split('\n')) {
252
+ // `lines()` in the Rust twin drops the carriage return; a body captured
253
+ // with CRLF must classify the same either way.
254
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
255
+ if (fence !== undefined) {
256
+ // A line that steps out of the fence's list item is not part of the
257
+ // block — the block ended above it — so it still has to be read.
258
+ if (leavesFenceContainer(line, fence)) {
259
+ fence = undefined
260
+ } else {
261
+ const closing = closesFence(line, fence)
262
+ if (closing) fence = undefined
263
+ // The delimiters belong to the document; only the body between them
264
+ // is code a consumer may drop.
265
+ visit(raw, undefined, !closing)
266
+ continue
267
+ }
268
+ }
269
+ const prefix = containerPrefix(line, true)
270
+ // A blank line neither opens nor closes a container, and the items it sits
271
+ // inside continue after it. An empty list item (`10.` with its content on a
272
+ // later line) looks blank once its marker is stripped, and is not.
273
+ if (prefix.rest.trim() === '' && !prefix.sawListMarker) {
274
+ visit(raw, undefined, false)
275
+ continue
276
+ }
277
+ while (openItems.length > 0) {
278
+ const innermost = openItems[openItems.length - 1]
279
+ if (innermost.depth <= prefix.depth && startsAt(prefix) >= innermost.column) break
280
+ openItems.pop()
281
+ }
282
+ const contentColumn = openItems[openItems.length - 1]?.column ?? 0
283
+ // An indented code block is not a list item, not a fence, and not a
284
+ // marker — it is code, whatever it looks like.
285
+ if (isIndentedCode(prefix, contentColumn)) {
286
+ visit(raw, undefined, false)
287
+ continue
288
+ }
289
+ for (const opened of prefix.openedItems) openItems.push(opened)
290
+ const opening = opensFence(prefix, openItems[openItems.length - 1]?.column ?? 0)
291
+ if (opening !== undefined) {
292
+ fence = opening
293
+ visit(raw, undefined, false)
294
+ continue
295
+ }
296
+ visit(raw, parseAppendixMarkerLine(line), false)
297
+ }
298
+ }
299
+
300
+ /**
301
+ * A body with the *contents* of fenced code removed, delimiters kept. Evidence
302
+ * ids inside a code example are page text, not citations — but a code block's
303
+ * own id rides on its opening fence, so the delimiters have to stay.
304
+ */
305
+ export function withoutFencedCode(markdown: string): string {
306
+ const kept: string[] = []
307
+ eachLine(markdown, (line, _marker, fenced) => {
308
+ if (!fenced) kept.push(line)
309
+ })
310
+ return kept.join('\n')
311
+ }
312
+
313
+ export interface AppendixMarker {
314
+ /** Evidence id of the first omitted block; on the trailing fold marker, the first block of the first folded run. */
315
+ readonly evidenceId?: number
316
+ readonly links: number
317
+ readonly approxTokens: number
318
+ readonly label?: string
319
+ readonly preview?: string
320
+ /** On the trailing fold marker: how many further omitted runs it stands for. */
321
+ readonly moreBlocks?: number
322
+ }
323
+
324
+ export function isAppendixMarkerLine(line: string): boolean {
325
+ const body = markerBody(line)
326
+ return body.startsWith(APPENDIX_MARKER_PREFIX) && MARKER_LINE.test(body.trimEnd())
327
+ }
328
+
329
+ export function parseAppendixMarkerLine(line: string): AppendixMarker | undefined {
330
+ const groups = MARKER_LINE.exec(markerBody(line).trimEnd())?.groups
331
+ if (!groups) return undefined
332
+ const links = Number(groups.links)
333
+ const approxTokens = Number(groups.tokens)
334
+ if (!Number.isSafeInteger(links) || !Number.isSafeInteger(approxTokens)) return undefined
335
+ const evidenceId = groups.id === undefined ? undefined : Number(groups.id)
336
+ const moreBlocks = groups.more === undefined ? undefined : Number(groups.more)
337
+ return {
338
+ evidenceId: evidenceId !== undefined && Number.isSafeInteger(evidenceId) ? evidenceId : undefined,
339
+ links,
340
+ approxTokens,
341
+ label: groups.label,
342
+ preview: groups.preview,
343
+ moreBlocks: moreBlocks !== undefined && Number.isSafeInteger(moreBlocks) ? moreBlocks : undefined,
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Remove marker paragraphs from a Markdown body. Markers are synthetic: they
349
+ * are never search snippets, never citable, and never scored by quality evals.
350
+ */
351
+ export function stripAppendixMarkers(markdown: string): string {
352
+ if (!markdown.includes(APPENDIX_MARKER_PREFIX)) return markdown
353
+ const kept: string[] = []
354
+ eachLine(markdown, (line, marker) => {
355
+ if (!marker) kept.push(line)
356
+ })
357
+ return kept
358
+ .join('\n')
359
+ .replace(/\n{3,}/g, '\n\n')
360
+ .replace(/\n+$/u, '\n')
361
+ }
362
+
363
+ /** Every marker in a Markdown body, in document order. */
364
+ export function parseAppendixMarkers(markdown: string): AppendixMarker[] {
365
+ const markers: AppendixMarker[] = []
366
+ eachLine(markdown, (_line, marker) => {
367
+ if (marker) markers.push(marker)
368
+ })
369
+ return markers
370
+ }
package/src/Client.ts CHANGED
@@ -1,4 +1,12 @@
1
+ import { Undici } from '@effect/platform-node'
1
2
  import { Effect, Either, Exit, Layer } from 'effect'
3
+ import {
4
+ type CrawlProjectionOptions,
5
+ type CrawlsClient,
6
+ makeCrawlProjectionsClient,
7
+ makeCrawlsClient,
8
+ } from './Crawls.js'
9
+ import { ExpandClientError } from './Error.js'
2
10
  import { InternalClient } from './Internal.js'
3
11
  import {
4
12
  type BatchedParams,
@@ -13,11 +21,14 @@ import {
13
21
  type FetchParams,
14
22
  type FetchRequestOptions,
15
23
  type FetchResponse,
16
- type FetchSearchParams,
17
- type FetchSearchResponse,
18
24
  type GetBatchedParams,
19
25
  type GetBatchedResponse,
20
26
  type RequestOptions,
27
+ type SnapshotCatalog,
28
+ type SnapshotCatalogParams,
29
+ type SnapshotGetParams,
30
+ type SnapshotHighlightsParams,
31
+ type SnapshotHighlightsResponse,
21
32
  } from './Service.js'
22
33
 
23
34
  export type {
@@ -31,15 +42,55 @@ export type {
31
42
  FetchParams,
32
43
  FetchRequestOptions,
33
44
  FetchResponse,
34
- FetchSearchParams,
35
- FetchSearchResponse,
36
45
  GetBatchedParams,
37
46
  GetBatchedResponse,
47
+ SnapshotCatalog,
48
+ SnapshotCatalogParams,
49
+ SnapshotGetParams,
50
+ SnapshotHighlightsParams,
51
+ SnapshotHighlightsResponse,
38
52
  } from './Service.js'
39
53
 
40
54
  /** Options for constructing an {@link ExpandClient}. Same shape as `ExpandServiceOptions`. */
41
55
  export type ExpandClientOptions = ExpandServiceOptions
42
56
 
57
+ export class SnapshotService {
58
+ constructor(
59
+ private readonly baseUrl: string,
60
+ private readonly readSnapshot: (
61
+ params: SnapshotGetParams,
62
+ options?: RequestOptions,
63
+ ) => Promise<SnapshotCatalog | SnapshotHighlightsResponse>,
64
+ ) {}
65
+
66
+ /** Read the public catalog, or request authenticated, billed Highlights from this Snapshot. */
67
+ get(params: SnapshotHighlightsParams, options?: RequestOptions): Promise<SnapshotHighlightsResponse>
68
+ get(params: SnapshotCatalogParams, options?: RequestOptions): Promise<SnapshotCatalog>
69
+ get(params: SnapshotGetParams, options?: RequestOptions): Promise<SnapshotCatalog | SnapshotHighlightsResponse>
70
+ get(params: SnapshotGetParams, options: RequestOptions = {}): Promise<SnapshotCatalog | SnapshotHighlightsResponse> {
71
+ return this.readSnapshot(params, options)
72
+ }
73
+
74
+ async network(params: { readonly snapshotId: string; readonly q?: string }): Promise<unknown> {
75
+ return this.request(`${params.snapshotId}/network${params.q ? `?q=${encodeURIComponent(params.q)}` : ''}`)
76
+ }
77
+
78
+ async preview(params: {
79
+ readonly snapshotId: string
80
+ readonly artifactId: string
81
+ readonly maxBytes?: number
82
+ }): Promise<unknown> {
83
+ const suffix = `/artifacts/${encodeURIComponent(params.artifactId)}/preview${params.maxBytes !== undefined ? `?maxBytes=${params.maxBytes}` : ''}`
84
+ return this.request(`${params.snapshotId}${suffix}`)
85
+ }
86
+
87
+ private async request<T>(path: string): Promise<T> {
88
+ const response = await globalThis.fetch(`${this.baseUrl.replace(/\/+$/u, '')}/v1/public/snapshots/${path}`)
89
+ if (!response.ok) throw new Error(`Snapshot request failed with HTTP ${response.status}`)
90
+ return (await response.json()) as T
91
+ }
92
+ }
93
+
43
94
  /**
44
95
  * Promise-based client for the Expand API. Holds an {@link InternalClient} layer and runs Effects
45
96
  * against it, translating typed failures into the `ExpandClient*Error` hierarchy that async/await
@@ -53,6 +104,18 @@ export type ExpandClientOptions = ExpandServiceOptions
53
104
  */
54
105
  export class ExpandClient {
55
106
  private readonly layer: Layer.Layer<InternalClient>
107
+ private readonly lifetime = new AbortController()
108
+ private readonly activeRequests = new Set<Promise<unknown>>()
109
+ private dispatcher?: Undici.EnvHttpProxyAgent
110
+ private closePromise?: Promise<void>
111
+ /** Create, list and control crawls, and read their event streams. */
112
+ readonly crawls: CrawlsClient
113
+ /** Read stored crawl captures: Markdown, HTML, State JSON and evidence. */
114
+ readonly captures: ReturnType<typeof makeCrawlProjectionsClient>['captures']
115
+ /** Read a crawled page's versions and diffs. */
116
+ readonly pages: ReturnType<typeof makeCrawlProjectionsClient>['pages']
117
+ readonly snapshots: SnapshotService
118
+ private readonly projections: ReturnType<typeof makeCrawlProjectionsClient>
56
119
 
57
120
  constructor(options: ExpandClientOptions = {}) {
58
121
  // Eagerly validate so the Promise constructor matches the documented sync-throw contract.
@@ -65,6 +128,47 @@ export class ExpandClient {
65
128
  // Layer.orDie narrows the error channel to `never` while keeping a die fallback as a defensive
66
129
  // net (translateCause picks up dies via Cause.dieOption and converts via toPublicError).
67
130
  this.layer = InternalClient.layer(options).pipe(Layer.orDie)
131
+ const apiKey = options.apiKey ?? process.env.EXPAND_API_KEY
132
+ if (apiKey === undefined) throw new Error('Missing Expand API key.')
133
+ const proxyAwareFetch: typeof globalThis.fetch = (input, init) => {
134
+ if (this.lifetime.signal.aborted) {
135
+ const reason: unknown = this.lifetime.signal.reason
136
+ return Promise.reject(reason instanceof Error ? reason : new ExpandClientError('The Expand client is closed.'))
137
+ }
138
+ this.dispatcher ??= new Undici.EnvHttpProxyAgent()
139
+ const requestSignal = init?.signal === undefined && input instanceof Request ? input.signal : init?.signal
140
+ const signal = AbortSignal.any([this.lifetime.signal, ...(requestSignal == null ? [] : [requestSignal])])
141
+ const proxyInit: RequestInit & { readonly dispatcher: Undici.Dispatcher } = {
142
+ ...init,
143
+ signal,
144
+ dispatcher: this.dispatcher,
145
+ }
146
+ return globalThis.fetch(input, proxyInit)
147
+ }
148
+ const transport = {
149
+ apiKey,
150
+ baseUrl: options.baseUrl ?? 'https://api.expand.ai',
151
+ fetch: proxyAwareFetch,
152
+ timeoutMs: options.timeoutMs,
153
+ maxRetries: options.maxRetries,
154
+ signal: this.lifetime.signal,
155
+ }
156
+ this.crawls = makeCrawlsClient(transport)
157
+ this.projections = makeCrawlProjectionsClient(transport)
158
+ this.captures = this.projections.captures
159
+ this.pages = this.projections.pages
160
+ this.snapshots = new SnapshotService(options.baseUrl ?? 'https://api.expand.ai', (params, requestOptions) =>
161
+ this.run((client) => client.getSnapshot(params, requestOptions)),
162
+ )
163
+ }
164
+
165
+ /** Abort active requests and release this client's HTTP connections. Repeated calls are safe. */
166
+ close(): Promise<void> {
167
+ if (!this.lifetime.signal.aborted) this.lifetime.abort(new ExpandClientError('The Expand client is closed.'))
168
+ this.closePromise ??= Promise.all([...this.activeRequests, this.dispatcher?.destroy() ?? Promise.resolve()]).then(
169
+ () => undefined,
170
+ )
171
+ return this.closePromise
68
172
  }
69
173
 
70
174
  /**
@@ -83,18 +187,18 @@ export class ExpandClient {
83
187
  * Fetch a URL through the object-mode endpoint.
84
188
  *
85
189
  * Returns `{ meta, markdown, json }`; use {@link fetch} for the raw markdown document.
190
+ * A string argument instead reads an existing JSON projection from this API without starting a capture.
86
191
  */
87
- async fetchJson(params: FetchJsonParams, options: FetchJsonRequestOptions = {}): Promise<FetchJsonResponse> {
192
+ fetchJson(params: FetchJsonParams, options?: FetchJsonRequestOptions): Promise<FetchJsonResponse>
193
+ fetchJson(url: string, options?: CrawlProjectionOptions): Promise<unknown>
194
+ async fetchJson(
195
+ params: FetchJsonParams | string,
196
+ options: FetchJsonRequestOptions & CrawlProjectionOptions = {},
197
+ ): Promise<unknown> {
198
+ if (typeof params === 'string') return this.projections.fetchJson(params, options)
88
199
  return this.run((client) => client.fetchJson(params, options))
89
200
  }
90
201
 
91
- /**
92
- * Search a previous Fetch response by `snapshotId` without starting a new capture.
93
- */
94
- async fetchSearch(params: FetchSearchParams, options: RequestOptions = {}): Promise<FetchSearchResponse> {
95
- return this.run((client) => client.fetchSearch(params, options))
96
- }
97
-
98
202
  /**
99
203
  * Kick off an asynchronous batched fetch over one or more URLs. Returns a batched-run ID that can
100
204
  * be polled via {@link getBatched}.
@@ -132,10 +236,21 @@ export class ExpandClient {
132
236
  }
133
237
 
134
238
  private async run<A>(body: (ic: InternalClient) => Effect.Effect<A, Error>): Promise<A> {
135
- const exit = await Effect.runPromiseExit(InternalClient.use(body).pipe(Effect.provide(this.layer)))
136
- if (Exit.isSuccess(exit)) {
137
- return exit.value
239
+ this.lifetime.signal.throwIfAborted()
240
+ const running = Effect.runPromiseExit(InternalClient.use(body).pipe(Effect.provide(this.layer)), {
241
+ signal: this.lifetime.signal,
242
+ })
243
+ this.activeRequests.add(running)
244
+ try {
245
+ const exit = await running
246
+ this.lifetime.signal.throwIfAborted()
247
+ if (Exit.isSuccess(exit)) return exit.value
248
+ throw InternalClient.translateCause(exit.cause)
249
+ } finally {
250
+ this.activeRequests.delete(running)
138
251
  }
139
- throw InternalClient.translateCause(exit.cause)
140
252
  }
141
253
  }
254
+
255
+ /** Concise public name used by the Crawl API examples. */
256
+ export class Expand extends ExpandClient {}