@apifuse/provider-sdk 2.0.0-beta.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/README.md +44 -0
- package/bin/apifuse-check.ts +406 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +387 -0
- package/bin/apifuse-perf.ts +1099 -0
- package/bin/apifuse-record.ts +444 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/providers-yaml.test.ts +135 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/loader.ts +122 -0
- package/src/config/providers-yaml.ts +370 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/executor.ts +54 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/provider.ts +20 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +664 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { Span } from "../runtime/trace";
|
|
4
|
+
import { renderWaterfall, type WaterfallRequest } from "../runtime/waterfall";
|
|
5
|
+
|
|
6
|
+
function assertDefined<T>(value: T | null | undefined, message?: string): T {
|
|
7
|
+
if (value === null || value === undefined) {
|
|
8
|
+
throw new Error(message ?? "Expected value to be defined");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeSpan(overrides: Partial<Span> & { name: string }): Span {
|
|
15
|
+
return {
|
|
16
|
+
id: crypto.randomUUID(),
|
|
17
|
+
name: overrides.name,
|
|
18
|
+
startedAt: overrides.startedAt ?? 1000,
|
|
19
|
+
endedAt: overrides.endedAt ?? 1100,
|
|
20
|
+
duration_ms: overrides.duration_ms ?? 100,
|
|
21
|
+
status: overrides.status ?? "ok",
|
|
22
|
+
attributes: overrides.attributes ?? {},
|
|
23
|
+
...(overrides.parentId ? { parentId: overrides.parentId } : {}),
|
|
24
|
+
...(overrides.error ? { error: overrides.error } : {}),
|
|
25
|
+
...(overrides.id ? { id: overrides.id } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultRequest: WaterfallRequest = {
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: "/v1/coingecko/prices",
|
|
32
|
+
status: 200,
|
|
33
|
+
totalMs: 289,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("renderWaterfall", () => {
|
|
37
|
+
it("renders span name and duration", () => {
|
|
38
|
+
const rootSpan = makeSpan({
|
|
39
|
+
id: "root-1",
|
|
40
|
+
name: "prices",
|
|
41
|
+
startedAt: 1000,
|
|
42
|
+
endedAt: 1289,
|
|
43
|
+
duration_ms: 289,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const child = makeSpan({
|
|
47
|
+
name: "normalizeRequest",
|
|
48
|
+
parentId: "root-1",
|
|
49
|
+
startedAt: 1000,
|
|
50
|
+
endedAt: 1001,
|
|
51
|
+
duration_ms: 0.1,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const output = renderWaterfall([rootSpan, child], defaultRequest);
|
|
55
|
+
|
|
56
|
+
expect(output).toContain("prices");
|
|
57
|
+
expect(output).toContain("289ms");
|
|
58
|
+
expect(output).toContain("normalizeRequest");
|
|
59
|
+
expect(output).toContain("0.1ms");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("indents child spans", () => {
|
|
63
|
+
const root = makeSpan({
|
|
64
|
+
id: "root-1",
|
|
65
|
+
name: "prices",
|
|
66
|
+
startedAt: 1000,
|
|
67
|
+
endedAt: 1289,
|
|
68
|
+
duration_ms: 289,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const parent = makeSpan({
|
|
72
|
+
id: "parent-1",
|
|
73
|
+
name: "tls.fetch",
|
|
74
|
+
parentId: "root-1",
|
|
75
|
+
startedAt: 1001,
|
|
76
|
+
endedAt: 1286,
|
|
77
|
+
duration_ms: 285,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const child = makeSpan({
|
|
81
|
+
name: "dns",
|
|
82
|
+
parentId: "parent-1",
|
|
83
|
+
startedAt: 1001,
|
|
84
|
+
endedAt: 1012,
|
|
85
|
+
duration_ms: 11.3,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const output = renderWaterfall([root, parent, child], defaultRequest);
|
|
89
|
+
|
|
90
|
+
const lines = output.split("\n");
|
|
91
|
+
|
|
92
|
+
const tlsFetchLine = lines.find((l) => l.includes("tls.fetch"));
|
|
93
|
+
const dnsLine = lines.find((l) => l.includes("dns"));
|
|
94
|
+
|
|
95
|
+
expect(tlsFetchLine).toBeDefined();
|
|
96
|
+
expect(dnsLine).toBeDefined();
|
|
97
|
+
|
|
98
|
+
const stripAnsi = (s: string) =>
|
|
99
|
+
s.replace(new RegExp("\\u001b\\[[0-9;]*m", "g"), "");
|
|
100
|
+
const tlsFetchPos = stripAnsi(assertDefined(tlsFetchLine)).indexOf("├─");
|
|
101
|
+
const dnsPos = stripAnsi(assertDefined(dnsLine)).indexOf("└─");
|
|
102
|
+
|
|
103
|
+
expect(dnsPos).toBeGreaterThan(tlsFetchPos);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("marks slow spans yellow", () => {
|
|
107
|
+
const root = makeSpan({
|
|
108
|
+
id: "root-1",
|
|
109
|
+
name: "prices",
|
|
110
|
+
startedAt: 1000,
|
|
111
|
+
endedAt: 1600,
|
|
112
|
+
duration_ms: 600,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const slowChild = makeSpan({
|
|
116
|
+
name: "tls.fetch",
|
|
117
|
+
parentId: "root-1",
|
|
118
|
+
startedAt: 1000,
|
|
119
|
+
endedAt: 1600,
|
|
120
|
+
duration_ms: 600,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const output = renderWaterfall(
|
|
124
|
+
[root, slowChild],
|
|
125
|
+
{
|
|
126
|
+
...defaultRequest,
|
|
127
|
+
totalMs: 600,
|
|
128
|
+
},
|
|
129
|
+
{ slowThresholdMs: 500 },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(output).toContain("\x1b[33m");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("marks error spans red", () => {
|
|
136
|
+
const root = makeSpan({
|
|
137
|
+
id: "root-1",
|
|
138
|
+
name: "prices",
|
|
139
|
+
startedAt: 1000,
|
|
140
|
+
endedAt: 1100,
|
|
141
|
+
duration_ms: 100,
|
|
142
|
+
status: "error",
|
|
143
|
+
error: "Network failure",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const output = renderWaterfall([root], {
|
|
147
|
+
...defaultRequest,
|
|
148
|
+
status: 500,
|
|
149
|
+
totalMs: 100,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(output).toContain("\x1b[31m");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("marks bottleneck with star", () => {
|
|
156
|
+
const root = makeSpan({
|
|
157
|
+
id: "root-1",
|
|
158
|
+
name: "prices",
|
|
159
|
+
startedAt: 1000,
|
|
160
|
+
endedAt: 1289,
|
|
161
|
+
duration_ms: 289,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const fast = makeSpan({
|
|
165
|
+
name: "normalizeRequest",
|
|
166
|
+
parentId: "root-1",
|
|
167
|
+
startedAt: 1000,
|
|
168
|
+
endedAt: 1001,
|
|
169
|
+
duration_ms: 0.1,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const slow = makeSpan({
|
|
173
|
+
name: "tls.fetch",
|
|
174
|
+
parentId: "root-1",
|
|
175
|
+
startedAt: 1001,
|
|
176
|
+
endedAt: 1286,
|
|
177
|
+
duration_ms: 285,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const transform = makeSpan({
|
|
181
|
+
name: "transformResponse",
|
|
182
|
+
parentId: "root-1",
|
|
183
|
+
startedAt: 1286,
|
|
184
|
+
endedAt: 1289,
|
|
185
|
+
duration_ms: 3.2,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const output = renderWaterfall(
|
|
189
|
+
[root, fast, slow, transform],
|
|
190
|
+
defaultRequest,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const lines = output.split("\n");
|
|
194
|
+
const tlsFetchLine = lines.find((l) => l.includes("tls.fetch"));
|
|
195
|
+
expect(tlsFetchLine).toContain("★");
|
|
196
|
+
|
|
197
|
+
const normalizeLine = lines.find((l) => l.includes("normalizeRequest"));
|
|
198
|
+
expect(normalizeLine).not.toContain("★");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns empty string for no spans", () => {
|
|
202
|
+
const output = renderWaterfall([], defaultRequest);
|
|
203
|
+
expect(output).toBe("");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("renders status line with method and path", () => {
|
|
207
|
+
const root = makeSpan({
|
|
208
|
+
id: "root-1",
|
|
209
|
+
name: "prices",
|
|
210
|
+
duration_ms: 100,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const output = renderWaterfall([root], defaultRequest);
|
|
214
|
+
|
|
215
|
+
expect(output).toContain("GET");
|
|
216
|
+
expect(output).toContain("/v1/coingecko/prices");
|
|
217
|
+
expect(output).toContain("200");
|
|
218
|
+
expect(output).toContain("OK");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("renders timing bars proportional to total duration", () => {
|
|
222
|
+
const root = makeSpan({
|
|
223
|
+
id: "root-1",
|
|
224
|
+
name: "op",
|
|
225
|
+
startedAt: 1000,
|
|
226
|
+
endedAt: 1200,
|
|
227
|
+
duration_ms: 200,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const half = makeSpan({
|
|
231
|
+
name: "half",
|
|
232
|
+
parentId: "root-1",
|
|
233
|
+
startedAt: 1000,
|
|
234
|
+
endedAt: 1100,
|
|
235
|
+
duration_ms: 100,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const full = makeSpan({
|
|
239
|
+
name: "full",
|
|
240
|
+
parentId: "root-1",
|
|
241
|
+
startedAt: 1100,
|
|
242
|
+
endedAt: 1200,
|
|
243
|
+
duration_ms: 200,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const output = renderWaterfall(
|
|
247
|
+
[root, half, full],
|
|
248
|
+
{
|
|
249
|
+
...defaultRequest,
|
|
250
|
+
totalMs: 200,
|
|
251
|
+
},
|
|
252
|
+
{ maxBarWidth: 20 },
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const stripAnsi = (s: string) =>
|
|
256
|
+
s.replace(new RegExp("\\u001b\\[[0-9;]*m", "g"), "");
|
|
257
|
+
const lines = output.split("\n").map(stripAnsi);
|
|
258
|
+
|
|
259
|
+
const halfLine = lines.find(
|
|
260
|
+
(l) => l.includes("half") && !l.includes("full"),
|
|
261
|
+
);
|
|
262
|
+
const fullLine = lines.find((l) => l.includes("full"));
|
|
263
|
+
|
|
264
|
+
const countBars = (s: string) => (s.match(/━/g) ?? []).length;
|
|
265
|
+
const halfBars = countBars(halfLine ?? "");
|
|
266
|
+
const fullBars = countBars(fullLine ?? "");
|
|
267
|
+
|
|
268
|
+
expect(fullBars).toBeGreaterThan(halfBars);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { TraceConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
export type ProxyOptions = {
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ProxyConfig = Partial<ProxyOptions> & {
|
|
11
|
+
provider?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BrowserConfig = {
|
|
16
|
+
executablePath?: string;
|
|
17
|
+
headless?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SessionConfig = {
|
|
21
|
+
storage?: "sqlite" | "supabase";
|
|
22
|
+
path?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ApiFuseConfig = {
|
|
26
|
+
proxy?: ProxyConfig;
|
|
27
|
+
browser?: BrowserConfig;
|
|
28
|
+
session?: SessionConfig;
|
|
29
|
+
trace?: TraceConfig;
|
|
30
|
+
credentials?: Record<string, Record<string, string>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ProxyResolutionOptions = {
|
|
34
|
+
proxy?: string;
|
|
35
|
+
upstream?: { proxy?: boolean };
|
|
36
|
+
apifuseConfig?: Pick<ApiFuseConfig, "proxy">;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ResolvedProxyConfig = {
|
|
40
|
+
shouldWarn: boolean;
|
|
41
|
+
url?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function normalizeProxyUrl(url?: string): string | undefined {
|
|
45
|
+
const normalized = url?.trim();
|
|
46
|
+
return normalized ? normalized : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function syncProxyEnv(config: ApiFuseConfig): void {
|
|
50
|
+
const configProxyUrl = normalizeProxyUrl(config.proxy?.url);
|
|
51
|
+
if (!process.env.APIFUSE_PROXY_URL && configProxyUrl) {
|
|
52
|
+
process.env.APIFUSE_PROXY_URL = configProxyUrl;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveProxyConfig(
|
|
57
|
+
options: ProxyResolutionOptions = {},
|
|
58
|
+
): ResolvedProxyConfig {
|
|
59
|
+
const explicitProxyUrl = normalizeProxyUrl(options.proxy);
|
|
60
|
+
if (explicitProxyUrl) {
|
|
61
|
+
return { shouldWarn: false, url: explicitProxyUrl };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!options.upstream?.proxy) {
|
|
65
|
+
return { shouldWarn: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE_PROXY_URL);
|
|
69
|
+
if (envProxyUrl) {
|
|
70
|
+
return { shouldWarn: false, url: envProxyUrl };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const configuredProxyUrl = normalizeProxyUrl(
|
|
74
|
+
options.apifuseConfig?.proxy?.url,
|
|
75
|
+
);
|
|
76
|
+
if (configuredProxyUrl) {
|
|
77
|
+
return { shouldWarn: false, url: configuredProxyUrl };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { shouldWarn: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function defineConfig(config: ApiFuseConfig): ApiFuseConfig {
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function importConfig(filePath: string): Promise<ApiFuseConfig | null> {
|
|
88
|
+
try {
|
|
89
|
+
const moduleUrl = new URL(`file://${encodeURI(filePath)}`);
|
|
90
|
+
const mod = (await import(moduleUrl.href)) as { default?: ApiFuseConfig };
|
|
91
|
+
if (mod.default && typeof mod.default === "object") {
|
|
92
|
+
return mod.default;
|
|
93
|
+
}
|
|
94
|
+
console.warn(`[provider-sdk] Ignoring invalid config export: ${filePath}`);
|
|
95
|
+
return {};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn(`[provider-sdk] Failed to load config ${filePath}:`, error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function loadApiFuseConfig(
|
|
103
|
+
dir: string = process.cwd(),
|
|
104
|
+
): Promise<ApiFuseConfig> {
|
|
105
|
+
const tsPath = path.resolve(dir, "apifuse.config.ts");
|
|
106
|
+
if (existsSync(tsPath)) {
|
|
107
|
+
const config = await importConfig(tsPath);
|
|
108
|
+
const resolvedConfig = config ?? {};
|
|
109
|
+
syncProxyEnv(resolvedConfig);
|
|
110
|
+
return resolvedConfig;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const jsPath = path.resolve(dir, "apifuse.config.js");
|
|
114
|
+
if (existsSync(jsPath)) {
|
|
115
|
+
const config = await importConfig(jsPath);
|
|
116
|
+
const resolvedConfig = config ?? {};
|
|
117
|
+
syncProxyEnv(resolvedConfig);
|
|
118
|
+
return resolvedConfig;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {};
|
|
122
|
+
}
|