@expandai/sdk 0.20.1 → 0.20.2

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 (51) hide show
  1. package/README.md +11 -0
  2. package/build/dts/Error.d.ts +25 -1
  3. package/build/dts/Error.d.ts.map +1 -1
  4. package/build/dts/Internal.d.ts.map +1 -1
  5. package/build/dts/RetryAfter.d.ts +2 -0
  6. package/build/dts/RetryAfter.d.ts.map +1 -1
  7. package/build/dts/Service.d.ts +9 -2
  8. package/build/dts/Service.d.ts.map +1 -1
  9. package/build/dts/Tunnel/Headers.d.ts +2 -0
  10. package/build/dts/Tunnel/Headers.d.ts.map +1 -0
  11. package/build/dts/Tunnel/LocalTarget.d.ts +122 -0
  12. package/build/dts/Tunnel/LocalTarget.d.ts.map +1 -0
  13. package/build/dts/Tunnel/Protocol.d.ts +160 -0
  14. package/build/dts/Tunnel/Protocol.d.ts.map +1 -0
  15. package/build/dts/Tunnel/StaticServer.d.ts +10 -0
  16. package/build/dts/Tunnel/StaticServer.d.ts.map +1 -0
  17. package/build/dts/Tunnel/Tunnel.d.ts +40 -0
  18. package/build/dts/Tunnel/Tunnel.d.ts.map +1 -0
  19. package/build/dts/index.d.ts +2 -2
  20. package/build/dts/index.d.ts.map +1 -1
  21. package/build/esm/Error.js +27 -0
  22. package/build/esm/Error.js.map +1 -1
  23. package/build/esm/Internal.js +9 -1
  24. package/build/esm/Internal.js.map +1 -1
  25. package/build/esm/RetryAfter.js +3 -1
  26. package/build/esm/RetryAfter.js.map +1 -1
  27. package/build/esm/Service.js +23 -7
  28. package/build/esm/Service.js.map +1 -1
  29. package/build/esm/Tunnel/Headers.js +18 -0
  30. package/build/esm/Tunnel/Headers.js.map +1 -0
  31. package/build/esm/Tunnel/LocalTarget.js +125 -0
  32. package/build/esm/Tunnel/LocalTarget.js.map +1 -0
  33. package/build/esm/Tunnel/Protocol.js +73 -0
  34. package/build/esm/Tunnel/Protocol.js.map +1 -0
  35. package/build/esm/Tunnel/StaticServer.js +69 -0
  36. package/build/esm/Tunnel/StaticServer.js.map +1 -0
  37. package/build/esm/Tunnel/Tunnel.js +302 -0
  38. package/build/esm/Tunnel/Tunnel.js.map +1 -0
  39. package/build/esm/index.js +1 -1
  40. package/build/esm/index.js.map +1 -1
  41. package/package.json +6 -3
  42. package/src/Error.ts +53 -1
  43. package/src/Internal.ts +10 -0
  44. package/src/RetryAfter.ts +4 -1
  45. package/src/Service.ts +48 -8
  46. package/src/Tunnel/Headers.ts +22 -0
  47. package/src/Tunnel/LocalTarget.ts +153 -0
  48. package/src/Tunnel/Protocol.ts +99 -0
  49. package/src/Tunnel/StaticServer.ts +85 -0
  50. package/src/Tunnel/Tunnel.ts +420 -0
  51. package/src/index.ts +3 -1
