diff --git a/CHANGELOG.md b/CHANGELOG.md index d11eba1..8b53184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ -## tdl (unreleased) +## tdl@7.4.0 (unreleased) - Added `tdl.setLogMessageCallback` that allows to pass a callback to the `td_set_log_message_callback` TDLib function using Node-API's thread-safe functions. (TDLib v1.8.0+ only) +- `tdl.configure`: Added an experimental option `useNewTdjsonInterface` that + enables the use of `td_create_client_id`/`td_send`/`td_receive`/`td_execute` + interface with a client manager and global receive loop, though the old + interface still works well. + This does not use the libuv threadpool and does not have a limitation of max + `UV_THREADPOOL_SIZE` clients. + (TDLib v1.7.0+ only) +- `tdl.configure`: Added a `receiveTimeout` advanced option. +- `receiveTimeout` in the client options is deprecated. - Deprecated the `useMutableRename` advanced option. ## tdl-install-types@0.1.0 (2023-09-26) diff --git a/README.md b/README.md index 1c0a1d5..f65f313 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ TDLib version 1.5.0 or newer is required. - The tdjson shared library (`libtdjson.so` on Linux, `libtdjson.dylib` on macOS, `tdjson.dll` on Windows) - In some cases, a C++ compiler and Python installed to build the node addon[^1] -[^1]: `tdl` is packaged with pre-built addons for Windows (x86_64), GNU/Linux (x86_64, glibc >= 2.17), and macOS (x86_64, aarch64). If a pre-built binary is not available for your system, then the node addon will be built using node-gyp, requiring Python and a C++ toolchain to be installed (on Windows, MSVS or Build Tools). Pass `--build-from-source` to never use the pre-built binaries. Note that macOS aarch64 binaries aren't tested. +[^1]: `tdl` is packaged with pre-built addons for Windows (x86_64), GNU/Linux (x86_64, glibc >= 2.17), and macOS (x86_64, aarch64). If a pre-built binary is not available for your system, then the node addon will be built using node-gyp, requiring Python and a C++ toolchain (C++14 is required) to be installed (on Windows, MSVS or Build Tools). Pass `--build-from-source` to never use the pre-built binaries. Note that macOS aarch64 binaries aren't tested. ## Installation @@ -57,7 +57,7 @@ const tdl = require('tdl') // If libtdjson is not present in the system search paths, the path to the // libtdjson shared library can be set manually, e.g.: -// tdl.configure({ tdjson: '/usr/local/lib/libtdjson.dylib'}) +// tdl.configure({ tdjson: '/usr/local/lib/libtdjson.dylib' }) // The library prefix can be set separate from the library name, // example to search for libtdjson in the directory of the current script: // tdl.configure({ libdir: __dirname }) @@ -145,9 +145,11 @@ tdl.configure({ // 'libtdjson.dylib' on macOS, or 'libtdjson.so' otherwise. tdjson: 'libtdjson.so', // Path to the library directory. By default, it is empty string. - libdir: '...', + libdir: '/usr/local/lib', // Verbosity level of TDLib. By default, it is 2. - verbosityLevel: 3 + verbosityLevel: 3, + // Experimental option. Defaults to false. + useNewTdjsonInterface: false }) ``` @@ -158,8 +160,8 @@ Some examples: - `tdl.configure({ tdjson: require('prebuilt-tdlib').getTdjson() })` The path concatenation of `libdir` + `tdjson` is directly passed to -[`dlopen`][dlopen] (Unix) or [`LoadLibrary`][LoadLibraryW] (Windows). Check your OS documentation -to find out where the shared library will be searched for. +[`dlopen`][dlopen] (Unix) or [`LoadLibrary`][LoadLibraryW] (Windows). Check your +OS documentation to find out where the shared library will be searched for. #### `tdl.createClient(options: ClientOptions) => Client` @@ -185,9 +187,8 @@ type ClientOptions = { useTestDc: boolean, // Use test telegram server (defaults to false) tdlibParameters: Object, // Raw TDLib parameters // Advanced options: - skipOldUpdates: boolean, bare: boolean, - receiveTimeout: number + skipOldUpdates: boolean } ``` @@ -421,6 +422,9 @@ globally or per-project as a dev dependency. The current limitation is that the number of created clients should not exceed [UV_THREADPOOL_SIZE][] (as for now, the default is 4, max is 1024). +When `useNewTdjsonInterface` (experimental option) is set to true in +`tdl.configure`, this limitation does not apply. + [UV_THREADPOOL_SIZE]: http://docs.libuv.org/en/v1.x/threadpool.html diff --git a/package.json b/package.json index 0323d73..4b7600d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint . --max-warnings 0", "jest-tests": "jest --testPathIgnorePatterns tests/integration", "test": "npm run flow:check && npm run ts:check && npm run lint && npm run jest-tests", - "integration-tests": "jest tests/integration && cross-env TEST_TDL_TDLIB_ADDON=1 jest tests/integration", + "integration-tests": "jest tests/integration && cross-env TEST_TDL_TDLIB_ADDON=1 jest tests/integration && cross-env TEST_NEW_TDJSON=1 jest tests/integration", "test:all": "npm run test && npm run integration-tests", "coverage": "jest --coverage", "prepare": "npm run clean && npm run build", diff --git a/packages/tdl/addon/td.cpp b/packages/tdl/addon/td.cpp index a7b48e7..2549482 100644 --- a/packages/tdl/addon/td.cpp +++ b/packages/tdl/addon/td.cpp @@ -3,6 +3,11 @@ #include #include +#include +#include +#include +#include +#include #if defined(WIN32) || defined(_WIN32) || defined(__WIN32__) # include "win32-dlfcn.h" @@ -30,6 +35,12 @@ typedef void (*td_set_log_fatal_error_callback_t)(td_log_fatal_error_callback_pt typedef void (*td_log_message_callback_ptr)(int verbosity_level, const char *message); typedef void (*td_set_log_message_callback_t)(int max_verbosity_level, td_log_message_callback_ptr callback); +// New tdjson interface +typedef int (*td_create_client_id_t)(); +typedef void (*td_send_t)(int client_id, const char *request); +typedef const char * (*td_receive_t)(double timeout); +typedef const char * (*td_execute_t)(const char *request); + td_json_client_create_t td_json_client_create; td_json_client_send_t td_json_client_send; td_json_client_receive_t td_json_client_receive; @@ -37,6 +48,10 @@ td_json_client_execute_t td_json_client_execute; td_json_client_destroy_t td_json_client_destroy; td_set_log_fatal_error_callback_t td_set_log_fatal_error_callback; td_set_log_message_callback_t td_set_log_message_callback; +td_create_client_id_t td_create_client_id; +td_send_t td_send; +td_receive_t td_receive; +td_execute_t td_execute; #define FAIL(MSG) NAPI_THROW(Napi::Error::New(env, MSG)); #define TYPEFAIL(MSG) NAPI_THROW(Napi::TypeError::New(env, MSG)); @@ -50,13 +65,8 @@ Napi::External TdClientCreate(const Napi::CallbackInfo& info) { } void TdClientSend(const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); void *client = info[0].As>().Data(); - auto request_obj = info[1].As(); - auto json = env.Global().Get("JSON").As(); - auto stringify = json.Get("stringify").As(); - std::string request = - stringify.Call(json, { request_obj }).As().Utf8Value(); + std::string request = info[1].As().Utf8Value(); td_json_client_send(client, request.c_str()); } @@ -82,14 +92,8 @@ class ReceiverAsyncWorker : public Napi::AsyncWorker void OnOK() override { Napi::Env env = Env(); - if (response.empty()) { - deferred.Resolve(env.Null()); - } else { - auto json = env.Global().Get("JSON").As(); - auto parse = json.Get("parse").As(); - Napi::Value obj = parse.Call(json, { Napi::String::New(env, response) }); - deferred.Resolve(obj); - } + auto val = response.empty() ? env.Null() : Napi::String::New(env, response); + deferred.Resolve(val); } void OnError(const Napi::Error& err) override { @@ -117,17 +121,12 @@ Napi::Value TdClientExecute(const Napi::CallbackInfo& info) { void *client = info[0].IsNull() || info[0].IsUndefined() ? nullptr : info[0].As>().Data(); - auto json = env.Global().Get("JSON").As(); - auto stringify = json.Get("stringify").As(); - if (!info[1].IsObject()) - TYPEFAIL_VALUE("Expected second argument to be an object"); - Napi::Object request_obj = info[1].As(); - std::string request = - stringify.Call(json, { request_obj }).As().Utf8Value(); + if (!info[1].IsString()) + TYPEFAIL_VALUE("Expected second argument to be a string"); + std::string request = info[1].As().Utf8Value(); const char *response = td_json_client_execute(client, request.c_str()); if (response == nullptr) return env.Null(); - auto parse = json.Get("parse").As(); - return parse.Call(json, { Napi::String::New(env, response) }); + return Napi::String::New(env, response); } void TdClientDestroy(const Napi::CallbackInfo& info) { @@ -142,7 +141,7 @@ namespace MessageCallback { std::string message; }; - void CallJs(Napi::Env env, Napi::Function callback, Context *context, DataType *data) { + void CallJs(Napi::Env env, Napi::Function callback, Context *, DataType *data) { if (data == nullptr) return; if (env != nullptr && callback != nullptr) { // NOTE: Without --force-node-api-uncaught-exceptions-policy=true, this will @@ -177,11 +176,11 @@ void TdSetLogMessageCallback(const Napi::CallbackInfo& info) { using namespace MessageCallback; Napi::Env env = info.Env(); if (td_set_log_message_callback == nullptr) - FAIL("td_set_log_message_callback is not available") + FAIL("td_set_log_message_callback is not available"); if (info.Length() < 2) - TYPEFAIL("Expected two arguments") + TYPEFAIL("Expected two arguments"); if (!info[0].IsNumber()) - TYPEFAIL("Expected first argument to be a number") + TYPEFAIL("Expected first argument to be a number"); int max_verbosity_level = info[0].As().Int32Value(); if (info[1].IsNull() || info[1].IsUndefined()) { td_set_log_message_callback(max_verbosity_level, nullptr); @@ -193,11 +192,11 @@ void TdSetLogMessageCallback(const Napi::CallbackInfo& info) { return; } if (!info[1].IsFunction()) - TYPEFAIL("Expected second argument to be one of: a function, null, undefined") + TYPEFAIL("Expected second argument to be one of: a function, null, undefined"); std::lock_guard lock(tsfn_mutex); if (tsfn != nullptr) tsfn.Release(); - tsfn = Tsfn::New(env, info[1].As(), "Callback", 0, 1); + tsfn = Tsfn::New(env, info[1].As(), "CallbackTSFN", 0, 1); tsfn.Unref(env); td_set_log_message_callback(max_verbosity_level, &c_message_callback); } @@ -231,15 +230,151 @@ void TdSetLogFatalErrorCallback(const Napi::CallbackInfo& info) { return; } if (!info[0].IsFunction()) - TYPEFAIL("Expected first argument to be one of: a function, null, undefined") + TYPEFAIL("Expected first argument to be one of: a function, null, undefined"); if (fatal_callback_tsfn != nullptr) fatal_callback_tsfn.Release(); fatal_callback_tsfn = Napi::ThreadSafeFunction::New( - env, info[0].As(), "Callback", 0, 1); + env, info[0].As(), "FatalCallbackTSFN", 0, 1); fatal_callback_tsfn.Unref(env); td_set_log_fatal_error_callback(&c_fatal_callback); } +// New tdjson interface, experimental +namespace Tdn { + class ReceiveWorker { + public: + ReceiveWorker(const Napi::Env& env, double timeout) + : timeout(timeout), + tsfn(Tsfn::New(env, "ReceiveTSFN", 0, 1, this)), + thread(&ReceiveWorker::loop, this) + { thread.detach(); tsfn.Unref(env); } + ~ReceiveWorker() { + { + std::lock_guard lock(mutex); + stop = true; + tsfn.Release(); + } + cv.notify_all(); + if (thread.joinable()) + thread.join(); + } + + Napi::Promise NewTask(const Napi::Env& env) { + // A task can be added only after the previous task is finished. + auto new_deferred = std::make_unique(env); + auto promise = new_deferred->Promise(); + if (working) { + auto error = Napi::Error::New(env, "receive is not finished yet"); + new_deferred->Reject(error.Value()); + return promise; + } + { + std::lock_guard lock(mutex); + tsfn.Ref(env); + deferred = std::move(new_deferred); + } + cv.notify_all(); + return promise; + } + private: + using TsfnCtx = ReceiveWorker; + struct TsfnData { + std::unique_ptr deferred; + const char *response; // can be nullptr + }; + static void CallJs(Napi::Env env, Napi::Function, TsfnCtx *ctx, TsfnData *data) { + if (data == nullptr) return; + if (env != nullptr) { + auto val = data->response == nullptr + ? env.Null() + : Napi::String::New(env, data->response); + data->deferred->Resolve(val); + if (ctx != nullptr) ctx->tsfn.Unref(env); + } + delete data; + } + using Tsfn = Napi::TypedThreadSafeFunction; + + void loop() { + while (true) { + std::unique_lock lock(mutex); + cv.wait(lock, [this] { return deferred != nullptr || stop; }); + if (stop) return; + working = true; + const char *response = td_receive(timeout); + // TDLib stores the response in thread-local storage that is deallocated + // on execute() and receive(). Since we never call execute() in this + // thread, it should be safe not to copy the response here. + tsfn.NonBlockingCall(new TsfnData { std::move(deferred), response }); + deferred = nullptr; + working = false; + } + } + + double timeout; + Tsfn tsfn; + std::atomic_bool working {false}; + std::unique_ptr deferred; + bool stop {false}; + std::mutex mutex; + std::condition_variable cv; + std::thread thread; + }; + + ReceiveWorker *worker = nullptr; + + // Set the receive timeout explicitly. + void Init(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (worker != nullptr) + FAIL("The worker is already initialized"); + if (!info[0].IsNumber()) + TYPEFAIL("Expected first argument to be a number"); + double timeout = info[0].As().DoubleValue(); + worker = new ReceiveWorker(env, timeout); + } + + Napi::Value CreateClientId(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (td_create_client_id == nullptr) + FAIL_VALUE("td_create_client_id is not available"); + if (td_send == nullptr) + FAIL_VALUE("td_send is not available"); + if (td_receive == nullptr) + FAIL_VALUE("td_receive is not available"); + int client_id = td_create_client_id(); + if (worker == nullptr) + worker = new ReceiveWorker(env, 10.0); + return Napi::Number::New(env, client_id); + } + + void Send(const Napi::CallbackInfo& info) { + int client_id = info[0].As().Int32Value(); + std::string request = info[1].As().Utf8Value(); + td_send(client_id, request.c_str()); + } + + // Should not be called again until promise is resolved/rejected. + Napi::Value Receive(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + return worker->NewTask(env); + } + + Napi::Value Execute(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (td_execute == nullptr) + FAIL_VALUE("td_execute is not available"); + if (!info[0].IsString()) + TYPEFAIL_VALUE("Expected first argument to be a string"); + std::string request = info[0].As().Utf8Value(); + const char *response = td_execute(request.c_str()); + if (response == nullptr) return env.Null(); + return Napi::String::New(env, response); + } + + // void Stop(const Napi::CallbackInfo& info) { delete worker; } +} + #define FINDFUNC(F) \ F = (F##_t) dlsym(handle, #F); \ if ((dlsym_err_cstr = dlerror()) != nullptr) { \ @@ -272,6 +407,10 @@ void LoadTdjson(const Napi::CallbackInfo& info) { FINDFUNC(td_json_client_destroy); FINDFUNC(td_set_log_fatal_error_callback); FINDFUNC_OPT(td_set_log_message_callback); + FINDFUNC_OPT(td_create_client_id); + FINDFUNC_OPT(td_send); + FINDFUNC_OPT(td_receive); + FINDFUNC_OPT(td_execute); } Napi::Object Init(Napi::Env env, Napi::Object exports) { @@ -284,6 +423,12 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { Napi::Function::New(env, TdSetLogFatalErrorCallback, "setLogFatalErrorCallback"); exports["setLogMessageCallback"] = Napi::Function::New(env, TdSetLogMessageCallback, "setLogMessageCallback"); + exports["tdnInit"] = Napi::Function::New(env, Tdn::Init, "init"); + exports["tdnCreateClientId"] = + Napi::Function::New(env, Tdn::CreateClientId, "createClientId"); + exports["tdnSend"] = Napi::Function::New(env, Tdn::Send, "send"); + exports["tdnReceive"] = Napi::Function::New(env, Tdn::Receive, "receive"); + exports["tdnExecute"] = Napi::Function::New(env, Tdn::Execute, "execute"); exports["loadTdjson"] = Napi::Function::New(env, LoadTdjson, "loadTdjson"); return exports; } diff --git a/packages/tdl/binding.gyp b/packages/tdl/binding.gyp index 6b6afed..cd12376 100644 --- a/packages/tdl/binding.gyp +++ b/packages/tdl/binding.gyp @@ -6,13 +6,14 @@ 'target_name': 'td', 'cflags!': [ '-fno-exceptions' ], 'cflags_cc!': [ '-fno-exceptions' ], + 'cflags': [ '-Wall', '-Wextra' ], + 'cflags_cc': [ '-Wall', '-Wextra', '-std=c++14' ], 'sources': [ 'addon/td.cpp' ], 'include_dirs': [ ", + execute(request: string): string | null +|} + export type Tdjson = {| create(): TdjsonClient, destroy(client: TdjsonClient): void, - execute(client: null | TdjsonClient, query: {...}): {...} | null, - receive(client: TdjsonClient, timeout: number): Promise<{...} | null>, - send(client: TdjsonClient, query: {...}): void, + execute(client: null | TdjsonClient, request: string): string | null, + receive(client: TdjsonClient, timeout: number): Promise, + send(client: TdjsonClient, request: string): void, /** td_set_log_fatal_error_callback is deprecated in TDLib v1.8.0 */ setLogFatalErrorCallback(fn: null | ((errorMessage: string) => void)): void, setLogMessageCallback( maxVerbosityLevel: number, callback: null | ((verbosityLevel: number, message: string) => void) - ): void + ): void, + tdn: TdjsonNew |} -export type TdjsonCompat = {| - // Compatibility with tdl-tdlib-addon - +getName?: () => void, +export type TdjsonTdlTdlibAddon = {| + getName(): void, create(): TdjsonClient, destroy(client: TdjsonClient): void, - execute(client: null | TdjsonClient, query: {...}): {...} | null, + execute(client: null | TdjsonClient, request: {...}): {...} | null, receive(client: TdjsonClient, timeout: number): Promise<{...} | null>, - send(client: TdjsonClient, query: {...}): void, - /** td_set_log_fatal_error_callback is deprecated in TDLib v1.8.0 */ + send(client: TdjsonClient, request: {...}): void, + setLogFatalErrorCallback(fn: null | ((errorMessage: string) => void)): void, +|} + +type TdjsonCompat = {| + /** `true` if runs in compatibility with the tdl-tdlib-addon package */ + compat?: boolean, + create(): TdjsonClient, + destroy(client: TdjsonClient): void, + execute(client: null | TdjsonClient, request: {...}): {...} | null, + receive(client: TdjsonClient, timeout: number): Promise<{...} | null>, + send(client: TdjsonClient, request: {...}): void, setLogFatalErrorCallback(fn: null | ((errorMessage: string) => void)): void, - +setLogMessageCallback?: ( + setLogMessageCallback( maxVerbosityLevel: number, callback: null | ((verbosityLevel: number, message: string) => void) - ) => void + ): void, + tdn: TdjsonNew |} +// Compatibility with tdl-tdlib-addon +function tdjsonCompat (td: TdjsonTdlTdlibAddon | Tdjson): TdjsonCompat { + function unvailable (name: string) { + throw new Error(`${name} is not available in tdl-tdlib-addon`) + } + const foundTdlTdlibAddon = (td: any).getName?.() === 'addon' + if (!foundTdlTdlibAddon) { + const tdjson: Tdjson = (td: any) + return { + ...tdjson, + execute (client, request) { + const response = tdjson.execute(client, JSON.stringify(request)) + if (response == null) return null + return JSON.parse(response) + }, + send (client, request) { + tdjson.send(client, JSON.stringify(request)) + }, + async receive (client, timeout) { + const response = await tdjson.receive(client, timeout) + if (response == null) return null + return JSON.parse(response) + } + } + } + const tdjsonOld: TdjsonTdlTdlibAddon = (td: any) + return { + compat: true, + create: tdjsonOld.create.bind(tdjsonOld), + destroy: tdjsonOld.destroy.bind(tdjsonOld), + execute: tdjsonOld.execute.bind(tdjsonOld), + receive: tdjsonOld.receive.bind(tdjsonOld), + send: tdjsonOld.send.bind(tdjsonOld), + setLogFatalErrorCallback: tdjsonOld.setLogFatalErrorCallback.bind(tdjsonOld), + setLogMessageCallback: () => unvailable('setLogMessageCallback'), + tdn: { + init: () => unvailable('tdn.init'), + createClientId: () => unvailable('tdn.createClientId'), + send: () => unvailable('tdn.send'), + receive: () => unvailable('tdn.receive'), + execute: () => unvailable('tdn.execute') + } + } +} + export type TDLibParameters = $Rest export type LoginUser = {| @@ -211,8 +276,13 @@ function invariant (cond: boolean, msg: string = 'Invariant violation') { if (!cond) throw new Error(msg) } +export type ManagingOptions = { + useTdn: boolean, + onClose: () => void +} + const TDLIB_1_8_6 = new Version('1.8.6') -const TDLIB_DEFAULT = new Version('1.8.12') +const TDLIB_DEFAULT = new Version('1.8.19') const TDL_MAGIC = '6c47e6b71ea' @@ -242,7 +312,7 @@ export class Client { +_emitter: EventEmitter = new EventEmitter(); +_pending: Map = new Map(); _requestId: number = 0 - _client: TdjsonClient | null + _client: TdjsonClient | null = null _initialized: boolean = false _paused: boolean = false _connectionState: Td$ConnectionState = { _: 'connectionStateConnecting' } @@ -250,10 +320,22 @@ export class Client { _loginDetails: ?StrictLoginDetails _loginDefer: TdlDeferred = new TdlDeferred() _version: Version = TDLIB_DEFAULT - - constructor (tdlibInstance: TdjsonCompat, options: ClientOptions = {}) { + _tdn: boolean = false + _onClose: (() => void) = (() => {}) + _clientId: number = -1 + + constructor ( + tdlibInstance: TdjsonTdlTdlibAddon | Tdjson, + options: ClientOptions = {}, + managing?: ManagingOptions + ) { this._options = (mergeDeepRight(defaultOptions, options): StrictClientOptions) - this._tdlib = tdlibInstance + this._tdlib = tdjsonCompat(tdlibInstance) + + if (managing && managing.useTdn) { + this._tdn = true + this._onClose = managing.onClose + } // Backward compatibility if (this._options.useDefaultVerbosityLevel) @@ -272,35 +354,44 @@ export class Client { // Backward compatibility if (this._options.verbosityLevel != null && this._options.verbosityLevel !== 'default') { debug('Executing setLogVerbosityLevel', this._options.verbosityLevel) - this._tdlib.execute(null, { - '@type': 'setLogVerbosityLevel', - new_verbosity_level: this._options.verbosityLevel + this.execute({ + _: 'setLogVerbosityLevel', + new_verbosity_level: parseInt(this._options.verbosityLevel) }) - } else if (this._tdlib.getName?.() === 'addon') { + } else if (this._tdlib.compat) { debug('Executing setLogVerbosityLevel (tdl-tdlib-addon found)', this._options.verbosityLevel) - this._tdlib.execute(null, { - '@type': 'setLogVerbosityLevel', + this.execute({ + _: 'setLogVerbosityLevel', new_verbosity_level: 2 }) } - this._client = this._tdlib.create() - - if (!this._client) - throw new Error('Failed to create a TDLib client') - if (this._options.bare) this._initialized = true - // Note: To allow defining listeners before the first update, we must ensure - // that emit is not executed in the current tick. process.nextTick or - // queueMicrotask are redundant here because of await in the _loop function. - this._loop() - .catch(e => this._catchError(new TdlError(e))) + if (!this._tdn) { + this._client = this._tdlib.create() + + if (!this._client) throw new Error('Failed to create a TDLib client') + + // Note: To allow defining listeners before the first update, we must + // ensure that emit is not executed in the current tick. process.nextTick + // or queueMicrotask are redundant here because of await in the _loop + // function. + this._loop().catch(e => this._catchError(new TdlError(e))) + } else { + this._clientId = this._tdlib.tdn.createClientId() + // The new tdjson interface requires to send a dummy request first + this._sendTdl({ _: 'getOption', name: 'version' }) + } + } + + getClientId (): number { + return this._clientId } /** @deprecated */ - static create (tdlibInstance: TdjsonCompat, options: ClientOptions = {}): Client { + static create (tdlibInstance: TdjsonTdlTdlibAddon, options: ClientOptions = {}): Client { return new Client(tdlibInstance, options) } @@ -383,15 +474,12 @@ export class Client { /** @deprecated */ connect: () => Promise = () => Promise.resolve() - /** @deprecated */ connectAndLogin: (fn?: () => LoginDetails) => Promise = (fn = emptyDetails) => { return this.login(fn) } - /** @deprecated */ getBackendName: () => string = () => 'addon' - /** @deprecated */ pause: () => void = () => { if (!this._paused) { @@ -401,7 +489,6 @@ export class Client { debug('pause (no-op)') } } - /** @deprecated */ resume: () => void = () => { if (this._paused) { @@ -462,6 +549,12 @@ export class Client { destroy: () => void = () => { debug('destroy') + if (this._tdn) { + this._onClose() + this._clientId = -1 + this.emit('destroy') + return + } if (this._client === null) return this._tdlib.destroy(this._client) this._client = null @@ -473,7 +566,7 @@ export class Client { close: () => Promise = () => { debug('close') return new Promise(resolve => { - if (this._client === null) return resolve() + if (this._client === null && this._clientId === -1) return resolve() // TODO: call this.resume() here? // If the client is paused, we can't receive authorizationStateClosed // and destroy won't be called @@ -495,6 +588,13 @@ export class Client { execute: Execute = request => { debugReq('execute', request) + if (this._tdn) { + const tdRequest = deepRenameKey('_', '@type', request) + // the client can be null, it's fine + const tdResponse = this._tdlib.tdn.execute(JSON.stringify(tdRequest)) + if (tdResponse == null) return null + return deepRenameKey('@type', '_', JSON.parse(tdResponse)) + } const tdRequest = deepRenameKey('_', '@type', request) // the client can be null, it's fine const tdResponse = this._tdlib.execute(this._client, tdRequest) @@ -505,23 +605,21 @@ export class Client { _send (request: { +_: string, +[k: any]: any }): void { debugReq('send', request) const tdRequest = deepRenameKey('_', '@type', request) - if (this._client === null) + if (this._client === null && this._clientId === -1) throw new Error('A closed client cannot be reused, create a new Client') - this._tdlib.send(this._client, tdRequest) + if (this._tdn) + this._tdlib.tdn.send(this._clientId, JSON.stringify(tdRequest)) + else + this._tdlib.send(this._client, tdRequest) } _sendTdl (request: { +_: string, +[k: any]: any }): void { this._send({ ...request, '@extra': TDL_MAGIC }) } - async _receive (timeout: number = this._options.receiveTimeout): Promise { - if (this._client === null) return null - const tdResponse = await this._tdlib.receive(this._client, timeout) - if (tdResponse == null) return null - return deepRenameKey('@type', '_', tdResponse) - } - + // Used with the old tdjson interface only async _loop (): Promise { + const timeout = this._options.receiveTimeout while (true) { if (this._paused) { debug('receive loop: waiting for resume') @@ -534,21 +632,30 @@ export class Client { break } - const response = await this._receive() + const res = await this._tdlib.receive(this._client, timeout) - if (!response) { + if (res == null) { debug('receive loop: response is empty') continue } try { - this._handleReceive(response) + this._handleReceive(deepRenameKey('@type', '_', res)) } catch (e) { this._catchError(new TdlError(e)) } } } + // Can be called by the client manager in case the new interface is used + handleReceive (res: any): void { + try { + this._handleReceive(deepRenameKey('@type', '_', res)) + } catch (e) { + this._catchError(new TdlError(e)) + } + } + // This function can be called with any TDLib object _handleReceive (res: any): void { this.emit('response', res) // TODO: rename or remove this event diff --git a/packages/tdl/src/index.js b/packages/tdl/src/index.js index 183bc0a..9e47ebb 100644 --- a/packages/tdl/src/index.js +++ b/packages/tdl/src/index.js @@ -26,20 +26,28 @@ const defaultLibraryFile = (() => { export type TDLibConfiguration = { tdjson?: string, libdir?: string, - verbosityLevel?: number | 'default' + verbosityLevel?: number | 'default', + receiveTimeout?: number, + useNewTdjsonInterface?: boolean } // TODO: Use Required from new Flow versions type StrictTDLibConfiguration = { tdjson: string, libdir: string, - verbosityLevel: number | 'default' + verbosityLevel: number | 'default', + receiveTimeout: number, + useNewTdjsonInterface: boolean } +const defaultReceiveTimeout = 10 + const cfg: StrictTDLibConfiguration = { tdjson: defaultLibraryFile, libdir: '', - verbosityLevel: 2 + verbosityLevel: 2, + receiveTimeout: defaultReceiveTimeout, + useNewTdjsonInterface: false } export function configure (opts: TDLibConfiguration = {}): void { @@ -48,6 +56,8 @@ export function configure (opts: TDLibConfiguration = {}): void { if (opts.tdjson != null) cfg.tdjson = opts.tdjson if (opts.libdir != null) cfg.libdir = opts.libdir if (opts.verbosityLevel != null) cfg.verbosityLevel = opts.verbosityLevel + if (opts.receiveTimeout != null) cfg.receiveTimeout = opts.receiveTimeout + if (opts.useNewTdjsonInterface != null) cfg.useNewTdjsonInterface = opts.useNewTdjsonInterface } export function init (): void { @@ -57,10 +67,12 @@ export function init (): void { tdjsonAddon = loadAddon(lib) if (cfg.verbosityLevel !== 'default') { debug('Executing setLogVerbosityLevel', cfg.verbosityLevel) - tdjsonAddon.execute(null, { + const request = JSON.stringify({ '@type': 'setLogVerbosityLevel', new_verbosity_level: cfg.verbosityLevel }) + if (cfg.useNewTdjsonInterface) tdjsonAddon.tdn.execute(request) + else tdjsonAddon.execute(null, request) } } @@ -70,29 +82,84 @@ export const execute: Execute = function execute (request: any) { if (!tdjsonAddon) throw Error('TDLib is uninitialized') } debug('execute', request) - const tdRequest = deepRenameKey('_', '@type', request) - const tdResponse = tdjsonAddon.execute(null, tdRequest) - if (tdResponse == null) return null - return deepRenameKey('@type', '_', tdResponse) + request = JSON.stringify(deepRenameKey('_', '@type', request)) + const response = cfg.useNewTdjsonInterface + ? tdjsonAddon.tdn.execute(request) + : tdjsonAddon.execute(null, request) + if (response == null) return null + return deepRenameKey('@type', '_', JSON.parse(response)) } -export function createClient (opts: any): Client { +export function setLogMessageCallback ( + maxVerbosityLevel: number, + callback: null | ((verbosityLevel: number, message: string) => void) +): void { if (!tdjsonAddon) { init() if (!tdjsonAddon) throw Error('TDLib is uninitialized') } - return new Client(tdjsonAddon, opts) + tdjsonAddon.setLogMessageCallback(maxVerbosityLevel, callback) } -export function setLogMessageCallback ( - maxVerbosityLevel: number, - callback: null | (verbosityLevel: number, message: string) => void -): void { +const clientMap: Map = new Map() +let tdnInitialized = false +let runningReceiveLoop = false + +// Loop for the new tdjson interface +async function receiveLoop () { + debug('Starting receive loop') + runningReceiveLoop = true + try { + while (true) { + if (clientMap.size < 1) { + debug('Stopping receive loop') + break + } + // $FlowIgnore[incompatible-use] + const responseString = await tdjsonAddon.tdn.receive() + if (responseString == null) { + debug('Receive loop: got empty response') + continue + } + const res = JSON.parse(responseString) + const clientId = res['@client_id'] + const client = clientId != null ? clientMap.get(clientId) : undefined + if (client == null) { + debug(`Cannot find client_id ${clientId}`) + continue + } + delete res['@client_id'] // Note that delete is somewhat slow + client.handleReceive(res) + } + } finally { + runningReceiveLoop = false + } +} + +export function createClient (opts: any): Client { if (!tdjsonAddon) { init() if (!tdjsonAddon) throw Error('TDLib is uninitialized') } - tdjsonAddon.setLogMessageCallback(maxVerbosityLevel, callback) + if (cfg.useNewTdjsonInterface) { + if (!tdnInitialized) { + tdjsonAddon.tdn.init(cfg.receiveTimeout) + tdnInitialized = true + } + const onClose = () => { + debug(`Deleting client_id ${clientId}`) + clientMap.delete(clientId) + } + const client = new Client(tdjsonAddon, opts, { useTdn: true, onClose }) + const clientId = client.getClientId() + clientMap.set(clientId, client) + if (!runningReceiveLoop) + receiveLoop() + return client + } + if (cfg.receiveTimeout !== defaultReceiveTimeout) + return new Client(tdjsonAddon, { ...opts, receiveTimeout: cfg.receiveTimeout }) + return new Client(tdjsonAddon, opts) } // TODO: We could possibly export an unsafe/unstable getRawTdjson() : Tdjson diff --git a/tests/integration/tdlib.test.js b/tests/integration/tdlib.test.js index 4451682..a376bb3 100644 --- a/tests/integration/tdlib.test.js +++ b/tests/integration/tdlib.test.js @@ -28,6 +28,7 @@ let testName/*: string */ let createClient/*: () => tdl.Client */ const testingTdlTdlibAddon = process.env.TEST_TDL_TDLIB_ADDON === '1' +const testingNewTdjson = process.env.TEST_NEW_TDJSON === '1' if (testingTdlTdlibAddon) { testName = 'tdl with tdl-tdlib-addon (backward compatibility)' createClient = function () { @@ -35,6 +36,19 @@ if (testingTdlTdlibAddon) { if (libdir) tdjson = path.join(projectRoot, defaultLibraryFile) return new tdl.Client(new TDLib(tdjson), { bare: true }) } +} else if (testingNewTdjson) { + const ver = tdjson && tdjson.match(/td-1\.(\d)\.0/) + if (ver && ver[1] && Number(ver[1]) < 7) { + console.log('TEST_NEW_TDJSON is disabled for TDLib < 1.7.0.') + process.exit(0) + } + testName = 'tdl with useNewTdjsonInterface' + createClient = function () { + if (libdir) tdl.configure({ libdir }) + else tdl.configure({ tdjson }) + tdl.configure({ useNewTdjsonInterface: true }) + return tdl.createClient({ bare: true }) + } } else { testName = 'tdl' createClient = function () { @@ -50,7 +64,7 @@ describe(testName, () => { client.on('error', e => console.error('error', e)) client.on('update', u => { - console.log('update', u) + // console.log('update', u) updates.push(u) })