2 Commits
5 changed files with 628 additions and 43 deletions
+250 -1
View File
@@ -167,6 +167,153 @@ result = await requestInterceptorClient.request("Svc", "interceptors", { value:
assert.deepEqual(requestInterceptorSeen, { value: 1 }); assert.deepEqual(requestInterceptorSeen, { value: 1 });
assert.equal(result.msg, "intercepted"); 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"); const failingRequestInterceptor = new Client("http://example.test");
let normalized = []; let normalized = [];
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); }); failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
@@ -299,6 +446,81 @@ streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(val
result = await streamHookClient.stream("Svc", "hook", undefined, () => {}); result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
assert.equal(result.status, 1); assert.equal(result.status, 1);
assert.deepEqual(normalized, [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") scriptPath := filepath.Join(dir, "behavior.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
@@ -320,8 +542,35 @@ func TestTypeScriptClientStrictTypecheck(t *testing.T) {
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil { if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err) 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, command := exec.Command(tsc,
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path) "--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
if output, err := command.CombinedOutput(); err != nil { if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output) t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
} }
+154
View File
@@ -0,0 +1,154 @@
# fun 框架(github.com/cyi-cc/fun)使用文档
> 适用版本:**v1.3.2**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
> 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
## 版本沿革
| 版本 | 要点 |
|---|---|
| v1.1.0 | BindRoute 自定义 GET/POST 路由(回调、健康检查) |
| v1.3.0 | BindRoute 通配符路由 `/prefix/*``RouteCtx.Wildcard` 取剩余路径 |
| v1.3.1 | TS 客户端可靠性:所有失败统一归一为 Result 并经过响应拦截器 |
| v1.3.2 | 每请求上下文(request/stream options + `state`)与免基础设施的生成期注册 `BindServiceForGen` |
## 1. 启动与服务注册
```go
func main() {
f := fun.GetFun()
f.BindService(&UserSvc{}, &AuthGuard{}) // 服务级 Guard 可选
f.BindGuard(&LogGuard{}) // 全局 Guard
cfg := fun.Wired[config.Config]() // 创建/获取单例(触发 DI
go f.Start(cfg.ListenPort()) // fasthttp 监听,RPC 只响应 POST /cell
}
```
- 服务结构体嵌入 `fun.Ctx` + 依赖字段(指针结构体字段自动装配)。
- **每请求新建服务实例**并注入依赖,服务内不放共享状态。
- 方法签名四种:`() error``(dto) (T, error)``(dto) (*fun.Stream, error)`
`(dto) (T, *fun.Stream, error)`(首条消息 T + 后续流)。
- 只有导出方法成为端点,注册名 `服务名.方法名`
## 2. DTO 规则(违反即注册期 panic)
- 允许:定宽整型(int8…int64、uint8…uint64)、string、bool、具名 struct、slice、指针。
- **不支持**:普通 `int`/`uint`、float、map、any/interface、匿名结构体、私有字段。
- 非指针且非 slice 字段必传且非 null;可空字段一律 `*T`;小数用字符串传。
- 枚举:`uint8` 底线 + `Names() []string`(可选 `DisplayNames()`)。
- 响应所有键递归转首字母小写,前端直接 camelCase 取值。
## 3. 线协议与 Result
请求:`POST /cell`body `{"serviceName","methodName","data","state"}`
```ts
export type result<T> = {
id?: string; code?: number; data?: T; msg?: string; status: number
}
```
- `status``0` 成功;`1` 框架/协议/基础设施失败;`2` 业务失败(`fun.Error(code,msg)`);
`4` 外部请求失败或调用方取消;`5` 明确的外部超时失败。
- 业务错误:`return nil, fun.Error(4001, "登录失败")` —— code/msg 原样透传。
- 成功空 slice 序列化为 `[]``data` 为 nil 时整个字段省略。
- `Ctx.State`map[string]string)请求往返透传;`Ctx.Ip` 已解析客户端 IP。
## 4. Guardv1.3.2 无变化,推荐用法见 vividai)
```go
func (g *AuthGuard) Guard(ctx fun.Ctx) { /* 校验失败 panic(fun.Error(...)) */ }
f.BindService(&AdminSvc{}, &AuthGuard{})
```
Guard 也是 Box,字段自动注入;panic 被框架兜底转错误响应。
vividai 的用法:**显式端点策略表**(缺省拒绝)+ Guard 从 HttpOnly Cookie 读会话,
并把校验结果经 `RequestCtx.SetUserValue` 传给服务层做对象级授权。
## 5. 自定义路由(v1.3.0+
```go
f.BindRoute("GET", "/image/*", func(c *fun.RouteCtx) error {
key := c.Wildcard // /image/ 之后的剩余路径
c.RequestCtx.WriteString("…") // 纯文本直写;返回 nil 框架不再写
return fun.Error(4001, "…") // 或统一 Result 错误
})
```
- 精确路由优先于通配符;`/cell` 保留;方法大小写不敏感。
- 查询参数与 form 表单合并进 `c.Param(name)`multipart 不支持(转 base64 走 /cell)。
## 6. 流式响应(NDJSON
```go
st := &fun.Stream{}
go func() {
for _, chunk := range chunks {
if st.Send(chunk) != nil { return } // 连接断开
}
st.Close() // 必须关闭
}()
return st, nil
```
`Content-Type: application/x-ndjson`,每行一个 JSON;合法零消息流正常结束;
业务出错在建流前返回普通 Result;`OnClose` 注册清理回调。
## 7. TS 客户端生成与请求上下文(v1.3.2 核心)
```go
f := fun.GetFun()
f.BindServiceForGen(&UserSvc{}) // 生成期专用:只反射注册方法,不装配任何基础设施
fun.SetOutput("./frontend/src/api")
fun.GenCode(fun.GenTs{})
```
- `BindServiceForGen` 不触发 Box 装配,生成命令**不需要数据库/Redis 在运行**。
- 生成确定性:service/method/imports 全部源端排序,重复生成字节一致。
- 产物:`client.ts`Client + `result<T>`)、每服务一个 `<service>.ts`、DTO/View 类型、
`fun.ts``api.create(url)` 聚合入口,服务属性首字母小写)。
### 每调用选项(v1.3.2
```ts
export type RequestOptions = { signal?: AbortSignal; state?: Record<string, string> }
export type StreamOptions = { signal?: AbortSignal; state?: Record<string, string> }
const r = await c.userSvc.profile({ signal: ctrl.signal })
c.chatSvc.chat(dto, msg => {...}, { signal: ctrl.signal })
```
- 调用方可传 `AbortSignal`;**框架自身不设任何请求/连接/空闲超时定时器**,
超时由调用方或网关(nginx)决定,框架只负责把失败归一为 Result。
- `state` 为每请求字符串字典:请求拦截器可写入(如会话纪元、请求标识),
响应拦截器经 `context.requestState` 只读快照取回。
### 拦截器(v1.3.2 四参上下文形态)
```ts
c.addRequestInterceptor((svc, m, state) => { state.epoch = myEpoch() })
c.addResponseInterceptor((svc, m, result, context) => {
// context.requestState: Readonly<Record<string,string>>
// context.response?: Response —— 原生 Response(头、状态码可读)
})
```
旧三参签名仍兼容。所有失败(网络、HTTP、HTML、非法 JSON、取消、拦截器异常)
统一归一为 Result 并**必经响应拦截器**,不存在绕过拦截器的错误路径。
## 8. 依赖注入(box.go
- `fun.Wired[T]()`:按 `*T` 建单例;先注入 `fun:"auto"` 字段(缺则递归创建),
再调 `New()`(无参;连接类资源在此初始化,失败可 log.Fatalf)。
- 启动顺序:先 `Wired` 基础配置/平台单例,再 `BindService`
## 9. 常见坑
- DTO 用普通 `int`/float/map → 注册期 panic;用 int64/字符串/指针。
- 非指针字段漏传 → 运行期 "must be a pointer or have a corresponding field"。
- 流式忘记 `Close()` → 客户端挂起;连接断开后 `Send` 返回 error,循环须检查。
- 方法首字母小写 = 不注册;客户端报 method not found。
- vite 代理需重写前缀:`/api/cell → /cell`
- 生成用 `BindService`(而非 `BindServiceForGen`)会把基础设施拉起来 —— 生成命令请用后者。
+38 -13
View File
@@ -15,6 +15,16 @@ type Fun struct {
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
guards []*any // 全局 Guard guards []*any // 全局 Guard
serviceGuards map[string][]*any // 服务级 Guard,按服务名 serviceGuards map[string][]*any // 服务级 Guard,按服务名
bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB
}
// SetBodyLimit 设置请求体上限(字节),须在 Start 前调用。
// multipart 上传等大请求体的自定义路由需要时设置;0 或负数恢复默认。
func (f *Fun) SetBodyLimit(n int) {
if n < 0 {
n = 0
}
f.bodyLimit = n
} }
// wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册): // wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册):
@@ -68,16 +78,7 @@ func GetFun() *Fun {
// //
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行 // guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
func (f *Fun) BindService(service any, guardList ...Guard) { func (f *Fun) BindService(service any, guardList ...Guard) {
t := reflect.TypeOf(service) t, name := serviceType(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")
}
boxWired(service, f) boxWired(service, f)
serviceGuards := make([]*any, 0, len(guardList)) serviceGuards := make([]*any, 0, len(guardList))
@@ -86,7 +87,29 @@ func (f *Fun) BindService(service any, guardList ...Guard) {
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f)) serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
} }
f.serviceGuards[name] = serviceGuards 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() { for m := range t.Methods() {
m := m m := m
// Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法 // Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法
@@ -159,9 +182,11 @@ func (f *Fun) callGuard(c *Ctx, serviceName string) {
} }
func (f *Fun) Start(port uint16) { func (f *Fun) Start(port uint16) {
addr := fmt.Sprintf(":%d", port) server := &fasthttp.Server{Handler: f.handle}
err := fasthttp.ListenAndServe(addr, f.handle) if f.bodyLimit > 0 {
if err != nil { server.MaxRequestBodySize = f.bodyLimit
}
if err := server.ListenAndServe(fmt.Sprintf(":%d", port)); err != nil {
panic(err.Error()) panic(err.Error())
} }
} }
+37
View File
@@ -3,6 +3,7 @@ package fun
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -31,6 +32,16 @@ type ZebraGenSvc struct{}
func (*ZebraGenSvc) Watch() (*Stream, error) { return &Stream{}, nil } 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) { func isolateGeneratorGlobals(t *testing.T) {
t.Helper() t.Helper()
oldFun, oldDirectory := fun, directory oldFun, oldDirectory := fun, directory
@@ -69,6 +80,18 @@ func generatedFiles(t *testing.T, root string) map[string]string {
return files 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) { func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
isolateGeneratorGlobals(t) isolateGeneratorGlobals(t)
f := GetFun() f := GetFun()
@@ -87,6 +110,20 @@ func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
return string(body) 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") alpha := read("alphaGenSvc.ts")
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` { 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) t.Fatalf("unexpected request-only imports: %s", first)
+146 -26
View File
@@ -16,10 +16,12 @@ export type resultStatus = 0 | 1 | 2 | 4 | 5;
export type RequestOptions = { export type RequestOptions = {
signal?: AbortSignal; signal?: AbortSignal;
state?: Record<string, string>;
}; };
export type StreamOptions = { export type StreamOptions = {
signal?: AbortSignal; signal?: AbortSignal;
state?: Record<string, string>;
}; };
export type RequestInterceptor = ( export type RequestInterceptor = (
@@ -29,12 +31,24 @@ export type RequestInterceptor = (
dto?: any dto?: any
) => Promise<void> | void; ) => Promise<void> | void;
export type ResponseContext = {
readonly requestState: Readonly<Record<string, string>>;
readonly response?: Response;
};
export type ResponseInterceptor = ( export type ResponseInterceptor = (
serviceName: string, serviceName: string,
methodName: string, methodName: string,
result: result<any> result: result<any>
) => Promise<result<any> | void> | result<any> | 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 { function messageOf(error: unknown): string {
if (error instanceof Error && error.message) return error.message; if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error; if (typeof error === "string" && error) return error;
@@ -137,7 +151,7 @@ export class Client {
private url: string; private url: string;
private state: Record<string, string> = {}; private state: Record<string, string> = {};
private requestInterceptors: RequestInterceptor[] = []; private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = []; private responseInterceptors: ContextResponseInterceptor[] = [];
constructor(url: string) { constructor(url: string) {
this.url = url.replace(/\/+$/, ""); this.url = url.replace(/\/+$/, "");
@@ -151,19 +165,26 @@ export class Client {
this.requestInterceptors.push(interceptor); this.requestInterceptors.push(interceptor);
} }
addResponseInterceptor(interceptor: ResponseInterceptor) { addResponseInterceptor(interceptor: ResponseInterceptor): void;
this.responseInterceptors.push(interceptor); addResponseInterceptor(interceptor: ContextResponseInterceptor): void;
addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) {
this.responseInterceptors.push(interceptor as ContextResponseInterceptor);
} }
private async interceptResponse( private async interceptResponse(
serviceName: string, serviceName: string,
methodName: string, methodName: string,
initial: result<any> initial: result<any>,
requestState: Readonly<Record<string, string>>,
response?: Response
): Promise<result<any>> { ): Promise<result<any>> {
let current = initial; let current = initial;
const context: ResponseContext = Object.freeze(
response === undefined ? { requestState } : { requestState, response }
);
for (const interceptor of this.responseInterceptors) { for (const interceptor of this.responseInterceptors) {
try { try {
const replaced = await interceptor(serviceName, methodName, current); const replaced = await interceptor(serviceName, methodName, current, context);
if (replaced) current = replaced; if (replaced) current = replaced;
} catch (error) { } catch (error) {
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `); current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
@@ -172,12 +193,19 @@ export class Client {
return current; return current;
} }
private async requestState(serviceName: string, methodName: string, dto: any): Promise<Record<string, string>> { private async interceptRequest(
const state: Record<string, string> = { ...this.state }; serviceName: string,
methodName: string,
state: Record<string, string>,
dto: any
): Promise<void> {
for (const interceptor of this.requestInterceptors) { for (const interceptor of this.requestInterceptors) {
await interceptor(serviceName, methodName, state, dto); await interceptor(serviceName, methodName, state, dto);
} }
return state; }
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
return Object.freeze({ ...state });
} }
async request<T>( async request<T>(
@@ -188,12 +216,45 @@ export class Client {
): Promise<result<T>> { ): Promise<result<T>> {
let state: Record<string, string>; let state: Record<string, string>;
try { try {
state = await this.requestState(serviceName, methodName, dto); state = { ...this.state, ...options?.state };
} catch (error) { } catch (error) {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `) 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>; ) as result<T>;
} }
@@ -203,7 +264,7 @@ export class Client {
serviceName, serviceName,
methodName, methodName,
data: dto, data: dto,
...(Object.keys(state).length ? { state } : {}), ...(Object.keys(requestState).length ? { state: requestState } : {}),
}); });
if (serialized === undefined) throw new Error("serialization produced no output"); if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized; body = serialized;
@@ -211,13 +272,15 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `) failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
) as result<T>; ) as result<T>;
} }
let output: result<any>; let output: result<any>;
let response: Response | undefined;
try { try {
const response = await fetch(` + "`${this.url}/cell`" + `, { response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body, body,
@@ -231,7 +294,7 @@ export class Client {
} catch (error) { } catch (error) {
output = requestFailure(error, options?.signal, false); output = requestFailure(error, options?.signal, false);
} }
return await this.interceptResponse(serviceName, methodName, output) as result<T>; return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result<T>;
} }
async stream<T>( async stream<T>(
@@ -243,12 +306,45 @@ export class Client {
): Promise<result<void>> { ): Promise<result<void>> {
let state: Record<string, string>; let state: Record<string, string>;
try { try {
state = await this.requestState(serviceName, methodName, dto); state = { ...this.state, ...options?.state };
} catch (error) { } catch (error) {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `) failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
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
);
}
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({})
); );
} }
@@ -258,7 +354,7 @@ export class Client {
serviceName, serviceName,
methodName, methodName,
data: dto, data: dto,
...(Object.keys(state).length ? { state } : {}), ...(Object.keys(requestState).length ? { state: requestState } : {}),
}); });
if (serialized === undefined) throw new Error("serialization produced no output"); if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized; body = serialized;
@@ -266,7 +362,8 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `) failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
); );
} }
@@ -282,7 +379,8 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
requestFailure(error, options?.signal, true) requestFailure(error, options?.signal, true),
requestState
); );
} }
@@ -294,10 +392,18 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
responseReadFailure(error, response, options?.signal, true) responseReadFailure(error, response, options?.signal, true),
requestState,
response
); );
} }
return await this.interceptResponse(serviceName, methodName, parseResult(response, text)); return await this.interceptResponse(
serviceName,
methodName,
parseResult(response, text),
requestState,
response
);
} }
if (mediaType(response) !== "application/x-ndjson") { if (mediaType(response) !== "application/x-ndjson") {
@@ -308,7 +414,9 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
responseReadFailure(error, response, options?.signal, true) responseReadFailure(error, response, options?.signal, true),
requestState,
response
); );
} }
const rpcResult = parseResult(response, text); const rpcResult = parseResult(response, text);
@@ -317,12 +425,20 @@ export class Client {
methodName, methodName,
rpcResult.status === 0 rpcResult.status === 0
? failure(1, "Expected application/x-ndjson response") ? failure(1, "Expected application/x-ndjson response")
: rpcResult : rpcResult,
requestState,
response
); );
} }
if (!response.body) { if (!response.body) {
return await this.interceptResponse(serviceName, methodName, { status: 0 }); return await this.interceptResponse(
serviceName,
methodName,
{ status: 0 },
requestState,
response
);
} }
let reader: ReadableStreamDefaultReader<Uint8Array>; let reader: ReadableStreamDefaultReader<Uint8Array>;
@@ -332,7 +448,9 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
responseReadFailure(error, response, options?.signal, true) responseReadFailure(error, response, options?.signal, true),
requestState,
response
); );
} }
const decoder = new TextDecoder("utf-8", { fatal: true }); const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -426,7 +544,9 @@ export class Client {
return await this.interceptResponse( return await this.interceptResponse(
serviceName, serviceName,
methodName, methodName,
failed || { status: 0 } failed || { status: 0 },
requestState,
response
); );
} }
}` }`