@typeship-ax/mcp 0.6.0 → 0.9.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/AGENTS.md +31 -0
- package/README.md +67 -10
- package/api.json +6735 -3243
- package/api.md +8537 -248
- package/dist/api-identity.d.ts +40 -0
- package/dist/api-identity.d.ts.map +1 -0
- package/dist/api-identity.js +128 -0
- package/dist/auth-profiles.d.ts +30 -0
- package/dist/auth-profiles.d.ts.map +1 -0
- package/dist/auth-profiles.js +138 -0
- package/dist/core/http.d.ts +21 -92
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +143 -221
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +6 -34
- package/dist/credential-storage.d.ts +24 -0
- package/dist/credential-storage.d.ts.map +1 -0
- package/dist/credential-storage.js +207 -0
- package/dist/dates.d.ts +0 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +0 -1
- package/dist/docs.d.ts +36 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +258 -0
- package/dist/errors.d.ts +42 -34
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +30 -20
- package/dist/index.d.ts +27 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -14
- package/dist/mcp-authorization.d.ts +52 -0
- package/dist/mcp-authorization.d.ts.map +1 -0
- package/dist/mcp-authorization.js +232 -0
- package/dist/mcp-protocol.d.ts +69 -25
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +386 -138
- package/dist/mcp.d.ts +21 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +199 -85
- package/dist/named-credentials.d.ts +21 -0
- package/dist/named-credentials.d.ts.map +1 -0
- package/dist/named-credentials.js +86 -0
- package/dist/oauth-request.d.ts +21 -0
- package/dist/oauth-request.d.ts.map +1 -0
- package/dist/oauth-request.js +119 -0
- package/dist/oauth-session.d.ts +106 -0
- package/dist/oauth-session.d.ts.map +1 -0
- package/dist/oauth-session.js +244 -0
- package/dist/ops.d.ts +18 -0
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +31 -17
- package/dist/resources/account.d.ts +4 -4
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +13 -8
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +5 -1
- package/dist/resources/definition-revisions.d.ts +58 -0
- package/dist/resources/definition-revisions.d.ts.map +1 -0
- package/dist/resources/definition-revisions.js +114 -0
- package/dist/resources/definitions.d.ts +35 -0
- package/dist/resources/definitions.d.ts.map +1 -0
- package/dist/resources/definitions.js +60 -0
- package/dist/resources/generate.d.ts +18 -7
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +13 -5
- package/dist/resources/generations.d.ts +6 -6
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +3 -1
- package/dist/resources/projects.d.ts +111 -35
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +125 -15
- package/dist/resources/targets.d.ts +97 -0
- package/dist/resources/targets.d.ts.map +1 -0
- package/dist/resources/targets.js +197 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +135 -62
- package/dist/types.d.ts +2072 -267
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -3
- package/dist/worker.js +4 -4
- package/package.json +11 -1
- package/server.json +42 -0
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/core/http.ts +156 -305
- package/src/core/pagination.ts +6 -30
- package/src/credential-storage.ts +183 -0
- package/src/dates.ts +0 -1
- package/src/docs.ts +239 -0
- package/src/errors.ts +52 -41
- package/src/index.ts +49 -14
- package/src/mcp-authorization.ts +211 -0
- package/src/mcp-protocol.ts +432 -133
- package/src/mcp.ts +204 -90
- package/src/named-credentials.ts +74 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +56 -17
- package/src/resources/account.ts +6 -3
- package/src/resources/api-keys.ts +27 -7
- package/src/resources/definition-revisions.ts +207 -0
- package/src/resources/definitions.ts +122 -0
- package/src/resources/generate.ts +29 -6
- package/src/resources/generations.ts +9 -4
- package/src/resources/projects.ts +274 -41
- package/src/resources/targets.ts +378 -0
- package/src/schemas.ts +135 -62
- package/src/types.ts +2273 -322
- package/src/worker.ts +4 -4
- package/dist/resources/spec-revisions.d.ts +0 -47
- package/dist/resources/spec-revisions.d.ts.map +0 -1
- package/dist/resources/spec-revisions.js +0 -90
- package/src/resources/spec-revisions.ts +0 -150
package/src/core/http.ts
CHANGED
|
@@ -17,7 +17,12 @@ export interface RequestOptions {
|
|
|
17
17
|
export interface ResponseMeta {
|
|
18
18
|
status: number;
|
|
19
19
|
headers: Headers;
|
|
20
|
-
/**
|
|
20
|
+
/** Parsed wire body before it is narrowed to the generated response type.
|
|
21
|
+
* Use this escape hatch for additive fields or variants introduced after
|
|
22
|
+
* this generator edition. */
|
|
23
|
+
rawBody?: unknown;
|
|
24
|
+
/** Request identifier from the JSON response body, or from headers for
|
|
25
|
+
* raw and bodyless responses. */
|
|
21
26
|
requestId?: string;
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -37,15 +42,6 @@ export interface RequestContext {
|
|
|
37
42
|
attempt: number;
|
|
38
43
|
}
|
|
39
44
|
|
|
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
45
|
|
|
50
46
|
/**
|
|
51
47
|
* Every SDK call returns a discriminated result instead of throwing.
|
|
@@ -85,97 +81,23 @@ export class UnexpectedApiError extends ApiError<number, unknown> {
|
|
|
85
81
|
}
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
/** A
|
|
89
|
-
export class
|
|
90
|
-
|
|
91
|
-
readonly
|
|
84
|
+
/** A successful response declared JSON but carried a body that could not be parsed. */
|
|
85
|
+
export class ResponseParseError extends Error {
|
|
86
|
+
readonly status: number;
|
|
87
|
+
readonly body: string;
|
|
92
88
|
readonly response: ResponseMeta;
|
|
89
|
+
override readonly cause?: unknown;
|
|
93
90
|
|
|
94
|
-
constructor(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
91
|
+
constructor(body: string, response: ResponseMeta, cause?: unknown) {
|
|
92
|
+
super("HTTP " + response.status + " response body was not valid JSON");
|
|
93
|
+
this.name = "ResponseParseError";
|
|
94
|
+
this.status = response.status;
|
|
95
|
+
this.body = body;
|
|
99
96
|
this.response = response;
|
|
97
|
+
this.cause = cause;
|
|
100
98
|
}
|
|
101
99
|
}
|
|
102
100
|
|
|
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
101
|
|
|
180
102
|
// ---------------------------------------------------------------------------
|
|
181
103
|
// Optional runtime validation — zero-dependency, schema table in schemas.ts
|
|
@@ -317,7 +239,7 @@ function transportFailureMessage(method: string, url: string, cause: unknown): s
|
|
|
317
239
|
return method + " " + url + " failed: " + detail;
|
|
318
240
|
}
|
|
319
241
|
|
|
320
|
-
/** The request
|
|
242
|
+
/** The request failed before a complete HTTP response arrived (network failure, timeout, abort, or truncated body). */
|
|
321
243
|
export class TransportError extends Error {
|
|
322
244
|
override readonly cause?: unknown;
|
|
323
245
|
|
|
@@ -331,6 +253,7 @@ export class TransportError extends Error {
|
|
|
331
253
|
type ErrorCtor = new (body: any, response: ResponseMeta) => ApiError<number, unknown>;
|
|
332
254
|
|
|
333
255
|
export interface CoreRequest {
|
|
256
|
+
security?: Record<string, string[]>[];
|
|
334
257
|
method: string;
|
|
335
258
|
path: string;
|
|
336
259
|
query?: Record<string, unknown>;
|
|
@@ -341,14 +264,9 @@ export interface CoreRequest {
|
|
|
341
264
|
errors?: Record<string, ErrorCtor>;
|
|
342
265
|
/** Idempotent requests are retried automatically. */
|
|
343
266
|
idempotent?: boolean;
|
|
344
|
-
/** Success body is text/event-stream: yield SseEvents instead of parsing. */
|
|
345
|
-
stream?: boolean;
|
|
346
267
|
/** Header name auto-filled with one UUID per call (stable across retries)
|
|
347
268
|
* when the caller doesn't supply a value. */
|
|
348
269
|
idempotencyKey?: string;
|
|
349
|
-
/** GraphQL: unwrap body.data[field] and turn body.errors into a
|
|
350
|
-
* GraphQLRequestError. */
|
|
351
|
-
graphqlField?: string;
|
|
352
270
|
/** Key into the schemas table for optional runtime validation. */
|
|
353
271
|
schemaKey?: string;
|
|
354
272
|
/** Operation-level retry policy (x-typeship-retries), merged over the
|
|
@@ -368,7 +286,39 @@ export interface RetryPolicy {
|
|
|
368
286
|
retryNonIdempotent?: boolean;
|
|
369
287
|
}
|
|
370
288
|
|
|
289
|
+
export interface SecurityCredential {
|
|
290
|
+
headers?: Record<string, AuthValue>;
|
|
291
|
+
query?: Record<string, AuthValue>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Resolve one complete alternative in spec order. Missing or incompatible
|
|
295
|
+
* combinations contribute no partial credentials. Anonymous operations send
|
|
296
|
+
* no generated credentials; explicit headers/hooks remain application owned. */
|
|
297
|
+
function selectSecurity(requirements: Record<string, string[]>[], credentials: Record<string, SecurityCredential>): SecurityCredential {
|
|
298
|
+
for (const requirement of requirements) {
|
|
299
|
+
const names = Object.keys(requirement);
|
|
300
|
+
if (!names.length || names.some((name) => !Object.hasOwn(credentials, name))) continue;
|
|
301
|
+
const selected: SecurityCredential = { headers: Object.create(null), query: Object.create(null) };
|
|
302
|
+
const destinations = new Set<string>();
|
|
303
|
+
let conflict = false;
|
|
304
|
+
for (const name of names) {
|
|
305
|
+
for (const location of ["headers", "query"] as const) {
|
|
306
|
+
for (const [wire, value] of Object.entries(credentials[name]![location] ?? {})) {
|
|
307
|
+
const destination = location + ":" + (location === "headers" ? wire.toLowerCase() : wire);
|
|
308
|
+
if (destinations.has(destination)) conflict = true;
|
|
309
|
+
destinations.add(destination);
|
|
310
|
+
selected[location]![wire] = value;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!conflict) return selected;
|
|
315
|
+
}
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
|
|
371
319
|
export interface CoreConfig {
|
|
320
|
+
/** Named credentials are selected using each operation's requirements. */
|
|
321
|
+
credentials?: Record<string, SecurityCredential>;
|
|
372
322
|
baseUrl: string;
|
|
373
323
|
headers: Record<string, AuthValue>;
|
|
374
324
|
/** Auth carried as query parameters (apiKey-in-query schemes). */
|
|
@@ -417,7 +367,7 @@ export interface DebugEvent {
|
|
|
417
367
|
/** 1-based; >1 means this was a retry */
|
|
418
368
|
attempt: number;
|
|
419
369
|
requestId?: string;
|
|
420
|
-
/** transport failure message, when
|
|
370
|
+
/** transport failure message, when the request failed before a complete response */
|
|
421
371
|
error?: string;
|
|
422
372
|
}
|
|
423
373
|
|
|
@@ -476,8 +426,14 @@ export class HttpCore {
|
|
|
476
426
|
const policy: RetryPolicy = { ...this.config.retry, ...req.retry };
|
|
477
427
|
const maxRetries = req.options?.maxRetries ?? policy.maxRetries ?? this.config.maxRetries;
|
|
478
428
|
const timeoutMs = req.options?.timeoutMs ?? this.config.timeoutMs;
|
|
479
|
-
const retryAllowed =
|
|
480
|
-
|
|
429
|
+
const retryAllowed =
|
|
430
|
+
req.idempotent === true ||
|
|
431
|
+
req.method === "GET" ||
|
|
432
|
+
req.idempotencyKey !== undefined ||
|
|
433
|
+
policy.retryNonIdempotent === true;
|
|
434
|
+
const retryableStatuses = policy.statuses
|
|
435
|
+
? new Set(policy.statuses)
|
|
436
|
+
: RETRYABLE_STATUSES;
|
|
481
437
|
|
|
482
438
|
// One key per logical call, reused on every retry — that's the point
|
|
483
439
|
// of idempotency keys.
|
|
@@ -509,16 +465,20 @@ export class HttpCore {
|
|
|
509
465
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
510
466
|
let response: Response;
|
|
511
467
|
const attemptStarted = Date.now();
|
|
468
|
+
const emitResponseDebug = (value: Response, body?: unknown) => this.config.debug?.({
|
|
469
|
+
method: req.method,
|
|
470
|
+
path: req.path,
|
|
471
|
+
status: value.status,
|
|
472
|
+
durationMs: Date.now() - attemptStarted,
|
|
473
|
+
attempt: attempt + 1,
|
|
474
|
+
requestId:
|
|
475
|
+
requestIdFromBody(body) ??
|
|
476
|
+
value.headers.get("request-id") ??
|
|
477
|
+
value.headers.get("x-request-id") ??
|
|
478
|
+
undefined,
|
|
479
|
+
});
|
|
512
480
|
try {
|
|
513
481
|
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
482
|
} catch (cause) {
|
|
523
483
|
lastError = cause;
|
|
524
484
|
this.config.debug?.({
|
|
@@ -541,23 +501,26 @@ export class HttpCore {
|
|
|
541
501
|
}
|
|
542
502
|
|
|
543
503
|
if (response.ok) {
|
|
544
|
-
if (req.stream) {
|
|
545
|
-
return { ok: true, data: sseEvents(response) as T, response: meta(response) };
|
|
546
|
-
}
|
|
547
504
|
let data: T;
|
|
548
505
|
try {
|
|
549
506
|
data = (await parseBody(response, req.method)) as T;
|
|
550
507
|
} catch (cause) {
|
|
551
|
-
const
|
|
552
|
-
|
|
508
|
+
const parseError = cause instanceof ResponseParseError ? cause : undefined;
|
|
509
|
+
emitResponseDebug(response, parseError?.body);
|
|
510
|
+
const error = (parseError ?? new TransportError(
|
|
511
|
+
"The response body read failed before completing",
|
|
553
512
|
cause,
|
|
554
|
-
) as unknown as E;
|
|
513
|
+
)) as unknown as E;
|
|
555
514
|
await this.config.onError?.(error, { method: req.method, path: req.path });
|
|
556
|
-
return { ok: false, error, response: meta(response) };
|
|
515
|
+
return { ok: false, error, response: parseError?.response ?? meta(response) };
|
|
557
516
|
}
|
|
558
|
-
|
|
517
|
+
emitResponseDebug(response, data);
|
|
518
|
+
const responseMeta = meta(response, data);
|
|
519
|
+
let responseData: unknown = data;
|
|
520
|
+
let shouldValidateResponse = responseData !== undefined;
|
|
521
|
+
if (opSchemas?.res && this.config.validate!.responses && shouldValidateResponse) {
|
|
559
522
|
const violations: Violation[] = [];
|
|
560
|
-
validateAgainstSchema(
|
|
523
|
+
validateAgainstSchema(responseData, opSchemas.res, "response", violations, this.config.schemaDefs);
|
|
561
524
|
if (violations.length > 0) {
|
|
562
525
|
const validationError = new ValidationError("response", violations);
|
|
563
526
|
if (this.config.validate!.mode === "warn") {
|
|
@@ -565,21 +528,11 @@ export class HttpCore {
|
|
|
565
528
|
} else {
|
|
566
529
|
const error = validationError as unknown as E;
|
|
567
530
|
await this.config.onError?.(error, { method: req.method, path: req.path });
|
|
568
|
-
return { ok: false, error, response:
|
|
531
|
+
return { ok: false, error, response: responseMeta };
|
|
569
532
|
}
|
|
570
533
|
}
|
|
571
534
|
}
|
|
572
|
-
|
|
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) };
|
|
535
|
+
return { ok: true, data, response: responseMeta };
|
|
583
536
|
}
|
|
584
537
|
|
|
585
538
|
// 429 is safe to retry regardless of idempotency; other retryable
|
|
@@ -588,7 +541,15 @@ export class HttpCore {
|
|
|
588
541
|
retryableStatuses.has(response.status) &&
|
|
589
542
|
(retryAllowed || response.status === 429);
|
|
590
543
|
if (attempt < maxRetries && retryableStatus) {
|
|
591
|
-
|
|
544
|
+
const delay = retryAfterMs(response) ?? backoff(attempt, policy);
|
|
545
|
+
let retryBody: unknown;
|
|
546
|
+
try {
|
|
547
|
+
retryBody = await parseBody(response, req.method);
|
|
548
|
+
} catch {
|
|
549
|
+
retryBody = undefined;
|
|
550
|
+
}
|
|
551
|
+
emitResponseDebug(response, retryBody);
|
|
552
|
+
await sleep(delay);
|
|
592
553
|
continue;
|
|
593
554
|
}
|
|
594
555
|
|
|
@@ -598,7 +559,8 @@ export class HttpCore {
|
|
|
598
559
|
} catch {
|
|
599
560
|
body = undefined; // error responses keep their status even if the body read aborts
|
|
600
561
|
}
|
|
601
|
-
|
|
562
|
+
emitResponseDebug(response, body);
|
|
563
|
+
const responseMeta = meta(response, body);
|
|
602
564
|
const Ctor =
|
|
603
565
|
req.errors?.[String(response.status)] ??
|
|
604
566
|
req.errors?.[String(Math.floor(response.status / 100)) + "XX"] ??
|
|
@@ -622,8 +584,9 @@ export class HttpCore {
|
|
|
622
584
|
attempt: number,
|
|
623
585
|
autoIdempotencyKey?: string,
|
|
624
586
|
): Promise<Response> {
|
|
587
|
+
const selected = req.security && this.config.credentials ? selectSecurity(req.security, this.config.credentials) : {};
|
|
625
588
|
const headers: Record<string, string> = {};
|
|
626
|
-
for (const [k, v] of Object.entries(this.config.headers)) {
|
|
589
|
+
for (const [k, v] of Object.entries({ ...this.config.headers, ...selected.headers })) {
|
|
627
590
|
headers[k] = await resolveAuthValue(v);
|
|
628
591
|
}
|
|
629
592
|
if (req.idempotencyKey && autoIdempotencyKey) {
|
|
@@ -639,34 +602,20 @@ export class HttpCore {
|
|
|
639
602
|
|
|
640
603
|
const context: RequestContext = {
|
|
641
604
|
method: req.method,
|
|
642
|
-
url: await this.buildUrl(req),
|
|
605
|
+
url: await this.buildUrl(req, selected.query),
|
|
643
606
|
headers,
|
|
644
607
|
attempt,
|
|
645
608
|
};
|
|
646
609
|
await this.config.onRequest?.(context);
|
|
647
610
|
|
|
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
611
|
const signals: AbortSignal[] = [];
|
|
653
|
-
|
|
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 {
|
|
612
|
+
{
|
|
664
613
|
signals.push(AbortSignal.timeout(timeoutMs));
|
|
665
614
|
}
|
|
666
615
|
if (req.options?.signal) signals.push(req.options.signal);
|
|
667
616
|
|
|
668
617
|
try {
|
|
669
|
-
const signal =
|
|
618
|
+
const signal = composeSignals(signals);
|
|
670
619
|
const response = this.manualRedirects
|
|
671
620
|
? await this.followRedirects(context, body, signal)
|
|
672
621
|
: await this.config.fetch(context.url, {
|
|
@@ -678,7 +627,6 @@ export class HttpCore {
|
|
|
678
627
|
await this.config.onResponse?.(response, context);
|
|
679
628
|
return response;
|
|
680
629
|
} finally {
|
|
681
|
-
clearStreamTimeout?.();
|
|
682
630
|
}
|
|
683
631
|
}
|
|
684
632
|
|
|
@@ -742,7 +690,7 @@ export class HttpCore {
|
|
|
742
690
|
}
|
|
743
691
|
}
|
|
744
692
|
|
|
745
|
-
private async buildUrl(req: CoreRequest): Promise<string> {
|
|
693
|
+
private async buildUrl(req: CoreRequest, authQuery?: Record<string, AuthValue>): Promise<string> {
|
|
746
694
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
747
695
|
const url = new URL(base + req.path);
|
|
748
696
|
for (const [k, v] of Object.entries(req.query ?? {})) {
|
|
@@ -754,7 +702,7 @@ export class HttpCore {
|
|
|
754
702
|
appendDeep(url.searchParams, k, v);
|
|
755
703
|
}
|
|
756
704
|
}
|
|
757
|
-
for (const [k, v] of Object.entries(this.config.query)) {
|
|
705
|
+
for (const [k, v] of Object.entries({ ...this.config.query, ...authQuery })) {
|
|
758
706
|
url.searchParams.append(k, await resolveAuthValue(v));
|
|
759
707
|
}
|
|
760
708
|
return url.toString();
|
|
@@ -765,55 +713,6 @@ async function resolveAuthValue(value: AuthValue): Promise<string> {
|
|
|
765
713
|
return typeof value === "function" ? await value() : value;
|
|
766
714
|
}
|
|
767
715
|
|
|
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
716
|
|
|
818
717
|
/** Default rendering for debug events (the boolean debug:true sink). */
|
|
819
718
|
export function formatDebugEvent(name: string, event: DebugEvent): string {
|
|
@@ -833,74 +732,6 @@ export function bearerAuth(token: AuthValue): AuthValue {
|
|
|
833
732
|
return "Bearer " + token;
|
|
834
733
|
}
|
|
835
734
|
|
|
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
735
|
|
|
905
736
|
/**
|
|
906
737
|
* Bracket-style deep encoding shared by query strings and form bodies:
|
|
@@ -925,60 +756,80 @@ function serializeBody(req: CoreRequest): { body: NonNullable<RequestInit["body"
|
|
|
925
756
|
switch (req.bodyKind ?? "json") {
|
|
926
757
|
case "json":
|
|
927
758
|
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
759
|
}
|
|
760
|
+
throw new Error("Unsupported request body kind: " + String(req.bodyKind));
|
|
953
761
|
}
|
|
954
762
|
|
|
955
763
|
async function parseBody(response: Response, method: string): Promise<unknown> {
|
|
956
764
|
if (method === "HEAD" || response.status === 204 || response.status === 205) return undefined;
|
|
957
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
765
|
+
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
766
|
+
const mediaType = contentType.split(";", 1)[0]!.trim();
|
|
767
|
+
const slash = mediaType.indexOf("/");
|
|
768
|
+
const subtype = slash === -1 ? "" : mediaType.slice(slash + 1);
|
|
958
769
|
try {
|
|
959
|
-
if (
|
|
770
|
+
if (subtype === "json" || subtype.endsWith("+json")) {
|
|
771
|
+
const body = await response.text();
|
|
772
|
+
if (body.length === 0) return undefined;
|
|
773
|
+
try {
|
|
774
|
+
return JSON.parse(body);
|
|
775
|
+
} catch (cause) {
|
|
776
|
+
throw new ResponseParseError(body, meta(response, body), cause);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
960
779
|
if (contentType.startsWith("text/")) return await response.text();
|
|
961
780
|
if (response.body === null) return undefined;
|
|
962
781
|
return await response.blob();
|
|
963
782
|
} catch (cause) {
|
|
964
|
-
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
783
|
+
if (cause instanceof ResponseParseError) throw cause;
|
|
784
|
+
// A timed-out, aborted, or prematurely terminated body is a transport
|
|
785
|
+
// failure, not an empty body — surface it instead of faking success.
|
|
786
|
+
throw cause;
|
|
968
787
|
}
|
|
969
788
|
}
|
|
970
789
|
|
|
971
|
-
function
|
|
790
|
+
function requestIdFromBody(body: unknown): string | undefined {
|
|
791
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
|
|
792
|
+
const value = (body as Record<string, unknown>).request_id ?? (body as Record<string, unknown>).requestId;
|
|
793
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function meta(response: Response, body?: unknown): ResponseMeta {
|
|
972
797
|
return {
|
|
973
798
|
status: response.status,
|
|
974
799
|
headers: response.headers,
|
|
800
|
+
...(body !== undefined ? { rawBody: body } : {}),
|
|
975
801
|
requestId:
|
|
976
|
-
|
|
802
|
+
requestIdFromBody(body) ??
|
|
977
803
|
response.headers.get("request-id") ??
|
|
804
|
+
response.headers.get("x-request-id") ??
|
|
978
805
|
undefined,
|
|
979
806
|
};
|
|
980
807
|
}
|
|
981
808
|
|
|
809
|
+
/** AbortSignal.any reached Node in 18.17. Keep the package's Node 18 floor
|
|
810
|
+
* honest for earlier 18.x releases and for web runtimes without it. */
|
|
811
|
+
function composeSignals(signals: AbortSignal[]): AbortSignal {
|
|
812
|
+
const nativeAny = (AbortSignal as typeof AbortSignal & { any?: (items: AbortSignal[]) => AbortSignal }).any;
|
|
813
|
+
if (nativeAny) return nativeAny.call(AbortSignal, signals);
|
|
814
|
+
const controller = new AbortController();
|
|
815
|
+
const listeners = new Map<AbortSignal, () => void>();
|
|
816
|
+
const abortFrom = (signal: AbortSignal) => {
|
|
817
|
+
for (const [item, listener] of listeners) item.removeEventListener("abort", listener);
|
|
818
|
+
listeners.clear();
|
|
819
|
+
controller.abort(signal.reason);
|
|
820
|
+
};
|
|
821
|
+
for (const signal of signals) {
|
|
822
|
+
if (signal.aborted) {
|
|
823
|
+
abortFrom(signal);
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
const listener = () => abortFrom(signal);
|
|
827
|
+
listeners.set(signal, listener);
|
|
828
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
829
|
+
}
|
|
830
|
+
return controller.signal;
|
|
831
|
+
}
|
|
832
|
+
|
|
982
833
|
function retryAfterMs(response: Response): number | undefined {
|
|
983
834
|
const header = response.headers.get("retry-after");
|
|
984
835
|
if (!header) return undefined;
|