@typeship-ax/cli 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +40 -0
  3. package/api.json +5163 -0
  4. package/api.md +512 -0
  5. package/dist/cli-agent.d.ts +204 -0
  6. package/dist/cli-agent.d.ts.map +1 -0
  7. package/dist/cli-agent.js +525 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +2811 -0
  11. package/dist/core/http.d.ts +303 -0
  12. package/dist/core/http.d.ts.map +1 -0
  13. package/dist/core/http.js +770 -0
  14. package/dist/core/pagination.d.ts +51 -0
  15. package/dist/core/pagination.d.ts.map +1 -0
  16. package/dist/core/pagination.js +154 -0
  17. package/dist/dates.d.ts +33 -0
  18. package/dist/dates.d.ts.map +1 -0
  19. package/dist/dates.js +136 -0
  20. package/dist/errors.d.ts +81 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +103 -0
  23. package/dist/index.d.ts +92 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +86 -0
  26. package/dist/ops.d.ts +115 -0
  27. package/dist/ops.d.ts.map +1 -0
  28. package/dist/ops.js +79 -0
  29. package/dist/resources/account.d.ts +18 -0
  30. package/dist/resources/account.d.ts.map +1 -0
  31. package/dist/resources/account.js +26 -0
  32. package/dist/resources/api-keys.d.ts +37 -0
  33. package/dist/resources/api-keys.d.ts.map +1 -0
  34. package/dist/resources/api-keys.js +67 -0
  35. package/dist/resources/generate.d.ts +25 -0
  36. package/dist/resources/generate.d.ts.map +1 -0
  37. package/dist/resources/generate.js +41 -0
  38. package/dist/resources/generations.d.ts +31 -0
  39. package/dist/resources/generations.d.ts.map +1 -0
  40. package/dist/resources/generations.js +56 -0
  41. package/dist/resources/projects.d.ts +110 -0
  42. package/dist/resources/projects.d.ts.map +1 -0
  43. package/dist/resources/projects.js +220 -0
  44. package/dist/resources/spec-revisions.d.ts +47 -0
  45. package/dist/resources/spec-revisions.d.ts.map +1 -0
  46. package/dist/resources/spec-revisions.js +90 -0
  47. package/dist/schemas.d.ts +6 -0
  48. package/dist/schemas.d.ts.map +1 -0
  49. package/dist/schemas.js +88 -0
  50. package/dist/types.d.ts +759 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +37 -0
  53. package/package.json +43 -0
  54. package/src/cli-agent.ts +685 -0
  55. package/src/cli.ts +2695 -0
  56. package/src/core/http.ts +1008 -0
  57. package/src/core/pagination.ts +195 -0
  58. package/src/dates.ts +126 -0
  59. package/src/errors.ts +117 -0
  60. package/src/index.ts +153 -0
  61. package/src/ops.ts +174 -0
  62. package/src/resources/account.ts +43 -0
  63. package/src/resources/api-keys.ts +105 -0
  64. package/src/resources/generate.ts +69 -0
  65. package/src/resources/generations.ts +100 -0
  66. package/src/resources/projects.ts +391 -0
  67. package/src/resources/spec-revisions.ts +150 -0
  68. package/src/schemas.ts +90 -0
  69. package/src/types.ts +825 -0
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Runtime core. Generated by typeship — https://typeship.dev
3
+ * Zero dependencies: built on the platform fetch API (Node 18+, browsers, edge).
4
+ */
5
+
6
+ export interface RequestOptions {
7
+ /** Abort the request (composed with the per-attempt timeout). */
8
+ signal?: AbortSignal;
9
+ /** Extra headers for this call. */
10
+ headers?: Record<string, string | undefined>;
11
+ /** Per-attempt timeout in milliseconds. Overrides the client default. */
12
+ timeoutMs?: number;
13
+ /** Retry attempts after the first try. Overrides the client default. */
14
+ maxRetries?: number;
15
+ }
16
+
17
+ export interface ResponseMeta {
18
+ status: number;
19
+ headers: Headers;
20
+ /** x-request-id / request-id header when the server sends one. */
21
+ requestId?: string;
22
+ }
23
+
24
+ /**
25
+ * A static credential or a callback resolved before every attempt. Use a
26
+ * callback for tokens that expire (OAuth access tokens, STS, Vault).
27
+ */
28
+ export type AuthValue = string | (() => string | Promise<string>);
29
+
30
+ /** Passed to onRequest/onResponse hooks. Mutations to headers and url in
31
+ * onRequest apply to the outgoing request. */
32
+ export interface RequestContext {
33
+ method: string;
34
+ url: string;
35
+ headers: Record<string, string>;
36
+ /** 0 on the first try, increments per retry. */
37
+ attempt: number;
38
+ }
39
+
40
+ /** One server-sent event from a text/event-stream response. */
41
+ export interface SseEvent {
42
+ /** The `event:` field; undefined for unnamed events. */
43
+ event?: string;
44
+ /** The `id:` field, when the server sends one. */
45
+ id?: string;
46
+ /** Concatenated `data:` lines. Parse as JSON if your API sends JSON. */
47
+ data: string;
48
+ }
49
+
50
+ /**
51
+ * Every SDK call returns a discriminated result instead of throwing.
52
+ * Narrow on `ok` and the error side is a typed union of the documented
53
+ * error responses for that exact operation.
54
+ */
55
+ export type ApiResult<T, E> =
56
+ | { ok: true; data: T; response: ResponseMeta }
57
+ | { ok: false; error: E; response?: ResponseMeta };
58
+
59
+ /** Collapse a result into its data, throwing the typed error when !ok. */
60
+ export function unwrap<T, E>(result: ApiResult<T, E>): T {
61
+ if (result.ok) return result.data;
62
+ if (result.error instanceof Error) throw result.error;
63
+ throw new Error(String(result.error));
64
+ }
65
+
66
+ /** Base class for every HTTP error response. */
67
+ export class ApiError<S extends number = number, B = unknown> extends Error {
68
+ readonly status: S;
69
+ readonly body: B;
70
+ readonly response: ResponseMeta;
71
+
72
+ constructor(message: string, status: S, body: B, response: ResponseMeta) {
73
+ super(message);
74
+ this.name = new.target.name;
75
+ this.status = status;
76
+ this.body = body;
77
+ this.response = response;
78
+ }
79
+ }
80
+
81
+ /** A response status the spec didn't document. */
82
+ export class UnexpectedApiError extends ApiError<number, unknown> {
83
+ constructor(status: number, body: unknown, response: ResponseMeta) {
84
+ super("Unexpected HTTP status " + status, status, body, response);
85
+ }
86
+ }
87
+
88
+ /** A 200 response whose GraphQL payload carried errors. */
89
+ export class GraphQLRequestError extends Error {
90
+ /** The raw errors array from the GraphQL response. */
91
+ readonly errors: { message?: string; path?: unknown[]; extensions?: unknown }[];
92
+ readonly response: ResponseMeta;
93
+
94
+ constructor(errors: unknown[], response: ResponseMeta) {
95
+ const first = (errors[0] as { message?: string } | undefined)?.message;
96
+ super(first ?? "GraphQL request returned errors");
97
+ this.name = "GraphQLRequestError";
98
+ this.errors = errors as GraphQLRequestError["errors"];
99
+ this.response = response;
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // GraphQL selections — typed field picking over the schema's result types
105
+ // ---------------------------------------------------------------------------
106
+
107
+ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
108
+ type Unwrap<T> = NonNullable<T> extends readonly (infer U)[] ? Unwrap<U> : NonNullable<T>;
109
+ type IsUnion<T, U = T> = T extends unknown ? ([U] extends [T] ? false : true) : never;
110
+ type TypeNameOf<T> = T extends { __typename?: infer N } ? Extract<N, string> : never;
111
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
112
+
113
+ /**
114
+ * A selection over a GraphQL result type `T`: `true` picks a leaf field, a
115
+ * nested object picks inside an object field, and `on` picks per concrete
116
+ * type when `T` is a union or interface (`{ on: { Transaction: { amount: true } } }`).
117
+ * Keys are checked against the schema, so a typo is a compile error.
118
+ */
119
+ export type Selection<T> = Unwrap<T> extends Primitive ? never : SelectionObject<Unwrap<T>>;
120
+
121
+ type FieldSelection<V> = Unwrap<V> extends Primitive ? true : SelectionObject<Unwrap<V>>;
122
+
123
+ type SelectionObject<T> = {
124
+ [K in Extract<keyof T, string> as K extends "__typename" ? never : K]?: FieldSelection<T[K]>;
125
+ } & { __typename?: true } & (IsUnion<T> extends true
126
+ ? { on?: { [N in TypeNameOf<T>]?: SelectionObject<Extract<T, { __typename?: N }>> } }
127
+ : { on?: never });
128
+
129
+ /** The result type a {@link Selection} `S` produces from result type `T`:
130
+ * exactly the fields picked, nullability and lists preserved, union members
131
+ * narrowed by `__typename`. */
132
+ export type Selected<T, S> = T extends readonly (infer U)[]
133
+ ? Selected<U, S>[]
134
+ : T extends Primitive
135
+ ? T
136
+ : S extends object
137
+ ? Prettify<SelectedMember<T, S>>
138
+ : never;
139
+
140
+ type SelectedMember<T, S> = (S extends { on?: infer O }
141
+ ? O extends object
142
+ ? TypeNameOf<T> extends keyof O
143
+ ? PickSelected<T, NonNullable<O[TypeNameOf<T>]>>
144
+ : {}
145
+ : {}
146
+ : {}) &
147
+ PickSelected<T, S>;
148
+
149
+ type PickSelected<T, S> = {
150
+ -readonly [K in keyof S as K extends "on" ? never : K extends keyof T ? (S[K] extends true | object ? K : never) : never]-?: K extends "__typename"
151
+ ? NonNullable<T[K & keyof T]>
152
+ : S[K] extends true
153
+ ? Exclude<T[K & keyof T], undefined>
154
+ : Selected<Exclude<T[K & keyof T], undefined>, S[K]>;
155
+ };
156
+
157
+ /** A selection object or a raw selection set (`"{ id name }"`). */
158
+ export type SelectionInput = string | Record<string, unknown>;
159
+
160
+ /** Serialize a selection object to a GraphQL selection set. Strings pass
161
+ * through untouched. */
162
+ export function selectionToString(selection: SelectionInput): string {
163
+ if (typeof selection === "string") return selection;
164
+ const parts: string[] = [];
165
+ for (const [key, value] of Object.entries(selection)) {
166
+ if (!value) continue;
167
+ if (key === "on" && typeof value === "object") {
168
+ for (const [typeName, sub] of Object.entries(value as Record<string, unknown>)) {
169
+ if (sub && typeof sub === "object") parts.push("... on " + typeName + " " + selectionToString(sub as Record<string, unknown>));
170
+ }
171
+ } else if (value === true) {
172
+ parts.push(key);
173
+ } else if (typeof value === "object") {
174
+ parts.push(key + " " + selectionToString(value as Record<string, unknown>));
175
+ }
176
+ }
177
+ return "{ " + (parts.length > 0 ? parts.join(" ") : "__typename") + " }";
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Optional runtime validation — zero-dependency, schema table in schemas.ts
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export interface Violation { path: string; message: string }
185
+
186
+ /** Request or response data did not match the spec's schema (opt-in via the
187
+ * client's validate option). Never thrown: returned as the error side of
188
+ * ApiResult, like every other failure. */
189
+ export class ValidationError extends Error {
190
+ readonly direction: "request" | "response";
191
+ readonly violations: Violation[];
192
+ constructor(direction: "request" | "response", violations: Violation[]) {
193
+ const shown = violations.slice(0, 3).map((v) => v.path + " " + v.message).join("; ");
194
+ super(direction + " body failed schema validation: " + shown
195
+ + (violations.length > 3 ? " (+" + (violations.length - 3) + " more)" : ""));
196
+ this.name = "ValidationError";
197
+ this.direction = direction;
198
+ this.violations = violations;
199
+ }
200
+ }
201
+
202
+ function jsonType(value: unknown): string {
203
+ if (value === null) return "null";
204
+ if (Array.isArray(value)) return "array";
205
+ return typeof value;
206
+ }
207
+
208
+ function typeMatches(value: unknown, t: string): boolean {
209
+ if (t === "integer") return typeof value === "number" && Number.isInteger(value);
210
+ return jsonType(value) === t;
211
+ }
212
+
213
+ const MAX_VIOLATIONS = 50;
214
+
215
+ /**
216
+ * Validates against the depth-capped JSON Schema subset emitted in
217
+ * schemas.ts. Constraints outside the subset (formats, multipleOf, not, ...)
218
+ * are ignored: validation can miss drift but never false-alarms.
219
+ */
220
+ export function validateAgainstSchema(value: unknown, schema: unknown, path: string, out: Violation[], defs?: Record<string, unknown>): void {
221
+ if (out.length >= MAX_VIOLATIONS) return;
222
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return;
223
+ const s = schema as Record<string, any>;
224
+
225
+ // Named components are deduplicated into the DEFS table (see schemas.ts);
226
+ // recursion is bounded by the data's own depth, so cycles terminate.
227
+ if (typeof s.$ref === "string") {
228
+ validateAgainstSchema(value, defs?.[s.$ref], path, out, defs);
229
+ return;
230
+ }
231
+
232
+ if (Array.isArray(s.allOf)) for (const sub of s.allOf) validateAgainstSchema(value, sub, path, out, defs);
233
+ const variants = s.anyOf ?? s.oneOf;
234
+ if (Array.isArray(variants) && variants.length > 0) {
235
+ const matched = variants.some((sub: unknown) => {
236
+ const scratch: Violation[] = [];
237
+ validateAgainstSchema(value, sub, path, scratch, defs);
238
+ return scratch.length === 0;
239
+ });
240
+ if (!matched) out.push({ path, message: "matches none of the allowed variants" });
241
+ }
242
+
243
+ if (s.type !== undefined) {
244
+ const allowed: string[] = Array.isArray(s.type) ? s.type : [s.type];
245
+ if (s.nullable === true && !allowed.includes("null")) allowed.push("null");
246
+ if (!allowed.some((t) => typeMatches(value, t))) {
247
+ out.push({ path, message: "expected " + allowed.join(" | ") + ", got " + jsonType(value) });
248
+ return; // remaining constraints assume the right type
249
+ }
250
+ }
251
+ if (value === null) return;
252
+
253
+ if (Array.isArray(s.enum) && !s.enum.some((e: unknown) => JSON.stringify(e) === JSON.stringify(value))) {
254
+ out.push({ path, message: "not one of the allowed enum values" });
255
+ }
256
+ if (s.const !== undefined && JSON.stringify(s.const) !== JSON.stringify(value)) {
257
+ out.push({ path, message: "does not equal the required constant" });
258
+ }
259
+ if (typeof value === "number") {
260
+ if (typeof s.minimum === "number" && value < s.minimum) out.push({ path, message: "below minimum " + s.minimum });
261
+ if (typeof s.maximum === "number" && value > s.maximum) out.push({ path, message: "above maximum " + s.maximum });
262
+ }
263
+ if (typeof value === "string") {
264
+ if (typeof s.minLength === "number" && value.length < s.minLength) out.push({ path, message: "shorter than minLength " + s.minLength });
265
+ if (typeof s.maxLength === "number" && value.length > s.maxLength) out.push({ path, message: "longer than maxLength " + s.maxLength });
266
+ if (typeof s.pattern === "string") {
267
+ try {
268
+ if (!new RegExp(s.pattern).test(value)) out.push({ path, message: "does not match pattern" });
269
+ } catch { /* invalid pattern in the spec: skip, never false-alarm */ }
270
+ }
271
+ }
272
+ if (Array.isArray(value)) {
273
+ if (typeof s.minItems === "number" && value.length < s.minItems) out.push({ path, message: "fewer than minItems " + s.minItems });
274
+ if (typeof s.maxItems === "number" && value.length > s.maxItems) out.push({ path, message: "more than maxItems " + s.maxItems });
275
+ if (s.items) {
276
+ for (let i = 0; i < value.length && out.length < MAX_VIOLATIONS; i++) {
277
+ validateAgainstSchema(value[i], s.items, path + "[" + i + "]", out, defs);
278
+ }
279
+ }
280
+ }
281
+ if (jsonType(value) === "object") {
282
+ const obj = value as Record<string, unknown>;
283
+ if (Array.isArray(s.required)) {
284
+ for (const key of s.required) {
285
+ if (obj[key as string] === undefined) out.push({ path, message: "missing required property " + JSON.stringify(key) });
286
+ }
287
+ }
288
+ if (s.properties && typeof s.properties === "object") {
289
+ for (const [key, sub] of Object.entries(s.properties)) {
290
+ if (obj[key] !== undefined) validateAgainstSchema(obj[key], sub, path + "." + key, out, defs);
291
+ }
292
+ if (s.additionalProperties === false) {
293
+ for (const key of Object.keys(obj)) {
294
+ if (!(key in (s.properties as object))) out.push({ path, message: "unexpected property " + JSON.stringify(key) });
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * "fetch failed" alone is useless in a bug report; surface the request line
303
+ * and the deepest cause message (getaddrinfo ENOTFOUND, ECONNREFUSED, ...)
304
+ * the platform buried in the error chain.
305
+ */
306
+ function transportFailureMessage(method: string, url: string, cause: unknown): string {
307
+ let detail = cause instanceof Error ? cause.message : String(cause ?? "request failed");
308
+ let node: unknown = cause;
309
+ for (let depth = 0; depth < 5 && node instanceof Error; depth++) {
310
+ node = Array.isArray((node as Error & { errors?: unknown[] }).errors)
311
+ ? (node as Error & { errors?: unknown[] }).errors![0]
312
+ : (node as Error & { cause?: unknown }).cause;
313
+ if (node instanceof Error && node.message && !detail.includes(node.message)) {
314
+ detail += ": " + node.message;
315
+ }
316
+ }
317
+ return method + " " + url + " failed: " + detail;
318
+ }
319
+
320
+ /** The request never produced an HTTP response (network failure, timeout, abort). */
321
+ export class TransportError extends Error {
322
+ override readonly cause?: unknown;
323
+
324
+ constructor(message: string, cause?: unknown) {
325
+ super(message);
326
+ this.name = "TransportError";
327
+ this.cause = cause;
328
+ }
329
+ }
330
+
331
+ type ErrorCtor = new (body: any, response: ResponseMeta) => ApiError<number, unknown>;
332
+
333
+ export interface CoreRequest {
334
+ method: string;
335
+ path: string;
336
+ query?: Record<string, unknown>;
337
+ headers?: Record<string, string | undefined>;
338
+ body?: unknown;
339
+ bodyKind?: "json" | "form" | "multipart" | "text" | "binary";
340
+ /** Status matcher -> generated error class ("404", "4XX", "default"). */
341
+ errors?: Record<string, ErrorCtor>;
342
+ /** Idempotent requests are retried automatically. */
343
+ idempotent?: boolean;
344
+ /** Success body is text/event-stream: yield SseEvents instead of parsing. */
345
+ stream?: boolean;
346
+ /** Header name auto-filled with one UUID per call (stable across retries)
347
+ * when the caller doesn't supply a value. */
348
+ idempotencyKey?: string;
349
+ /** GraphQL: unwrap body.data[field] and turn body.errors into a
350
+ * GraphQLRequestError. */
351
+ graphqlField?: string;
352
+ /** Key into the schemas table for optional runtime validation. */
353
+ schemaKey?: string;
354
+ /** Operation-level retry policy (x-typeship-retries), merged over the
355
+ * client-level policy; per-call options.maxRetries still wins. */
356
+ retry?: RetryPolicy;
357
+ options?: RequestOptions;
358
+ }
359
+
360
+ /** Tunable retry behavior (x-typeship-retries; defaults preserved when unset). */
361
+ export interface RetryPolicy {
362
+ maxRetries?: number;
363
+ /** Replaces the default retryable set (408, 429, 500, 502, 503, 504). */
364
+ statuses?: number[];
365
+ initialDelayMs?: number;
366
+ maxDelayMs?: number;
367
+ /** Also retry non-idempotent methods (POST/PATCH). */
368
+ retryNonIdempotent?: boolean;
369
+ }
370
+
371
+ export interface CoreConfig {
372
+ baseUrl: string;
373
+ headers: Record<string, AuthValue>;
374
+ /** Auth carried as query parameters (apiKey-in-query schemes). */
375
+ query: Record<string, AuthValue>;
376
+ fetch: typeof fetch;
377
+ timeoutMs: number;
378
+ maxRetries: number;
379
+ /** Called before every attempt; mutate context.headers / context.url. */
380
+ onRequest?: (context: RequestContext) => void | Promise<void>;
381
+ /** Called after every HTTP response, before parsing and retry decisions.
382
+ * Clone the response before reading its body. */
383
+ onResponse?: (response: Response, context: RequestContext) => void | Promise<void>;
384
+ /** Called once per failed call — after retries are exhausted, with the
385
+ * typed error about to be returned. Observability only; the error is
386
+ * returned unchanged. */
387
+ onError?: (error: unknown, request: { method: string; path: string }) => void | Promise<void>;
388
+ /** One structured event per attempt. Never includes headers or bodies,
389
+ * so nothing secret can reach logs through it. */
390
+ debug?: (event: DebugEvent) => void;
391
+ /** Opt-in runtime validation of JSON request/response bodies against the
392
+ * spec's schemas. Zero-dependency: the validator lives in this file and
393
+ * the schema table in schemas.ts. */
394
+ validate?: { requests: boolean; responses: boolean; mode: "throw" | "warn" };
395
+ /** Per-operation schema table, keyed "resource.method" (see schemas.ts). */
396
+ schemas?: Record<string, { req?: unknown; res?: unknown }>;
397
+ /** Shared component definitions the schema table references. */
398
+ schemaDefs?: Record<string, unknown>;
399
+ /** Root-level retry policy (x-typeship-retries at the document root). */
400
+ retry?: RetryPolicy;
401
+ /** Client-level values for x-typeship-globals parameters, by wire name. */
402
+ globals?: Record<string, unknown>;
403
+ /** Header names the spec's API-key schemes use. fetch drops the standard
404
+ * credential headers on a cross-origin redirect but knows nothing of these,
405
+ * so when a spec declares any, this runtime follows redirects itself and
406
+ * drops them too. */
407
+ authHeaders?: string[];
408
+ }
409
+
410
+ /** What the debug sink receives: one event per HTTP attempt. */
411
+ export interface DebugEvent {
412
+ method: string;
413
+ path: string;
414
+ /** absent when the attempt failed before a response arrived */
415
+ status?: number;
416
+ durationMs: number;
417
+ /** 1-based; >1 means this was a retry */
418
+ attempt: number;
419
+ requestId?: string;
420
+ /** transport failure message, when there was no response */
421
+ error?: string;
422
+ }
423
+
424
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
425
+
426
+ /** Credential-bearing headers fetch itself drops when a redirect crosses to
427
+ * another origin. A spec's own API-key headers join them below. */
428
+ const SENSITIVE_HEADERS = ["authorization", "cookie", "cookie2", "proxy-authorization", "www-authenticate"];
429
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
430
+ /** Dropped along with the body when a redirect rewrites the method to GET. */
431
+ const CONTENT_HEADERS = ["content-encoding", "content-language", "content-location", "content-type", "content-length"];
432
+ /** fetch's own ceiling, so a redirect loop fails the same way it did before. */
433
+ const MAX_REDIRECTS = 20;
434
+
435
+ function withoutHeaders(headers: Record<string, string>, drop: string[]): Record<string, string> {
436
+ const kept: Record<string, string> = {};
437
+ for (const [name, value] of Object.entries(headers)) {
438
+ if (!drop.includes(name.toLowerCase())) kept[name] = value;
439
+ }
440
+ return kept;
441
+ }
442
+
443
+ /** A body that can only be read once, so a redirect cannot replay it. */
444
+ function isStreamBody(body: unknown): boolean {
445
+ return typeof (body as { getReader?: unknown } | undefined)?.getReader === "function";
446
+ }
447
+
448
+ export class HttpCore {
449
+ readonly config: CoreConfig;
450
+ /** Lowercased header names dropped on a cross-origin hop. */
451
+ private readonly sensitiveHeaders: string[];
452
+ /** Whether this runtime follows redirects itself instead of letting the
453
+ * platform do it — see followRedirects(). */
454
+ private readonly manualRedirects: boolean;
455
+
456
+ // no parameter properties: this file also runs under strip-only TS
457
+ constructor(config: CoreConfig) {
458
+ this.config = config;
459
+ const declared = (config.authHeaders ?? []).map((name) => name.toLowerCase());
460
+ const custom = declared.filter((name, i) => !SENSITIVE_HEADERS.includes(name) && declared.indexOf(name) === i);
461
+ this.sensitiveHeaders = [...SENSITIVE_HEADERS, ...custom];
462
+ // Only worth taking over from the platform when the spec puts its
463
+ // credential on a header fetch has never heard of. A browser is excluded:
464
+ // there, `redirect: "manual"` yields an opaque response with no Location
465
+ // to read, and a cross-origin hop carrying a non-safelisted header needs
466
+ // the target's CORS consent anyway.
467
+ this.manualRedirects = custom.length > 0 && (globalThis as { document?: unknown }).document === undefined;
468
+ }
469
+
470
+ /** The client-level value for an x-typeship-globals parameter. */
471
+ globalValue(name: string): unknown {
472
+ return this.config.globals?.[name];
473
+ }
474
+
475
+ async request<T, E>(req: CoreRequest): Promise<ApiResult<T, E>> {
476
+ const policy: RetryPolicy = { ...this.config.retry, ...req.retry };
477
+ const maxRetries = req.options?.maxRetries ?? policy.maxRetries ?? this.config.maxRetries;
478
+ const timeoutMs = req.options?.timeoutMs ?? this.config.timeoutMs;
479
+ const retryAllowed = req.idempotent === true || req.method === "GET" || policy.retryNonIdempotent === true;
480
+ const retryableStatuses = policy.statuses ? new Set(policy.statuses) : RETRYABLE_STATUSES;
481
+
482
+ // One key per logical call, reused on every retry — that's the point
483
+ // of idempotency keys.
484
+ const autoIdempotencyKey =
485
+ req.idempotencyKey !== undefined &&
486
+ req.headers?.[req.idempotencyKey] === undefined &&
487
+ req.options?.headers?.[req.idempotencyKey] === undefined
488
+ ? crypto.randomUUID()
489
+ : undefined;
490
+
491
+ const opSchemas = this.config.validate && req.schemaKey ? this.config.schemas?.[req.schemaKey] : undefined;
492
+ if (opSchemas?.req && this.config.validate!.requests
493
+ && req.body !== undefined && (req.bodyKind ?? "json") === "json") {
494
+ const violations: Violation[] = [];
495
+ validateAgainstSchema(req.body, opSchemas.req, "body", violations, this.config.schemaDefs);
496
+ if (violations.length > 0) {
497
+ const validationError = new ValidationError("request", violations);
498
+ if (this.config.validate!.mode === "warn") {
499
+ console.warn(req.method + " " + req.path + ": " + validationError.message);
500
+ } else {
501
+ const error = validationError as unknown as E;
502
+ await this.config.onError?.(error, { method: req.method, path: req.path });
503
+ return { ok: false, error };
504
+ }
505
+ }
506
+ }
507
+
508
+ let lastError: unknown;
509
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
510
+ let response: Response;
511
+ const attemptStarted = Date.now();
512
+ try {
513
+ response = await this.send(req, timeoutMs, attempt, autoIdempotencyKey);
514
+ this.config.debug?.({
515
+ method: req.method,
516
+ path: req.path,
517
+ status: response.status,
518
+ durationMs: Date.now() - attemptStarted,
519
+ attempt: attempt + 1,
520
+ requestId: response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined,
521
+ });
522
+ } catch (cause) {
523
+ lastError = cause;
524
+ this.config.debug?.({
525
+ method: req.method,
526
+ path: req.path,
527
+ durationMs: Date.now() - attemptStarted,
528
+ attempt: attempt + 1,
529
+ error: cause instanceof Error ? cause.message : String(cause),
530
+ });
531
+ if (attempt < maxRetries && retryAllowed && !req.options?.signal?.aborted) {
532
+ await sleep(backoff(attempt, policy));
533
+ continue;
534
+ }
535
+ const error = new TransportError(
536
+ transportFailureMessage(req.method, this.config.baseUrl.replace(/\/+$/, "") + req.path, cause),
537
+ cause,
538
+ ) as unknown as E;
539
+ await this.config.onError?.(error, { method: req.method, path: req.path });
540
+ return { ok: false, error };
541
+ }
542
+
543
+ if (response.ok) {
544
+ if (req.stream) {
545
+ return { ok: true, data: sseEvents(response) as T, response: meta(response) };
546
+ }
547
+ let data: T;
548
+ try {
549
+ data = (await parseBody(response, req.method)) as T;
550
+ } catch (cause) {
551
+ const error = new TransportError(
552
+ "The response body read was aborted before completing",
553
+ cause,
554
+ ) as unknown as E;
555
+ await this.config.onError?.(error, { method: req.method, path: req.path });
556
+ return { ok: false, error, response: meta(response) };
557
+ }
558
+ if (opSchemas?.res && this.config.validate!.responses && data !== undefined) {
559
+ const violations: Violation[] = [];
560
+ validateAgainstSchema(data, opSchemas.res, "response", violations, this.config.schemaDefs);
561
+ if (violations.length > 0) {
562
+ const validationError = new ValidationError("response", violations);
563
+ if (this.config.validate!.mode === "warn") {
564
+ console.warn(req.method + " " + req.path + ": " + validationError.message);
565
+ } else {
566
+ const error = validationError as unknown as E;
567
+ await this.config.onError?.(error, { method: req.method, path: req.path });
568
+ return { ok: false, error, response: meta(response) };
569
+ }
570
+ }
571
+ }
572
+ if (req.graphqlField) {
573
+ const payload = data as { data?: Record<string, unknown>; errors?: unknown[] } | undefined;
574
+ const responseMeta = meta(response);
575
+ if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
576
+ const gqlError = new GraphQLRequestError(payload.errors, responseMeta) as unknown as E;
577
+ await this.config.onError?.(gqlError, { method: req.method, path: req.path });
578
+ return { ok: false, error: gqlError, response: responseMeta };
579
+ }
580
+ return { ok: true, data: payload?.data?.[req.graphqlField] as T, response: responseMeta };
581
+ }
582
+ return { ok: true, data, response: meta(response) };
583
+ }
584
+
585
+ // 429 is safe to retry regardless of idempotency; other retryable
586
+ // statuses only when the verb is idempotent.
587
+ const retryableStatus =
588
+ retryableStatuses.has(response.status) &&
589
+ (retryAllowed || response.status === 429);
590
+ if (attempt < maxRetries && retryableStatus) {
591
+ await sleep(retryAfterMs(response) ?? backoff(attempt, policy));
592
+ continue;
593
+ }
594
+
595
+ let body: unknown;
596
+ try {
597
+ body = await parseBody(response, req.method);
598
+ } catch {
599
+ body = undefined; // error responses keep their status even if the body read aborts
600
+ }
601
+ const responseMeta = meta(response);
602
+ const Ctor =
603
+ req.errors?.[String(response.status)] ??
604
+ req.errors?.[String(Math.floor(response.status / 100)) + "XX"] ??
605
+ req.errors?.["default"];
606
+ const error = (Ctor
607
+ ? new Ctor(body, responseMeta)
608
+ : new UnexpectedApiError(response.status, body, responseMeta)) as unknown as E;
609
+ await this.config.onError?.(error, { method: req.method, path: req.path });
610
+ return { ok: false, error, response: responseMeta };
611
+ }
612
+
613
+ // Unreachable, but keeps the compiler honest.
614
+ const error = new TransportError("Request failed", lastError) as unknown as E;
615
+ await this.config.onError?.(error, { method: req.method, path: req.path });
616
+ return { ok: false, error };
617
+ }
618
+
619
+ private async send(
620
+ req: CoreRequest,
621
+ timeoutMs: number,
622
+ attempt: number,
623
+ autoIdempotencyKey?: string,
624
+ ): Promise<Response> {
625
+ const headers: Record<string, string> = {};
626
+ for (const [k, v] of Object.entries(this.config.headers)) {
627
+ headers[k] = await resolveAuthValue(v);
628
+ }
629
+ if (req.idempotencyKey && autoIdempotencyKey) {
630
+ headers[req.idempotencyKey] = autoIdempotencyKey;
631
+ }
632
+ const { body, contentType } = serializeBody(req);
633
+ if (contentType) headers["Content-Type"] = contentType;
634
+ for (const source of [req.headers, req.options?.headers]) {
635
+ for (const [k, v] of Object.entries(source ?? {})) {
636
+ if (v !== undefined) headers[k] = v;
637
+ }
638
+ }
639
+
640
+ const context: RequestContext = {
641
+ method: req.method,
642
+ url: await this.buildUrl(req),
643
+ headers,
644
+ attempt,
645
+ };
646
+ await this.config.onRequest?.(context);
647
+
648
+ // Streaming responses are exempt from the attempt timeout once headers
649
+ // arrive (an event stream may stay open far longer than timeoutMs);
650
+ // everything else keeps the timeout armed through the body read, so a
651
+ // stalled body aborts instead of hanging.
652
+ const signals: AbortSignal[] = [];
653
+ let clearStreamTimeout: (() => void) | undefined;
654
+ if (req.stream) {
655
+ const headersTimeout = new AbortController();
656
+ const timer = setTimeout(
657
+ () => headersTimeout.abort(new DOMException("Timed out waiting for response headers", "TimeoutError")),
658
+ timeoutMs,
659
+ );
660
+ (timer as { unref?: () => void }).unref?.();
661
+ clearStreamTimeout = () => clearTimeout(timer);
662
+ signals.push(headersTimeout.signal);
663
+ } else {
664
+ signals.push(AbortSignal.timeout(timeoutMs));
665
+ }
666
+ if (req.options?.signal) signals.push(req.options.signal);
667
+
668
+ try {
669
+ const signal = AbortSignal.any(signals);
670
+ const response = this.manualRedirects
671
+ ? await this.followRedirects(context, body, signal)
672
+ : await this.config.fetch(context.url, {
673
+ method: req.method,
674
+ headers: context.headers,
675
+ body,
676
+ signal,
677
+ });
678
+ await this.config.onResponse?.(response, context);
679
+ return response;
680
+ } finally {
681
+ clearStreamTimeout?.();
682
+ }
683
+ }
684
+
685
+ /**
686
+ * The redirect chain, walked here instead of inside fetch, so the header
687
+ * this spec puts its API key on is dropped when a hop crosses to another
688
+ * origin. fetch drops Authorization and friends for us, but it has never
689
+ * heard of X-Whatever-Key, and an operation that returns a file commonly
690
+ * redirects to object storage.
691
+ *
692
+ * Only reached when the spec declares such a header (see the constructor);
693
+ * every other client stays on the platform's own redirect handling. One
694
+ * chain is one attempt: retries, the per-attempt timeout, the caller's
695
+ * signal, and the onRequest/onResponse hooks all sit outside it and see
696
+ * the chain as the single exchange it replaces.
697
+ */
698
+ private async followRedirects(
699
+ context: RequestContext,
700
+ body: NonNullable<RequestInit["body"]> | undefined,
701
+ signal: AbortSignal,
702
+ ): Promise<Response> {
703
+ let url = context.url;
704
+ let origin = new URL(url).origin;
705
+ let method = context.method;
706
+ let headers = context.headers;
707
+ for (let hop = 0; ; hop++) {
708
+ const response = await this.config.fetch(url, { method, headers, body, redirect: "manual", signal });
709
+ // A runtime that filters manual redirects (a browser) hands back an
710
+ // opaque response with no Location to read. Refuse rather than let the
711
+ // credential travel blind — the constructor already keeps browsers off
712
+ // this path, so this is a guard, not an expected outcome.
713
+ if (response.type === "opaqueredirect" || response.status === 0) {
714
+ throw new TransportError("This runtime hides redirect targets, so the credential cannot be dropped before the hop");
715
+ }
716
+ const location = response.headers.get("location");
717
+ if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
718
+ if (hop >= MAX_REDIRECTS) throw new TransportError("Too many redirects (" + (hop + 1) + ")");
719
+ await response.body?.cancel();
720
+
721
+ const target = new URL(location, url);
722
+ headers = { ...headers };
723
+ // Origins are compared exactly — scheme, host, and port — which is
724
+ // fetch's rule and the one the Python runtime applies.
725
+ if (target.origin !== origin) headers = withoutHeaders(headers, this.sensitiveHeaders);
726
+ // The method rewrite fetch performs: a 303 continues as a bodyless GET,
727
+ // and so does a 301/302 that answered a POST.
728
+ const toGet = response.status === 303
729
+ ? method !== "HEAD"
730
+ : (response.status === 301 || response.status === 302) && method === "POST";
731
+ if (toGet) {
732
+ method = "GET";
733
+ body = undefined;
734
+ headers = withoutHeaders(headers, CONTENT_HEADERS);
735
+ } else if (isStreamBody(body)) {
736
+ // A stream reads once; fetch fails the same way rather than sending
737
+ // an empty body to the new location.
738
+ throw new TransportError("Cannot replay a streaming request body across a redirect");
739
+ }
740
+ url = target.toString();
741
+ origin = target.origin;
742
+ }
743
+ }
744
+
745
+ private async buildUrl(req: CoreRequest): Promise<string> {
746
+ const base = this.config.baseUrl.replace(/\/+$/, "");
747
+ const url = new URL(base + req.path);
748
+ for (const [k, v] of Object.entries(req.query ?? {})) {
749
+ if (v === undefined || v === null) continue;
750
+ if (Array.isArray(v)) {
751
+ // top-level arrays repeat the key: expand=a&expand=b
752
+ for (const item of v) appendDeep(url.searchParams, k, item);
753
+ } else {
754
+ appendDeep(url.searchParams, k, v);
755
+ }
756
+ }
757
+ for (const [k, v] of Object.entries(this.config.query)) {
758
+ url.searchParams.append(k, await resolveAuthValue(v));
759
+ }
760
+ return url.toString();
761
+ }
762
+ }
763
+
764
+ async function resolveAuthValue(value: AuthValue): Promise<string> {
765
+ return typeof value === "function" ? await value() : value;
766
+ }
767
+
768
+ /** Parse a text/event-stream body into SseEvents, lazily. */
769
+ async function* sseEvents(response: Response): AsyncGenerator<SseEvent, void, undefined> {
770
+ if (!response.body) return;
771
+ const reader = response.body.getReader();
772
+ const decoder = new TextDecoder();
773
+ let buffer = "";
774
+ let dataLines: string[] = [];
775
+ let eventName: string | undefined;
776
+ let eventId: string | undefined;
777
+
778
+ const flush = (): SseEvent | undefined => {
779
+ if (dataLines.length === 0) return undefined;
780
+ const event: SseEvent = { data: dataLines.join("\n") };
781
+ if (eventName !== undefined) event.event = eventName;
782
+ if (eventId !== undefined) event.id = eventId;
783
+ dataLines = [];
784
+ eventName = undefined;
785
+ return event;
786
+ };
787
+
788
+ try {
789
+ while (true) {
790
+ const { done, value } = await reader.read();
791
+ if (done) break;
792
+ buffer += decoder.decode(value, { stream: true });
793
+ let newline: number;
794
+ while ((newline = buffer.indexOf("\n")) !== -1) {
795
+ const hasCr = newline > 0 && buffer[newline - 1] === "\r";
796
+ const line = buffer.slice(0, hasCr ? newline - 1 : newline);
797
+ buffer = buffer.slice(newline + 1);
798
+ if (line === "") {
799
+ const event = flush();
800
+ if (event) yield event;
801
+ } else if (line.startsWith("data:")) {
802
+ dataLines.push(line.slice(5).replace(/^ /, ""));
803
+ } else if (line.startsWith("event:")) {
804
+ eventName = line.slice(6).replace(/^ /, "");
805
+ } else if (line.startsWith("id:")) {
806
+ eventId = line.slice(3).replace(/^ /, "");
807
+ }
808
+ // comments (":") and "retry:" are intentionally ignored
809
+ }
810
+ }
811
+ const last = flush();
812
+ if (last) yield last;
813
+ } finally {
814
+ reader.releaseLock();
815
+ }
816
+ }
817
+
818
+ /** Default rendering for debug events (the boolean debug:true sink). */
819
+ export function formatDebugEvent(name: string, event: DebugEvent): string {
820
+ return name + " " + event.method + " " + event.path +
821
+ " -> " + (event.status !== undefined ? String(event.status) : "error") +
822
+ " (" + event.durationMs + "ms)" +
823
+ (event.attempt > 1 ? " attempt " + event.attempt : "") +
824
+ (event.requestId !== undefined ? " " + event.requestId : "") +
825
+ (event.error !== undefined ? ": " + event.error : "");
826
+ }
827
+
828
+ /** Wrap a bearer credential (static or callback) as an Authorization value. */
829
+ export function bearerAuth(token: AuthValue): AuthValue {
830
+ if (typeof token === "function") {
831
+ return async () => "Bearer " + (await token());
832
+ }
833
+ return "Bearer " + token;
834
+ }
835
+
836
+ export interface ClientCredentialsConfig {
837
+ clientId: string;
838
+ clientSecret: string;
839
+ tokenUrl: string;
840
+ scopes?: string[];
841
+ /** Extra token-request parameters, e.g. the "audience" some
842
+ * authorization servers require for API-valid access tokens. */
843
+ tokenParams?: Record<string, string>;
844
+ /** How credentials reach the token endpoint. Default "post"
845
+ * (client_secret_post, form fields); "basic" sends an Authorization
846
+ * header (client_secret_basic). */
847
+ authMethod?: "post" | "basic";
848
+ fetchImpl?: typeof fetch;
849
+ }
850
+
851
+ /**
852
+ * OAuth2 client-credentials token source: fetches from the token URL,
853
+ * caches until expiry (60s early refresh), and shares one in-flight
854
+ * request across concurrent callers. Returned function plugs in as an
855
+ * Authorization AuthValue, resolved before every attempt.
856
+ */
857
+ export function oauthClientCredentials(config: ClientCredentialsConfig): () => Promise<string> {
858
+ let token: string | undefined;
859
+ let expiresAt = 0;
860
+ let inflight: Promise<string> | undefined;
861
+ const fetchImpl = config.fetchImpl ?? fetch;
862
+
863
+ async function fetchToken(): Promise<string> {
864
+ const params = new URLSearchParams({ grant_type: "client_credentials" });
865
+ if (config.scopes !== undefined && config.scopes.length > 0) {
866
+ params.set("scope", config.scopes.join(" "));
867
+ }
868
+ for (const [key, value] of Object.entries(config.tokenParams ?? {})) {
869
+ params.set(key, value);
870
+ }
871
+ const headers: Record<string, string> = {
872
+ "Content-Type": "application/x-www-form-urlencoded",
873
+ Accept: "application/json",
874
+ };
875
+ if (config.authMethod === "basic") {
876
+ headers["Authorization"] = "Basic " + toBase64(config.clientId + ":" + config.clientSecret);
877
+ } else {
878
+ params.set("client_id", config.clientId);
879
+ params.set("client_secret", config.clientSecret);
880
+ }
881
+ const response = await fetchImpl(config.tokenUrl, { method: "POST", headers, body: params.toString() });
882
+ const body = (await response.json().catch(() => null)) as
883
+ | { access_token?: string; expires_in?: number; error?: string }
884
+ | null;
885
+ if (!response.ok || typeof body?.access_token !== "string") {
886
+ throw new TransportError(
887
+ "OAuth token request failed (HTTP " + response.status + (body?.error ? ": " + body.error : "") + ")",
888
+ body,
889
+ );
890
+ }
891
+ token = body.access_token;
892
+ expiresAt = body.expires_in !== undefined
893
+ ? Date.now() + body.expires_in * 1000 - 60_000
894
+ : Number.MAX_SAFE_INTEGER;
895
+ return token;
896
+ }
897
+
898
+ return async () => {
899
+ if (token !== undefined && Date.now() < expiresAt) return "Bearer " + token;
900
+ inflight ??= fetchToken().finally(() => { inflight = undefined; });
901
+ return "Bearer " + (await inflight);
902
+ };
903
+ }
904
+
905
+ /**
906
+ * Bracket-style deep encoding shared by query strings and form bodies:
907
+ * { created: { gte: 5 } } -> created[gte]=5, { items: [{ id: "x" }] } ->
908
+ * items[0][id]=x. Scalars append as-is.
909
+ */
910
+ function appendDeep(target: URLSearchParams, key: string, value: unknown): void {
911
+ if (value === undefined || value === null) return;
912
+ if (Array.isArray(value)) {
913
+ value.forEach((item, i) => appendDeep(target, key + "[" + i + "]", item));
914
+ } else if (typeof value === "object" && !(value instanceof Blob) && !(value instanceof Date)) {
915
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
916
+ appendDeep(target, key + "[" + k + "]", v);
917
+ }
918
+ } else {
919
+ target.append(key, value instanceof Date ? value.toISOString() : String(value));
920
+ }
921
+ }
922
+
923
+ function serializeBody(req: CoreRequest): { body: NonNullable<RequestInit["body"]> | undefined; contentType?: string } {
924
+ if (req.body === undefined) return { body: undefined };
925
+ switch (req.bodyKind ?? "json") {
926
+ case "json":
927
+ return { body: JSON.stringify(req.body), contentType: "application/json" };
928
+ case "form": {
929
+ const params = new URLSearchParams();
930
+ for (const [k, v] of Object.entries(req.body as Record<string, unknown>)) {
931
+ appendDeep(params, k, v);
932
+ }
933
+ // URLSearchParams sets its own content type with the charset suffix.
934
+ return { body: params };
935
+ }
936
+ case "multipart": {
937
+ const form = new FormData();
938
+ for (const [k, v] of Object.entries(req.body as Record<string, unknown>)) {
939
+ if (v === undefined || v === null) continue;
940
+ form.append(
941
+ k,
942
+ v instanceof Blob ? v : typeof v === "object" ? JSON.stringify(v) : String(v),
943
+ );
944
+ }
945
+ // Let fetch set the boundary header.
946
+ return { body: form };
947
+ }
948
+ case "text":
949
+ return { body: String(req.body), contentType: "text/plain" };
950
+ case "binary":
951
+ return { body: req.body as NonNullable<RequestInit["body"]> };
952
+ }
953
+ }
954
+
955
+ async function parseBody(response: Response, method: string): Promise<unknown> {
956
+ if (method === "HEAD" || response.status === 204 || response.status === 205) return undefined;
957
+ const contentType = response.headers.get("content-type") ?? "";
958
+ try {
959
+ if (contentType.includes("json")) return await response.json();
960
+ if (contentType.startsWith("text/")) return await response.text();
961
+ if (response.body === null) return undefined;
962
+ return await response.blob();
963
+ } catch (cause) {
964
+ // A timed-out or aborted body read is a transport failure, not an
965
+ // empty body — surface it instead of faking success.
966
+ if (cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError")) throw cause;
967
+ return undefined;
968
+ }
969
+ }
970
+
971
+ function meta(response: Response): ResponseMeta {
972
+ return {
973
+ status: response.status,
974
+ headers: response.headers,
975
+ requestId:
976
+ response.headers.get("x-request-id") ??
977
+ response.headers.get("request-id") ??
978
+ undefined,
979
+ };
980
+ }
981
+
982
+ function retryAfterMs(response: Response): number | undefined {
983
+ const header = response.headers.get("retry-after");
984
+ if (!header) return undefined;
985
+ const seconds = Number(header);
986
+ if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 60_000);
987
+ const date = Date.parse(header);
988
+ if (!Number.isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), 60_000);
989
+ return undefined;
990
+ }
991
+
992
+ /** Exponential backoff with full jitter, capped at 10s by default. */
993
+ function backoff(attempt: number, policy?: RetryPolicy): number {
994
+ const initial = policy?.initialDelayMs ?? 300;
995
+ const max = policy?.maxDelayMs ?? 10_000;
996
+ const cap = Math.min(initial * 2 ** attempt, max);
997
+ return Math.random() * cap;
998
+ }
999
+
1000
+ function sleep(ms: number): Promise<void> {
1001
+ return new Promise((resolve) => setTimeout(resolve, ms));
1002
+ }
1003
+
1004
+ export function toBase64(input: string): string {
1005
+ if (typeof btoa === "function") return btoa(input);
1006
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1007
+ return (globalThis as any).Buffer.from(input, "utf-8").toString("base64");
1008
+ }