@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,238 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+
3
+ import { TransportError } from "../errors";
4
+ import { createHttpClient } from "../runtime/http";
5
+ import { getStealthProfile } from "../stealth/profiles";
6
+
7
+ describe("createHttpClient", () => {
8
+ let originalFetch: typeof fetch;
9
+
10
+ beforeEach(() => {
11
+ originalFetch = global.fetch;
12
+ });
13
+
14
+ afterEach(() => {
15
+ global.fetch = originalFetch;
16
+ });
17
+
18
+ it("get() returns HttpResponse with plain response fields", async () => {
19
+ global.fetch = (async () =>
20
+ new Response(JSON.stringify({ args: { q: "1" } }), {
21
+ status: 200,
22
+ headers: { "Content-Type": "application/json" },
23
+ })) as unknown as typeof fetch;
24
+
25
+ const http = createHttpClient();
26
+ const result = await http.get("https://httpbin.org/get", {
27
+ params: { q: "1" },
28
+ });
29
+
30
+ expect(result.status).toBe(200);
31
+ expect(result.ok).toBeTrue();
32
+ expect(result.headers["content-type"]).toBe("application/json");
33
+ expect(result.data).toEqual({ args: { q: "1" } });
34
+ expect(await result.json<{ args: { q: string } }>()).toEqual({
35
+ args: { q: "1" },
36
+ });
37
+ expect(await result.text()).toBe(JSON.stringify({ args: { q: "1" } }));
38
+ });
39
+
40
+ it("post() sends body and returns response", async () => {
41
+ let capturedBody: string | null = null;
42
+
43
+ global.fetch = (async (_url, init) => {
44
+ capturedBody = init?.body as string;
45
+ return new Response(JSON.stringify({ json: { key: "value" } }), {
46
+ status: 200,
47
+ headers: { "Content-Type": "application/json" },
48
+ });
49
+ }) as typeof fetch;
50
+
51
+ const http = createHttpClient();
52
+ const result = await http.post("https://httpbin.org/post", {
53
+ key: "value",
54
+ });
55
+
56
+ if (capturedBody === null) {
57
+ throw new Error("Expected request body to be captured");
58
+ }
59
+
60
+ expect(capturedBody === JSON.stringify({ key: "value" })).toBe(true);
61
+ expect(result.data).toEqual({ json: { key: "value" } });
62
+ expect(await result.text()).toBe(
63
+ JSON.stringify({ json: { key: "value" } }),
64
+ );
65
+ });
66
+
67
+ it("throws TransportError on 4xx", async () => {
68
+ global.fetch = (async () =>
69
+ new Response("Not Found", { status: 404 })) as unknown as typeof fetch;
70
+
71
+ const http = createHttpClient();
72
+
73
+ await expect(http.get("https://example.com/not-found")).rejects.toThrow(
74
+ TransportError,
75
+ );
76
+ });
77
+
78
+ it("TransportError has status code", async () => {
79
+ global.fetch = (async () =>
80
+ new Response("Server Error", { status: 500 })) as unknown as typeof fetch;
81
+
82
+ const http = createHttpClient();
83
+
84
+ try {
85
+ await http.get("https://example.com/error");
86
+ } catch (error) {
87
+ expect(error instanceof TransportError).toBe(true);
88
+ expect((error as TransportError).status).toBe(500);
89
+ }
90
+ });
91
+
92
+ it("throws TransportError on network error", async () => {
93
+ global.fetch = (async () => {
94
+ throw new Error("Network error");
95
+ }) as unknown as typeof fetch;
96
+
97
+ const http = createHttpClient();
98
+
99
+ await expect(http.get("https://example.com")).rejects.toThrow(
100
+ TransportError,
101
+ );
102
+ });
103
+
104
+ it("put() sends body with PUT method", async () => {
105
+ let capturedMethod: string | undefined;
106
+
107
+ global.fetch = (async (_url, init) => {
108
+ capturedMethod = init?.method;
109
+ return new Response("{}", {
110
+ status: 200,
111
+ headers: { "Content-Type": "application/json" },
112
+ });
113
+ }) as typeof fetch;
114
+
115
+ const http = createHttpClient();
116
+
117
+ await http.put("https://example.com/resource", { data: "updated" });
118
+ expect(capturedMethod).toBe("PUT");
119
+ });
120
+
121
+ it("delete() sends DELETE method", async () => {
122
+ let capturedMethod: string | undefined;
123
+
124
+ global.fetch = (async (_url, init) => {
125
+ capturedMethod = init?.method;
126
+ return new Response("{}", {
127
+ status: 200,
128
+ headers: { "Content-Type": "application/json" },
129
+ });
130
+ }) as typeof fetch;
131
+
132
+ const http = createHttpClient();
133
+
134
+ await http.delete("https://example.com/resource");
135
+ expect(capturedMethod).toBe("DELETE");
136
+ });
137
+
138
+ it("request() uses GET by default and supports custom methods", async () => {
139
+ const capturedMethods: string[] = [];
140
+
141
+ global.fetch = (async (_url, init) => {
142
+ capturedMethods.push(String(init?.method));
143
+ return new Response("{}", {
144
+ status: 200,
145
+ headers: { "Content-Type": "application/json" },
146
+ });
147
+ }) as typeof fetch;
148
+
149
+ const http = createHttpClient();
150
+
151
+ await http.request("https://example.com/default");
152
+ await http.request("https://example.com/custom", { method: "PATCH" });
153
+
154
+ expect(capturedMethods).toEqual(["GET", "PATCH"]);
155
+ });
156
+
157
+ it("injects Layer 2 headers from a Chromium stealth profile", async () => {
158
+ let capturedHeaders: HeadersInit | undefined;
159
+
160
+ global.fetch = (async (_url, init) => {
161
+ capturedHeaders = init?.headers;
162
+ return new Response("{}", {
163
+ status: 200,
164
+ headers: { "Content-Type": "application/json" },
165
+ });
166
+ }) as typeof fetch;
167
+
168
+ const http = createHttpClient("https://example.com", {
169
+ stealthProfile: getStealthProfile("chrome-131"),
170
+ });
171
+
172
+ await http.get("/resource");
173
+
174
+ expect(capturedHeaders).toMatchObject({
175
+ "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8",
176
+ "Sec-Ch-Ua":
177
+ '"Chromium";v="131", "Google Chrome";v="131", "Not)A;Brand";v="99"',
178
+ "Sec-Ch-Ua-Mobile": "?0",
179
+ "Sec-Ch-Ua-Platform": '"macOS"',
180
+ "User-Agent": getStealthProfile("chrome-131").userAgent,
181
+ });
182
+ });
183
+
184
+ it("allows caller headers to override auto-injected Layer 2 headers", async () => {
185
+ let capturedHeaders: HeadersInit | undefined;
186
+
187
+ global.fetch = (async (_url, init) => {
188
+ capturedHeaders = init?.headers;
189
+ return new Response("{}", {
190
+ status: 200,
191
+ headers: { "Content-Type": "application/json" },
192
+ });
193
+ }) as typeof fetch;
194
+
195
+ const http = createHttpClient("https://example.com", {
196
+ stealthProfile: getStealthProfile("edge-131"),
197
+ });
198
+
199
+ await http.get("/resource", {
200
+ headers: {
201
+ "Accept-Language": "en-US",
202
+ "Sec-Ch-Ua": "custom-brand",
203
+ },
204
+ });
205
+
206
+ expect(capturedHeaders).toMatchObject({
207
+ "Accept-Language": "en-US",
208
+ "Sec-Ch-Ua": "custom-brand",
209
+ "Sec-Ch-Ua-Mobile": "?0",
210
+ "Sec-Ch-Ua-Platform": '"Windows"',
211
+ });
212
+ });
213
+
214
+ it("omits Sec-Ch-Ua headers for Firefox while keeping Accept-Language", async () => {
215
+ let capturedHeaders: Record<string, string> | undefined;
216
+
217
+ global.fetch = (async (_url, init) => {
218
+ capturedHeaders = init?.headers as Record<string, string>;
219
+ return new Response("{}", {
220
+ status: 200,
221
+ headers: { "Content-Type": "application/json" },
222
+ });
223
+ }) as typeof fetch;
224
+
225
+ const http = createHttpClient("https://example.com", {
226
+ stealthProfile: getStealthProfile("firefox-133"),
227
+ });
228
+
229
+ await http.get("/resource");
230
+
231
+ expect(capturedHeaders?.["Accept-Language"]).toBe(
232
+ "ko-KR,ko;q=0.9,en-US;q=0.8",
233
+ );
234
+ expect(capturedHeaders?.["Sec-Ch-Ua"]).toBeUndefined();
235
+ expect(capturedHeaders?.["Sec-Ch-Ua-Platform"]).toBeUndefined();
236
+ expect(capturedHeaders?.["Sec-Ch-Ua-Mobile"]).toBeUndefined();
237
+ });
238
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ generateInsights,
5
+ type Insight,
6
+ type InsightSeverity,
7
+ } from "../runtime/insights";
8
+ import type { Span } from "../runtime/trace";
9
+
10
+ function makeSpan(
11
+ name: string,
12
+ overrides: Partial<Span> = {},
13
+ attributes: Span["attributes"] = {},
14
+ ): Span {
15
+ const duration_ms = overrides.duration_ms ?? 10;
16
+ return {
17
+ id: overrides.id ?? crypto.randomUUID(),
18
+ name,
19
+ startedAt: overrides.startedAt ?? 1_000,
20
+ endedAt: overrides.endedAt ?? 1_000 + duration_ms,
21
+ duration_ms,
22
+ status: overrides.status ?? "ok",
23
+ attributes: overrides.attributes ?? attributes,
24
+ ...(overrides.parentId ? { parentId: overrides.parentId } : {}),
25
+ ...(overrides.error ? { error: overrides.error } : {}),
26
+ };
27
+ }
28
+
29
+ function findInsight(insights: Insight[], id: string): Insight {
30
+ const insight = insights.find((item) => item.id === id);
31
+ if (!insight) {
32
+ throw new Error(`Missing insight: ${id}`);
33
+ }
34
+
35
+ return insight;
36
+ }
37
+
38
+ function expectSeverity(
39
+ insights: Insight[],
40
+ id: string,
41
+ severity: InsightSeverity,
42
+ ): Insight {
43
+ const insight = findInsight(insights, id);
44
+ expect(insight.severity).toBe(severity);
45
+ return insight;
46
+ }
47
+
48
+ describe("generateInsights", () => {
49
+ it("returns empty array for empty spans", () => {
50
+ expect(generateInsights([])).toEqual([]);
51
+ });
52
+
53
+ it("detects TLS reuse failure when less than 20% are reused", () => {
54
+ const spans: Span[] = [
55
+ makeSpan("tls.fetch", {}, { connection_reused: false }),
56
+ makeSpan("tls.fetch", {}, { connection_reused: false }),
57
+ makeSpan("tls.fetch", {}, { connection_reused: false }),
58
+ makeSpan("tls.fetch", {}, { connection_reused: false }),
59
+ makeSpan("tls.fetch", {}, { connection_reused: true }),
60
+ ];
61
+
62
+ const insight = expectSeverity(
63
+ generateInsights(spans),
64
+ "tls_reuse_failure",
65
+ "warning",
66
+ );
67
+
68
+ expect(insight.message).toContain("TLS connection reuse");
69
+ expect(insight.fix).toContain("ctx.tls.createSession");
70
+ });
71
+
72
+ it("marks TLS reuse as OK when at least 80% are reused", () => {
73
+ const spans: Span[] = [
74
+ makeSpan("tls.fetch", {}, { connection_reused: true }),
75
+ makeSpan("tls.fetch", {}, { connection_reused: true }),
76
+ makeSpan("tls.fetch", {}, { connection_reused: true }),
77
+ makeSpan("tls.fetch", {}, { connection_reused: true }),
78
+ makeSpan("tls.fetch", {}, { connection_reused: false }),
79
+ ];
80
+
81
+ const insight = expectSeverity(
82
+ generateInsights(spans),
83
+ "tls_reuse_failure",
84
+ "info",
85
+ );
86
+
87
+ expect(insight.message.startsWith("✓")).toBe(true);
88
+ expect(insight.message).toContain("80%");
89
+ });
90
+
91
+ it("detects slow transform spans by p95", () => {
92
+ const spans: Span[] = [
93
+ makeSpan("transformResponse", { duration_ms: 5 }),
94
+ makeSpan("transformResponse", { duration_ms: 8 }),
95
+ makeSpan("transformResponse", { duration_ms: 20 }),
96
+ makeSpan("transformResponse", { duration_ms: 25 }),
97
+ ];
98
+
99
+ const insight = expectSeverity(
100
+ generateInsights(spans),
101
+ "slow_transform",
102
+ "warning",
103
+ );
104
+
105
+ expect(insight.message).toContain("p95");
106
+ expect(insight.fix).toContain("transformResponse");
107
+ });
108
+
109
+ it("detects large responses over 100KB", () => {
110
+ const spans: Span[] = [
111
+ makeSpan("tls.fetch", {}, { response_size: 120_000 }),
112
+ makeSpan("http.get", {}, { response_size: 5_000 }),
113
+ ];
114
+
115
+ const insight = expectSeverity(
116
+ generateInsights(spans),
117
+ "large_response",
118
+ "warning",
119
+ );
120
+
121
+ expect(insight.message).toContain("Response size");
122
+ expect(insight.fix).toContain("limit");
123
+ });
124
+
125
+ it("detects repeated DNS latency for the same host without reuse", () => {
126
+ const spans: Span[] = [
127
+ makeSpan(
128
+ "tls.fetch",
129
+ {},
130
+ {
131
+ url: "https://api.example.com/a",
132
+ dns_ms: 8,
133
+ connection_reused: false,
134
+ },
135
+ ),
136
+ makeSpan(
137
+ "tls.fetch",
138
+ {},
139
+ {
140
+ url: "https://api.example.com/b",
141
+ dns_ms: 12,
142
+ connection_reused: false,
143
+ },
144
+ ),
145
+ ];
146
+
147
+ const insight = expectSeverity(
148
+ generateInsights(spans),
149
+ "dns_repeated",
150
+ "warning",
151
+ );
152
+
153
+ expect(insight.message).toContain("api.example.com");
154
+ expect(insight.fix).toContain("DNS");
155
+ });
156
+
157
+ it("detects proxy overhead when proxied requests are twice as slow", () => {
158
+ const spans: Span[] = [
159
+ makeSpan("tls.fetch", { duration_ms: 100 }, { proxy: false }),
160
+ makeSpan("tls.fetch", { duration_ms: 120 }, { proxy: false }),
161
+ makeSpan("tls.fetch", { duration_ms: 250 }, { proxy: true }),
162
+ makeSpan("tls.fetch", { duration_ms: 260 }, { proxy: true }),
163
+ ];
164
+
165
+ const insight = expectSeverity(
166
+ generateInsights(spans),
167
+ "proxy_overhead",
168
+ "warning",
169
+ );
170
+
171
+ expect(insight.message).toContain("with proxy");
172
+ expect(insight.fix).toContain("proxy");
173
+ });
174
+
175
+ it("detects browser idle waits over five seconds", () => {
176
+ const spans: Span[] = [
177
+ makeSpan("page.waitForSelector", {}, { wait_ms: 6_200 }),
178
+ makeSpan("page.click", { duration_ms: 20 }),
179
+ ];
180
+
181
+ const insight = expectSeverity(
182
+ generateInsights(spans),
183
+ "browser_idle",
184
+ "warning",
185
+ );
186
+
187
+ expect(insight.message).toContain("6200ms");
188
+ expect(insight.fix).toContain("waitForSelector");
189
+ });
190
+
191
+ it("detects frequent session refreshes", () => {
192
+ const spans: Span[] = [
193
+ makeSpan("tls.fetch"),
194
+ makeSpan("tls.fetch"),
195
+ makeSpan("tls.fetch"),
196
+ makeSpan("tls.fetch"),
197
+ makeSpan("tls.fetch"),
198
+ makeSpan("auth.refresh"),
199
+ ];
200
+
201
+ const insight = expectSeverity(
202
+ generateInsights(spans),
203
+ "session_expiry_frequent",
204
+ "warning",
205
+ );
206
+
207
+ expect(insight.message).toContain("20%");
208
+ expect(insight.fix).toContain("ttl");
209
+ });
210
+ });