Fix: Modified files so the client works with TS fine

This commit is contained in:
2025-10-10 10:58:49 -05:00
parent 0771466433
commit 4001d5620f
15 changed files with 1825 additions and 1889 deletions

View File

@@ -1,15 +1,22 @@
/**
* Cache system similar to Python's CacheDict from Tryton
* Implements LRU (Least Recently Used) cache using JavaScript Map
* TypeScript version
*/
class CacheDict extends Map {
export type CacheFactory<T> = () => T;
export class CacheDict<K = any, V = any> extends Map<K, V> {
private cacheLen: number;
private defaultFactory: CacheFactory<V> | null;
/**
* Create a new CacheDict
* @param {number} cacheLen - Maximum number of items to cache
* @param {Function} defaultFactory - Factory function for missing keys
*/
constructor(cacheLen = 10, defaultFactory = null) {
constructor(
cacheLen: number = 10,
defaultFactory: CacheFactory<V> | null = null
) {
super();
this.cacheLen = cacheLen;
this.defaultFactory = defaultFactory;
@@ -17,11 +24,8 @@ class CacheDict extends Map {
/**
* Set a key-value pair and maintain LRU order
* @param {*} key - The key
* @param {*} value - The value
* @returns {CacheDict} - This instance for chaining
*/
set(key, value) {
override set(key: K, value: V): this {
// If key exists, delete it first to move to end
if (this.has(key)) {
this.delete(key);
@@ -32,7 +36,9 @@ class CacheDict extends Map {
// Remove oldest entries if cache is full
while (this.size > this.cacheLen) {
const firstKey = this.keys().next().value;
this.delete(firstKey);
if (firstKey !== undefined) {
this.delete(firstKey);
}
}
return this;
@@ -40,12 +46,10 @@ class CacheDict extends Map {
/**
* Get a value and move it to end (most recently used)
* @param {*} key - The key to retrieve
* @returns {*} - The value
*/
get(key) {
override get(key: K): V | undefined {
if (this.has(key)) {
const value = super.get(key);
const value = super.get(key)!;
// Move to end by re-setting
this.delete(key);
super.set(key, value);
@@ -64,14 +68,12 @@ class CacheDict extends Map {
/**
* Override has() to update LRU order on access
* @param {*} key - The key to check
* @returns {boolean} - Whether the key exists
*/
has(key) {
override has(key: K): boolean {
const exists = super.has(key);
if (exists) {
// Move to end on access
const value = super.get(key);
const value = super.get(key)!;
this.delete(key);
super.set(key, value);
}
@@ -80,54 +82,64 @@ class CacheDict extends Map {
/**
* Get current cache size
* @returns {number} - Number of items in cache
*/
get length() {
get length(): number {
return this.size;
}
/**
* Clear all items from cache
*/
clear() {
override clear(): void {
super.clear();
}
/**
* Convert cache to array for debugging
* @returns {Array} - Array of [key, value] pairs
*/
toArray() {
toArray(): Array<[K, V]> {
return Array.from(this.entries());
}
}
export interface CacheEntry<T = any> {
expire: Date;
value: T;
}
export interface CacheStats {
totalPrefixes: number;
totalEntries: number;
prefixes: Record<string, number>;
}
/**
* Advanced cache for Tryton RPC with expiration support
*/
class TrytonCache {
constructor(cacheLen = 1024) {
this.store = new CacheDict(cacheLen, () => new CacheDict(cacheLen));
export class TrytonCache {
private store: CacheDict<string, CacheDict<string, CacheEntry>>;
private cacheLen: number;
constructor(cacheLen: number = 1024) {
this.cacheLen = cacheLen;
this.store = new CacheDict<string, CacheDict<string, CacheEntry>>(
cacheLen,
() => new CacheDict<string, CacheEntry>(cacheLen)
);
}
/**
* Check if a prefix is cached
* @param {string} prefix - Method prefix
* @returns {boolean} - Whether prefix exists
*/
cached(prefix) {
cached(prefix: string): boolean {
return this.store.has(prefix);
}
/**
* Set cache entry with expiration
* @param {string} prefix - Method prefix
* @param {string} key - Cache key
* @param {number|Date} expire - Expiration time
* @param {*} value - Value to cache
*/
set(prefix, key, expire, value) {
let expiration;
set(prefix: string, key: string, expire: number | Date, value: any): void {
let expiration: Date;
if (typeof expire === "number") {
// Assume seconds, convert to Date
@@ -141,7 +153,7 @@ class TrytonCache {
// Deep copy value to avoid mutations
const cachedValue = this._deepCopy(value);
this.store.get(prefix).set(key, {
this.store.get(prefix)!.set(key, {
expire: expiration,
value: cachedValue,
});
@@ -149,24 +161,20 @@ class TrytonCache {
/**
* Get cached value if not expired
* @param {string} prefix - Method prefix
* @param {string} key - Cache key
* @returns {*} - Cached value
* @throws {Error} - If key not found or expired
*/
get(prefix, key) {
get(prefix: string, key: string): any {
const now = new Date();
if (!this.store.has(prefix)) {
throw new Error("Key not found");
}
const prefixCache = this.store.get(prefix);
const prefixCache = this.store.get(prefix)!;
if (!prefixCache.has(key)) {
throw new Error("Key not found");
}
const entry = prefixCache.get(key);
const entry = prefixCache.get(key)!;
if (entry.expire < now) {
prefixCache.delete(key);
@@ -179,12 +187,11 @@ class TrytonCache {
/**
* Clear cache for a specific prefix or all
* @param {string} [prefix] - Optional prefix to clear
*/
clear(prefix = null) {
clear(prefix?: string): void {
if (prefix) {
if (this.store.has(prefix)) {
this.store.get(prefix).clear();
this.store.get(prefix)!.clear();
}
} else {
this.store.clear();
@@ -193,35 +200,33 @@ class TrytonCache {
/**
* Deep copy objects to prevent mutations
* @param {*} obj - Object to copy
* @returns {*} - Deep copied object
* @private
*/
_deepCopy(obj) {
private _deepCopy<T>(obj: T): T {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime());
return new Date(obj.getTime()) as T;
}
if (obj instanceof Array) {
return obj.map((item) => this._deepCopy(item));
return obj.map((item) => this._deepCopy(item)) as T;
}
if (obj instanceof Buffer) {
return Buffer.from(obj);
// Handle Buffer in Node.js environment
if (typeof Buffer !== "undefined" && obj instanceof Buffer) {
return Buffer.from(obj) as T;
}
if (typeof obj === "object") {
const copy = {};
const copy: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
copy[key] = this._deepCopy(obj[key]);
if (Object.prototype.hasOwnProperty.call(obj, key)) {
copy[key] = this._deepCopy((obj as any)[key]);
}
}
return copy;
return copy as T;
}
return obj;
@@ -229,10 +234,9 @@ class TrytonCache {
/**
* Get cache statistics
* @returns {Object} - Cache statistics
*/
getStats() {
const stats = {
getStats(): CacheStats {
const stats: CacheStats = {
totalPrefixes: this.store.size,
totalEntries: 0,
prefixes: {},
@@ -246,9 +250,48 @@ class TrytonCache {
return stats;
}
}
module.exports = {
CacheDict,
TrytonCache,
};
/**
* Remove expired entries
*/
cleanupExpired(): number {
const now = new Date();
let removedCount = 0;
for (const [prefix, prefixCache] of this.store.entries()) {
const keysToRemove: string[] = [];
for (const [key, entry] of prefixCache.entries()) {
if (entry.expire < now) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => {
prefixCache.delete(key);
removedCount++;
});
}
return removedCount;
}
/**
* Get cache size in bytes (approximate)
*/
getSizeEstimate(): number {
let size = 0;
for (const [prefix, prefixCache] of this.store.entries()) {
size += prefix.length * 2; // UTF-16 encoding
for (const [key, entry] of prefixCache.entries()) {
size += key.length * 2;
size += JSON.stringify(entry.value).length * 2;
size += 24; // Date object overhead
}
}
return size;
}
}

View File

@@ -1,34 +1,62 @@
/**
* Main Tryton RPC Client
* JavaScript implementation of sabatron-tryton-rpc-client
* Tryton RPC Client for Node.js
* TypeScript implementation of sabatron-tryton-rpc-client
*/
const { ServerProxy, ServerPool } = require("./jsonrpc");
import { ServerProxy, ServerPool } from "./jsonrpc";
import { ServerProxyOptions, ServerPoolOptions } from "../types";
// Constants
const CONNECT_TIMEOUT = 5000; // 5 seconds
const DEFAULT_TIMEOUT = 30000; // 30 seconds
import {
TrytonClientConfig,
TrytonClientOptions,
SearchDomain,
TrytonContext,
TrytonRecord,
TrytonUser,
UserPreferences,
LoginResult,
RecordId,
RecordIds,
FieldName,
ModelName,
ClientConfig,
TrytonMethodCall,
TypedModelOperations,
} from "../types";
/**
* Main client class for connecting to Tryton server via RPC
*/
class TrytonClient {
export class TrytonClient {
private hostname: string;
private database: string;
private username: string;
private password: string;
private port: number;
private language: string;
private options: TrytonClientOptions;
private useHttps: boolean;
private connection: ServerPool | null;
private session: string | null;
/**
* Create a new Tryton client
* @param {Object} config - Configuration object
* @param {string} config.hostname - Server hostname
* @param {string} config.database - Database name
* @param {string} config.username - Username
* @param {string} config.password - Password
* @param {number} [config.port=8000] - Server port
* @param {string} [config.language='en'] - Language code
* @param {Object} [config.options={}] - Additional options
*/
constructor({
hostname,
database,
username,
password,
port = 8000,
language = "en",
options = {},
}) {
constructor(config: TrytonClientConfig) {
const {
hostname,
database,
username,
password,
port = 8000,
language = "en",
options = {},
} = config;
// Extract protocol from hostname if present
if (hostname.startsWith("https://")) {
this.hostname = hostname.replace("https://", "");
@@ -53,22 +81,15 @@ class TrytonClient {
/**
* Alternative constructor for backward compatibility
* @param {string} hostname - Server hostname
* @param {string} database - Database name
* @param {string} username - Username
* @param {string} password - Password
* @param {number} [port=8000] - Server port
* @param {string} [language='en'] - Language code
* @returns {TrytonClient} - New client instance
*/
static create(
hostname,
database,
username,
password,
port = 8000,
language = "en"
) {
hostname: string,
database: string,
username: string,
password: string,
port: number = 8000,
language: string = "en"
): TrytonClient {
return new TrytonClient({
hostname,
database,
@@ -84,17 +105,17 @@ class TrytonClient {
* @returns {Promise<boolean>} - True if connection successful
* @throws {Error} - If connection or authentication fails
*/
async connect() {
async connect(): Promise<boolean> {
try {
// Create proxy for login
const proxy = new ServerProxy(
this.hostname,
this.port,
this.database,
{
verbose: this.options.verbose || false,
connectTimeout: this.options.connectTimeout,
timeout: this.options.timeout,
connectTimeout:
this.options.connectTimeout || CONNECT_TIMEOUT,
timeout: this.options.timeout || DEFAULT_TIMEOUT,
useHttps: this.useHttps,
}
);
@@ -104,16 +125,14 @@ class TrytonClient {
password: this.password,
};
const result = await proxy.request("common.db.login", [
const result = await proxy.request<LoginResult>("common.db.login", [
this.username,
parameters,
this.language,
]);
// Close temporary proxy
proxy.close();
// Create session string
this.session = [this.username, ...result].join(":");
// Create connection pool with session
@@ -125,8 +144,9 @@ class TrytonClient {
session: this.session,
cache: this.options.cache !== false ? [] : null, // Enable cache by default
verbose: this.options.verbose || false,
connectTimeout: this.options.connectTimeout,
timeout: this.options.timeout,
connectTimeout:
this.options.connectTimeout || CONNECT_TIMEOUT,
timeout: this.options.timeout || DEFAULT_TIMEOUT,
keepMax: this.options.keepMax || 4,
useHttps: this.useHttps,
}
@@ -134,7 +154,7 @@ class TrytonClient {
return true;
} catch (error) {
throw new Error(`Connection failed: ${error.message}`);
throw new Error(`Connection failed: ${(error as Error).message}`);
}
}
@@ -145,13 +165,13 @@ class TrytonClient {
* @returns {Promise<*>} - Method result
* @throws {Error} - If not connected or method call fails
*/
async call(methodName, args = []) {
async call<T = any>(methodName: string, args: any[] = []): Promise<T> {
if (!this.connection) {
throw new Error("Not connected. Call connect() first.");
}
return this.connection.withConnection(async (conn) => {
return conn.request(methodName, args);
return conn.request<T>(methodName, args);
});
}
@@ -160,8 +180,8 @@ class TrytonClient {
* @param {Array<{method: string, args: Array}>} calls - Array of method calls
* @returns {Promise<Array>} - Array of results
*/
async callMultiple(calls) {
const results = [];
async callMultiple(calls: TrytonMethodCall[]): Promise<any[]> {
const results: any[] = [];
for (const call of calls) {
const result = await this.call(call.method, call.args);
results.push(result);
@@ -174,7 +194,7 @@ class TrytonClient {
* @param {Array<{method: string, args: Array}>} calls - Array of method calls
* @returns {Promise<Array>} - Array of results
*/
async callParallel(calls) {
async callParallel(calls: TrytonMethodCall[]): Promise<any[]> {
const promises = calls.map((call) => this.call(call.method, call.args));
return Promise.all(promises);
}
@@ -187,8 +207,13 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<Array>} - Array of records
*/
async read(model, ids, fields, context = {}) {
return this.call(`model.${model}.read`, [ids, fields, context]);
async read<T extends TrytonRecord = TrytonRecord>(
model: ModelName,
ids: RecordIds,
fields: FieldName[],
context: TrytonContext = {}
): Promise<T[]> {
return this.call<T[]>(`model.${model}.read`, [ids, fields, context]);
}
/**
@@ -198,8 +223,15 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<Array<number>>} - Array of created record IDs
*/
async create(model, records, context = {}) {
return this.call(`model.${model}.create`, [records, context]);
async create(
model: ModelName,
records: Record<string, any>[],
context: TrytonContext = {}
): Promise<RecordIds> {
return this.call<RecordIds>(`model.${model}.create`, [
records,
context,
]);
}
/**
@@ -210,8 +242,13 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<void>}
*/
async write(model, ids, values, context = {}) {
return this.call(`model.${model}.write`, [ids, values, context]);
async write(
model: ModelName,
ids: RecordIds,
values: Record<string, any>,
context: TrytonContext = {}
): Promise<void> {
return this.call<void>(`model.${model}.write`, [ids, values, context]);
}
/**
@@ -221,8 +258,12 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<void>}
*/
async delete(model, ids, context = {}) {
return this.call(`model.${model}.delete`, [ids, context]);
async delete(
model: ModelName,
ids: RecordIds,
context: TrytonContext = {}
): Promise<void> {
return this.call<void>(`model.${model}.delete`, [ids, context]);
}
/**
@@ -236,14 +277,14 @@ class TrytonClient {
* @returns {Promise<Array<number>>} - Array of record IDs
*/
async search(
model,
domain,
offset = 0,
limit = null,
order = null,
context = {}
) {
return this.call(`model.${model}.search`, [
model: ModelName,
domain: SearchDomain,
offset: number = 0,
limit: number | null = null,
order: string[] | null = null,
context: TrytonContext = {}
): Promise<RecordIds> {
return this.call<RecordIds>(`model.${model}.search`, [
domain,
offset,
limit,
@@ -263,16 +304,16 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<Array>} - Array of records
*/
async searchRead(
model,
domain,
fields,
offset = 0,
limit = null,
order = null,
context = {}
) {
return this.call(`model.${model}.search_read`, [
async searchRead<T extends TrytonRecord = TrytonRecord>(
model: ModelName,
domain: SearchDomain,
fields: FieldName[],
offset: number = 0,
limit: number | null = null,
order: string[] | null = null,
context: TrytonContext = {}
): Promise<T[]> {
return this.call<T[]>(`model.${model}.search_read`, [
domain,
offset,
limit,
@@ -289,15 +330,22 @@ class TrytonClient {
* @param {Object} [context={}] - Context dictionary
* @returns {Promise<number>} - Number of records
*/
async searchCount(model, domain, context = {}) {
return this.call(`model.${model}.search_count`, [domain, context]);
async searchCount(
model: ModelName,
domain: SearchDomain,
context: TrytonContext = {}
): Promise<number> {
return this.call<number>(`model.${model}.search_count`, [
domain,
context,
]);
}
/**
* Get database information
* @returns {Promise<Object>} - Database info
*/
async getDatabaseInfo() {
async getDatabaseInfo(): Promise<any> {
return this.call("common.db.get_info", []);
}
@@ -305,23 +353,49 @@ class TrytonClient {
* List available databases
* @returns {Promise<Array<string>>} - Database names
*/
async listDatabases() {
return this.call("common.db.list", []);
async listDatabases(): Promise<string[]> {
return this.call<string[]>("common.db.list", []);
}
/**
* Get server version
* @returns {Promise<string>} - Server version
*/
async getVersion() {
return this.call("common.version", []);
async getVersion(): Promise<string> {
return this.call<string>("common.version", []);
}
/**
* Get user preferences
*/
async getUserPreferences(): Promise<UserPreferences> {
return this.call<UserPreferences>("model.res.user.get_preferences", [
true,
{},
]);
}
/**
* Get current user information
*/
async getCurrentUser(): Promise<TrytonUser> {
const preferences = await this.getUserPreferences();
const users = await this.read<TrytonUser>(
"res.user",
[preferences.id],
["id", "name", "login", "language", "company", "email"]
);
if (!users || users.length === 0) {
throw new Error("User not found");
}
return users[0]!;
}
/**
* Clear cache for specific prefix or all
* @param {string} [prefix] - Optional prefix to clear
*/
clearCache(prefix = null) {
clearCache(prefix?: string): void {
if (this.connection) {
this.connection.clearCache(prefix);
}
@@ -331,7 +405,7 @@ class TrytonClient {
* Get connection SSL status
* @returns {boolean|null} - SSL status or null if not connected
*/
get ssl() {
get ssl(): boolean | null {
return this.connection ? this.connection.ssl : null;
}
@@ -339,7 +413,7 @@ class TrytonClient {
* Get connection URL
* @returns {string|null} - Connection URL or null if not connected
*/
get url() {
get url(): string | null {
return this.connection ? this.connection.url : null;
}
@@ -347,7 +421,7 @@ class TrytonClient {
* Check if client is connected
* @returns {boolean} - True if connected
*/
get isConnected() {
get isConnected(): boolean {
return this.connection !== null;
}
@@ -355,14 +429,39 @@ class TrytonClient {
* Get current session
* @returns {string|null} - Session string or null if not connected
*/
getSession() {
getSession(): string | null {
return this.session;
}
/**
* Get current user ID from session
*/
getUserId(): number | null {
if (!this.session) return null;
const parts = this.session.split(":");
return parts.length >= 2 ? parseInt(parts[1], 10) : null;
}
/**
* Get session token from session
*/
getSessionToken(): string | null {
if (!this.session) return null;
const parts = this.session.split(":");
return parts.length >= 3 ? parts[2] : null;
}
/**
* Get username
*/
getUsername(): string {
return this.username;
}
/**
* Close connection and cleanup resources
*/
close() {
close(): void {
if (this.connection) {
this.connection.close();
this.connection = null;
@@ -374,7 +473,7 @@ class TrytonClient {
* Create a new client instance with the same configuration
* @returns {TrytonClient} - New client instance
*/
clone() {
clone(): TrytonClient {
return new TrytonClient({
hostname: this.hostname,
database: this.database,
@@ -390,7 +489,7 @@ class TrytonClient {
* Get client configuration (without sensitive data)
* @returns {Object} - Client configuration
*/
getConfig() {
getConfig(): ClientConfig {
return {
hostname: this.hostname,
database: this.database,
@@ -402,8 +501,77 @@ class TrytonClient {
url: this.url,
};
}
/**
* Type-safe model operations factory
* Creates a typed interface for specific models
*/
model<T extends TrytonRecord = TrytonRecord>(
modelName: ModelName
): TypedModelOperations<T> {
return {
read: (
ids: RecordIds,
fields: FieldName[],
context?: TrytonContext
) => this.read<T>(modelName, ids, fields, context),
create: (
records: Partial<Omit<T, "id">>[],
context?: TrytonContext
) =>
this.create(
modelName,
records as Record<string, any>[],
context
),
write: (
ids: RecordIds,
values: Partial<Omit<T, "id">>,
context?: TrytonContext
) =>
this.write(
modelName,
ids,
values as Record<string, any>,
context
),
delete: (ids: RecordIds, context?: TrytonContext) =>
this.delete(modelName, ids, context),
search: (
domain: SearchDomain,
offset?: number,
limit?: number,
order?: string[],
context?: TrytonContext
) => this.search(modelName, domain, offset, limit, order, context),
searchRead: (
domain: SearchDomain,
fields: FieldName[],
offset?: number,
limit?: number,
order?: string[],
context?: TrytonContext
) =>
this.searchRead<T>(
modelName,
domain,
fields,
offset,
limit,
order,
context
),
searchCount: (domain: SearchDomain, context?: TrytonContext) =>
this.searchCount(modelName, domain, context),
};
}
}
module.exports = {
TrytonClient,
};
// Export for CommonJS compatibility
export default TrytonClient;

View File

@@ -1,37 +0,0 @@
/**
* Main module exports
* Entry point for the Tryton RPC Client package
*/
const { TrytonClient } = require("./client");
const {
ServerProxy,
ServerPool,
ResponseError,
Fault,
ProtocolError,
TrytonJSONEncoder,
} = require("./jsonrpc");
const { CacheDict, TrytonCache } = require("./cache");
module.exports = {
// Main client class
TrytonClient,
// Low-level RPC classes
ServerProxy,
ServerPool,
// Error classes
ResponseError,
Fault,
ProtocolError,
// Utility classes
TrytonJSONEncoder,
CacheDict,
TrytonCache,
// Convenience export for backward compatibility
Client: TrytonClient,
};

170
src/index.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Tryton RPC Client for Node.js - TypeScript Entry Point
* Main exports for the Tryton RPC Client library
*/
// Import types and classes for internal use
import { TrytonClient } from "./client";
import type {
TrytonClientConfig,
TrytonClientOptions,
TrytonRecord,
TrytonUser,
TrytonError,
} from "../types";
// Main client class
export { TrytonClient } from "./client";
export { default as TrytonClientDefault } from "./client";
// RPC and transport classes (Node.js only)
export {
ServerProxy,
ServerPool,
Transport,
TrytonJSONEncoder,
ResponseError,
Fault,
ProtocolError,
} from "./jsonrpc";
// Cache classes
export {
TrytonCache,
CacheDict,
type CacheEntry,
type CacheStats,
type CacheFactory,
} from "./cache";
// All type definitions
export type {
// Configuration types
TrytonClientConfig,
TrytonClientOptions,
ServerProxyOptions,
ServerPoolOptions,
// Data types
TrytonRecord,
TrytonUser,
TrytonCompany,
TrytonParty,
// Search and domain types
SearchDomain,
DomainClause,
DomainOperator,
DomainLogicalOperator,
// RPC types
TrytonMethodCall,
TrytonBatchCall,
// Authentication types
LoginParameters,
LoginResult,
UserPreferences,
// Database types
DatabaseInfo,
// Error types
RpcError,
// Context types
TrytonContext,
// CRUD operation types
CreateOptions,
ReadOptions,
WriteOptions,
DeleteOptions,
SearchOptions,
SearchReadOptions,
// Client state types
ClientConfig,
// Utility types
CacheEntry as CacheEntryType,
ModelName,
FieldName,
RecordId,
RecordIds,
TypedModelOperations,
} from "../types";
// Re-export error class for convenience
export { TrytonError } from "../types";
// Version information
export const VERSION = "1.1.0";
// Default export is the main client
export default TrytonClient;
// Convenience factory functions
export function createClient(config: TrytonClientConfig): TrytonClient {
return new TrytonClient(config);
}
export function createTrytonClient(
hostname: string,
database: string,
username: string,
password: string,
port?: number,
language?: string
): TrytonClient {
return TrytonClient.create(
hostname,
database,
username,
password,
port,
language
);
}
// Type guard functions
export function isTrytonRecord(obj: any): obj is TrytonRecord {
return obj && typeof obj === "object" && typeof obj.id === "number";
}
export function isTrytonUser(obj: any): obj is TrytonUser {
return (
isTrytonRecord(obj) &&
typeof obj.login === "string" &&
typeof obj.name === "string"
);
}
export function isTrytonError(error: any): error is TrytonError {
return error instanceof Error && error.name === "TrytonError";
}
// Helper functions for React Context
export interface ReactTrytonContextConfig {
hostname?: string;
database?: string;
port?: number;
language?: string;
options?: TrytonClientOptions;
}
export function createReactTrytonConfig(
baseConfig: ReactTrytonContextConfig,
username: string,
password: string
): TrytonClientConfig {
return {
hostname: baseConfig.hostname || "localhost",
database: baseConfig.database || "tryton",
username,
password,
port: baseConfig.port || 8000,
language: baseConfig.language || "en",
options: baseConfig.options || {},
};
}

View File

@@ -1,13 +1,15 @@
/**
* JSON-RPC implementation for Tryton server communication
* Based on the Python implementation from sabatron-tryton-rpc-client
* TypeScript version - Node.js only
*/
const https = require("https");
const http = require("http");
const zlib = require("zlib");
const { URL } = require("url");
const { TrytonCache } = require("./cache");
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
@@ -16,29 +18,45 @@ const DEFAULT_TIMEOUT = 30000; // 30 seconds
/**
* Custom error classes
*/
class ResponseError extends Error {
constructor(message) {
export class ResponseError extends Error {
constructor(message: string) {
super(message);
this.name = "ResponseError";
}
}
class Fault extends Error {
constructor(faultCode, faultString = "", extra = {}) {
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);
}
toString() {
override toString(): string {
return String(this.faultCode);
}
}
class ProtocolError extends Error {
constructor(message, errcode = null, errmsg = null) {
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;
@@ -46,17 +64,66 @@ class ProtocolError extends Error {
}
}
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
*/
class TrytonJSONEncoder {
export class TrytonJSONEncoder {
/**
* Serialize JavaScript objects to JSON with Tryton type handling
* @param {*} obj - Object to serialize
* @returns {string} - JSON string
*/
static serialize(obj) {
return JSON.stringify(obj, (key, value) => {
static serialize(obj: any): string {
return JSON.stringify(obj, (key: string, value: any) => {
if (value instanceof Date) {
return {
__class__: "datetime",
@@ -67,14 +134,14 @@ class TrytonJSONEncoder {
minute: value.getMinutes(),
second: value.getSeconds(),
microsecond: value.getMilliseconds() * 1000,
};
} as TrytonDateTime;
}
if (value instanceof Buffer) {
if (typeof Buffer !== "undefined" && value instanceof Buffer) {
return {
__class__: "bytes",
base64: value.toString("base64"),
};
} as TrytonBytes;
}
// Handle BigInt as Decimal
@@ -82,7 +149,7 @@ class TrytonJSONEncoder {
return {
__class__: "Decimal",
decimal: value.toString(),
};
} as TrytonDecimal;
}
return value;
@@ -94,46 +161,67 @@ class TrytonJSONEncoder {
* @param {string} str - JSON string
* @returns {*} - Parsed object
*/
static deserialize(str) {
return JSON.parse(str, (key, value) => {
static deserialize(str: string): any {
return JSON.parse(str, (key: string, value: any) => {
if (value && typeof value === "object" && value.__class__) {
switch (value.__class__) {
case "datetime":
const specialValue = value as TrytonSpecialType;
switch (specialValue.__class__) {
case "datetime": {
const dt = specialValue as TrytonDateTime;
return new Date(
value.year,
value.month - 1,
value.day,
value.hour || 0,
value.minute || 0,
value.second || 0,
Math.floor((value.microsecond || 0) / 1000)
dt.year,
dt.month - 1,
dt.day,
dt.hour || 0,
dt.minute || 0,
dt.second || 0,
Math.floor((dt.microsecond || 0) / 1000)
);
}
case "date":
return new Date(value.year, value.month - 1, value.day);
case "date": {
const d = specialValue as TrytonDate;
return new Date(d.year, d.month - 1, d.day);
}
case "time":
case "time": {
const t = specialValue as TrytonTime;
const today = new Date();
return new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
value.hour || 0,
value.minute || 0,
value.second || 0,
Math.floor((value.microsecond || 0) / 1000)
t.hour || 0,
t.minute || 0,
t.second || 0,
Math.floor((t.microsecond || 0) / 1000)
);
}
case "timedelta":
case "timedelta": {
const td = specialValue as TrytonTimeDelta;
// Return seconds as number
return value.seconds || 0;
return td.seconds || 0;
}
case "bytes":
return Buffer.from(value.base64, "base64");
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":
case "Decimal": {
const dec = specialValue as TrytonDecimal;
// Convert to number or keep as string for precision
return parseFloat(value.decimal);
return parseFloat(dec.decimal);
}
default:
return value;
@@ -144,11 +232,41 @@ class TrytonJSONEncoder {
}
}
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
*/
class Transport {
constructor(options = {}) {
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;
@@ -166,7 +284,12 @@ class Transport {
* @param {boolean} verbose - Enable verbose logging
* @returns {Promise<Object>} - Response object
*/
async request(host, handler, requestData, verbose = false) {
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;
@@ -183,14 +306,17 @@ class Transport {
const url = new URL(`${protocol}://${host}${handler}`);
const isHttps = url.protocol === "https:";
const options = {
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": Buffer.byteLength(requestData),
"Content-Length":
typeof Buffer !== "undefined"
? Buffer.byteLength(requestData)
: new TextEncoder().encode(requestData).length,
Connection: "keep-alive",
"Accept-Encoding": "gzip, deflate",
},
@@ -201,25 +327,44 @@ class Transport {
// Add session authentication
if (this.session) {
const auth = Buffer.from(this.session).toString("base64");
options.headers["Authorization"] = `Session ${auth}`;
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) => {
return new Promise<JsonRpcResponse>((resolve, reject) => {
const client = isHttps ? https : http;
const req = client.request(options, (res) => {
let data = Buffer.alloc(0);
const req = client.request(options, (res: http.IncomingMessage) => {
let data =
typeof Buffer !== "undefined"
? Buffer.alloc(0)
: new Uint8Array(0);
res.on("data", (chunk) => {
data = Buffer.concat([data, chunk]);
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;
let responseText: string;
if (encoding === "gzip") {
responseText = zlib
@@ -237,12 +382,13 @@ class Transport {
console.log("Response:", responseText);
}
const response =
TrytonJSONEncoder.deserialize(responseText);
const response = TrytonJSONEncoder.deserialize(
responseText
) as JsonRpcResponse;
// Add cache header if present
const cacheHeader = res.headers["x-tryton-cache"];
if (cacheHeader) {
if (cacheHeader && typeof cacheHeader === "string") {
try {
response.cache = parseInt(cacheHeader);
} catch (e) {
@@ -254,14 +400,16 @@ class Transport {
} catch (error) {
reject(
new ResponseError(
`Failed to parse response: ${error.message}`
`Failed to parse response: ${
(error as Error).message
}`
)
);
}
});
});
req.on("error", (error) => {
req.on("error", (error: any) => {
if (error.code === "ECONNRESET" || error.code === "EPIPE") {
// Retry once on connection reset
reject(
@@ -290,7 +438,7 @@ class Transport {
/**
* Close transport connection
*/
close() {
close(): void {
if (this.connection) {
this.connection.destroy();
this.connection = null;
@@ -301,8 +449,24 @@ class Transport {
/**
* Server proxy for making RPC calls
*/
class ServerProxy {
constructor(host, port, database = "", options = {}) {
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;
@@ -310,7 +474,10 @@ class ServerProxy {
this.handler = database ? `/${encodeURIComponent(database)}/` : "/";
this.hostUrl = `${host}:${port}`;
this.requestId = 0;
this.cache = options.cache || null;
this.cache =
options.cache && !Array.isArray(options.cache)
? (options.cache as TrytonCache)
: null;
this.useHttps = options.useHttps || false;
this.transport = new Transport({
@@ -325,11 +492,8 @@ class ServerProxy {
/**
* Make RPC request with retry logic
* @param {string} methodName - RPC method name
* @param {Array} params - Method parameters
* @returns {Promise<*>} - Method result
*/
async request(methodName, params) {
async request<T = any>(methodName: string, params: any[]): Promise<T> {
this.requestId += 1;
const id = this.requestId;
@@ -337,7 +501,7 @@ class ServerProxy {
id: id,
method: methodName,
params: params,
});
} as JsonRpcRequest);
// Check cache first
if (this.cache && this.cache.cached(methodName)) {
@@ -348,7 +512,7 @@ class ServerProxy {
}
}
let lastError = null;
let lastError: Error | null = null;
// Retry logic (up to 5 attempts)
for (let attempt = 0; attempt < 5; attempt++) {
@@ -385,9 +549,9 @@ class ServerProxy {
);
}
return response.result;
return response.result as T;
} catch (error) {
lastError = error;
lastError = error as Error;
// Check if we should retry
if (error instanceof ProtocolError && error.errcode === 503) {
@@ -400,8 +564,8 @@ class ServerProxy {
// For connection errors, try once more
if (
attempt === 0 &&
(error.code === "ECONNRESET" ||
error.code === "EPIPE" ||
((error as any).code === "ECONNRESET" ||
(error as any).code === "EPIPE" ||
error instanceof ProtocolError)
) {
this.transport.close();
@@ -419,7 +583,7 @@ class ServerProxy {
/**
* Close server proxy
*/
close() {
close(): void {
this.transport.close();
}
@@ -427,7 +591,7 @@ class ServerProxy {
* Get SSL status
* @returns {boolean} - Whether connection uses SSL
*/
get ssl() {
get ssl(): boolean {
return this.port === 443 || this.hostUrl.startsWith("https");
}
@@ -435,7 +599,7 @@ class ServerProxy {
* Get full URL
* @returns {string} - Full server URL
*/
get url() {
get url(): string {
const scheme = this.ssl ? "https" : "http";
return `${scheme}://${this.hostUrl}${this.handler}`;
}
@@ -444,8 +608,23 @@ class ServerProxy {
/**
* Connection pool for reusing ServerProxy instances
*/
class ServerPool {
constructor(host, port, database, options = {}) {
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;
@@ -454,13 +633,16 @@ class ServerPool {
this.session = options.session || null;
this.pool = [];
this.used = new Set();
this.used = new Set<ServerProxy>();
this.cache = null;
// Initialize cache if requested
if (options.cache) {
this.cache = new TrytonCache();
this.options.cache = this.cache;
if (Array.isArray(options.cache)) {
this.cache = new TrytonCache();
} else {
this.cache = options.cache as TrytonCache;
}
}
}
@@ -468,15 +650,15 @@ class ServerPool {
* Get connection from pool or create new one
* @returns {ServerProxy} - Server proxy instance
*/
getConnection() {
let conn;
getConnection(): ServerProxy {
let conn: ServerProxy;
if (this.pool.length > 0) {
conn = this.pool.pop();
conn = this.pool.pop()!;
} else {
conn = new ServerProxy(this.host, this.port, this.database, {
...this.options,
cache: this.cache,
cache: this.cache as any,
});
}
@@ -488,14 +670,16 @@ class ServerPool {
* Return connection to pool
* @param {ServerProxy} conn - Connection to return
*/
putConnection(conn) {
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();
oldConn.close();
if (oldConn) {
oldConn.close();
}
}
}
@@ -504,7 +688,9 @@ class ServerPool {
* @param {Function} callback - Async function to execute
* @returns {Promise<*>} - Callback result
*/
async withConnection(callback) {
async withConnection<T>(
callback: (conn: ServerProxy) => Promise<T>
): Promise<T> {
const conn = this.getConnection();
try {
return await callback(conn);
@@ -516,7 +702,7 @@ class ServerPool {
/**
* Close all connections in pool
*/
close() {
close(): void {
// Close all pooled connections
for (const conn of this.pool) {
conn.close();
@@ -535,7 +721,7 @@ class ServerPool {
* Clear cache
* @param {string} [prefix] - Optional prefix to clear
*/
clearCache(prefix = null) {
clearCache(prefix?: string): void {
if (this.cache) {
this.cache.clear(prefix);
}
@@ -545,10 +731,10 @@ class ServerPool {
* Get SSL status from any connection
* @returns {boolean|null} - SSL status or null if no connections
*/
get ssl() {
get ssl(): boolean | null {
const allConns = [...this.pool, ...this.used];
if (allConns.length > 0) {
return allConns[0].ssl;
return allConns[0]?.ssl || null;
}
return null;
}
@@ -557,21 +743,11 @@ class ServerPool {
* Get URL from any connection
* @returns {string|null} - URL or null if no connections
*/
get url() {
get url(): string | null {
const allConns = [...this.pool, ...this.used];
if (allConns.length > 0) {
return allConns[0].url;
return allConns[0]?.url || null;
}
return null;
}
}
module.exports = {
ResponseError,
Fault,
ProtocolError,
TrytonJSONEncoder,
Transport,
ServerProxy,
ServerPool,
};