From fe923ec9bd4a595d7dd246c34bdf7d5efcd822ab Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 10 Jan 2026 12:57:01 +0000 Subject: [PATCH] async addon, tests --- .../types/typescript/README.md | 2 +- .../types/typescript/package.json | 4 +- .../simplex-chat-client/typescript/README.md | 6 +- .../typescript/package.json | 6 +- packages/simplex-chat-nodejs/.gitignore | 4 + packages/simplex-chat-nodejs/binding.gyp | 11 +- packages/simplex-chat-nodejs/cpp/simplex.cc | 628 ++++++++++++++---- packages/simplex-chat-nodejs/package.json | 14 +- packages/simplex-chat-nodejs/src/index.js | 2 +- packages/simplex-chat-nodejs/src/tests.js | 22 - .../simplex-chat-nodejs/tests/addon.test.js | 101 +++ 11 files changed, 640 insertions(+), 160 deletions(-) create mode 100644 packages/simplex-chat-nodejs/.gitignore delete mode 100644 packages/simplex-chat-nodejs/src/tests.js create mode 100644 packages/simplex-chat-nodejs/tests/addon.test.js diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index ad13a5d76e..e30cf0d0c3 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -2,7 +2,7 @@ This TypeScript library provides auto-generated types for bots API: commands and responses, events and all types they use. -It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) library that uses WebSockets interface of SimpleX Chat CLI. +It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 1bf593b483..af4f594ded 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.1.0", + "version": "6.5.0-beta.3", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -35,7 +35,7 @@ "bugs": { "url": "https://github.com/simplex-chat/simplex-chat/issues" }, - "homepage": "https://github.com/simplex-chat/simplex-chat#readme", + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/types/typescript#readme", "dependencies": { "typescript": "^5.9.2" } diff --git a/packages/simplex-chat-client/typescript/README.md b/packages/simplex-chat-client/typescript/README.md index c1756dc82c..c0b70caf69 100644 --- a/packages/simplex-chat-client/typescript/README.md +++ b/packages/simplex-chat-client/typescript/README.md @@ -1,5 +1,9 @@ # SimpleX Chat JavaScript client +**THIS PACKAGE IS DEPRECATED** + +Use [SimpleX Chat Node.js library](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs#readme) instead of this package. + This is a TypeScript library that defines WebSocket API client for [SimpleX Chat terminal CLI](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/CLI.md) that should be run as a WebSockets server on any port: ```bash @@ -24,7 +28,7 @@ Please share your use cases and implementations. ## Quick start ``` -npm i simplex-chat +npm i @simplex-chat/webrtc-client npm run build ``` diff --git a/packages/simplex-chat-client/typescript/package.json b/packages/simplex-chat-client/typescript/package.json index b721dbcce2..5cb0579b1a 100644 --- a/packages/simplex-chat-client/typescript/package.json +++ b/packages/simplex-chat-client/typescript/package.json @@ -1,6 +1,6 @@ { - "name": "simplex-chat", - "version": "0.3.0", + "name": "@simplex-chat/webrtc-client", + "version": "6.5.0-beta.3", "description": "SimpleX Chat client", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -38,7 +38,7 @@ "bugs": { "url": "https://github.com/simplex-chat/simplex-chat/issues" }, - "homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-client/typescript#readme", + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript#readme", "dependencies": { "isomorphic-ws": "^4.0.1" }, diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore new file mode 100644 index 0000000000..7481e30d5a --- /dev/null +++ b/packages/simplex-chat-nodejs/.gitignore @@ -0,0 +1,4 @@ +node_modules +package-lock.json +dist +coverage diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index fa72d9b1d2..addb293f5b 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -1,12 +1,21 @@ { "targets": [ { - "target_name": "simplex_shim", + "target_name": "simplex", "sources": [ "cpp/simplex.cc" ], + "include_dirs": [ + " #include #include - -#include +#include +#include #include "simplex.h" namespace simplex { - using v8::Array; - using v8::ArrayBuffer; - using v8::Context; - using v8::FunctionCallbackInfo; - using v8::Isolate; - using v8::Local; - using v8::Maybe; - using v8::Number; - using v8::Object; - using v8::String; - using v8::Value; + using namespace Napi; void haskell_init() { int argc = 5; - char *argv[] = { + const char *argv[] = { "simplex", "+RTS", // requires `hs_init_with_rtsopts` "-A64m", // chunk size for new allocations "-H64m", // initial heap size "-xn", // non-moving GC - 0}; - char **pargv = argv; + nullptr}; + char **pargv = const_cast(argv); hs_init_with_rtsopts(&argc, &pargv); } - void ChatMigrateInit(const FunctionCallbackInfo &args) - { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); - - std::string path(*(v8::String::Utf8Value(isolate, args[0]))); - std::string key(*(v8::String::Utf8Value(isolate, args[1]))); - std::string confirm(*(v8::String::Utf8Value(isolate, args[2]))); - - long *ctrl(0); - - std::string res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); - std::stringstream ss; - ss << ctrl - << "\n" - << res; - // printf("ChatCtrl after init %d", cChatCtrl); - // v8::Local ctrl = v8::String::NewFromUtf8(isolate, cppChatCtrl.c_str(), v8::String::kNormalString); - - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, ss.str().c_str()) - .ToLocalChecked()); + Napi::Value ParseJson(Env env, const std::string& json_str) { + Object global = env.Global(); + Object json = global.Get("JSON").As(); + Function parse = json.Get("parse").As(); + return parse.Call(json, {String::New(env, json_str)}); } - void ChatCloseStore(const FunctionCallbackInfo &args) + class GenericAsyncWorker : public AsyncWorker { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + public: + GenericAsyncWorker(Function& callback, std::function execute_lambda) + : AsyncWorker(callback), execute_lambda_(std::move(execute_lambda)) {} - long *ctrl = (long *)((long)args[0].As()->Value()); + void Execute() override { + execute_lambda_(this); + } - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_close_store(ctrl)) - .ToLocalChecked()); + void OnOK() override { + HandleScope scope(Env()); + std::vector args = { Env().Null() }; + auto res = GetResult(Env()); + args.insert(args.end(), res.begin(), res.end()); + Callback().Call(args); + } + + void OnError(const Error& e) override { + HandleScope scope(Env()); + Callback().Call({e.Value(), Env().Undefined()}); + } + + virtual std::vector GetResult(Napi::Env env) override { + return { String::New(env, result_) }; + } + + void SetResult(std::string result) { + result_ = std::move(result); + } + + void SetWorkerError(const std::string& msg) { + SetError(msg); + } + + protected: + std::string result_; + + private: + std::function execute_lambda_; + }; + + class JsonAsyncWorker : public GenericAsyncWorker + { + public: + JsonAsyncWorker(Function& callback, std::function execute_lambda) + : GenericAsyncWorker(callback, std::move(execute_lambda)) {} + + void OnOK() override { + HandleScope scope(Env()); + Value parsed = ParseJson(Env(), result_); + if (Env().IsExceptionPending()) { + Error exception = Env().GetAndClearPendingException(); + Callback().Call({exception.Value(), Env().Undefined()}); + return; + } + Callback().Call({Env().Null(), parsed}); + } + }; + + class ResultUnwrapAsyncWorker : public GenericAsyncWorker + { + public: + ResultUnwrapAsyncWorker(Function& callback, std::function execute_lambda) + : GenericAsyncWorker(callback, std::move(execute_lambda)) {} + + void OnOK() override { + HandleScope scope(Env()); + Value parsed = ParseJson(Env(), result_); + if (Env().IsExceptionPending()) { + Error exception = Env().GetAndClearPendingException(); + Callback().Call({exception.Value(), Env().Undefined()}); + return; + } + Object parsed_obj = parsed.As(); + Value type_val = parsed_obj.Get("type"); + if (!type_val.IsString()) { + Error err = Error::New(Env(), "Invalid response type"); + Callback().Call({err.Value(), Env().Undefined()}); + return; + } + std::string type = type_val.As().Utf8Value(); + if (type == "error") { + Value err_val = parsed_obj.Get("writeError"); + std::string err_msg = err_val.IsString() ? err_val.As().Utf8Value() : "Unknown error"; + Error err = Error::New(Env(), err_msg); + Callback().Call({err.Value(), Env().Undefined()}); + } else { + Value cryptoArgs = parsed_obj.Get("cryptoArgs"); + if (cryptoArgs.IsUndefined()) { + Error err = Error::New(Env(), "Missing cryptoArgs"); + Callback().Call({err.Value(), Env().Undefined()}); + return; + } + Callback().Call({Env().Null(), cryptoArgs}); + } + } + }; + + class MigrateAsyncWorker : public GenericAsyncWorker + { + public: + MigrateAsyncWorker(Function& callback, std::function execute_lambda) + : GenericAsyncWorker(callback, execute_lambda) {} + + void SetCtrl(uintptr_t ctrl) { + ctrl_ = ctrl; + } + + void OnOK() override { + HandleScope scope(Env()); + Value parsed = ParseJson(Env(), result_); + if (Env().IsExceptionPending()) { + Error exception = Env().GetAndClearPendingException(); + Callback().Call({exception.Value(), Env().Undefined()}); + return; + } + Object parsed_obj = parsed.As(); + Value type_val = parsed_obj.Get("type"); + if (type_val.IsString() && type_val.As().Utf8Value() == "ok") { + Callback().Call({Env().Null(), BigInt::New(Env(), static_cast(ctrl_))}); + } else { + Error err = Error::New(Env(), "Database or migration error (see dbMigrationError property)"); + err.Set("dbMigrationError", parsed_obj); + Callback().Call({err.Value(), Env().Undefined()}); + } + } + + private: + uintptr_t ctrl_ = 0; + }; + + class BinaryAsyncWorker : public AsyncWorker + { + public: + BinaryAsyncWorker(Function& callback, std::function execute_lambda) + : AsyncWorker(callback), execute_lambda_(std::move(execute_lambda)) {} + + void Execute() override { + execute_lambda_(this); + } + + void OnOK() override { + HandleScope scope(Env()); + char* data_ptr = original_buf + 5; + auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) { + free(orig); + }; + Napi::Buffer buffer = Napi::Buffer::New(Env(), data_ptr, binary_len, finalizer, original_buf); + Callback().Call({Env().Null(), buffer}); + } + + void OnError(const Error& e) override { + HandleScope scope(Env()); + Callback().Call({e.Value(), Env().Undefined()}); + } + + void SetWorkerError(const std::string& msg) { + SetError(msg); + } + + public: + char* original_buf = nullptr; + size_t binary_len = 0; + + private: + std::function execute_lambda_; + }; + + class WriteAsyncWorker : public ResultUnwrapAsyncWorker + { + public: + WriteAsyncWorker(Function& callback, std::function execute_lambda, ArrayBuffer ab) + : ResultUnwrapAsyncWorker(callback, std::move(execute_lambda)), ab_ref(Reference::New(ab, 1)) {} + + Reference ab_ref; + }; + + Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) { + Promise::Deferred deferred = Promise::Deferred::New(env); + cb_out = Function::New(env, [deferred](const CallbackInfo& args) { + if (!args[0].IsNull() && !args[0].IsUndefined()) { + deferred.Reject(args[0]); + } else { + deferred.Resolve(args[1]); + } + }); + return deferred.Promise(); } - void ChatSendCmd(const FunctionCallbackInfo &args) + Value ChatMigrateInit(const CallbackInfo& args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException(); + return env.Undefined(); + } - long *ctrl = (long *)((long)args[0].As()->Value()); - std::string cmd(*(v8::String::Utf8Value(isolate, args[1]))); + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string confirm = args[2].As().Utf8Value(); - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_send_cmd(ctrl, cmd.c_str())) - .ToLocalChecked()); + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [path, key, confirm](GenericAsyncWorker* worker) { + chat_ctrl ctrl = nullptr; + char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); + if (c_res == nullptr) { + worker->SetWorkerError("chat_migrate_init failed"); + return; + } + std::string res = c_res; + free(c_res); + auto mworker = static_cast(worker); + mworker->SetCtrl(reinterpret_cast(ctrl)); + worker->SetResult(res); + }; + + MigrateAsyncWorker* worker = new MigrateAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; } - void ChatRecvMsgWait(const FunctionCallbackInfo &args) + class CloseStoreAsyncWorker : public GenericAsyncWorker { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + public: + CloseStoreAsyncWorker(Function& callback, std::function execute_lambda) + : GenericAsyncWorker(callback, std::move(execute_lambda)) {} - long *ctrl = (long *)((long)args[0].As()->Value()); - long wait = ((long)args[1].As()->Value()); + void OnOK() override { + HandleScope scope(Env()); + if (result_.empty()) { + Callback().Call({Env().Null(), Env().Undefined()}); + } else { + Error err = Error::New(Env(), result_); + Callback().Call({err.Value(), Env().Undefined()}); + } + } + }; - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_recv_msg_wait(ctrl, wait)) - .ToLocalChecked()); + Value ChatCloseStore(const CallbackInfo& args) + { + Env env = args.Env(); + if (args.Length() < 1 || !args[0].IsBigInt()) { + TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + bool lossless; + chat_ctrl ctrl = reinterpret_cast(args[0].As().Int64Value(&lossless)); + + if (!lossless) { + TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [ctrl](GenericAsyncWorker* worker) { + char* c_res = chat_close_store(ctrl); + if (c_res == nullptr) { + worker->SetWorkerError("chat_close_store failed"); + return; + } + std::string res = c_res; + free(c_res); + worker->SetResult(res); + }; + + CloseStoreAsyncWorker* worker = new CloseStoreAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; } - void ChatWriteFile(const FunctionCallbackInfo &args) + Value ChatSendCmd(const CallbackInfo& args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + Env env = args.Env(); + if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) { + TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException(); + return env.Undefined(); + } - long *ctrl = (long *)((long)args[0].As()->Value()); - std::string path(*(v8::String::Utf8Value(isolate, args[1]))); + bool lossless; + chat_ctrl ctrl = reinterpret_cast(args[0].As().Int64Value(&lossless)); + if (!lossless) { + TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string cmd = args[1].As().Utf8Value(); - Local arr = args[2].As(); - char *buffer = (char *)arr->Data(); - int len(arr->ByteLength()); - // std::array address(*arr); + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); - // v8::Array a = *buffer; - // std::string data(*((isolate, args[1]))); + auto execute_lambda = [ctrl, cmd](GenericAsyncWorker* worker) { + char* c_res = chat_send_cmd(ctrl, cmd.c_str()); + if (c_res == nullptr) { + worker->SetWorkerError("chat_send_cmd failed"); + return; + } + std::string res = c_res; + free(c_res); + if (res.empty()) { + worker->SetWorkerError("chat_send_cmd failed"); + return; + } + worker->SetResult(res); + }; - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_write_file(ctrl, path.c_str(), buffer, len)) - .ToLocalChecked()); + JsonAsyncWorker* worker = new JsonAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; } - void ChatReadFile(const FunctionCallbackInfo &args) + Value ChatRecvMsgWait(const CallbackInfo& args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + Env env = args.Env(); + if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) { + TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + bool lossless; + chat_ctrl ctrl = reinterpret_cast(args[0].As().Int64Value(&lossless)); + if (!lossless) { + TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return env.Undefined(); + } + int wait = static_cast(args[1].As().Int32Value()); - long *ctrl = (long *)((long)args[0].As()->Value()); - std::string path(*(v8::String::Utf8Value(isolate, args[1]))); + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); - Local arr = args[2].As(); - char *buffer = (char *)arr->Data(); - int len(arr->ByteLength()); + auto execute_lambda = [ctrl, wait](GenericAsyncWorker* worker) { + char* c_res = chat_recv_msg_wait(ctrl, wait); + if (c_res == nullptr) { + worker->SetWorkerError("chat_recv_msg_wait failed"); + return; + } + std::string res = c_res; + free(c_res); + if (res.empty()) { + worker->SetWorkerError("chat_recv_msg_wait failed"); + return; + } + worker->SetResult(res); + }; - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_write_file(ctrl, path.c_str(), buffer, len)) - .ToLocalChecked()); + JsonAsyncWorker* worker = new JsonAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; } - void ChatEncryptFile(const FunctionCallbackInfo &args) + Value ChatWriteFile(const CallbackInfo& args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) { + TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException(); + return env.Undefined(); + } - long *ctrl = (long *)((long)args[0].As()->Value()); - std::string fromPath(*(v8::String::Utf8Value(isolate, args[1]))); - std::string toPath(*(v8::String::Utf8Value(isolate, args[2]))); + bool lossless; + chat_ctrl ctrl = reinterpret_cast(args[0].As().Int64Value(&lossless)); + if (!lossless) { + TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string path = args[1].As().Utf8Value(); + ArrayBuffer ab = args[2].As(); + char* data = static_cast(ab.Data()); + int len = static_cast(ab.ByteLength()); - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str())) - .ToLocalChecked()); + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [ctrl, path, data, len](GenericAsyncWorker* worker) { + char* c_res = chat_write_file(ctrl, path.c_str(), data, len); + if (c_res == nullptr) { + worker->SetWorkerError("chat_write_file failed"); + return; + } + std::string res = c_res; + free(c_res); + if (res.empty()) { + worker->SetWorkerError("chat_write_file failed"); + return; + } + worker->SetResult(res); + }; + + WriteAsyncWorker* worker = new WriteAsyncWorker(cb, std::move(execute_lambda), ab); + worker->Queue(); + + return promise; } - void ChatDecryptFile(const FunctionCallbackInfo &args) + Value ChatReadFile(const CallbackInfo& args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException(); + return env.Undefined(); + } - std::string fromPath(*(v8::String::Utf8Value(isolate, args[0]))); - std::string key(*(v8::String::Utf8Value(isolate, args[1]))); - std::string nonce(*(v8::String::Utf8Value(isolate, args[2]))); - std::string toPath(*(v8::String::Utf8Value(isolate, args[3]))); + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string nonce = args[2].As().Utf8Value(); - args.GetReturnValue().Set( - String::NewFromUtf8( - isolate, chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str())) - .ToLocalChecked()); + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [path, key, nonce](BinaryAsyncWorker* worker) { + char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); + if (buf == nullptr) { + worker->SetWorkerError("chat_read_file failed"); + return; + } + char status = buf[0]; + if (status == 1) { + std::string err = buf + 1; + free(buf); + worker->SetWorkerError(err); + return; + } else if (status == 0) { + uint32_t len = *(uint32_t*)(buf + 1); + worker->original_buf = buf; + worker->binary_len = len; + } else { + free(buf); + worker->SetWorkerError("Unexpected status from chat_read_file"); + return; + } + }; + + BinaryAsyncWorker* worker = new BinaryAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; } - void Initialize(Local exports) + Value ChatEncryptFile(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) { + TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + bool lossless; + chat_ctrl ctrl = reinterpret_cast(args[0].As().Int64Value(&lossless)); + if (!lossless) { + TypeError::New(env, "BigInt too large for ctrl").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string fromPath = args[1].As().Utf8Value(); + std::string toPath = args[2].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [ctrl, fromPath, toPath](GenericAsyncWorker* worker) { + char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); + if (c_res == nullptr) { + worker->SetWorkerError("chat_encrypt_file failed"); + return; + } + std::string res = c_res; + free(c_res); + if (res.empty()) { + worker->SetWorkerError("chat_encrypt_file failed"); + return; + } + worker->SetResult(res); + }; + + ResultUnwrapAsyncWorker* worker = new ResultUnwrapAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; + } + + Value ChatDecryptFile(const CallbackInfo& args) + { + Env env = args.Env(); + if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) { + TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string fromPath = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string nonce = args[2].As().Utf8Value(); + std::string toPath = args[3].As().Utf8Value(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_lambda = [fromPath, key, nonce, toPath](GenericAsyncWorker* worker) { + char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); + std::string res = c_res ? c_res : ""; + free(c_res); + if (!res.empty()) { + worker->SetWorkerError(res); + return; + } + worker->SetResult("ok"); + }; + + GenericAsyncWorker* worker = new GenericAsyncWorker(cb, std::move(execute_lambda)); + worker->Queue(); + + return promise; + } + + Object Init(Env env, Object exports) { haskell_init(); - NODE_SET_METHOD(exports, "chat_migrate_init", ChatMigrateInit); - NODE_SET_METHOD(exports, "chat_close_store", ChatCloseStore); - NODE_SET_METHOD(exports, "chat_send_cmd", ChatSendCmd); - NODE_SET_METHOD(exports, "chat_recv_msg_wait", ChatRecvMsgWait); - NODE_SET_METHOD(exports, "chat_write_file", ChatWriteFile); - NODE_SET_METHOD(exports, "chat_read_file", ChatReadFile); - NODE_SET_METHOD(exports, "chat_encrypt_file", ChatEncryptFile); - NODE_SET_METHOD(exports, "chat_decrypt_file", ChatDecryptFile); + exports.Set("chatMigrateInit", Function::New(env, ChatMigrateInit)); + exports.Set("chatCloseStore", Function::New(env, ChatCloseStore)); + exports.Set("chatSendCmd", Function::New(env, ChatSendCmd)); + exports.Set("chatRecvMsgWait", Function::New(env, ChatRecvMsgWait)); + exports.Set("chatWriteFile", Function::New(env, ChatWriteFile)); + exports.Set("chatReadFile", Function::New(env, ChatReadFile)); + exports.Set("chatEncryptFile", Function::New(env, ChatEncryptFile)); + exports.Set("chatDecryptFile", Function::New(env, ChatDecryptFile)); + return exports; } - NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize) + NODE_API_MODULE(simplex, Init) -} // namespace simplex \ No newline at end of file +} diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index d3171624f3..c416e595e6 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { - "name": "simplexjs", - "version": "6.5.3", + "name": "simplex-chat", + "version": "6.5.0-beta.3", "main": "src/index.js", "scripts": { "preinstall": "node src/download-libs.js", @@ -11,10 +11,14 @@ "build": "node-gyp build", "run": "node src/index.js", "build-run": "node-gyp build && node src/index.js", - "test": "node src/tests.js" + "test": "jest" }, "dependencies": { - "extract-zip": "^2.0.1" + "extract-zip": "^2.0.1", + "node-addon-api": "^8.5.0" + }, + "devDependencies": { + "jest": "^30.2.0" }, "repository": { "type": "git", @@ -31,6 +35,6 @@ "bugs": { "url": "https://github.com/simplex-chat/simplex-chat/issues" }, - "homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-nodejs#readme", + "homepage": "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs#readme", "description": "" } diff --git a/packages/simplex-chat-nodejs/src/index.js b/packages/simplex-chat-nodejs/src/index.js index aa58ac45bb..48f42d72d2 100644 --- a/packages/simplex-chat-nodejs/src/index.js +++ b/packages/simplex-chat-nodejs/src/index.js @@ -1 +1 @@ -module.exports = require('../build/Release/simplex_shim'); +module.exports = require('../build/Release/simplex'); diff --git a/packages/simplex-chat-nodejs/src/tests.js b/packages/simplex-chat-nodejs/src/tests.js deleted file mode 100644 index 6e009ebc83..0000000000 --- a/packages/simplex-chat-nodejs/src/tests.js +++ /dev/null @@ -1,22 +0,0 @@ -const addon = require('../build/Release/simplex_shim'); -const fs = require('fs'); - -const ctrlAndRes = addon.chat_migrate_init("db", "a", "yesUp") -const ctrl = Number(ctrlAndRes.split("\n")[0]) -const res = ctrlAndRes.split("\n")[1] -console.log("Migrate ctrl:", ctrl, "res:", res) -console.log(addon.chat_send_cmd(ctrl, "/v")) -// const wait = 15_000_000 -// console.log(ctrl, addon.chat_recv_msg_wait(ctrl, wait)) - -const path = "/tmp/write_file.txt" -const data = [0, 1, 2] -console.log(addon.chat_write_file(ctrl, path, new ArrayBuffer(data))) - - -fs.writeFileSync("/tmp/file_unencrypted.txt", "unencrypted") -const encRes = JSON.parse(addon.chat_encrypt_file(ctrl, "/tmp/file_unencrypted.txt", "/tmp/file_encrypted.txt")) -const key = encRes.cryptoArgs.fileKey -const nonce = encRes.cryptoArgs.fileNonce -console.log(encRes) -console.log(addon.chat_decrypt_file("/tmp/file_encrypted.txt", key, nonce, "/tmp/file_decrypted.txt")) diff --git a/packages/simplex-chat-nodejs/tests/addon.test.js b/packages/simplex-chat-nodejs/tests/addon.test.js new file mode 100644 index 0000000000..13fb458ad0 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/addon.test.js @@ -0,0 +1,101 @@ +const core = require('../src/index'); +const fs = require('fs'); +const path = require('path'); + +describe('Addon Tests', () => { + const tmpDir = './tests/tmp'; + const dbPath = path.join(tmpDir, 'simplex_v1'); + + beforeEach(() => { + fs.mkdirSync(tmpDir, {recursive: true}); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('should initialize controller', async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", "yesUp"); + expect(typeof ctrl).toBe("bigint"); + await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined); + + await expect(core.chatMigrateInit(dbPath, "wrong_key", "yesUp")).rejects.toMatchObject({ + message: 'Database or migration error (see dbMigrationError property)', + dbMigrationError: expect.objectContaining({type: 'errorNotADatabase'}) + }); + await expect(core.chatMigrateInit(dbPath, "key", "invalidMigrationMode")).rejects.toMatchObject({ + message: 'Database or migration error (see dbMigrationError property)', + dbMigrationError: expect.objectContaining({type: 'invalidConfirmation'}) + }); + }); + + it('should send command and receive event', async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", "yesUp"); + + const sendRes1 = await core.chatSendCmd(ctrl, "/v"); + expect(typeof sendRes1).toBe('object'); + expect(sendRes1).toHaveProperty('result') + + const sendRes2 = await core.chatSendCmd(ctrl, '/debug event {"type": "chatSuspended"}'); + expect(sendRes2).toMatchObject({result: {type: 'cmdOk'}}); + + const wait = 15_000_000; + const recvRes = await core.chatRecvMsgWait(ctrl, wait); + expect(recvRes).toMatchObject({result: {type: 'chatSuspended'}}); + + await core.chatCloseStore(ctrl); + }); + + it('should write/read encrypted file from/to buffer', async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", "yesUp"); + + const filePath = path.join(tmpDir, 'write_file.txt'); + const buffer = new Uint8Array([0, 1, 2]).buffer; + const encRes1 = await core.chatWriteFile(ctrl, filePath, buffer); + const key1 = encRes1.fileKey; + const nonce1 = encRes1.fileNonce; + expect(typeof key1).toBe('string'); + expect(typeof nonce1).toBe('string'); + + const buffer2 = await core.chatReadFile(filePath, key1, nonce1); + expect(Buffer.from(buffer2).equals(Buffer.from(buffer))).toBe(true); + + await expect(core.chatWriteFile(ctrl, path.join(tmpDir, 'unknown', 'unknown.txt'), buffer)).rejects.toThrow(); + await expect(core.chatReadFile(path.join(tmpDir, 'unknown.txt'), key1, nonce1)).rejects.toThrow(); + + core.chatCloseStore(ctrl); + }); + + it('should encrypt/decrypt file', async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", "yesUp"); + + const unencryptedPath = path.join(tmpDir, 'file_unencrypted.txt'); + fs.writeFileSync(unencryptedPath, "unencrypted\n"); + const encryptedPath = path.join(tmpDir, 'file_encrypted.txt'); + const encRes = await core.chatEncryptFile(ctrl, unencryptedPath, encryptedPath); + const key = encRes.fileKey; + const nonce = encRes.fileNonce; + expect(typeof key).toBe('string'); + expect(typeof nonce).toBe('string'); + + const decryptedPath = path.join(tmpDir, 'file_decrypted.txt'); + const decryptRes = await core.chatDecryptFile(encryptedPath, key, nonce, decryptedPath); + expect(decryptRes).toBeDefined(); // Assuming it returns something like 'ok' + + const decryptedContent = fs.readFileSync(decryptedPath, 'utf8'); + expect(decryptedContent).toBe("unencrypted\n"); + + await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, 'unknown.txt'), encryptedPath)).rejects.toThrow(); + await expect(core.chatDecryptFile(path.join(tmpDir, 'unknown.txt'), key, nonce, decryptedPath)).rejects.toThrow(); + + core.chatCloseStore(ctrl); + }); +}); + + +// data DBMigrationResult +// = DBMOk +// | DBMInvalidConfirmation +// | DBMErrorNotADatabase {dbFile :: String} +// | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} +// | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} \ No newline at end of file