diff --git a/example/ts/index.ts b/example/ts/index.ts index a066590..421422f 100644 --- a/example/ts/index.ts +++ b/example/ts/index.ts @@ -1,4 +1,4 @@ -import {KittenIPC} from '../../lib/ts/lib.js'; +import {KittenIPC} from '../../lib/ts/lib.ts'; import GoIpcApi from './goapi.gen.ts'; /** diff --git a/example/ts/package.json b/example/ts/package.json index 5b78c30..38a25e8 100644 --- a/example/ts/package.json +++ b/example/ts/package.json @@ -2,6 +2,7 @@ "name": "kitten-example-simple", "version": "1.0.0", "main": "index.js", + "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc" diff --git a/example/ts/tsconfig.json b/example/ts/tsconfig.json index cec4a3a..ed32265 100644 --- a/example/ts/tsconfig.json +++ b/example/ts/tsconfig.json @@ -40,5 +40,7 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, + + "allowImportingTsExtensions": true } } diff --git a/kitcom/ts_gen.tmpl b/kitcom/ts_gen.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/lib/golang/lib.go b/lib/golang/lib.go index 610f1a9..41d6282 100644 --- a/lib/golang/lib.go +++ b/lib/golang/lib.go @@ -2,6 +2,7 @@ package kittenipc import ( "bufio" + "encoding/json" "flag" "fmt" "net" @@ -13,7 +14,6 @@ import ( "sync" "time" - "github.com/go-json-experiment/json" "github.com/samber/mo" ) @@ -23,6 +23,8 @@ type StdioMode int type MsgType int +type Vals []any + const ( MsgCall MsgType = 1 MsgResponse MsgType = 2 @@ -32,23 +34,18 @@ type Message struct { Type MsgType `json:"type"` Id int64 `json:"id"` Method string `json:"method"` - Params []any `json:"params"` - Result []any `json:"result"` + Params Vals `json:"params"` + Result Vals `json:"result"` Error string `json:"error"` } -type callResult struct { - result []any - err error -} - type ipcCommon struct { localApi any socketPath string conn net.Conn errCh chan error nextId int64 - pendingCalls map[int64]chan callResult + pendingCalls map[int64]chan mo.Result[Vals] mu sync.Mutex } @@ -202,6 +199,15 @@ func (ipc *ipcCommon) raiseErr(err error) { } } +func (ipc *ipcCommon) cleanup() { + ipc.mu.Lock() + defer ipc.mu.Unlock() + _ = ipc.conn.Close() + for _, call := range ipc.pendingCalls { + call <- mo.Err[Vals](fmt.Errorf("call cancelled due to ipc termination")) + } +} + type ParentIPC struct { *ipcCommon cmd *exec.Cmd diff --git a/lib/ts/lib.ts b/lib/ts/lib.ts index e69de29..eea15ab 100644 --- a/lib/ts/lib.ts +++ b/lib/ts/lib.ts @@ -0,0 +1,315 @@ +import * as net from 'node:net'; +import * as readline from 'node:readline'; +import {type ChildProcess, spawn} from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as util from 'node:util'; +import {QueuedEvent} from 'ts-events'; + +const IPC_SOCKET_ARG = '--ipc-socket'; + +enum MsgType { + Call = 1, + Response = 2, +} + +type Vals = any[]; + +interface CallMessage { + type: MsgType.Call, + id: number, + method: string; + params: Vals; +} + +interface ResponseMessage { + type: MsgType.Response, + id: number, + result?: Vals; + error?: string; +} + +type Message = CallMessage | ResponseMessage; + +interface CallResult { + result: Vals; + error: Error | null; +} + +abstract class IPCCommon { + protected localApi: any; + protected socketPath: string; + protected conn: net.Socket | null = null; + protected nextId: number = 0; + protected pendingCalls: Record void> = {}; + protected errors: QueuedEvent; + + protected constructor(localApi: any, socketPath: string) { + this.localApi = localApi; + this.socketPath = socketPath; + this.errors = new QueuedEvent(); + } + + protected readConn(): void { + if (!this.conn) throw new Error('no connection'); + + const rl = readline.createInterface({ + input: this.conn, + crlfDelay: Infinity, + }); + + rl.on('line', (line) => { + try { + const msg: Message = JSON.parse(line); + this.processMsg(msg); + } catch (e) { + this.raiseErr(new Error(`${e}`)); + } + }); + + rl.on('error', (e) => { + this.raiseErr(e); + }); + + rl.on('close', () => { + this.raiseErr(new Error('connection closed')); + }); + } + + protected processMsg(msg: Message): void { + switch (msg.type) { + case MsgType.Call: + this.handleCall(msg); + break; + case MsgType.Response: + this.handleResponse(msg); + break; + } + } + + protected handleCall(msg: CallMessage): void { + if (!this.localApi) { + this.sendMsg({type: MsgType.Response, id: msg.id, error: 'remote side does not accept ipc calls'}); + return; + } + + const method = this.localApi[msg.method]; + if (!method || typeof method !== 'function') { + this.sendMsg({type: MsgType.Response, id: msg.id, error: `method not found: ${msg.method}`}); + return; + } + + const argsCount = method.length; + if (msg.params.length !== argsCount) { + this.sendMsg({ + type: MsgType.Response, + id: msg.id, + error: `argument count mismatch: expected ${argsCount}, got ${msg.params.length}` + }); + return; + } + + try { + const result = method.apply(this.localApi, msg.params); + + if (result instanceof Promise) { + result + .then((res) => { + this.sendMsg({type: MsgType.Response, id: msg.id, result: [res]}); + }) + .catch((err) => { + this.sendMsg({type: MsgType.Response, id: msg.id, error: `${err}`}); + }); + } else { + this.sendMsg({type: MsgType.Response, id: msg.id, result: [result]}); + } + } catch (err) { + this.sendMsg({type: MsgType.Response, id: msg.id, error: `${err}`}); + } + } + + protected sendMsg(msg: Message): void { + if (!this.conn) throw new Error('no connection'); + + try { + const data = JSON.stringify(msg) + '\n'; + this.conn.write(data); + } catch (e) { + this.raiseErr(new Error(`send response for ${msg.id}: ${e}`)); + } + } + + protected handleResponse(msg: ResponseMessage): void { + const callback = this.pendingCalls[msg.id]; + if (!callback) { + this.raiseErr(new Error(`received response for unknown msgId: ${msg.id}`)); + return; + } + + delete this.pendingCalls[msg.id]; + + const err = msg.error ? new Error(`remote error: ${msg.error}`) : null; + callback({ result: msg.result || [], error: err }); + } + + protected raiseErr(err: Error): void { + this.errors.post(err); + } + + call(method: string, ...params: Vals): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + + this.pendingCalls[id] = (result: CallResult) => { + if (result.error) { + reject(result.error); + } else { + resolve(result.result); + } + }; + try { + this.sendMsg({ + type: MsgType.Call, + id, + method, + params, + }); + } catch (e) { + delete this.pendingCalls[id]; + reject(new Error(`send call: ${e}`)); + } + }); + } +} + + +class ParentIPC extends IPCCommon { + private readonly cmdPath: string; + private readonly cmdArgs: string[]; + private cmd: ChildProcess | null = null; + private readonly listener: net.Server; + + constructor(cmdPath: string, cmdArgs: string[], localApi: any) { + const socketPath = path.join(os.tmpdir(), `kitten-ipc-${process.pid}.sock`); + super(localApi, socketPath); + + this.cmdPath = cmdPath; + if (cmdArgs.includes(IPC_SOCKET_ARG)) { + throw new Error(`you should not use '${IPC_SOCKET_ARG}' argument in your command`); + } + this.cmdArgs = cmdArgs; + + this.listener = net.createServer(); + } + + async start(): Promise { + try { + fs.unlinkSync(this.socketPath); + } catch {} + + + await new Promise((resolve, reject) => { + this.listener.listen(this.socketPath, () => { + resolve(); + }); + this.listener.on('error', reject); + }); + + const cmdArgs = [...this.cmdArgs, IPC_SOCKET_ARG, this.socketPath]; + this.cmd = spawn(this.cmdPath, cmdArgs, {stdio: 'inherit'}); + + this.cmd.on('error', (err) => { + this.raiseErr(err); + }); + + this.acceptConn().catch(); + } + + private async acceptConn(): Promise { + const acceptTimeout = 10000; + + const acceptPromise = new Promise((resolve, reject) => { + this.listener.once('connection', (conn) => { + resolve(conn); + }); + this.listener.once('error', reject); + }); + + try { + this.conn = await timeout(acceptPromise, acceptTimeout); + this.readConn(); + } catch (e) { + if (this.cmd) this.cmd.kill(); + this.raiseErr(e as Error); + } + } + + async wait(): Promise { + return new Promise((resolve, reject) => { + if(!this.cmd) throw new Error('Command is not started yet'); + this.cmd.addListener('close', (code, signal) => { + if(signal || code) { + if(signal) reject(new Error(`Process exited with signal ${signal}`)); + else reject(new Error(`Process exited with code ${code}`)); + } else { + resolve(); + } + }); + this.errors.attach(err => { + reject(err); + }) + }) + } +} + + +export class ChildIPC extends IPCCommon { + constructor(localApi: any) { + super(localApi, socketPathFromArgs()); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.conn = net.createConnection(this.socketPath, () => { + this.readConn(); + resolve(); + }); + this.conn.on('error', reject); + }); + } + + async wait(): Promise { + return new Promise((resolve, reject) => { + + }); + } +} + + +function socketPathFromArgs(): string { + const {values} = util.parseArgs({options: { + IPC_SOCKET_ARG: { + type: 'string', + } + }}); + + if(!values.IPC_SOCKET_ARG) { + throw new Error('ipc socket path is missing'); + } + + return values.IPC_SOCKET_ARG; +} + + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + + +// throws on timeout +function timeout(prom: Promise, ms: number): Promise { + return Promise.race( + [prom, new Promise((res, reject) => setTimeout(reject, ms))] + ) as Promise; +} diff --git a/lib/ts/package-lock.json b/lib/ts/package-lock.json new file mode 100644 index 0000000..7110d2b --- /dev/null +++ b/lib/ts/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "kitten-ipc", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kitten-ipc", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@types/node": "^22.10.5", + "ts-events": "^3.4.1" + } + }, + "node_modules/@types/node": { + "version": "22.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", + "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ts-events": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ts-events/-/ts-events-3.4.1.tgz", + "integrity": "sha512-px05Slmyh6Bnfi7ma0YIU6cYXnisi+iL/2lhClu+s0ZkTdfPosiGp0H8aoQW7ASSXgcXYXAqujD0CcKYr5YlAw==", + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + } + } +} diff --git a/lib/ts/package.json b/lib/ts/package.json index 21a6081..dea02a2 100644 --- a/lib/ts/package.json +++ b/lib/ts/package.json @@ -1,12 +1,16 @@ { "name": "kitten-ipc", "version": "1.0.0", - "main": "index.js", + "main": "lib.ts", + "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "build": "tsc" + "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", - "description": "" + "description": "", + "dependencies": { + "@types/node": "^22.10.5", + "ts-events": "^3.4.1" + } } diff --git a/lib/ts/tsconfig.json b/lib/ts/tsconfig.json index cec4a3a..02a27bd 100644 --- a/lib/ts/tsconfig.json +++ b/lib/ts/tsconfig.json @@ -9,7 +9,7 @@ // See also https://aka.ms/tsconfig/module "module": "nodenext", "target": "esnext", - "types": [], + "types": ["node"], // For nodejs: // "lib": ["esnext"], // "types": ["node"], @@ -40,5 +40,7 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, + + "allowImportingTsExtensions": true } }