754 lines
22 KiB
TypeScript
754 lines
22 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
|
|
constructor(
|
|
faultCode: string | number,
|
|
faultString: string = "",
|
|
extra: Record<string, any> = {}
|
|
) {
|
|
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<Object>} - Response object
|
|
*/
|
|
async request(
|
|
host: string,
|
|
handler: string,
|
|
requestData: string,
|
|
verbose: boolean = false
|
|
): Promise<JsonRpcResponse> {
|
|
// 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<JsonRpcResponse>((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<T = any>(methodName: string, params: any[]): Promise<T> {
|
|
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<ServerProxy>;
|
|
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<ServerProxy>();
|
|
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<T>(
|
|
callback: (conn: ServerProxy) => Promise<T>
|
|
): Promise<T> {
|
|
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;
|
|
}
|
|
}
|