@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.
- package/README.md +11 -0
- package/build/dts/Error.d.ts +25 -1
- package/build/dts/Error.d.ts.map +1 -1
- package/build/dts/Internal.d.ts.map +1 -1
- package/build/dts/RetryAfter.d.ts +2 -0
- package/build/dts/RetryAfter.d.ts.map +1 -1
- package/build/dts/Service.d.ts +9 -2
- package/build/dts/Service.d.ts.map +1 -1
- package/build/dts/Tunnel/Headers.d.ts +2 -0
- package/build/dts/Tunnel/Headers.d.ts.map +1 -0
- package/build/dts/Tunnel/LocalTarget.d.ts +122 -0
- package/build/dts/Tunnel/LocalTarget.d.ts.map +1 -0
- package/build/dts/Tunnel/Protocol.d.ts +160 -0
- package/build/dts/Tunnel/Protocol.d.ts.map +1 -0
- package/build/dts/Tunnel/StaticServer.d.ts +10 -0
- package/build/dts/Tunnel/StaticServer.d.ts.map +1 -0
- package/build/dts/Tunnel/Tunnel.d.ts +40 -0
- package/build/dts/Tunnel/Tunnel.d.ts.map +1 -0
- package/build/dts/index.d.ts +2 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/esm/Error.js +27 -0
- package/build/esm/Error.js.map +1 -1
- package/build/esm/Internal.js +9 -1
- package/build/esm/Internal.js.map +1 -1
- package/build/esm/RetryAfter.js +3 -1
- package/build/esm/RetryAfter.js.map +1 -1
- package/build/esm/Service.js +23 -7
- package/build/esm/Service.js.map +1 -1
- package/build/esm/Tunnel/Headers.js +18 -0
- package/build/esm/Tunnel/Headers.js.map +1 -0
- package/build/esm/Tunnel/LocalTarget.js +125 -0
- package/build/esm/Tunnel/LocalTarget.js.map +1 -0
- package/build/esm/Tunnel/Protocol.js +73 -0
- package/build/esm/Tunnel/Protocol.js.map +1 -0
- package/build/esm/Tunnel/StaticServer.js +69 -0
- package/build/esm/Tunnel/StaticServer.js.map +1 -0
- package/build/esm/Tunnel/Tunnel.js +302 -0
- package/build/esm/Tunnel/Tunnel.js.map +1 -0
- package/build/esm/index.js +1 -1
- package/build/esm/index.js.map +1 -1
- package/package.json +6 -3
- package/src/Error.ts +53 -1
- package/src/Internal.ts +10 -0
- package/src/RetryAfter.ts +4 -1
- package/src/Service.ts +48 -8
- package/src/Tunnel/Headers.ts +22 -0
- package/src/Tunnel/LocalTarget.ts +153 -0
- package/src/Tunnel/Protocol.ts +99 -0
- package/src/Tunnel/StaticServer.ts +85 -0
- package/src/Tunnel/Tunnel.ts +420 -0
- 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'
|