Skip to content

Commit 1336226

Browse files
committed
feat(sdk): add injectable fetch transport seam and route all provider requests through it
1 parent a7ea745 commit 1336226

5 files changed

Lines changed: 133 additions & 8 deletions

File tree

packages/sdk/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
// ./internal and are deliberately not exported — do not add internal-only symbols here.
44

55
export { UserError } from "./internal/errors.ts";
6+
// Server-side HTTP errors thrown by provider clients. Exported so hosts (CLIs,
7+
// servers) can branch on them and surface statusCode/responseBody structurally
8+
// instead of parsing the message string.
9+
export { ApiError, ConflictError } from "./internal/providers/base-client.ts";
10+
11+
// Transport seam: hosts install a fetch-compatible implementation to add
12+
// cross-cutting request concerns (tracking headers, logging) without touching
13+
// globalThis.fetch. Unset → provider clients use the global fetch as before.
14+
export { setDefaultFetch, type FetchLike } from "./internal/transport.ts";
615

716
export type {
817
BackendRuntimeInput,

packages/sdk/src/internal/executor/skill-resolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync, statSync } from "node:fs";
22
import { dirname, resolve } from "node:path";
3+
import { resolveFetch } from "../transport.ts";
34
import type { SkillDecl } from "../types/config.ts";
45
import type { SkillFile } from "../types/skill-file.ts";
56
import { collectFiles } from "../utils/collect-files.ts";
@@ -10,7 +11,7 @@ import type { ExecContext } from "./context.ts";
1011
// is read relative to configPath (zip, directory, or a single SKILL.md).
1112
export async function resolveSkillFiles(decl: SkillDecl, ctx: ExecContext): Promise<SkillFile[]> {
1213
if (/^https?:\/\//i.test(decl.source)) {
13-
const res = await fetch(decl.source);
14+
const res = await resolveFetch()(decl.source);
1415
if (!res.ok) throw new Error(`skill source 下载失败:${res.status} ${decl.source}`);
1516
return extractSkillZipFiles(Buffer.from(await res.arrayBuffer()));
1617
}

packages/sdk/src/internal/providers/base-client.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveFetch } from "../transport.ts";
12
import type { RemoteResource } from "./interface.ts";
23

