package fun type templateTs struct{} func (ctx templateTs) genClientTemplate() string { return `export type result = { id?: string; 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; }; export type StreamOptions = { signal?: AbortSignal; }; export type RequestInterceptor = ( serviceName: string, methodName: string, state: Record, dto?: any ) => Promise | void; export type ResponseInterceptor = ( serviceName: string, methodName: string, result: result ) => Promise | void> | result | void; function messageOf(error: unknown): string { if (error instanceof Error && error.message) return error.message; if (typeof error === "string" && error) return error; return "unknown error"; } function failure(status: resultStatus, msg: string): result { return { status, msg }; } function isTimeout(error: unknown, signal?: AbortSignal): boolean { const errorName = error !== null && typeof error === "object" ? (error as { name?: unknown }).name : undefined; const reason = signal?.reason; const reasonName = reason !== null && typeof reason === "object" ? (reason as { name?: unknown }).name : undefined; return errorName === "TimeoutError" || reasonName === "TimeoutError"; } function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result { if (isTimeout(error, signal)) { return failure(5, stream ? "Stream timed out" : "Request timed out"); } if (signal?.aborted === true) { return failure(4, stream ? "Stream aborted" : "Request aborted"); } const kind = stream ? "External stream request" : "External request"; return failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `); } function isResult(value: unknown): value is result { return value !== null && typeof value === "object" && typeof (value as { status?: unknown }).status === "number"; } function mediaType(response: Response): string { return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase(); } function excerpt(text: string, limit = 180): string { const value = text.replace(/\s+/g, " ").trim(); return value.length <= limit ? value : ` + "`${value.slice(0, limit)}...`" + `; } function externalFailure(response: Response, detail?: string): result { const timeout = response.status === 408 || response.status === 504; const statusText = response.statusText || (timeout ? "timeout" : "request failed"); const suffix = detail ? ` + "`: ${detail}`" + ` : ""; return failure(timeout ? 5 : 4, ` + "`HTTP ${response.status} ${statusText}${suffix}`" + `); } function responseReadFailure( error: unknown, response: Response, signal: AbortSignal | undefined, stream: boolean ): result { if (isTimeout(error, signal)) { return failure(5, stream ? "Stream timed out" : "Request timed out"); } if (signal?.aborted === true) { return failure(4, stream ? "Stream aborted" : "Request aborted"); } const kind = stream ? "Stream" : "Response body"; return response.ok ? failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `) : externalFailure(response, ` + "`response body failed: ${messageOf(error)}`" + `); } function parseResult(response: Response, text: string): result { const body = text.trim(); if (!body) { return response.ok ? failure(1, "Empty response body") : externalFailure(response); } let value: unknown; try { value = JSON.parse(body); } catch { if (!response.ok) return externalFailure(response, excerpt(body)); const type = mediaType(response); if (type === "text/html" || /^\s*(?: = {}; private requestInterceptors: RequestInterceptor[] = []; private responseInterceptors: ResponseInterceptor[] = []; constructor(url: string) { this.url = url.replace(/\/+$/, ""); } setState(state: Record) { this.state = state; } addRequestInterceptor(interceptor: RequestInterceptor) { this.requestInterceptors.push(interceptor); } addResponseInterceptor(interceptor: ResponseInterceptor) { this.responseInterceptors.push(interceptor); } private async interceptResponse( serviceName: string, methodName: string, initial: result ): Promise> { let current = initial; for (const interceptor of this.responseInterceptors) { try { const replaced = await interceptor(serviceName, methodName, current); if (replaced) current = replaced; } catch (error) { current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `); } } return current; } private async requestState(serviceName: string, methodName: string, dto: any): Promise> { const state: Record = { ...this.state }; for (const interceptor of this.requestInterceptors) { await interceptor(serviceName, methodName, state, dto); } return state; } async request( serviceName: string, methodName: string, dto?: any, options?: RequestOptions ): Promise> { let state: Record; try { state = await this.requestState(serviceName, methodName, dto); } catch (error) { return await this.interceptResponse( serviceName, methodName, failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `) ) as result; } let body: string; try { const serialized = JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(state).length ? { state } : {}), }); 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)}`" + `) ) as result; } let output: result; try { const 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) as result; } async stream( serviceName: string, methodName: string, dto: any | undefined, onMessage: (data: T) => unknown, options?: StreamOptions ): Promise> { let state: Record; try { state = await this.requestState(serviceName, methodName, dto); } catch (error) { return await this.interceptResponse( serviceName, methodName, failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `) ); } let body: string; try { const serialized = JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(state).length ? { state } : {}), }); 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)}`" + `) ); } 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) ); } 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) ); } return await this.interceptResponse(serviceName, methodName, parseResult(response, text)); } 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) ); } const rpcResult = parseResult(response, text); return await this.interceptResponse( serviceName, methodName, rpcResult.status === 0 ? failure(1, "Expected application/x-ndjson response") : rpcResult ); } if (!response.body) { return await this.interceptResponse(serviceName, methodName, { status: 0 }); } let reader: ReadableStreamDefaultReader; try { reader = response.body.getReader(); } catch (error) { return await this.interceptResponse( serviceName, methodName, responseReadFailure(error, response, options?.signal, true) ); } const decoder = new TextDecoder("utf-8", { fatal: true }); let buffer = ""; let lineNumber = 0; let failed: result | undefined; let cause: unknown; const emitLine = async (line: string) => { const payload = line.replace(/\r$/, "").trim(); if (!payload) return; let data: T; try { data = JSON.parse(payload) as T; } catch (error) { failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `); cause = error; return; } try { await onMessage(data); } catch (error) { failed = failure(1, ` + "`Stream callback failed: ${messageOf(error)}`" + `); cause = error; } }; try { for (;;) { let part: ReadableStreamReadResult; try { part = await reader.read(); } catch (error) { cause = error; if (isTimeout(error, options?.signal)) { failed = failure(5, "Stream timed out"); } else if (options?.signal?.aborted === true) { failed = failure(4, "Stream aborted"); } else { failed = failure(4, ` + "`Stream read failed: ${messageOf(error)}`" + `); } break; } if (part.done) break; try { buffer += decoder.decode(part.value, { stream: true }); } catch (error) { failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `); cause = error; break; } for (;;) { const newline = buffer.indexOf("\n"); if (newline < 0) break; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); lineNumber++; await emitLine(line); if (failed) break; } if (failed) break; } if (!failed) { try { buffer += decoder.decode(); } catch (error) { failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `); cause = error; } } if (!failed && buffer.length > 0) { lineNumber++; await emitLine(buffer); } } finally { if (failed) { try { await reader.cancel(cause); } catch { // The reader may already be closed by the runtime. } } try { reader.releaseLock(); } catch { // The reader may already be errored or released. } } return await this.interceptResponse( serviceName, methodName, failed || { status: 0 } ); } }` } 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}}{{.DtoText}}, {{end}}onMessage: (data: {{.GenericTypeText}}) => unknown, options?: StreamOptions): Promise> { return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage, options) }{{else}}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}options?: RequestOptions): Promise<{{.ReturnValueText}}> { return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, options) }{{end}} {{- end}} }` } func (ctx templateTs) genStructTemplate() string { return `{{- range .GenImport}}import type {{.Name}} from "./{{.Name}}";{{"\n"}}{{- end}}export default interface {{.Name}} { {{- range .GenClassFieldType}} {{.Name}}:{{.Type}} {{- end}} }` } func (ctx templateTs) genEnumTemplate() string { return `enum {{.Name}} { {{- range $index, $element := .Names}} {{$element}}, {{- end}} }{{ $enumName := .Name }} function values(): {{.Name}}[] { return [ {{- range $index, $element := .Names}} {{$enumName}}.{{$element}}, {{- end}} ] } {{if .DisplayNames}} function displayNames(): string[] { return [ {{- range $index, $element := .DisplayNames}} "{{$element}}", {{- end}} ] } {{end}} export default {{.Name}}` }