commit a82223e194e939a2dc5c886fd0375f8e67ee75f0 Author: JuLi0n21 Date: Fri Mar 13 16:55:58 2026 +0100 init diff --git a/.direnv/flake-profile b/.direnv/flake-profile new file mode 120000 index 0000000..0c05709 --- /dev/null +++ b/.direnv/flake-profile @@ -0,0 +1 @@ +flake-profile-1-link \ No newline at end of file diff --git a/.direnv/flake-profile-1-link b/.direnv/flake-profile-1-link new file mode 120000 index 0000000..3e86f46 --- /dev/null +++ b/.direnv/flake-profile-1-link @@ -0,0 +1 @@ +/nix/store/yir3akbqrs37krrd4v7kgb5qpjzws30d-nix-shell-env \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..44610e5 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake; diff --git a/api.ts b/api.ts new file mode 100644 index 0000000..b5b8813 --- /dev/null +++ b/api.ts @@ -0,0 +1,146 @@ +export class WSBackend { + private ws: WebSocket | null = null; + private tokenProvider?: () => string; + private url: string; + private callbacks: Record void; + timeout: ReturnType; + }> = {}; + private counter = 0; + private _api: any = null; + private queue: Array<{ id: string; method: string; params: any[]; resolve: Function; reject: Function }> = []; + private reconnectDelay = 1000; + private _connected = false; + private connectedListeners: Array<() => void> = []; + private disconnectedListeners: Array<() => void> = []; + private callTimeout = 10000; // 10 second timeout for calls + + constructor(url: string) { + this.url = url; + this._api = new Proxy({}, { + get: (_t, method: string) => (...args: any[]) => this.call(method, args) + }); + this.connect(); + } + + public setTokenProvider(fn: () => string) { + this.tokenProvider = fn; + } + + public get api() { + return this._api; + } + + public get connected() { + return this._connected; + } + + public onConnected(cb: () => void) { + this.connectedListeners.push(cb); + } + + public onDisconnected(cb: () => void) { + this.disconnectedListeners.push(cb); + } + + public setCallTimeout(ms: number) { + this.callTimeout = ms; + } + + private connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log("[WS] Connected to", this.url); + this._connected = true; + this.connectedListeners.forEach(cb => cb()); + + this.queue.forEach(item => { + this._send(item.id, item.method, item.params); + }); + this.queue = []; + }; + + this.ws.onmessage = (evt) => { + let msg: any; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + + const callbackData = this.callbacks[msg.id]; + if (!callbackData) return; + + clearTimeout(callbackData.timeout); + + if (msg.result && typeof msg.result === "object" && ("data" in msg.result || "error" in msg.result)) { + const r = msg.result; + callbackData.callback(r.error, r.data); + } else { + callbackData.callback(msg.error, msg.result); + } + + delete this.callbacks[msg.id]; + }; + + this.ws.onclose = () => { + const wasConnected = this._connected; + this._connected = false; + + Object.keys(this.callbacks).forEach(id => { + const callbackData = this.callbacks[id]; + clearTimeout(callbackData.timeout); + callbackData.callback({ message: 'WebSocket disconnected' }, null); + delete this.callbacks[id]; + }); + + if (wasConnected) { + this.disconnectedListeners.forEach(cb => cb()); + } + + console.log("[WS] Disconnected. Reconnecting..."); + setTimeout(() => this.connect(), this.reconnectDelay); + }; + + this.ws.onerror = (e) => { + console.warn("[WS] Error:", e); + this.ws?.close(); + }; + } + + private call(method: string, params: any[]): Promise { + const id = (++this.counter).toString(); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.callbacks[id]) { + console.warn(`[WS] Call timeout for \${method} (id: \${id})`); + this.callbacks[id].callback({ message: 'Request timeout' }, null); + delete this.callbacks[id]; + } + }, this.callTimeout); + + this.callbacks[id] = { + callback: (err, res) => { + resolve({ data: res, error: err }); + }, + timeout + }; + + if (this._connected && this.ws && this.ws.readyState === WebSocket.OPEN) { + this._send(id, method, params); + } else { + console.log("[WS] Queueing call", method, id, params); + this.queue.push({ id, method, params, resolve, reject: () => { } }); + } + }); + } + + private _send(id: string, method: string, params: any[]) { + if (!this.ws) throw new Error("WebSocket not initialized"); + const msg: any = { id, method, params }; + if (this.tokenProvider) msg.token = this.tokenProvider(); + this.ws.send(JSON.stringify(msg)); + } +} diff --git a/client.ts b/client.ts new file mode 100644 index 0000000..5a07d80 --- /dev/null +++ b/client.ts @@ -0,0 +1,189 @@ +// --- AUTO-GENERATED --- +// Generated by generator. Do not edit by hand (unless you know what you do). +// Types + +export interface ReceiveValue { + Field: string[]; + Second: number; + Uhhhh?: any; +} + +export interface ReturnValue { + Field: string[]; + Second: number; + Uhhhh?: any; +} + +// Generic RPC method result +export type RPCResult = { data: T; error?: any }; + +export interface RPCClient { + ComplexThings(arg0: string[]): Promise>; + EmptyParams(): Promise>; + ManyParams(arg0: number, arg1: number, arg2: number, arg3: number, arg4: number, arg5: number, arg6: number, arg7: number, arg8: number): Promise>; + Structs(receiveValue: ReceiveValue): Promise>; +} + +export class WSBackend { + private ws: WebSocket | null = null; + private tokenProvider?: () => string; + private url: string; + private callbacks: Record void; + timeout: ReturnType; + }> = {}; + private counter = 0; + private _api: any = null; + private queue: Array<{ id: string; method: string; params: any[]; resolve: Function; reject: Function }> = []; + private reconnectDelay = 1000; + private _connected = false; + private connectedListeners: Array<() => void> = []; + private disconnectedListeners: Array<() => void> = []; + private callTimeout = 10000; // 10 second timeout for calls + + constructor(url: string) { + this.url = url; + this._api = new Proxy({}, { + get: (_t, method: string) => (...args: any[]) => this.call(method, args) + }); + this.connect(); + } + + public setTokenProvider(fn: () => string) { + this.tokenProvider = fn; + } + + public get api() { + return this._api; + } + + public get connected() { + return this._connected; + } + + public onConnected(cb: () => void) { + this.connectedListeners.push(cb); + } + + public onDisconnected(cb: () => void) { + this.disconnectedListeners.push(cb); + } + + public setCallTimeout(ms: number) { + this.callTimeout = ms; + } + + private connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log("[WS] Connected to", this.url); + this._connected = true; + this.connectedListeners.forEach(cb => cb()); + + this.queue.forEach(item => { + this._send(item.id, item.method, item.params); + }); + this.queue = []; + }; + + this.ws.onmessage = (evt) => { + let msg: any; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + + const callbackData = this.callbacks[msg.id]; + if (!callbackData) return; + + clearTimeout(callbackData.timeout); + + if (msg.result && typeof msg.result === "object" && ("data" in msg.result || "error" in msg.result)) { + const r = msg.result; + callbackData.callback(r.error, r.data); + } else { + callbackData.callback(msg.error, msg.result); + } + + delete this.callbacks[msg.id]; + }; + + this.ws.onclose = () => { + const wasConnected = this._connected; + this._connected = false; + + Object.keys(this.callbacks).forEach(id => { + const callbackData = this.callbacks[id]; + clearTimeout(callbackData.timeout); + callbackData.callback({ message: 'WebSocket disconnected' }, null); + delete this.callbacks[id]; + }); + + if (wasConnected) { + this.disconnectedListeners.forEach(cb => cb()); + } + + console.log("[WS] Disconnected. Reconnecting..."); + setTimeout(() => this.connect(), this.reconnectDelay); + }; + + this.ws.onerror = (e) => { + console.warn("[WS] Error:", e); + this.ws?.close(); + }; + } + + private call(method: string, params: any[]): Promise { + const id = (++this.counter).toString(); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.callbacks[id]) { + console.warn(`[WS] Call timeout for \${method} (id: \${id})`); + this.callbacks[id].callback({ message: 'Request timeout' }, null); + delete this.callbacks[id]; + } + }, this.callTimeout); + + this.callbacks[id] = { + callback: (err, res) => { + resolve({ data: res, error: err }); + }, + timeout + }; + + if (this._connected && this.ws && this.ws.readyState === WebSocket.OPEN) { + this._send(id, method, params); + } else { + console.log("[WS] Queueing call", method, id, params); + this.queue.push({ id, method, params, resolve, reject: () => { } }); + } + }); + } + + private _send(id: string, method: string, params: any[]) { + if (!this.ws) throw new Error("WebSocket not initialized"); + const msg: any = { id, method, params }; + if (this.tokenProvider) msg.token = this.tokenProvider(); + this.ws.send(JSON.stringify(msg)); + } +} + +let backendWrapper: WSBackend | null = null; +let backendInstance: RPCClient | null = null; + +export function useBackend(url: string = '/ws'): { api: RPCClient, backend: WSBackend } { + if (url.startsWith('/')) { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const host = window.location.host; + url = `${protocol}://${host}${url}`; + } + + if (!backendWrapper) { + backendWrapper = new WSBackend(url); + backendInstance = backendWrapper.api as unknown as RPCClient; + } + return { api: backendInstance!, backend: backendWrapper! }; +} diff --git a/example.go b/example.go new file mode 100644 index 0000000..d5a5ab2 --- /dev/null +++ b/example.go @@ -0,0 +1,32 @@ +package gotsclient + +type Api struct { +} + +type ReturnValue struct { + Field []string + Second int + Uhhhh *uint32 +} + +type ReceiveValue struct { + Field []string + Second int + Uhhhh *uint32 +} + +func (a Api) EmptyParams() (string, error) { + return "", nil +} + +func (a Api) Structs(value ReceiveValue) (ReturnValue, error) { + return ReturnValue{}, nil +} + +func (a Api) ComplexThings(items []string) (map[string]int, error) { + return map[string]int{}, nil +} + +func (a Api) ManyParams(b, c, d, e, f, g, h, i, j int) (string, error) { + return "", nil +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..25a5ae9 --- /dev/null +++ b/example_test.go @@ -0,0 +1,11 @@ +package gotsclient + +import ( + "fmt" + "testing" +) + +func TestGenerateClient(t *testing.T) { + + fmt.Println(GenClient(Api{}, "./client.ts")) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..88ab73f --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1773282481, + "narHash": "sha256-b/GV2ysM8mKHhinse2wz+uP37epUrSE+sAKXy/xvBY4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fe416aaedd397cacb33a610b33d60ff2b431b127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fa1836d --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + description = "Dev environment for Horsa"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs, ... }: + let + pkgs = import nixpkgs { system = "x86_64-linux"; }; + in + { + devShells.x86_64-linux.default = pkgs.mkShell { + packages = with pkgs; [ + go_1_26 + + nodejs_24 + ]; + }; + }; +} diff --git a/generator.go b/generator.go new file mode 100644 index 0000000..1d20e0b --- /dev/null +++ b/generator.go @@ -0,0 +1,242 @@ +package gotsclient + +import ( + _ "embed" + "errors" + "fmt" + "log" + "os" + "reflect" + "slices" + "strings" +) + +//go:embed api.ts +var backendClient string + +var errorType = reflect.TypeOf((*error)(nil)).Elem() + +func GenClient(api any, outPutPath string) error { + + ts, err := GenerateTS(reflect.TypeOf(api), "RPCClient") + if err != nil { + return err + } + if err := os.WriteFile(outPutPath, []byte(ts), 0644); err != nil { + return err + } + log.Println("Generated TS client", outPutPath) + return nil +} + +func GenerateTS(apiType reflect.Type, clientName string) (string, error) { + var b strings.Builder + + b.WriteString("// --- AUTO-GENERATED ---\n") + b.WriteString("// Generated by generator. Do not edit by hand (unless you know what you do).\n") + b.WriteString("// Types\n\n") + + structs := map[string]reflect.Type{} + + for method := range apiType.Methods() { + i := -1 + for param := range method.Type.Ins() { + i++ + //skip self + if i == 0 { + continue + } + log.Println(param) + collectStructs(param, structs) + } + + outParams := method.Type.NumOut() + + if outParams > 1 && method.Type.Out(1).Implements(errorType) { + collectStructs(method.Type.Out(0), structs) + } else { + return "", errors.New("supported layout are func() (Struct, error), func(x...) (*Struct, error) ") + } + + } + + names := make([]string, 0, len(structs)) + for n := range structs { + names = append(names, n) + } + + slices.Sort(names) + + for _, name := range names { + st := structs[name] + b.WriteString(fmt.Sprintf("export interface %s {\n", name)) + for f := range st.Fields() { + jsName := f.Name + tag := f.Tag.Get("json") + if tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" { + jsName = parts[0] + } + } + + optional := "" + ft := f.Type + if ft.Kind() == reflect.Ptr { + optional = "?" + ft = ft.Elem() + } + b.WriteString(fmt.Sprintf(" %s%s: %s;\n", jsName, optional, goTypeToTS(ft))) + } + b.WriteString("}\n\n") + } + + b.WriteString("// Generic RPC method result\n") + b.WriteString("export type RPCResult = { data: T; error?: any };\n\n") + + b.WriteString(fmt.Sprintf("export interface %s {\n", clientName)) + for m := range apiType.Methods() { + params := []string{} + i := -1 + for ptypeGo := range m.Type.Ins() { + i++ + //skip self or context + if ptypeGo.String() == "context.Context" || i == 0 { + continue + } + + var pname string + if ptypeGo.Kind() == reflect.Struct { + named := false + for fields := range ptypeGo.Fields() { + tag := fields.Tag.Get("json") + if tag != "" && tag != "-" { + pname = tag + named = true + break + } + } + if !named { + // fallback to camelCase struct name + if ptypeGo.Name() != "" { + pname = strings.ToLower(ptypeGo.Name()[:1]) + ptypeGo.Name()[1:] + } else { + pname = fmt.Sprintf("arg%d", i-1) + } + } + } else { + pname = fmt.Sprintf("arg%d", i-1) + } + + ptype := goTypeToTS(ptypeGo) + + params = append(params, fmt.Sprintf("%s: %s", pname, ptype)) + } + + resType := "any" + if m.Type.NumOut() > 0 { + resType = goTypeToTS(m.Type.Out(0)) + } + b.WriteString(fmt.Sprintf(" %s(%s): Promise>;\n", m.Name, strings.Join(params, ", "), resType)) + } + b.WriteString("}\n\n") + + b.WriteString(backendClient) + + b.WriteString("\nlet backendWrapper: WSBackend | null = null;\n") + b.WriteString(fmt.Sprintf("let backendInstance: %s | null = null;\n\n", clientName)) + b.WriteString(fmt.Sprintf("export function useBackend(url: string = '/ws'): { api: %s, backend: WSBackend } {\n", clientName)) + b.WriteString(" if (url.startsWith('/')) {\n") + b.WriteString(" const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';\n") + b.WriteString(" const host = window.location.host;\n") + b.WriteString(" url = `${protocol}://${host}${url}`;\n") + b.WriteString(" }\n\n") + b.WriteString(" if (!backendWrapper) {\n") + b.WriteString(" backendWrapper = new WSBackend(url);\n") + b.WriteString(" backendInstance = backendWrapper.api as unknown as " + clientName + ";\n") + b.WriteString(" }\n") + b.WriteString(" return { api: backendInstance!, backend: backendWrapper! };\n") + b.WriteString("}\n") + + return b.String(), nil +} + +func collectStructs(t reflect.Type, m map[string]reflect.Type) { + if t == nil { + return + } + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + switch t.Kind() { + case reflect.Struct: + name := t.Name() + if name == "" { + // anonymous struct: skip named interface generation; will fallback to 'any' or inline handling + return + } + if _, ok := m[name]; ok { + return + } + m[name] = t + + for field := range t.Fields() { + collectStructs(field.Type, m) + } + + case reflect.Slice, reflect.Array: + collectStructs(t.Elem(), m) + case reflect.Map: + collectStructs(t.Elem(), m) + } +} + +func goTypeToTS(t reflect.Type) string { + if t == nil { + return "any" + } + if t.Kind() == reflect.Ptr { + return goTypeToTS(t.Elem()) + } + switch t.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Float32, reflect.Float64: + return "number" + case reflect.String: + return "string" + case reflect.Bool: + return "boolean" + case reflect.Struct: + if t.PkgPath() == "time" && t.Name() == "Time" { + return "Date" + } + + if t.Name() != "" { + return t.Name() + } + var parts []string + for f := range t.Fields() { + fn := f.Name + tag := f.Tag.Get("json") + if tag != "" { + parts = append(parts, fmt.Sprintf("%s: %s", strings.Split(tag, ",")[0], goTypeToTS(f.Type))) + } else { + parts = append(parts, fmt.Sprintf("%s: %s", fn, goTypeToTS(f.Type))) + } + } + return fmt.Sprintf("{ %s }", strings.Join(parts, "; ")) + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + return "string" + } + return goTypeToTS(t.Elem()) + "[]" + case reflect.Map: + key := t.Key() + if key.Kind() == reflect.String { + return fmt.Sprintf("{ [key: string]: %s }", goTypeToTS(t.Elem())) + } + return "any" + default: + return "any" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d12e291 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module clientGen + +go 1.26.1 + +require golang.org/x/net v0.52.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3b24b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..f77fab3 --- /dev/null +++ b/handler.go @@ -0,0 +1,159 @@ +package gotsclient + +import ( + "context" + "encoding/json" + "fmt" + "log" + "reflect" + + "golang.org/x/net/websocket" +) + +func wsHandler(api any) func(*websocket.Conn) { + return func(ws *websocket.Conn) { + apiVal := reflect.ValueOf(api) + + for { + var msg []byte + if err := websocket.Message.Receive(ws, &msg); err != nil { + break + } + + var req struct { + ID string `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` + Token string `json:"token"` + } + if err := json.Unmarshal(msg, &req); err != nil { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": "", + "error": "invalid json payload", + }) + continue + } + + log.Printf("%s, %v", req.Method, req.Params) + + method := apiVal.MethodByName(req.Method) + if !method.IsValid() { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "error": "method not found: " + req.Method, + }) + continue + } + + mType := method.Type() + numIn := mType.NumIn() + + hasCtx := false + startIdx := 0 + ctxType := reflect.TypeOf((*context.Context)(nil)).Elem() + + if numIn > 0 && mType.In(0).Implements(ctxType) { + hasCtx = true + startIdx = 1 + } + + expectedParams := numIn - startIdx + if len(req.Params) != expectedParams { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "error": fmt.Sprintf( + "invalid param count: expected %d, got %d", + expectedParams, len(req.Params), + ), + }) + continue + } + + var in []reflect.Value + + if hasCtx { + ctx := context.Background() + in = append(in, reflect.ValueOf(ctx)) + } + + valid := true + for i := startIdx; i < numIn; i++ { + argType := mType.In(i) + raw := req.Params[i-startIdx] + + argPtr := reflect.New(argType) + b, err := json.Marshal(raw) + if err != nil { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "error": fmt.Sprintf("param %d marshal error: %v", i-startIdx, err), + }) + valid = false + break + } + if err := json.Unmarshal(b, argPtr.Interface()); err != nil { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "error": fmt.Sprintf( + "param %d unmarshal to %s failed: %v", + i-startIdx, argType.Name(), err, + ), + }) + valid = false + break + } + + in = append(in, argPtr.Elem()) + } + + if !valid { + continue + } + + var out []reflect.Value + func() { // protect against panics + defer func() { + if r := recover(); r != nil { + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "error": fmt.Sprintf("method panic: %v", r), + }) + out = nil + } + }() + out = method.Call(in) + }() + if out == nil { + continue + } + + var result any + var errVal any + + if len(out) > 0 { + result = out[0].Interface() + } + if len(out) > 1 { + if errIF := out[1].Interface(); errIF != nil { + if e, ok := errIF.(error); ok { + errVal = e.Error() + } else { + errVal = "unknown error type" + } + } + } + + if errVal != nil { + log.Println(errVal) + } + + _ = websocket.JSON.Send(ws, map[string]any{ + "id": req.ID, + "result": map[string]any{ + "data": result, + "error": errVal, + }, + }) + } + } +}