@apifuse/connector-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.
Files changed (67) hide show
  1. package/README.md +44 -0
  2. package/bin/apifuse-check.ts +408 -0
  3. package/bin/apifuse-dev.ts +222 -0
  4. package/bin/apifuse-init.ts +390 -0
  5. package/bin/apifuse-perf.ts +1101 -0
  6. package/bin/apifuse-record.ts +446 -0
  7. package/bin/apifuse-test.ts +688 -0
  8. package/bin/apifuse.ts +51 -0
  9. package/package.json +64 -0
  10. package/src/__tests__/auth.test.ts +396 -0
  11. package/src/__tests__/browser-auth.test.ts +180 -0
  12. package/src/__tests__/browser.test.ts +632 -0
  13. package/src/__tests__/connectors-yaml.test.ts +135 -0
  14. package/src/__tests__/define.test.ts +225 -0
  15. package/src/__tests__/errors.test.ts +69 -0
  16. package/src/__tests__/executor.test.ts +214 -0
  17. package/src/__tests__/http.test.ts +238 -0
  18. package/src/__tests__/insights.test.ts +210 -0
  19. package/src/__tests__/instrumentation.test.ts +290 -0
  20. package/src/__tests__/otlp.test.ts +141 -0
  21. package/src/__tests__/perf.test.ts +60 -0
  22. package/src/__tests__/proxy.test.ts +359 -0
  23. package/src/__tests__/recipes.test.ts +36 -0
  24. package/src/__tests__/serve.test.ts +233 -0
  25. package/src/__tests__/session.test.ts +231 -0
  26. package/src/__tests__/state.test.ts +100 -0
  27. package/src/__tests__/stealth.test.ts +57 -0
  28. package/src/__tests__/testing.test.ts +97 -0
  29. package/src/__tests__/tls.test.ts +345 -0
  30. package/src/__tests__/types.test.ts +142 -0
  31. package/src/__tests__/utils.test.ts +62 -0
  32. package/src/__tests__/waterfall.test.ts +270 -0
  33. package/src/config/connectors-yaml.ts +373 -0
  34. package/src/config/loader.ts +122 -0
  35. package/src/define.ts +137 -0
  36. package/src/dev.ts +38 -0
  37. package/src/errors.ts +68 -0
  38. package/src/index.test.ts +1 -0
  39. package/src/index.ts +100 -0
  40. package/src/protocol.ts +183 -0
  41. package/src/recipes/gov-api.ts +97 -0
  42. package/src/recipes/rest-api.ts +152 -0
  43. package/src/runtime/auth.ts +245 -0
  44. package/src/runtime/browser.ts +724 -0
  45. package/src/runtime/connector.ts +20 -0
  46. package/src/runtime/executor.ts +51 -0
  47. package/src/runtime/http.ts +248 -0
  48. package/src/runtime/insights.ts +456 -0
  49. package/src/runtime/instrumentation.ts +424 -0
  50. package/src/runtime/otlp.ts +171 -0
  51. package/src/runtime/perf.ts +73 -0
  52. package/src/runtime/session.ts +573 -0
  53. package/src/runtime/state.ts +124 -0
  54. package/src/runtime/tls.ts +410 -0
  55. package/src/runtime/trace.ts +261 -0
  56. package/src/runtime/waterfall.ts +245 -0
  57. package/src/serve.ts +665 -0
  58. package/src/stealth/profiles.ts +391 -0
  59. package/src/testing/helpers.ts +144 -0
  60. package/src/testing/index.ts +2 -0
  61. package/src/testing/run.ts +88 -0
  62. package/src/types/playwright-stealth.d.ts +9 -0
  63. package/src/types.ts +243 -0
  64. package/src/utils/date.ts +163 -0
  65. package/src/utils/parse.ts +66 -0
  66. package/src/utils/text.ts +20 -0
  67. package/src/utils/transform.ts +62 -0
