@apifuse/provider-sdk 2.1.0-beta.0 → 2.1.0-beta.2
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/AUTHORING.md +35 -26
- package/CHANGELOG.md +14 -0
- package/README.md +104 -2
- package/bin/apifuse-check.ts +30 -2
- package/bin/apifuse-dev.ts +30 -6
- package/bin/apifuse-pack-check.ts +90 -0
- package/bin/apifuse-pack-smoke.ts +296 -0
- package/package.json +9 -8
- package/src/ceremonies/index.ts +22 -1
- package/src/cli/create.ts +2 -3
- package/src/cli/templates/provider/README.md.tpl +57 -2
- package/src/cli/templates/provider/index.ts.tpl +4 -1
- package/src/config/loader.ts +61 -1
- package/src/define.ts +44 -6
- package/src/index.ts +0 -6
- package/src/provider.ts +0 -1
- package/src/runtime/http.ts +27 -11
- package/src/runtime/tls.ts +21 -12
- package/src/server/serve.ts +17 -3
- package/src/types.ts +28 -2
- package/src/composite.ts +0 -43
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ChildProcess,
|
|
5
|
+
execFileSync,
|
|
6
|
+
spawn,
|
|
7
|
+
spawnSync,
|
|
8
|
+
} from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { createServer } from "node:net";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
const PACK_RESULT_SCHEMA = z.array(
|
|
23
|
+
z.object({
|
|
24
|
+
filename: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
const HEALTH_RESPONSE_SCHEMA = z.object({
|
|
28
|
+
status: z.string(),
|
|
29
|
+
provider: z.string(),
|
|
30
|
+
version: z.string().optional(),
|
|
31
|
+
});
|
|
32
|
+
const PING_RESPONSE_SCHEMA = z.object({
|
|
33
|
+
data: z
|
|
34
|
+
.object({
|
|
35
|
+
ok: z.boolean(),
|
|
36
|
+
message: z.string(),
|
|
37
|
+
})
|
|
38
|
+
.optional(),
|
|
39
|
+
error: z.unknown().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const KEEP_TEMP = process.env.APIFUSE_PACK_SMOKE_KEEP_TEMP === "1";
|
|
43
|
+
|
|
44
|
+
const tempRoot = mkdtempSync(
|
|
45
|
+
join(tmpdir(), "apifuse-provider-sdk-pack-smoke-"),
|
|
46
|
+
);
|
|
47
|
+
const packDir = join(tempRoot, "pack");
|
|
48
|
+
const consumerDir = join(tempRoot, "consumer");
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(packDir, { recursive: true });
|
|
52
|
+
mkdirSync(consumerDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const packed = packSdk(packDir);
|
|
55
|
+
const tarballPath = resolve(packDir, packed.filename);
|
|
56
|
+
const tarballSpecifier = `file:${tarballPath}`;
|
|
57
|
+
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(consumerDir, "package.json"),
|
|
60
|
+
`${JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
private: true,
|
|
63
|
+
type: "module",
|
|
64
|
+
dependencies: {
|
|
65
|
+
"@apifuse/provider-sdk": tarballSpecifier,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
null,
|
|
69
|
+
2,
|
|
70
|
+
)}\n`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
run("bun", ["install"], consumerDir);
|
|
74
|
+
|
|
75
|
+
const cliBin = join(consumerDir, "node_modules", ".bin", "apifuse");
|
|
76
|
+
if (!existsSync(cliBin)) {
|
|
77
|
+
throw new Error(`Expected CLI bin at ${cliBin}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
run(
|
|
81
|
+
"bun",
|
|
82
|
+
[
|
|
83
|
+
cliBin,
|
|
84
|
+
"create",
|
|
85
|
+
"dx-smoke",
|
|
86
|
+
"--yes",
|
|
87
|
+
"--json",
|
|
88
|
+
"--sdk-specifier",
|
|
89
|
+
tarballSpecifier,
|
|
90
|
+
],
|
|
91
|
+
consumerDir,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const generatedProviderDir = join(consumerDir, "dx-smoke");
|
|
95
|
+
run("bun", ["run", "check"], generatedProviderDir);
|
|
96
|
+
run("bun", ["run", "test"], generatedProviderDir);
|
|
97
|
+
assertGeneratedReadme(generatedProviderDir);
|
|
98
|
+
await smokeGeneratedDevServer(generatedProviderDir);
|
|
99
|
+
|
|
100
|
+
console.log(
|
|
101
|
+
`Provider SDK packed-artifact smoke passed: ${tarballPath} -> ${generatedProviderDir}`,
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
if (KEEP_TEMP) {
|
|
105
|
+
console.log(`Keeping smoke temp directory: ${tempRoot}`);
|
|
106
|
+
} else {
|
|
107
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function packSdk(destination: string): { filename: string } {
|
|
112
|
+
const raw = execFileSync(
|
|
113
|
+
"npm",
|
|
114
|
+
["pack", "--json", "--pack-destination", destination],
|
|
115
|
+
{
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
const parsed = PACK_RESULT_SCHEMA.parse(JSON.parse(raw));
|
|
122
|
+
const first = parsed[0];
|
|
123
|
+
if (!first) {
|
|
124
|
+
throw new Error("npm pack --json returned no package metadata.");
|
|
125
|
+
}
|
|
126
|
+
return first;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function run(command: string, args: string[], cwd: string): void {
|
|
130
|
+
const result = spawnSync(command, args, {
|
|
131
|
+
cwd,
|
|
132
|
+
env: process.env,
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (result.error) {
|
|
137
|
+
throw result.error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.status !== 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Command failed (${[command, ...args].join(" ")}) in ${cwd} with exit code ${result.status}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertGeneratedReadme(providerDir: string): void {
|
|
148
|
+
const readme = readFileSync(join(providerDir, "README.md"), "utf8");
|
|
149
|
+
if (!readme.includes('"requestId":"req_local_ping"')) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"Generated README is missing requestId in local smoke docs.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (readme.includes('"connection":null')) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"Generated README must not document connection:null for no-auth local smoke.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!readme.includes("bunx playwright install chromium")) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
"Generated README is missing browser runtime troubleshooting guidance.",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (!readme.includes("bun pm untrusted")) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"Generated README is missing Bun trusted-dependency troubleshooting guidance.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!readme.includes("bun run record -- --operation <operation>")) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"Generated README must document fixture recording through the generated record script.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function smokeGeneratedDevServer(providerDir: string): Promise<void> {
|
|
177
|
+
const port = await getAvailablePort();
|
|
178
|
+
const server = spawn("bun", ["run", "dev"], {
|
|
179
|
+
cwd: providerDir,
|
|
180
|
+
env: { ...process.env, PORT: String(port) },
|
|
181
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
182
|
+
});
|
|
183
|
+
let output = "";
|
|
184
|
+
server.stdout?.on("data", (chunk) => {
|
|
185
|
+
output += chunk.toString();
|
|
186
|
+
});
|
|
187
|
+
server.stderr?.on("data", (chunk) => {
|
|
188
|
+
output += chunk.toString();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
193
|
+
await waitForHttp(`${baseUrl}/health`, server, () => output);
|
|
194
|
+
|
|
195
|
+
const health = await fetchJson(`${baseUrl}/health`, HEALTH_RESPONSE_SCHEMA);
|
|
196
|
+
if (health.status !== "ok" || health.provider !== "dx-smoke") {
|
|
197
|
+
throw new Error(`Unexpected /health payload: ${JSON.stringify(health)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const response = await fetch(`${baseUrl}/v1/ping`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "content-type": "application/json" },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
requestId: "req_pack_smoke_ping",
|
|
205
|
+
input: { value: "hello" },
|
|
206
|
+
headers: {},
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
const payload = PING_RESPONSE_SCHEMA.parse(await response.json());
|
|
210
|
+
|
|
211
|
+
if (!response.ok || payload.data?.ok !== true) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Unexpected /v1/ping response (${response.status}): ${JSON.stringify(payload)}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
await stopServer(server);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getAvailablePort(): Promise<number> {
|
|
222
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
223
|
+
const server = createServer();
|
|
224
|
+
server.once("error", rejectPromise);
|
|
225
|
+
server.listen(0, "127.0.0.1", () => {
|
|
226
|
+
const address = server.address();
|
|
227
|
+
server.close((error) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
rejectPromise(error);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!address || typeof address === "string") {
|
|
233
|
+
rejectPromise(new Error("Could not allocate a local TCP port."));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
resolvePromise(address.port);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
|
|
243
|
+
const response = await fetch(url);
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
throw new Error(`${url} returned ${response.status}`);
|
|
246
|
+
}
|
|
247
|
+
return schema.parse(await response.json());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function waitForHttp(
|
|
251
|
+
url: string,
|
|
252
|
+
server: ChildProcess,
|
|
253
|
+
getOutput: () => string,
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
const deadline = Date.now() + 10_000;
|
|
256
|
+
let lastError: unknown;
|
|
257
|
+
|
|
258
|
+
while (Date.now() < deadline) {
|
|
259
|
+
if (server.exitCode !== null) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Dev server exited early with code ${server.exitCode}\n${getOutput()}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await fetchJson(url, HEALTH_RESPONSE_SCHEMA);
|
|
267
|
+
return;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
lastError = error;
|
|
270
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${getOutput()}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function stopServer(server: ChildProcess): Promise<void> {
|
|
280
|
+
if (server.exitCode !== null) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
server.kill("SIGTERM");
|
|
284
|
+
await new Promise<void>((resolvePromise) => {
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
if (server.exitCode === null) {
|
|
287
|
+
server.kill("SIGKILL");
|
|
288
|
+
}
|
|
289
|
+
resolvePromise();
|
|
290
|
+
}, 2_000);
|
|
291
|
+
server.once("exit", () => {
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
resolvePromise();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apifuse/provider-sdk",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "ApiFuse Provider SDK
|
|
6
|
+
"description": "ApiFuse Provider SDK — Build providers with zero architectural constraints",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./src/index.ts",
|
|
@@ -61,23 +61,24 @@
|
|
|
61
61
|
"type-check": "tsgo --noEmit",
|
|
62
62
|
"test": "bun test",
|
|
63
63
|
"check": "bun run lint && bun run type-check",
|
|
64
|
-
"pack:check": "bun bin/apifuse-pack-check.ts"
|
|
64
|
+
"pack:check": "bun bin/apifuse-pack-check.ts",
|
|
65
|
+
"pack:smoke": "bun bin/apifuse-pack-smoke.ts"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
|
-
"@biomejs/biome": "^2.4.
|
|
68
|
-
"@clack/prompts": "^1.2.0",
|
|
68
|
+
"@biomejs/biome": "^2.4.15",
|
|
69
69
|
"@types/bun": "latest",
|
|
70
|
-
"@types/node": "^25.
|
|
70
|
+
"@types/node": "^25.8.0",
|
|
71
71
|
"typescript": "^6.0.3"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
+
"@clack/prompts": "^1.4.0",
|
|
74
75
|
"ajv": "^8.17",
|
|
75
|
-
"hono": "^4.12.
|
|
76
|
+
"hono": "^4.12.19",
|
|
76
77
|
"playwright": "^1.55.1",
|
|
77
78
|
"playwright-stealth": "^0.0.1",
|
|
78
79
|
"re2-wasm": "^1.0",
|
|
79
80
|
"safe-regex": "^2.1",
|
|
80
81
|
"tlsclientwrapper": "^4.2.0",
|
|
81
|
-
"zod": "^4.3
|
|
82
|
+
"zod": "^4.4.3"
|
|
82
83
|
}
|
|
83
84
|
}
|
package/src/ceremonies/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ const DEVICE_FLOW_KEY = "__device_flow";
|
|
|
63
63
|
const MAGIC_LINK_KEY = "__magic_link";
|
|
64
64
|
const COMBINED_STAGE_KEY = "__combined_stage";
|
|
65
65
|
const SWITCH_SELECTION_KEY = "__switch_selection";
|
|
66
|
+
const FORM_FIELD_ORDER_EXTENSION = "x-apifuse-field-order";
|
|
66
67
|
|
|
67
68
|
function isRecord(value: unknown): value is JsonObject {
|
|
68
69
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -175,11 +176,31 @@ function buildJsonSchemaForm(
|
|
|
175
176
|
): AuthTurn {
|
|
176
177
|
return createTurn("form", {
|
|
177
178
|
hint,
|
|
178
|
-
expectedInput,
|
|
179
|
+
expectedInput: withDeclaredFormFieldOrder(expectedInput),
|
|
179
180
|
data: {},
|
|
180
181
|
});
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
function withDeclaredFormFieldOrder(expectedInput: JsonObject): JsonObject {
|
|
185
|
+
const properties = expectedInput.properties;
|
|
186
|
+
if (!isRecord(properties)) {
|
|
187
|
+
return expectedInput;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const existingOrder = expectedInput[FORM_FIELD_ORDER_EXTENSION];
|
|
191
|
+
if (
|
|
192
|
+
Array.isArray(existingOrder) &&
|
|
193
|
+
existingOrder.every((value) => typeof value === "string")
|
|
194
|
+
) {
|
|
195
|
+
return expectedInput;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
...expectedInput,
|
|
200
|
+
[FORM_FIELD_ORDER_EXTENSION]: Object.keys(properties),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
export function validateCeremonyOutput(turn: unknown): AuthTurn {
|
|
184
205
|
if (!validateAuthTurn(turn)) {
|
|
185
206
|
const detail = validateAuthTurn.errors
|
package/src/cli/create.ts
CHANGED
|
@@ -479,7 +479,7 @@ export async function buildProviderCreatePlan(
|
|
|
479
479
|
RUNTIME: options.runtime,
|
|
480
480
|
BROWSER_BLOCK:
|
|
481
481
|
options.runtime === "browser"
|
|
482
|
-
? ',\n browser: {\n engine: "
|
|
482
|
+
? ',\n browser: {\n engine: "playwright-stealth",\n }'
|
|
483
483
|
: "",
|
|
484
484
|
SECRETS_BLOCK: renderSecretsBlock(options.authMode),
|
|
485
485
|
CREDENTIAL_BLOCK: renderCredentialBlock(options.authMode),
|
|
@@ -568,9 +568,8 @@ function renderPackageJson(input: {
|
|
|
568
568
|
scripts: {
|
|
569
569
|
dev: "apifuse dev .",
|
|
570
570
|
check: "apifuse check .",
|
|
571
|
-
"record:sample":
|
|
572
|
-
'apifuse record . --operation ping --params \'{"value":"hello"}\'',
|
|
573
571
|
test: "apifuse test .",
|
|
572
|
+
record: "apifuse record .",
|
|
574
573
|
"perf:sample": "apifuse perf . --operation ping --runs 3",
|
|
575
574
|
start: "bun start.ts",
|
|
576
575
|
},
|
|
@@ -21,8 +21,63 @@ bun run test
|
|
|
21
21
|
- `POST /auth/poll`
|
|
22
22
|
- `POST /auth/disconnect`
|
|
23
23
|
|
|
24
|
+
## Local smoke
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -s http://localhost:3900/health
|
|
28
|
+
curl -s -X POST http://localhost:3900/v1/ping \
|
|
29
|
+
-H 'Content-Type: application/json' \
|
|
30
|
+
-d '{"requestId":"req_local_ping","input":{"value":"hello"},"headers":{}}'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `POST /v1/{operation}` body is a request envelope:
|
|
34
|
+
|
|
35
|
+
- `requestId` is required and can be any unique local debugging string.
|
|
36
|
+
- `input` contains the operation input shape.
|
|
37
|
+
- `headers` is optional.
|
|
38
|
+
- `connection` is optional; omit it for no-auth/public operations. For
|
|
39
|
+
credential debugging, pass `{ "id", "mode", "secrets", "metadata",
|
|
40
|
+
"externalRef" }` with local-only secret values.
|
|
41
|
+
|
|
42
|
+
Structured errors return an `error` object with `code`, `message`,
|
|
43
|
+
`requestId`, and optional `details`; validation failures include field paths in
|
|
44
|
+
`details`, and the `apifuse dev` terminal prints a structured provider log.
|
|
45
|
+
|
|
46
|
+
## Debugging checklist
|
|
47
|
+
|
|
48
|
+
- `invalid_request`: include `requestId` and `input`; omit `connection` for
|
|
49
|
+
public/no-auth operations and never send `connection: null`.
|
|
50
|
+
- Credentials: declare `credential.keys`, pass local-only values through
|
|
51
|
+
`connection.secrets`, and read them with `ctx.credential`.
|
|
52
|
+
- Auth flow: call `/auth/start`, then `/auth/continue` with the same `flowId`;
|
|
53
|
+
carry returned `contextPatch` values into the next request's `context`.
|
|
54
|
+
- TLS/browser runtime: if Bun blocks native dependency lifecycle scripts, run
|
|
55
|
+
`bun pm untrusted` and trust SDK dependencies such as `koffi` before debugging
|
|
56
|
+
`ctx.tls`; for TypeScript browser Providers use
|
|
57
|
+
`browser.engine: "playwright-stealth"` (`nodriver` is Python-runtime only),
|
|
58
|
+
then install local Chromium with `bunx playwright install chromium` or set
|
|
59
|
+
`CDP_POOL_URL`.
|
|
60
|
+
|
|
24
61
|
## Next steps
|
|
25
62
|
|
|
26
63
|
1. Replace the sample `ping` operation with real upstream logic.
|
|
27
|
-
2.
|
|
28
|
-
|
|
64
|
+
2. Once the real operation declares `upstream.baseUrl` and uses `ctx.http` or
|
|
65
|
+
`ctx.tls`, record a fixture with:
|
|
66
|
+
`bun run record -- --operation <operation> --params '<json-input>'`.
|
|
67
|
+
3. Replace the starter `healthCheckUnsupported` with a real `healthCheck` for read-only upstream operations when safe.
|
|
68
|
+
4. Extend tests and operation metadata until the provider is bounty-ready.
|
|
69
|
+
|
|
70
|
+
`apifuse record` is not expected to work with the generated local-only `ping`
|
|
71
|
+
operation because it intentionally has no upstream response to capture.
|
|
72
|
+
|
|
73
|
+
## Health-check authorship
|
|
74
|
+
|
|
75
|
+
Every operation must declare exactly one of:
|
|
76
|
+
|
|
77
|
+
- `healthCheck` — preferred for safe read-only upstream probes.
|
|
78
|
+
- `healthCheckUnsupported` — allowed only when a probe is destructive, paid,
|
|
79
|
+
credential-sensitive, flaky by design, or otherwise unsafe. Use a specific
|
|
80
|
+
reason; reviewers reject placeholder reasons such as "TODO" or "later".
|
|
81
|
+
|
|
82
|
+
The generated `ping` operation uses `healthCheckUnsupported` only because it is
|
|
83
|
+
a local scaffold check, not a real upstream API probe.
|
|
@@ -4,7 +4,6 @@ const InputSchema = z
|
|
|
4
4
|
.object({
|
|
5
5
|
value: z
|
|
6
6
|
.string()
|
|
7
|
-
.default("hello")
|
|
8
7
|
.describe("Sample input value used to verify the generated provider scaffold is wired correctly."),
|
|
9
8
|
})
|
|
10
9
|
.describe("Input payload for the generated ping operation.");
|
|
@@ -49,6 +48,10 @@ export default defineProvider({
|
|
|
49
48
|
request: { value: "hello" },
|
|
50
49
|
response: { ok: true, message: "{{DISPLAY_NAME}} received: hello" },
|
|
51
50
|
},
|
|
51
|
+
healthCheckUnsupported: {
|
|
52
|
+
reason:
|
|
53
|
+
"Generated local-only scaffold operation. Replace this with a real healthCheck for upstream-backed bounty operations when safe; keep healthCheckUnsupported only for destructive, paid, credential-sensitive, or otherwise unprobeable operations with a specific rationale.",
|
|
54
|
+
},
|
|
52
55
|
},
|
|
53
56
|
},
|
|
54
57
|
});
|
package/src/config/loader.ts
CHANGED
|
@@ -43,7 +43,67 @@ export type ResolvedProxyConfig = {
|
|
|
43
43
|
|
|
44
44
|
function normalizeProxyUrl(url?: string): string | undefined {
|
|
45
45
|
const normalized = url?.trim();
|
|
46
|
-
return normalized ? normalized : undefined;
|
|
46
|
+
return normalized ? applyStickyProxySession(normalized) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function applyStickyProxySession(proxyUrl: string): string {
|
|
50
|
+
let parsed: URL;
|
|
51
|
+
try {
|
|
52
|
+
parsed = new URL(proxyUrl);
|
|
53
|
+
} catch {
|
|
54
|
+
return proxyUrl;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!parsed.hostname || !parsed.username || !parsed.password) {
|
|
58
|
+
return proxyUrl;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const host = parsed.hostname.toLowerCase();
|
|
62
|
+
if (!host.includes("smartproxy") && !host.includes("decodo")) {
|
|
63
|
+
return proxyUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const username = decodeURIComponent(parsed.username);
|
|
67
|
+
const sessionId =
|
|
68
|
+
process.env.APIFUSE_PROXY_SESSION_ID?.trim() || "apifuse-shared";
|
|
69
|
+
const sessionDuration =
|
|
70
|
+
process.env.APIFUSE_PROXY_SESSION_DURATION?.trim() || undefined;
|
|
71
|
+
const stickyUsername = host.includes("smartproxy")
|
|
72
|
+
? buildSmartproxyUsername(username, sessionId, sessionDuration)
|
|
73
|
+
: buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
|
|
74
|
+
|
|
75
|
+
parsed.username = stickyUsername;
|
|
76
|
+
return parsed.toString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildSmartproxyUsername(
|
|
80
|
+
username: string,
|
|
81
|
+
sessionId: string,
|
|
82
|
+
sessionDuration?: string,
|
|
83
|
+
): string {
|
|
84
|
+
const parts = username.split("_");
|
|
85
|
+
const configuredLife = parts
|
|
86
|
+
.find((part) => part.startsWith("life-"))
|
|
87
|
+
?.slice("life-".length);
|
|
88
|
+
const baseUsername = parts
|
|
89
|
+
.filter((part) => !part.startsWith("session-") && !part.startsWith("life-"))
|
|
90
|
+
.join("_");
|
|
91
|
+
return `${baseUsername}_session-${sessionId}_life-${sessionDuration ?? configuredLife ?? "60"}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildDecodoUsername(
|
|
95
|
+
username: string,
|
|
96
|
+
sessionId: string,
|
|
97
|
+
sessionDuration: string,
|
|
98
|
+
): string {
|
|
99
|
+
const withoutSticky = username.replace(
|
|
100
|
+
/-session-.+-sessionduration-\d+$/,
|
|
101
|
+
"",
|
|
102
|
+
);
|
|
103
|
+
const baseUsername = withoutSticky.startsWith("user-")
|
|
104
|
+
? withoutSticky
|
|
105
|
+
: `user-${withoutSticky}`;
|
|
106
|
+
return `${baseUsername}-session-${sessionId}-sessionduration-${sessionDuration}`;
|
|
47
107
|
}
|
|
48
108
|
|
|
49
109
|
function syncProxyEnv(config: ApiFuseConfig): void {
|
package/src/define.ts
CHANGED
|
@@ -233,8 +233,10 @@ const HEALTH_CHECK_CASE_FIELDS = new Set([
|
|
|
233
233
|
const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
|
|
234
234
|
const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
|
|
235
235
|
"requiredSecrets",
|
|
236
|
+
"probeOverrides",
|
|
236
237
|
"serviceAccount",
|
|
237
238
|
]);
|
|
239
|
+
const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set(["interval"]);
|
|
238
240
|
|
|
239
241
|
function levenshtein(a: string, b: string): number {
|
|
240
242
|
const m = a.length;
|
|
@@ -307,13 +309,13 @@ function validateProviderHealthMonitor(
|
|
|
307
309
|
fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
|
|
308
310
|
},
|
|
309
311
|
);
|
|
312
|
+
const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
|
|
310
313
|
rejectUnknownFields(
|
|
311
|
-
|
|
314
|
+
healthMonitorRecord,
|
|
312
315
|
PROVIDER_HEALTH_MONITOR_FIELDS,
|
|
313
316
|
"healthMonitor",
|
|
314
317
|
);
|
|
315
|
-
const requiredSecrets =
|
|
316
|
-
.requiredSecrets;
|
|
318
|
+
const requiredSecrets = healthMonitorRecord.requiredSecrets;
|
|
317
319
|
if (requiredSecrets !== undefined) {
|
|
318
320
|
if (!Array.isArray(requiredSecrets))
|
|
319
321
|
throw new ValidationError(
|
|
@@ -326,8 +328,44 @@ function validateProviderHealthMonitor(
|
|
|
326
328
|
);
|
|
327
329
|
}
|
|
328
330
|
}
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
+
const probeOverrides = healthMonitorRecord.probeOverrides;
|
|
332
|
+
if (probeOverrides !== undefined) {
|
|
333
|
+
if (
|
|
334
|
+
!probeOverrides ||
|
|
335
|
+
typeof probeOverrides !== "object" ||
|
|
336
|
+
Array.isArray(probeOverrides)
|
|
337
|
+
)
|
|
338
|
+
throw new ValidationError(
|
|
339
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
|
|
340
|
+
);
|
|
341
|
+
for (const [probeId, override] of Object.entries(probeOverrides)) {
|
|
342
|
+
if (probeId.length === 0)
|
|
343
|
+
throw new ValidationError(
|
|
344
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
|
|
345
|
+
);
|
|
346
|
+
if (!override || typeof override !== "object" || Array.isArray(override))
|
|
347
|
+
throw new ValidationError(
|
|
348
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
|
|
349
|
+
);
|
|
350
|
+
const overrideRecord = Object.fromEntries(Object.entries(override));
|
|
351
|
+
rejectUnknownFields(
|
|
352
|
+
overrideRecord,
|
|
353
|
+
PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
|
|
354
|
+
`healthMonitor.probeOverrides["${probeId}"]`,
|
|
355
|
+
);
|
|
356
|
+
const interval = overrideRecord.interval;
|
|
357
|
+
const validProbeIntervals: readonly string[] = PROBE_INTERVALS;
|
|
358
|
+
if (
|
|
359
|
+
interval !== undefined &&
|
|
360
|
+
(typeof interval !== "string" ||
|
|
361
|
+
!validProbeIntervals.includes(interval))
|
|
362
|
+
)
|
|
363
|
+
throw new ValidationError(
|
|
364
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be one of ${PROBE_INTERVALS.join(", ")}.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const serviceAccount = healthMonitorRecord.serviceAccount;
|
|
331
369
|
if (
|
|
332
370
|
serviceAccount !== undefined &&
|
|
333
371
|
(typeof serviceAccount !== "string" || serviceAccount.length === 0)
|
|
@@ -591,7 +629,7 @@ export function defineProvider<
|
|
|
591
629
|
throw new ProviderError(
|
|
592
630
|
`Provider "${config.id}" must define browser.engine when runtime is "browser"`,
|
|
593
631
|
{
|
|
594
|
-
fix: 'Add browser: { engine: "
|
|
632
|
+
fix: 'Add browser: { engine: "playwright-stealth" } for TypeScript providers, or another supported engine for your runtime',
|
|
595
633
|
},
|
|
596
634
|
);
|
|
597
635
|
if (config.browser && config.runtime !== "browser")
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
export { z } from "zod";
|
|
4
4
|
export * from "./ceremonies";
|
|
5
|
-
export {
|
|
6
|
-
type ClarifyResponse,
|
|
7
|
-
type CompositeContext,
|
|
8
|
-
type CompositeOperationDefinition,
|
|
9
|
-
defineCompositeOperation,
|
|
10
|
-
} from "./composite";
|
|
11
5
|
export type {
|
|
12
6
|
ApiFuseConfig,
|
|
13
7
|
BrowserConfig,
|
package/src/provider.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export { z } from "zod";
|
|
2
2
|
|
|
3
3
|
export { createFormCeremony } from "./ceremonies";
|
|
4
|
-
export { defineCompositeOperation } from "./composite";
|
|
5
4
|
export { defineOperation, defineProvider } from "./define";
|
|
6
5
|
export { AuthError, ProviderError, ValidationError } from "./errors";
|
|
7
6
|
export type {
|