khotan-data 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +54 -0
- package/README.md +117 -1
- package/dist/cli.js +2869 -0
- package/dist/factory.cjs +3303 -0
- package/dist/factory.cjs.map +1 -0
- package/dist/factory.d.cts +662 -0
- package/dist/factory.d.ts +662 -0
- package/dist/factory.js +3292 -0
- package/dist/factory.js.map +1 -0
- package/dist/plug-client.cjs +99 -0
- package/dist/plug-client.cjs.map +1 -0
- package/dist/plug-client.d.cts +71 -0
- package/dist/plug-client.d.ts +71 -0
- package/dist/plug-client.js +96 -0
- package/dist/plug-client.js.map +1 -0
- package/dist/templates/agent-skill.md +73 -0
- package/dist/templates/agents.md +41 -0
- package/dist/templates/cache.example.ts +11 -0
- package/dist/templates/cache.ts +58 -0
- package/dist/templates/catch.example.ts +36 -0
- package/dist/templates/catch.ts +119 -0
- package/dist/templates/config-page.tsx +20 -0
- package/dist/templates/debug-index-page.tsx +101 -0
- package/dist/templates/debug-page.tsx +48 -0
- package/dist/templates/graph-page.tsx +11 -0
- package/dist/templates/hub.tsx +450 -0
- package/dist/templates/inflow.example.ts +61 -0
- package/dist/templates/inflow.ts +98 -0
- package/dist/templates/khotan-config.ts +49 -0
- package/dist/templates/khotan-route.ts +13 -0
- package/dist/templates/logs-page.tsx +9 -0
- package/dist/templates/logs.tsx +20 -0
- package/dist/templates/mapping-browser.tsx +761 -0
- package/dist/templates/mappings-page.tsx +9 -0
- package/dist/templates/outflow.example.ts +52 -0
- package/dist/templates/outflow.ts +90 -0
- package/dist/templates/pass.example.ts +51 -0
- package/dist/templates/pass.ts +134 -0
- package/dist/templates/plug-debugger.tsx +1185 -0
- package/dist/templates/plug.example.ts +93 -0
- package/dist/templates/plug.ts +806 -0
- package/dist/templates/relay.example.ts +71 -0
- package/dist/templates/relay.ts +104 -0
- package/dist/templates/runs-table.tsx +592 -0
- package/dist/templates/schema.ts +505 -0
- package/dist/templates/skill-dashboard.md +144 -0
- package/dist/templates/skill-plug.md +216 -0
- package/dist/templates/skill-setup.md +161 -0
- package/dist/templates/skill-webhook.md +196 -0
- package/dist/templates/topology-canvas.tsx +1406 -0
- package/dist/templates/var-panel.tsx +276 -0
- package/dist/templates/webhook-events-table.tsx +241 -0
- package/dist/templates/wire-panel.tsx +216 -0
- package/dist/templates/wire.ts +155 -0
- package/package.json +46 -5
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Plug — a self-contained fetch wrapper for external APIs
|
|
3
|
+
// Generated by khotan CLI · https://github.com/khotan-data
|
|
4
|
+
//
|
|
5
|
+
// This file is yours. Edit anything — auth strategies, retry logic,
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
const _khotanDebug =
|
|
9
|
+
typeof process !== "undefined" && process.env?.KHOTAN_DEBUG;
|
|
10
|
+
function kd(scope: string, ...args: unknown[]) {
|
|
11
|
+
if (_khotanDebug) console.log(`[khotan:${scope}]`, ...args);
|
|
12
|
+
}
|
|
13
|
+
// pagination helpers, error handling. It has zero runtime dependencies
|
|
14
|
+
// on khotan-data.
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Error
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export class PlugError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
message: string,
|
|
24
|
+
public readonly status: number,
|
|
25
|
+
public readonly statusText: string,
|
|
26
|
+
public readonly body: unknown,
|
|
27
|
+
public readonly url: string,
|
|
28
|
+
public readonly method: string,
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "PlugError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Auth strategies
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface AuthStrategy {
|
|
40
|
+
type: string;
|
|
41
|
+
apply(headers: Headers): void | Promise<void>;
|
|
42
|
+
onUnauthorized?: () => void | Promise<void>;
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function bearer(
|
|
47
|
+
token: string | (() => string | Promise<string>),
|
|
48
|
+
): AuthStrategy {
|
|
49
|
+
return {
|
|
50
|
+
type: "bearer",
|
|
51
|
+
async apply(headers: Headers) {
|
|
52
|
+
const t = typeof token === "function" ? await token() : token;
|
|
53
|
+
headers.set("Authorization", `Bearer ${t}`);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function basic(username: string, password: string): AuthStrategy {
|
|
59
|
+
return {
|
|
60
|
+
type: "basic",
|
|
61
|
+
apply(headers: Headers) {
|
|
62
|
+
const encoded = btoa(`${username}:${password}`);
|
|
63
|
+
headers.set("Authorization", `Basic ${encoded}`);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function apiKey(
|
|
69
|
+
name: string,
|
|
70
|
+
value: string,
|
|
71
|
+
options?: { in?: "header" | "query" },
|
|
72
|
+
): AuthStrategy {
|
|
73
|
+
const location = options?.in ?? "header";
|
|
74
|
+
return {
|
|
75
|
+
type: "apiKey",
|
|
76
|
+
apply(headers: Headers) {
|
|
77
|
+
if (location === "header") {
|
|
78
|
+
headers.set(name, value);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
...(location === "query" ? { queryParam: { name, value } } : {}),
|
|
82
|
+
} as AuthStrategy & { queryParam?: { name: string; value: string } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function custom(
|
|
86
|
+
fn: (headers: Headers) => void | Promise<void>,
|
|
87
|
+
): AuthStrategy {
|
|
88
|
+
return {
|
|
89
|
+
type: "custom",
|
|
90
|
+
apply: fn,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Var fields — declare what runtime variables a plug needs
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export interface VarField {
|
|
99
|
+
readonly key: string;
|
|
100
|
+
label: string;
|
|
101
|
+
type: "text" | "password" | "url";
|
|
102
|
+
secret?: boolean;
|
|
103
|
+
hidden?: boolean;
|
|
104
|
+
required?: boolean;
|
|
105
|
+
placeholder?: string;
|
|
106
|
+
defaultValue?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Type utilities for inferring var keys
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
type VarKeys<V extends readonly VarField[]> = V[number]["key"];
|
|
114
|
+
type VarsRecord<V extends readonly VarField[]> = Record<VarKeys<V>, string>;
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Token exchange auth strategy
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface TokenExchangeConfig {
|
|
121
|
+
getVariables: () => Promise<Record<string, string>> | Record<string, string>;
|
|
122
|
+
/** Full URL or path relative to the plug's baseUrl (e.g. "/token") */
|
|
123
|
+
tokenEndpoint: string;
|
|
124
|
+
buildTokenRequest: (vars: Record<string, string>) => {
|
|
125
|
+
headers?: Record<string, string>;
|
|
126
|
+
body?: unknown;
|
|
127
|
+
method?: string;
|
|
128
|
+
};
|
|
129
|
+
parseTokenResponse: (data: unknown) => {
|
|
130
|
+
accessToken: string;
|
|
131
|
+
expiresIn?: number;
|
|
132
|
+
};
|
|
133
|
+
extraHeaders?: (vars: Record<string, string>) => Record<string, string>;
|
|
134
|
+
refreshBufferSeconds?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function tokenExchange(config: TokenExchangeConfig): AuthStrategy {
|
|
138
|
+
let cachedToken: string | null = null;
|
|
139
|
+
let tokenExpiresAt = 0;
|
|
140
|
+
let _baseUrl = "";
|
|
141
|
+
const refreshBuffer = (config.refreshBufferSeconds ?? 60) * 1000;
|
|
142
|
+
|
|
143
|
+
function resolveEndpoint(): string {
|
|
144
|
+
const ep = config.tokenEndpoint;
|
|
145
|
+
if (ep.startsWith("http://") || ep.startsWith("https://")) return ep;
|
|
146
|
+
return `${_baseUrl.replace(/\/$/, "")}${ep.startsWith("/") ? "" : "/"}${ep}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isExpired(): boolean {
|
|
150
|
+
if (!cachedToken) return true;
|
|
151
|
+
if (tokenExpiresAt === 0) return false;
|
|
152
|
+
return Date.now() >= tokenExpiresAt - refreshBuffer;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function fetchToken(): Promise<string> {
|
|
156
|
+
const vars = await config.getVariables();
|
|
157
|
+
kd(
|
|
158
|
+
"auth",
|
|
159
|
+
"tokenExchange: fetching token, has variables:",
|
|
160
|
+
Object.keys(vars).join(", "),
|
|
161
|
+
);
|
|
162
|
+
const reqConfig = config.buildTokenRequest(vars);
|
|
163
|
+
|
|
164
|
+
const headers = new Headers(reqConfig.headers);
|
|
165
|
+
if (reqConfig.body && !headers.has("Content-Type")) {
|
|
166
|
+
headers.set("Content-Type", "application/json");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const endpoint = resolveEndpoint();
|
|
170
|
+
kd("auth", "tokenExchange: POST", endpoint);
|
|
171
|
+
const res = await fetch(endpoint, {
|
|
172
|
+
method: reqConfig.method ?? "POST",
|
|
173
|
+
headers,
|
|
174
|
+
body: reqConfig.body ? JSON.stringify(reqConfig.body) : undefined,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const body = await res.text().catch(() => "");
|
|
179
|
+
kd("auth", "tokenExchange: FAILED", res.status, res.statusText, body);
|
|
180
|
+
throw new PlugError(
|
|
181
|
+
`Token exchange failed: ${res.status} ${res.statusText}`,
|
|
182
|
+
res.status,
|
|
183
|
+
res.statusText,
|
|
184
|
+
body,
|
|
185
|
+
endpoint,
|
|
186
|
+
reqConfig.method ?? "POST",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const data = await res.json();
|
|
191
|
+
kd(
|
|
192
|
+
"auth",
|
|
193
|
+
"tokenExchange: raw response keys:",
|
|
194
|
+
Object.keys(data as object),
|
|
195
|
+
);
|
|
196
|
+
const parsed = config.parseTokenResponse(data);
|
|
197
|
+
cachedToken = parsed.accessToken;
|
|
198
|
+
if (!cachedToken) {
|
|
199
|
+
kd(
|
|
200
|
+
"auth",
|
|
201
|
+
"tokenExchange: WARNING - accessToken is undefined/null, check parseTokenResponse",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (parsed.expiresIn) {
|
|
205
|
+
tokenExpiresAt = Date.now() + parsed.expiresIn * 1000;
|
|
206
|
+
}
|
|
207
|
+
kd(
|
|
208
|
+
"auth",
|
|
209
|
+
"tokenExchange: got token, expires in",
|
|
210
|
+
parsed.expiresIn ?? "unknown",
|
|
211
|
+
"seconds",
|
|
212
|
+
);
|
|
213
|
+
return cachedToken;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
type: "tokenExchange",
|
|
218
|
+
async apply(headers: Headers) {
|
|
219
|
+
if (isExpired()) {
|
|
220
|
+
kd("auth", "tokenExchange: token expired or missing, refreshing");
|
|
221
|
+
await fetchToken();
|
|
222
|
+
}
|
|
223
|
+
headers.set("Authorization", `Bearer ${cachedToken}`);
|
|
224
|
+
kd(
|
|
225
|
+
"auth",
|
|
226
|
+
"tokenExchange: applied bearer token",
|
|
227
|
+
cachedToken ? `${cachedToken.slice(0, 8)}...` : "MISSING",
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (config.extraHeaders) {
|
|
231
|
+
const vars = await config.getVariables();
|
|
232
|
+
const extra = config.extraHeaders(vars);
|
|
233
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
234
|
+
headers.set(key, value);
|
|
235
|
+
kd("auth", "tokenExchange: set extra header", key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
onUnauthorized() {
|
|
240
|
+
kd("auth", "tokenExchange: 401 received, clearing cached token");
|
|
241
|
+
cachedToken = null;
|
|
242
|
+
tokenExpiresAt = 0;
|
|
243
|
+
},
|
|
244
|
+
set baseUrl(url: string) {
|
|
245
|
+
_baseUrl = url;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Pagination strategies
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export interface PaginationStrategy {
|
|
255
|
+
type: string;
|
|
256
|
+
getNextParams(
|
|
257
|
+
response: Record<string, unknown>,
|
|
258
|
+
currentParams: Record<string, unknown>,
|
|
259
|
+
): Record<string, unknown> | null;
|
|
260
|
+
getDataPath(): string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getByPath(obj: Record<string, unknown>, path: string): unknown {
|
|
264
|
+
return path.split(".").reduce<unknown>((acc, key) => {
|
|
265
|
+
if (acc != null && typeof acc === "object") {
|
|
266
|
+
return (acc as Record<string, unknown>)[key];
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}, obj);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function cursorPagination(config: {
|
|
273
|
+
cursorParam: string;
|
|
274
|
+
cursorPath: string;
|
|
275
|
+
dataPath: string;
|
|
276
|
+
}): PaginationStrategy {
|
|
277
|
+
return {
|
|
278
|
+
type: "cursor",
|
|
279
|
+
getNextParams(response) {
|
|
280
|
+
const cursor = getByPath(response, config.cursorPath);
|
|
281
|
+
if (cursor == null || cursor === "") return null;
|
|
282
|
+
return { [config.cursorParam]: cursor };
|
|
283
|
+
},
|
|
284
|
+
getDataPath() {
|
|
285
|
+
return config.dataPath;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function offsetPagination(config: {
|
|
291
|
+
limitParam?: string;
|
|
292
|
+
offsetParam?: string;
|
|
293
|
+
dataPath: string;
|
|
294
|
+
pageSize: number;
|
|
295
|
+
}): PaginationStrategy {
|
|
296
|
+
const limitParam = config.limitParam ?? "limit";
|
|
297
|
+
const offsetParam = config.offsetParam ?? "offset";
|
|
298
|
+
return {
|
|
299
|
+
type: "offset",
|
|
300
|
+
getNextParams(_response, currentParams) {
|
|
301
|
+
const data = getByPath(_response, config.dataPath) as
|
|
302
|
+
| unknown[]
|
|
303
|
+
| undefined;
|
|
304
|
+
if (!data || data.length < config.pageSize) return null;
|
|
305
|
+
const currentOffset =
|
|
306
|
+
typeof currentParams[offsetParam] === "number"
|
|
307
|
+
? (currentParams[offsetParam] as number)
|
|
308
|
+
: 0;
|
|
309
|
+
return {
|
|
310
|
+
[limitParam]: config.pageSize,
|
|
311
|
+
[offsetParam]: currentOffset + config.pageSize,
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
getDataPath() {
|
|
315
|
+
return config.dataPath;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function keysetPagination(config: {
|
|
321
|
+
param: string;
|
|
322
|
+
idField: string;
|
|
323
|
+
dataPath: string;
|
|
324
|
+
}): PaginationStrategy {
|
|
325
|
+
return {
|
|
326
|
+
type: "keyset",
|
|
327
|
+
getNextParams(response) {
|
|
328
|
+
const data = getByPath(response, config.dataPath) as
|
|
329
|
+
| Record<string, unknown>[]
|
|
330
|
+
| undefined;
|
|
331
|
+
if (!data || data.length === 0) return null;
|
|
332
|
+
const lastItem = data[data.length - 1];
|
|
333
|
+
const id = lastItem?.[config.idField];
|
|
334
|
+
if (id == null) return null;
|
|
335
|
+
return { [config.param]: id };
|
|
336
|
+
},
|
|
337
|
+
getDataPath() {
|
|
338
|
+
return config.dataPath;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Retry logic
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
interface RetryConfig {
|
|
348
|
+
attempts: number;
|
|
349
|
+
backoff?: number;
|
|
350
|
+
maxBackoff?: number;
|
|
351
|
+
retryableStatuses?: number[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const DEFAULT_RETRY: RetryConfig = {
|
|
355
|
+
attempts: 3,
|
|
356
|
+
backoff: 1000,
|
|
357
|
+
maxBackoff: 30000,
|
|
358
|
+
retryableStatuses: [408, 429, 500, 502, 503, 504],
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
function computeDelay(attempt: number, config: RetryConfig): number {
|
|
362
|
+
const base = config.backoff ?? 1000;
|
|
363
|
+
const max = config.maxBackoff ?? 30000;
|
|
364
|
+
const exponential = base * Math.pow(2, attempt);
|
|
365
|
+
const jitter = Math.random() * exponential * 0.1;
|
|
366
|
+
return Math.min(exponential + jitter, max);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseRetryAfter(header: string | null): number | null {
|
|
370
|
+
if (!header) return null;
|
|
371
|
+
const seconds = Number(header);
|
|
372
|
+
if (!Number.isNaN(seconds)) return seconds * 1000;
|
|
373
|
+
const date = Date.parse(header);
|
|
374
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Hooks
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
export interface HookContext<Vars = Record<string, string>> {
|
|
383
|
+
url: string;
|
|
384
|
+
path: string;
|
|
385
|
+
method: string;
|
|
386
|
+
headers: Headers;
|
|
387
|
+
body?: unknown;
|
|
388
|
+
vars: Vars;
|
|
389
|
+
setVars(updates: Partial<Vars>): Promise<void>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface PlugHooks<Vars = Record<string, string>> {
|
|
393
|
+
beforeRequest?: (ctx: HookContext<Vars>) => void | Promise<void>;
|
|
394
|
+
afterResponse?: (
|
|
395
|
+
response: Response,
|
|
396
|
+
ctx: HookContext<Vars>,
|
|
397
|
+
) => void | Promise<void>;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Wire configuration
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
export interface EndpointSchema {
|
|
405
|
+
parse(data: unknown): unknown;
|
|
406
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
407
|
+
_def?: any;
|
|
408
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
409
|
+
shape?: any;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface EndpointDef {
|
|
413
|
+
method: string;
|
|
414
|
+
path: string;
|
|
415
|
+
description?: string;
|
|
416
|
+
body?: EndpointSchema;
|
|
417
|
+
query?: EndpointSchema;
|
|
418
|
+
responses?: Record<number, EndpointSchema>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface PlugConfig<V extends readonly VarField[] = VarField[]> {
|
|
422
|
+
name?: string;
|
|
423
|
+
baseUrl: string;
|
|
424
|
+
auth?: AuthStrategy;
|
|
425
|
+
vars?: V;
|
|
426
|
+
endpoints?: Record<string, EndpointDef>;
|
|
427
|
+
retry?: RetryConfig | false;
|
|
428
|
+
timeout?: number;
|
|
429
|
+
defaultHeaders?: Record<string, string>;
|
|
430
|
+
pagination?: PaginationStrategy;
|
|
431
|
+
hooks?: PlugHooks<VarsRecord<V>>;
|
|
432
|
+
parsers?: Record<string, (text: string) => unknown>;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface RequestOptions {
|
|
436
|
+
params?: Record<string, unknown>;
|
|
437
|
+
body?: unknown;
|
|
438
|
+
headers?: Record<string, string>;
|
|
439
|
+
signal?: AbortSignal;
|
|
440
|
+
/** Injected vars available in hooks via ctx.vars */
|
|
441
|
+
vars?: Record<string, string>;
|
|
442
|
+
/** Function to persist var updates back to encrypted storage */
|
|
443
|
+
_setVars?: (updates: Record<string, string>) => Promise<void>;
|
|
444
|
+
/** @internal Skip hooks for this request (used to prevent recursion in beforeRequest) */
|
|
445
|
+
_skipHooks?: boolean;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Plug class
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
export class Plug<V extends readonly VarField[] = VarField[]> {
|
|
453
|
+
private readonly config: PlugConfig<V>;
|
|
454
|
+
|
|
455
|
+
constructor(config: PlugConfig<V>) {
|
|
456
|
+
this.config = config;
|
|
457
|
+
if (config.auth) {
|
|
458
|
+
config.auth.baseUrl = config.baseUrl;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
get name(): string {
|
|
463
|
+
return this.config.name ?? "unnamed";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
get baseUrl(): string {
|
|
467
|
+
return this.config.baseUrl;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
get authType(): string {
|
|
471
|
+
return this.config.auth?.type ?? "none";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
get varFields(): readonly VarField[] {
|
|
475
|
+
return this.config.vars ?? [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
get endpoints(): Record<string, EndpointDef> {
|
|
479
|
+
return this.config.endpoints ?? {};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async get<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
483
|
+
return this.request<T>("GET", path, options);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async post<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
487
|
+
return this.request<T>("POST", path, options);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async put<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
491
|
+
return this.request<T>("PUT", path, options);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async patch<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
495
|
+
return this.request<T>("PATCH", path, options);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async delete<T>(path: string, options?: RequestOptions): Promise<T> {
|
|
499
|
+
return this.request<T>("DELETE", path, options);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async request<T>(
|
|
503
|
+
method: string,
|
|
504
|
+
path: string,
|
|
505
|
+
options?: RequestOptions,
|
|
506
|
+
): Promise<T> {
|
|
507
|
+
const url = this.buildUrl(path, options?.params);
|
|
508
|
+
const headers = this.buildHeaders(options?.headers);
|
|
509
|
+
|
|
510
|
+
if (!options?._skipHooks && this.config.hooks?.beforeRequest) {
|
|
511
|
+
const setVars = options?._setVars ?? (async () => {});
|
|
512
|
+
await this.config.hooks.beforeRequest({
|
|
513
|
+
url,
|
|
514
|
+
path,
|
|
515
|
+
method,
|
|
516
|
+
headers,
|
|
517
|
+
body: options?.body,
|
|
518
|
+
vars: (options?.vars ?? {}) as VarsRecord<V>,
|
|
519
|
+
setVars: (updates) => setVars(updates as Record<string, string>),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (this.config.auth) {
|
|
524
|
+
await this.config.auth.apply(headers);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const authWithQuery = this.config.auth as AuthStrategy & {
|
|
528
|
+
queryParam?: { name: string; value: string };
|
|
529
|
+
};
|
|
530
|
+
if (authWithQuery?.queryParam) {
|
|
531
|
+
const u = new URL(url);
|
|
532
|
+
u.searchParams.set(
|
|
533
|
+
authWithQuery.queryParam.name,
|
|
534
|
+
authWithQuery.queryParam.value,
|
|
535
|
+
);
|
|
536
|
+
return this._fetchWithAuthRetry<T>(
|
|
537
|
+
method,
|
|
538
|
+
u.toString(),
|
|
539
|
+
headers,
|
|
540
|
+
options,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return this._fetchWithAuthRetry<T>(method, url, headers, options);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async *paginate<T>(
|
|
548
|
+
path: string,
|
|
549
|
+
options?: RequestOptions,
|
|
550
|
+
): AsyncIterable<T[]> {
|
|
551
|
+
const pagination = this.config.pagination;
|
|
552
|
+
if (!pagination) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
"Pagination strategy must be configured to use paginate()",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let currentParams: Record<string, unknown> = { ...options?.params };
|
|
559
|
+
|
|
560
|
+
while (true) {
|
|
561
|
+
const response = await this.request<Record<string, unknown>>(
|
|
562
|
+
"GET",
|
|
563
|
+
path,
|
|
564
|
+
{ ...options, params: currentParams },
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const data = getByPath(response, pagination.getDataPath()) as
|
|
568
|
+
| T[]
|
|
569
|
+
| undefined;
|
|
570
|
+
if (!data || data.length === 0) break;
|
|
571
|
+
|
|
572
|
+
yield data;
|
|
573
|
+
|
|
574
|
+
const nextParams = pagination.getNextParams(response, currentParams);
|
|
575
|
+
if (!nextParams) break;
|
|
576
|
+
|
|
577
|
+
currentParams = { ...currentParams, ...nextParams };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
with(overrides: Partial<PlugConfig<V>>): Plug<V> {
|
|
582
|
+
return new Plug({ ...this.config, ...overrides });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
withAuth(auth: AuthStrategy): Plug<V> {
|
|
586
|
+
return this.with({ auth });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private buildUrl(path: string, params?: Record<string, unknown>): string {
|
|
590
|
+
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
591
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
592
|
+
const url = new URL(`${base}${cleanPath}`);
|
|
593
|
+
|
|
594
|
+
if (params) {
|
|
595
|
+
for (const [key, value] of Object.entries(params)) {
|
|
596
|
+
if (value != null) {
|
|
597
|
+
url.searchParams.set(key, String(value));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return url.toString();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private buildHeaders(extra?: Record<string, string>): Headers {
|
|
606
|
+
const headers = new Headers();
|
|
607
|
+
|
|
608
|
+
if (this.config.defaultHeaders) {
|
|
609
|
+
for (const [key, value] of Object.entries(this.config.defaultHeaders)) {
|
|
610
|
+
headers.set(key, value);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (extra) {
|
|
615
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
616
|
+
headers.set(key, value);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return headers;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private async _fetchWithAuthRetry<T>(
|
|
624
|
+
method: string,
|
|
625
|
+
url: string,
|
|
626
|
+
headers: Headers,
|
|
627
|
+
options?: RequestOptions,
|
|
628
|
+
): Promise<T> {
|
|
629
|
+
try {
|
|
630
|
+
return await this._fetch<T>(method, url, headers, options);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (
|
|
633
|
+
error instanceof PlugError &&
|
|
634
|
+
error.status === 401 &&
|
|
635
|
+
this.config.auth?.onUnauthorized
|
|
636
|
+
) {
|
|
637
|
+
kd(
|
|
638
|
+
"request",
|
|
639
|
+
`${this.name}: 401 on ${method} ${url}, retrying with fresh auth`,
|
|
640
|
+
);
|
|
641
|
+
await this.config.auth.onUnauthorized();
|
|
642
|
+
const freshHeaders = this.buildHeaders(options?.headers);
|
|
643
|
+
await this.config.auth.apply(freshHeaders);
|
|
644
|
+
return this._fetch<T>(method, url, freshHeaders, options);
|
|
645
|
+
}
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private async _fetch<T>(
|
|
651
|
+
method: string,
|
|
652
|
+
url: string,
|
|
653
|
+
headers: Headers,
|
|
654
|
+
options?: RequestOptions,
|
|
655
|
+
): Promise<T> {
|
|
656
|
+
kd("request", `${this.name}: ${method} ${url}`);
|
|
657
|
+
const retryConfig =
|
|
658
|
+
this.config.retry === false
|
|
659
|
+
? null
|
|
660
|
+
: { ...DEFAULT_RETRY, ...this.config.retry };
|
|
661
|
+
|
|
662
|
+
const maxAttempts = retryConfig ? retryConfig.attempts : 1;
|
|
663
|
+
|
|
664
|
+
let lastError: Error | undefined;
|
|
665
|
+
|
|
666
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
667
|
+
try {
|
|
668
|
+
const controller = new AbortController();
|
|
669
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
670
|
+
|
|
671
|
+
if (this.config.timeout) {
|
|
672
|
+
timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const externalSignal = options?.signal;
|
|
676
|
+
if (externalSignal?.aborted) {
|
|
677
|
+
controller.abort(externalSignal.reason);
|
|
678
|
+
}
|
|
679
|
+
externalSignal?.addEventListener("abort", () =>
|
|
680
|
+
controller.abort(externalSignal.reason),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
const init: globalThis.RequestInit = {
|
|
684
|
+
method,
|
|
685
|
+
headers,
|
|
686
|
+
signal: controller.signal,
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
if (options?.body !== undefined && method !== "GET") {
|
|
690
|
+
init.body = JSON.stringify(options.body);
|
|
691
|
+
headers.set("Content-Type", "application/json");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const response = await fetch(url, init);
|
|
695
|
+
|
|
696
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
697
|
+
|
|
698
|
+
if (this.config.hooks?.afterResponse && !options?._skipHooks) {
|
|
699
|
+
const setVars = options?._setVars ?? (async () => {});
|
|
700
|
+
await this.config.hooks.afterResponse(response.clone(), {
|
|
701
|
+
url,
|
|
702
|
+
path: url.replace(this.config.baseUrl.replace(/\/+$/, ""), ""),
|
|
703
|
+
method,
|
|
704
|
+
headers,
|
|
705
|
+
body: options?.body,
|
|
706
|
+
vars: (options?.vars ?? {}) as VarsRecord<V>,
|
|
707
|
+
setVars: (updates) => setVars(updates as Record<string, string>),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (!response.ok) {
|
|
712
|
+
const body = await response.text().catch(() => "");
|
|
713
|
+
const plugError = new PlugError(
|
|
714
|
+
`${method} ${url} failed with ${response.status} ${response.statusText}`,
|
|
715
|
+
response.status,
|
|
716
|
+
response.statusText,
|
|
717
|
+
body,
|
|
718
|
+
url,
|
|
719
|
+
method,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (
|
|
723
|
+
retryConfig &&
|
|
724
|
+
attempt < maxAttempts - 1 &&
|
|
725
|
+
(retryConfig.retryableStatuses?.includes(response.status) ?? false)
|
|
726
|
+
) {
|
|
727
|
+
let delay = computeDelay(attempt, retryConfig);
|
|
728
|
+
|
|
729
|
+
if (response.status === 429) {
|
|
730
|
+
const retryAfter = parseRetryAfter(
|
|
731
|
+
response.headers.get("Retry-After"),
|
|
732
|
+
);
|
|
733
|
+
if (retryAfter !== null) {
|
|
734
|
+
delay = retryAfter;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
lastError = plugError;
|
|
739
|
+
await sleep(delay);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
throw plugError;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const contentType = response.headers.get("Content-Type") ?? "";
|
|
747
|
+
if (contentType.includes("application/json")) {
|
|
748
|
+
return (await response.json()) as T;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (this.config.parsers) {
|
|
752
|
+
for (const [mime, parser] of Object.entries(this.config.parsers)) {
|
|
753
|
+
if (contentType.includes(mime)) {
|
|
754
|
+
const text = await response.text();
|
|
755
|
+
return parser(text) as T;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return (await response.text()) as unknown as T;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof PlugError) throw error;
|
|
763
|
+
|
|
764
|
+
if (
|
|
765
|
+
retryConfig &&
|
|
766
|
+
attempt < maxAttempts - 1 &&
|
|
767
|
+
!(error instanceof DOMException && error.name === "AbortError")
|
|
768
|
+
) {
|
|
769
|
+
lastError = error as Error;
|
|
770
|
+
await sleep(computeDelay(attempt, retryConfig));
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
775
|
+
throw new Error(`Request timed out: ${method} ${url}`, {
|
|
776
|
+
cause: error,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
throw (
|
|
785
|
+
lastError ?? new Error(`Request failed after ${maxAttempts} attempts`)
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Factory
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
export function plug<const V extends readonly VarField[]>(
|
|
795
|
+
config: PlugConfig<V>,
|
|
796
|
+
): Plug<V> {
|
|
797
|
+
return new Plug(config);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// Utilities
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
function sleep(ms: number): Promise<void> {
|
|
805
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
806
|
+
}
|