mirror of
https://github.com/JuLi0n21/goTsClient.git
synced 2026-04-20 00:10:07 +00:00
init
This commit is contained in:
1
.direnv/flake-profile
Symbolic link
1
.direnv/flake-profile
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
flake-profile-1-link
|
||||||
1
.direnv/flake-profile-1-link
Symbolic link
1
.direnv/flake-profile-1-link
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yir3akbqrs37krrd4v7kgb5qpjzws30d-nix-shell-env
|
||||||
146
api.ts
Normal file
146
api.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
export class WSBackend {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private tokenProvider?: () => string;
|
||||||
|
private url: string;
|
||||||
|
private callbacks: Record<string, {
|
||||||
|
callback: (err: any, res: any) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}> = {};
|
||||||
|
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<any> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
189
client.ts
Normal file
189
client.ts
Normal file
@@ -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<T> = { data: T; error?: any };
|
||||||
|
|
||||||
|
export interface RPCClient {
|
||||||
|
ComplexThings(arg0: string[]): Promise<RPCResult<{ [key: string]: number }>>;
|
||||||
|
EmptyParams(): Promise<RPCResult<string>>;
|
||||||
|
ManyParams(arg0: number, arg1: number, arg2: number, arg3: number, arg4: number, arg5: number, arg6: number, arg7: number, arg8: number): Promise<RPCResult<string>>;
|
||||||
|
Structs(receiveValue: ReceiveValue): Promise<RPCResult<ReturnValue>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WSBackend {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private tokenProvider?: () => string;
|
||||||
|
private url: string;
|
||||||
|
private callbacks: Record<string, {
|
||||||
|
callback: (err: any, res: any) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}> = {};
|
||||||
|
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<any> {
|
||||||
|
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! };
|
||||||
|
}
|
||||||
32
example.go
Normal file
32
example.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
11
example_test.go
Normal file
11
example_test.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package gotsclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateClient(t *testing.T) {
|
||||||
|
|
||||||
|
fmt.Println(GenClient(Api{}, "./client.ts"))
|
||||||
|
}
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
21
flake.nix
Normal file
21
flake.nix
Normal file
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
242
generator.go
Normal file
242
generator.go
Normal file
@@ -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<T> = { 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<RPCResult<%s>>;\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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module clientGen
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require golang.org/x/net v0.52.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -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=
|
||||||
159
handler.go
Normal file
159
handler.go
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user