@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.
- package/README.md +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -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__/connectors-yaml.test.ts +135 -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__/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/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -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/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -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/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 +665 -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,373 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { SDKError, ValidationError } from "../errors";
|
|
6
|
+
|
|
7
|
+
type YamlObject = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
function stripInlineComment(line: string): string {
|
|
10
|
+
let inSingleQuote = false;
|
|
11
|
+
let inDoubleQuote = false;
|
|
12
|
+
|
|
13
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
14
|
+
const char = line[index];
|
|
15
|
+
|
|
16
|
+
if (char === "'" && !inDoubleQuote) {
|
|
17
|
+
inSingleQuote = !inSingleQuote;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (char === '"' && !inSingleQuote) {
|
|
22
|
+
inDoubleQuote = !inDoubleQuote;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (char === "#" && !inSingleQuote && !inDoubleQuote) {
|
|
27
|
+
return line.slice(0, index).trimEnd();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return line;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseQuotedString(value: string): string {
|
|
35
|
+
if (
|
|
36
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
37
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
38
|
+
) {
|
|
39
|
+
return value.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitFlowArrayItems(value: string): string[] {
|
|
46
|
+
const items: string[] = [];
|
|
47
|
+
let current = "";
|
|
48
|
+
let inSingleQuote = false;
|
|
49
|
+
let inDoubleQuote = false;
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
52
|
+
const char = value[index];
|
|
53
|
+
|
|
54
|
+
if (char === "'" && !inDoubleQuote) {
|
|
55
|
+
inSingleQuote = !inSingleQuote;
|
|
56
|
+
current += char;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (char === '"' && !inSingleQuote) {
|
|
61
|
+
inDoubleQuote = !inDoubleQuote;
|
|
62
|
+
current += char;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (char === "," && !inSingleQuote && !inDoubleQuote) {
|
|
67
|
+
items.push(current.trim());
|
|
68
|
+
current = "";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
current += char;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (current.trim()) {
|
|
76
|
+
items.push(current.trim());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseYamlScalar(value: string): unknown {
|
|
83
|
+
if (value === "{}") {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (value === "[]") {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
92
|
+
const inner = value.slice(1, -1).trim();
|
|
93
|
+
if (!inner) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return splitFlowArrayItems(inner).map((item) => parseYamlScalar(item));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (value === "true") {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (value === "false") {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (/^-?\d+$/.test(value)) {
|
|
109
|
+
return Number.parseInt(value, 10);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parseQuotedString(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseYamlObject(yamlContent: string): YamlObject {
|
|
116
|
+
const root: YamlObject = {};
|
|
117
|
+
const stack: Array<{ container: YamlObject; indent: number }> = [
|
|
118
|
+
{ container: root, indent: -1 },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
for (const rawLine of yamlContent.split(/\r?\n/u)) {
|
|
122
|
+
const withoutComment = stripInlineComment(rawLine);
|
|
123
|
+
if (!withoutComment.trim()) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const indent = withoutComment.match(/^\s*/u)?.[0].length ?? 0;
|
|
128
|
+
const line = withoutComment.trim();
|
|
129
|
+
const separatorIndex = line.indexOf(":");
|
|
130
|
+
|
|
131
|
+
if (separatorIndex === -1) {
|
|
132
|
+
throw new Error(`Invalid YAML line: ${rawLine}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
136
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
137
|
+
|
|
138
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1]?.indent) {
|
|
139
|
+
stack.pop();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parent = stack[stack.length - 1]?.container;
|
|
143
|
+
if (!parent) {
|
|
144
|
+
throw new Error(`Could not resolve YAML parent for line: ${rawLine}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!rawValue) {
|
|
148
|
+
const child: YamlObject = {};
|
|
149
|
+
parent[key] = child;
|
|
150
|
+
stack.push({ container: child, indent });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
parent[key] = parseYamlScalar(rawValue);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return root;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const PoolSizeRangeInputSchema = z
|
|
161
|
+
.string()
|
|
162
|
+
.regex(/^\d+\.\.\d+$/, "pool-size must use min..max format")
|
|
163
|
+
.transform((value, ctx) => {
|
|
164
|
+
const [minRaw, maxRaw] = value.split("..");
|
|
165
|
+
const min = Number.parseInt(minRaw, 10);
|
|
166
|
+
const max = Number.parseInt(maxRaw, 10);
|
|
167
|
+
|
|
168
|
+
if (min > max) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: z.ZodIssueCode.custom,
|
|
171
|
+
message: "pool-size min must be less than or equal to max",
|
|
172
|
+
});
|
|
173
|
+
return z.NEVER;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { max, min };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const PoolSizeRangeSchema = z.object({
|
|
180
|
+
max: z.number().int(),
|
|
181
|
+
min: z.number().int(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const SecurityOverrideSchema = z
|
|
185
|
+
.object({
|
|
186
|
+
"read-only-rootfs": z.boolean().optional(),
|
|
187
|
+
"no-new-privileges": z.boolean().optional(),
|
|
188
|
+
egress: z.array(z.string()).optional(),
|
|
189
|
+
env: z.array(z.string()).optional(),
|
|
190
|
+
})
|
|
191
|
+
.passthrough();
|
|
192
|
+
|
|
193
|
+
const ContainerOverrideSchema = z
|
|
194
|
+
.object({
|
|
195
|
+
image: z.string().optional(),
|
|
196
|
+
memory: z.string().optional(),
|
|
197
|
+
cpu: z.string().optional(),
|
|
198
|
+
timeout: z.string().optional(),
|
|
199
|
+
"restart-policy": z.enum(["on-failure", "always", "never"]).optional(),
|
|
200
|
+
"max-restarts": z.number().int().optional(),
|
|
201
|
+
})
|
|
202
|
+
.passthrough();
|
|
203
|
+
|
|
204
|
+
const CdpOverrideSchema = z
|
|
205
|
+
.object({
|
|
206
|
+
"pool-size": PoolSizeRangeInputSchema,
|
|
207
|
+
"page-timeout": z.string().optional(),
|
|
208
|
+
"pages-per-instance": z.number().int().optional(),
|
|
209
|
+
"idle-timeout": z.string().optional(),
|
|
210
|
+
"chrome-flags": z.array(z.string()).optional(),
|
|
211
|
+
})
|
|
212
|
+
.passthrough();
|
|
213
|
+
|
|
214
|
+
const DefaultsSchema = z
|
|
215
|
+
.object({
|
|
216
|
+
container: ContainerOverrideSchema.optional(),
|
|
217
|
+
security: SecurityOverrideSchema.optional(),
|
|
218
|
+
})
|
|
219
|
+
.passthrough();
|
|
220
|
+
|
|
221
|
+
const ConnectorConfigOverrideSchema = z
|
|
222
|
+
.object({
|
|
223
|
+
container: ContainerOverrideSchema.optional(),
|
|
224
|
+
runtime: z.enum(["standard", "browser"]).optional(),
|
|
225
|
+
replicas: z.number().int().optional(),
|
|
226
|
+
security: SecurityOverrideSchema.optional(),
|
|
227
|
+
cdp: CdpOverrideSchema.optional(),
|
|
228
|
+
})
|
|
229
|
+
.passthrough();
|
|
230
|
+
|
|
231
|
+
export const SecuritySchema = z
|
|
232
|
+
.object({
|
|
233
|
+
"read-only-rootfs": z.boolean().default(true),
|
|
234
|
+
"no-new-privileges": z.boolean().default(true),
|
|
235
|
+
egress: z.array(z.string()).optional(),
|
|
236
|
+
env: z.array(z.string()).optional(),
|
|
237
|
+
})
|
|
238
|
+
.passthrough();
|
|
239
|
+
|
|
240
|
+
export const ContainerSchema = z
|
|
241
|
+
.object({
|
|
242
|
+
image: z.string().default("auto"),
|
|
243
|
+
memory: z.string().default("256Mi"),
|
|
244
|
+
cpu: z.string().default("250m"),
|
|
245
|
+
timeout: z.string().default("30s"),
|
|
246
|
+
"restart-policy": z
|
|
247
|
+
.enum(["on-failure", "always", "never"])
|
|
248
|
+
.default("on-failure"),
|
|
249
|
+
"max-restarts": z.number().int().default(3),
|
|
250
|
+
})
|
|
251
|
+
.passthrough();
|
|
252
|
+
|
|
253
|
+
export const CdpSchema = z
|
|
254
|
+
.object({
|
|
255
|
+
"pool-size": PoolSizeRangeSchema,
|
|
256
|
+
"page-timeout": z.string().default("120s"),
|
|
257
|
+
"pages-per-instance": z.number().int().default(4),
|
|
258
|
+
"idle-timeout": z.string().default("5m"),
|
|
259
|
+
"chrome-flags": z.array(z.string()).optional(),
|
|
260
|
+
})
|
|
261
|
+
.passthrough();
|
|
262
|
+
|
|
263
|
+
export const ConnectorConfigSchema = z
|
|
264
|
+
.object({
|
|
265
|
+
container: ContainerSchema,
|
|
266
|
+
runtime: z.enum(["standard", "browser"]).default("standard"),
|
|
267
|
+
replicas: z.number().int().default(1),
|
|
268
|
+
security: SecuritySchema,
|
|
269
|
+
cdp: CdpSchema.optional(),
|
|
270
|
+
})
|
|
271
|
+
.passthrough();
|
|
272
|
+
|
|
273
|
+
export const ConnectorsYamlSchema = z
|
|
274
|
+
.object({
|
|
275
|
+
defaults: DefaultsSchema.optional(),
|
|
276
|
+
connectors: z.record(z.string(), ConnectorConfigOverrideSchema),
|
|
277
|
+
})
|
|
278
|
+
.passthrough();
|
|
279
|
+
|
|
280
|
+
export type PoolSizeRange = z.infer<typeof PoolSizeRangeSchema>;
|
|
281
|
+
export type ConnectorsYaml = z.infer<typeof ConnectorsYamlSchema>;
|
|
282
|
+
export type ConnectorConfig = z.infer<typeof ConnectorConfigSchema>;
|
|
283
|
+
|
|
284
|
+
function toValidationError(error: unknown): ValidationError {
|
|
285
|
+
if (error instanceof z.ZodError) {
|
|
286
|
+
return new ValidationError("Invalid connectors.yaml configuration", {
|
|
287
|
+
code: "INVALID_CONNECTORS_YAML",
|
|
288
|
+
fix: "Match the connectors.yaml schema from openspec/connector-sdk/08-infrastructure.md §8.2.",
|
|
289
|
+
zodError: error,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return new ValidationError("Invalid connectors.yaml configuration", {
|
|
294
|
+
code: "INVALID_CONNECTORS_YAML",
|
|
295
|
+
cause: error instanceof Error ? error : undefined,
|
|
296
|
+
fix: "Check connectors.yaml syntax and field values.",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function parseConnectorsYaml(yamlContent: string): ConnectorsYaml {
|
|
301
|
+
try {
|
|
302
|
+
const parsed = parseYamlObject(yamlContent);
|
|
303
|
+
return ConnectorsYamlSchema.parse(parsed);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof z.ZodError) {
|
|
306
|
+
throw toValidationError(error);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
throw new SDKError("Failed to parse connectors.yaml", {
|
|
310
|
+
code: "CONNECTORS_YAML_PARSE_FAILED",
|
|
311
|
+
cause: error instanceof Error ? error : undefined,
|
|
312
|
+
fix: "Ensure connectors.yaml contains valid YAML.",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function loadConnectorsYaml(
|
|
318
|
+
filePath: string,
|
|
319
|
+
): Promise<ConnectorsYaml> {
|
|
320
|
+
try {
|
|
321
|
+
const yamlContent = await readFile(filePath, "utf8");
|
|
322
|
+
return parseConnectorsYaml(yamlContent);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof SDKError || error instanceof ValidationError) {
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw new SDKError(`Failed to load connectors.yaml from ${filePath}`, {
|
|
329
|
+
code: "CONNECTORS_YAML_LOAD_FAILED",
|
|
330
|
+
cause: error instanceof Error ? error : undefined,
|
|
331
|
+
fix: "Verify the file exists and is readable.",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function resolveConnectorConfig(
|
|
337
|
+
yaml: ConnectorsYaml,
|
|
338
|
+
connectorId: string,
|
|
339
|
+
): ConnectorConfig {
|
|
340
|
+
const connectorOverride = yaml.connectors[connectorId];
|
|
341
|
+
|
|
342
|
+
if (!connectorOverride) {
|
|
343
|
+
throw new SDKError(
|
|
344
|
+
`Connector "${connectorId}" not found in connectors.yaml`,
|
|
345
|
+
{
|
|
346
|
+
code: "CONNECTOR_NOT_FOUND",
|
|
347
|
+
fix: `Add ${connectorId} under connectors: in connectors.yaml.`,
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const container = ContainerSchema.parse({
|
|
353
|
+
...yaml.defaults?.container,
|
|
354
|
+
...connectorOverride.container,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const security = SecuritySchema.parse({
|
|
358
|
+
...yaml.defaults?.security,
|
|
359
|
+
...connectorOverride.security,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const resolved = {
|
|
363
|
+
container,
|
|
364
|
+
security,
|
|
365
|
+
runtime: connectorOverride.runtime ?? "standard",
|
|
366
|
+
replicas: connectorOverride.replicas ?? 1,
|
|
367
|
+
cdp: connectorOverride.cdp
|
|
368
|
+
? CdpSchema.parse(connectorOverride.cdp)
|
|
369
|
+
: undefined,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return ConnectorConfigSchema.parse(resolved);
|
|
373
|
+
}
|