4 Commits
Author SHA1 Message Date
chiyi 095cb859a5 v1.3.2: add request context and generation-only binding 2026-08-21 19:12:40 +08:00
chiyi eb290cc88c v1.3.1: improve TypeScript client reliability 2026-08-21 15:06:11 +08:00
chiyi 503d0904d5 v1.3.0: BindRoute wildcard routes (/prefix/*) with RouteCtx.Wildcard 2026-08-20 21:10:24 +08:00
chiyi e6c6f68a3a fun: upgrade TS client interceptors to fun-client parity (v1.2.0)
- request interceptor now receives the mutable per-request state map (token injection)
- response interceptor may return a replacement result (central token swap / error handling)
- fetch failures normalized to result status=4 (network error) instead of throwing
- stream requests also pass through request interceptors with state copy
2026-08-20 12:09:43 +08:00
12 changed files with 1514 additions and 96 deletions
+1
View File
@@ -75,6 +75,7 @@ func TestBugNullableEnum(t *testing.T) {
// bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void
func TestBugGenErrorOnly(t *testing.T) {
isolateGeneratorGlobals(t)
GetFun().BindService(&BugSvc{})
SetOutput(t.TempDir())
GenCode(GenGo{}, GenTs{})
+589
View File
@@ -0,0 +1,589 @@
package fun
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestTypeScriptClientBehavior(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is not installed")
}
probe := exec.Command(node, "--experimental-strip-types", "--input-type=module", "-e",
`if (typeof fetch !== "function" || typeof ReadableStream !== "function") process.exit(1)`)
if err := probe.Run(); err != nil {
t.Skip("node does not support TypeScript stripping and web streams")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
const script = `
import assert from "node:assert/strict";
import { Client } from "./client.ts";
const json = (value, status = 200) => new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json" },
});
const ndjson = body => new Response(body, {
headers: { "Content-Type": "application/x-ndjson; charset=utf-8" },
});
const client = new Client("http://example.test///");
const seen = [];
let cleanupCheck;
client.addResponseInterceptor((_service, _method, result) => {
if (cleanupCheck) {
assert.equal(cleanupCheck(), true);
cleanupCheck = undefined;
}
seen.push(result.status);
});
const expectSeen = status => assert.deepEqual(seen.splice(0), [status]);
let fetchInit;
globalThis.fetch = async (url, init) => {
fetchInit = { url, init };
return json({ status: 0, data: "ok" });
};
let result = await client.request("Svc", "fetchShape", undefined, { signal: new AbortController().signal });
assert.equal(result.status, 0);
assert.equal(fetchInit.url, "http://example.test/cell");
assert.equal(fetchInit.init.method, "POST");
assert.equal(fetchInit.init.headers["Content-Type"], "application/json");
assert.ok(fetchInit.init.signal);
assert.deepEqual(JSON.parse(fetchInit.init.body), { serviceName: "Svc", methodName: "fetchShape" });
expectSeen(0);
globalThis.fetch = async () => json({ status: 2, code: 4001, msg: "business" }, 503);
result = await client.request("Svc", "business");
assert.deepEqual(result, { status: 2, code: 4001, msg: "business" });
expectSeen(2);
globalThis.fetch = async () => json({ status: 1, msg: "framework" }, 500);
result = await client.request("Svc", "framework");
assert.deepEqual(result, { status: 1, msg: "framework" });
expectSeen(1);
globalThis.fetch = async () => new Response("<html>gateway timeout</html>", { status: 504 });
result = await client.request("Svc", "gatewayTimeout");
assert.equal(result.status, 5);
assert.match(result.msg, /504/);
expectSeen(5);
globalThis.fetch = async () => new Response("request timeout", { status: 408 });
result = await client.request("Svc", "gateway408");
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => json({ status: 5, msg: "server timeout" });
result = await client.request("Svc", "serverTimeout");
assert.equal(result.status, 5);
assert.equal(result.msg, "server timeout");
expectSeen(5);
globalThis.fetch = async () => new Response("bad gateway", { status: 502 });
result = await client.request("Svc", "gateway");
assert.equal(result.status, 4);
expectSeen(4);
globalThis.fetch = async () => new Response("", { status: 200 });
result = await client.request("Svc", "empty");
assert.equal(result.status, 1);
assert.match(result.msg, /Empty/);
expectSeen(1);
globalThis.fetch = async () => new Response("not-json", { status: 200 });
result = await client.request("Svc", "invalidJson");
assert.equal(result.status, 1);
assert.match(result.msg, /Invalid JSON/);
expectSeen(1);
globalThis.fetch = async () => new Response("<html>login</html>", {
status: 200,
headers: { "Content-Type": "text/html" },
});
result = await client.request("Svc", "html");
assert.equal(result.status, 1);
assert.match(result.msg, /HTML/);
expectSeen(1);
globalThis.fetch = async () => json({ data: 1 });
result = await client.request("Svc", "invalidResult");
assert.equal(result.status, 1);
expectSeen(1);
globalThis.fetch = async () => { throw new TypeError("DNS failed"); };
result = await client.request("Svc", "network");
assert.equal(result.status, 4);
assert.match(result.msg, /DNS failed/);
expectSeen(4);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("body read failed")); },
}));
result = await client.request("Svc", "bodyRead");
assert.equal(result.status, 4);
assert.match(result.msg, /body read failed/);
expectSeen(4);
const manualAbort = new AbortController();
manualAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.request("Svc", "abort", undefined, { signal: manualAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const timeoutAbort = new AbortController();
timeoutAbort.abort(new DOMException("timed out", "TimeoutError"));
result = await client.request("Svc", "timeoutSignal", undefined, { signal: timeoutAbort.signal });
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => { throw new DOMException("timed out", "TimeoutError"); };
result = await client.request("Svc", "timeoutError");
assert.equal(result.status, 5);
expectSeen(5);
const requestInterceptorClient = new Client("http://example.test");
let requestInterceptorSeen;
requestInterceptorClient.addRequestInterceptor((_service, _method, state, dto) => {
state.token = "abc";
requestInterceptorSeen = dto;
});
requestInterceptorClient.addResponseInterceptor((_service, _method, value) => ({ ...value, msg: "intercepted" }));
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
assert.deepEqual(payload.state, { token: "abc" });
return json({ status: 0 });
};
result = await requestInterceptorClient.request("Svc", "interceptors", { value: 1 });
assert.deepEqual(requestInterceptorSeen, { value: 1 });
assert.equal(result.msg, "intercepted");
const stateClient = new Client("http://example.test");
stateClient.setState({ shared: "global", globalOnly: "yes" });
const stateContexts = new Map();
const nativeResponses = new Map();
const payloads = new Map();
let releaseFirst;
const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; });
let firstInterceptorEntered;
const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; });
let firstStateReference;
stateClient.addRequestInterceptor(async (_service, method, state, dto) => {
state.interceptor = dto.id;
if (method === "first") {
firstStateReference = state;
firstInterceptorEntered();
await firstMayContinue;
}
state.completed = dto.id;
});
stateClient.addResponseInterceptor((_service, method, _value, context) => {
stateContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
payloads.set(payload.methodName, payload);
const response = json({ status: 0, data: payload.methodName });
nativeResponses.set(payload.methodName, response);
return response;
};
const firstOverride = { shared: "first", requestOnly: "one" };
const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride });
await firstDidEnter;
firstOverride.shared = "mutated outside";
const secondRequest = stateClient.request("Svc", "second", { id: "two" }, {
state: { shared: "second", requestOnly: "two" },
});
assert.equal((await secondRequest).status, 0);
releaseFirst();
assert.equal((await firstRequest).status, 0);
assert.deepEqual(payloads.get("first").state, {
shared: "first",
globalOnly: "yes",
requestOnly: "one",
interceptor: "one",
completed: "one",
});
assert.deepEqual(payloads.get("second").state, {
shared: "second",
globalOnly: "yes",
requestOnly: "two",
interceptor: "two",
completed: "two",
});
firstStateReference.shared = "mutated after snapshot";
for (const method of ["first", "second"]) {
const context = stateContexts.get(method);
assert.deepEqual(context.requestState, payloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
assert.equal(context.response, nativeResponses.get(method));
assert.ok(context.response instanceof Response);
}
assert.equal(stateContexts.get("first").requestState.shared, "first");
const throwingState = Object.defineProperty({}, "token", {
enumerable: true,
get() { throw new Error("state read failed"); },
});
const stateFailureClient = new Client("http://example.test");
let stateFailureSeen = [];
stateFailureClient.addResponseInterceptor((_service, _method, value, context) => {
stateFailureSeen.push([value.status, context.requestState]);
});
globalThis.fetch = async () => assert.fail("fetch must not run for state failure");
result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]);
const snapshotFailureClient = new Client("http://example.test");
snapshotFailureClient.addRequestInterceptor((_service, _method, state) => {
Object.defineProperty(state, "broken", {
enumerable: true,
get() { throw new Error("snapshot read failed"); },
});
});
globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure");
result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure");
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
const contextClient = new Client("http://example.test");
contextClient.setState({ base: "global" });
const outcomeContexts = new Map();
contextClient.addResponseInterceptor((_service, method, _value, context) => {
outcomeContexts.set(method, context);
});
contextClient.addRequestInterceptor((_service, method, state) => {
state.method = method;
if (method === "requestHookFailure") throw new Error("request context hook");
});
globalThis.fetch = async () => assert.fail("fetch must not run before serialization");
const contextCyclic = {};
contextCyclic.self = contextCyclic;
result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("serializeContext").requestState, {
base: "request",
method: "serializeContext",
});
assert.equal(outcomeContexts.get("serializeContext").response, undefined);
result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, {
base: "global",
request: "hook",
method: "requestHookFailure",
});
assert.equal(outcomeContexts.get("requestHookFailure").response, undefined);
globalThis.fetch = async () => { throw new TypeError("offline"); };
result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("networkContext").requestState, {
base: "global",
request: "network",
method: "networkContext",
});
assert.equal(outcomeContexts.get("networkContext").response, undefined);
const bodyReadResponse = new Response(new ReadableStream({
pull(controller) { controller.error(new Error("context body read failed")); },
}));
globalThis.fetch = async () => bodyReadResponse;
result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, {
base: "global",
request: "body",
method: "bodyReadContext",
});
assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse);
const failingRequestInterceptor = new Client("http://example.test");
let normalized = [];
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
failingRequestInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await failingRequestInterceptor.request("Svc", "hook");
assert.equal(result.status, 1);
assert.match(result.msg, /request hook/);
assert.deepEqual(normalized, [1]);
const failingResponseInterceptor = new Client("http://example.test");
normalized = [];
failingResponseInterceptor.addResponseInterceptor(() => { throw new Error("response hook"); });
failingResponseInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
globalThis.fetch = async () => json({ status: 2, code: 4003, msg: "original" });
result = await failingResponseInterceptor.request("Svc", "responseHook");
assert.equal(result.status, 1);
assert.match(result.msg, /response hook/);
assert.deepEqual(normalized, [1]);
const cyclic = {};
cyclic.self = cyclic;
globalThis.fetch = async () => assert.fail("fetch must not run for serialization failure");
result = await client.request("Svc", "serialize", cyclic);
assert.equal(result.status, 1);
assert.match(result.msg, /serialize/i);
expectSeen(1);
globalThis.fetch = async () => json({ status: 2, code: 4002, msg: "stream business" }, 422);
result = await client.stream("Svc", "streamBusiness", undefined, () => {});
assert.deepEqual(result, { status: 2, code: 4002, msg: "stream business" });
expectSeen(2);
globalThis.fetch = async () => json({ status: 0 });
result = await client.stream("Svc", "wrongMedia", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /x-ndjson/);
expectSeen(1);
const bytes = new TextEncoder().encode('{"text":"你好"}\r\n\n{"n":2}');
const split = bytes.indexOf(0xe5) + 1;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, split));
controller.enqueue(bytes.slice(split));
controller.close();
},
}), { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } });
const messages = [];
result = await client.stream("Svc", "valid", undefined, value => messages.push(value));
assert.equal(result.status, 0);
assert.deepEqual(messages, [{ text: "你好" }, { n: 2 }]);
expectSeen(0);
let malformedCancelled = false;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(new TextEncoder().encode("bad\n")); },
cancel() { malformedCancelled = true; },
}), { headers: { "Content-Type": "application/x-ndjson" } });
cleanupCheck = () => malformedCancelled;
result = await client.stream("Svc", "malformed", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /line 1/);
assert.equal(malformedCancelled, true);
expectSeen(1);
globalThis.fetch = async () => ndjson('{"n":1}\n');
result = await client.stream("Svc", "callback", undefined, () => { throw new Error("callback boom"); });
assert.equal(result.status, 1);
assert.match(result.msg, /callback boom/);
expectSeen(1);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("read boom")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "read", undefined, () => {});
assert.equal(result.status, 4);
assert.match(result.msg, /read boom/);
expectSeen(4);
globalThis.fetch = async () => ndjson(new Uint8Array([0xff, 0x0a]));
result = await client.stream("Svc", "utf8", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /UTF-8/);
expectSeen(1);
globalThis.fetch = async () => ndjson("");
result = await client.stream("Svc", "emptyStream", undefined, () => assert.fail("empty stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
globalThis.fetch = async () => new Response(null, {
headers: { "Content-Type": "application/x-ndjson" },
});
result = await client.stream("Svc", "nullBody", undefined, () => assert.fail("null stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
const streamAbort = new AbortController();
streamAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.stream("Svc", "abortStream", undefined, () => {}, { signal: streamAbort.signal });
assert.equal(result.status, 4);
expectSeen(4);
const readAbort = new AbortController();
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
readAbort.signal.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true });
readAbort.abort();
},
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "readAbort", undefined, () => {}, { signal: readAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const readTimeout = new AbortController();
readTimeout.abort(new DOMException("timed out", "TimeoutError"));
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new DOMException("aborted", "AbortError")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "timeoutStream", undefined, () => {}, { signal: readTimeout.signal });
assert.equal(result.status, 5);
expectSeen(5);
const streamHookClient = new Client("http://example.test");
normalized = [];
streamHookClient.addRequestInterceptor(() => { throw new Error("stream hook"); });
streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
assert.equal(result.status, 1);
assert.deepEqual(normalized, [1]);
const streamStateClient = new Client("http://example.test");
streamStateClient.setState({ shared: "global", globalOnly: "stream" });
const streamContexts = new Map();
const streamResponses = new Map();
const streamPayloads = new Map();
streamStateClient.addRequestInterceptor((_service, method, state) => {
state.interceptor = method;
if (method === "streamHookFailure") throw new Error("stream context hook");
});
streamStateClient.addResponseInterceptor((_service, method, _value, context) => {
streamContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
streamPayloads.set(payload.methodName, payload);
if (payload.methodName === "streamNetwork") throw new TypeError("stream offline");
const response = payload.methodName === "streamWrongMedia"
? json({ status: 0 })
: payload.methodName === "streamReadFailure"
? new Response(new ReadableStream({
pull(controller) { controller.error(new Error("stream context read failed")); },
}), { headers: { "Content-Type": "application/x-ndjson" } })
: ndjson('{"n":1}\n');
streamResponses.set(payload.methodName, response);
return response;
};
result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, {
state: { shared: "request", requestOnly: "success" },
});
assert.equal(result.status, 0);
result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, {
state: { shared: "wrong-media" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, {
state: { shared: "network" },
});
assert.equal(result.status, 4);
result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, {
state: { shared: "read" },
});
assert.equal(result.status, 4);
const streamCyclic = {};
streamCyclic.self = streamCyclic;
result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, {
state: { shared: "serialize" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, {
state: { shared: "hook" },
});
assert.equal(result.status, 1);
for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) {
const context = streamContexts.get(method);
assert.deepEqual(context.requestState, streamPayloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
}
assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess"));
assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia"));
assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure"));
assert.equal(streamContexts.get("streamNetwork").response, undefined);
assert.deepEqual(streamContexts.get("streamSerialize").requestState, {
shared: "serialize",
globalOnly: "stream",
interceptor: "streamSerialize",
});
assert.equal(streamContexts.get("streamSerialize").response, undefined);
assert.deepEqual(streamContexts.get("streamHookFailure").requestState, {
shared: "hook",
globalOnly: "stream",
interceptor: "streamHookFailure",
});
assert.equal(streamContexts.get("streamHookFailure").response, undefined);
`
scriptPath := filepath.Join(dir, "behavior.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(node, "--experimental-strip-types", scriptPath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("TypeScript client behavior failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientStrictTypecheck(t *testing.T) {
tsc, err := exec.LookPath("tsc")
if err != nil {
t.Skip("tsc is not installed")
}
dir := t.TempDir()
path := filepath.Join(dir, "client.ts")
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
usagePath := filepath.Join(dir, "usage.ts")
const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client";
const requestOptions: RequestOptions = { state: { token: "request" } };
const streamOptions: StreamOptions = { state: { token: "stream" } };
const legacy: ResponseInterceptor = (_service, _method, result) => result;
const legacyConsumer: (service: string, method: string, result: import("./client").result<any>) => unknown = legacy;
const current: ContextResponseInterceptor = (_service, _method, result, context) => {
const token: string | undefined = context.requestState.token;
const response: Response | undefined = context.response;
void token;
void response;
return result;
};
void legacyConsumer;
const context = {} as ResponseContext;
// @ts-expect-error requestState is readonly
context.requestState.token = "changed";
const client = new Client("/");
client.addResponseInterceptor(legacy);
client.addResponseInterceptor(current);
void client.request("Svc", "method", undefined, requestOptions);
void client.stream("Svc", "method", undefined, () => {}, streamOptions);
`
if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(tsc,
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientSimplicityGate(t *testing.T) {
source := templateTs{}.genClientTemplate() + templateTs{}.genServiceTemplate()
for _, forbidden := range []string{"RpcError", "httpStatus", "result.aborted", "ClientErrorCode", "setTimeout"} {
if strings.Contains(source, forbidden) {
t.Errorf("generated TypeScript templates contain forbidden %q", forbidden)
}
}
if !strings.Contains(source, "Promise<result<void>>") {
t.Fatal("stream methods must resolve result<void>")
}
}
+41 -19
View File
@@ -9,11 +9,19 @@ import (
)
type Fun struct {
methods map[string]methodInfo
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
guards []*any // 全局 Guard
serviceGuards map[string][]*any // 服务级 Guard,按服务名
methods map[string]methodInfo
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配)
wildcardRoutes map[string][]wildcardRoute
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
guards []*any // 全局 Guard
serviceGuards map[string][]*any // 服务级 Guard,按服务名
}
// wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册):
// prefix 如 "/image",匹配 prefix 与 prefix 下任意子路径
type wildcardRoute struct {
prefix string
handler RouteHandler
}
var (
@@ -33,10 +41,11 @@ type methodInfo struct {
func New() *Fun {
f := &Fun{
methods: map[string]methodInfo{},
routes: map[string]RouteHandler{},
boxes: &sync.Map{},
serviceGuards: map[string][]*any{},
methods: map[string]methodInfo{},
routes: map[string]RouteHandler{},
wildcardRoutes: map[string][]wildcardRoute{},
boxes: &sync.Map{},
serviceGuards: map[string][]*any{},
}
if fun == nil {
fun = f
@@ -59,16 +68,7 @@ func GetFun() *Fun {
//
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
func (f *Fun) BindService(service any, guardList ...Guard) {
t := reflect.TypeOf(service)
// 必须是指针指向的结构体,匿名类型无法注册
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
panic("fun: BindService requires a pointer to a struct")
}
name := t.Elem().Name()
if name == "" {
panic("fun: BindService requires a named type")
}
t, name := serviceType(service)
boxWired(service, f)
serviceGuards := make([]*any, 0, len(guardList))
@@ -77,7 +77,29 @@ func (f *Fun) BindService(service any, guardList ...Guard) {
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
}
f.serviceGuards[name] = serviceGuards
f.bindServiceMethods(t, name)
}
// BindServiceForGen registers service metadata for code generation without
// constructing runtime dependencies or guards.
func (f *Fun) BindServiceForGen(service any) {
t, name := serviceType(service)
f.bindServiceMethods(t, name)
}
func serviceType(service any) (reflect.Type, string) {
t := reflect.TypeOf(service)
if t == nil || t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
panic("fun: BindService requires a pointer to a struct")
}
name := t.Elem().Name()
if name == "" {
panic("fun: BindService requires a named type")
}
return t, name
}
func (f *Fun) bindServiceMethods(t reflect.Type, name string) {
for m := range t.Methods() {
m := m
// Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法
+1
View File
@@ -93,6 +93,7 @@ func TestCheckDtoRequired(t *testing.T) {
}
func TestGenCode(t *testing.T) {
isolateGeneratorGlobals(t)
GetFun().BindService(&TestSvc{})
SetOutput(t.TempDir())
GenCode(GenGo{}, GenTs{})
+13 -3
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"text/template"
)
@@ -33,8 +34,8 @@ type genMethod struct {
isStream bool
}
// serviceGroups 按服务名分组已注册方法
func (f *Fun) serviceGroups() map[string][]*genMethod {
// serviceGroups 按服务名和方法名稳定分组已注册方法
func (f *Fun) serviceGroups() []*genSvc {
groups := map[string][]*genMethod{}
for key, m := range f.methods {
parts := strings.SplitN(key, ".", 2)
@@ -47,7 +48,14 @@ func (f *Fun) serviceGroups() map[string][]*genMethod {
isStream: m.isStream,
})
}
return groups
services := make([]*genSvc, 0, len(groups))
for name, methods := range groups {
sort.Slice(methods, func(i, j int) bool { return methods[i].name < methods[j].name })
services = append(services, &genSvc{name: name, methods: methods})
}
sort.Slice(services, func(i, j int) bool { return services[i].name < services[j].name })
return services
}
type genType struct {
@@ -79,6 +87,7 @@ type genServiceType struct {
GenMethodTypeList []*genMethodType
GenImport []*genImportType
IsIncludeProxy bool
IsIncludeRequest bool
IsIncludeStream bool
}
@@ -103,6 +112,7 @@ func deduplicateServiceImports(imports []*genImportType) []*genImportType {
result = append(result, imp)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result
}
+3 -3
View File
@@ -132,13 +132,13 @@ func (ctx GenGo) genDefaultService() {
f := GetFun()
genContext := genType{GenServiceList: []*genServiceType{}}
for svcName, methods := range f.serviceGroups() {
for _, svc := range f.serviceGroups() {
serviceContext := &genServiceType{
ServiceName: svcName,
ServiceName: svc.name,
GenMethodTypeList: []*genMethodType{},
}
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
ctx.genService(&genSvc{name: svcName, methods: methods}, serviceContext)
ctx.genService(svc, serviceContext)
}
genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName())
}
+216
View File
@@ -0,0 +1,216 @@
package fun
import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
type AlphaGenDto struct {
Value string
}
type ZebraGenDto struct {
Value string
}
type AlphaGenSvc struct{}
func (*AlphaGenSvc) Zebra(dto ZebraGenDto) (AlphaGenDto, error) { return AlphaGenDto{}, nil }
func (*AlphaGenSvc) Alpha(dto AlphaGenDto) (ZebraGenDto, error) { return ZebraGenDto{}, nil }
func (*AlphaGenSvc) Ping() error { return nil }
type MixedGenSvc struct{}
func (*MixedGenSvc) Request() (string, error) { return "", nil }
func (*MixedGenSvc) Stream() (*Stream, error) { return &Stream{}, nil }
type ZebraGenSvc struct{}
func (*ZebraGenSvc) Watch() (*Stream, error) { return &Stream{}, nil }
type GenOnlyDependency struct{}
func (*GenOnlyDependency) New() { panic("generation initialized a runtime dependency") }
type DependencyGenSvc struct {
Dependency *GenOnlyDependency
}
func (*DependencyGenSvc) Ping() error { return nil }
func isolateGeneratorGlobals(t *testing.T) {
t.Helper()
oldFun, oldDirectory := fun, directory
fun = nil
directory = "./gen"
t.Cleanup(func() {
fun = oldFun
directory = oldDirectory
})
}
func generatedFiles(t *testing.T, root string) map[string]string {
t.Helper()
files := map[string]string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
files[rel] = string(body)
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}
func TestBindServiceForGenDoesNotInitializeDependencies(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
f.BindServiceForGen(&DependencyGenSvc{})
if _, ok := f.methods["DependencyGenSvc.Ping"]; !ok {
t.Fatal("generation-only service method was not registered")
}
if _, ok := f.boxes.Load(reflect.TypeFor[*GenOnlyDependency]()); ok {
t.Fatal("generation-only registration stored a runtime dependency")
}
}
func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
f.BindService(&ZebraGenSvc{})
f.BindService(&MixedGenSvc{})
f.BindService(&AlphaGenSvc{})
SetOutput(t.TempDir())
GenCode(GenTs{})
read := func(name string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(getDirectory(), "ts", name))
if err != nil {
t.Fatal(err)
}
return string(body)
}
client := read("client.ts")
for _, want := range []string{
`state?: Record<string, string>;`,
`export type ResponseContext = {`,
`readonly requestState: Readonly<Record<string, string>>;`,
`readonly response?: Response;`,
`state = { ...this.state, ...options?.state };`,
`interceptor(serviceName, methodName, current, context)`,
} {
if !strings.Contains(client, want) {
t.Errorf("client.ts missing %q:\n%s", want, client)
}
}
alpha := read("alphaGenSvc.ts")
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` {
t.Fatalf("unexpected request-only imports: %s", first)
}
for _, want := range []string{
`async ping(options?: RequestOptions): Promise<result<void>>`,
`this.client.request<void>("alphaGenSvc", "ping", undefined, options)`,
`async alpha(dto:alphaGenDto, options?: RequestOptions): Promise<result<zebraGenDto>>`,
`this.client.request<zebraGenDto>("alphaGenSvc", "alpha", dto, options)`,
} {
if !strings.Contains(alpha, want) {
t.Errorf("alphaGenSvc.ts missing %q:\n%s", want, alpha)
}
}
if strings.Index(alpha, `import type alphaGenDto`) > strings.Index(alpha, `import type zebraGenDto`) {
t.Fatalf("DTO imports are not sorted:\n%s", alpha)
}
if strings.Index(alpha, `async alpha`) > strings.Index(alpha, `async ping`) ||
strings.Index(alpha, `async ping`) > strings.Index(alpha, `async zebra`) {
t.Fatalf("methods are not sorted:\n%s", alpha)
}
stream := read("zebraGenSvc.ts")
if first := strings.SplitN(stream, "\n", 2)[0]; first != `import { Client, type result, type StreamOptions } from "./client";` {
t.Fatalf("unexpected stream-only imports: %s", first)
}
for _, want := range []string{
`async watch(onMessage: (data: any) => unknown, options?: StreamOptions): Promise<result<void>>`,
`this.client.stream<any>("zebraGenSvc", "watch", undefined, onMessage, options)`,
} {
if !strings.Contains(stream, want) {
t.Errorf("zebraGenSvc.ts missing %q:\n%s", want, stream)
}
}
mixed := read("mixedGenSvc.ts")
if first := strings.SplitN(mixed, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions, type StreamOptions } from "./client";` {
t.Fatalf("unexpected mixed imports: %s", first)
}
}
func TestGeneratedSourcesAreDeterministic(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
f.BindService(&ZebraGenSvc{})
f.BindService(&AlphaGenSvc{})
f.BindService(&MixedGenSvc{})
root := t.TempDir()
SetOutput(root)
GenCode(GenGo{}, GenTs{})
first := generatedFiles(t, root)
GenCode(GenGo{}, GenTs{})
second := generatedFiles(t, root)
if len(first) != len(second) {
t.Fatalf("generated file count changed: %d != %d", len(first), len(second))
}
for name, body := range first {
if second[name] != body {
t.Errorf("generated file changed between runs: %s", name)
}
}
tsFun := first[filepath.Join("ts", "fun.ts")]
positions := []int{
strings.Index(tsFun, `import alphaGenSvc`),
strings.Index(tsFun, `import mixedGenSvc`),
strings.Index(tsFun, `import zebraGenSvc`),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("TypeScript services are not sorted:\n%s", tsFun)
}
goFun := first[filepath.Join("go", "fun.go")]
positions = []int{
strings.Index(goFun, "AlphaGenSvc *AlphaGenSvc"),
strings.Index(goFun, "MixedGenSvc *MixedGenSvc"),
strings.Index(goFun, "ZebraGenSvc *ZebraGenSvc"),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("Go services are not sorted:\n%s", goFun)
}
goService := first[filepath.Join("go", "alpha_gen_svc.go")]
positions = []int{
strings.Index(goService, "func (ctx *AlphaGenSvc) Alpha("),
strings.Index(goService, "func (ctx *AlphaGenSvc) Ping("),
strings.Index(goService, "func (ctx *AlphaGenSvc) Zebra("),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("Go methods are not sorted:\n%s", goService)
}
}
+5 -3
View File
@@ -56,6 +56,7 @@ func (ctx GenTs) genService(svc *genSvc, serviceContext *genServiceType) {
argsText += ",dto"
nestedImports = append(nestedImports, ctx.genStruct(gm.dtoType))
}
serviceContext.IsIncludeRequest = true
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
MethodName: firstLetterToLower(gm.name),
ReturnValueText: returnValueText,
@@ -83,6 +84,7 @@ func (ctx GenTs) genService(svc *genSvc, serviceContext *genServiceType) {
nestedImports = ctx.genReturnTypes(returnType, nestedImports)
}
} else {
serviceContext.IsIncludeRequest = true
t := firstLetterToLower(ctx.typeToTemplateType(returnType))
if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t)
@@ -151,13 +153,13 @@ func (ctx GenTs) genDefaultService() {
f := GetFun()
genContext := genType{GenServiceList: []*genServiceType{}}
for svcName, methods := range f.serviceGroups() {
for _, svc := range f.serviceGroups() {
serviceContext := &genServiceType{
ServiceName: firstLetterToLower(svcName),
ServiceName: firstLetterToLower(svc.name),
GenMethodTypeList: []*genMethodType{},
}
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
ctx.genService(&genSvc{name: svcName, methods: methods}, serviceContext)
ctx.genService(svc, serviceContext)
}
genCode(ctx.template.genClientTemplate(), "client", nil, ctx.getName())
genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName())
+11 -3
View File
@@ -7,19 +7,27 @@ import (
"fmt"
"reflect"
"runtime/debug"
"strings"
"github.com/valyala/fasthttp"
)
// handle 处理 HTTP 请求:先匹配自定义路由(BindRoute),未命中走 /cell RPC
// handle 处理 HTTP 请求:先匹配自定义路由(BindRoute 精确/通配),未命中走 /cell RPC
func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
ctx := &Ctx{RequestCtx: fastCtx}
defer f.handlePanic(ctx)
if handler, ok := f.routes[string(fastCtx.Method())+" "+string(fastCtx.Path())]; ok {
f.handleRoute(fastCtx, handler)
method, path := string(fastCtx.Method()), string(fastCtx.Path())
if handler, ok := f.routes[method+" "+path]; ok {
f.handleRoute(fastCtx, handler, "")
return
}
for _, r := range f.wildcardRoutes[method] {
if path == r.prefix || strings.HasPrefix(path, r.prefix+"/") {
f.handleRoute(fastCtx, r.handler, strings.TrimPrefix(path, r.prefix+"/"))
return
}
}
if ctx.path() != "/cell" {
ctx.setStatusCode(fasthttp.StatusNotFound)
+22 -6
View File
@@ -16,10 +16,12 @@ type RouteHandler func(ctx *RouteCtx) error
// RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先),
// 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。
// Wildcard 为通配符路由(/prefix/*)匹配到的剩余路径(不含前导 "/")。
// 独立于服务内嵌的 Ctx:后者辅助方法刻意全小写以防混入 RPC 方法集,路由不复用该类型。
type RouteCtx struct {
RequestCtx *fasthttp.RequestCtx
Data map[string]string
Wildcard string
}
// Param 取查询/表单参数,不存在返回空串
@@ -27,10 +29,12 @@ func (c *RouteCtx) Param(name string) string {
return c.Data[name]
}
// BindRoute 注册自定义路由(方法大小写不敏感 + 精确路径匹配),用于 GET 直链、
// 健康检查、支付回调等无法走 POST /cell RPC 的场景。
// BindRoute 注册自定义路由(方法大小写不敏感path 精确匹配,或以 "/*" 结尾做前缀通配),
// 用于 GET 直链、健康检查、支付回调等无法走 POST /cell RPC 的场景。
//
// - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册
// - 通配符形式如 "/image/*":匹配 "/image/a/b.png" 等任意子路径,
// 匹配到的剩余路径(去掉前导 "/",如 "a/b.png")经 RouteCtx.Wildcard 取出
// - 同一 方法+路径 重复注册直接 panic
// - 与 BindService 一致,需在 Start 前完成注册(启动阶段单线程)
func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
@@ -44,9 +48,21 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
if !strings.HasPrefix(path, "/") {
panic(fmt.Sprintf("fun: BindRoute path %q must start with '/'", path))
}
if path == "/cell" {
if path == "/cell" || path == "/cell/*" {
panic("fun: /cell is reserved for RPC")
}
if prefix, ok := strings.CutSuffix(path, "/*"); ok {
if prefix == "" || strings.HasSuffix(prefix, "/") {
panic(fmt.Sprintf("fun: BindRoute wildcard path %q invalid (no trailing '/' allowed before /*)", path))
}
for _, r := range f.wildcardRoutes[method] {
if r.prefix == prefix {
panic(fmt.Sprintf("fun: route %s %s/* already bound", method, prefix))
}
}
f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, handler: handler})
return
}
key := method + " " + path
if _, exists := f.routes[key]; exists {
panic(fmt.Sprintf("fun: route %s already bound", key))
@@ -55,8 +71,8 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
}
// handleRoute 执行自定义路由:合并查询与表单参数(application/x-www-form-urlencoded),
// 处理器返回 error 时按统一 Result 格式输出错误响应
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler) {
// 处理器返回 error 时按统一 Result 格式输出错误响应;wildcard 为通配路由匹配的剩余路径
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wildcard string) {
data := map[string]string{}
fastCtx.QueryArgs().VisitAll(func(k, v []byte) {
data[string(k)] = string(v)
@@ -64,7 +80,7 @@ func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler) {
fastCtx.PostArgs().VisitAll(func(k, v []byte) {
data[string(k)] = string(v)
})
if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data}); err != nil {
if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil {
(&Ctx{RequestCtx: fastCtx}).sendError(err)
}
}
+109
View File
@@ -0,0 +1,109 @@
package fun
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"github.com/valyala/fasthttp"
)
type ProtocolStreamSvc struct{}
func (*ProtocolStreamSvc) Empty() (*Stream, error) {
stream := &Stream{}
go stream.Close()
return stream, nil
}
func (*ProtocolStreamSvc) Before() (*Stream, error) {
return nil, errors.New("before stream")
}
func (*ProtocolStreamSvc) Business() (*Stream, error) {
return nil, Error(4201, "business before stream")
}
func serveFun(t *testing.T, f *Fun) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- fasthttp.Serve(listener, f.handle) }()
t.Cleanup(func() {
_ = listener.Close()
<-done
})
return "http://" + listener.Addr().String()
}
func streamPost(t *testing.T, url, method string) (*http.Response, []byte) {
t.Helper()
body := strings.NewReader(`{"serviceName":"ProtocolStreamSvc","methodName":"` + method + `"}`)
request, err := http.NewRequest(http.MethodPost, url+"/cell", body)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", "application/json")
request.Close = true
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return response, data
}
func TestServerStreamContentTypeAndEmptyStream(t *testing.T) {
f := New()
f.BindService(&ProtocolStreamSvc{})
response, body := streamPost(t, serveFun(t, f), "Empty")
if got := response.Header.Get("Content-Type"); got != "application/x-ndjson" {
t.Fatalf("Content-Type = %q", got)
}
if len(body) != 0 {
t.Fatalf("empty stream returned %q", body)
}
}
func TestServerStreamSetupErrorsUseResultProtocol(t *testing.T) {
f := New()
f.BindService(&ProtocolStreamSvc{})
url := serveFun(t, f)
for _, test := range []struct {
method string
status uint8
code uint16
msg string
}{
{method: "Before", status: 1, msg: "before stream"},
{method: "Business", status: 2, code: 4201, msg: "business before stream"},
} {
t.Run(test.method, func(t *testing.T) {
response, body := streamPost(t, url, test.method)
if strings.HasPrefix(response.Header.Get("Content-Type"), "application/x-ndjson") {
t.Fatalf("setup error used stream Content-Type: %q", response.Header.Get("Content-Type"))
}
var result Result[any]
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("invalid Result body %q: %v", body, err)
}
if result.Status != test.status || result.Msg == nil || *result.Msg != test.msg {
t.Fatalf("unexpected Result: %+v", result)
}
if test.code != 0 && (result.Code == nil || *result.Code != test.code) {
t.Fatalf("code = %v, want %d", result.Code, test.code)
}
})
}
}
+503 -59
View File
@@ -11,23 +11,147 @@ func (ctx templateTs) genClientTemplate() string {
status: number;
};
export type resultStatus = 0 | 1 | 2 | 4 | 5;
// 0 success; 1 framework/client protocol error; 2 business error; 4 external request error; 5 external timeout
export type RequestOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
};
export type StreamOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
};
export type RequestInterceptor = (
serviceName: string,
methodName: string,
dto: any
state: Record<string, string>,
dto?: any
) => Promise<void> | void;
export type ResponseContext = {
readonly requestState: Readonly<Record<string, string>>;
readonly response?: Response;
};
export type ResponseInterceptor = (
serviceName: string,
methodName: string,
result: result<any>
) => Promise<void> | void;
) => Promise<result<any> | void> | result<any> | void;
export type ContextResponseInterceptor = (
serviceName: string,
methodName: string,
result: result<any>,
context: ResponseContext
) => Promise<result<any> | void> | result<any> | void;
function messageOf(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return "unknown error";
}
function failure(status: resultStatus, msg: string): result<any> {
return { status, msg };
}
function isTimeout(error: unknown, signal?: AbortSignal): boolean {
const errorName = error !== null && typeof error === "object"
? (error as { name?: unknown }).name
: undefined;
const reason = signal?.reason;
const reasonName = reason !== null && typeof reason === "object"
? (reason as { name?: unknown }).name
: undefined;
return errorName === "TimeoutError" || reasonName === "TimeoutError";
}
function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "External stream request" : "External request";
return failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `);
}
function isResult(value: unknown): value is result<any> {
return value !== null && typeof value === "object" &&
typeof (value as { status?: unknown }).status === "number";
}
function mediaType(response: Response): string {
return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase();
}
function excerpt(text: string, limit = 180): string {
const value = text.replace(/\s+/g, " ").trim();
return value.length <= limit ? value : ` + "`${value.slice(0, limit)}...`" + `;
}
function externalFailure(response: Response, detail?: string): result<any> {
const timeout = response.status === 408 || response.status === 504;
const statusText = response.statusText || (timeout ? "timeout" : "request failed");
const suffix = detail ? ` + "`: ${detail}`" + ` : "";
return failure(timeout ? 5 : 4, ` + "`HTTP ${response.status} ${statusText}${suffix}`" + `);
}
function responseReadFailure(
error: unknown,
response: Response,
signal: AbortSignal | undefined,
stream: boolean
): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "Stream" : "Response body";
return response.ok
? failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `)
: externalFailure(response, ` + "`response body failed: ${messageOf(error)}`" + `);
}
function parseResult(response: Response, text: string): result<any> {
const body = text.trim();
if (!body) {
return response.ok
? failure(1, "Empty response body")
: externalFailure(response);
}
let value: unknown;
try {
value = JSON.parse(body);
} catch {
if (!response.ok) return externalFailure(response, excerpt(body));
const type = mediaType(response);
if (type === "text/html" || /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)) {
return failure(1, ` + "`Unexpected HTML response: ${excerpt(body)}`" + `);
}
return failure(1, ` + "`Invalid JSON response: ${excerpt(body)}`" + `);
}
if (!isResult(value)) {
return response.ok
? failure(1, "Invalid fun response")
: externalFailure(response, "invalid fun response");
}
return value;
}
export class Client {
private url: string;
private state: Record<string, string> = {};
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
private responseInterceptors: ContextResponseInterceptor[] = [];
constructor(url: string) {
this.url = url.replace(/\/+$/, "");
@@ -37,79 +161,399 @@ export class Client {
this.state = state;
}
addRequestInterceptor(i: RequestInterceptor) {
this.requestInterceptors.push(i);
addRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(i: ResponseInterceptor) {
this.responseInterceptors.push(i);
addResponseInterceptor(interceptor: ResponseInterceptor): void;
addResponseInterceptor(interceptor: ContextResponseInterceptor): void;
addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) {
this.responseInterceptors.push(interceptor as ContextResponseInterceptor);
}
async request<T>(serviceName: string, methodName: string, dto?: any): Promise<result<T>> {
for (const i of this.requestInterceptors) {
await i(serviceName, methodName, dto);
private async interceptResponse(
serviceName: string,
methodName: string,
initial: result<any>,
requestState: Readonly<Record<string, string>>,
response?: Response
): Promise<result<any>> {
let current = initial;
const context: ResponseContext = Object.freeze(
response === undefined ? { requestState } : { requestState, response }
);
for (const interceptor of this.responseInterceptors) {
try {
const replaced = await interceptor(serviceName, methodName, current, context);
if (replaced) current = replaced;
} catch (error) {
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
}
}
const res = await fetch(this.url + "/cell", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(this.state).length ? { state: this.state } : {}) }),
});
const out = (await res.json()) as result<T>;
const anyResult: result<any> = {
id: out.id,
code: out.code,
data: out.data,
msg: out.msg,
status: out.status,
};
for (const i of this.responseInterceptors) {
await i(serviceName, methodName, anyResult);
return current;
}
private async interceptRequest(
serviceName: string,
methodName: string,
state: Record<string, string>,
dto: any
): Promise<void> {
for (const interceptor of this.requestInterceptors) {
await interceptor(serviceName, methodName, state, dto);
}
return out;
}
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
return Object.freeze({ ...state });
}
async request<T>(
serviceName: string,
methodName: string,
dto?: any,
options?: RequestOptions
): Promise<result<T>> {
let state: Record<string, string>;
try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
) as result<T>;
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
let body: string;
try {
const serialized = JSON.stringify({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let output: result<any>;
let response: Response | undefined;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: options?.signal,
});
try {
output = parseResult(response, await response.text());
} catch (error) {
output = responseReadFailure(error, response, options?.signal, false);
}
} catch (error) {
output = requestFailure(error, options?.signal, false);
}
return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result<T>;
}
async stream<T>(
serviceName: string,
methodName: string,
dto: any | undefined,
onMessage: (data: T) => void
): Promise<void> {
for (const i of this.requestInterceptors) {
await i(serviceName, methodName, dto);
onMessage: (data: T) => unknown,
options?: StreamOptions
): Promise<result<void>> {
let state: Record<string, string>;
try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
const res = await fetch(this.url + "/cell", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(this.state).length ? { state: this.state } : {}) }),
});
if (!res.ok) return;
const anyResult: result<any> = { status: 0 };
for (const i of this.responseInterceptors) {
await i(serviceName, methodName, anyResult);
try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
);
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
);
}
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
let body: string;
try {
const serialized = JSON.stringify({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
);
}
let response: Response;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: options?.signal,
});
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
requestFailure(error, options?.signal, true),
requestState
);
}
if (!response.ok) {
let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
return await this.interceptResponse(
serviceName,
methodName,
parseResult(response, text),
requestState,
response
);
}
if (mediaType(response) !== "application/x-ndjson") {
let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const rpcResult = parseResult(response, text);
return await this.interceptResponse(
serviceName,
methodName,
rpcResult.status === 0
? failure(1, "Expected application/x-ndjson response")
: rpcResult,
requestState,
response
);
}
if (!response.body) {
return await this.interceptResponse(
serviceName,
methodName,
{ status: 0 },
requestState,
response
);
}
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = response.body.getReader();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const decoder = new TextDecoder("utf-8", { fatal: true });
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const payload = line.trim();
if (!payload) continue;
const data = JSON.parse(payload) as T;
onMessage(data);
let lineNumber = 0;
let failed: result<any> | undefined;
let cause: unknown;
const emitLine = async (line: string) => {
const payload = line.replace(/\r$/, "").trim();
if (!payload) return;
let data: T;
try {
data = JSON.parse(payload) as T;
} catch (error) {
failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `);
cause = error;
return;
}
try {
await onMessage(data);
} catch (error) {
failed = failure(1, ` + "`Stream callback failed: ${messageOf(error)}`" + `);
cause = error;
}
};
try {
for (;;) {
let part: ReadableStreamReadResult<Uint8Array>;
try {
part = await reader.read();
} catch (error) {
cause = error;
if (isTimeout(error, options?.signal)) {
failed = failure(5, "Stream timed out");
} else if (options?.signal?.aborted === true) {
failed = failure(4, "Stream aborted");
} else {
failed = failure(4, ` + "`Stream read failed: ${messageOf(error)}`" + `);
}
break;
}
if (part.done) break;
try {
buffer += decoder.decode(part.value, { stream: true });
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
break;
}
for (;;) {
const newline = buffer.indexOf("\n");
if (newline < 0) break;
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
lineNumber++;
await emitLine(line);
if (failed) break;
}
if (failed) break;
}
if (!failed) {
try {
buffer += decoder.decode();
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
}
}
if (!failed && buffer.length > 0) {
lineNumber++;
await emitLine(buffer);
}
} finally {
if (failed) {
try {
await reader.cancel(cause);
} catch {
// The reader may already be closed by the runtime.
}
}
try {
reader.releaseLock();
} catch {
// The reader may already be errored or released.
}
}
return await this.interceptResponse(
serviceName,
methodName,
failed || { status: 0 },
requestState,
response
);
}
}`
}
func (ctx templateTs) genDefaultServiceTemplate() string {
return `import { Client, type result } from "./client";
return `import { Client } from "./client";
{{- range .GenServiceList}}
import {{.ServiceName}} from "./{{.ServiceName}}";
{{- end}}
@@ -131,7 +575,7 @@ export default class api {
}
func (ctx templateTs) genServiceTemplate() string {
return `import { Client, type result } from "./client"
return `import { Client{{if .IsIncludeRequest}}, type result, type RequestOptions{{end}}{{if .IsIncludeStream}}{{if not .IsIncludeRequest}}, type result{{end}}, type StreamOptions{{end}} } from "./client";
{{- range .GenImport}}
import type {{.Name}} from "./{{.Name}}";
{{- end}}
@@ -143,10 +587,10 @@ export default class {{.ServiceName}} {
}
{{- $serviceName := .ServiceName }}
{{- range .GenMethodTypeList}}
{{if .IsStream }}async {{.MethodName}}({{.DtoText}}{{if .DtoText}},{{end}}onMessage: (data: {{.GenericTypeText}}) => void): Promise<void> {
return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage)
}{{else}}async {{.MethodName}}({{.DtoText}}): Promise<{{.ReturnValueText}}> {
return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}})
{{if .IsStream }}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}onMessage: (data: {{.GenericTypeText}}) => unknown, options?: StreamOptions): Promise<result<void>> {
return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage, options)
}{{else}}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}options?: RequestOptions): Promise<{{.ReturnValueText}}> {
return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, options)
}{{end}}
{{- end}}
}`