Files
fun-cli/templates.go
T
chiyi e29767362a 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 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持)
- 纯标准库实现,零第三方依赖
2026-09-19 06:32:45 +08:00

1027 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 模板与 fun v1.3.7 核心库的字节级一致移植(gen 产物保持不变)
type templateGo struct{}
func (ctx templateGo) genDefaultServiceTemplate() string {
return `package api
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// Result 统一响应结构
type Result[T any] struct {
Code *uint16
Data *T
Msg *string
Status uint8
}
func (r Result[T]) Error() string {
if r.Msg != nil {
return *r.Msg
}
if r.Code != nil {
return fmt.Sprintf("code=%d", *r.Code)
}
return "api: unknown error"
}
// Void 用于无数据返回的方法
type Void = struct{}
// RequestInterceptor 请求前拦截器:可鉴权、加签、改 dto;返回 error 则直接失败
type RequestInterceptor func(serviceName string, methodName string, dto any) error
// ResponseInterceptor 响应后拦截器:可记录日志、埋点、解密;返回 error 则转为失败响应
type ResponseInterceptor func(serviceName string, methodName string, result Result[any]) error
// Client 内联 HTTP 客户端,不依赖外部 funclient 包
type Client struct {
url string
client *http.Client
state map[string]string
requestInterceptors []RequestInterceptor
responseInterceptors []ResponseInterceptor
}
// NewClient 创建客户端
func NewClient(url string) (*Client, error) {
return &Client{
url: strings.TrimRight(url, "/"),
client: &http.Client{},
}, nil
}
// SetHttpClient 替换底层 http.Client
func (c *Client) SetHttpClient(client *http.Client) {
c.client = client
}
// AddRequestInterceptor 注册请求前拦截器
func (c *Client) AddRequestInterceptor(i RequestInterceptor) {
c.requestInterceptors = append(c.requestInterceptors, i)
}
// AddResponseInterceptor 注册响应后拦截器
func (c *Client) AddResponseInterceptor(i ResponseInterceptor) {
c.responseInterceptors = append(c.responseInterceptors, i)
}
// SetState 设置随每个请求携带的状态(如 token),服务端 Guard 可读取
func (c *Client) SetState(state map[string]string) {
c.state = state
}
// Request 发起普通调用
func Request[T any](c *Client, serviceName string, methodName string, dto ...any) Result[T] {
payload := newPayload(serviceName, methodName, dto, c.state)
b, err := json.Marshal(payload)
if err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
for _, i := range c.requestInterceptors {
var dtoVal any
if len(dto) > 0 {
dtoVal = dto[0]
}
if err := i(serviceName, methodName, dtoVal); err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
}
req, err := http.NewRequest(http.MethodPost, c.url+"/cell", bytes.NewReader(b))
if err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
defer resp.Body.Close()
var out Result[T]
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
var anyData *any
if out.Data != nil {
v := any(*out.Data)
anyData = &v
}
anyResult := Result[any]{Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status}
for _, i := range c.responseInterceptors {
if err := i(serviceName, methodName, anyResult); err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())}
}
}
return out
}
// Stream 发起流式调用,通过 NDJSON 行逐个推送消息(Streamable HTTP)
func Stream[T any](c *Client, serviceName string, methodName string, dto ...any) (<-chan T, error) {
payload := newPayload(serviceName, methodName, dto, c.state)
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
for _, i := range c.requestInterceptors {
var dtoVal any
if len(dto) > 0 {
dtoVal = dto[0]
}
if err := i(serviceName, methodName, dtoVal); err != nil {
return nil, err
}
}
req, err := http.NewRequest(http.MethodPost, c.url+"/cell", bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode)
}
anyResult := Result[any]{Status: 0}
for _, i := range c.responseInterceptors {
if err := i(serviceName, methodName, anyResult); err != nil {
resp.Body.Close()
return nil, err
}
}
ch := make(chan T)
go func() {
defer resp.Body.Close()
defer close(ch)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg T
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
ch <- msg
}
}()
return ch, nil
}
func newPayload(serviceName string, methodName string, dto []any, state map[string]string) map[string]any {
payload := map[string]any{
"serviceName": serviceName,
"methodName": methodName,
}
if len(dto) > 0 {
payload["data"] = dto[0]
}
if len(state) > 0 {
payload["state"] = state
}
return payload
}
func ptr(s string) *string { return &s }
type Api struct {
{{- range .GenServiceList}}
{{.ServiceName}} *{{.ServiceName}}
{{- end}}
*Client
}
func CreateApi(url string) (Api, error) {
apiClient, err := NewClient(url)
return Api{
{{- range .GenServiceList}}
{{.ServiceName}}: New{{.ServiceName}}(apiClient),
{{- end}}
Client: apiClient,
}, err
}`
}
func (ctx templateGo) genServiceTemplate() string {
return `package api
type {{.ServiceName}} struct {
*Client
}
func New{{.ServiceName}}(client *Client) *{{.ServiceName}} {
return &{{.ServiceName}}{
Client: client,
}
}
{{- $serviceName := .ServiceName }}
{{- range .GenMethodTypeList}}
{{if .IsStream }}func (ctx *{{$serviceName}}) {{.MethodName}}({{.DtoText}}) (<-chan {{.GenericTypeText}}, error) {
return Stream[{{.GenericTypeText}}](ctx.Client, "{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}})
}{{else}}func (ctx *{{$serviceName}}) {{.MethodName}}({{.DtoText}}) {{.ReturnValueText}} {
return Request[{{.GenericTypeText}}](ctx.Client, "{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}})
}{{end}}
{{- end}}`
}
func (ctx templateGo) genStructTemplate() string {
return `package api
type {{.Name}} struct{
{{- range .GenClassFieldType}}
{{.Name}} {{.Type}} {{.Tag}}
{{- end}}
}`
}
func (ctx templateGo) genEnumTemplate() string {
return `package api
type {{.Name}} uint8
{{$enumName := .Name}}
const (
{{- range $index, $element := .Names}}
{{$element}}{{if eq $index 0}} {{$enumName}} = iota{{end}}
{{- end}}
)
func ({{.Name}}) Values() []{{.Name}} {
return []{{.Name}}{
{{- range $index, $element := .Names}}
{{$element}},
{{- end}}
}
}
{{if .DisplayNames}}
func ({{.Name}}) DisplayNames() []string {
return []string{
{{- range $index, $element := .DisplayNames}}
"{{$element}}",
{{- end}}
}
}
{{end}}`
}
type templateTs struct{}
func (ctx templateTs) genClientTemplate() string {
return `// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量
// 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。
// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可
const BIGINT_PREFIX = "\u0000fun-bigint:";
function reviveBigint(value: any): any {
if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) &&
/^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) {
return BigInt(value.slice(BIGINT_PREFIX.length));
}
if (Array.isArray(value)) return value.map(reviveBigint);
if (value !== null && typeof value === "object") {
for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]);
return value;
}
return value;
}
function parseLossless(text: string): any {
// 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先,
// 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt
const guarded = text.replace(
/"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g,
token => {
if (token.startsWith('"')) return token;
if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) {
// 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符),
// JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头)
return '"\\u0000fun-bigint:' + token + '"';
}
return token;
}
);
return reviveBigint(JSON.parse(guarded));
}
function stringifyLossless(value: any): string | undefined {
const raw = JSON.stringify(value, (_key, v) =>
typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v
);
if (raw === undefined) return undefined;
// 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"(NUL 被转义),去引号还原为数字字面量
return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1");
}
export type result<T> = {
code?: number;
data?: T;
msg?: string;
status: number;
};
export type resultStatus = 0 | 1 | 2 | 4 | 5;
// 0 success; 1 framework/client protocol error; 2 business error; 4 external request error; 5 external timeout
export type RequestOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
};
export type StreamOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
/**
* 原生断线重连:流因传输失败(status 4/5)结束时按指数退避自动重连。
* dto 传工厂函数时每次重连重新求值,用于刷新续传游标。
* 未传时关闭(默认),与 request 语义一致。
*/
retry?: StreamRetryOptions | false;
/** 空闲看门狗:超过该毫秒数未收到任何字节(含心跳空行)即判定流死亡并走重连。0 关闭。 */
idleTimeoutMs?: number;
};
export type StreamRetryOptions = {
/** 最大重连次数,默认 Infinity(直到 signal 取消或成功)。 */
maxAttempts?: number;
/** 首次重连延迟(毫秒),默认 500。 */
baseDelayMs?: number;
/** 退避上限(毫秒),默认 10000。实际延迟为指数退避 × 0.75~1.25 抖动。 */
maxDelayMs?: number;
};
export type RequestInterceptor = (
serviceName: string,
methodName: string,
state: Record<string, string>,
dto?: any
) => Promise<void> | void;
export type ResponseContext = {
readonly requestState: Readonly<Record<string, string>>;
readonly response?: Response;
};
export type ResponseInterceptor = (
serviceName: string,
methodName: string,
result: result<any>
) => Promise<result<any> | void> | result<any> | void;
export type ContextResponseInterceptor = (
serviceName: string,
methodName: string,
result: result<any>,
context: ResponseContext
) => Promise<result<any> | void> | result<any> | void;
function messageOf(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return "unknown error";
}
function failure(status: resultStatus, msg: string): result<any> {
return { status, msg };
}
function isTimeout(error: unknown, signal?: AbortSignal): boolean {
const errorName = error !== null && typeof error === "object"
? (error as { name?: unknown }).name
: undefined;
const reason = signal?.reason;
const reasonName = reason !== null && typeof reason === "object"
? (reason as { name?: unknown }).name
: undefined;
return errorName === "TimeoutError" || reasonName === "TimeoutError";
}
function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "External stream request" : "External request";
return failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `);
}
function isResult(value: unknown): value is result<any> {
return value !== null && typeof value === "object" &&
typeof (value as { status?: unknown }).status === "number";
}
function mediaType(response: Response): string {
return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase();
}
function excerpt(text: string, limit = 180): string {
const value = text.replace(/\s+/g, " ").trim();
return value.length <= limit ? value : ` + "`${value.slice(0, limit)}...`" + `;
}
function externalFailure(response: Response, detail?: string): result<any> {
const timeout = response.status === 408 || response.status === 504;
const statusText = response.statusText || (timeout ? "timeout" : "request failed");
const suffix = detail ? ` + "`: ${detail}`" + ` : "";
return failure(timeout ? 5 : 4, ` + "`HTTP ${response.status} ${statusText}${suffix}`" + `);
}
function responseReadFailure(
error: unknown,
response: Response,
signal: AbortSignal | undefined,
stream: boolean
): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "Stream" : "Response body";
return response.ok
? failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `)
: externalFailure(response, ` + "`response body failed: ${messageOf(error)}`" + `);
}
function parseResult(response: Response, text: string): result<any> {
const body = text.trim();
if (!body) {
return response.ok
? failure(1, "Empty response body")
: externalFailure(response);
}
let value: unknown;
try {
value = parseLossless(body);
} catch {
if (!response.ok) return externalFailure(response, excerpt(body));
const type = mediaType(response);
if (type === "text/html" || /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)) {
return failure(1, ` + "`Unexpected HTML response: ${excerpt(body)}`" + `);
}
return failure(1, ` + "`Invalid JSON response: ${excerpt(body)}`" + `);
}
if (!isResult(value)) {
return response.ok
? failure(1, "Invalid fun response")
: externalFailure(response, "invalid fun response");
}
return value;
}
export class Client {
private url: string;
private state: Record<string, string> = {};
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ContextResponseInterceptor[] = [];
constructor(url: string) {
this.url = url.replace(/\/+$/, "");
}
setState(state: Record<string, string>) {
this.state = state;
}
addRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(interceptor: ResponseInterceptor): void;
addResponseInterceptor(interceptor: ContextResponseInterceptor): void;
addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) {
this.responseInterceptors.push(interceptor as ContextResponseInterceptor);
}
private async interceptResponse(
serviceName: string,
methodName: string,
initial: result<any>,
requestState: Readonly<Record<string, string>>,
response?: Response
): Promise<result<any>> {
let current = initial;
const context: ResponseContext = Object.freeze(
response === undefined ? { requestState } : { requestState, response }
);
for (const interceptor of this.responseInterceptors) {
try {
const replaced = await interceptor(serviceName, methodName, current, context);
if (replaced) current = replaced;
} catch (error) {
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
}
}
return current;
}
private async interceptRequest(
serviceName: string,
methodName: string,
state: Record<string, string>,
dto: any
): Promise<void> {
for (const interceptor of this.requestInterceptors) {
await interceptor(serviceName, methodName, state, dto);
}
}
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
return Object.freeze({ ...state });
}
async request<T>(
serviceName: string,
methodName: string,
dto?: any,
options?: RequestOptions
): Promise<result<T>> {
let state: Record<string, string>;
try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
) as result<T>;
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
let body: string;
try {
const serialized = stringifyLossless({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let output: result<any>;
let response: Response | undefined;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: options?.signal,
});
try {
output = parseResult(response, await response.text());
} catch (error) {
output = responseReadFailure(error, response, options?.signal, false);
}
} catch (error) {
output = requestFailure(error, options?.signal, false);
}
return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result<T>;
}
async stream<T>(
serviceName: string,
methodName: string,
dto: any | undefined | (() => any | undefined),
onMessage: (data: T) => unknown,
options?: StreamOptions
): Promise<result<void>> {
// dto 工厂:重连时重新求值,用于刷新续传游标(如 afterId)
const getDto = typeof dto === "function" ? (dto as () => any | undefined) : () => dto;
// 重连默认关闭(与 request 语义一致);传 retry: {...} 显式开启
const retry = options?.retry
? {
maxAttempts: Infinity,
baseDelayMs: 500,
maxDelayMs: 10000,
...options.retry,
}
: null;
for (let attempt = 0; ; attempt++) {
const r = await this.streamOnce<T>(serviceName, methodName, getDto(), onMessage, options);
// 0 成功、1 协议错误、2 业务错误:终结态,不重连
if (r.status !== 4 && r.status !== 5) return r;
if (!retry) return r;
if (options?.signal?.aborted) return r;
if (attempt + 1 > retry.maxAttempts) return r;
const delay =
Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** attempt) * (0.75 + Math.random() * 0.5);
// 退避等待:AbortSignal.timeout 免定时器 API,且后台标签页照常走时
await new Promise<void>((resolve) => {
if (options?.signal?.aborted) {
resolve();
return;
}
AbortSignal.timeout(delay).addEventListener("abort", () => resolve(), { once: true });
options?.signal?.addEventListener("abort", () => resolve(), { once: true });
});
if (options?.signal?.aborted) return r;
}
}
private async streamOnce<T>(
serviceName: string,
methodName: string,
dto: any | undefined,
onMessage: (data: T) => unknown,
options?: StreamOptions
): Promise<result<void>> {
let state: Record<string, string>;
try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
);
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
);
}
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
let body: string;
try {
const serialized = stringifyLossless({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
);
}
let response: Response;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: options?.signal,
});
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
requestFailure(error, options?.signal, true),
requestState
);
}
if (!response.ok) {
let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
return await this.interceptResponse(
serviceName,
methodName,
parseResult(response, text),
requestState,
response
);
}
if (mediaType(response) !== "application/x-ndjson") {
let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const rpcResult = parseResult(response, text);
return await this.interceptResponse(
serviceName,
methodName,
rpcResult.status === 0
? failure(1, "Expected application/x-ndjson response")
: rpcResult,
requestState,
response
);
}
if (!response.body) {
return await this.interceptResponse(
serviceName,
methodName,
{ status: 0 },
requestState,
response
);
}
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = response.body.getReader();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const decoder = new TextDecoder("utf-8", { fatal: true });
let buffer = "";
let lineNumber = 0;
let failed: result<any> | undefined;
let cause: unknown;
const emitLine = async (line: string) => {
const payload = line.replace(/\r$/, "").trim();
if (!payload) return;
let data: T;
try {
data = parseLossless(payload) as T;
} catch (error) {
failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `);
cause = error;
return;
}
try {
await onMessage(data);
} catch (error) {
failed = failure(1, ` + "`Stream callback failed: ${messageOf(error)}`" + `);
cause = error;
}
};
try {
// 空闲看门狗:idleTimeoutMs 内未收到任何字节(含心跳空行)判定流死亡,
// 抛出哨兵错误 → status 5 → 交给上层重连。服务端 25s 心跳时建议 35000。
const STREAM_IDLE = Symbol("stream-idle");
for (;;) {
let part: ReadableStreamReadResult<Uint8Array>;
try {
if (options?.idleTimeoutMs) {
const idlePromise = new Promise<never>((_, reject) => {
AbortSignal.timeout(options.idleTimeoutMs!).addEventListener(
"abort",
() => reject(STREAM_IDLE),
{ once: true }
);
});
idlePromise.catch(() => {}); // read 先返回时防 unhandled rejection
part = await Promise.race([reader.read(), idlePromise]);
} else {
part = await reader.read();
}
} catch (error) {
cause = error;
if (error === STREAM_IDLE) {
failed = failure(5, "Stream idle timeout");
} else if (isTimeout(error, options?.signal)) {
failed = failure(5, "Stream timed out");
} else if (options?.signal?.aborted === true) {
failed = failure(4, "Stream aborted");
} else {
failed = failure(4, ` + "`Stream read failed: ${messageOf(error)}`" + `);
}
break;
}
if (part.done) break;
try {
buffer += decoder.decode(part.value, { stream: true });
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
break;
}
for (;;) {
const newline = buffer.indexOf("\n");
if (newline < 0) break;
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
lineNumber++;
await emitLine(line);
if (failed) break;
}
if (failed) break;
}
if (!failed) {
try {
buffer += decoder.decode();
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
}
}
if (!failed && buffer.length > 0) {
lineNumber++;
await emitLine(buffer);
}
} finally {
if (failed) {
try {
await reader.cancel(cause);
} catch {
// The reader may already be closed by the runtime.
}
}
try {
reader.releaseLock();
} catch {
// The reader may already be errored or released.
}
}
return await this.interceptResponse(
serviceName,
methodName,
failed || { status: 0 },
requestState,
response
);
}
}`
}
func (ctx templateTs) genDefaultServiceTemplate() string {
return `import { Client } from "./client";
{{- range .GenServiceList}}
import {{.ServiceName}} from "./{{.ServiceName}}";
{{- end}}
export class defaultApi extends Client {
constructor(url: string) {
super(url);
}
{{- range .GenServiceList}}
public {{.ServiceName}}: {{.ServiceName}} = new {{.ServiceName}}(this);
{{- end}}
}
export default class api {
static create(url: string): defaultApi {
return new defaultApi(url);
}
}`
}
func (ctx templateTs) genServiceTemplate() string {
return `import { Client{{if .IsIncludeRequest}}, type result, type RequestOptions{{end}}{{if .IsIncludeStream}}{{if not .IsIncludeRequest}}, type result{{end}}, type StreamOptions{{end}} } from "./client";
{{- range .GenImport}}
import type {{.Name}} from "./{{.Name}}";
{{- end}}
export default class {{.ServiceName}} {
private client: Client;
constructor(client: Client) {
this.client = client;
}
{{- $serviceName := .ServiceName }}
{{- range .GenMethodTypeList}}
{{if .IsStream }}async {{.MethodName}}({{if .DtoText}}dto: {{.DtoText}} | (() => {{.DtoText}}), {{end}}onMessage: (data: {{.GenericTypeText}}) => unknown, options?: StreamOptions): Promise<result<void>> {
return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage, options)
}{{else}}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}options?: RequestOptions): Promise<{{.ReturnValueText}}> {
return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, options)
}{{end}}
{{- end}}
}`
}
func (ctx templateTs) genStructTemplate() string {
return `{{- range .GenImport}}import type {{.Name}} from "./{{.Name}}";{{"\n"}}{{- end}}export default interface {{.Name}} {
{{- range .GenClassFieldType}}
{{.Name}}:{{.Type}}
{{- end}}
}`
}
func (ctx templateTs) genEnumTemplate() string {
return `enum {{.Name}} {
{{- range $index, $element := .Names}}
{{$element}},
{{- end}}
}{{ $enumName := .Name }}
function values(): {{.Name}}[] {
return [
{{- range $index, $element := .Names}}
{{$enumName}}.{{$element}},
{{- end}}
]
}
{{if .DisplayNames}}
function displayNames(): string[] {
return [
{{- range $index, $element := .DisplayNames}}
"{{$element}}",
{{- end}}
]
}
{{end}}
export default {{.Name}}`
}