From e29767362ac23823eef6c0ee39e923aaebaffcc6 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sat, 19 Sep 2026 06:32:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20fun-cli=20v1.0.0=20=E2=80=94=20gen/run/?= =?UTF-8?q?docs=20=E4=B8=89=E5=91=BD=E4=BB=A4,fun=20=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E9=9B=B6=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fun gen [ts|go]:AST 扫描 BindService* 调用,临时生成 go test 辅助采集, 经 fun v1.3.7 公开 API 生成,产物与内置 GenCode 字节级一致(回归测试锁定) - fun run:热更新开发循环,先编译成功再切换,失败保留旧进程 - fun docs:浅色标准文档站——参数表/State 添加字段/请求 JSON 可编辑/ 响应格式化/NDJSON 流式逐行展示,反向代理 /cell 同源无 CORS - 元数据:TS 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持) - 纯标准库实现,零第三方依赖 --- .gitignore | 2 + README.md | 79 ++++ astscan.go | 229 ++++++++++ client_ts_test.go | 589 +++++++++++++++++++++++++ docs.go | 130 ++++++ docsui/index.html | 420 ++++++++++++++++++ gen.go | 186 ++++++++ gen_test.go | 195 ++++++++ go.mod | 3 + helper.go | 134 ++++++ main.go | 60 +++ meta.go | 55 +++ render.go | 151 +++++++ render_go.go | 194 ++++++++ render_ts.go | 225 ++++++++++ run.go | 196 +++++++++ templates.go | 1026 +++++++++++++++++++++++++++++++++++++++++++ testdata/alpha.json | 1 + testdata/demo.json | 1 + tsparse.go | 186 ++++++++ tsparse_test.go | 86 ++++ 21 files changed, 4148 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 astscan.go create mode 100644 client_ts_test.go create mode 100644 docs.go create mode 100644 docsui/index.html create mode 100644 gen.go create mode 100644 gen_test.go create mode 100644 go.mod create mode 100644 helper.go create mode 100644 main.go create mode 100644 meta.go create mode 100644 render.go create mode 100644 render_go.go create mode 100644 render_ts.go create mode 100644 run.go create mode 100644 templates.go create mode 100644 testdata/alpha.json create mode 100644 testdata/demo.json create mode 100644 tsparse.go create mode 100644 tsparse_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e57407c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +fun-cli +*.test diff --git a/README.md b/README.md new file mode 100644 index 0000000..5966fc8 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# fun-cli + +[fun](https://git.cyi.cc/chiyi/fun) 框架的配套命令行工具。**不修改 fun 框架任何一行**, +兼容 v1.3.7+;纯 Go 标准库实现,`go install` 即用。 + +## 安装 + +```bash +go install github.com/cyi-cc/fun-cli@latest +``` + +## 工作原理(为什么 fun 可以零改动) + +fun 的服务注册全在 `BindService`/`BindServiceForGen` 调用里。fun-cli 用 AST 扫描定位 +这些调用(支持 `&pkg.Svc{}`、同包 `&Svc{}`、`for _, x := range []any{...}` 循环注册), +在被调用包目录**临时生成一个 `go test` 辅助文件**——测试文件天然能访问包内类型 +(服务定义在 `package main` 也不怕)——执行后立即删除。生成由 fun 公开的 +`BindServiceForGen + SetOutput + GenCode` 完成,产物与内置生成器完全一致。 + +## 三个命令 + +### fun gen —— 生成客户端代码 + +```bash +fun gen # Go + TS → ./gen +fun gen ts # 只生成 TypeScript +fun gen go # 只生成 Go +fun gen ts -o ./frontend/src/api +fun gen -from meta.json # 直接渲染元数据 JSON(CI 友好) +``` + +### fun run —— 热更新 + +```bash +fun run # 改 .go/go.mod/go.sum 自动重编译重启 +fun run -p ./cmd/server +fun run -- -config dev.yaml # 透传程序参数 +``` + +先编译成功再切换进程;编译失败保留旧进程。Ctrl+C 连子进程一起退出。 + +### fun docs —— API 文档站 + +```bash +fun docs # 自动 go run . 拉起后端 +fun docs -upstream http://127.0.0.1:9000 # 指定后端地址 +fun docs -no-run # 后端已在别处运行 +``` + +- 浅色标准文档风格:侧栏服务/方法树(stream/void 徽标)+ 搜索 +- 参数表:字段/类型/示例值/说明;枚举下拉、嵌套 DTO 分层、可空标注 +- **请求**:表单实时生成 JSON,可直接编辑后发送 +- **State**:"添加字段"式键值编辑(存 localStorage),值注入请求 `state` 对象—— + 键以后端 Guard 读取为准,前端只负责填值 +- **响应**:格式化 JSON;NDJSON 流式方法逐行实时追加 +- 文档站反向代理 `/cell` 到后端,同源无 CORS 问题 + +## 元数据 JSON + +`fun docs -from`/`fun gen -from` 消费的元数据格式(由 TS 产物解析重建): + +```json +{"services":[{"name":"userSvc","methods":[ + {"name":"login","dto":{"kind":"struct","name":"loginDto","fields":[ + {"name":"email","type":{"kind":"string","name":"string"}}]}}, + "returns":{"kind":"string","name":"string"}}]}]} +``` + +## 已知边界 + +- fun v1.3.7 的 TS 模板对流式+DTO 方法会生成 `dto: dto:X` 的双前缀类型 + (框架自身的模板怪癖)。fun-cli 的文档站解析已兼容;生成的 TS 文件如需编译, + 该怪癖仍随产物保留——按"fun 零改动"约定不去修它。 + +## 开发 + +```bash +go test ./... # 渲染回归 + 解析回环 + node 门控的 TS 客户端行为测试 +``` diff --git a/astscan.go b/astscan.go new file mode 100644 index 0000000..8cb1a47 --- /dev/null +++ b/astscan.go @@ -0,0 +1,229 @@ +package main + +// AST 扫描:在用户模块里找 BindService / BindServiceForGen 调用点, +// 按目录聚合——fun 框架零改动,采集全靠 fun 公开 API + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type serviceRef struct { + Alias string // &pkg.Svc 的 pkg 别名;同包引用为空 + Import string // pkg 对应的导入路径 + TypeName string +} + +// modulePath 读 go.mod 的 module 行 +func modulePath(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + return "", fmt.Errorf("找不到 go.mod(%s): %w", root, err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil + } + } + return "", fmt.Errorf("go.mod 中没有 module 声明") +} + +// findModuleRoot 从 dir 向上找 go.mod +func findModuleRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(abs, "go.mod")); err == nil { + return abs, nil + } + parent := filepath.Dir(abs) + if parent == abs { + return "", fmt.Errorf("从 %s 向上未找到 go.mod", dir) + } + abs = parent + } +} + +var scanSkipDirs = map[string]bool{ + ".git": true, "vendor": true, "node_modules": true, ".fun-tmp": true, "testdata": true, +} + +// scanBindTargets 扫描模块内全部 .go 文件的 BindService* 调用, +// 按目录聚合成待生成辅助测试的包目标(测试文件可访问包内类型, +// 服务定义在 package main 也能采集) +func scanBindTargets(root string) ([]helperTarget, error) { + byDir := map[string]*helperTarget{} + seen := map[string]bool{} + + add := func(dir, pkgName string, r serviceRef) { + key := dir + "|" + r.Alias + "|" + r.Import + "|" + r.TypeName + if seen[key] { + return + } + seen[key] = true + t, ok := byDir[dir] + if !ok { + t = &helperTarget{Dir: dir, Pkg: pkgName} + byDir[dir] = t + } + t.Refs = append(t.Refs, r) + } + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if path != root && (scanSkipDirs[d.Name()] || strings.HasPrefix(d.Name(), ".")) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return nil // 语法不过的文件(生成物/草稿)跳过 + } + + // 该文件可见的导入:别名/包名 → 导入路径 + imports := map[string]string{} + for _, imp := range file.Imports { + importPath, _ := strconv.Unquote(imp.Path.Value) + alias := filepath.Base(importPath) + if imp.Name != nil { + alias = imp.Name.Name + } + imports[alias] = importPath + } + + // 服务列表变量:for _, x := range []any{&A{}, &B{}} / x := []any{...} + // (BindServiceForGen(svc) 循环注册的常见写法) + parsePtrComposite := func(e ast.Expr) (serviceRef, bool) { + u, ok := e.(*ast.UnaryExpr) + if !ok || u.Op != token.AND { + return serviceRef{}, false + } + lit, ok := u.X.(*ast.CompositeLit) + if !ok { + return serviceRef{}, false + } + switch t := lit.Type.(type) { + case *ast.Ident: + return serviceRef{TypeName: t.Name}, true + case *ast.SelectorExpr: + if pkg, ok := t.X.(*ast.Ident); ok { + if importPath, ok := imports[pkg.Name]; ok { + return serviceRef{Alias: pkg.Name, Import: importPath, TypeName: t.Sel.Name}, true + } + } + } + return serviceRef{}, false + } + collectSlice := func(lit *ast.CompositeLit) []serviceRef { + var refs []serviceRef + for _, el := range lit.Elts { + if r, ok := parsePtrComposite(el); ok { + refs = append(refs, r) + } + } + return refs + } + sliceVars := map[string][]serviceRef{} + var collectVars func(n ast.Node) bool + collectVars = func(n ast.Node) bool { + switch st := n.(type) { + case *ast.RangeStmt: + if v, ok := st.Value.(*ast.Ident); ok { + if lit, ok := st.X.(*ast.CompositeLit); ok { + sliceVars[v.Name] = collectSlice(lit) + } + } + case *ast.AssignStmt: + if len(st.Lhs) == 1 { + if id, ok := st.Lhs[0].(*ast.Ident); ok { + if lit, ok := st.Rhs[0].(*ast.CompositeLit); ok { + sliceVars[id.Name] = collectSlice(lit) + } + } + } + } + return true + } + ast.Inspect(file, collectVars) + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + if sel.Sel.Name != "BindService" && sel.Sel.Name != "BindServiceForGen" { + return true + } + if len(call.Args) == 0 { + return true + } + // 参数形如 &pkg.Svc{} / &Svc{} + var typeExpr ast.Expr + if a, ok := call.Args[0].(*ast.UnaryExpr); ok && a.Op == token.AND { + if lit, ok := a.X.(*ast.CompositeLit); ok { + typeExpr = lit.Type + } + } + if ident, ok := call.Args[0].(*ast.Ident); ok { + // BindServiceForGen(svc):循环/切片变量注册 + for _, r := range sliceVars[ident.Name] { + add(filepath.Dir(path), file.Name.Name, r) + } + return true + } + if typeExpr == nil { + return true + } + switch t := typeExpr.(type) { + case *ast.Ident: + add(filepath.Dir(path), file.Name.Name, serviceRef{TypeName: t.Name}) + case *ast.SelectorExpr: + if pkg, ok := t.X.(*ast.Ident); ok { + if importPath, ok := imports[pkg.Name]; ok { + add(filepath.Dir(path), file.Name.Name, serviceRef{ + Alias: pkg.Name, Import: importPath, TypeName: t.Sel.Name, + }) + } + } + } + return true + }) + return nil + }) + if err != nil { + return nil, err + } + + dirs := make([]string, 0, len(byDir)) + for d := range byDir { + dirs = append(dirs, d) + } + sort.Strings(dirs) + targets := make([]helperTarget, 0, len(dirs)) + for _, d := range dirs { + targets = append(targets, *byDir[d]) + } + return targets, nil +} diff --git a/client_ts_test.go b/client_ts_test.go new file mode 100644 index 0000000..1a0ff39 --- /dev/null +++ b/client_ts_test.go @@ -0,0 +1,589 @@ +package main + +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("gateway timeout", { 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("login", { + 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) => 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>") { + t.Fatal("stream methods must resolve result") + } +} diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..6e804de --- /dev/null +++ b/docs.go @@ -0,0 +1,130 @@ +package main + +// fun docs:拉起 API 文档站——侧栏服务/方法树、DTO 参数表单、 +// 一键发送打到反向代理的后端,流式方法实时展示 NDJSON 行 + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" +) + +//go:embed docsui/index.html +var docsFS embed.FS + +func cmdDocs(args []string) { + fs := flag.NewFlagSet("docs", flag.ExitOnError) + addr := fs.String("addr", "127.0.0.1:7788", "文档站监听地址") + upstream := fs.String("upstream", "http://127.0.0.1:8080", "后端服务地址(反向代理目标)") + from := fs.String("from", "", "从元数据 JSON 文件读取") + pkg := fs.String("p", ".", "业务 main 包路径(用于采集元数据与自动启动后端)") + noStart := fs.Bool("no-run", false, "不自动启动后端(后端已在别处运行时使用)") + fs.Parse(args) + + var meta *FunMeta + var err error + if *from != "" { + meta, err = loadMeta(*from) + } else { + var root string + root, err = findModuleRoot(*pkg) + if err == nil { + genDir, _ := os.MkdirTemp("", "fun-docs-gen-*") + defer os.RemoveAll(genDir) + err = runFunGen(root, "ts", genDir) + if err == nil { + meta, err = parseTsDir(filepath.Join(genDir, "ts")) + } + } + } + if err != nil { + fmt.Fprintln(os.Stderr, "fun: 采集元数据失败:", err) + os.Exit(1) + } + if len(meta.Services) == 0 { + fmt.Fprintln(os.Stderr, "fun: 元数据中没有服务,请先 BindService") + os.Exit(1) + } + + // 自动起后端:FUN_DUMP 采集进程已退出,这里再起一个常驻的 + var backend *exec.Cmd + if !*noStart { + backend = goCommand("run", *pkg) + backend.Stdout, backend.Stderr = os.Stdout, os.Stderr + // 独立进程组:go run 的孙进程(真正的服务)随 CLI 一起被杀干净 + backend.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := backend.Start(); err != nil { + fmt.Fprintln(os.Stderr, "fun: 启动后端失败:", err) + os.Exit(1) + } + } + + target, err := url.Parse(*upstream) + if err != nil { + fmt.Fprintln(os.Stderr, "fun: upstream 地址无效:", err) + killChild(backend, nil) + os.Exit(1) + } + proxy := httputil.NewSingleHostReverseProxy(target) + + metaJSON := renderMetaJSON(meta) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/", "/index.html": + data, _ := docsFS.ReadFile("docsui/index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + case "/fun-meta.json": + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write(metaJSON) + default: + proxy.ServeHTTP(w, r) + } + }) + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigs + killChild(backend, nil) + os.Exit(0) + }() + + nMethods := 0 + for _, svc := range meta.Services { + nMethods += len(svc.Methods) + } + fmt.Printf(` + fun docs 已启动 + ───────────────────────────────────────── + 文档站: http://%s + 后端代理: %s → %s + ───────────────────────────────────────── + %d 个服务 / %d 个方法。Ctrl+C 退出(连同自动拉起的后端) +`, *addr, *upstream, *upstream, len(meta.Services), nMethods) + + if err := http.ListenAndServe(*addr, mux); err != nil { + fmt.Fprintln(os.Stderr, "fun: 文档站启动失败:", err) + killChild(backend, nil) + os.Exit(1) + } +} + +func renderMetaJSON(meta *FunMeta) []byte { + data, err := json.Marshal(meta) + if err != nil { + return []byte(`{"services":[]}`) + } + return data +} diff --git a/docsui/index.html b/docsui/index.html new file mode 100644 index 0000000..4cc12be --- /dev/null +++ b/docsui/index.html @@ -0,0 +1,420 @@ + + + + + +fun docs + + + +
+ + + 加载中… +
+
+ +
在左侧选择一个方法查看文档
+
+ + + diff --git a/gen.go b/gen.go new file mode 100644 index 0000000..6d29c02 --- /dev/null +++ b/gen.go @@ -0,0 +1,186 @@ +package main + +// fun gen:从运行中的服务采集元数据,渲染 Go/TS 客户端代码 + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// goCommand 组装 go run/build 命令:-p 为目录时切换工作目录, +// 否则视为当前模块内的包路径(如 ./cmd/server) +func goCommand(subcmd string, pkg string, extra ...string) *exec.Cmd { + args := append([]string{subcmd}, extra...) + if st, err := os.Stat(pkg); err == nil && st.IsDir() { + args = append(args, ".") + cmd := exec.Command("go", args...) + cmd.Dir = pkg + return cmd + } + args = append(args, pkg) + return exec.Command("go", args...) +} + +// collectMeta 采集元数据:优先 -from 文件,否则 FUN_DUMP= go run +func collectMeta(pkg string, from string) (*FunMeta, error) { + if from != "" { + return loadMeta(from) + } + tmp, err := os.CreateTemp("", "fun-meta-*.json") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + cmd := goCommand("run", pkg) + cmd.Env = append(os.Environ(), "FUN_DUMP="+tmpPath) + cmd.Stdout = os.Stderr // 用户程序的输出不污染元数据文件 + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("启动 go run 失败: %w", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + // 用户 main 需要响应 FUN_DUMP(调用 f.Start 的都会自动响应); + // 首次 go run 含编译耗时,给足余量 + select { + case err := <-done: + if err != nil { + return nil, fmt.Errorf("go run 退出: %w(程序需调用 f.Start 或显式处理 fun.DumpRequested)", err) + } + case <-time.After(120 * time.Second): + cmd.Process.Kill() + return nil, fmt.Errorf("等待元数据超时:程序 120 秒内未退出。请确认 main 调用了 f.Start(会自动响应 FUN_DUMP 导出后退出),或用 fun.DumpRequested + f.DumpMetadata 显式导出") + } + + return loadMeta(tmpPath) +} + +func cmdGen(args []string) { + fs := flag.NewFlagSet("gen", flag.ExitOnError) + out := fs.String("o", "./gen", "输出目录(会被清空重建)") + from := fs.String("from", "", "从元数据 JSON 文件读取(跳过 go run 采集)") + pkg := fs.String("p", ".", "业务 main 包路径") + fs.Parse(reorderFlags(args)) + + rest := fs.Args() + wantTS, wantGo := false, false + switch { + case len(rest) == 0: + wantTS, wantGo = true, true + case len(rest) == 1 && (rest[0] == "ts" || rest[0] == "go"): + wantTS = rest[0] == "ts" + wantGo = rest[0] == "go" + default: + fmt.Fprintln(os.Stderr, "用法: fun gen [ts|go] [-o 输出目录] [-from meta.json] [-p 包路径]") + os.Exit(2) + } + + lang := "both" + if wantTS && !wantGo { + lang = "ts" + } else if wantGo && !wantTS { + lang = "go" + } + + if *from != "" { + meta, err := loadMeta(*from) + if err != nil { + fmt.Fprintln(os.Stderr, "fun: 读取元数据失败:", err) + os.Exit(1) + } + files := fileSet{} + if wantTS { + renderTs{}.renderAll(meta, files) + } + if wantGo { + renderGo{}.renderAll(meta, files) + } + if err := writeFiles(*out, files); err != nil { + fmt.Fprintln(os.Stderr, "fun: 写入产物失败:", err) + os.Exit(1) + } + reportGen(*out, files) + return + } + + root, err := findModuleRoot(*pkg) + if err != nil { + fmt.Fprintln(os.Stderr, "fun:", err) + os.Exit(1) + } + if err := runFunGen(root, lang, *out); err != nil { + fmt.Fprintln(os.Stderr, "fun:", err) + os.Exit(1) + } + count := countGenFiles(*out) + fmt.Printf("fun: 已生成客户端(%s)→ %s\n", lang, *out) + for _, name := range count { + fmt.Println(" 生成:", name) + } +} + +func reportGen(out string, files fileSet) { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, filepath.ToSlash(name)) + } + sortStrings(names) + fmt.Printf("fun: 已生成 %d 个文件 → %s\n", len(files), out) + for _, name := range names { + fmt.Println(" 生成:", name) + } +} + +func countGenFiles(out string) []string { + var names []string + filepath.WalkDir(out, func(path string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() { + rel, _ := filepath.Rel(out, path) + names = append(names, filepath.ToSlash(rel)) + } + return nil + }) + sortStrings(names) + return names +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// reorderFlags 把 -x 值 对挪到位置参数之前,让 "fun gen ts -o dir" 与 +// "fun gen -o dir ts" 等价(flag 包遇到首个位置参数就停止解析) +func reorderFlags(args []string) []string { + var flags, rest []string + i := 0 + for i < len(args) { + a := args[i] + if strings.HasPrefix(a, "-") && a != "-" && a != "--" { + flags = append(flags, a) + // 有值的标志:-o dir / -odir + if !strings.Contains(a, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + flags = append(flags, args[i+1]) + i += 2 + continue + } + i++ + continue + } + rest = append(rest, a) + i++ + } + return append(flags, rest...) +} diff --git a/gen_test.go b/gen_test.go new file mode 100644 index 0000000..207abb1 --- /dev/null +++ b/gen_test.go @@ -0,0 +1,195 @@ +package main + +// 渲染回归测试:断言与 fun v1.3.7 内置生成器产物一致的关键特征 + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func renderToDir(t *testing.T, fixture string) string { + t.Helper() + meta, err := loadMeta(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + files := fileSet{} + renderTs{}.renderAll(meta, files) + renderGo{}.renderAll(meta, files) + dir := t.TempDir() + if err := writeFiles(dir, files); err != nil { + t.Fatal(err) + } + return dir +} + +func read(t *testing.T, root, name string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("file %s missing: %v", name, err) + } + return string(body) +} + +func TestRenderTypeScriptSignaturesAndImports(t *testing.T) { + root := renderToDir(t, "alpha.json") + + client := read(t, root, filepath.Join("ts", "client.ts")) + for _, want := range []string{ + `state?: Record;`, + `export type ResponseContext = {`, + `readonly requestState: Readonly>;`, + `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(t, root, filepath.Join("ts", "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>`, + `this.client.request("alphaGenSvc", "ping", undefined, options)`, + `async alpha(dto:alphaGenDto, options?: RequestOptions): Promise>`, + `this.client.request("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(t, root, filepath.Join("ts", "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>`, + `this.client.stream("zebraGenSvc", "watch", undefined, onMessage, options)`, + } { + if !strings.Contains(stream, want) { + t.Errorf("zebraGenSvc.ts missing %q:\n%s", want, stream) + } + } + + mixed := read(t, root, filepath.Join("ts", "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 TestRenderGoErrorOnlyMethod(t *testing.T) { + root := renderToDir(t, "alpha.json") + goSrc := read(t, root, filepath.Join("go", "alpha_gen_svc.go")) + if !strings.Contains(goSrc, "Result[Void]") { + t.Fatalf("go: expect Result[Void], got:\n%s", goSrc) + } + tsSrc := read(t, root, filepath.Join("ts", "alphaGenSvc.ts")) + if !strings.Contains(tsSrc, "result") { + t.Fatalf("ts: expect result, got:\n%s", tsSrc) + } +} + +func TestRenderDeterministic(t *testing.T) { + first := renderToDir(t, "alpha.json") + second := renderToDir(t, "alpha.json") + + files1, files2 := walkFiles(t, first), walkFiles(t, second) + if len(files1) != len(files2) { + t.Fatalf("file count changed: %d != %d", len(files1), len(files2)) + } + for name, body := range files1 { + if files2[name] != body { + t.Errorf("generated file changed between runs: %s", name) + } + } + + tsFun := files1[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 := files1[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 := files1[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) + } +} + +// TestRenderDemoFixture 用真实导出的示例元数据验证枚举、可空字段、流式首条消息 +func TestRenderDemoFixture(t *testing.T) { + root := renderToDir(t, "demo.json") + + enumGo := read(t, root, filepath.Join("go", "order_status.go")) + for _, want := range []string{"type OrderStatus uint8", "OrderStatus = iota", `func (OrderStatus) DisplayNames()`} { + if !strings.Contains(enumGo, want) { + t.Errorf("order_status.go missing %q:\n%s", want, enumGo) + } + } + enumTs := read(t, root, filepath.Join("ts", "orderStatus.ts")) + if !strings.Contains(enumTs, "enum orderStatus {") { + t.Errorf("orderStatus.ts missing enum:\n%s", enumTs) + } + + dtoTs := read(t, root, filepath.Join("ts", "createOrderDto.ts")) + for _, want := range []string{"sku:string", "count:number", "note?:string | null", "tags:string[]"} { + if !strings.Contains(dtoTs, want) { + t.Errorf("createOrderDto.ts missing %q:\n%s", want, dtoTs) + } + } + + chatTs := read(t, root, filepath.Join("ts", "chatSvc.ts")) + if !strings.Contains(chatTs, `this.client.stream("chatSvc", "ask"`) { + t.Errorf("chatSvc.ts missing first-message stream signature:\n%s", chatTs) + } +} + +func walkFiles(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + rel, _ := filepath.Rel(root, path) + out[rel] = string(body) + return nil + }) + return out +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5326703 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/cyi-cc/fun-cli + +go 1.26.1 diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..524f22a --- /dev/null +++ b/helper.go @@ -0,0 +1,134 @@ +package main + +// 采集辅助:在 BindService 调用所在的包目录临时生成 fun_gen_helper_test.go +// (测试文件可访问包内类型,天然解决"服务定义在 package main"不可 import 的问题), +// go test 执行完毕后删除。fun 框架本体零改动,只用其公开 API + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +const helperTestFile = "fun_gen_helper_test.go" +const helperTestName = "TestFunGenHelper" + +// helperTarget 一个待生成辅助测试的包目录 +type helperTarget struct { + Dir string + Pkg string + Refs []serviceRef +} + +type helperRef struct { + Alias string // 引用别名(&pkg.Svc 的 pkg);同包引用为空 + Import string // 引用的导入路径 + TypeName string +} + +// runFunGen 扫描绑定调用,生成辅助测试并执行: +// lang: "ts" / "go" / "both";out 为 GenCode 输出目录(会被 fun 清空重建) +func runFunGen(moduleRoot, lang, out string) error { + targets, err := scanBindTargets(moduleRoot) + if err != nil { + return err + } + if len(targets) == 0 { + return fmt.Errorf("未在模块中找到 BindService/BindServiceForGen 调用。" + + "请确认服务注册代码,或用 -from 直接指定元数据 JSON 文件") + } + + // 清理保险:先移除可能的历史残留 + defer cleanupHelpers(targets) + cleanupHelpers(targets) + + pkgs := make([]string, 0, len(targets)) + for _, t := range targets { + if err := writeHelperTest(moduleRoot, t); err != nil { + return err + } + rel, err := filepath.Rel(moduleRoot, t.Dir) + if err != nil { + return err + } + pkg := "./" + filepath.ToSlash(rel) + pkgs = append(pkgs, pkg) + } + + args := append([]string{"test", "-vet=off", "-count=1", "-run", "^" + helperTestName + "$"}, pkgs...) + cmd := exec.Command("go", args...) + cmd.Dir = moduleRoot + cmd.Env = append(os.Environ(), "FUN_OUT="+out, "FUN_LANG="+lang) + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + if err := cmd.Run(); err != nil { + cleanupHelpers(targets) + return fmt.Errorf("采集执行失败: %w", err) + } + return nil +} + +func cleanupHelpers(targets []helperTarget) { + for _, t := range targets { + os.Remove(filepath.Join(t.Dir, helperTestFile)) + } +} + +func writeHelperTest(moduleRoot string, t helperTarget) error { + // 汇总需要的外部导入(去重,按路径排序) + imports := map[string]string{} // alias → path + for _, r := range t.Refs { + if r.Alias != "" { + imports[r.Alias] = r.Import + } + } + paths := make([]string, 0, len(imports)) + for _, p := range imports { + paths = append(paths, p) + } + sort.Strings(paths) + aliasOf := func(path string) string { + for a, p := range imports { + if p == path { + return a + } + } + return "" + } + + var imp, binds strings.Builder + imp.WriteString("\t\"os\"\n\t\"testing\"\n\n\t\"github.com/cyi-cc/fun\"\n") + for _, p := range paths { + fmt.Fprintf(&imp, "\t%s %q\n", aliasOf(p), p) + } + for _, r := range t.Refs { + if r.Alias != "" { + fmt.Fprintf(&binds, "\tf.BindServiceForGen(&%s.%s{})\n", r.Alias, r.TypeName) + } else { + fmt.Fprintf(&binds, "\tf.BindServiceForGen(&%s{})\n", r.TypeName) + } + } + + src := fmt.Sprintf(`// fun-cli 临时采集辅助(自动生成,执行完即删除) +package %s + +import ( +%s) + +func %s(t *testing.T) { + f := fun.New() +%s fun.SetOutput(os.Getenv("FUN_OUT")) + switch os.Getenv("FUN_LANG") { + case "ts": + fun.GenCode(fun.GenTs{}) + case "go": + fun.GenCode(fun.GenGo{}) + default: + fun.GenCode(fun.GenGo{}, fun.GenTs{}) + } +} +`, t.Pkg, imp.String(), helperTestName, binds.String()) + return os.WriteFile(filepath.Join(t.Dir, helperTestFile), []byte(src), 0o644) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ff6f0a4 --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +package main + +// fun-cli:fun 框架的配套命令行工具 +// +// fun gen [ts|go] 生成 TypeScript / Go 客户端 +// fun run 热更新开发循环 +// fun docs 拉起 API 文档站(参数表单 + 在线发送测试) + +import ( + "fmt" + "os" +) + +const version = "1.0.0" + +const usage = `fun-cli ` + version + ` — fun 框架配套工具 + +用法: + fun gen [ts|go] [-o 目录] [-p 模块路径] [-from meta.json] + 生成客户端代码(fun 核心直接产出,与内置 GenCode 完全一致)。 + 扫描模块内 BindService/BindServiceForGen 调用,在对应包目录临时 + 生成辅助测试执行采集,跑完即删;或 -from 直接读元数据 JSON。 + + fun run [-p 包路径] [-- 程序参数] + 热更新:改 .go/go.mod/go.sum 自动重编译重启。先编译成功再切换, + 编译失败保留旧进程。Ctrl+C 退出。 + + fun docs [-addr 地址] [-upstream 后端URL] [-p 包路径] [-no-run] [-from meta.json] + API 文档站:服务/方法树、DTO 参数表单、State 编辑、一键发送; + 流式方法实时展示 NDJSON 行。默认自动 go run 拉起后端并反向代理 /cell。 + +示例: + fun gen ts -o frontend/src/api + fun run -p ./cmd/server + fun docs -upstream http://127.0.0.1:9000 -p . + +安装: + go install github.com/cyi-cc/fun-cli@latest +` + +func main() { + if len(os.Args) < 2 { + fmt.Print(usage) + os.Exit(2) + } + switch os.Args[1] { + case "gen": + cmdGen(os.Args[2:]) + case "run": + cmdRun(os.Args[2:]) + case "docs": + cmdDocs(os.Args[2:]) + case "-v", "version": + fmt.Println("fun-cli", version) + default: + fmt.Fprintf(os.Stderr, "未知命令: %s\n\n", os.Args[1]) + fmt.Print(usage) + os.Exit(2) + } +} diff --git a/meta.go b/meta.go new file mode 100644 index 0000000..38849b5 --- /dev/null +++ b/meta.go @@ -0,0 +1,55 @@ +package main + +// 元数据 JSON 契约:与 fun 核心库 meta.go 的导出结构一一对应。 +// CLI 不依赖 fun 模块,仅消费该 JSON,保持零第三方依赖 + +import ( + "encoding/json" + "os" +) + +type FunMeta struct { + Services []MetaService `json:"services"` +} + +type MetaService struct { + Name string `json:"name"` + Methods []MetaMethod `json:"methods"` +} + +type MetaMethod struct { + Name string `json:"name"` + IsStream bool `json:"isStream"` + Void bool `json:"void,omitempty"` + DTO *MetaType `json:"dto,omitempty"` + Returns *MetaType `json:"returns,omitempty"` +} + +type MetaType struct { + Kind string `json:"kind"` // int/bool/string/struct/slice/enum + Name string `json:"name,omitempty"` + Optional bool `json:"optional,omitempty"` + Fields []MetaField `json:"fields,omitempty"` + Elem *MetaType `json:"elem,omitempty"` + + Names []string `json:"names,omitempty"` + DisplayNames []string `json:"displayNames,omitempty"` +} + +type MetaField struct { + Name string `json:"name"` + Type *MetaType `json:"type"` +} + +// loadMeta 从文件读取元数据 +func loadMeta(path string) (*FunMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta FunMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..0646216 --- /dev/null +++ b/render.go @@ -0,0 +1,151 @@ +package main + +// 渲染层共享类型与工具:模板数据结构与 fun v1.3.7 gen.go 保持一致, +// 保证产物字节级不变 + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/template" +) + +type genType struct { + GenServiceList []*genServiceType +} + +type genMethodType struct { + MethodName string + ReturnValueText string + DtoText string + ArgsText string + GenericTypeText string + IsProxy bool + IsStream bool +} + +type genEnumType struct { + Names []string + DisplayNames []string + Name string +} + +type genImportType struct { + Name string +} + +type genServiceType struct { + ServiceName string + GenMethodTypeList []*genMethodType + GenImport []*genImportType + IsIncludeProxy bool + IsIncludeRequest bool + IsIncludeStream bool +} + +type genClassType struct { + Name string + GenImport []*genImportType + GenClassFieldType []*genClassFieldType +} + +type genClassFieldType struct { + Name string + Type string + Tag string +} + +func deduplicateServiceImports(imports []*genImportType) []*genImportType { + seen := make(map[string]bool) + var result []*genImportType + for _, imp := range imports { + if !seen[imp.Name] { + seen[imp.Name] = true + result = append(result, imp) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func parseGenericTypeParams(typeName string) string { + start := strings.Index(typeName, "[") + end := strings.LastIndex(typeName, "]") + paramsStr := typeName[start+1 : end] + params := strings.Split(paramsStr, ",") + for i, param := range params { + LL := strings.Split(strings.TrimSpace(param), ".") + params[i] = firstLetterToUpper(LL[len(LL)-1]) + } + return strings.Join(params, "") +} + +func getGenericTypeName(typeName string) string { + start := strings.Index(typeName, "[") + return typeName[0:start] +} + +func camelToSnake(s string) string { + re := regexp.MustCompile(`([a-z0-9])([A-Z])`) + snake := re.ReplaceAllString(s, `${1}_${2}`) + return strings.ToLower(snake) +} + +func firstLetterToUpper(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func firstLetterToLower(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} + +// fileSet 渲染产物集合:相对路径 → 内容 +type fileSet map[string][]byte + +// renderCode 模板渲染(等价旧 genCode 的模板执行部分,写盘由调用方统一处理) +func renderCode(templateContent string, outputFileName string, templateData any, languageName string, files fileSet) { + tmpl, err := template.New(languageName).Parse(templateContent) + if err != nil { + panic(err.Error()) + } + var buf bytes.Buffer + err = tmpl.Execute(&buf, templateData) + if err != nil { + panic(err.Error()) + } + files[filepath.Join(languageName, outputFileName+"."+languageName)] = buf.Bytes() +} + +// writeFiles 清空输出目录后写入全部产物 +func writeFiles(dir string, files fileSet) error { + if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err + } + if err := os.WriteFile(full, files[name], 0o644); err != nil { + return err + } + } + return nil +} diff --git a/render_go.go b/render_go.go new file mode 100644 index 0000000..d66241f --- /dev/null +++ b/render_go.go @@ -0,0 +1,194 @@ +package main + +// Go 渲染器:fun v1.3.7 gen_go.go 的等价移植,输入从 reflect.Type 换成元数据类型树 + +import "strings" + +type renderGo struct{} + +// typeName 等价旧 typeToTemplateType:指针 → "*" 前缀,切片 → "[]",其余用原始类型名 +func (renderGo) typeName(t *MetaType) string { + prefix := "" + if t.Optional { + prefix = "*" + } + if t.Kind == "slice" { + return prefix + "[]" + renderGo{}.typeName(t.Elem) + } + return prefix + t.Name +} + +func (r renderGo) genService(svc MetaService, serviceContext *genServiceType, files fileSet) { + for _, gm := range svc.Methods { + var returnValueText string + var dtoText string + var argsText string + var genericTypeText string + + // () error:无数据返回,客户端类型为 Void + if gm.Void { + genericTypeText = "Void" + returnValueText = "Result[Void]" + if gm.DTO != nil { + v := r.typeName(gm.DTO) + if !strings.Contains(v, "[]") && strings.Contains(v, "[") { + dtoText += "dto " + getGenericTypeName(v) + parseGenericTypeParams(v) + } else { + dtoText += "dto " + v + } + argsText += ",dto" + r.genStruct(gm.DTO, files) + } + serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{ + MethodName: gm.Name, + ReturnValueText: returnValueText, + DtoText: dtoText, + ArgsText: argsText, + GenericTypeText: genericTypeText, + }) + continue + } + + if gm.IsStream { + serviceContext.IsIncludeStream = true + if gm.Returns == nil { + genericTypeText = "any" + returnValueText = "Void" + } else { + t := r.typeName(gm.Returns) + if !strings.Contains(t, "[]") && strings.Contains(t, "[") { + genericTypeText = getGenericTypeName(t) + parseGenericTypeParams(t) + } else { + genericTypeText = t + } + returnValueText = genericTypeText + r.genReturnTypes(gm.Returns, files) + } + } else { + t := r.typeName(gm.Returns) + if !strings.Contains(t, "[]") && strings.Contains(t, "[") { + returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t) + } else { + returnValueText = t + } + genericTypeText = returnValueText + r.genReturnTypes(gm.Returns, files) + returnValueText = "Result[" + returnValueText + "]" + } + + if gm.DTO != nil { + v := r.typeName(gm.DTO) + if !strings.Contains(v, "[]") && strings.Contains(v, "[") { + dtoText += "dto " + getGenericTypeName(v) + parseGenericTypeParams(v) + } else { + dtoText += "dto " + v + } + argsText += ",dto" + r.genStruct(gm.DTO, files) + } + + serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{ + MethodName: gm.Name, + ReturnValueText: returnValueText, + DtoText: dtoText, + ArgsText: argsText, + GenericTypeText: genericTypeText, + IsStream: gm.IsStream, + }) + } + renderCode(templateGo{}.genServiceTemplate(), camelToSnake(svc.Name), serviceContext, "go", files) +} + +// genReturnTypes 递归生成返回类型涉及的 struct/enum 定义 +func (r renderGo) genReturnTypes(returnType *MetaType, files fileSet) { + if returnType.Kind == "struct" { + r.genStruct(returnType, files) + } + if returnType.Kind == "slice" && returnType.Elem.Kind == "struct" { + r.genStruct(returnType.Elem, files) + } + if returnType.Kind == "enum" { + r.getEnum(returnType, files) + } +} + +func (r renderGo) renderAll(meta *FunMeta, files fileSet) { + genContext := genType{GenServiceList: []*genServiceType{}} + + for _, svc := range meta.Services { + serviceContext := &genServiceType{ + ServiceName: svc.Name, + GenMethodTypeList: []*genMethodType{}, + } + genContext.GenServiceList = append(genContext.GenServiceList, serviceContext) + r.genService(svc, serviceContext, files) + } + renderCode(templateGo{}.genDefaultServiceTemplate(), "fun", genContext, "go", files) +} + +func (r renderGo) genStruct(t *MetaType, files fileSet) *genImportType { + var structTemplate genClassType + if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") { + structTemplate = genClassType{ + Name: getGenericTypeName(t.Name) + parseGenericTypeParams(t.Name), + } + } else { + structTemplate = genClassType{ + Name: t.Name, + } + } + + for _, field := range t.Fields { + fieldType := field.Type + jsType := r.typeName(fieldType) + tag := "`json:\"" + firstLetterToLower(field.Name) + "\"`" + if !strings.Contains(jsType, "[]") && strings.Contains(jsType, "[") { + structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{ + Name: field.Name, + Type: getGenericTypeName(jsType) + parseGenericTypeParams(jsType), + Tag: tag, + }) + } else { + structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{ + Name: field.Name, + Type: jsType, + Tag: tag, + }) + } + + if fieldType.Kind == "struct" { + r.genStruct(fieldType, files) + } + if fieldType.Kind == "slice" && fieldType.Elem.Kind == "struct" { + r.genStruct(fieldType.Elem, files) + } + if fieldType.Kind == "enum" { + r.getEnum(fieldType, files) + } + } + + renderCode( + templateGo{}.genStructTemplate(), + camelToSnake(structTemplate.Name), + structTemplate, + "go", + files, + ) + return &genImportType{} +} + +func (r renderGo) getEnum(t *MetaType, files fileSet) *genImportType { + var enumTemplate genEnumType + enumTemplate.Names = t.Names + enumTemplate.DisplayNames = t.DisplayNames + enumTemplate.Name = t.Name + + renderCode( + templateGo{}.genEnumTemplate(), + camelToSnake(t.Name), + enumTemplate, + "go", + files, + ) + return &genImportType{} +} diff --git a/render_ts.go b/render_ts.go new file mode 100644 index 0000000..ded5580 --- /dev/null +++ b/render_ts.go @@ -0,0 +1,225 @@ +package main + +// TS 渲染器:fun v1.3.7 gen_ts.go 的等价移植,输入从 reflect.Type 换成元数据类型树 + +import "strings" + +type renderTs struct{} + +// typeName 等价旧 typeToTemplateType:指针 → " | null" 后缀,切片 → "[]", +// 整数 → number(枚举除外),struct/string → 类型名 +func (renderTs) typeName(t *MetaType) string { + suffix := "" + if t.Optional { + suffix = " | null" + } + switch t.Kind { + case "enum": + return t.Name + suffix + case "int": + return "number" + suffix + case "bool": + return "boolean" + suffix + case "string", "struct": + return t.Name + suffix + default: // slice + return renderTs{}.typeName(t.Elem) + "[]" + suffix + } +} + +func (r renderTs) genService(svc MetaService, serviceContext *genServiceType, files fileSet) { + var nestedImports []*genImportType + + for _, gm := range svc.Methods { + var returnValueText string + var dtoText string + var argsText string + var genericTypeText string + + // () error:无数据返回,客户端类型为 void + if gm.Void { + genericTypeText = "void" + returnValueText = "result" + if gm.DTO != nil { + v := firstLetterToLower(r.typeName(gm.DTO)) + if !strings.Contains(v, "[]") && strings.Contains(v, "[") { + dtoText += "dto:" + getGenericTypeName(v) + parseGenericTypeParams(v) + } else { + dtoText += "dto:" + v + } + argsText += ",dto" + nestedImports = append(nestedImports, r.genStruct(gm.DTO, files)) + } + serviceContext.IsIncludeRequest = true + serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{ + MethodName: firstLetterToLower(gm.Name), + ReturnValueText: returnValueText, + DtoText: dtoText, + ArgsText: argsText, + GenericTypeText: genericTypeText, + }) + continue + } + + if gm.IsStream { + serviceContext.IsIncludeStream = true + if gm.Returns == nil { + genericTypeText = "any" + returnValueText = "void" + } else { + t := firstLetterToLower(r.typeName(gm.Returns)) + if !strings.Contains(t, "[]") && strings.Contains(t, "[") { + genericTypeText = getGenericTypeName(t) + parseGenericTypeParams(t) + } else { + genericTypeText = t + } + returnValueText = "void" + nestedImports = r.genReturnTypes(gm.Returns, nestedImports, files) + } + } else { + serviceContext.IsIncludeRequest = true + t := firstLetterToLower(r.typeName(gm.Returns)) + if !strings.Contains(t, "[]") && strings.Contains(t, "[") { + returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t) + } else { + returnValueText = t + } + genericTypeText = returnValueText + nestedImports = r.genReturnTypes(gm.Returns, nestedImports, files) + returnValueText = "result<" + returnValueText + ">" + } + + if gm.DTO != nil { + v := firstLetterToLower(r.typeName(gm.DTO)) + if !strings.Contains(v, "[]") && strings.Contains(v, "[") { + dtoText += "dto:" + getGenericTypeName(v) + parseGenericTypeParams(v) + } else { + dtoText += "dto:" + v + } + argsText += ",dto" + nestedImports = append(nestedImports, r.genStruct(gm.DTO, files)) + } + + serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{ + MethodName: firstLetterToLower(gm.Name), + ReturnValueText: returnValueText, + DtoText: dtoText, + ArgsText: argsText, + GenericTypeText: firstLetterToLower(genericTypeText), + IsStream: gm.IsStream, + }) + } + serviceContext.GenImport = deduplicateServiceImports(nestedImports) + + renderCode( + templateTs{}.genServiceTemplate(), + firstLetterToLower(svc.Name), + serviceContext, + "ts", + files, + ) +} + +// genReturnTypes 递归生成返回类型涉及的 struct/enum 导入 +func (r renderTs) genReturnTypes(returnType *MetaType, nestedImports []*genImportType, files fileSet) []*genImportType { + if returnType.Kind == "struct" { + nestedImports = append(nestedImports, r.genStruct(returnType, files)) + } + if returnType.Kind == "slice" && returnType.Elem.Kind == "struct" { + nestedImports = append(nestedImports, r.genStruct(returnType.Elem, files)) + } + if returnType.Kind == "enum" { + nestedImports = append(nestedImports, r.getEnum(returnType, files)) + } + return nestedImports +} + +func (r renderTs) renderAll(meta *FunMeta, files fileSet) { + genContext := genType{GenServiceList: []*genServiceType{}} + + for _, svc := range meta.Services { + serviceContext := &genServiceType{ + ServiceName: firstLetterToLower(svc.Name), + GenMethodTypeList: []*genMethodType{}, + } + genContext.GenServiceList = append(genContext.GenServiceList, serviceContext) + r.genService(svc, serviceContext, files) + } + renderCode(templateTs{}.genClientTemplate(), "client", nil, "ts", files) + renderCode(templateTs{}.genDefaultServiceTemplate(), "fun", genContext, "ts", files) +} + +func (r renderTs) genStruct(t *MetaType, files fileSet) *genImportType { + var structTemplate genClassType + if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") { + structTemplate = genClassType{ + Name: firstLetterToLower(getGenericTypeName(t.Name)) + parseGenericTypeParams(t.Name), + } + } else { + structTemplate = genClassType{ + Name: firstLetterToLower(t.Name), + } + } + var nestedImports []*genImportType + + for _, field := range t.Fields { + fieldType := field.Type + jsType := r.typeName(fieldType) + name := field.Name + if fieldType.Optional { + name += "?" + } + if !strings.Contains(jsType, "[]") && strings.Contains(jsType, "[") { + structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{ + Name: firstLetterToLower(name), + Type: firstLetterToLower(getGenericTypeName(jsType)) + parseGenericTypeParams(jsType), + }) + } else { + structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{ + Name: firstLetterToLower(name), + Type: firstLetterToLower(jsType), + }) + } + + if fieldType.Kind == "struct" { + nestedImports = append(nestedImports, r.genStruct(fieldType, files)) + } + if fieldType.Kind == "slice" && fieldType.Elem.Kind == "struct" { + nestedImports = append(nestedImports, r.genStruct(fieldType.Elem, files)) + } + if fieldType.Kind == "enum" { + nestedImports = append(nestedImports, r.getEnum(fieldType, files)) + } + } + + structTemplate.GenImport = deduplicateServiceImports(nestedImports) + + renderCode( + templateTs{}.genStructTemplate(), + structTemplate.Name, + structTemplate, + "ts", + files, + ) + + if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") { + return &genImportType{Name: structTemplate.Name} + } + return &genImportType{Name: firstLetterToLower(t.Name)} +} + +func (r renderTs) getEnum(t *MetaType, files fileSet) *genImportType { + var enumTemplate genEnumType + enumTemplate.Names = t.Names + enumTemplate.DisplayNames = t.DisplayNames + enumTemplate.Name = firstLetterToLower(t.Name) + + renderCode( + templateTs{}.genEnumTemplate(), + firstLetterToLower(t.Name), + enumTemplate, + "ts", + files, + ) + return &genImportType{Name: firstLetterToLower(t.Name)} +} diff --git a/run.go b/run.go new file mode 100644 index 0000000..0a703dd --- /dev/null +++ b/run.go @@ -0,0 +1,196 @@ +package main + +// fun run:热更新开发循环——改代码自动重编译重启。 +// 纯标准库实现:轮询 mtime 检测变更(400ms),先编译成功再切换进程, +// 编译失败保留旧进程继续跑 + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +func cmdRun(args []string) { + pkg := "." + var childArgs []string + for i := 0; i < len(args); i++ { + if args[i] == "-p" && i+1 < len(args) { + pkg = args[i+1] + i++ + continue + } + if args[i] == "--" { + childArgs = append(childArgs, args[i+1:]...) + break + } + if strings.HasPrefix(args[i], "-p") && len(args[i]) > 2 { + pkg = args[i][2:] + continue + } + childArgs = append(childArgs, args[i]) + } + + binPath := filepath.Join(os.TempDir(), fmt.Sprintf("fun-run-%d", os.Getpid())) + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + var child *exec.Cmd + var childDone chan struct{} + restart := make(chan struct{}, 1) + + // 初始编译 + if !build(pkg, binPath) { + fmt.Println("fun: 初始编译失败,等待文件变更后重试") + } else { + child, childDone = spawn(binPath, childArgs) + } + + go watchLoop(pkg, restart) + + for { + select { + case <-sigs: + fmt.Println("\nfun: 退出") + killChild(child, childDone) + return + case <-restart: + fmt.Println("fun: 检测到变更,重新编译...") + if !build(pkg, binPath) { + fmt.Println("fun: 编译失败,旧进程继续运行") + continue + } + killChild(child, childDone) + child, childDone = spawn(binPath, childArgs) + } + } +} + +func build(pkg, binPath string) bool { + cmd := goCommand("build", pkg, "-o", binPath) + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + return cmd.Run() == nil +} + +func spawn(binPath string, args []string) (*exec.Cmd, chan struct{}) { + cmd := exec.Command(binPath, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + // 独立进程组:杀父进程时连带子进程(业务可能 fork 了 worker) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + done := make(chan struct{}) + if err := cmd.Start(); err != nil { + fmt.Fprintln(os.Stderr, "fun: 启动失败:", err) + close(done) + return nil, done + } + go func() { + if err := cmd.Wait(); err != nil { + fmt.Fprintf(os.Stderr, "fun: 进程退出: %v\n", err) + } + close(done) + }() + return cmd, done +} + +func killChild(child *exec.Cmd, done chan struct{}) { + if child == nil || child.Process == nil { + return + } + syscall.Kill(-child.Process.Pid, syscall.SIGTERM) + if done == nil { + done = make(chan struct{}) + go func() { child.Wait(); close(done) }() + } + select { + case <-done: + case <-time.After(3 * time.Second): + syscall.Kill(-child.Process.Pid, syscall.SIGKILL) + } +} + +var skipDirs = map[string]bool{ + ".git": true, "vendor": true, "node_modules": true, ".fun": true, + "gen": true, "testdata": true, +} + +// snapshot 收集监控文件的 (路径, mtime) +func snapshot(root string) map[string]int64 { + out := map[string]int64{} + filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + name := d.Name() + if d.IsDir() { + if path != root && skipDirs[name] { + return filepath.SkipDir + } + return nil + } + ext := filepath.Ext(name) + if ext == ".go" || name == "go.mod" || name == "go.sum" { + if info, err := d.Info(); err == nil { + out[path] = info.ModTime().UnixNano() + } + } + return nil + }) + return out +} + +func diffSnapshot(a, b map[string]int64) bool { + if len(a) != len(b) { + return true + } + for k, v := range a { + if b[k] != v { + return true + } + } + return false +} + +func watchLoop(root string, changed chan struct{}) { + last := snapshot(root) + var pending bool + var pendingAt time.Time + for { + time.Sleep(400 * time.Millisecond) + cur := snapshot(root) + if !diffSnapshot(last, cur) { + // 静默 300ms 后触发(编辑器分多次写盘时合并) + if pending && time.Since(pendingAt) > 300*time.Millisecond { + pending = false + select { + case changed <- struct{}{}: + default: + } + } + last = cur + continue + } + last = cur + if !pending { + pending = true + pendingAt = time.Now() + } else { + pendingAt = time.Now() + } + } +} + +// sortedKeys 调试用(保留给未来扩展) +func sortedKeys(m map[string]int64) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..c7d3c39 --- /dev/null +++ b/templates.go @@ -0,0 +1,1026 @@ +package main + +// 模板与 fun v1.3.7 核心库的字节级一致移植(gen 产物保持不变) + +type templateGo struct{} + +func (ctx templateGo) genDefaultServiceTemplate() string { + return `package api + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// Result 统一响应结构 +type Result[T any] struct { + Code *uint16 + Data *T + Msg *string + Status uint8 +} + +func (r Result[T]) Error() string { + if r.Msg != nil { + return *r.Msg + } + if r.Code != nil { + return fmt.Sprintf("code=%d", *r.Code) + } + return "api: unknown error" +} + +// Void 用于无数据返回的方法 +type Void = struct{} + +// RequestInterceptor 请求前拦截器:可鉴权、加签、改 dto;返回 error 则直接失败 +type RequestInterceptor func(serviceName string, methodName string, dto any) error + +// ResponseInterceptor 响应后拦截器:可记录日志、埋点、解密;返回 error 则转为失败响应 +type ResponseInterceptor func(serviceName string, methodName string, result Result[any]) error + +// Client 内联 HTTP 客户端,不依赖外部 funclient 包 +type Client struct { + url string + client *http.Client + state map[string]string + requestInterceptors []RequestInterceptor + responseInterceptors []ResponseInterceptor +} + +// NewClient 创建客户端 +func NewClient(url string) (*Client, error) { + return &Client{ + url: strings.TrimRight(url, "/"), + client: &http.Client{}, + }, nil +} + +// SetHttpClient 替换底层 http.Client +func (c *Client) SetHttpClient(client *http.Client) { + c.client = client +} + +// AddRequestInterceptor 注册请求前拦截器 +func (c *Client) AddRequestInterceptor(i RequestInterceptor) { + c.requestInterceptors = append(c.requestInterceptors, i) +} + +// AddResponseInterceptor 注册响应后拦截器 +func (c *Client) AddResponseInterceptor(i ResponseInterceptor) { + c.responseInterceptors = append(c.responseInterceptors, i) +} + +// SetState 设置随每个请求携带的状态(如 token),服务端 Guard 可读取 +func (c *Client) SetState(state map[string]string) { + c.state = state +} + +// Request 发起普通调用 +func Request[T any](c *Client, serviceName string, methodName string, dto ...any) Result[T] { + payload := newPayload(serviceName, methodName, dto, c.state) + b, err := json.Marshal(payload) + if err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + for _, i := range c.requestInterceptors { + var dtoVal any + if len(dto) > 0 { + dtoVal = dto[0] + } + if err := i(serviceName, methodName, dtoVal); err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + } + req, err := http.NewRequest(http.MethodPost, c.url+"/cell", bytes.NewReader(b)) + if err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + defer resp.Body.Close() + var out Result[T] + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + var anyData *any + if out.Data != nil { + v := any(*out.Data) + anyData = &v + } + anyResult := Result[any]{Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status} + for _, i := range c.responseInterceptors { + if err := i(serviceName, methodName, anyResult); err != nil { + return Result[T]{Status: 2, Msg: ptr(err.Error())} + } + } + return out +} + +// Stream 发起流式调用,通过 NDJSON 行逐个推送消息(Streamable HTTP) +func Stream[T any](c *Client, serviceName string, methodName string, dto ...any) (<-chan T, error) { + payload := newPayload(serviceName, methodName, dto, c.state) + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + for _, i := range c.requestInterceptors { + var dtoVal any + if len(dto) > 0 { + dtoVal = dto[0] + } + if err := i(serviceName, methodName, dtoVal); err != nil { + return nil, err + } + } + req, err := http.NewRequest(http.MethodPost, c.url+"/cell", bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode) + } + anyResult := Result[any]{Status: 0} + for _, i := range c.responseInterceptors { + if err := i(serviceName, methodName, anyResult); err != nil { + resp.Body.Close() + return nil, err + } + } + ch := make(chan T) + go func() { + defer resp.Body.Close() + defer close(ch) + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var msg T + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + ch <- msg + } + }() + return ch, nil +} + +func newPayload(serviceName string, methodName string, dto []any, state map[string]string) map[string]any { + payload := map[string]any{ + "serviceName": serviceName, + "methodName": methodName, + } + if len(dto) > 0 { + payload["data"] = dto[0] + } + if len(state) > 0 { + payload["state"] = state + } + return payload +} + +func ptr(s string) *string { return &s } + +type Api struct { +{{- range .GenServiceList}} + {{.ServiceName}} *{{.ServiceName}} +{{- end}} + *Client +} + +func CreateApi(url string) (Api, error) { + apiClient, err := NewClient(url) + return Api{ +{{- range .GenServiceList}} + {{.ServiceName}}: New{{.ServiceName}}(apiClient), +{{- end}} + Client: apiClient, + }, err +}` +} + +func (ctx templateGo) genServiceTemplate() string { + return `package api + +type {{.ServiceName}} struct { + *Client +} + +func New{{.ServiceName}}(client *Client) *{{.ServiceName}} { + return &{{.ServiceName}}{ + Client: client, + } +} + +{{- $serviceName := .ServiceName }} +{{- range .GenMethodTypeList}} +{{if .IsStream }}func (ctx *{{$serviceName}}) {{.MethodName}}({{.DtoText}}) (<-chan {{.GenericTypeText}}, error) { + return Stream[{{.GenericTypeText}}](ctx.Client, "{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}}) +}{{else}}func (ctx *{{$serviceName}}) {{.MethodName}}({{.DtoText}}) {{.ReturnValueText}} { + return Request[{{.GenericTypeText}}](ctx.Client, "{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}}) +}{{end}} +{{- end}}` +} + +func (ctx templateGo) genStructTemplate() string { + return `package api + +type {{.Name}} struct{ + {{- range .GenClassFieldType}} + {{.Name}} {{.Type}} {{.Tag}} + {{- end}} +}` +} + +func (ctx templateGo) genEnumTemplate() string { + return `package api + +type {{.Name}} uint8 + +{{$enumName := .Name}} +const ( +{{- range $index, $element := .Names}} + {{$element}}{{if eq $index 0}} {{$enumName}} = iota{{end}} +{{- end}} +) + +func ({{.Name}}) Values() []{{.Name}} { + return []{{.Name}}{ +{{- range $index, $element := .Names}} + {{$element}}, +{{- end}} + } +} + +{{if .DisplayNames}} +func ({{.Name}}) DisplayNames() []string { + return []string{ +{{- range $index, $element := .DisplayNames}} + "{{$element}}", +{{- end}} + } +} +{{end}}` +} + +type templateTs struct{} + +func (ctx templateTs) genClientTemplate() string { + return `// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量 +// 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。 +// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可 +const BIGINT_PREFIX = "\u0000fun-bigint:"; + +function reviveBigint(value: any): any { + if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) && + /^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) { + return BigInt(value.slice(BIGINT_PREFIX.length)); + } + if (Array.isArray(value)) return value.map(reviveBigint); + if (value !== null && typeof value === "object") { + for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]); + return value; + } + return value; +} + +function parseLossless(text: string): any { + // 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先, + // 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt + const guarded = text.replace( + /"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, + token => { + if (token.startsWith('"')) return token; + if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) { + // 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符), + // JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头) + return '"\\u0000fun-bigint:' + token + '"'; + } + return token; + } + ); + return reviveBigint(JSON.parse(guarded)); +} + +function stringifyLossless(value: any): string | undefined { + const raw = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v + ); + if (raw === undefined) return undefined; + // 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"(NUL 被转义),去引号还原为数字字面量 + return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1"); +} + +export type result = { + code?: number; + data?: T; + msg?: 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; +}; + +export type StreamOptions = { + signal?: AbortSignal; + state?: Record; + /** + * 原生断线重连:流因传输失败(status 4/5)结束时按指数退避自动重连。 + * dto 传工厂函数时每次重连重新求值,用于刷新续传游标。 + * 未传时关闭(默认),与 request 语义一致。 + */ + retry?: StreamRetryOptions | false; + /** 空闲看门狗:超过该毫秒数未收到任何字节(含心跳空行)即判定流死亡并走重连。0 关闭。 */ + idleTimeoutMs?: number; +}; + +export type StreamRetryOptions = { + /** 最大重连次数,默认 Infinity(直到 signal 取消或成功)。 */ + maxAttempts?: number; + /** 首次重连延迟(毫秒),默认 500。 */ + baseDelayMs?: number; + /** 退避上限(毫秒),默认 10000。实际延迟为指数退避 × 0.75~1.25 抖动。 */ + maxDelayMs?: number; +}; + +export type RequestInterceptor = ( + serviceName: string, + methodName: string, + state: Record, + dto?: any +) => Promise | void; + +export type ResponseContext = { + readonly requestState: Readonly>; + readonly response?: Response; +}; + +export type ResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result +) => Promise | void> | result | void; + +export type ContextResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result, + context: ResponseContext +) => Promise | void> | result | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const body = text.trim(); + if (!body) { + return response.ok + ? failure(1, "Empty response body") + : externalFailure(response); + } + + let value: unknown; + try { + value = parseLossless(body); + } catch { + if (!response.ok) return externalFailure(response, excerpt(body)); + const type = mediaType(response); + if (type === "text/html" || /^\s*(?: = {}; + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ContextResponseInterceptor[] = []; + + constructor(url: string) { + this.url = url.replace(/\/+$/, ""); + } + + setState(state: Record) { + this.state = state; + } + + addRequestInterceptor(interceptor: RequestInterceptor) { + this.requestInterceptors.push(interceptor); + } + + addResponseInterceptor(interceptor: ResponseInterceptor): void; + addResponseInterceptor(interceptor: ContextResponseInterceptor): void; + addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) { + this.responseInterceptors.push(interceptor as ContextResponseInterceptor); + } + + private async interceptResponse( + serviceName: string, + methodName: string, + initial: result, + requestState: Readonly>, + response?: Response + ): Promise> { + 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)}`" + `); + } + } + return current; + } + + private async interceptRequest( + serviceName: string, + methodName: string, + state: Record, + dto: any + ): Promise { + for (const interceptor of this.requestInterceptors) { + await interceptor(serviceName, methodName, state, dto); + } + } + + private snapshotState(state: Record): Readonly> { + return Object.freeze({ ...state }); + } + + async request( + serviceName: string, + methodName: string, + dto?: any, + options?: RequestOptions + ): Promise> { + let state: Record; + 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; + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + 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; + } + return await this.interceptResponse( + serviceName, + methodName, + failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `), + requestState + ) as result; + } + let requestState: Readonly>; + 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; + } + + let body: string; + try { + const serialized = stringifyLossless({ + 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; + } + + let output: result; + 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; + } + + async stream( + serviceName: string, + methodName: string, + dto: any | undefined | (() => any | undefined), + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + // dto 工厂:重连时重新求值,用于刷新续传游标(如 afterId) + const getDto = typeof dto === "function" ? (dto as () => any | undefined) : () => dto; + // 重连默认关闭(与 request 语义一致);传 retry: {...} 显式开启 + const retry = options?.retry + ? { + maxAttempts: Infinity, + baseDelayMs: 500, + maxDelayMs: 10000, + ...options.retry, + } + : null; + for (let attempt = 0; ; attempt++) { + const r = await this.streamOnce(serviceName, methodName, getDto(), onMessage, options); + // 0 成功、1 协议错误、2 业务错误:终结态,不重连 + if (r.status !== 4 && r.status !== 5) return r; + if (!retry) return r; + if (options?.signal?.aborted) return r; + if (attempt + 1 > retry.maxAttempts) return r; + const delay = + Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** attempt) * (0.75 + Math.random() * 0.5); + // 退避等待:AbortSignal.timeout 免定时器 API,且后台标签页照常走时 + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(); + return; + } + AbortSignal.timeout(delay).addEventListener("abort", () => resolve(), { once: true }); + options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + if (options?.signal?.aborted) return r; + } + } + + private async streamOnce( + serviceName: string, + methodName: string, + dto: any | undefined, + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + let state: Record; + 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({}) + ); + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + 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>; + 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 = stringifyLossless({ + 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; + 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 = ""; + let lineNumber = 0; + let failed: result | undefined; + let cause: unknown; + + const emitLine = async (line: string) => { + const payload = line.replace(/\r$/, "").trim(); + if (!payload) return; + let data: T; + try { + data = parseLossless(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 { + // 空闲看门狗:idleTimeoutMs 内未收到任何字节(含心跳空行)判定流死亡, + // 抛出哨兵错误 → status 5 → 交给上层重连。服务端 25s 心跳时建议 35000。 + const STREAM_IDLE = Symbol("stream-idle"); + for (;;) { + let part: ReadableStreamReadResult; + try { + if (options?.idleTimeoutMs) { + const idlePromise = new Promise((_, reject) => { + AbortSignal.timeout(options.idleTimeoutMs!).addEventListener( + "abort", + () => reject(STREAM_IDLE), + { once: true } + ); + }); + idlePromise.catch(() => {}); // read 先返回时防 unhandled rejection + part = await Promise.race([reader.read(), idlePromise]); + } else { + part = await reader.read(); + } + } catch (error) { + cause = error; + if (error === STREAM_IDLE) { + failed = failure(5, "Stream idle timeout"); + } else 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 } from "./client"; +{{- range .GenServiceList}} +import {{.ServiceName}} from "./{{.ServiceName}}"; +{{- end}} + +export class defaultApi extends Client { + constructor(url: string) { + super(url); + } + {{- range .GenServiceList}} + public {{.ServiceName}}: {{.ServiceName}} = new {{.ServiceName}}(this); + {{- end}} +} + +export default class api { + static create(url: string): defaultApi { + return new defaultApi(url); + } +}` +} + +func (ctx templateTs) genServiceTemplate() string { + 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}} + +export default class {{.ServiceName}} { + private client: Client; + constructor(client: Client) { + this.client = client; + } + {{- $serviceName := .ServiceName }} + {{- range .GenMethodTypeList}} + {{if .IsStream }}async {{.MethodName}}({{if .DtoText}}dto: {{.DtoText}} | (() => {{.DtoText}}), {{end}}onMessage: (data: {{.GenericTypeText}}) => unknown, options?: StreamOptions): Promise> { + 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}} +}` +} + +func (ctx templateTs) genStructTemplate() string { + return `{{- range .GenImport}}import type {{.Name}} from "./{{.Name}}";{{"\n"}}{{- end}}export default interface {{.Name}} { + {{- range .GenClassFieldType}} + {{.Name}}:{{.Type}} + {{- end}} +}` +} + +func (ctx templateTs) genEnumTemplate() string { + return `enum {{.Name}} { +{{- range $index, $element := .Names}} + {{$element}}, +{{- end}} +}{{ $enumName := .Name }} +function values(): {{.Name}}[] { + return [ +{{- range $index, $element := .Names}} + {{$enumName}}.{{$element}}, +{{- end}} + ] +} +{{if .DisplayNames}} +function displayNames(): string[] { + return [ +{{- range $index, $element := .DisplayNames}} + "{{$element}}", +{{- end}} + ] +} +{{end}} +export default {{.Name}}` +} diff --git a/testdata/alpha.json b/testdata/alpha.json new file mode 100644 index 0000000..5d50a01 --- /dev/null +++ b/testdata/alpha.json @@ -0,0 +1 @@ +{"services":[{"name":"AlphaGenSvc","methods":[{"name":"Alpha","dto":{"kind":"struct","name":"AlphaGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"struct","name":"ZebraGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]}},{"name":"Ping","void":true},{"name":"Zebra","dto":{"kind":"struct","name":"ZebraGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"struct","name":"AlphaGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]}}]},{"name":"MixedGenSvc","methods":[{"name":"Request","returns":{"kind":"string","name":"string"}},{"name":"Stream","isStream":true}]},{"name":"ZebraGenSvc","methods":[{"name":"Watch","isStream":true}]}]} \ No newline at end of file diff --git a/testdata/demo.json b/testdata/demo.json new file mode 100644 index 0000000..d97c324 --- /dev/null +++ b/testdata/demo.json @@ -0,0 +1 @@ +{"services":[{"name":"ChatSvc","methods":[{"name":"Ask","isStream":true,"dto":{"kind":"struct","name":"AskDto","fields":[{"name":"Prompt","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"string","name":"string"}},{"name":"Chat","isStream":true,"dto":{"kind":"struct","name":"ChatDto","fields":[{"name":"Prompt","type":{"kind":"string","name":"string"}}]}}]},{"name":"OrderSvc","methods":[{"name":"Cancel","isStream":false,"void":true,"dto":{"kind":"struct","name":"CancelDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Reason","type":{"kind":"string","name":"string","optional":true}}]}},{"name":"Create","isStream":false,"dto":{"kind":"struct","name":"CreateOrderDto","fields":[{"name":"Sku","type":{"kind":"string","name":"string"}},{"name":"Count","type":{"kind":"int","name":"int64"}},{"name":"Note","type":{"kind":"string","name":"string","optional":true}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","optional":true,"names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Tags","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]},"returns":{"kind":"struct","name":"OrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Amount","type":{"kind":"string","name":"string"}},{"name":"Items","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]}},{"name":"Get","isStream":false,"dto":{"kind":"struct","name":"GetOrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}}]},"returns":{"kind":"struct","name":"OrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Amount","type":{"kind":"string","name":"string"}},{"name":"Items","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]}}]}]} diff --git a/tsparse.go b/tsparse.go new file mode 100644 index 0000000..180a99b --- /dev/null +++ b/tsparse.go @@ -0,0 +1,186 @@ +package main + +// 解析 fun 生成的 TypeScript 客户端产物,重建文档站所需的元数据。 +// 生成物即元数据:fun 自己的反射保证类型准确,CLI 只做只读解析 + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + reServiceClass = regexp.MustCompile(`(?m)^export default class (\w+) \{`) + reStreamMethod = regexp.MustCompile(`(?m)^ async (\w+)\((?:dto:\s?([^,]+), )?onMessage: \(data: (.+?)\) => unknown`) + reDataMethod = regexp.MustCompile(`(?m)^ async (\w+)\((?:dto:\s?([^,]+), )?options\?: RequestOptions\): Promise>`) + reInterface = regexp.MustCompile(`(?s)export default interface (\w+) \{([^}]*)\}`) + reEnum = regexp.MustCompile(`(?s)enum (\w+) \{([^}]*)\}`) + reEnumMember = regexp.MustCompile(`(?m)^\s+(\w+),?\s*$`) + reField = regexp.MustCompile(`(?m)^\s+([\w?]+):(.+)$`) +) + +// parseTsDir 解析 GenTs 输出目录(ts/ 子目录),重建 FunMeta。 +// 两遍解析:先收集全部 enum/interface 原始声明,再解析字段引用, +// 避免文件顺序(字母序)导致前置类型解析不到 +func parseTsDir(tsDir string) (*FunMeta, error) { + if _, err := os.Stat(filepath.Join(tsDir, "client.ts")); err != nil { + return nil, fmt.Errorf("%s 不是 fun 生成的 ts 目录(缺 client.ts)", tsDir) + } + + enums := map[string][]string{} + type rawField struct { + Name string + TsType string + Optional bool + } + ifaceRaw := map[string][]rawField{} + ifaceNode := map[string]*MetaType{} + var serviceFiles []string + + entries, err := os.ReadDir(tsDir) + if err != nil { + return nil, err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".ts") { + continue + } + if e.Name() == "client.ts" || e.Name() == "fun.ts" { + continue + } + body, err := os.ReadFile(filepath.Join(tsDir, e.Name())) + if err != nil { + continue + } + src := string(body) + if reServiceClass.MatchString(src) { + serviceFiles = append(serviceFiles, src) + continue + } + if m := reEnum.FindStringSubmatch(src); m != nil { + var names []string + for _, line := range reEnumMember.FindAllStringSubmatch(m[2], -1) { + names = append(names, line[1]) + } + enums[m[1]] = names + continue + } + if m := reInterface.FindStringSubmatch(src); m != nil { + var fields []rawField + for _, f := range reField.FindAllStringSubmatch(m[2], -1) { + name, tsType := f[1], strings.TrimSpace(f[2]) + fields = append(fields, rawField{ + Name: strings.TrimSuffix(name, "?"), + TsType: tsType, + Optional: strings.HasSuffix(name, "?"), + }) + } + ifaceRaw[m[1]] = fields + ifaceNode[m[1]] = &MetaType{Kind: "struct", Name: m[1]} + } + } + + // 第二遍:填充 interface 字段(引用此时全部可见) + resolve := func(tsType string) *MetaType { return parseTsType(tsType, ifaceNode, enums) } + for name, fields := range ifaceRaw { + node := ifaceNode[name] + for _, f := range fields { + ft := resolve(f.TsType) + if f.Optional { + ft.Optional = true + } + node.Fields = append(node.Fields, MetaField{Name: f.Name, Type: ft}) + } + } + + meta := &FunMeta{} + for _, src := range serviceFiles { + svcName := reServiceClass.FindStringSubmatch(src)[1] + svc := MetaService{Name: svcName} + for _, m := range reDataMethod.FindAllStringSubmatch(src, -1) { + mm := MetaMethod{Name: m[1], Returns: resolve(m[3])} + if m[3] == "void" { + mm.Void = true + mm.Returns = nil + } + if m[2] != "" { + mm.DTO = resolve(m[2]) + } + svc.Methods = append(svc.Methods, mm) + } + for _, m := range reStreamMethod.FindAllStringSubmatch(src, -1) { + mm := MetaMethod{Name: m[1], IsStream: true} + if m[2] != "" { + mm.DTO = resolve(m[2]) + } + if m[3] != "any" { + mm.Returns = resolve(m[3]) + } + svc.Methods = append(svc.Methods, mm) + } + sortMetaService(&svc) + meta.Services = append(meta.Services, svc) + } + sortMeta(meta) + return meta, nil +} + +// parseTsType TS 类型串 → 元数据类型树 +func parseTsType(s string, interfaces map[string]*MetaType, enums map[string][]string) *MetaType { + s = strings.TrimSpace(s) + // 流式 dto 工厂类型 "X | (() => X)" 取值类型部分 + if i := strings.Index(s, " | ("); i >= 0 { + s = s[:i] + } + // fun v1.3.7 流式模板对 DTO 会多写一层 "dto:" 前缀,兼容剥离 + s = strings.TrimPrefix(s, "dto:") + t := &MetaType{} + if strings.HasSuffix(s, " | null") { + t.Optional = true + s = strings.TrimSuffix(s, " | null") + } + if strings.HasSuffix(s, "[]") { + t.Kind = "slice" + t.Elem = parseTsType(strings.TrimSuffix(s, "[]"), interfaces, enums) + return t + } + switch s { + case "number": + t.Kind, t.Name = "int", "int64" + case "boolean": + t.Kind, t.Name = "bool", "bool" + case "string": + t.Kind, t.Name = "string", "string" + case "any": + t.Kind, t.Name = "string", "any" + default: + if names, ok := enums[s]; ok { + t.Kind, t.Name = "enum", s + t.Names = names + return t + } + if it, ok := interfaces[s]; ok { + return it + } + t.Kind, t.Name = "struct", s // 定义在其他文件(如跨包),仅名字引用 + } + return t +} + +func sortMetaService(svc *MetaService) { + for i := 1; i < len(svc.Methods); i++ { + for j := i; j > 0 && svc.Methods[j].Name < svc.Methods[j-1].Name; j-- { + svc.Methods[j], svc.Methods[j-1] = svc.Methods[j-1], svc.Methods[j] + } + } +} + +func sortMeta(meta *FunMeta) { + for i := 1; i < len(meta.Services); i++ { + for j := i; j > 0 && meta.Services[j].Name < meta.Services[j-1].Name; j-- { + meta.Services[j], meta.Services[j-1] = meta.Services[j-1], meta.Services[j] + } + } +} diff --git a/tsparse_test.go b/tsparse_test.go new file mode 100644 index 0000000..925d9cb --- /dev/null +++ b/tsparse_test.go @@ -0,0 +1,86 @@ +package main + +// 渲染→解析回环:用渲染器从元数据生成 TS,再解析回元数据,验证解析器覆盖 +// 枚举/可空/流式/嵌套等全部特性 + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTsRenderParseRoundTrip(t *testing.T) { + meta, err := loadMeta(filepath.Join("testdata", "demo.json")) + if err != nil { + t.Fatal(err) + } + files := fileSet{} + renderTs{}.renderAll(meta, files) + dir := t.TempDir() + if err := writeFiles(dir, files); err != nil { + t.Fatal(err) + } + + parsed, err := parseTsDir(filepath.Join(dir, "ts")) + if err != nil { + t.Fatal(err) + } + + var ask, create *MetaMethod + var order, chat *MetaService + for i := range parsed.Services { + switch parsed.Services[i].Name { + case "orderSvc": + order = &parsed.Services[i] + case "chatSvc": + chat = &parsed.Services[i] + } + } + if order == nil || chat == nil { + t.Fatalf("services missing: %+v", parsed.Services) + } + for i := range order.Methods { + if order.Methods[i].Name == "create" { + create = &order.Methods[i] + } + } + for i := range chat.Methods { + if chat.Methods[i].Name == "ask" { + ask = &chat.Methods[i] + } + } + + if ask == nil || !ask.IsStream || ask.Returns == nil || ask.Returns.Kind != "string" { + t.Errorf("chatSvc.ask 解析错误: %+v", ask) + } + if ask.DTO == nil || len(ask.DTO.Fields) != 1 || ask.DTO.Fields[0].Name != "prompt" { + t.Errorf("chatSvc.ask DTO 解析错误: %+v", ask.DTO) + } + if create == nil || create.Void { + t.Errorf("orderSvc.create 解析错误: %+v", create) + } + if create == nil || create.DTO == nil || len(create.DTO.Fields) != 5 { + t.Fatalf("createOrderDto 字段数错误: %+v", create.DTO) + } + want := map[string]struct { + kind string + opt bool + }{ + "sku": {"string", false}, + "count": {"int", false}, + "note": {"string", true}, + "status": {"enum", true}, + "tags": {"slice", false}, + } + for _, f := range create.DTO.Fields { + w, ok := want[f.Name] + if !ok { + t.Errorf("多余字段: %s", f.Name) + continue + } + if f.Type.Kind != w.kind || f.Type.Optional != w.opt { + t.Errorf("字段 %s: kind=%s opt=%v, 期望 %s/%v", f.Name, f.Type.Kind, f.Type.Optional, w.kind, w.opt) + } + } + _ = os.Environ +}