@or3/intern-client 0.1.1
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 +76 -0
- package/dist/index.js +1362 -0
- package/package.json +34 -0
- package/src/client.ts +676 -0
- package/src/errors.ts +131 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +692 -0
- package/src/redaction.ts +114 -0
- package/src/sse.ts +151 -0
- package/src/transport.ts +915 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternClientError,
|
|
3
|
+
InternUnavailableError,
|
|
4
|
+
type InternErrorCode,
|
|
5
|
+
} from './errors';
|
|
6
|
+
import { redactInternSecrets } from './redaction';
|
|
7
|
+
import {
|
|
8
|
+
internSseEventCursor,
|
|
9
|
+
internSseEventKey,
|
|
10
|
+
readInternSseStream,
|
|
11
|
+
type InternSseEvent,
|
|
12
|
+
} from './sse';
|
|
13
|
+
|
|
14
|
+
export type InternHttpMethod =
|
|
15
|
+
| 'GET'
|
|
16
|
+
| 'POST'
|
|
17
|
+
| 'PUT'
|
|
18
|
+
| 'PATCH'
|
|
19
|
+
| 'DELETE';
|
|
20
|
+
|
|
21
|
+
export interface InternAuthContext {
|
|
22
|
+
method: InternHttpMethod;
|
|
23
|
+
path: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
requireAuth: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InternResolvedAuth {
|
|
29
|
+
token?: string;
|
|
30
|
+
scheme?: string;
|
|
31
|
+
headers?: HeadersInit;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type InternAuthResolver = (
|
|
35
|
+
context: InternAuthContext
|
|
36
|
+
) =>
|
|
37
|
+
| InternResolvedAuth
|
|
38
|
+
| null
|
|
39
|
+
| undefined
|
|
40
|
+
| Promise<InternResolvedAuth | null | undefined>;
|
|
41
|
+
|
|
42
|
+
export interface InternClock {
|
|
43
|
+
now(): number;
|
|
44
|
+
random(): number;
|
|
45
|
+
sleep(milliseconds: number, signal?: AbortSignal): Promise<void>;
|
|
46
|
+
setTimeout(callback: () => void, milliseconds: number): unknown;
|
|
47
|
+
clearTimeout(handle: unknown): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InternTransportOptions {
|
|
51
|
+
baseUrl: string | (() => string);
|
|
52
|
+
fetch?: typeof globalThis.fetch;
|
|
53
|
+
resolveAuth?: InternAuthResolver;
|
|
54
|
+
defaultTimeoutMs?: number;
|
|
55
|
+
streamConnectTimeoutMs?: number;
|
|
56
|
+
clock?: Partial<InternClock>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface InternResponseContext {
|
|
60
|
+
method: InternHttpMethod;
|
|
61
|
+
path: string;
|
|
62
|
+
response: Response;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type InternResponseType = 'json' | 'text' | 'void';
|
|
66
|
+
|
|
67
|
+
export interface InternRequestOptions<T = unknown> {
|
|
68
|
+
method?: InternHttpMethod;
|
|
69
|
+
body?: unknown;
|
|
70
|
+
headers?: HeadersInit;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
requireAuth?: boolean;
|
|
74
|
+
baseUrl?: string;
|
|
75
|
+
responseType?: InternResponseType;
|
|
76
|
+
acceptedStatuses?:
|
|
77
|
+
| readonly number[]
|
|
78
|
+
| ((status: number, response: Response) => boolean);
|
|
79
|
+
onResponse?: (
|
|
80
|
+
context: InternResponseContext
|
|
81
|
+
) => void | Promise<void>;
|
|
82
|
+
parse?: (value: unknown, response: Response) => T;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface InternReconnectOptions {
|
|
86
|
+
maxAttempts?: number;
|
|
87
|
+
minDelayMs?: number;
|
|
88
|
+
maxDelayMs?: number;
|
|
89
|
+
factor?: number;
|
|
90
|
+
jitter?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface InternStreamResumeOptions<T = unknown> {
|
|
94
|
+
initialCursor?: string | number;
|
|
95
|
+
queryParameter?: string;
|
|
96
|
+
sendLastEventId?: boolean;
|
|
97
|
+
cursorFromEvent?: (
|
|
98
|
+
event: InternSseEvent<T>
|
|
99
|
+
) => string | number | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface InternStreamDedupeOptions<T = unknown> {
|
|
103
|
+
maxEntries?: number;
|
|
104
|
+
key?: (event: InternSseEvent<T>) => string | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface InternStreamOptions<T = unknown>
|
|
108
|
+
extends Omit<InternRequestOptions<never>, 'parse' | 'responseType'> {
|
|
109
|
+
reconnect?: boolean | InternReconnectOptions;
|
|
110
|
+
resume?: InternStreamResumeOptions<T>;
|
|
111
|
+
dedupe?: boolean | InternStreamDedupeOptions<T>;
|
|
112
|
+
isTerminal?: (event: InternSseEvent<T>) => boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface InternTransport {
|
|
116
|
+
buildUrl(path: string, explicitBaseUrl?: string): string;
|
|
117
|
+
request<T = unknown>(
|
|
118
|
+
path: string,
|
|
119
|
+
options?: InternRequestOptions<T>
|
|
120
|
+
): Promise<T>;
|
|
121
|
+
stream<T = unknown>(
|
|
122
|
+
path: string,
|
|
123
|
+
options?: InternStreamOptions<T>
|
|
124
|
+
): AsyncIterable<InternSseEvent<T>>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sensitiveQueryKey(key: string): boolean {
|
|
128
|
+
return (
|
|
129
|
+
/^(?:authorization|password|passphrase|secret|api[_-]?key)$/i.test(
|
|
130
|
+
key
|
|
131
|
+
) ||
|
|
132
|
+
/(?:^|[_-])(?:password|passphrase|secret|token|api[_-]?key)$/i.test(
|
|
133
|
+
key
|
|
134
|
+
) ||
|
|
135
|
+
/(?:Password|Passphrase|Secret|Token|ApiKey)$/.test(key)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
140
|
+
const DEFAULT_STREAM_CONNECT_TIMEOUT_MS = 15_000;
|
|
141
|
+
|
|
142
|
+
function defaultSleep(
|
|
143
|
+
milliseconds: number,
|
|
144
|
+
signal?: AbortSignal
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
if (milliseconds <= 0) return Promise.resolve();
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
if (signal?.aborted) {
|
|
149
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const timer = globalThis.setTimeout(done, milliseconds);
|
|
153
|
+
function done() {
|
|
154
|
+
signal?.removeEventListener('abort', aborted);
|
|
155
|
+
resolve();
|
|
156
|
+
}
|
|
157
|
+
function aborted() {
|
|
158
|
+
globalThis.clearTimeout(timer);
|
|
159
|
+
signal?.removeEventListener('abort', aborted);
|
|
160
|
+
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
161
|
+
}
|
|
162
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function createClock(input?: Partial<InternClock>): InternClock {
|
|
167
|
+
return {
|
|
168
|
+
now: input?.now ?? (() => Date.now()),
|
|
169
|
+
random: input?.random ?? (() => Math.random()),
|
|
170
|
+
sleep: input?.sleep ?? defaultSleep,
|
|
171
|
+
setTimeout:
|
|
172
|
+
input?.setTimeout ??
|
|
173
|
+
((callback, milliseconds) =>
|
|
174
|
+
globalThis.setTimeout(callback, milliseconds)),
|
|
175
|
+
clearTimeout:
|
|
176
|
+
input?.clearTimeout ??
|
|
177
|
+
((handle) =>
|
|
178
|
+
globalThis.clearTimeout(
|
|
179
|
+
handle as ReturnType<typeof globalThis.setTimeout>
|
|
180
|
+
)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeBaseUrl(value: string): string {
|
|
185
|
+
let url: URL;
|
|
186
|
+
try {
|
|
187
|
+
url = new URL(value.trim());
|
|
188
|
+
} catch {
|
|
189
|
+
throw new InternClientError(
|
|
190
|
+
'validation_failed',
|
|
191
|
+
'The selected host URL is invalid.'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
195
|
+
throw new InternClientError(
|
|
196
|
+
'validation_failed',
|
|
197
|
+
'The selected host must use HTTP or HTTPS.'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
201
|
+
throw new InternClientError(
|
|
202
|
+
'validation_failed',
|
|
203
|
+
'Host credentials and query parameters must not appear in the URL.'
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return url.toString().replace(/\/+$/, '');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validatePath(path: string): string {
|
|
210
|
+
const trimmed = path.trim();
|
|
211
|
+
if (
|
|
212
|
+
!trimmed ||
|
|
213
|
+
/^[a-z][a-z\d+.-]*:/i.test(trimmed) ||
|
|
214
|
+
trimmed.startsWith('//')
|
|
215
|
+
) {
|
|
216
|
+
throw new InternClientError(
|
|
217
|
+
'validation_failed',
|
|
218
|
+
'Service requests require a relative path.'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
222
|
+
let url: URL;
|
|
223
|
+
try {
|
|
224
|
+
url = new URL(normalized, 'https://or3.invalid');
|
|
225
|
+
} catch {
|
|
226
|
+
throw new InternClientError(
|
|
227
|
+
'validation_failed',
|
|
228
|
+
'The service request path is invalid.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (url.hash) {
|
|
232
|
+
throw new InternClientError(
|
|
233
|
+
'validation_failed',
|
|
234
|
+
'Service request paths must not contain fragments.'
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
for (const key of url.searchParams.keys()) {
|
|
238
|
+
if (sensitiveQueryKey(key)) {
|
|
239
|
+
throw new InternClientError(
|
|
240
|
+
'validation_failed',
|
|
241
|
+
'Credentials and secrets must be sent in headers or request bodies, never query parameters.'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return `${url.pathname}${url.search}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function appendCursor(
|
|
249
|
+
path: string,
|
|
250
|
+
queryParameter: string,
|
|
251
|
+
cursor?: string
|
|
252
|
+
): string {
|
|
253
|
+
const validated = validatePath(path);
|
|
254
|
+
if (cursor === undefined || cursor === '') return validated;
|
|
255
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(queryParameter)) {
|
|
256
|
+
throw new InternClientError(
|
|
257
|
+
'validation_failed',
|
|
258
|
+
'The stream cursor parameter is invalid.'
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const url = new URL(validated, 'https://or3.invalid');
|
|
262
|
+
url.searchParams.set(queryParameter, cursor);
|
|
263
|
+
return `${url.pathname}${url.search}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isAbortError(error: unknown): boolean {
|
|
267
|
+
return (
|
|
268
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
269
|
+
(error instanceof Error && error.name === 'AbortError')
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isRawBody(value: unknown): value is BodyInit {
|
|
274
|
+
if (typeof value === 'string') return true;
|
|
275
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return true;
|
|
276
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
|
277
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
|
|
278
|
+
if (
|
|
279
|
+
typeof URLSearchParams !== 'undefined' &&
|
|
280
|
+
value instanceof URLSearchParams
|
|
281
|
+
) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
typeof ReadableStream !== 'undefined' &&
|
|
286
|
+
value instanceof ReadableStream
|
|
287
|
+
) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function payloadMessage(payload: unknown, fallback: string): string {
|
|
294
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
295
|
+
const record = payload as Record<string, unknown>;
|
|
296
|
+
for (const key of ['message', 'error', 'detail']) {
|
|
297
|
+
if (typeof record[key] === 'string' && record[key].trim()) {
|
|
298
|
+
return record[key].trim();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (typeof payload === 'string' && payload.trim()) return payload.trim();
|
|
303
|
+
return fallback;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function payloadString(payload: unknown, key: string): string | undefined {
|
|
307
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
const value = (payload as Record<string, unknown>)[key];
|
|
311
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
312
|
+
return String(value);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function errorCodeForResponse(
|
|
318
|
+
status: number,
|
|
319
|
+
remoteCode?: string
|
|
320
|
+
): InternErrorCode {
|
|
321
|
+
const code = remoteCode?.toLowerCase() ?? '';
|
|
322
|
+
if (
|
|
323
|
+
status === 503 ||
|
|
324
|
+
code === 'capability_unavailable' ||
|
|
325
|
+
code === 'runner_disabled' ||
|
|
326
|
+
code === 'runner_missing' ||
|
|
327
|
+
code === 'runner_auth_missing'
|
|
328
|
+
) {
|
|
329
|
+
return 'unavailable';
|
|
330
|
+
}
|
|
331
|
+
if (status === 401) return 'unauthorized';
|
|
332
|
+
if (status === 403) return 'forbidden';
|
|
333
|
+
if (status === 404) return 'not_found';
|
|
334
|
+
if (status === 408 || status === 504 || code === 'timeout') return 'timeout';
|
|
335
|
+
if (status === 409) return 'conflict';
|
|
336
|
+
if (status === 400 || status === 422) return 'validation_failed';
|
|
337
|
+
return 'http';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function makeResponseError(
|
|
341
|
+
response: Response,
|
|
342
|
+
payload: unknown,
|
|
343
|
+
secrets: readonly string[]
|
|
344
|
+
): InternClientError {
|
|
345
|
+
const remoteCode = payloadString(payload, 'code');
|
|
346
|
+
const requestId =
|
|
347
|
+
response.headers.get('X-Request-Id') ??
|
|
348
|
+
payloadString(payload, 'request_id');
|
|
349
|
+
const code = errorCodeForResponse(response.status, remoteCode);
|
|
350
|
+
const options = {
|
|
351
|
+
status: response.status,
|
|
352
|
+
remoteCode,
|
|
353
|
+
requestId,
|
|
354
|
+
retryable:
|
|
355
|
+
code === 'timeout' ||
|
|
356
|
+
code === 'offline' ||
|
|
357
|
+
response.status === 429 ||
|
|
358
|
+
response.status >= 500,
|
|
359
|
+
details: {
|
|
360
|
+
status: response.status,
|
|
361
|
+
payload,
|
|
362
|
+
requestId,
|
|
363
|
+
},
|
|
364
|
+
secrets,
|
|
365
|
+
};
|
|
366
|
+
const message = payloadMessage(
|
|
367
|
+
payload,
|
|
368
|
+
`Request failed with status ${response.status}.`
|
|
369
|
+
);
|
|
370
|
+
return code === 'unavailable'
|
|
371
|
+
? new InternUnavailableError(message, options)
|
|
372
|
+
: new InternClientError(code, message, options);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readResponsePayload(response: Response): Promise<unknown> {
|
|
376
|
+
const text = await response.text().catch(() => '');
|
|
377
|
+
if (!text) return undefined;
|
|
378
|
+
try {
|
|
379
|
+
return JSON.parse(text);
|
|
380
|
+
} catch {
|
|
381
|
+
return text;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function statusAccepted(
|
|
386
|
+
response: Response,
|
|
387
|
+
accepted?: InternRequestOptions['acceptedStatuses']
|
|
388
|
+
): boolean {
|
|
389
|
+
if (response.ok) return true;
|
|
390
|
+
if (Array.isArray(accepted)) return accepted.includes(response.status);
|
|
391
|
+
return typeof accepted === 'function'
|
|
392
|
+
? accepted(response.status, response)
|
|
393
|
+
: false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface AbortScope {
|
|
397
|
+
readonly signal: AbortSignal;
|
|
398
|
+
readonly timedOut: () => boolean;
|
|
399
|
+
clearTimeout(): void;
|
|
400
|
+
dispose(): void;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function createAbortScope(
|
|
404
|
+
externalSignal: AbortSignal | undefined,
|
|
405
|
+
timeoutMs: number,
|
|
406
|
+
clock: InternClock
|
|
407
|
+
): AbortScope {
|
|
408
|
+
const controller = new AbortController();
|
|
409
|
+
let timeoutHandle: unknown;
|
|
410
|
+
let didTimeout = false;
|
|
411
|
+
const externalAbort = () => {
|
|
412
|
+
controller.abort(
|
|
413
|
+
externalSignal?.reason ?? new DOMException('Aborted', 'AbortError')
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
if (externalSignal?.aborted) {
|
|
417
|
+
externalAbort();
|
|
418
|
+
} else {
|
|
419
|
+
externalSignal?.addEventListener('abort', externalAbort, {
|
|
420
|
+
once: true,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
424
|
+
timeoutHandle = clock.setTimeout(() => {
|
|
425
|
+
didTimeout = true;
|
|
426
|
+
controller.abort(new DOMException('Timed out', 'TimeoutError'));
|
|
427
|
+
}, timeoutMs);
|
|
428
|
+
}
|
|
429
|
+
const clearTimeout = () => {
|
|
430
|
+
if (timeoutHandle !== undefined) {
|
|
431
|
+
clock.clearTimeout(timeoutHandle);
|
|
432
|
+
timeoutHandle = undefined;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
signal: controller.signal,
|
|
437
|
+
timedOut: () => didTimeout,
|
|
438
|
+
clearTimeout,
|
|
439
|
+
dispose() {
|
|
440
|
+
clearTimeout();
|
|
441
|
+
externalSignal?.removeEventListener('abort', externalAbort);
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function requestFailure(
|
|
447
|
+
error: unknown,
|
|
448
|
+
scope: AbortScope,
|
|
449
|
+
externalSignal: AbortSignal | undefined,
|
|
450
|
+
secrets: readonly string[]
|
|
451
|
+
): InternClientError {
|
|
452
|
+
if (error instanceof InternClientError) return error;
|
|
453
|
+
if (scope.timedOut()) {
|
|
454
|
+
return new InternClientError('timeout', 'The request timed out.', {
|
|
455
|
+
retryable: true,
|
|
456
|
+
cause: error,
|
|
457
|
+
secrets,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (externalSignal?.aborted || isAbortError(error)) {
|
|
461
|
+
return new InternClientError('aborted', 'The request was stopped.', {
|
|
462
|
+
cause: error,
|
|
463
|
+
secrets,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return new InternClientError(
|
|
467
|
+
'offline',
|
|
468
|
+
'Could not reach the selected host.',
|
|
469
|
+
{
|
|
470
|
+
retryable: true,
|
|
471
|
+
cause: error,
|
|
472
|
+
secrets,
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function reconnectConfig(
|
|
478
|
+
input: InternStreamOptions['reconnect']
|
|
479
|
+
): Required<InternReconnectOptions> | null {
|
|
480
|
+
if (!input) return null;
|
|
481
|
+
const options = input === true ? {} : input;
|
|
482
|
+
const finite = (value: number | undefined, fallback: number) =>
|
|
483
|
+
Number.isFinite(value) ? (value as number) : fallback;
|
|
484
|
+
return {
|
|
485
|
+
maxAttempts: Math.min(
|
|
486
|
+
100,
|
|
487
|
+
Math.max(
|
|
488
|
+
0,
|
|
489
|
+
Math.floor(finite(options.maxAttempts, 5))
|
|
490
|
+
)
|
|
491
|
+
),
|
|
492
|
+
minDelayMs: Math.min(
|
|
493
|
+
60_000,
|
|
494
|
+
Math.max(0, finite(options.minDelayMs, 250))
|
|
495
|
+
),
|
|
496
|
+
maxDelayMs: Math.min(
|
|
497
|
+
60_000,
|
|
498
|
+
Math.max(0, finite(options.maxDelayMs, 10_000))
|
|
499
|
+
),
|
|
500
|
+
factor: Math.min(
|
|
501
|
+
10,
|
|
502
|
+
Math.max(1, finite(options.factor, 2))
|
|
503
|
+
),
|
|
504
|
+
jitter: Math.min(
|
|
505
|
+
1,
|
|
506
|
+
Math.max(0, finite(options.jitter, 0.2))
|
|
507
|
+
),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function reconnectDelay(
|
|
512
|
+
attempt: number,
|
|
513
|
+
config: Required<InternReconnectOptions>,
|
|
514
|
+
random: number,
|
|
515
|
+
serverRetry?: number
|
|
516
|
+
): number {
|
|
517
|
+
const exponential =
|
|
518
|
+
serverRetry ??
|
|
519
|
+
Math.min(
|
|
520
|
+
config.maxDelayMs,
|
|
521
|
+
config.minDelayMs * config.factor ** Math.max(0, attempt - 1)
|
|
522
|
+
);
|
|
523
|
+
const bounded = Math.min(config.maxDelayMs, Math.max(0, exponential));
|
|
524
|
+
const jittered =
|
|
525
|
+
bounded *
|
|
526
|
+
(1 - config.jitter + config.jitter * 2 * Math.min(1, Math.max(0, random)));
|
|
527
|
+
return Math.round(jittered);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function shouldReconnect(error: InternClientError): boolean {
|
|
531
|
+
return (
|
|
532
|
+
error.retryable ||
|
|
533
|
+
error.code === 'offline' ||
|
|
534
|
+
error.code === 'timeout' ||
|
|
535
|
+
(error.code === 'http' && (error.status ?? 0) >= 500)
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
class BoundedEventKeys {
|
|
540
|
+
private readonly values = new Set<string>();
|
|
541
|
+
private readonly order: string[] = [];
|
|
542
|
+
|
|
543
|
+
constructor(private readonly limit: number) {}
|
|
544
|
+
|
|
545
|
+
hasOrAdd(key: string): boolean {
|
|
546
|
+
if (this.values.has(key)) return true;
|
|
547
|
+
this.values.add(key);
|
|
548
|
+
this.order.push(key);
|
|
549
|
+
while (this.order.length > this.limit) {
|
|
550
|
+
const oldest = this.order.shift();
|
|
551
|
+
if (oldest !== undefined) this.values.delete(oldest);
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function createInternTransport(
|
|
558
|
+
options: InternTransportOptions
|
|
559
|
+
): InternTransport {
|
|
560
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
561
|
+
if (typeof fetchImpl !== 'function') {
|
|
562
|
+
throw new InternUnavailableError(
|
|
563
|
+
'No Fetch-compatible transport is available.',
|
|
564
|
+
{ capability: 'fetch' }
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const clock = createClock(options.clock);
|
|
568
|
+
const configuredTimeout =
|
|
569
|
+
options.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
570
|
+
const configuredStreamTimeout =
|
|
571
|
+
options.streamConnectTimeoutMs ?? DEFAULT_STREAM_CONNECT_TIMEOUT_MS;
|
|
572
|
+
|
|
573
|
+
const currentBaseUrl = (explicit?: string) =>
|
|
574
|
+
normalizeBaseUrl(
|
|
575
|
+
explicit ??
|
|
576
|
+
(typeof options.baseUrl === 'function'
|
|
577
|
+
? options.baseUrl()
|
|
578
|
+
: options.baseUrl)
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const buildUrl = (path: string, explicitBaseUrl?: string): string =>
|
|
582
|
+
`${currentBaseUrl(explicitBaseUrl)}${validatePath(path)}`;
|
|
583
|
+
|
|
584
|
+
async function resolvedHeaders(
|
|
585
|
+
method: InternHttpMethod,
|
|
586
|
+
path: string,
|
|
587
|
+
requestOptions: Pick<
|
|
588
|
+
InternRequestOptions,
|
|
589
|
+
'headers' | 'requireAuth' | 'baseUrl'
|
|
590
|
+
>,
|
|
591
|
+
accept: string
|
|
592
|
+
): Promise<{ headers: Headers; secrets: string[] }> {
|
|
593
|
+
const requireAuth = requestOptions.requireAuth !== false;
|
|
594
|
+
const baseUrl = currentBaseUrl(requestOptions.baseUrl);
|
|
595
|
+
const headers = new Headers(requestOptions.headers);
|
|
596
|
+
if (!headers.has('Accept')) headers.set('Accept', accept);
|
|
597
|
+
const auth = requireAuth
|
|
598
|
+
? await options.resolveAuth?.({
|
|
599
|
+
method,
|
|
600
|
+
path,
|
|
601
|
+
baseUrl,
|
|
602
|
+
requireAuth,
|
|
603
|
+
})
|
|
604
|
+
: undefined;
|
|
605
|
+
const authHeaders = new Headers(auth?.headers);
|
|
606
|
+
authHeaders.forEach((value, key) => headers.set(key, value));
|
|
607
|
+
const token = auth?.token?.trim();
|
|
608
|
+
if (token) {
|
|
609
|
+
headers.set(
|
|
610
|
+
'Authorization',
|
|
611
|
+
`${auth?.scheme?.trim() || 'Bearer'} ${token}`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (
|
|
615
|
+
requireAuth &&
|
|
616
|
+
options.resolveAuth &&
|
|
617
|
+
!headers.has('Authorization')
|
|
618
|
+
) {
|
|
619
|
+
throw new InternClientError(
|
|
620
|
+
'unauthorized',
|
|
621
|
+
'No credential is available for the selected host.',
|
|
622
|
+
{ status: 401 }
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const secrets: string[] = [];
|
|
626
|
+
if (token) secrets.push(token);
|
|
627
|
+
for (const [key, value] of headers.entries()) {
|
|
628
|
+
if (
|
|
629
|
+
/authorization|cookie|secret|password|token|api[_-]?key/i.test(
|
|
630
|
+
key
|
|
631
|
+
)
|
|
632
|
+
) {
|
|
633
|
+
secrets.push(value, value.replace(/^\S+\s+/, ''));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return { headers, secrets: secrets.filter(Boolean) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function request<T = unknown>(
|
|
640
|
+
path: string,
|
|
641
|
+
requestOptions: InternRequestOptions<T> = {}
|
|
642
|
+
): Promise<T> {
|
|
643
|
+
const method =
|
|
644
|
+
requestOptions.method ??
|
|
645
|
+
(requestOptions.body === undefined ? 'GET' : 'POST');
|
|
646
|
+
const validatedPath = validatePath(path);
|
|
647
|
+
const { headers, secrets } = await resolvedHeaders(
|
|
648
|
+
method,
|
|
649
|
+
validatedPath,
|
|
650
|
+
requestOptions,
|
|
651
|
+
'application/json'
|
|
652
|
+
);
|
|
653
|
+
let body: BodyInit | undefined;
|
|
654
|
+
if (requestOptions.body !== undefined) {
|
|
655
|
+
if (isRawBody(requestOptions.body)) {
|
|
656
|
+
body = requestOptions.body as BodyInit;
|
|
657
|
+
} else {
|
|
658
|
+
body = JSON.stringify(requestOptions.body);
|
|
659
|
+
if (!headers.has('Content-Type')) {
|
|
660
|
+
headers.set('Content-Type', 'application/json');
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const scope = createAbortScope(
|
|
665
|
+
requestOptions.signal,
|
|
666
|
+
requestOptions.timeoutMs ?? configuredTimeout,
|
|
667
|
+
clock
|
|
668
|
+
);
|
|
669
|
+
try {
|
|
670
|
+
const response = await fetchImpl(
|
|
671
|
+
buildUrl(validatedPath, requestOptions.baseUrl),
|
|
672
|
+
{
|
|
673
|
+
method,
|
|
674
|
+
headers,
|
|
675
|
+
body,
|
|
676
|
+
cache: 'no-store',
|
|
677
|
+
signal: scope.signal,
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
await requestOptions.onResponse?.({
|
|
681
|
+
method,
|
|
682
|
+
path: validatedPath,
|
|
683
|
+
response,
|
|
684
|
+
});
|
|
685
|
+
if (
|
|
686
|
+
!statusAccepted(response, requestOptions.acceptedStatuses)
|
|
687
|
+
) {
|
|
688
|
+
const payload = await readResponsePayload(response);
|
|
689
|
+
throw makeResponseError(response, payload, secrets);
|
|
690
|
+
}
|
|
691
|
+
const responseType = requestOptions.responseType ?? 'json';
|
|
692
|
+
let value: unknown;
|
|
693
|
+
if (responseType === 'void' || response.status === 204) {
|
|
694
|
+
value = undefined;
|
|
695
|
+
} else if (responseType === 'text') {
|
|
696
|
+
value = await response.text();
|
|
697
|
+
} else {
|
|
698
|
+
try {
|
|
699
|
+
value = await response.json();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
throw new InternClientError(
|
|
702
|
+
'protocol',
|
|
703
|
+
'The host returned invalid JSON.',
|
|
704
|
+
{
|
|
705
|
+
status: response.status,
|
|
706
|
+
cause: error,
|
|
707
|
+
secrets,
|
|
708
|
+
}
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!requestOptions.parse) return value as T;
|
|
713
|
+
try {
|
|
714
|
+
return requestOptions.parse(value, response);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof InternClientError) throw error;
|
|
717
|
+
throw new InternClientError(
|
|
718
|
+
'protocol',
|
|
719
|
+
'The host returned an invalid response.',
|
|
720
|
+
{
|
|
721
|
+
status: response.status,
|
|
722
|
+
details: redactInternSecrets(value, secrets),
|
|
723
|
+
cause: error,
|
|
724
|
+
secrets,
|
|
725
|
+
}
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
} catch (error) {
|
|
729
|
+
throw requestFailure(
|
|
730
|
+
error,
|
|
731
|
+
scope,
|
|
732
|
+
requestOptions.signal,
|
|
733
|
+
secrets
|
|
734
|
+
);
|
|
735
|
+
} finally {
|
|
736
|
+
scope.dispose();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function* stream<T = unknown>(
|
|
741
|
+
path: string,
|
|
742
|
+
streamOptions: InternStreamOptions<T> = {}
|
|
743
|
+
): AsyncIterable<InternSseEvent<T>> {
|
|
744
|
+
const method = streamOptions.method ?? 'GET';
|
|
745
|
+
const reconnect = reconnectConfig(streamOptions.reconnect);
|
|
746
|
+
const resume = streamOptions.resume;
|
|
747
|
+
const queryParameter = resume?.queryParameter ?? 'cursor';
|
|
748
|
+
const dedupeInput = streamOptions.dedupe;
|
|
749
|
+
const dedupeOptions =
|
|
750
|
+
dedupeInput === true
|
|
751
|
+
? {}
|
|
752
|
+
: dedupeInput && typeof dedupeInput === 'object'
|
|
753
|
+
? dedupeInput
|
|
754
|
+
: null;
|
|
755
|
+
const eventKeys = dedupeOptions
|
|
756
|
+
? new BoundedEventKeys(
|
|
757
|
+
Math.max(1, Math.floor(dedupeOptions.maxEntries ?? 2048))
|
|
758
|
+
)
|
|
759
|
+
: null;
|
|
760
|
+
let cursor =
|
|
761
|
+
resume?.initialCursor === undefined
|
|
762
|
+
? undefined
|
|
763
|
+
: String(resume.initialCursor);
|
|
764
|
+
let attempt = 0;
|
|
765
|
+
let serverRetry: number | undefined;
|
|
766
|
+
|
|
767
|
+
while (true) {
|
|
768
|
+
const attemptPath = appendCursor(path, queryParameter, cursor);
|
|
769
|
+
const attemptHeaders = new Headers(streamOptions.headers);
|
|
770
|
+
if (cursor && resume?.sendLastEventId) {
|
|
771
|
+
attemptHeaders.set('Last-Event-ID', cursor);
|
|
772
|
+
}
|
|
773
|
+
const { headers, secrets } = await resolvedHeaders(
|
|
774
|
+
method,
|
|
775
|
+
attemptPath,
|
|
776
|
+
{ ...streamOptions, headers: attemptHeaders },
|
|
777
|
+
'text/event-stream'
|
|
778
|
+
);
|
|
779
|
+
let body: BodyInit | undefined;
|
|
780
|
+
if (streamOptions.body !== undefined) {
|
|
781
|
+
if (isRawBody(streamOptions.body)) {
|
|
782
|
+
body = streamOptions.body as BodyInit;
|
|
783
|
+
} else {
|
|
784
|
+
body = JSON.stringify(streamOptions.body);
|
|
785
|
+
if (!headers.has('Content-Type')) {
|
|
786
|
+
headers.set('Content-Type', 'application/json');
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const scope = createAbortScope(
|
|
791
|
+
streamOptions.signal,
|
|
792
|
+
streamOptions.timeoutMs ?? configuredStreamTimeout,
|
|
793
|
+
clock
|
|
794
|
+
);
|
|
795
|
+
let failure: InternClientError | undefined;
|
|
796
|
+
let ended = false;
|
|
797
|
+
try {
|
|
798
|
+
const response = await fetchImpl(
|
|
799
|
+
buildUrl(attemptPath, streamOptions.baseUrl),
|
|
800
|
+
{
|
|
801
|
+
method,
|
|
802
|
+
headers,
|
|
803
|
+
body,
|
|
804
|
+
cache: 'no-store',
|
|
805
|
+
signal: scope.signal,
|
|
806
|
+
}
|
|
807
|
+
);
|
|
808
|
+
scope.clearTimeout();
|
|
809
|
+
await streamOptions.onResponse?.({
|
|
810
|
+
method,
|
|
811
|
+
path: attemptPath,
|
|
812
|
+
response,
|
|
813
|
+
});
|
|
814
|
+
if (
|
|
815
|
+
!statusAccepted(
|
|
816
|
+
response,
|
|
817
|
+
streamOptions.acceptedStatuses
|
|
818
|
+
)
|
|
819
|
+
) {
|
|
820
|
+
const payload = await readResponsePayload(response);
|
|
821
|
+
throw makeResponseError(response, payload, secrets);
|
|
822
|
+
}
|
|
823
|
+
if (!response.body) {
|
|
824
|
+
throw new InternClientError(
|
|
825
|
+
'protocol',
|
|
826
|
+
'The host did not return an event stream.',
|
|
827
|
+
{ status: response.status, secrets }
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
for await (const event of readInternSseStream<T>(
|
|
831
|
+
response.body,
|
|
832
|
+
scope.signal
|
|
833
|
+
)) {
|
|
834
|
+
if (event.retry !== undefined) {
|
|
835
|
+
serverRetry = event.retry;
|
|
836
|
+
}
|
|
837
|
+
const nextCursor =
|
|
838
|
+
resume?.cursorFromEvent?.(event) ??
|
|
839
|
+
internSseEventCursor(
|
|
840
|
+
event as InternSseEvent<unknown>
|
|
841
|
+
);
|
|
842
|
+
if (nextCursor !== undefined) {
|
|
843
|
+
cursor = String(nextCursor);
|
|
844
|
+
event.cursor = cursor;
|
|
845
|
+
}
|
|
846
|
+
const key =
|
|
847
|
+
dedupeOptions?.key?.(event) ??
|
|
848
|
+
internSseEventKey(
|
|
849
|
+
event as InternSseEvent<unknown>
|
|
850
|
+
);
|
|
851
|
+
if (key && eventKeys?.hasOrAdd(key)) continue;
|
|
852
|
+
yield event;
|
|
853
|
+
if (streamOptions.isTerminal?.(event)) return;
|
|
854
|
+
}
|
|
855
|
+
ended = true;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
failure = requestFailure(
|
|
858
|
+
error,
|
|
859
|
+
scope,
|
|
860
|
+
streamOptions.signal,
|
|
861
|
+
secrets
|
|
862
|
+
);
|
|
863
|
+
} finally {
|
|
864
|
+
scope.dispose();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (streamOptions.signal?.aborted) {
|
|
868
|
+
throw (
|
|
869
|
+
failure ??
|
|
870
|
+
new InternClientError(
|
|
871
|
+
'aborted',
|
|
872
|
+
'The stream was stopped.'
|
|
873
|
+
)
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (!reconnect) {
|
|
877
|
+
if (failure) throw failure;
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const reconnectFailure =
|
|
881
|
+
failure ??
|
|
882
|
+
new InternClientError(
|
|
883
|
+
'offline',
|
|
884
|
+
ended
|
|
885
|
+
? 'The event stream ended before a terminal event.'
|
|
886
|
+
: 'The event stream disconnected.',
|
|
887
|
+
{ retryable: true }
|
|
888
|
+
);
|
|
889
|
+
if (
|
|
890
|
+
!shouldReconnect(reconnectFailure) ||
|
|
891
|
+
attempt >= reconnect.maxAttempts
|
|
892
|
+
) {
|
|
893
|
+
throw reconnectFailure;
|
|
894
|
+
}
|
|
895
|
+
attempt += 1;
|
|
896
|
+
const delay = reconnectDelay(
|
|
897
|
+
attempt,
|
|
898
|
+
reconnect,
|
|
899
|
+
clock.random(),
|
|
900
|
+
serverRetry
|
|
901
|
+
);
|
|
902
|
+
try {
|
|
903
|
+
await clock.sleep(delay, streamOptions.signal);
|
|
904
|
+
} catch (error) {
|
|
905
|
+
throw new InternClientError(
|
|
906
|
+
'aborted',
|
|
907
|
+
'The stream was stopped.',
|
|
908
|
+
{ cause: error, secrets }
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return { buildUrl, request, stream };
|
|
915
|
+
}
|