/** * JSON-RPC implementation for Tryton server communication * Based on the Python implementation from sabatron-tryton-rpc-client * TypeScript version - Node.js only */ import https from "https"; import http from "http"; import zlib from "zlib"; import { URL } from "url"; import { TrytonCache } from "./cache"; import type { ServerProxyOptions, ServerPoolOptions } from "./types"; // Constants const CONNECT_TIMEOUT = 5000; // 5 seconds const DEFAULT_TIMEOUT = 30000; // 30 seconds /** * Custom error classes */ export class ResponseError extends Error { constructor(message: string) { super(message); this.name = "ResponseError"; } } export class Fault extends Error { public readonly faultCode: string | number; public readonly faultString: string; public readonly extra: Record; constructor( faultCode: string | number, faultString: string = "", extra: Record = {} ) { super(faultString); this.name = "Fault"; this.faultCode = faultCode; this.faultString = faultString; this.extra = extra; Object.assign(this, extra); } override toString(): string { return String(this.faultCode); } } export class ProtocolError extends Error { public readonly errcode: string | number | null; public readonly errmsg: string | null; constructor( message: string, errcode: string | number | null = null, errmsg: string | null = null ) { super(message); this.name = "ProtocolError"; this.errcode = errcode; this.errmsg = errmsg; } } interface TrytonDateTime { __class__: "datetime"; year: number; month: number; day: number; hour?: number; minute?: number; second?: number; microsecond?: number; } interface TrytonDate { __class__: "date"; year: number; month: number; day: number; } interface TrytonTime { __class__: "time"; hour?: number; minute?: number; second?: number; microsecond?: number; } interface TrytonTimeDelta { __class__: "timedelta"; seconds?: number; } interface TrytonBytes { __class__: "bytes"; base64: string; } interface TrytonDecimal { __class__: "Decimal"; decimal: string; } type TrytonSpecialType = | TrytonDateTime | TrytonDate | TrytonTime | TrytonTimeDelta | TrytonBytes | TrytonDecimal; /** * JSON encoder/decoder for Tryton specific types */ export class TrytonJSONEncoder { /** * Serialize JavaScript objects to JSON with Tryton type handling * @param {*} obj - Object to serialize * @returns {string} - JSON string */ static serialize(obj: any): string { return JSON.stringify(obj, (key: string, value: any) => { if (value instanceof Date) { return { __class__: "datetime", year: value.getFullYear(), month: value.getMonth() + 1, day: value.getDate(), hour: value.getHours(), minute: value.getMinutes(), second: value.getSeconds(), microsecond: value.getMilliseconds() * 1000, } as TrytonDateTime; } if (typeof Buffer !== "undefined" && value instanceof Buffer) { return { __class__: "bytes", base64: value.toString("base64"), } as TrytonBytes; } // Handle BigInt as Decimal if (typeof value === "bigint") { return { __class__: "Decimal", decimal: value.toString(), } as TrytonDecimal; } return value; }); } /** * Deserialize JSON with Tryton type handling * @param {string} str - JSON string * @returns {*} - Parsed object */ static deserialize(str: string): any { return JSON.parse(str, (key: string, value: any) => { if (value && typeof value === "object" && value.__class__) { const specialValue = value as TrytonSpecialType; switch (specialValue.__class__) { case "datetime": { const dt = specialValue as TrytonDateTime; return new Date( dt.year, dt.month - 1, dt.day, dt.hour || 0, dt.minute || 0, dt.second || 0, Math.floor((dt.microsecond || 0) / 1000) ); } case "date": { const d = specialValue as TrytonDate; return new Date(d.year, d.month - 1, d.day); } case "time": { const t = specialValue as TrytonTime; const today = new Date(); return new Date( today.getFullYear(), today.getMonth(), today.getDate(), t.hour || 0, t.minute || 0, t.second || 0, Math.floor((t.microsecond || 0) / 1000) ); } case "timedelta": { const td = specialValue as TrytonTimeDelta; // Return seconds as number return td.seconds || 0; } case "bytes": { const b = specialValue as TrytonBytes; if (typeof Buffer !== "undefined") { return Buffer.from(b.base64, "base64"); } // Fallback for browser environment return new Uint8Array( atob(b.base64) .split("") .map((c) => c.charCodeAt(0)) ); } case "Decimal": { const dec = specialValue as TrytonDecimal; // Convert to number or keep as string for precision return parseFloat(dec.decimal); } default: return value; } } return value; }); } } interface TransportOptions { fingerprints?: string[] | null | undefined; caCerts?: string[] | null | undefined; session?: string | null | undefined; connectTimeout?: number | undefined; timeout?: number | undefined; useHttps?: boolean; } interface JsonRpcRequest { id: number; method: string; params: any[]; } interface JsonRpcResponse { id: number; result?: any; error?: [string | number, string]; cache?: number; } /** * HTTP Transport for JSON-RPC requests */ export class Transport { private fingerprints: string[] | null; private caCerts: string[] | null; private session: string | null; private connection: http.ClientRequest | null; private connectTimeout: number; private timeout: number; private useHttps: boolean; constructor(options: TransportOptions = {}) { this.fingerprints = options.fingerprints || null; this.caCerts = options.caCerts || null; this.session = options.session || null; this.connection = null; this.connectTimeout = options.connectTimeout || CONNECT_TIMEOUT; this.timeout = options.timeout || DEFAULT_TIMEOUT; this.useHttps = options.useHttps || false; } /** * Make HTTP request to server * @param {string} host - Server host * @param {string} handler - URL path * @param {string} requestData - JSON request data * @param {boolean} verbose - Enable verbose logging * @returns {Promise} - Response object */ async request( host: string, handler: string, requestData: string, verbose: boolean = false ): Promise { // Detect protocol based on port or explicit protocol const hostParts = host.split(":"); const port = hostParts[1] ? parseInt(hostParts[1]) : 80; const hostname = hostParts[0]; // Use HTTPS if explicitly configured, or for standard HTTPS ports const shouldUseHttps = this.useHttps || port === 443 || port === 8443 || host.startsWith("https://"); const protocol = shouldUseHttps ? "https" : "http"; const url = new URL(`${protocol}://${host}${handler}`); const isHttps = url.protocol === "https:"; const options: https.RequestOptions = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + url.search, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": typeof Buffer !== "undefined" ? Buffer.byteLength(requestData) : new TextEncoder().encode(requestData).length, Connection: "keep-alive", "Accept-Encoding": "gzip, deflate", }, timeout: this.connectTimeout, // Allow self-signed certificates for testing rejectUnauthorized: false, }; // Add session authentication if (this.session) { const auth = typeof Buffer !== "undefined" ? Buffer.from(this.session).toString("base64") : btoa(this.session); options.headers = { ...options.headers, Authorization: `Session ${auth}`, }; } return new Promise((resolve, reject) => { const client = isHttps ? https : http; const req = client.request(options, (res: http.IncomingMessage) => { let data = typeof Buffer !== "undefined" ? Buffer.alloc(0) : new Uint8Array(0); res.on("data", (chunk: any) => { if (typeof Buffer !== "undefined") { data = Buffer.concat([data as Buffer, chunk]); } else { // Browser fallback const newData = new Uint8Array( (data as Uint8Array).length + chunk.length ); newData.set(data as Uint8Array); newData.set(chunk, (data as Uint8Array).length); data = newData; } }); res.on("end", () => { try { // Handle compression const encoding = res.headers["content-encoding"]; let responseText: string; if (encoding === "gzip") { responseText = zlib .gunzipSync(data) .toString("utf-8"); } else if (encoding === "deflate") { responseText = zlib .inflateSync(data) .toString("utf-8"); } else { responseText = data.toString("utf-8"); } if (verbose) { console.log("Response:", responseText); } const response = TrytonJSONEncoder.deserialize( responseText ) as JsonRpcResponse; // Add cache header if present const cacheHeader = res.headers["x-tryton-cache"]; if (cacheHeader && typeof cacheHeader === "string") { try { response.cache = parseInt(cacheHeader); } catch (e) { // Ignore invalid cache header } } resolve(response); } catch (error) { reject( new ResponseError( `Failed to parse response: ${ (error as Error).message }` ) ); } }); }); req.on("error", (error: any) => { if (error.code === "ECONNRESET" || error.code === "EPIPE") { // Retry once on connection reset reject( new ProtocolError( "Connection reset", error.code, error.message ) ); } else { reject(error); } }); req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); req.setTimeout(this.timeout); req.write(requestData); req.end(); }); } /** * Close transport connection */ close(): void { if (this.connection) { this.connection.destroy(); this.connection = null; } } } /** * Server proxy for making RPC calls */ export class ServerProxy { private host: string; private port: number; private database: string; private verbose: boolean; private handler: string; private hostUrl: string; private requestId: number; private cache: TrytonCache | null; private useHttps: boolean; private transport: Transport; constructor( host: string, port: number, database: string = "", options: ServerProxyOptions = {} ) { this.host = host; this.port = port; this.database = database; this.verbose = options.verbose || false; this.handler = database ? `/${encodeURIComponent(database)}/` : "/"; this.hostUrl = `${host}:${port}`; this.requestId = 0; this.cache = options.cache && !Array.isArray(options.cache) ? (options.cache as TrytonCache) : null; this.useHttps = options.useHttps || false; this.transport = new Transport({ fingerprints: options.fingerprints, caCerts: options.caCerts, session: options.session, connectTimeout: options.connectTimeout, timeout: options.timeout, useHttps: this.useHttps, }); } /** * Make RPC request with retry logic */ async request(methodName: string, params: any[]): Promise { this.requestId += 1; const id = this.requestId; const requestData = TrytonJSONEncoder.serialize({ id: id, method: methodName, params: params, } as JsonRpcRequest); // Check cache first if (this.cache && this.cache.cached(methodName)) { try { return this.cache.get(methodName, requestData); } catch (error) { // Cache miss or expired, continue with request } } let lastError: Error | null = null; // Retry logic (up to 5 attempts) for (let attempt = 0; attempt < 5; attempt++) { try { const response = await this.transport.request( this.hostUrl, this.handler, requestData, this.verbose ); // Validate response if (response.id !== id) { throw new ResponseError( `Invalid response id (${response.id}) expected ${id}` ); } // Handle RPC errors if (response.error) { if (this.verbose) { console.error("RPC Error:", response); } throw new Fault(response.error[0], response.error[1] || ""); } // Cache successful response if (this.cache && response.cache) { this.cache.set( methodName, requestData, response.cache, response.result ); } return response.result as T; } catch (error) { lastError = error as Error; // Check if we should retry if (error instanceof ProtocolError && error.errcode === 503) { // Service unavailable, wait and retry const delay = Math.min(attempt + 1, 10) * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); continue; } // For connection errors, try once more if ( attempt === 0 && ((error as any).code === "ECONNRESET" || (error as any).code === "EPIPE" || error instanceof ProtocolError) ) { this.transport.close(); continue; } // Don't retry other errors break; } } throw lastError; } /** * Close server proxy */ close(): void { this.transport.close(); } /** * Get SSL status * @returns {boolean} - Whether connection uses SSL */ get ssl(): boolean { return this.port === 443 || this.hostUrl.startsWith("https"); } /** * Get full URL * @returns {string} - Full server URL */ get url(): string { const scheme = this.ssl ? "https" : "http"; return `${scheme}://${this.hostUrl}${this.handler}`; } } /** * Connection pool for reusing ServerProxy instances */ export class ServerPool { private host: string; private port: number; private database: string; private options: ServerPoolOptions; private keepMax: number; private session: string | null; private pool: ServerProxy[]; private used: Set; private cache: TrytonCache | null; constructor( host: string, port: number, database: string, options: ServerPoolOptions = {} ) { this.host = host; this.port = port; this.database = database; this.options = options; this.keepMax = options.keepMax || 4; this.session = options.session || null; this.pool = []; this.used = new Set(); this.cache = null; // Initialize cache if requested if (options.cache) { if (Array.isArray(options.cache)) { this.cache = new TrytonCache(); } else { this.cache = options.cache as TrytonCache; } } } /** * Get connection from pool or create new one * @returns {ServerProxy} - Server proxy instance */ getConnection(): ServerProxy { let conn: ServerProxy; if (this.pool.length > 0) { conn = this.pool.pop()!; } else { conn = new ServerProxy(this.host, this.port, this.database, { ...this.options, cache: this.cache as any, }); } this.used.add(conn); return conn; } /** * Return connection to pool * @param {ServerProxy} conn - Connection to return */ putConnection(conn: ServerProxy): void { this.used.delete(conn); this.pool.push(conn); // Remove excess connections while (this.pool.length > this.keepMax) { const oldConn = this.pool.shift(); if (oldConn) { oldConn.close(); } } } /** * Execute callback with a pooled connection * @param {Function} callback - Async function to execute * @returns {Promise<*>} - Callback result */ async withConnection( callback: (conn: ServerProxy) => Promise ): Promise { const conn = this.getConnection(); try { return await callback(conn); } finally { this.putConnection(conn); } } /** * Close all connections in pool */ close(): void { // Close all pooled connections for (const conn of this.pool) { conn.close(); } // Close all used connections for (const conn of this.used) { conn.close(); } this.pool = []; this.used.clear(); } /** * Clear cache * @param {string} [prefix] - Optional prefix to clear */ clearCache(prefix?: string): void { if (this.cache) { this.cache.clear(prefix); } } /** * Get SSL status from any connection * @returns {boolean|null} - SSL status or null if no connections */ get ssl(): boolean | null { const allConns = [...this.pool, ...this.used]; if (allConns.length > 0) { return allConns[0]?.ssl || null; } return null; } /** * Get URL from any connection * @returns {string|null} - URL or null if no connections */ get url(): string | null { const allConns = [...this.pool, ...this.used]; if (allConns.length > 0) { return allConns[0]?.url || null; } return null; } }