@@ -0,0 +1,456 @@
1
+ import { computePercentile } from "./perf";
2
+ import type { Span, SpanAttributeValue } from "./trace";
3
+
4
+ export type InsightSeverity = "info" | "warning" | "error";
5
+
6
+ export type Insight = {
7
+ id: string;
8
+ severity: InsightSeverity;
9
+ message: string;
10
+ fix?: string;
11
+ };
12
+
13
+ type InsightResult = {
14
+ message: string;
15
+ fix?: string;
16
+ triggered: boolean;
17
+ };
18
+
19
+ type DnsHostInsight = {
20
+ hostname: string;
21
+ avgDnsMs: number;
22
+ };
23
+
24
+ const LARGE_RESPONSE_BYTES = 100_000;
25
+ const SLOW_TRANSFORM_MS = 10;
26
+ const DNS_WARN_MS = 5;
27
+ const BROWSER_IDLE_MS = 5_000;
28
+ const REFRESH_WARN_RATE = 0.1;
29
+
30
+ const TLS_REUSE_FIX = `const session = ctx.tls.createSession({ profile: 'chrome-131' });
31
+ const resp = await session.fetch(url, opts);`;
32
+
33
+ const TRANSFORM_FIX = `transformResponse: (raw) => {
34
+ return raw.items.map(({ id, name, price }) => ({ id, name, price }));
35
+ }`;
36
+
37
+ const LARGE_RESPONSE_FIX = `const resp = await ctx.http.get('/items', {
38
+ params: { limit: 50, page: 1 },
39
+ });`;
40
+
41
+ const DNS_FIX = `// Enable DNS caching or reuse a long-lived session per host.
42
+ const session = ctx.tls.createSession({ profile: 'chrome-131' });
43
+ await session.fetch(url, opts);`;
44
+
45
+ const PROXY_FIX = `// Re-check whether this operation really needs a proxy.
46
+ await ctx.tls.fetch(url, { ...opts, proxy: undefined });`;
47
+
48
+ const BROWSER_FIX = `await page.waitForSelector('[data-ready="true"]', {
49
+ timeout: 5_000,
50
+ });`;
51
+
52
+ const SESSION_FIX = `export default defineConnector({
53
+ session: {
54
+ ttl: 60 * 60,
55
+ },
56
+ });`;
57
+
58
+ function isNumber(value: SpanAttributeValue | undefined): value is number {
59
+ return typeof value === "number" && Number.isFinite(value);
60
+ }
61
+
62
+ function isBoolean(value: SpanAttributeValue | undefined): value is boolean {
63
+ return typeof value === "boolean";
64
+ }
65
+
66
+ function isString(value: SpanAttributeValue | undefined): value is string {
67
+ return typeof value === "string" && value.length > 0;
68
+ }
69
+
70
+ function getNumberAttribute(span: Span, key: string): number | undefined {
71
+ const value = span.attributes[key];
72
+ return isNumber(value) ? value : undefined;
73
+ }
74
+
75
+ function getBooleanAttribute(span: Span, key: string): boolean | undefined {
76
+ const value = span.attributes[key];
77
+ return isBoolean(value) ? value : undefined;
78
+ }
79
+
80
+ function getStringAttribute(span: Span, key: string): string | undefined {
81
+ const value = span.attributes[key];
82
+ return isString(value) ? value : undefined;
83
+ }
84
+
85
+ function formatPercent(value: number): string {
86
+ return `${Math.round(value)}%`;
87
+ }
88
+
89
+ function formatMs(value: number): string {
90
+ const rounded = Number(value.toFixed(value >= 10 ? 0 : 1));
91
+ return `${rounded}ms`;
92
+ }
93
+
94
+ function formatBytes(value: number): string {
95
+ if (value >= 1_000_000) {
96
+ return `${(value / 1_000_000).toFixed(1)}MB`;
97
+ }
98
+
99
+ if (value >= 1_000) {
100
+ return `${(value / 1_000).toFixed(1)}KB`;
101
+ }
102
+
103
+ return `${value}B`;
104
+ }
105
+
106
+ function parseHostname(url: string | undefined): string | undefined {
107
+ if (!url) {
108
+ return undefined;
109
+ }
110
+
111
+ try {
112
+ return new URL(url).hostname;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ function makeInsight(
119
+ id: string,
120
+ severity: InsightSeverity,
121
+ result: InsightResult,
122
+ ): Insight {
123
+ return {
124
+ id,
125
+ severity,
126
+ message: result.message,
127
+ ...(result.fix ? { fix: result.fix } : {}),
128
+ };
129
+ }
130
+
131
+ function isTlsSpan(span: Span): boolean {
132
+ return span.name.startsWith("tls.");
133
+ }
134
+
135
+ function isRequestSpan(span: Span): boolean {
136
+ return span.name === "tls.fetch" || span.name.startsWith("http.");
137
+ }
138
+
139
+ function isBrowserSpan(span: Span): boolean {
140
+ return span.name.startsWith("browser.") || span.name.startsWith("page.");
141
+ }
142
+
143
+ function hasProxy(span: Span): boolean {
144
+ const proxy = span.attributes.proxy;
145
+ return proxy === true || (typeof proxy === "string" && proxy.length > 0);
146
+ }
147
+
148
+ function getTlsReuseInsight(spans: Span[]): InsightResult {
149
+ const tlsSpans = spans.filter(isTlsSpan);
150
+ if (tlsSpans.length === 0) {
151
+ return {
152
+ triggered: false,
153
+ message: "✓ TLS connection reuse: no TLS spans sampled yet",
154
+ };
155
+ }
156
+
157
+ const reusedCount = tlsSpans.filter(
158
+ (span) => getBooleanAttribute(span, "connection_reused") === true,
159
+ ).length;
160
+ const reuseRate = reusedCount / tlsSpans.length;
161
+
162
+ if (1 - reuseRate >= 0.8) {
163
+ return {
164
+ triggered: true,
165
+ message: `⚠ TLS connection reuse: ${formatPercent(reuseRate * 100)} reused — TLS handshakes are happening on most requests`,
166
+ fix: TLS_REUSE_FIX,
167
+ };
168
+ }
169
+
170
+ return {
171
+ triggered: false,
172
+ message: `✓ TLS connection reuse: ${formatPercent(reuseRate * 100)} (good)`,
173
+ };
174
+ }
175
+
176
+ function getSlowTransformInsight(spans: Span[]): InsightResult {
177
+ const durations = spans
178
+ .filter((span) => span.name === "transformResponse")
179
+ .map((span) => span.duration_ms)
180
+ .filter((value) => Number.isFinite(value));
181
+
182
+ if (durations.length === 0) {
183
+ return {
184
+ triggered: false,
185
+ message: "✓ Transform overhead: no transformResponse spans sampled yet",
186
+ };
187
+ }
188
+
189
+ const p95 = computePercentile(
190
+ [...durations].sort((a, b) => a - b),
191
+ 95,
192
+ );
193
+ if (p95 > SLOW_TRANSFORM_MS) {
194
+ return {
195
+ triggered: true,
196
+ message: `⚠ Transform overhead: p95 ${formatMs(p95)} — trim array size or transformation complexity`,
197
+ fix: TRANSFORM_FIX,
198
+ };
199
+ }
200
+
201
+ return {
202
+ triggered: false,
203
+ message: `✓ Transform overhead: p95 ${formatMs(p95)} (good)`,
204
+ };
205
+ }
206
+
207
+ function getLargeResponseInsight(spans: Span[]): InsightResult {
208
+ const sizes = spans
209
+ .map((span) => getNumberAttribute(span, "response_size"))
210
+ .filter((value): value is number => value !== undefined);
211
+
212
+ if (sizes.length === 0) {
213
+ return {
214
+ triggered: false,
215
+ message: "✓ Response size: no response payloads sampled yet",
216
+ };
217
+ }
218
+
219
+ const maxSize = Math.max(...sizes);
220
+ if (maxSize > LARGE_RESPONSE_BYTES) {
221
+ return {
222
+ triggered: true,
223
+ message: `⚠ Response size: ${formatBytes(maxSize)} — consider pagination or a lower limit`,
224
+ fix: LARGE_RESPONSE_FIX,
225
+ };
226
+ }
227
+
228
+ return {
229
+ triggered: false,
230
+ message: `✓ Response size: ${formatBytes(maxSize)} max (good)`,
231
+ };
232
+ }
233
+
234
+ function getDnsRepeatedCandidate(spans: Span[]): DnsHostInsight | null {
235
+ const grouped = new Map<
236
+ string,
237
+ { dnsDurations: number[]; reuseCount: number; totalCount: number }
238
+ >();
239
+
240
+ for (const span of spans.filter(isTlsSpan)) {
241
+ const hostname = parseHostname(getStringAttribute(span, "url"));
242
+ const dnsMs = getNumberAttribute(span, "dns_ms");
243
+ if (!hostname || dnsMs === undefined) {
244
+ continue;
245
+ }
246
+
247
+ const entry = grouped.get(hostname) ?? {
248
+ dnsDurations: [],
249
+ reuseCount: 0,
250
+ totalCount: 0,
251
+ };
252
+
253
+ entry.dnsDurations.push(dnsMs);
254
+ entry.totalCount += 1;
255
+ if (getBooleanAttribute(span, "connection_reused") === true) {
256
+ entry.reuseCount += 1;
257
+ }
258
+
259
+ grouped.set(hostname, entry);
260
+ }
261
+
262
+ let candidate: DnsHostInsight | null = null;
263
+ for (const [hostname, entry] of grouped) {
264
+ if (entry.totalCount < 2) {
265
+ continue;
266
+ }
267
+
268
+ const avgDnsMs =
269
+ entry.dnsDurations.reduce((sum, value) => sum + value, 0) /
270
+ entry.dnsDurations.length;
271
+ const reuseRate = entry.reuseCount / entry.totalCount;
272
+
273
+ if (avgDnsMs > DNS_WARN_MS && reuseRate < 0.2) {
274
+ if (!candidate || avgDnsMs > candidate.avgDnsMs) {
275
+ candidate = {
276
+ hostname,
277
+ avgDnsMs,
278
+ };
279
+ }
280
+ }
281
+ }
282
+
283
+ return candidate;
284
+ }
285
+
286
+ function getDnsRepeatedInsight(spans: Span[]): InsightResult {
287
+ const candidate = getDnsRepeatedCandidate(spans);
288
+ if (!candidate) {
289
+ return {
290
+ triggered: false,
291
+ message: "✓ DNS resolution: no repeated DNS bottleneck detected",
292
+ };
293
+ }
294
+
295
+ return {
296
+ triggered: true,
297
+ message: `⚠ DNS resolution: ${formatMs(candidate.avgDnsMs)} avg for ${candidate.hostname} — consider DNS caching`,
298
+ fix: DNS_FIX,
299
+ };
300
+ }
301
+
302
+ function getProxyOverheadInsight(spans: Span[]): InsightResult {
303
+ const requestSpans = spans.filter(isRequestSpan);
304
+ const proxiedDurations = requestSpans
305
+ .filter(hasProxy)
306
+ .map((span) => span.duration_ms);
307
+ const directDurations = requestSpans
308
+ .filter((span) => !hasProxy(span))
309
+ .map((span) => span.duration_ms);
310
+
311
+ if (proxiedDurations.length === 0 || directDurations.length === 0) {
312
+ return {
313
+ triggered: false,
314
+ message: "✓ Proxy overhead: insufficient proxy/direct samples",
315
+ };
316
+ }
317
+
318
+ const proxyAvg =
319
+ proxiedDurations.reduce((sum, value) => sum + value, 0) /
320
+ proxiedDurations.length;
321
+ const directAvg =
322
+ directDurations.reduce((sum, value) => sum + value, 0) /
323
+ directDurations.length;
324
+
325
+ if (directAvg > 0 && proxyAvg >= directAvg * 2) {
326
+ return {
327
+ triggered: true,
328
+ message: `⚠ Proxy overhead: ${formatMs(proxyAvg)} avg with proxy vs ${formatMs(directAvg)} direct`,
329
+ fix: PROXY_FIX,
330
+ };
331
+ }
332
+
333
+ return {
334
+ triggered: false,
335
+ message: `✓ Proxy overhead: ${formatMs(proxyAvg)} avg with proxy vs ${formatMs(directAvg)} direct (good)`,
336
+ };
337
+ }
338
+
339
+ function getBrowserIdleInsight(spans: Span[]): InsightResult {
340
+ const waits = spans
341
+ .filter(isBrowserSpan)
342
+ .map((span) => {
343
+ const waitMs = getNumberAttribute(span, "wait_ms");
344
+ if (waitMs !== undefined) {
345
+ return waitMs;
346
+ }
347
+
348
+ return span.name.toLowerCase().includes("wait")
349
+ ? span.duration_ms
350
+ : undefined;
351
+ })
352
+ .filter((value): value is number => value !== undefined);
353
+
354
+ if (waits.length === 0) {
355
+ return {
356
+ triggered: false,
357
+ message: "✓ Browser waits: no idle wait spans sampled yet",
358
+ };
359
+ }
360
+
361
+ const maxWait = Math.max(...waits);
362
+ if (maxWait > BROWSER_IDLE_MS) {
363
+ return {
364
+ triggered: true,
365
+ message: `⚠ Browser idle wait: ${formatMs(maxWait)} — optimize waitFor conditions`,
366
+ fix: BROWSER_FIX,
367
+ };
368
+ }
369
+
370
+ return {
371
+ triggered: false,
372
+ message: `✓ Browser waits: ${formatMs(maxWait)} max (good)`,
373
+ };
374
+ }
375
+
376
+ function getSessionExpiryInsight(spans: Span[]): InsightResult {
377
+ const refreshCount = spans.filter(
378
+ (span) => span.name === "auth.refresh",
379
+ ).length;
380
+ const requestCount = spans.filter(isRequestSpan).length;
381
+
382
+ if (requestCount === 0) {
383
+ return {
384
+ triggered: false,
385
+ message: "✓ Session refresh frequency: no request spans sampled yet",
386
+ };
387
+ }
388
+
389
+ const refreshRate = refreshCount / requestCount;
390
+ if (refreshRate > REFRESH_WARN_RATE) {
391
+ return {
392
+ triggered: true,
393
+ message: `⚠ Session refresh frequency: ${formatPercent(refreshRate * 100)} of requests — adjust session TTL`,
394
+ fix: SESSION_FIX,
395
+ };
396
+ }
397
+
398
+ return {
399
+ triggered: false,
400
+ message: `✓ Session refresh frequency: ${formatPercent(refreshRate * 100)} of requests (good)`,
401
+ };
402
+ }
403
+
404
+ export function generateInsights(spans: Span[]): Insight[] {
405
+ if (spans.length === 0) {
406
+ return [];
407
+ }
408
+
409
+ const tlsReuse = getTlsReuseInsight(spans);
410
+ const slowTransform = getSlowTransformInsight(spans);
411
+ const largeResponse = getLargeResponseInsight(spans);
412
+ const dnsRepeated = getDnsRepeatedInsight(spans);
413
+ const proxyOverhead = getProxyOverheadInsight(spans);
414
+ const browserIdle = getBrowserIdleInsight(spans);
415
+ const sessionExpiry = getSessionExpiryInsight(spans);
416
+
417
+ const rules = [
418
+ makeInsight(
419
+ "tls_reuse_failure",
420
+ tlsReuse.triggered ? "warning" : "info",
421
+ tlsReuse,
422
+ ),
423
+ makeInsight(
424
+ "slow_transform",
425
+ slowTransform.triggered ? "warning" : "info",
426
+ slowTransform,
427
+ ),
428
+ makeInsight(
429
+ "large_response",
430
+ largeResponse.triggered ? "warning" : "info",
431
+ largeResponse,
432
+ ),
433
+ makeInsight(
434
+ "dns_repeated",
435
+ dnsRepeated.triggered ? "warning" : "info",
436
+ dnsRepeated,
437
+ ),
438
+ makeInsight(
439
+ "proxy_overhead",
440
+ proxyOverhead.triggered ? "warning" : "info",
441
+ proxyOverhead,
442
+ ),
443
+ makeInsight(
444
+ "browser_idle",
445
+ browserIdle.triggered ? "warning" : "info",
446
+ browserIdle,
447
+ ),
448
+ makeInsight(
449
+ "session_expiry_frequent",
450
+ sessionExpiry.triggered ? "warning" : "info",
451
+ sessionExpiry,
452
+ ),
453
+ ];
454
+
455
+ return rules;
456
+ }