@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,290 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+
3
+ import { wrapWithInstrumentation } from "../runtime/instrumentation";
4
+ import { createTraceContext } from "../runtime/trace";
5
+ import type {
6
+ AuthContext,
7
+ BrowserClient,
8
+ ConnectorContext,
9
+ HttpClient,
10
+ SessionStore,
11
+ StateContext,
12
+ TlsClient,
13
+ } from "../types";
14
+
15
+ function createMockContext(): ConnectorContext {
16
+ const mockPage = {
17
+ pageId: "page-1",
18
+ goto: mock(async (url: string) => ({ url })),
19
+ fill: mock(async () => undefined),
20
+ click: mock(async () => undefined),
21
+ type: mock(async () => undefined),
22
+ waitForSelector: mock(async () => undefined),
23
+ };
24
+
25
+ return {
26
+ http: {
27
+ request: mock(async () => ({
28
+ status: 200,
29
+ ok: true,
30
+ headers: { "content-type": "application/json" },
31
+ data: { ok: true },
32
+ meta: { requestId: "req-0", duration: 10 },
33
+ json: async <T>() => ({ ok: true }) as T,
34
+ text: async () => JSON.stringify({ ok: true }),
35
+ })),
36
+ get: mock(async () => ({
37
+ status: 200,
38
+ ok: true,
39
+ headers: { "content-type": "application/json" },
40
+ data: { ok: true },
41
+ meta: { requestId: "req-1", duration: 15 },
42
+ json: async <T>() => ({ ok: true }) as T,
43
+ text: async () => JSON.stringify({ ok: true }),
44
+ })),
45
+ post: mock(async () => ({
46
+ status: 200,
47
+ ok: true,
48
+ headers: { "content-type": "application/json" },
49
+ data: { ok: true },
50
+ meta: { requestId: "req-2", duration: 20 },
51
+ json: async <T>() => ({ ok: true }) as T,
52
+ text: async () => JSON.stringify({ ok: true }),
53
+ })),
54
+ put: mock(async () => ({
55
+ status: 200,
56
+ ok: true,
57
+ headers: { "content-type": "application/json" },
58
+ data: { ok: true },
59
+ meta: { requestId: "req-3", duration: 25 },
60
+ json: async <T>() => ({ ok: true }) as T,
61
+ text: async () => JSON.stringify({ ok: true }),
62
+ })),
63
+ delete: mock(async () => ({
64
+ status: 200,
65
+ ok: true,
66
+ headers: { "content-type": "application/json" },
67
+ data: { ok: true },
68
+ meta: { requestId: "req-4", duration: 30 },
69
+ json: async <T>() => ({ ok: true }) as T,
70
+ text: async () => JSON.stringify({ ok: true }),
71
+ })),
72
+ } as HttpClient,
73
+ tls: {
74
+ fetch: mock(async () => ({
75
+ status: 201,
76
+ ok: true,
77
+ headers: {},
78
+ rawHeaders: [] as [string, string][],
79
+ body: "created",
80
+ cookies: {
81
+ get: () => undefined,
82
+ getAll: () => ({}),
83
+ toString: () => "",
84
+ },
85
+ json: async <T>() => ({}) as T,
86
+ })),
87
+ createSession: mock(() => ({
88
+ fetch: async () => ({
89
+ status: 200,
90
+ ok: true,
91
+ headers: {},
92
+ rawHeaders: [] as [string, string][],
93
+ body: "ok",
94
+ cookies: {
95
+ get: () => undefined,
96
+ getAll: () => ({}),
97
+ toString: () => "",
98
+ },
99
+ json: async <T>() => ({}) as T,
100
+ }),
101
+ close: () => {},
102
+ })),
103
+ } as TlsClient,
104
+ browser: {
105
+ engine: "playwright-stealth",
106
+ newPage: mock(async () => mockPage),
107
+ goto: mock(async (url: string) => ({ url })),
108
+ } as unknown as BrowserClient,
109
+ session: {} as SessionStore,
110
+ state: {} as StateContext,
111
+ trace: createTraceContext(),
112
+ auth: {} as AuthContext,
113
+ };
114
+ }
115
+
116
+ describe("createTraceContext", () => {
117
+ it("collects nested custom spans in start order", async () => {
118
+ const trace = createTraceContext();
119
+
120
+ const result = await trace.span("operation", async () => {
121
+ return trace.span("parse", async () => "done");
122
+ });
123
+
124
+ const spans = trace.getSpans();
125
+
126
+ expect(result).toBe("done");
127
+ expect(spans).toHaveLength(2);
128
+ expect(spans[0]?.name).toBe("operation");
129
+ expect(spans[1]?.name).toBe("parse");
130
+ expect(spans[1]?.parentId).toBe(spans[0]?.id);
131
+ expect(spans[0]?.status).toBe("ok");
132
+ expect(spans[1]?.attributes.duration_ms).toBeNumber();
133
+ });
134
+
135
+ it("enforces maxSpans by trimming the oldest completed spans", async () => {
136
+ const trace = createTraceContext({ maxSpans: 2 });
137
+
138
+ await trace.span("first", async () => undefined);
139
+ await trace.span("second", async () => undefined);
140
+ await trace.span("third", async () => undefined);
141
+
142
+ expect(trace.getSpans().map((span) => span.name)).toEqual([
143
+ "second",
144
+ "third",
145
+ ]);
146
+ });
147
+ });
148
+
149
+ describe("wrapWithInstrumentation", () => {
150
+ it("creates spans for http, tls, and browser method calls", async () => {
151
+ const onSpan = mock(() => {});
152
+ const ctx = createMockContext();
153
+ const instrumented = wrapWithInstrumentation(ctx, { onSpan });
154
+
155
+ await instrumented.trace.span("connector.search", async () => {
156
+ await instrumented.http.get("https://api.example.com/items");
157
+ await instrumented.tls.fetch("https://secure.example.com/login", {
158
+ method: "POST",
159
+ });
160
+ await (
161
+ instrumented.browser as BrowserClient & {
162
+ goto(url: string): Promise<{ url: string }>;
163
+ }
164
+ ).goto("https://app.example.com/dashboard");
165
+ });
166
+
167
+ const spans = instrumented.trace.getSpans();
168
+
169
+ expect(spans.map((span) => span.name)).toEqual([
170
+ "connector.search",
171
+ "http.get",
172
+ "tls.fetch",
173
+ "browser.goto",
174
+ ]);
175
+
176
+ const httpSpan = spans[1];
177
+ expect(httpSpan).toMatchObject({
178
+ status: "ok",
179
+ parentId: spans[0]?.id,
180
+ attributes: {
181
+ url: "https://api.example.com/items",
182
+ method: "GET",
183
+ status: 200,
184
+ duration_ms: 15,
185
+ },
186
+ });
187
+
188
+ const tlsSpan = spans[2];
189
+ expect(tlsSpan).toMatchObject({
190
+ status: "ok",
191
+ parentId: spans[0]?.id,
192
+ attributes: {
193
+ url: "https://secure.example.com/login",
194
+ method: "POST",
195
+ status: 201,
196
+ },
197
+ });
198
+
199
+ const browserSpan = spans[3];
200
+ expect(browserSpan).toMatchObject({
201
+ status: "ok",
202
+ parentId: spans[0]?.id,
203
+ attributes: {
204
+ url: "https://app.example.com/dashboard",
205
+ },
206
+ });
207
+
208
+ expect(onSpan).toHaveBeenCalledTimes(4);
209
+ });
210
+
211
+ it("wraps browser newPage and page methods with spans", async () => {
212
+ const ctx = createMockContext();
213
+ const instrumented = wrapWithInstrumentation(ctx);
214
+
215
+ const page = (await instrumented.browser.newPage()) as {
216
+ goto(url: string): Promise<{ url: string }>;
217
+ fill(selector: string, value: string): Promise<void>;
218
+ click(selector: string): Promise<void>;
219
+ type(selector: string, text: string): Promise<void>;
220
+ waitForSelector(selector: string): Promise<void>;
221
+ };
222
+
223
+ await page.goto("https://app.example.com/login");
224
+ await page.fill("#username", "demo");
225
+ await page.click("button[type=submit]");
226
+ await page.type("#otp", "123456");
227
+ await page.waitForSelector(".dashboard");
228
+
229
+ const spans = instrumented.trace.getSpans();
230
+ expect(spans.map((span) => span.name)).toEqual([
231
+ "browser.newPage",
232
+ "browser.page.goto",
233
+ "browser.page.fill",
234
+ "browser.page.click",
235
+ "browser.page.type",
236
+ "browser.page.waitForSelector",
237
+ ]);
238
+ expect(spans[0]?.attributes).toMatchObject({
239
+ allocate_ms: expect.any(Number),
240
+ page_id: "page-1",
241
+ engine: "playwright-stealth",
242
+ });
243
+ expect(spans[1]?.attributes).toMatchObject({
244
+ url: "https://app.example.com/login",
245
+ navigation_ms: expect.any(Number),
246
+ });
247
+ expect(spans[2]?.attributes).toMatchObject({
248
+ selector: "#username",
249
+ action_ms: expect.any(Number),
250
+ });
251
+ expect(spans[3]?.attributes).toMatchObject({
252
+ selector: "button[type=submit]",
253
+ action_ms: expect.any(Number),
254
+ });
255
+ expect(spans[4]?.attributes).toMatchObject({
256
+ selector: "#otp",
257
+ action_ms: expect.any(Number),
258
+ });
259
+ expect(spans[5]?.attributes).toMatchObject({
260
+ selector: ".dashboard",
261
+ wait_ms: expect.any(Number),
262
+ });
263
+ });
264
+
265
+ it("records error spans when instrumented methods throw", async () => {
266
+ const ctx = createMockContext();
267
+ ctx.tls.fetch = mock(async () => {
268
+ const error = new Error("boom") as Error & { status: number };
269
+ error.status = 503;
270
+ throw error;
271
+ });
272
+
273
+ const instrumented = wrapWithInstrumentation(ctx);
274
+
275
+ await expect(
276
+ instrumented.tls.fetch("https://secure.example.com/fail"),
277
+ ).rejects.toThrow("boom");
278
+
279
+ expect(instrumented.trace.getSpans()[0]).toMatchObject({
280
+ name: "tls.fetch",
281
+ status: "error",
282
+ error: "boom",
283
+ attributes: {
284
+ url: "https://secure.example.com/fail",
285
+ method: "GET",
286
+ status: 503,
287
+ },
288
+ });
289
+ });
290
+ });
@@ -0,0 +1,141 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { exportSpansOTLP, spansToOTLP } from "../runtime/otlp";
4
+ import type { TraceSpan } from "../types";
5
+
6
+ function makeSpan(overrides: Partial<TraceSpan> = {}): TraceSpan {
7
+ return {
8
+ id: overrides.id ?? "span-1",
9
+ name: overrides.name ?? "connector.search",
10
+ startedAt: overrides.startedAt ?? 1_000,
11
+ endedAt: overrides.endedAt ?? 1_025,
12
+ duration_ms: overrides.duration_ms ?? 25,
13
+ status: overrides.status ?? "ok",
14
+ attributes: overrides.attributes ?? {
15
+ method: "GET",
16
+ status: 200,
17
+ success: true,
18
+ },
19
+ ...(overrides.parentId ? { parentId: overrides.parentId } : {}),
20
+ ...(overrides.error ? { error: overrides.error } : {}),
21
+ };
22
+ }
23
+
24
+ describe("otlp export", () => {
25
+ let originalFetch: typeof fetch;
26
+ let originalWarn: typeof console.warn;
27
+
28
+ beforeEach(() => {
29
+ originalFetch = global.fetch;
30
+ originalWarn = console.warn;
31
+ });
32
+
33
+ afterEach(() => {
34
+ global.fetch = originalFetch;
35
+ console.warn = originalWarn;
36
+ });
37
+
38
+ it("spansToOTLP() returns OTLP resource and scope span payload", () => {
39
+ const payload = spansToOTLP(
40
+ [makeSpan({ id: "abc123", parentId: "def456", name: "tls.fetch" })],
41
+ { "service.name": "connector-sdk-test" },
42
+ );
43
+
44
+ expect(payload).toEqual({
45
+ resourceSpans: [
46
+ {
47
+ resource: {
48
+ attributes: [
49
+ {
50
+ key: "service.name",
51
+ value: { stringValue: "connector-sdk-test" },
52
+ },
53
+ ],
54
+ },
55
+ scopeSpans: [
56
+ {
57
+ scope: {
58
+ name: "apifuse-connector-sdk",
59
+ version: "0.1.0",
60
+ },
61
+ spans: [
62
+ {
63
+ attributes: [
64
+ {
65
+ key: "method",
66
+ value: { stringValue: "GET" },
67
+ },
68
+ {
69
+ key: "status",
70
+ value: { doubleValue: 200 },
71
+ },
72
+ {
73
+ key: "success",
74
+ value: { boolValue: true },
75
+ },
76
+ ],
77
+ endTimeUnixNano: "1025000000",
78
+ kind: 2,
79
+ name: "tls.fetch",
80
+ parentSpanId: "0000000000def456",
81
+ spanId: "0000000000abc123",
82
+ startTimeUnixNano: "1000000000",
83
+ status: { code: 1 },
84
+ traceId: "00000000000000000000000000000001",
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ });
92
+ });
93
+
94
+ it("exportSpansOTLP() posts OTLP JSON with merged headers", async () => {
95
+ let requestUrl: string | undefined;
96
+ let requestInit: RequestInit | undefined;
97
+
98
+ global.fetch = mock(
99
+ async (url: string | URL | Request, init?: RequestInit) => {
100
+ requestUrl = typeof url === "string" ? url : url.toString();
101
+ requestInit = init;
102
+ return new Response(null, { status: 200 });
103
+ },
104
+ ) as unknown as typeof fetch;
105
+
106
+ await exportSpansOTLP([makeSpan()], {
107
+ endpoint: "http://localhost:4318/v1/traces",
108
+ headers: { Authorization: "Bearer test" },
109
+ timeout: 100,
110
+ });
111
+
112
+ expect(requestUrl).toBe("http://localhost:4318/v1/traces");
113
+ expect(requestInit?.method).toBe("POST");
114
+ expect(requestInit?.headers).toEqual({
115
+ "Content-Type": "application/json",
116
+ Authorization: "Bearer test",
117
+ });
118
+ expect(JSON.parse(String(requestInit?.body))).toEqual(
119
+ spansToOTLP([makeSpan()]),
120
+ );
121
+ });
122
+
123
+ it("exportSpansOTLP() swallows fetch errors and warns", async () => {
124
+ const warn = mock(() => {});
125
+ console.warn = warn;
126
+ global.fetch = mock(async () => {
127
+ throw new Error("network down");
128
+ }) as unknown as typeof fetch;
129
+
130
+ await expect(
131
+ exportSpansOTLP([makeSpan()], {
132
+ endpoint: "http://localhost:4318/v1/traces",
133
+ }),
134
+ ).resolves.toBeUndefined();
135
+
136
+ expect(warn).toHaveBeenCalledWith(
137
+ "[apifuse] OTLP export failed:",
138
+ "network down",
139
+ );
140
+ });
141
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ computePercentile,
5
+ computeStats,
6
+ groupSpansByName,
7
+ } from "../runtime/perf";
8
+ import type { Span } from "../runtime/trace";
9
+
10
+ function makeSpan(name: string, duration_ms: number): Span {
11
+ return {
12
+ id: crypto.randomUUID(),
13
+ name,
14
+ startedAt: 1000,
15
+ endedAt: 1000 + duration_ms,
16
+ duration_ms,
17
+ status: "ok",
18
+ attributes: {},
19
+ };
20
+ }
21
+
22
+ describe("computePercentile", () => {
23
+ it("computes p50 correctly", () => {
24
+ expect(computePercentile([10, 20, 30, 40, 50], 50)).toBe(30);
25
+ });
26
+
27
+ it("computes p95 correctly", () => {
28
+ expect(computePercentile([10, 20, 30, 40, 50], 95)).toBe(48);
29
+ });
30
+
31
+ it("handles single element", () => {
32
+ expect(computePercentile([42], 99)).toBe(42);
33
+ });
34
+ });
35
+
36
+ describe("computeStats", () => {
37
+ it("returns p50/p95/p99/avg for sample data", () => {
38
+ expect(computeStats([10, 20, 30, 40, 50])).toEqual({
39
+ p50: 30,
40
+ p95: 48,
41
+ p99: 49.6,
42
+ avg: 30,
43
+ min: 10,
44
+ max: 50,
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("groupSpansByName", () => {
50
+ it("groups spans across multiple runs", () => {
51
+ const grouped = groupSpansByName([
52
+ [makeSpan("tls.fetch", 100), makeSpan("transformResponse", 5)],
53
+ [makeSpan("tls.fetch", 120), makeSpan("normalizeRequest", 1)],
54
+ ]);
55
+
56
+ expect(grouped.get("tls.fetch")).toEqual([100, 120]);
57
+ expect(grouped.get("transformResponse")).toEqual([5]);
58
+ expect(grouped.get("normalizeRequest")).toEqual([1]);
59
+ });
60
+ });