@apifuse/provider-sdk 2.1.0-beta.1 → 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 +18 -0
- package/CHANGELOG.md +7 -0
- package/README.md +70 -1
- package/bin/apifuse-check.ts +30 -2
- package/bin/apifuse-dev.ts +30 -6
- package/bin/apifuse-pack-check.ts +50 -0
- package/bin/apifuse-pack-smoke.ts +175 -1
- package/package.json +6 -6
- package/src/cli/create.ts +2 -3
- package/src/cli/templates/provider/README.md.tpl +43 -1
- package/src/cli/templates/provider/index.ts.tpl +0 -1
- package/src/define.ts +1 -1
package/AUTHORING.md
CHANGED
|
@@ -79,6 +79,24 @@ External contributors are expected to submit standalone Provider source plus:
|
|
|
79
79
|
Maintainers own monorepo import under `providers/<id>/`, registry generation,
|
|
80
80
|
deployment projection checks, and release workflows.
|
|
81
81
|
|
|
82
|
+
### Public local debugging checklist
|
|
83
|
+
|
|
84
|
+
- Operation smoke requests use the provider server envelope:
|
|
85
|
+
`{"requestId":"req_local_<operation>","input":{...},"headers":{}}`.
|
|
86
|
+
Omit `connection` for public/no-auth operations; do not send `connection: null`.
|
|
87
|
+
- Credential-backed smoke requests pass local-only credential material in
|
|
88
|
+
`connection.secrets`. Keep real values in shell env or `.env`, never in source
|
|
89
|
+
or fixtures.
|
|
90
|
+
- Auth-flow debugging starts with `/auth/start`, continues with
|
|
91
|
+
`/auth/continue`, and carries returned `contextPatch` values into the next
|
|
92
|
+
request's `context`.
|
|
93
|
+
- TLS/browser providers may require local runtime setup outside Provider code:
|
|
94
|
+
use `bun pm untrusted`/`bun pm trust koffi` for blocked native TLS dependency
|
|
95
|
+
scripts, `browser.engine: "playwright-stealth"` for TypeScript browser
|
|
96
|
+
Providers (`nodriver` is Python-runtime only), `bunx playwright install
|
|
97
|
+
chromium` for local Playwright browser assets, or
|
|
98
|
+
`CDP_POOL_URL`/`APIFUSE_CDP_POOL_URL` for remote browser debugging.
|
|
99
|
+
|
|
82
100
|
### Running the lint locally
|
|
83
101
|
|
|
84
102
|
```bash
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @apifuse/provider-sdk Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.0-beta.2
|
|
4
|
+
|
|
5
|
+
- Harden public bounty contributor DX with server-contract accurate README and generated Provider smoke examples.
|
|
6
|
+
- Add packed-artifact regression checks so stale `connection: null` or missing `requestId` examples cannot ship again.
|
|
7
|
+
- Extend clean-room packed SDK smoke coverage to boot the generated dev server and call `/health` plus `POST /v1/ping`.
|
|
8
|
+
- Document credential, auth-flow, TLS, browser, and Bun trusted-dependency troubleshooting for SDK-only local development.
|
|
9
|
+
|
|
3
10
|
## 2.1.0-beta.1
|
|
4
11
|
|
|
5
12
|
- Fix public `apifuse create` runtime packaging by publishing `@clack/prompts` as a production dependency.
|
package/README.md
CHANGED
|
@@ -67,9 +67,73 @@ Smoke the generated local server:
|
|
|
67
67
|
curl -s http://localhost:3900/health
|
|
68
68
|
curl -s -X POST http://localhost:3900/v1/ping \
|
|
69
69
|
-H 'Content-Type: application/json' \
|
|
70
|
-
-d '{"input":{"value":"hello"},"headers":{}
|
|
70
|
+
-d '{"requestId":"req_local_ping","input":{"value":"hello"},"headers":{}}'
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
The operation request body is the same envelope used by the ApiFuse gateway:
|
|
74
|
+
|
|
75
|
+
| Field | Required | Notes |
|
|
76
|
+
|---|---:|---|
|
|
77
|
+
| `requestId` | yes | Any unique string is fine for local debugging; it is echoed in structured errors. |
|
|
78
|
+
| `input` | yes | The operation input after schema validation. |
|
|
79
|
+
| `headers` | no | Extra caller headers to expose through `ctx.request.headers`. |
|
|
80
|
+
| `connection` | no | Omit for no-auth/public operations. For credential debugging, pass an object with `id`, `mode`, `secrets`, `metadata`, and `externalRef`. Do not pass `null`. |
|
|
81
|
+
|
|
82
|
+
Credential-bearing local smoke example:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl -s -X POST http://localhost:3900/v1/me \
|
|
86
|
+
-H 'Content-Type: application/json' \
|
|
87
|
+
-d '{
|
|
88
|
+
"requestId":"req_local_me",
|
|
89
|
+
"input":{},
|
|
90
|
+
"connection":{
|
|
91
|
+
"id":"conn_local_debug",
|
|
92
|
+
"mode":"credentials",
|
|
93
|
+
"secrets":{"apiKey":"dev-only-secret"},
|
|
94
|
+
"scopes":[],
|
|
95
|
+
"metadata":{},
|
|
96
|
+
"externalRef":"local-debug"
|
|
97
|
+
}
|
|
98
|
+
}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Auth-flow endpoints use the same `requestId` convention:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
curl -s -X POST http://localhost:3900/auth/start \
|
|
105
|
+
-H 'Content-Type: application/json' \
|
|
106
|
+
-d '{"requestId":"req_auth_start","flowId":"flow_local_1","providerId":"my-provider","context":{}}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If a local smoke returns `{"error":...}`, inspect the JSON error body and the
|
|
110
|
+
`apifuse dev` server log. Validation failures include a `details` array with
|
|
111
|
+
the bad request path; provider/runtime failures include `code`, `message`, and
|
|
112
|
+
`requestId`.
|
|
113
|
+
|
|
114
|
+
### Local debugging checklist
|
|
115
|
+
|
|
116
|
+
- **`invalid_request` on `/v1/{operation}`**: confirm the request body includes
|
|
117
|
+
`requestId` and `input`. Omit `connection` for public/no-auth operations;
|
|
118
|
+
never send `connection: null`.
|
|
119
|
+
- **Credential-backed operations**: declare `credential.keys`, then pass matching
|
|
120
|
+
local-only values through `connection.secrets`. Read them in handlers with
|
|
121
|
+
`ctx.credential.get("key")` or `ctx.credential.getAccessToken()`.
|
|
122
|
+
- **Provider env secrets**: declare `secrets[]`, set values in your shell or
|
|
123
|
+
`.env`, and read only those names through `ctx.env.get("NAME")`.
|
|
124
|
+
- **Auth flows**: call `/auth/start`, then `/auth/continue` with the same
|
|
125
|
+
`flowId`; preserve any returned `contextPatch` in the next local request's
|
|
126
|
+
`context` object.
|
|
127
|
+
- **TLS-sensitive providers**: if Bun reports blocked lifecycle scripts after
|
|
128
|
+
install, run `bun pm untrusted`; when it lists trusted SDK native dependencies
|
|
129
|
+
such as `koffi`, run `bun pm trust koffi` (or `bun pm trust`) before debugging
|
|
130
|
+
`ctx.tls` failures.
|
|
131
|
+
- **Browser providers**: for TypeScript Providers use `runtime: "browser"` plus
|
|
132
|
+
`browser.engine: "playwright-stealth"`; `nodriver` is a Python-runtime path.
|
|
133
|
+
Install local browser assets with `bunx playwright install chromium` when
|
|
134
|
+
using the Playwright runtime, or set `CDP_POOL_URL`/`APIFUSE_CDP_POOL_URL`
|
|
135
|
+
when debugging against a remote browser pool.
|
|
136
|
+
|
|
73
137
|
## Authoring ergonomics
|
|
74
138
|
|
|
75
139
|
`defineProvider()` infers each operation handler input from the operation `input` schema. For larger providers, factor operations with `defineOperation()` and compose them later:
|
|
@@ -134,6 +198,11 @@ apifuse test [path]
|
|
|
134
198
|
apifuse perf <path> --operation <operation>
|
|
135
199
|
```
|
|
136
200
|
|
|
201
|
+
`apifuse record` is for real upstream-backed operations that declare
|
|
202
|
+
`upstream.baseUrl` and call the upstream through `ctx.http` or `ctx.tls`. The
|
|
203
|
+
generated local-only `ping` operation intentionally has no upstream and should
|
|
204
|
+
be replaced before recording fixtures.
|
|
205
|
+
|
|
137
206
|
## Scope boundary
|
|
138
207
|
|
|
139
208
|
Generator v1 scaffolds **TypeScript providers only** for this redesign. Python generation remains future work.
|
package/bin/apifuse-check.ts
CHANGED
|
@@ -7,13 +7,14 @@ import { pathToFileURL } from "node:url";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
|
|
9
9
|
import type { ProviderDefinition } from "../src";
|
|
10
|
+
import { lintProvider } from "../src/lint";
|
|
10
11
|
import { safeParseSchemaSync } from "../src/schema";
|
|
11
12
|
|
|
12
13
|
const HELP_TEXT = `Usage: apifuse check [path]
|
|
13
14
|
Example: apifuse check providers/airkorea
|
|
14
15
|
Default: apifuse check .`;
|
|
15
16
|
|
|
16
|
-
type CheckResult = {
|
|
17
|
+
export type CheckResult = {
|
|
17
18
|
message: string;
|
|
18
19
|
passed: boolean;
|
|
19
20
|
details?: string[];
|
|
@@ -103,7 +104,7 @@ function resolveFromParents(inputPath: string): string {
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
async function runChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
107
|
+
export async function runChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
107
108
|
const indexPath = resolve(providerRoot, "index.ts");
|
|
108
109
|
const dockerfilePath = resolve(providerRoot, "Dockerfile");
|
|
109
110
|
const packageJsonPath = resolve(providerRoot, "package.json");
|
|
@@ -118,6 +119,7 @@ async function runChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
118
119
|
checkOperations(provider),
|
|
119
120
|
checkFixtures(provider),
|
|
120
121
|
checkSchemas(provider),
|
|
122
|
+
checkAuthoringLint(provider),
|
|
121
123
|
checkProviderMetadata(provider),
|
|
122
124
|
checkDockerfile(dockerfilePath),
|
|
123
125
|
checkPackageJson(packageJsonPath),
|
|
@@ -254,6 +256,32 @@ function checkSchemas(provider: ProviderDefinition | undefined): CheckResult {
|
|
|
254
256
|
};
|
|
255
257
|
}
|
|
256
258
|
|
|
259
|
+
function checkAuthoringLint(
|
|
260
|
+
provider: ProviderDefinition | undefined,
|
|
261
|
+
): CheckResult {
|
|
262
|
+
if (!provider) {
|
|
263
|
+
return {
|
|
264
|
+
message: "Provider authoring lint has no error-level diagnostics",
|
|
265
|
+
passed: false,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const diagnostics = lintProvider(provider);
|
|
270
|
+
const errors = diagnostics.filter(
|
|
271
|
+
(diagnostic) => diagnostic.level === "error",
|
|
272
|
+
);
|
|
273
|
+
const details = diagnostics.map((diagnostic) => {
|
|
274
|
+
const field = diagnostic.field ? `${diagnostic.field}: ` : "";
|
|
275
|
+
return `${diagnostic.level.toUpperCase()} ${diagnostic.rule} ${field}${diagnostic.message}`;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
message: "Provider authoring lint has no error-level diagnostics",
|
|
280
|
+
passed: errors.length === 0,
|
|
281
|
+
details,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
257
285
|
function checkProviderMetadata(
|
|
258
286
|
provider: ProviderDefinition | undefined,
|
|
259
287
|
): CheckResult {
|
package/bin/apifuse-dev.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, relative, resolve } from "node:path";
|
|
5
5
|
|
|
6
6
|
import type { ProviderDefinition } from "../src";
|
|
7
7
|
import {
|
|
@@ -51,10 +51,24 @@ export async function main() {
|
|
|
51
51
|
console.log(` POST http://localhost:${port}/auth/poll`);
|
|
52
52
|
console.log(` POST http://localhost:${port}/auth/disconnect`);
|
|
53
53
|
|
|
54
|
+
const firstOperation = Object.keys(provider.operations)[0];
|
|
55
|
+
if (firstOperation) {
|
|
56
|
+
const sampleInput =
|
|
57
|
+
provider.operations[firstOperation]?.fixtures?.request ?? {};
|
|
58
|
+
const sampleBody = JSON.stringify({
|
|
59
|
+
requestId: `req_local_${firstOperation}`,
|
|
60
|
+
input: sampleInput,
|
|
61
|
+
headers: {},
|
|
62
|
+
});
|
|
63
|
+
console.log("\nSmoke:");
|
|
64
|
+
console.log(` curl -s http://localhost:${port}/health`);
|
|
65
|
+
console.log(
|
|
66
|
+
` curl -s -X POST http://localhost:${port}/v1/${firstOperation} -H 'Content-Type: application/json' -d ${shellSingleQuote(sampleBody)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
console.log("\nHot reload:");
|
|
55
|
-
console.log(
|
|
56
|
-
` bun --hot ${resolveImportPath("apifuse-dev.ts")} ${args[0] ?? "."}`,
|
|
57
|
-
);
|
|
71
|
+
console.log(` ${renderHotReloadCommand(providerPath, port)}`);
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
export function createProviderContext(provider: ProviderDefinition): {
|
|
@@ -111,8 +125,18 @@ function resolveFromParents(inputPath: string): string {
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
function
|
|
115
|
-
|
|
128
|
+
function renderHotReloadCommand(providerPath: string, port: number): string {
|
|
129
|
+
const devEntry = resolve(providerPath, "dev.ts");
|
|
130
|
+
if (existsSync(devEntry)) {
|
|
131
|
+
const relativeDevEntry = relative(process.cwd(), devEntry) || "dev.ts";
|
|
132
|
+
const portPrefix = process.env.PORT ? `PORT=${port} ` : "";
|
|
133
|
+
return `${portPrefix}bun --hot ${relativeDevEntry}`;
|
|
134
|
+
}
|
|
135
|
+
return "rerun `apifuse dev` after edits (no dev.ts entrypoint found)";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function shellSingleQuote(value: string): string {
|
|
139
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
function createUnsupportedBrowserStub(): BrowserClient {
|
|
@@ -81,7 +81,57 @@ if (packageJson.devDependencies?.["@clack/prompts"]) {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
assertPublicSmokeDocs("README.md", readFileSync("README.md", "utf8"));
|
|
85
|
+
assertPublicSmokeDocs(
|
|
86
|
+
"src/cli/templates/provider/README.md.tpl",
|
|
87
|
+
readFileSync("src/cli/templates/provider/README.md.tpl", "utf8"),
|
|
88
|
+
);
|
|
89
|
+
|
|
84
90
|
console.log(`Packed artifact OK: ${first.filename}`);
|
|
85
91
|
for (const filePath of filePaths) {
|
|
86
92
|
console.log(` - ${filePath}`);
|
|
87
93
|
}
|
|
94
|
+
|
|
95
|
+
function assertPublicSmokeDocs(label: string, content: string): void {
|
|
96
|
+
if (!content.includes('"requestId":"req_local_ping"')) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${label} must document the current provider server request envelope with requestId.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (content.includes('"connection":null')) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${label} must not tell public users to send connection:null; omit connection for no-auth operations.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!content.includes("bunx playwright install chromium")) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${label} must include browser runtime troubleshooting for public SDK-only debugging.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!content.includes("bun pm untrusted")) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${label} must include Bun trusted-dependency troubleshooting for TLS/browser bounties.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
!content.includes('browser.engine: "playwright-stealth"') ||
|
|
122
|
+
!content.includes("nodriver")
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${label} must clarify that TypeScript browser providers use playwright-stealth and nodriver is not the TypeScript happy path.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
label.includes("templates/provider/README.md.tpl") &&
|
|
131
|
+
!content.includes("bun run record -- --operation <operation>")
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${label} must document fixture recording through a generated package script, not a shell-global apifuse command.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type ChildProcess,
|
|
5
|
+
execFileSync,
|
|
6
|
+
spawn,
|
|
7
|
+
spawnSync,
|
|
8
|
+
} from "node:child_process";
|
|
4
9
|
import {
|
|
5
10
|
existsSync,
|
|
6
11
|
mkdirSync,
|
|
7
12
|
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
8
14
|
rmSync,
|
|
9
15
|
writeFileSync,
|
|
10
16
|
} from "node:fs";
|
|
17
|
+
import { createServer } from "node:net";
|
|
11
18
|
import { tmpdir } from "node:os";
|
|
12
19
|
import { join, resolve } from "node:path";
|
|
13
20
|
import { z } from "zod";
|
|
@@ -17,6 +24,20 @@ const PACK_RESULT_SCHEMA = z.array(
|
|
|
17
24
|
filename: z.string(),
|
|
18
25
|
}),
|
|
19
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
|
+
});
|
|
20
41
|
|
|
21
42
|
const KEEP_TEMP = process.env.APIFUSE_PACK_SMOKE_KEEP_TEMP === "1";
|
|
22
43
|
|
|
@@ -73,6 +94,8 @@ try {
|
|
|
73
94
|
const generatedProviderDir = join(consumerDir, "dx-smoke");
|
|
74
95
|
run("bun", ["run", "check"], generatedProviderDir);
|
|
75
96
|
run("bun", ["run", "test"], generatedProviderDir);
|
|
97
|
+
assertGeneratedReadme(generatedProviderDir);
|
|
98
|
+
await smokeGeneratedDevServer(generatedProviderDir);
|
|
76
99
|
|
|
77
100
|
console.log(
|
|
78
101
|
`Provider SDK packed-artifact smoke passed: ${tarballPath} -> ${generatedProviderDir}`,
|
|
@@ -120,3 +143,154 @@ function run(command: string, args: string[], cwd: string): void {
|
|
|
120
143
|
);
|
|
121
144
|
}
|
|
122
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,6 +1,6 @@
|
|
|
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
6
|
"description": "ApiFuse Provider SDK — Build providers with zero architectural constraints",
|
|
@@ -65,20 +65,20 @@
|
|
|
65
65
|
"pack:smoke": "bun bin/apifuse-pack-smoke.ts"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@biomejs/biome": "^2.4.
|
|
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.
|
|
74
|
+
"@clack/prompts": "^1.4.0",
|
|
75
75
|
"ajv": "^8.17",
|
|
76
|
-
"hono": "^4.12.
|
|
76
|
+
"hono": "^4.12.19",
|
|
77
77
|
"playwright": "^1.55.1",
|
|
78
78
|
"playwright-stealth": "^0.0.1",
|
|
79
79
|
"re2-wasm": "^1.0",
|
|
80
80
|
"safe-regex": "^2.1",
|
|
81
81
|
"tlsclientwrapper": "^4.2.0",
|
|
82
|
-
"zod": "^4.3
|
|
82
|
+
"zod": "^4.4.3"
|
|
83
83
|
}
|
|
84
84
|
}
|
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,13 +21,55 @@ 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.
|
|
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>'`.
|
|
28
67
|
3. Replace the starter `healthCheckUnsupported` with a real `healthCheck` for read-only upstream operations when safe.
|
|
29
68
|
4. Extend tests and operation metadata until the provider is bounty-ready.
|
|
30
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
|
+
|
|
31
73
|
## Health-check authorship
|
|
32
74
|
|
|
33
75
|
Every operation must declare exactly one of:
|
package/src/define.ts
CHANGED
|
@@ -629,7 +629,7 @@ export function defineProvider<
|
|
|
629
629
|
throw new ProviderError(
|
|
630
630
|
`Provider "${config.id}" must define browser.engine when runtime is "browser"`,
|
|
631
631
|
{
|
|
632
|
-
fix: 'Add browser: { engine: "
|
|
632
|
+
fix: 'Add browser: { engine: "playwright-stealth" } for TypeScript providers, or another supported engine for your runtime',
|
|
633
633
|
},
|
|
634
634
|
);
|
|
635
635
|
if (config.browser && config.runtime !== "browser")
|