@@ -0,0 +1,420 @@
1
+ import { createServer } from 'node:http'
2
+ import {
3
+ Cookies,
4
+ FetchHttpClient,
5
+ HttpBody,
6
+ HttpClient,
7
+ HttpClientRequest,
8
+ type HttpMethod,
9
+ Path,
10
+ Socket,
11
+ } from '@effect/platform'
12
+ import { NodeFileSystem, NodeHttpServer, NodePath } from '@effect/platform-node'
13
+ import { Config, Deferred, Duration, Effect, FiberMap, Iterable, Layer, Match, Option, Schema, Stream } from 'effect'
14
+ import { WebSocket } from 'ws'
15
+ import { ExpandLocalTargetError } from '../Error.js'
16
+ import { endToEndHeaders } from './Headers.js'
17
+ import { LocalTarget, resolveLocalTarget, resolveSafeDirectory } from './LocalTarget.js'
18
+ import {
19
+ type ClientToRelay,
20
+ decodeBodyFrame,
21
+ encodeBodyFrame,
22
+ encodeClientToRelay,
23
+ type Hello,
24
+ MAX_BODY_BYTES,
25
+ MAX_INFLIGHT_REQUESTS,
26
+ PROTOCOL_VERSION,
27
+ RelayToClient,
28
+ type RequestId,
29
+ type RequestStart,
30
+ splitBodyChunk,
31
+ } from './Protocol.js'
32
+ import { serveDirectory, serveFile } from './StaticServer.js'
33
+
34
+ export class TunnelError extends Schema.TaggedError<TunnelError>()('TunnelError', {
35
+ reason: Schema.Literal('Unauthorized', 'Configuration', 'RelayUnreachable'),
36
+ message: Schema.String,
37
+ cause: Schema.optional(Schema.Defect),
38
+ }) {}
39
+
40
+ /** What the relay needs to accept a tunnel: the API base URL it derives from, and the caller's credentials. */
41
+ interface TunnelCredentials {
42
+ readonly baseUrl: string
43
+ /** `x-expand-api-key`, or the CLI's OAuth `authorization`. */
44
+ readonly headers: Readonly<Record<string, string>>
45
+ }
46
+
47
+ const HELLO_TIMEOUT = Duration.seconds(10)
48
+ const MAX_QUEUED_RELAY_BYTES = 16 * 1024 * 1024
49
+ // A masked WebSocket frame has at most 14 bytes of framing in addition to its payload.
50
+ const MAX_WEBSOCKET_FRAME_OVERHEAD = 14
51
+ const RELAY_URLS = new Map([
52
+ ['https://api.expand.ai', 'https://expandusercontent.com'],
53
+ ['https://api.staging.expand.ai', 'https://staging.expandusercontent.com'],
54
+ ])
55
+ const decodeControlFrame = Schema.decodeUnknownOption(Schema.parseJson(RelayToClient))
56
+ const decodeBody = Option.liftThrowable(decodeBodyFrame)
57
+ // Hop-by-hop or recomputed by `fetch`; forwarding them breaks the local request.
58
+ const DROPPED_REQUEST_HEADERS = new Set(['host', 'content-length', 'accept-encoding'])
59
+ // `fetch` decoded the body; cookies are forwarded separately to preserve repeated fields.
60
+ const DROPPED_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'set-cookie'])
61
+
62
+ function rewriteLocalLocation(value: string, localUrl: string, publicUrl: string): string {
63
+ if (!URL.canParse(value)) return value
64
+ const location = new URL(value)
65
+ return location.origin === new URL(localUrl).origin
66
+ ? `${publicUrl}${location.pathname}${location.search}${location.hash}`
67
+ : value
68
+ }
69
+
70
+ // `fetch` reports "fetch failed" and hides the reason in `cause`; a refused connection is an
71
+ // `AggregateError` (one attempt per address) with an empty message and the reason in `code`.
72
+ function describeFetchError(error: unknown): string {
73
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error
74
+ if (!(cause instanceof Error)) return String(cause)
75
+ return cause.message || ('code' in cause && typeof cause.code === 'string' ? cause.code : cause.name)
76
+ }
77
+
78
+ /** Uses the relay for the selected API environment, unless `EXPAND_TUNNEL_URL` says otherwise. */
79
+ const relayUrlFor = Effect.fn('Tunnel.relayUrlFor')(function* (baseUrl: string) {
80
+ const override = yield* Config.option(Config.string('EXPAND_TUNNEL_URL')).pipe(Effect.orDie)
81
+ const configured = Option.orElse(override, () => Option.fromNullable(RELAY_URLS.get(baseUrl.replace(/\/+$/u, ''))))
82
+ if (Option.isNone(configured)) {
83
+ return yield* new TunnelError({
84
+ reason: 'Configuration',
85
+ message: `No tunnel relay is known for ${baseUrl}; set EXPAND_TUNNEL_URL.`,
86
+ })
87
+ }
88
+ const relayUrl = configured.value
89
+ if (!URL.canParse(relayUrl) || !['http:', 'https:'].includes(new URL(relayUrl).protocol)) {
90
+ return yield* new TunnelError({
91
+ reason: 'Configuration',
92
+ message: 'EXPAND_TUNNEL_URL must be an http:// or https:// relay address.',
93
+ })
94
+ }
95
+ return relayUrl.replace(/\/+$/u, '')
96
+ })
97
+
98
+ function tunnelOpenError(relayUrl: string, status: number): TunnelError {
99
+ if (status === 401 || status === 403) {
100
+ return new TunnelError({ reason: 'Unauthorized', message: 'The tunnel relay rejected the Expand credentials.' })
101
+ }
102
+ const retryable = status === 0 || status === 408 || status === 429 || status >= 500
103
+ return new TunnelError({
104
+ reason: retryable ? 'RelayUnreachable' : 'Configuration',
105
+ message: `Could not open a tunnel at ${relayUrl}${status === 0 ? '' : ` (HTTP ${status})`}.`,
106
+ })
107
+ }
108
+
109
+ // Effect owns receiving and socket lifetime; native send callbacks supply the backpressure its writer lacks.
110
+ const makeRelaySocket = Effect.fn('Tunnel.makeRelaySocket')(function* (
111
+ relayUrl: string,
112
+ headers: Readonly<Record<string, string>>,
113
+ ) {
114
+ const opened = yield* Deferred.make<WebSocket>()
115
+ let rejectedStatus = 0
116
+ const acquire = Effect.acquireRelease(
117
+ Effect.sync(() => {
118
+ // Disable compression so bufferedAmount plus frame overhead bounds the actual native send queue.
119
+ const ws = new WebSocket(`${relayUrl.replace(/^http/u, 'ws')}/v1/connect`, { headers, perMessageDeflate: false })
120
+ ws.once('open', () => Deferred.unsafeDone(opened, Effect.succeed(ws)))
121
+ ws.once('unexpected-response', (_request, response) => {
122
+ rejectedStatus = response.statusCode ?? 0
123
+ ws.terminate()
124
+ })
125
+ return ws
126
+ }),
127
+ // Graceful close queues behind body frames and can retain them for ws's 30-second close timeout.
128
+ (ws) => Effect.sync(() => ws.terminate()),
129
+ )
130
+ const socket = yield* Socket.fromWebSocket(
131
+ // `ws` implements the WHATWG event interface `fromWebSocket` uses.
132
+ acquire.pipe(Effect.map((ws) => ws as unknown as globalThis.WebSocket)),
133
+ )
134
+ const writer = Effect.map(socket.writer, (closeWrite) =>
135
+ Effect.fnUntraced(function* (chunk: Uint8Array | string | Socket.CloseEvent) {
136
+ if (Socket.isCloseEvent(chunk)) return yield* closeWrite(chunk)
137
+ const ws = yield* Deferred.await(opened)
138
+ return yield* Effect.async<void, Socket.SocketError>((resume) => {
139
+ const fail = (cause: unknown) => Effect.fail(new Socket.SocketGenericError({ reason: 'Write', cause }))
140
+ if (ws.readyState !== WebSocket.OPEN) {
141
+ resume(fail(new Error('Tunnel connection closed.')))
142
+ return
143
+ }
144
+ const bytes = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength
145
+ if (ws.bufferedAmount + bytes + MAX_WEBSOCKET_FRAME_OVERHEAD > MAX_QUEUED_RELAY_BYTES) {
146
+ // Cancelled requests leave native writes queued, so overflow must end the whole transport.
147
+ ws.terminate()
148
+ resume(fail(new Error('Tunnel relay stopped reading.')))
149
+ return
150
+ }
151
+ let complete: typeof resume | undefined = resume
152
+ try {
153
+ ws.send(chunk, (error) => {
154
+ complete?.(error ? fail(error) : Effect.void)
155
+ complete = undefined
156
+ })
157
+ } catch (error: unknown) {
158
+ complete = undefined
159
+ resume(fail(error))
160
+ }
161
+ // ws retains the send callback until flushing or close; don't retain an interrupted request fiber.
162
+ return Effect.sync(() => {
163
+ complete = undefined
164
+ })
165
+ })
166
+ }),
167
+ )
168
+ return { socket: Socket.Socket.of({ ...socket, writer }), rejectedStatus: () => rejectedStatus }
169
+ })
170
+
171
+ // Where the relay's requests go, and the path on the public URL that shows the target.
172
+ const localOrigin = Effect.fn('Tunnel.localOrigin')(
173
+ function* (target: LocalTarget) {
174
+ if (LocalTarget.$is('Port')(target)) return { host: target.host, port: target.port, path: target.path }
175
+ const path = yield* Path.Path
176
+ const isFile = LocalTarget.$is('File')(target)
177
+ const directory = isFile ? target.path : yield* resolveSafeDirectory(target.path)
178
+ // Build into the tunnel's scope so the listener stays open after this function returns.
179
+ const port = yield* Layer.build(NodeHttpServer.layer(() => createServer(), { host: '127.0.0.1', port: 0 })).pipe(
180
+ Effect.flatMap((context) =>
181
+ (isFile ? serveFile(target.path) : serveDirectory(directory)).pipe(Effect.provide(context)),
182
+ ),
183
+ Effect.mapError(
184
+ (cause) =>
185
+ new ExpandLocalTargetError({
186
+ reason: 'Unavailable',
187
+ input: target.path,
188
+ cause,
189
+ message: `Could not start a local file server for "${target.path}". ${cause.message}`,
190
+ }),
191
+ ),
192
+ )
193
+ return { host: '127.0.0.1', port, path: isFile ? `/${encodeURIComponent(path.basename(target.path))}` : '/' }
194
+ },
195
+ Effect.provide(Layer.merge(NodePath.layer, NodeFileSystem.layer)),
196
+ )
197
+
198
+ const makeResponder = Effect.fn('Tunnel.makeResponder')(function* (
199
+ socket: Socket.Socket,
200
+ localUrl: string,
201
+ hello: Deferred.Deferred<typeof Hello.Type, TunnelError>,
202
+ ) {
203
+ // Loopback requests must not inherit API proxies or trace user paths and forwarded credentials.
204
+ const http = HttpClient.withScope(yield* HttpClient.HttpClient).pipe(
205
+ HttpClient.withTracerDisabledWhen(() => true),
206
+ HttpClient.withTracerPropagation(false),
207
+ )
208
+ const write = yield* socket.writer
209
+ const send = (frame: ClientToRelay) => write(encodeClientToRelay(frame))
210
+ return Effect.fn('Tunnel.respond')(
211
+ function* (start: typeof RequestStart.Type, body: Option.Option<Uint8Array<ArrayBuffer>>) {
212
+ const { url: publicUrl } = yield* Deferred.await(hello)
213
+ const response = yield* http
214
+ .execute(
215
+ // Effect's method union is narrower than fetch; preserve custom methods from the relay.
216
+ HttpClientRequest.make(start.method as HttpMethod.HttpMethod)(`${localUrl}${start.path}`, {
217
+ headers: endToEndHeaders(start.headers).filter(
218
+ ([name]) => !DROPPED_REQUEST_HEADERS.has(name.toLowerCase()),
219
+ ),
220
+ body: Option.isSome(body) ? HttpBody.raw(body.value) : undefined,
221
+ }),
222
+ )
223
+ .pipe(Effect.mapError((error) => describeFetchError(error.cause ?? error)))
224
+ yield* send({
225
+ _tag: 'ResponseStart',
226
+ v: PROTOCOL_VERSION,
227
+ id: start.id,
228
+ status: response.status,
229
+ headers: endToEndHeaders(Object.entries(response.headers))
230
+ .filter(([name]) => !DROPPED_RESPONSE_HEADERS.has(name))
231
+ // A dev server redirecting to its own absolute URL must send the fetcher back through the tunnel.
232
+ .map(([name, value]): readonly [string, string] => [
233
+ name,
234
+ name === 'location' ? rewriteLocalLocation(value, localUrl, publicUrl) : value,
235
+ ])
236
+ // Effect's flat Headers record loses repeated Set-Cookie fields.
237
+ .concat(Cookies.toSetCookieHeaders(response.cookies).map((cookie) => ['set-cookie', cookie])),
238
+ })
239
+ let responseBytes = 0
240
+ yield* response.stream.pipe(
241
+ Stream.catchTag('ResponseError', (error) =>
242
+ error.reason === 'EmptyBody' ? Stream.empty : Stream.fail(describeFetchError(error.cause ?? error)),
243
+ ),
244
+ Stream.runForEach(
245
+ Effect.fnUntraced(function* (chunk: Uint8Array) {
246
+ responseBytes += chunk.byteLength
247
+ // Enforce the response bound independently of transport backpressure and relay cancellation.
248
+ if (responseBytes > MAX_BODY_BYTES) return yield* Effect.fail('response body too large')
249
+ yield* Effect.forEach(splitBodyChunk(chunk), (slice) => write(encodeBodyFrame(start.id, slice)))
250
+ }),
251
+ ),
252
+ )
253
+ yield* send({ _tag: 'ResponseEnd', v: PROTOCOL_VERSION, id: start.id })
254
+ },
255
+ (effect, start) =>
256
+ effect.pipe(
257
+ Effect.scoped,
258
+ Effect.catchAll((error) =>
259
+ send({
260
+ _tag: 'ResponseError',
261
+ v: PROTOCOL_VERSION,
262
+ id: start.id,
263
+ message: `${localUrl} did not answer: ${String(error)}`,
264
+ }),
265
+ ),
266
+ Effect.ignore,
267
+ ),
268
+ )
269
+ })
270
+
271
+ const runRequests = Effect.fn('Tunnel.runRequests')(function* (
272
+ socket: Socket.Socket,
273
+ localUrl: string,
274
+ hello: Deferred.Deferred<typeof Hello.Type, TunnelError>,
275
+ ) {
276
+ const write = yield* socket.writer
277
+ const send = (frame: ClientToRelay) => write(encodeClientToRelay(frame))
278
+ const requestFibers = yield* FiberMap.make<RequestId, void, never>()
279
+ const run = yield* FiberMap.runtime(requestFibers)()
280
+
281
+ const respond = yield* makeResponder(socket, localUrl, hello)
282
+ function runRequest(start: typeof RequestStart.Type, body: Option.Option<Uint8Array<ArrayBuffer>>) {
283
+ run(start.id, respond(start, body))
284
+ }
285
+ // Bookkeeping runs synchronously in frame order so body chunks always land after their
286
+ // `RequestStart` and before their `RequestEnd`; only the returned effects run concurrently.
287
+ const bodies = new Map<
288
+ RequestId,
289
+ { readonly start: typeof RequestStart.Type; readonly chunks: Uint8Array[]; bytes: number }
290
+ >()
291
+ function handleBodyFrame(data: Uint8Array) {
292
+ const body = decodeBody(data)
293
+ if (Option.isNone(body)) return
294
+ const pending = bodies.get(body.value.id)
295
+ if (pending === undefined) return
296
+ pending.chunks.push(body.value.chunk)
297
+ pending.bytes += body.value.chunk.byteLength
298
+ // The public URL is open to anyone who learns it; do not let a request body exhaust memory.
299
+ if (pending.bytes <= MAX_BODY_BYTES) return
300
+ bodies.delete(body.value.id)
301
+ return send({
302
+ _tag: 'ResponseError',
303
+ v: PROTOCOL_VERSION,
304
+ id: body.value.id,
305
+ message: 'request body too large',
306
+ })
307
+ }
308
+ function handleControlFrame(data: string) {
309
+ return Option.match(decodeControlFrame(data), {
310
+ onNone: () => Effect.logDebug('Ignoring unrecognised tunnel frame'),
311
+ onSome: Match.valueTags({
312
+ Hello: (frame) => Deferred.succeed(hello, frame),
313
+ RequestStart: (start) => {
314
+ if (bodies.has(start.id) || FiberMap.unsafeHas(requestFibers, start.id)) return
315
+ if (bodies.size + Iterable.size(requestFibers) >= MAX_INFLIGHT_REQUESTS) {
316
+ return send({
317
+ _tag: 'ResponseError',
318
+ v: PROTOCOL_VERSION,
319
+ id: start.id,
320
+ message: 'too many local requests',
321
+ })
322
+ }
323
+ if (!start.hasBody) {
324
+ runRequest(start, Option.none())
325
+ return
326
+ }
327
+ bodies.set(start.id, { start, chunks: [], bytes: 0 })
328
+ return Effect.void
329
+ },
330
+ RequestCancel: ({ id }) => {
331
+ bodies.delete(id)
332
+ return FiberMap.remove(requestFibers, id)
333
+ },
334
+ RequestEnd: ({ id }) => {
335
+ const pending = Option.fromNullable(bodies.get(id))
336
+ bodies.delete(id)
337
+ return Option.match(pending, {
338
+ onNone: () => Effect.void,
339
+ onSome: ({ start, chunks }) => {
340
+ runRequest(start, Option.some(Buffer.concat(chunks)))
341
+ return Effect.void
342
+ },
343
+ })
344
+ },
345
+ }),
346
+ })
347
+ }
348
+ return yield* socket
349
+ .runRaw((data) => (typeof data === 'string' ? handleControlFrame(data) : handleBodyFrame(data)))
350
+ .pipe(Effect.ensuring(Effect.sync(() => bodies.clear())))
351
+ })
352
+
353
+ /**
354
+ * Opens a tunnel from the relay to `target` for the lifetime of the scope and returns its public URL.
355
+ * Closing the scope closes the socket and, for file targets, the static server.
356
+ */
357
+ export const openTunnel = Effect.fn('Tunnel.openTunnel')(
358
+ function* (target: LocalTarget, relayUrl: string, headers: Readonly<Record<string, string>>) {
359
+ const origin = yield* localOrigin(target)
360
+ const { socket, rejectedStatus } = yield* makeRelaySocket(relayUrl, headers)
361
+ const hello = yield* Deferred.make<typeof Hello.Type, TunnelError>()
362
+
363
+ // If the socket ends before Hello the open fails; after Hello, the API simply sees the origin go away.
364
+ yield* runRequests(socket, `http://${origin.host}:${origin.port}`, hello).pipe(
365
+ Effect.scoped,
366
+ Effect.exit,
367
+ Effect.flatMap(() => Deferred.fail(hello, tunnelOpenError(relayUrl, rejectedStatus()))),
368
+ Effect.forkScoped,
369
+ )
370
+
371
+ const { url } = yield* Deferred.await(hello).pipe(
372
+ Effect.timeoutFail({
373
+ duration: HELLO_TIMEOUT,
374
+ onTimeout: () =>
375
+ new TunnelError({
376
+ reason: 'RelayUnreachable',
377
+ message: `No answer from the tunnel relay at ${relayUrl}.`,
378
+ }),
379
+ }),
380
+ )
381
+ return `${url}${origin.path}`
382
+ },
383
+ Effect.provide(FetchHttpClient.layer),
384
+ Effect.provideService(FetchHttpClient.RequestInit, { redirect: 'manual' }),
385
+ )
386
+
387
+ /**
388
+ * Runs `use` with a public URL for `input`, which must be a local target (see `resolveLocalTarget`),
389
+ * and closes the tunnel afterwards.
390
+ */
391
+ export const withLocalTunnel: <A, E, R, CredentialsError>(
392
+ input: string,
393
+ credentials: Effect.Effect<TunnelCredentials, CredentialsError>,
394
+ use: (url: string) => Effect.Effect<A, E, R>,
395
+ ) => Effect.Effect<A, E | CredentialsError | TunnelError | ExpandLocalTargetError, R> = Effect.fn(
396
+ 'Tunnel.withLocalTunnel',
397
+ )(
398
+ function* <A, E, R, CredentialsError>(
399
+ input: string,
400
+ credentials: Effect.Effect<TunnelCredentials, CredentialsError>,
401
+ use: (url: string) => Effect.Effect<A, E, R>,
402
+ ) {
403
+ const target = yield* resolveLocalTarget(input).pipe(
404
+ Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
405
+ )
406
+ const { baseUrl, headers } = yield* credentials
407
+ const relayUrl = yield* relayUrlFor(baseUrl)
408
+ const url = yield* openTunnel(target, relayUrl, headers)
409
+ return yield* use(url)
410
+ },
411
+ (effect, input) =>
412
+ effect.pipe(
413
+ Effect.mapError((error) =>
414
+ error instanceof ExpandLocalTargetError && error.input !== input
415
+ ? new ExpandLocalTargetError({ reason: error.reason, input, message: error.message, cause: error.cause })
416
+ : error,
417
+ ),
418
+ Effect.scoped,
419
+ ),
420
+ )
package/src/index.ts CHANGED
@@ -30,14 +30,16 @@ export type {
30
30
  } from './Client.js'
31
31
  export { Expand, ExpandClient } from './Client.js'
32
32
  export * from './Crawls.js'
33
- export type { ExpandError } from './Error.js'
33
+ export type { ExpandError, LocalTargetErrorReason } from './Error.js'
34
34
  export {
35
35
  ExpandApiError,
36
36
  ExpandClientApiError,
37
37
  ExpandClientConnectionError,
38
38
  ExpandClientError,
39
+ ExpandClientLocalTargetError,
39
40
  ExpandClientTimeoutError,
40
41
  ExpandConnectionError,
42
+ ExpandLocalTargetError,
41
43
  ExpandSdkError,
42
44
  ExpandTimeoutError,
43
45
  } from './Error.js'