34
export class ApiError extends Error {
@@ -34,7 +35,7 @@ export abstract class BaseApiClient {
3435
}
3536

3637
async post(path: string, body: unknown): Promise<unknown> {
37-
const res = await fetch(`${this.baseUrl}${path}`, {
38+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
3839
method: "POST",
3940
headers: this.headers(),
4041
body: JSON.stringify(body),
@@ -44,7 +45,7 @@ export abstract class BaseApiClient {
4445
}
4546

4647
async put(path: string, body: unknown): Promise<unknown> {
47-
const res = await fetch(`${this.baseUrl}${path}`, {
48+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
4849
method: "PUT",
4950
headers: this.headers(),
5051
body: JSON.stringify(body),
@@ -54,15 +55,15 @@ export abstract class BaseApiClient {
5455
}
5556

5657
async delete(path: string): Promise<void> {
57-
const res = await fetch(`${this.baseUrl}${path}`, {
58+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
5859
method: "DELETE",
5960
headers: this.headers(),
6061
});
6162
await this.throwIfError(res);
6263
}
6364

6465
async get(path: string): Promise<unknown> {
65-
const res = await fetch(`${this.baseUrl}${path}`, {
66+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
6667
method: "GET",
6768
headers: this.headers(),
6869
});
@@ -71,7 +72,7 @@ export abstract class BaseApiClient {
7172
}
7273

7374
async getBuffer(path: string): Promise<Buffer> {
74-
const res = await fetch(`${this.baseUrl}${path}`, {
75+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
7576
method: "GET",
7677
headers: this.headers(),
7778
});
@@ -81,7 +82,7 @@ export abstract class BaseApiClient {
8182

8283
async *sse(path: string, options?: { headers?: Record<string, string> }): AsyncGenerator<Record<string, unknown>> {
8384
const controller = new AbortController();
84-
const res = await fetch(`${this.baseUrl}${path}`, {
85+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
8586
method: "GET",
8687
headers: { ...this.headers(), Accept: "text/event-stream", ...options?.headers },
8788
signal: controller.signal,
@@ -140,7 +141,7 @@ export abstract class BaseApiClient {
140141
// boundary itself. Every provider's multipart headers are exactly its JSON
141142
// headers minus Content-Type, so derive them here.
142143
const { "Content-Type": _contentType, ...headers } = this.headers();
143-
const res = await fetch(`${this.baseUrl}${path}`, {
144+
const res = await resolveFetch()(`${this.baseUrl}${path}`, {
144145
method: "POST",
145146
headers,
146147
body: formData,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Host-injectable transport seam. A host embedding the SDK (a CLI, a server)
2+
// can install a fetch-compatible implementation — e.g. to add tracking headers
3+
// or request logging — without monkey-patching globalThis.fetch. Response
4+
// semantics (ApiError classification, SSE parsing, pagination) stay in the
5+
// provider clients regardless of the installed implementation.
6+
7+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
8+
9+
let defaultFetch: FetchLike | undefined;
10+
11+
/** Install the fetch implementation used by all provider clients; pass undefined to reset. */
12+
export function setDefaultFetch(fetchImpl: FetchLike | undefined): void {
13+
defaultFetch = fetchImpl;
14+
}
15+
16+
/**
17+
* Resolve the active fetch implementation. Falls back to the *current*
18+
* globalThis.fetch (resolved per call, so test-time fetch mocks keep working).
19+
*/
20+
export function resolveFetch(): FetchLike {
21+
return defaultFetch ?? ((input, init) => fetch(input, init));
22+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { afterEach, expect, test } from "bun:test";
2+
import { BaseApiClient } from "../../src/internal/providers/base-client.ts";
3+
import { type FetchLike, resolveFetch, setDefaultFetch } from "../../src/internal/transport.ts";
4+
5+
class TestClient extends BaseApiClient {
6+
protected baseUrl = "https://example.test";
7+
protected errorPrefix = "test";
8+
protected paginationStrategy = "page" as const;
9+
10+
protected headers(): Record<string, string> {
11+
return { "Content-Type": "application/json", Authorization: "Bearer k" };
12+
}
13+
}
14+
15+
afterEach(() => {
16+
setDefaultFetch(undefined);
17+
});
18+
19+
test("resolveFetch falls back to the current globalThis.fetch when nothing is installed", async () => {
20+
const originalFetch = globalThis.fetch;
21+
globalThis.fetch = (async () =>
22+
new Response(JSON.stringify({ id: "from-global" }), {
23+
status: 200,
24+
})) as unknown as typeof fetch;
25+
26+
try {
27+
const res = await new TestClient().get("/x");
28+
expect(res).toEqual({ id: "from-global" });
29+
} finally {
30+
globalThis.fetch = originalFetch;
31+
}
32+
});
33+
34+
test("setDefaultFetch routes provider client requests through the installed implementation", async () => {
35+
const captured: Array<{
36+
url: string;
37+
method?: string;
38+
headers: Record<string, string>;
39+
}> = [];
40+
const installed: FetchLike = async (input, init) => {
41+
captured.push({
42+
url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
43+
method: init?.method,
44+
headers: (init?.headers ?? {}) as Record<string, string>,
45+
});
46+
return new Response(JSON.stringify({ id: "from-installed" }), {
47+
status: 200,
48+
});
49+
};
50+
setDefaultFetch(installed);
51+
52+
const res = await new TestClient().post("/agents", { name: "a" });
53+
expect(res).toEqual({ id: "from-installed" });
54+
expect(captured).toEqual([
55+
{
56+
url: "https://example.test/agents",
57+
method: "POST",
58+
headers: {
59+
"Content-Type": "application/json",
60+
Authorization: "Bearer k",
61+
},
62+
},
63+
]);
64+
});
65+
66+
test("installed implementation still gets ApiError semantics from the client (non-ok response)", async () => {
67+
setDefaultFetch(async () => new Response("missing", { status: 404 }));
68+
69+
const err = (await new TestClient().get("/missing").catch((error: unknown) => error)) as Error & {
70+
statusCode?: number;
71+
};
72+
expect(err.statusCode).toBe(404);
73+
});
74+
75+
test("setDefaultFetch(undefined) resets back to the global fetch", async () => {
76+
setDefaultFetch(async () => new Response("{}", { status: 200 }));
77+
setDefaultFetch(undefined);
78+
79+
const originalFetch = globalThis.fetch;
80+
let globalHit = false;
81+
globalThis.fetch = (async () => {
82+
globalHit = true;
83+
return new Response("{}", { status: 200 });
84+
}) as unknown as typeof fetch;
85+
86+
try {
87+
await resolveFetch()("https://example.test/ping");
88+
expect(globalHit).toBe(true);
89+
} finally {
90+
globalThis.fetch = originalFetch;
91+
}
92+
});

0 commit comments

Comments
 (0)