feat: fun-cli v1.0.0 — gen/run/docs 三命令,fun 框架零改动

- fun gen [ts|go]:AST 扫描 BindService* 调用,临时生成 go test 辅助采集,
  经 fun v1.3.7 公开 API 生成,产物与内置 GenCode 字节级一致(回归测试锁定)
- fun run:热更新开发循环,先编译成功再切换,失败保留旧进程
- fun docs:浅色标准文档站——参数表/State 添加字段/请求 JSON 可编辑/
  响应格式化/NDJSON 流式逐行展示,反向代理 /cell 同源无 CORS
- 元数据:TS 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持)
- 纯标准库实现,零第三方依赖
This commit is contained in:
2026-09-19 06:32:45 +08:00
commit e29767362a
21 changed files with 4148 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
fun-cli
*.test
+79
View File
@@ -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 客户端行为测试
```
+229
View File
@@ -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
}
+589
View File
@@ -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("<html>gateway timeout</html>", { status: 504 });
result = await client.request("Svc", "gatewayTimeout");
assert.equal(result.status, 5);
assert.match(result.msg, /504/);
expectSeen(5);
globalThis.fetch = async () => new Response("request timeout", { status: 408 });
result = await client.request("Svc", "gateway408");
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => json({ status: 5, msg: "server timeout" });
result = await client.request("Svc", "serverTimeout");
assert.equal(result.status, 5);
assert.equal(result.msg, "server timeout");
expectSeen(5);
globalThis.fetch = async () => new Response("bad gateway", { status: 502 });
result = await client.request("Svc", "gateway");
assert.equal(result.status, 4);
expectSeen(4);
globalThis.fetch = async () => new Response("", { status: 200 });
result = await client.request("Svc", "empty");
assert.equal(result.status, 1);
assert.match(result.msg, /Empty/);
expectSeen(1);
globalThis.fetch = async () => new Response("not-json", { status: 200 });
result = await client.request("Svc", "invalidJson");
assert.equal(result.status, 1);
assert.match(result.msg, /Invalid JSON/);
expectSeen(1);
globalThis.fetch = async () => new Response("<html>login</html>", {
status: 200,
headers: { "Content-Type": "text/html" },
});
result = await client.request("Svc", "html");
assert.equal(result.status, 1);
assert.match(result.msg, /HTML/);
expectSeen(1);
globalThis.fetch = async () => json({ data: 1 });
result = await client.request("Svc", "invalidResult");
assert.equal(result.status, 1);
expectSeen(1);
globalThis.fetch = async () => { throw new TypeError("DNS failed"); };
result = await client.request("Svc", "network");
assert.equal(result.status, 4);
assert.match(result.msg, /DNS failed/);
expectSeen(4);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("body read failed")); },
}));
result = await client.request("Svc", "bodyRead");
assert.equal(result.status, 4);
assert.match(result.msg, /body read failed/);
expectSeen(4);
const manualAbort = new AbortController();
manualAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.request("Svc", "abort", undefined, { signal: manualAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const timeoutAbort = new AbortController();
timeoutAbort.abort(new DOMException("timed out", "TimeoutError"));
result = await client.request("Svc", "timeoutSignal", undefined, { signal: timeoutAbort.signal });
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => { throw new DOMException("timed out", "TimeoutError"); };
result = await client.request("Svc", "timeoutError");
assert.equal(result.status, 5);
expectSeen(5);
const requestInterceptorClient = new Client("http://example.test");
let requestInterceptorSeen;
requestInterceptorClient.addRequestInterceptor((_service, _method, state, dto) => {
state.token = "abc";
requestInterceptorSeen = dto;
});
requestInterceptorClient.addResponseInterceptor((_service, _method, value) => ({ ...value, msg: "intercepted" }));
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
assert.deepEqual(payload.state, { token: "abc" });
return json({ status: 0 });
};
result = await requestInterceptorClient.request("Svc", "interceptors", { value: 1 });
assert.deepEqual(requestInterceptorSeen, { value: 1 });
assert.equal(result.msg, "intercepted");
const stateClient = new Client("http://example.test");
stateClient.setState({ shared: "global", globalOnly: "yes" });
const stateContexts = new Map();
const nativeResponses = new Map();
const payloads = new Map();
let releaseFirst;
const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; });
let firstInterceptorEntered;
const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; });
let firstStateReference;
stateClient.addRequestInterceptor(async (_service, method, state, dto) => {
state.interceptor = dto.id;
if (method === "first") {
firstStateReference = state;
firstInterceptorEntered();
await firstMayContinue;
}
state.completed = dto.id;
});
stateClient.addResponseInterceptor((_service, method, _value, context) => {
stateContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
payloads.set(payload.methodName, payload);
const response = json({ status: 0, data: payload.methodName });
nativeResponses.set(payload.methodName, response);
return response;
};
const firstOverride = { shared: "first", requestOnly: "one" };
const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride });
await firstDidEnter;
firstOverride.shared = "mutated outside";
const secondRequest = stateClient.request("Svc", "second", { id: "two" }, {
state: { shared: "second", requestOnly: "two" },
});
assert.equal((await secondRequest).status, 0);
releaseFirst();
assert.equal((await firstRequest).status, 0);
assert.deepEqual(payloads.get("first").state, {
shared: "first",
globalOnly: "yes",
requestOnly: "one",
interceptor: "one",
completed: "one",
});
assert.deepEqual(payloads.get("second").state, {
shared: "second",
globalOnly: "yes",
requestOnly: "two",
interceptor: "two",
completed: "two",
});
firstStateReference.shared = "mutated after snapshot";
for (const method of ["first", "second"]) {
const context = stateContexts.get(method);
assert.deepEqual(context.requestState, payloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
assert.equal(context.response, nativeResponses.get(method));
assert.ok(context.response instanceof Response);
}
assert.equal(stateContexts.get("first").requestState.shared, "first");
const throwingState = Object.defineProperty({}, "token", {
enumerable: true,
get() { throw new Error("state read failed"); },
});
const stateFailureClient = new Client("http://example.test");
let stateFailureSeen = [];
stateFailureClient.addResponseInterceptor((_service, _method, value, context) => {
stateFailureSeen.push([value.status, context.requestState]);
});
globalThis.fetch = async () => assert.fail("fetch must not run for state failure");
result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]);
const snapshotFailureClient = new Client("http://example.test");
snapshotFailureClient.addRequestInterceptor((_service, _method, state) => {
Object.defineProperty(state, "broken", {
enumerable: true,
get() { throw new Error("snapshot read failed"); },
});
});
globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure");
result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure");
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
const contextClient = new Client("http://example.test");
contextClient.setState({ base: "global" });
const outcomeContexts = new Map();
contextClient.addResponseInterceptor((_service, method, _value, context) => {
outcomeContexts.set(method, context);
});
contextClient.addRequestInterceptor((_service, method, state) => {
state.method = method;
if (method === "requestHookFailure") throw new Error("request context hook");
});
globalThis.fetch = async () => assert.fail("fetch must not run before serialization");
const contextCyclic = {};
contextCyclic.self = contextCyclic;
result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("serializeContext").requestState, {
base: "request",
method: "serializeContext",
});
assert.equal(outcomeContexts.get("serializeContext").response, undefined);
result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, {
base: "global",
request: "hook",
method: "requestHookFailure",
});
assert.equal(outcomeContexts.get("requestHookFailure").response, undefined);
globalThis.fetch = async () => { throw new TypeError("offline"); };
result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("networkContext").requestState, {
base: "global",
request: "network",
method: "networkContext",
});
assert.equal(outcomeContexts.get("networkContext").response, undefined);
const bodyReadResponse = new Response(new ReadableStream({
pull(controller) { controller.error(new Error("context body read failed")); },
}));
globalThis.fetch = async () => bodyReadResponse;
result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, {
base: "global",
request: "body",
method: "bodyReadContext",
});
assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse);
const failingRequestInterceptor = new Client("http://example.test");
let normalized = [];
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
failingRequestInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await failingRequestInterceptor.request("Svc", "hook");
assert.equal(result.status, 1);
assert.match(result.msg, /request hook/);
assert.deepEqual(normalized, [1]);
const failingResponseInterceptor = new Client("http://example.test");
normalized = [];
failingResponseInterceptor.addResponseInterceptor(() => { throw new Error("response hook"); });
failingResponseInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
globalThis.fetch = async () => json({ status: 2, code: 4003, msg: "original" });
result = await failingResponseInterceptor.request("Svc", "responseHook");
assert.equal(result.status, 1);
assert.match(result.msg, /response hook/);
assert.deepEqual(normalized, [1]);
const cyclic = {};
cyclic.self = cyclic;
globalThis.fetch = async () => assert.fail("fetch must not run for serialization failure");
result = await client.request("Svc", "serialize", cyclic);
assert.equal(result.status, 1);
assert.match(result.msg, /serialize/i);
expectSeen(1);
globalThis.fetch = async () => json({ status: 2, code: 4002, msg: "stream business" }, 422);
result = await client.stream("Svc", "streamBusiness", undefined, () => {});
assert.deepEqual(result, { status: 2, code: 4002, msg: "stream business" });
expectSeen(2);
globalThis.fetch = async () => json({ status: 0 });
result = await client.stream("Svc", "wrongMedia", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /x-ndjson/);
expectSeen(1);
const bytes = new TextEncoder().encode('{"text":"你好"}\r\n\n{"n":2}');
const split = bytes.indexOf(0xe5) + 1;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, split));
controller.enqueue(bytes.slice(split));
controller.close();
},
}), { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } });
const messages = [];
result = await client.stream("Svc", "valid", undefined, value => messages.push(value));
assert.equal(result.status, 0);
assert.deepEqual(messages, [{ text: "你好" }, { n: 2 }]);
expectSeen(0);
let malformedCancelled = false;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(new TextEncoder().encode("bad\n")); },
cancel() { malformedCancelled = true; },
}), { headers: { "Content-Type": "application/x-ndjson" } });
cleanupCheck = () => malformedCancelled;
result = await client.stream("Svc", "malformed", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /line 1/);
assert.equal(malformedCancelled, true);
expectSeen(1);
globalThis.fetch = async () => ndjson('{"n":1}\n');
result = await client.stream("Svc", "callback", undefined, () => { throw new Error("callback boom"); });
assert.equal(result.status, 1);
assert.match(result.msg, /callback boom/);
expectSeen(1);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("read boom")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "read", undefined, () => {});
assert.equal(result.status, 4);
assert.match(result.msg, /read boom/);
expectSeen(4);
globalThis.fetch = async () => ndjson(new Uint8Array([0xff, 0x0a]));
result = await client.stream("Svc", "utf8", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /UTF-8/);
expectSeen(1);
globalThis.fetch = async () => ndjson("");
result = await client.stream("Svc", "emptyStream", undefined, () => assert.fail("empty stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
globalThis.fetch = async () => new Response(null, {
headers: { "Content-Type": "application/x-ndjson" },
});
result = await client.stream("Svc", "nullBody", undefined, () => assert.fail("null stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
const streamAbort = new AbortController();
streamAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.stream("Svc", "abortStream", undefined, () => {}, { signal: streamAbort.signal });
assert.equal(result.status, 4);
expectSeen(4);
const readAbort = new AbortController();
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
readAbort.signal.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true });
readAbort.abort();
},
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "readAbort", undefined, () => {}, { signal: readAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const readTimeout = new AbortController();
readTimeout.abort(new DOMException("timed out", "TimeoutError"));
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new DOMException("aborted", "AbortError")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "timeoutStream", undefined, () => {}, { signal: readTimeout.signal });
assert.equal(result.status, 5);
expectSeen(5);
const streamHookClient = new Client("http://example.test");
normalized = [];
streamHookClient.addRequestInterceptor(() => { throw new Error("stream hook"); });
streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
assert.equal(result.status, 1);
assert.deepEqual(normalized, [1]);
const streamStateClient = new Client("http://example.test");
streamStateClient.setState({ shared: "global", globalOnly: "stream" });
const streamContexts = new Map();
const streamResponses = new Map();
const streamPayloads = new Map();
streamStateClient.addRequestInterceptor((_service, method, state) => {
state.interceptor = method;
if (method === "streamHookFailure") throw new Error("stream context hook");
});
streamStateClient.addResponseInterceptor((_service, method, _value, context) => {
streamContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
streamPayloads.set(payload.methodName, payload);
if (payload.methodName === "streamNetwork") throw new TypeError("stream offline");
const response = payload.methodName === "streamWrongMedia"
? json({ status: 0 })
: payload.methodName === "streamReadFailure"
? new Response(new ReadableStream({
pull(controller) { controller.error(new Error("stream context read failed")); },
}), { headers: { "Content-Type": "application/x-ndjson" } })
: ndjson('{"n":1}\n');
streamResponses.set(payload.methodName, response);
return response;
};
result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, {
state: { shared: "request", requestOnly: "success" },
});
assert.equal(result.status, 0);
result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, {
state: { shared: "wrong-media" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, {
state: { shared: "network" },
});
assert.equal(result.status, 4);
result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, {
state: { shared: "read" },
});
assert.equal(result.status, 4);
const streamCyclic = {};
streamCyclic.self = streamCyclic;
result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, {
state: { shared: "serialize" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, {
state: { shared: "hook" },
});
assert.equal(result.status, 1);
for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) {
const context = streamContexts.get(method);
assert.deepEqual(context.requestState, streamPayloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
}
assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess"));
assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia"));
assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure"));
assert.equal(streamContexts.get("streamNetwork").response, undefined);
assert.deepEqual(streamContexts.get("streamSerialize").requestState, {
shared: "serialize",
globalOnly: "stream",
interceptor: "streamSerialize",
});
assert.equal(streamContexts.get("streamSerialize").response, undefined);
assert.deepEqual(streamContexts.get("streamHookFailure").requestState, {
shared: "hook",
globalOnly: "stream",
interceptor: "streamHookFailure",
});
assert.equal(streamContexts.get("streamHookFailure").response, undefined);
`
scriptPath := filepath.Join(dir, "behavior.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(node, "--experimental-strip-types", scriptPath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("TypeScript client behavior failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientStrictTypecheck(t *testing.T) {
tsc, err := exec.LookPath("tsc")
if err != nil {
t.Skip("tsc is not installed")
}
dir := t.TempDir()
path := filepath.Join(dir, "client.ts")
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
usagePath := filepath.Join(dir, "usage.ts")
const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client";
const requestOptions: RequestOptions = { state: { token: "request" } };
const streamOptions: StreamOptions = { state: { token: "stream" } };
const legacy: ResponseInterceptor = (_service, _method, result) => result;
const legacyConsumer: (service: string, method: string, result: import("./client").result<any>) => unknown = legacy;
const current: ContextResponseInterceptor = (_service, _method, result, context) => {
const token: string | undefined = context.requestState.token;
const response: Response | undefined = context.response;
void token;
void response;
return result;
};
void legacyConsumer;
const context = {} as ResponseContext;
// @ts-expect-error requestState is readonly
context.requestState.token = "changed";
const client = new Client("/");
client.addResponseInterceptor(legacy);
client.addResponseInterceptor(current);
void client.request("Svc", "method", undefined, requestOptions);
void client.stream("Svc", "method", undefined, () => {}, streamOptions);
`
if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(tsc,
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientSimplicityGate(t *testing.T) {
source := templateTs{}.genClientTemplate() + templateTs{}.genServiceTemplate()
for _, forbidden := range []string{"RpcError", "httpStatus", "result.aborted", "ClientErrorCode", "setTimeout"} {
if strings.Contains(source, forbidden) {
t.Errorf("generated TypeScript templates contain forbidden %q", forbidden)
}
}
if !strings.Contains(source, "Promise<result<void>>") {
t.Fatal("stream methods must resolve result<void>")
}
}
+130
View File
@@ -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
}
+420
View File
@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fun docs</title>
<style>
:root{
--bg:#ffffff;--bg2:#f6f8fa;--line:#d8dee4;
--fg:#1f2328;--fg2:#636c76;--fg3:#8b949e;
--acc:#0969da;--accbg:#ddf4ff;
--ok:#1a7f37;--okbg:#dafbe1;--err:#cf222e;--errbg:#ffebe9;
--stream:#9a6700;--streambg:#fff8c5;
--code:#24292f;
--mono:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.65 -apple-system,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;overflow:hidden}
/* 顶栏 */
header{display:flex;align-items:center;gap:10px;padding:9px 20px;background:var(--bg2);border-bottom:1px solid var(--line);flex-shrink:0}
header .logo{font-family:var(--mono);font-weight:700;font-size:15px;color:var(--fg)}
header .logo b{color:var(--acc)}
header .meta{color:var(--fg2);font-size:12.5px}
header .dot{width:7px;height:7px;border-radius:50%;background:var(--ok)}
#layout{flex:1;display:flex;min-height:0}
/* 侧栏 */
aside{width:280px;min-width:280px;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--bg)}
#search{margin:12px 14px 6px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;font-size:13px;outline:none;background:var(--bg)}
#search:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
#tree{flex:1;overflow-y:auto;padding:2px 8px 16px}
.svc{margin:14px 8px 4px;font-size:11.5px;color:var(--fg2);font-weight:600;letter-spacing:.06em;text-transform:uppercase}
.mth{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--fg)}
.mth:hover{background:var(--bg2)}
.mth.on{background:var(--accbg);color:var(--fg)}
.mth.on::before{content:'';display:inline-block;width:3px;height:14px;background:var(--acc);border-radius:2px;margin-right:2px}
.mth .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mth.on .nm{font-weight:600}
.badge{font-size:10px;padding:1px 6px;border-radius:10px;font-family:var(--mono);border:1px solid}
.b-stream{color:var(--stream);border-color:var(--streambg);background:var(--streambg)}
.b-void{color:var(--fg2);background:var(--bg2);border-color:var(--line)}
/* 正文 */
main{flex:1;overflow-y:auto;padding:26px 34px 60px}
.doc{max-width:860px}
h1{font-size:24px;font-weight:600;letter-spacing:-.01em;font-family:var(--mono);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.tag{font-size:11px;font-weight:500;padding:2px 9px;border-radius:10px;font-family:var(--mono)}
.t-stream{color:var(--stream);background:var(--streambg)}
.t-void{color:var(--fg2);background:var(--bg2);border:1px solid var(--line)}
.sig{margin:8px 0 4px;color:var(--fg2);font-size:13px;font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:6px;padding:8px 12px}
.sig b{color:var(--acc);font-weight:600}
h2{font-size:17px;font-weight:600;margin:26px 0 10px;padding-top:18px;border-top:1px solid var(--line)}
h2 .sub{font-weight:400;color:var(--fg2);font-size:12.5px;margin-left:8px}
/* 参数表 */
table.params{width:100%;border-collapse:collapse;font-size:13px}
table.params th{text-align:left;color:var(--fg2);font-weight:600;font-size:12px;padding:6px 10px;border-bottom:2px solid var(--line);white-space:nowrap}
table.params td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
table.params tr:last-child td{border-bottom:none}
table.params td.name{font-family:var(--mono);white-space:nowrap}
table.params td.type{font-family:var(--mono);color:var(--fg2);white-space:nowrap}
table.params td.name .req{color:var(--err);font-weight:700}
table.params td.name .opt{color:var(--fg3);font-size:11px}
input[type=text],textarea,select{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:5px 9px;font-size:13px;font-family:var(--mono);outline:none}
input:focus,textarea:focus,select:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
textarea{min-height:60px;resize:vertical}
input[type=checkbox]{accent-color:var(--acc);width:14px;height:14px}
.nest{border-left:2px solid var(--line);margin:6px 0 2px;padding-left:14px}
.subhdr{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:10px 0 4px}
/* state */
/* 发送 */
#sendBar{display:flex;gap:10px;align-items:center;margin:4px 0 4px}
#send{background:var(--acc);color:#fff;border:none;border-radius:6px;padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer}
#send:hover{background:#0860c4}
#send:disabled{opacity:.55;cursor:wait}
#send.stop{background:var(--err)}
#copyReq{background:var(--bg);border:1px solid var(--line);color:var(--fg2);border-radius:6px;padding:7px 14px;cursor:pointer;font-size:13px}
#copyReq:hover{border-color:var(--acc);color:var(--acc)}
#sendHint{color:var(--fg2);font-size:12.5px}
/* 请求/响应示例块 */
.block{border:1px solid var(--line);border-radius:8px;margin-top:8px;overflow:hidden}
.block .bhead{display:flex;align-items:center;gap:10px;padding:7px 14px;background:var(--bg2);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--fg2)}
.chip{padding:1px 9px;border-radius:10px;font-weight:500}
.c-ok{color:var(--ok);background:var(--okbg)}
.c-err{color:var(--err);background:var(--errbg)}
.c-info{color:var(--acc);background:var(--accbg)}
#stateRows .srow{display:grid;grid-template-columns:1fr 1.4fr 30px;gap:8px;margin-bottom:6px;max-width:560px}
#stateRows .srow button{background:none;border:none;color:var(--fg3);cursor:pointer;font-size:15px}
#stateRows .srow button:hover{color:var(--err)}
#addState{background:var(--bg);border:1px dashed var(--line);color:var(--fg2);border-radius:6px;padding:3px 12px;cursor:pointer;font-size:12.5px}
#addState:hover{border-color:var(--acc);color:var(--acc)}
textarea.body{width:100%;border:none;outline:none;background:var(--bg);padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre;resize:vertical;color:var(--code);display:block}
pre.body{padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre-wrap;word-break:break-all;color:var(--code)}
.empty{color:var(--fg3);font-size:12.5px;font-family:var(--mono)}
.note{color:var(--fg2);font-size:12.5px;margin-top:6px}
.note code,td.hint code{font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11.5px}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#c9d1d9;border-radius:5px;border:2px solid var(--bg)}
::-webkit-scrollbar-thumb:hover{background:#afb8c1}
#empty{color:var(--fg3);margin-top:20vh;text-align:center;font-size:14px}
</style>
</head>
<body>
<header>
<span class="logo">fun<b>·docs</b></span>
<span class="dot" id="live"></span>
<span class="meta" id="hmeta">加载中…</span>
</header>
<div id="layout">
<aside>
<input id="search" placeholder="搜索服务或方法…" autofocus>
<div id="tree"></div>
</aside>
<main id="main"><div id="empty">在左侧选择一个方法查看文档</div></main>
</div>
<script>
let META=null, CUR=null, ABORT=null;
const $=s=>document.querySelector(s);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
fetch('/fun-meta.json').then(r=>r.json()).then(m=>{
META=m;
const nS=m.services.length;let nM=0;m.services.forEach(s=>nM+=s.methods.length);
$('#hmeta').textContent=`${nS} 个服务 · ${nM} 个方法`;
renderTree('');
}).catch(e=>{$('#hmeta').textContent='元数据加载失败: '+e.message;$('#live').style.background='var(--err)'});
$('#search').addEventListener('input',e=>renderTree(e.target.value));
function renderTree(q){
q=(q||'').toLowerCase();
const t=$('#tree');t.innerHTML='';
META.services.forEach(svc=>{
const ms=svc.methods.filter(m=>!q||m.name.toLowerCase().includes(q)||svc.name.toLowerCase().includes(q));
if(!ms.length)return;
const h=document.createElement('div');h.className='svc';h.textContent=svc.name;t.appendChild(h);
ms.forEach(m=>{
const d=document.createElement('div');d.className='mth';
d.innerHTML=`<span class="nm">${esc(m.name)}</span>`+
(m.isStream?'<span class="badge b-stream">stream</span>':'')+
(m.void?'<span class="badge b-void">void</span>':'');
d.onclick=()=>{document.querySelectorAll('.mth').forEach(x=>x.classList.remove('on'));d.classList.add('on');showMethod(svc,m)};
t.appendChild(d);
});
});
}
function typeLabel(t){
if(!t)return '—';
if(t.kind==='struct'||t.kind==='enum')return t.name+(t.optional?'?':'');
if(t.kind==='slice')return typeLabel(t.elem)+'[]';
return (t.optional?t.name+'?':t.name);
}
function showMethod(svc,m){
CUR={svc,m};reqDirty=false;ABORT&&ABORT.abort();ABORT=null;
const badges=(m.isStream?'<span class="tag t-stream">NDJSON 流式</span>':'')+(m.void?'<span class="tag t-void">无返回值</span>':'');
const retLine = m.isStream
? (m.returns?`首条消息 <b>${esc(typeLabel(m.returns))}</b>,其后逐行推送 NDJSON`:`持续推送 NDJSON 行`)
: (m.void?`无数据返回,仅 <b>result.void</b>`:(m.returns?`<b>${esc(typeLabel(m.returns))}</b>`:''));
const dtoName = m.dto?esc(m.dto.name):'无';
$('#main').innerHTML=`<div class="doc">
<h1>${esc(svc.name)}.${esc(m.name)} ${badges}</h1>
<div class="sig"><b>POST</b> /cell · serviceName=<b>${esc(svc.name)}</b> · methodName=<b>${esc(m.name)}</b></div>
<div class="note">返回:${retLine} · 请求 DTO:${dtoName}${m.dto&&m.dto.fields?'':'(该方法不接收参数)'}</div>
${m.dto?`
<h2>请求参数<span class="sub">${esc(m.dto.name)}</span></h2>
${paramsTable(m.dto,[])}
<div class="note">带 <code>*</code> 为必填;指针/切片字段可留空省略;切片按 JSON 数组填写。字段名以小写开头随请求传输。</div>
`:''}
<h2>State<span class="sub">随请求携带 · Guard 可读 · 存于 localStorage</span></h2>
<div id="stateRows"></div>
<button id="addState">+ 添加字段</button>
<h2>试一下<span class="sub">填好参数点发送,请求与响应都会展示在下方</span></h2>
<div id="sendBar">
<button id="send">发送请求</button>
<button id="copyReq">复制请求 JSON</button>
<span id="sendHint"></span>
</div>
<h2>请求<span class="sub">表单填充自动生成,可直接编辑后发送</span></h2>
<div class="note" style="margin:0 0 8px">请求体即发送到 <code>POST /cell</code> 的 JSON,State 值由上方字段注入,也可手动编辑后发送。</div>
<div class="block"><div class="bhead" id="reqHead"><span class="chip c-info">表单生成</span><button id="resetReq" style="margin-left:auto;background:none;border:1px solid var(--line);border-radius:5px;color:var(--fg2);font-size:11px;padding:1px 8px;cursor:pointer;display:none">按表单重置</button></div><textarea class="body edit" id="reqBody" spellcheck="false"></textarea></div>
<h2>响应</h2>
<div class="block"><div class="bhead" id="respHead"><span class="chip c-info">未发送</span></div><pre class="body" id="respBody"><span class="empty">发送后在此展示格式化的响应${m.isStream?'(流式方法逐行追加)':''}</span></pre></div>
</div>`;
$('#addState').onclick=()=>addStateRow('','');
const st=JSON.parse(localStorage.getItem('fun-docs-state')||'{}');
Object.entries(st).forEach(([k,v])=>addStateRow(k,v));
$('#send').onclick=send;
$('#copyReq').onclick=copyReq;
// 表单一变就同步请求体(手动编辑过则不动,可点"按表单重置"恢复)
document.querySelectorAll('#main input,#main select').forEach(el=>{
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
});
document.querySelectorAll('#main textarea[data-t]').forEach(el=>{
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
});
$('#reqBody').addEventListener('input',()=>{
reqDirty=true;
$('#reqHead').querySelector('.chip').textContent='手动编辑';
$('#resetReq').style.display='';
});
$('#resetReq').onclick=()=>{reqDirty=false;$('#resetReq').style.display='none';$('#reqHead').querySelector('.chip').textContent='表单生成';previewRequest();};
previewRequest();
}
/* ---------- 参数表 ---------- */
function paramsTable(t,path){
let h=`<table class="params"><tr><th style="width:190px">字段</th><th style="width:130px">类型</th><th style="width:270px">示例值</th><th>说明</th></tr>`;
h+=paramRows(t,path);
return h+'</table>';
}
function paramRows(t,path){
let h='';
t.fields.forEach(f=>{
const p=[...path,f.name];
const id='f-'+p.join('.');
const req=!f.type.optional&&f.type.kind!=='slice';
h+=`<tr><td class="name">${esc(firstLower(f.name))}${f.type.optional?'<span class="opt"> 可空</span>':'<span class="req">*</span>'}</td><td class="type">${esc(typeLabel(f.type))}</td>`;
h+=`<td>${controlHTML(f.type,id)}</td>`;
h+=`<td class="hint" style="color:var(--fg2);font-size:12.5px">${hintFor(f.type)}</td></tr>`;
if(f.type.kind==='struct')h+=`<tr><td colspan="4"><div class="nest"><div class="subhdr">${esc(f.type.name)} 字段:</div>${paramsTable(f.type,p)}</div></td></tr>`;
});
return h;
}
function hintFor(t){
switch(t.kind){
case 'int':return '定宽整数,<code>int64</code> 全链路精度';
case 'enum':return '枚举序号:'+(t.names||[]).map((n,i)=>(t.displayNames&&t.displayNames[i]?t.displayNames[i]:n)+'='+i).join(' · ');
case 'slice':return 'JSON 数组,如 <code>["a","b"]</code>';
case 'struct':return '嵌套对象';
case 'bool':return 'true / false';
default:return '';
}
}
function controlHTML(t,id){
if(t.optional){
return `<div style="display:flex;gap:7px;align-items:center"><input type="checkbox" class="prov" data-for="${id}" title="勾选后提供该字段"><div style="flex:1" data-prov="${id}" data-dis>${innerControl(t,id)}</div></div>`;
}
return innerControl(t,id);
}
function innerControl(t,id){
switch(t.kind){
case 'string':return `<input type="text" id="${id}" data-t="string">`;
case 'int':return `<input type="text" id="${id}" data-t="int" inputmode="numeric" placeholder="0">`;
case 'bool':return `<select id="${id}" data-t="bool"><option value=""></option><option>true</option><option>false</option></select>`;
case 'enum':{
let h=`<select id="${id}" data-t="enum"><option value=""></option>`;
(t.names||[]).forEach((n,i)=>h+=`<option value="${i}">${esc((t.displayNames&&t.displayNames[i])?t.displayNames[i]+' ('+n+')':n)}</option>`);
return h+'</select>';
}
case 'slice':return `<textarea id="${id}" data-t="slice" placeholder='["a","b"] 或 [{...}]'></textarea>`;
default:return `<input type="text" id="${id}" data-t="string">`;
}
}
const firstLower=s=>s?s[0].toLowerCase()+s.slice(1):s;
// 指针字段勾选联动
document.addEventListener('change',e=>{
if(e.target.classList&&e.target.classList.contains('prov')){
const wrap=document.querySelector(`[data-prov="${e.target.dataset.for}"]`);
if(wrap){const el=wrap.querySelector('input,textarea,select');if(el)el.disabled=!e.target.checked}
}
});
/* ---------- 取值 ---------- */
function collectStruct(t,path,obj,errors){
t.fields.forEach(f=>{
const p=[...path,f.name];
const id='f-'+p.join('.');
const key=firstLower(f.name);
let el=document.getElementById(id);
let provided=true;
if(f.type.optional){
const cb=document.querySelector(`.prov[data-for="${id}"]`);
provided=cb&&cb.checked;
}
if(!provided)return;
if(!el)return;
const v=el.value!=null?el.value.trim():'';
const kind=f.type.kind;
if(kind==='struct'){obj[key]=collectStruct(f.type,p,{},errors);return}
if(v===''){
const req=!f.type.optional&&kind!=='slice'&&kind!=='bool'&&kind!=='enum';
if(req)errors.push(`缺少必填字段: ${p.join('.')}`);
return;
}
switch(kind){
case 'int':{
const n=Number(v);
if(!Number.isFinite(n)||!Number.isInteger(n)){errors.push(`${p.join('.')} 不是整数`);return}
obj[key]=n;break;
}
case 'bool':obj[key]=v==='true';break;
case 'enum':obj[key]=Number(v);break;
case 'slice':{
try{obj[key]=JSON.parse(v)}catch{errors.push(`${p.join('.')} 不是合法 JSON 数组`)}
break;
}
default:obj[key]=v;
}
});
return obj;
}
let reqDirty=false;
function buildRequest(){
const errors=[];
const body={serviceName:CUR.svc.name,methodName:CUR.m.name};
if(CUR.m.dto)body.data=collectStruct(CUR.m.dto,[],{},errors);
const state={};
document.querySelectorAll('#stateRows .srow').forEach(r=>{
const k=r.querySelector('[data-sk]').value.trim(),v=r.querySelector('[data-sv]').value;
if(k)state[k]=v;
});
if(Object.keys(state).length)body.state=state;
localStorage.setItem('fun-docs-state',JSON.stringify(state));
return {body,errors};
}
function addStateRow(k,v){
const d=document.createElement('div');d.className='srow';
d.innerHTML=`<input type="text" placeholder="key(如 token)" data-sk value="${esc(k)}"><input type="text" placeholder="value" data-sv value="${esc(v)}"><button title="删除">×</button>`;
d.querySelector('button').onclick=()=>d.remove();
$('#stateRows').appendChild(d);
d.querySelectorAll('input').forEach(i=>i.addEventListener('input',previewRequest));
}
function previewRequest(){
if(!CUR||reqDirty)return;
const {body,errors}=buildRequest();
const pre=$('#reqBody');if(!pre)return;
if(errors.length){pre.value=JSON.stringify({serviceName:CUR.svc.name,methodName:CUR.m.name},null,2);return}
pre.value=pretty(JSON.stringify(body));
}
function requestBody(){
const raw=$('#reqBody').value.trim();
try{return{ok:JSON.parse(raw)}}catch(e){return{ok:null,err:e.message}}
}
function copyReq(){
const raw=$('#reqBody').value.trim();
try{JSON.parse(raw);navigator.clipboard.writeText(JSON.stringify(JSON.parse(raw),null,2))}
catch{navigator.clipboard.writeText(raw)}
$('#sendHint').textContent='已复制';
setTimeout(()=>$('#sendHint').textContent='',1500);
}
/* ---------- 发送 ---------- */
async function send(){
if(ABORT){ABORT.abort();return}
const parsed=requestBody();
if(!parsed.ok){
$('#reqHead').innerHTML='<span class="chip c-err">请求 JSON 无效</span>';
$('#respBody').innerHTML=`<span style="color:var(--err)">${esc(parsed.err)}</span>`;
return;
}
const body=parsed.ok;
try{$('#reqBody').value=pretty(JSON.stringify(body))}catch{}
$('#reqHead').innerHTML='<span class="chip c-info">POST /cell</span>';
const btn=$('#send');btn.disabled=true;
const t0=performance.now();
$('#respHead').innerHTML='<span class="chip c-info">发送中…</span>';
$('#respBody').innerHTML='<span class="empty">等待响应…</span>';
ABORT=new AbortController();
try{
const res=await fetch('/cell',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:ABORT.signal});
const ms=Math.round(performance.now()-t0);
if(CUR.m.isStream){
btn.textContent='停止';btn.classList.add('stop');btn.disabled=false;
await readStream(res,ms);
}else{
const text=await res.text();
$('#respHead').innerHTML=statusChip(res.status,ms,res.headers.get('content-type'));
$('#respBody').textContent=text?pretty(text):'(空响应体)';
}
}catch(e){
if(e.name==='AbortError')$('#respHead').innerHTML='<span class="chip c-err">已取消</span>';
else $('#respHead').innerHTML='<span class="chip c-err">请求失败</span>';
$('#respBody').textContent=String(e.message||e);
}finally{
ABORT=null;btn.textContent='发送请求';btn.classList.remove('stop');btn.disabled=false;
}
}
async function readStream(res,ms){
$('#respHead').innerHTML=statusChip(res.status,ms,'application/x-ndjson');
const pre=$('#respBody');pre.textContent='';
if(!res.ok||!(res.headers.get('content-type')||'').includes('ndjson')){
pre.textContent=pretty(await res.text());return;
}
const reader=res.body.getReader(),dec=new TextDecoder();
let buf='',lines=0;
while(true){
if(!ABORT)break;
const {done,value}=await reader.read();
if(done)break;
buf+=dec.decode(value,{stream:true});
let i;
while((i=buf.indexOf('\n'))>=0){
const line=buf.slice(0,i).trim();buf=buf.slice(i+1);
if(!line)continue;
lines++;pre.textContent+=pretty(line)+'\n';
if(lines>800)pre.textContent='(超出 800 行,已截断)\n'+pre.textContent.split('\n').slice(-800).join('\n');
pre.scrollTop=pre.scrollHeight;
$('#respHead').lastChild&&$('#respHead').lastChild.remove();
$('#respHead').insertAdjacentHTML('beforeend',`<span class="chip c-ok">流式 · ${lines} 行</span>`);
}
}
$('#respHead').insertAdjacentHTML('beforeend','<span class="chip c-info">流结束</span>');
}
function statusChip(status,ms,ct){
const ok=status>=200&&status<300;
return `<span class="chip ${ok?'c-ok':'c-err'}">HTTP ${status}</span><span class="chip c-info">${ms} ms</span>`+(ct?`<span class="chip c-info">${esc(ct.split(';')[0])}</span>`:'');
}
function pretty(text){
try{return JSON.stringify(JSON.parse(text),null,2)}catch{return text}
}
</script>
</body>
</html>
+186
View File
@@ -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=<tmp> go run <pkg>
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...)
}
+195
View File
@@ -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<string, string>;`,
`export type ResponseContext = {`,
`readonly requestState: Readonly<Record<string, string>>;`,
`readonly response?: Response;`,
`state = { ...this.state, ...options?.state };`,
`interceptor(serviceName, methodName, current, context)`,
} {
if !strings.Contains(client, want) {
t.Errorf("client.ts missing %q:\n%s", want, client)
}
}
alpha := read(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<result<void>>`,
`this.client.request<void>("alphaGenSvc", "ping", undefined, options)`,
`async alpha(dto:alphaGenDto, options?: RequestOptions): Promise<result<zebraGenDto>>`,
`this.client.request<zebraGenDto>("alphaGenSvc", "alpha", dto, options)`,
} {
if !strings.Contains(alpha, want) {
t.Errorf("alphaGenSvc.ts missing %q:\n%s", want, alpha)
}
}
if strings.Index(alpha, `import type alphaGenDto`) > strings.Index(alpha, `import type zebraGenDto`) {
t.Fatalf("DTO imports are not sorted:\n%s", alpha)
}
if strings.Index(alpha, `async alpha`) > strings.Index(alpha, `async ping`) ||
strings.Index(alpha, `async ping`) > strings.Index(alpha, `async zebra`) {
t.Fatalf("methods are not sorted:\n%s", alpha)
}
stream := read(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<result<void>>`,
`this.client.stream<any>("zebraGenSvc", "watch", undefined, onMessage, options)`,
} {
if !strings.Contains(stream, want) {
t.Errorf("zebraGenSvc.ts missing %q:\n%s", want, stream)
}
}
mixed := read(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<void>") {
t.Fatalf("ts: expect result<void>, 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<string>("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
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/cyi-cc/fun-cli
go 1.26.1
+134
View File
@@ -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)
}
+60
View File
@@ -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)
}
}
+55
View File
@@ -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
}
+151
View File
@@ -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
}
+194
View File
@@ -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{}
}
+225
View File
@@ -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<void>"
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)}
}
+196
View File
@@ -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
}
+1026
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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}]}]}
+1
View File
@@ -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"}}}]}}]}]}
+186
View File
@@ -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<result<(.+?)>>`)
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]
}
}
}
+86
View File
@@ -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
}