@typeship-ax/mcp 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.
- package/LICENSE +9 -0
- package/README.md +43 -0
- package/api.json +5163 -0
- package/api.md +512 -0
- package/dist/core/http.d.ts +303 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +770 -0
- package/dist/core/pagination.d.ts +51 -0
- package/dist/core/pagination.d.ts.map +1 -0
- package/dist/core/pagination.js +154 -0
- package/dist/dates.d.ts +33 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +136 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +103 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/mcp-protocol.d.ts +453 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +1262 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +449 -0
- package/dist/ops.d.ts +115 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +79 -0
- package/dist/resources/account.d.ts +18 -0
- package/dist/resources/account.d.ts.map +1 -0
- package/dist/resources/account.js +26 -0
- package/dist/resources/api-keys.d.ts +37 -0
- package/dist/resources/api-keys.d.ts.map +1 -0
- package/dist/resources/api-keys.js +67 -0
- package/dist/resources/generate.d.ts +25 -0
- package/dist/resources/generate.d.ts.map +1 -0
- package/dist/resources/generate.js +41 -0
- package/dist/resources/generations.d.ts +31 -0
- package/dist/resources/generations.d.ts.map +1 -0
- package/dist/resources/generations.js +56 -0
- package/dist/resources/projects.d.ts +110 -0
- package/dist/resources/projects.d.ts.map +1 -0
- package/dist/resources/projects.js +220 -0
- package/dist/resources/spec-revisions.d.ts +47 -0
- package/dist/resources/spec-revisions.d.ts.map +1 -0
- package/dist/resources/spec-revisions.js +90 -0
- package/dist/schemas.d.ts +6 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +88 -0
- package/dist/types.d.ts +759 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +12 -0
- package/package.json +45 -0
- package/src/core/http.ts +1008 -0
- package/src/core/pagination.ts +195 -0
- package/src/dates.ts +126 -0
- package/src/errors.ts +117 -0
- package/src/index.ts +153 -0
- package/src/mcp-protocol.ts +1451 -0
- package/src/mcp.ts +448 -0
- package/src/ops.ts +174 -0
- package/src/resources/account.ts +43 -0
- package/src/resources/api-keys.ts +105 -0
- package/src/resources/generate.ts +69 -0
- package/src/resources/generations.ts +100 -0
- package/src/resources/projects.ts +391 -0
- package/src/resources/spec-revisions.ts +150 -0
- package/src/schemas.ts +90 -0
- package/src/types.ts +825 -0
- package/src/worker.ts +13 -0
package/src/core/http.ts
ADDED
|
@@ -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
|
+
}
|