@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.
@@ -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
+ }