diff --git a/packages/host/android/CMakeLists.txt b/packages/host/android/CMakeLists.txt index b913de7f..9fd1d22c 100644 --- a/packages/host/android/CMakeLists.txt +++ b/packages/host/android/CMakeLists.txt @@ -20,6 +20,8 @@ add_library(node-api-host SHARED ../cpp/Logger.cpp ../cpp/CxxNodeApiHostModule.cpp ../cpp/WeakNodeApiInjector.cpp + ../cpp/RuntimeNodeApi.cpp + ../cpp/RuntimeNodeApi.hpp ) target_include_directories(node-api-host PRIVATE diff --git a/packages/host/cpp/RuntimeNodeApi.cpp b/packages/host/cpp/RuntimeNodeApi.cpp new file mode 100644 index 00000000..dc661833 --- /dev/null +++ b/packages/host/cpp/RuntimeNodeApi.cpp @@ -0,0 +1,112 @@ +#include "RuntimeNodeApi.hpp" +#include + +auto ArrayType = napi_uint8_array; + +napi_status NAPI_CDECL callstack::nodeapihost::napi_create_buffer( + napi_env env, size_t length, void** data, napi_value* result) { + napi_value buffer; + const auto status = napi_create_arraybuffer(env, length, data, &buffer); + if (status != napi_ok) { + return status; + } + + // Warning: The returned data structure does not fully align with the + // characteristics of a Buffer. + // @see + // https://github.com/callstackincubator/react-native-node-api/issues/171 + return napi_create_typedarray(env, ArrayType, length, buffer, 0, result); +} + +napi_status NAPI_CDECL callstack::nodeapihost::napi_create_buffer_copy( + napi_env env, + size_t length, + const void* data, + void** result_data, + napi_value* result) { + if (!length || !data || !result) { + return napi_invalid_arg; + } + + void* buffer = nullptr; + if (const auto status = ::napi_create_buffer(env, length, &buffer, result); + status != napi_ok) { + return status; + } + + std::memcpy(buffer, data, length); + return napi_ok; +} + +napi_status callstack::nodeapihost::napi_is_buffer( + napi_env env, napi_value value, bool* result) { + if (!result) { + return napi_invalid_arg; + } + + if (!value) { + *result = false; + return napi_ok; + } + + napi_valuetype type{}; + if (const auto status = napi_typeof(env, value, &type); status != napi_ok) { + return status; + } + + if (type != napi_object && type != napi_external) { + *result = false; + return napi_ok; + } + + auto isArrayBuffer{false}; + if (const auto status = napi_is_arraybuffer(env, value, &isArrayBuffer); + status != napi_ok) { + return status; + } + auto isTypedArray{false}; + if (const auto status = napi_is_typedarray(env, value, &isTypedArray); + status != napi_ok) { + return status; + } + + *result = isArrayBuffer || isTypedArray; + return napi_ok; +} + +napi_status callstack::nodeapihost::napi_get_buffer_info( + napi_env env, napi_value value, void** data, size_t* length) { + if (!data || !length) { + return napi_invalid_arg; + } + *data = nullptr; + *length = 0; + if (!value) { + return napi_ok; + } + + auto isArrayBuffer{false}; + if (const auto status = napi_is_arraybuffer(env, value, &isArrayBuffer); + status == napi_ok && isArrayBuffer) { + return napi_get_arraybuffer_info(env, value, data, length); + } + + auto isTypedArray{false}; + if (const auto status = napi_is_typedarray(env, value, &isTypedArray); + status == napi_ok && isTypedArray) { + return napi_get_typedarray_info( + env, value, &ArrayType, length, data, nullptr, nullptr); + } + + return napi_ok; +} + +napi_status callstack::nodeapihost::napi_create_external_buffer(napi_env env, + size_t length, + void* data, + node_api_basic_finalize basic_finalize_cb, + void* finalize_hint, + napi_value* result) { + return napi_create_external_arraybuffer( + env, data, length, basic_finalize_cb, finalize_hint, result); +} diff --git a/packages/host/cpp/RuntimeNodeApi.hpp b/packages/host/cpp/RuntimeNodeApi.hpp new file mode 100644 index 00000000..e64b03fa --- /dev/null +++ b/packages/host/cpp/RuntimeNodeApi.hpp @@ -0,0 +1,25 @@ +#include "node_api.h" + +namespace callstack::nodeapihost { +napi_status napi_create_buffer( + napi_env env, size_t length, void** data, napi_value* result); + +napi_status napi_create_buffer_copy(napi_env env, + size_t length, + const void* data, + void** result_data, + napi_value* result); + +napi_status napi_is_buffer(napi_env env, napi_value value, bool* result); + +napi_status napi_get_buffer_info( + napi_env env, napi_value value, void** data, size_t* length); + +napi_status napi_create_external_buffer(napi_env env, + size_t length, + void* data, + node_api_basic_finalize basic_finalize_cb, + void* finalize_hint, + napi_value* result); + +} // namespace callstack::nodeapihost diff --git a/packages/host/scripts/generate-weak-node-api-injector.ts b/packages/host/scripts/generate-weak-node-api-injector.ts index ff73b0ed..71e66893 100644 --- a/packages/host/scripts/generate-weak-node-api-injector.ts +++ b/packages/host/scripts/generate-weak-node-api-injector.ts @@ -6,6 +6,15 @@ import { FunctionDecl, getNodeApiFunctions } from "./node-api-functions"; export const CPP_SOURCE_PATH = path.join(__dirname, "../cpp"); +// TODO: Remove when all runtime Node API functions are implemented +const IMPLEMENTED_RUNTIME_FUNCTIONS = [ + "napi_create_buffer", + "napi_create_buffer_copy", + "napi_is_buffer", + "napi_get_buffer_info", + "napi_create_external_buffer", +]; + /** * Generates source code which injects the Node API functions from the host. */ @@ -15,7 +24,8 @@ export function generateSource(functions: FunctionDecl[]) { #include #include #include - + #include + #if defined(__APPLE__) #define WEAK_NODE_API_LIBRARY_NAME "@rpath/weak-node-api.framework/weak-node-api" #elif defined(__ANDROID__) @@ -43,7 +53,10 @@ export function generateSource(functions: FunctionDecl[]) { log_debug("Injecting WeakNodeApiHost"); inject_weak_node_api_host(WeakNodeApiHost { ${functions - .filter(({ kind }) => kind === "engine") + .filter( + ({ kind, name }) => + kind === "engine" || IMPLEMENTED_RUNTIME_FUNCTIONS.includes(name) + ) .flatMap(({ name }) => `.${name} = ${name},`) .join("\n")} }); diff --git a/packages/node-addon-examples/index.js b/packages/node-addon-examples/index.js index 88470b76..8d5faecf 100644 --- a/packages/node-addon-examples/index.js +++ b/packages/node-addon-examples/index.js @@ -14,5 +14,8 @@ module.exports = { "5-async-work": { // TODO: This crashes (SIGABRT) // "async_work_thread_safe_function": () => require("./examples/5-async-work/async_work_thread_safe_function/napi/index.js"), - } + }, + "tests": { + "buffers": () => require("./tests/buffers/addon.js"), + }, }; diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index 835485c9..8960d63d 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -10,7 +10,7 @@ }, "scripts": { "copy-examples": "tsx scripts/copy-examples.mts", - "gyp-to-cmake": "gyp-to-cmake ./examples", + "gyp-to-cmake": "gyp-to-cmake .", "build": "tsx scripts/build-examples.mts", "copy-and-build": "npm run copy-examples && npm run gyp-to-cmake && npm run build", "verify": "tsx scripts/verify-prebuilds.mts", diff --git a/packages/node-addon-examples/scripts/cmake-projects.mts b/packages/node-addon-examples/scripts/cmake-projects.mts index a9d605f3..ede02e2c 100644 --- a/packages/node-addon-examples/scripts/cmake-projects.mts +++ b/packages/node-addon-examples/scripts/cmake-projects.mts @@ -2,15 +2,17 @@ import { readdirSync, statSync } from "node:fs"; import path from "node:path"; export const EXAMPLES_DIR = path.resolve(import.meta.dirname, "../examples"); +export const TESTS_DIR = path.resolve(import.meta.dirname, "../tests"); +export const DIRS = [EXAMPLES_DIR, TESTS_DIR]; -export function findCMakeProjects(dir = EXAMPLES_DIR): string[] { +export function findCMakeProjectsRecursively(dir): string[] { let results: string[] = []; const files = readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); if (statSync(fullPath).isDirectory()) { - results = results.concat(findCMakeProjects(fullPath)); + results = results.concat(findCMakeProjectsRecursively(fullPath)); } else if (file === "CMakeLists.txt") { results.push(dir); } @@ -18,3 +20,7 @@ export function findCMakeProjects(dir = EXAMPLES_DIR): string[] { return results; } + +export function findCMakeProjects(): string[] { + return DIRS.flatMap(findCMakeProjectsRecursively); +} diff --git a/packages/node-addon-examples/tests/.gitignore b/packages/node-addon-examples/tests/.gitignore new file mode 100644 index 00000000..378eac25 --- /dev/null +++ b/packages/node-addon-examples/tests/.gitignore @@ -0,0 +1 @@ +build diff --git a/packages/node-addon-examples/tests/buffers/CMakeLists.txt b/packages/node-addon-examples/tests/buffers/CMakeLists.txt new file mode 100644 index 00000000..7de25130 --- /dev/null +++ b/packages/node-addon-examples/tests/buffers/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.15) +project(tests-buffers) + +add_compile_definitions(NAPI_VERSION=8) + +add_library(addon SHARED addon.c ${CMAKE_JS_SRC}) +set_target_properties(addon PROPERTIES PREFIX "" SUFFIX ".node") +target_include_directories(addon PRIVATE ${CMAKE_JS_INC}) +target_link_libraries(addon PRIVATE ${CMAKE_JS_LIB}) +target_compile_features(addon PRIVATE cxx_std_17) + +if(MSVC AND CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET) + # Generate node.lib + execute_process(COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS}) +endif() diff --git a/packages/node-addon-examples/tests/buffers/addon.c b/packages/node-addon-examples/tests/buffers/addon.c new file mode 100644 index 00000000..2f83f681 --- /dev/null +++ b/packages/node-addon-examples/tests/buffers/addon.c @@ -0,0 +1,281 @@ +#include +#include +#include +#include + +#define NODE_API_RETVAL_NOTHING // Intentionally blank #define + +#define GET_AND_THROW_LAST_ERROR(env) \ + do { \ + const napi_extended_error_info* error_info; \ + napi_get_last_error_info((env), &error_info); \ + bool is_pending; \ + const char* err_message = error_info->error_message; \ + napi_is_exception_pending((env), &is_pending); \ + /* If an exception is already pending, don't rethrow it */ \ + if (!is_pending) { \ + const char* error_message = \ + err_message != NULL ? err_message : "empty error message"; \ + napi_throw_error((env), NULL, error_message); \ + } \ + } while (0) + +// The basic version of GET_AND_THROW_LAST_ERROR. We cannot access any +// exceptions and we cannot fail by way of JS exception, so we abort. +#define FATALLY_FAIL_WITH_LAST_ERROR(env) \ + do { \ + const napi_extended_error_info* error_info; \ + napi_get_last_error_info((env), &error_info); \ + const char* err_message = error_info->error_message; \ + const char* error_message = \ + err_message != NULL ? err_message : "empty error message"; \ + fprintf(stderr, "%s\n", error_message); \ + abort(); \ + } while (0) + +#define NODE_API_ASSERT_BASE(env, assertion, message, ret_val) \ + do { \ + if (!(assertion)) { \ + napi_throw_error( \ + (env), NULL, "assertion (" #assertion ") failed: " message); \ + return ret_val; \ + } \ + } while (0) + +#define NODE_API_BASIC_ASSERT_BASE(assertion, message, ret_val) \ + do { \ + if (!(assertion)) { \ + fprintf(stderr, "assertion (" #assertion ") failed: " message); \ + abort(); \ + return ret_val; \ + } \ + } while (0) + +// Returns NULL on failed assertion. +// This is meant to be used inside napi_callback methods. +#define NODE_API_ASSERT(env, assertion, message) \ + NODE_API_ASSERT_BASE(env, assertion, message, NULL) + +// Returns empty on failed assertion. +// This is meant to be used inside functions with void return type. +#define NODE_API_ASSERT_RETURN_VOID(env, assertion, message) \ + NODE_API_ASSERT_BASE(env, assertion, message, NODE_API_RETVAL_NOTHING) + +#define NODE_API_BASIC_ASSERT_RETURN_VOID(assertion, message) \ + NODE_API_BASIC_ASSERT_BASE(assertion, message, NODE_API_RETVAL_NOTHING) + +#define NODE_API_CALL_BASE(env, the_call, ret_val) \ + do { \ + if ((the_call) != napi_ok) { \ + GET_AND_THROW_LAST_ERROR((env)); \ + return ret_val; \ + } \ + } while (0) + +#define NODE_API_BASIC_CALL_BASE(env, the_call, ret_val) \ + do { \ + if ((the_call) != napi_ok) { \ + FATALLY_FAIL_WITH_LAST_ERROR((env)); \ + return ret_val; \ + } \ + } while (0) + +// Returns NULL if the_call doesn't return napi_ok. +#define NODE_API_CALL(env, the_call) NODE_API_CALL_BASE(env, the_call, NULL) + +// Returns empty if the_call doesn't return napi_ok. +#define NODE_API_CALL_RETURN_VOID(env, the_call) \ + NODE_API_CALL_BASE(env, the_call, NODE_API_RETVAL_NOTHING) + +#define NODE_API_BASIC_CALL_RETURN_VOID(env, the_call) \ + NODE_API_BASIC_CALL_BASE(env, the_call, NODE_API_RETVAL_NOTHING) + +#define NODE_API_CHECK_STATUS(the_call) \ + do { \ + napi_status status = (the_call); \ + if (status != napi_ok) { \ + return status; \ + } \ + } while (0) + +#define NODE_API_ASSERT_STATUS(env, assertion, message) \ + NODE_API_ASSERT_BASE(env, assertion, message, napi_generic_failure) + +#define DECLARE_NODE_API_PROPERTY(name, func) \ + {(name), NULL, (func), NULL, NULL, NULL, napi_default, NULL} + +#define DECLARE_NODE_API_GETTER(name, func) \ + {(name), NULL, NULL, (func), NULL, NULL, napi_default, NULL} + +#define DECLARE_NODE_API_PROPERTY_VALUE(name, value) \ + {(name), NULL, NULL, NULL, NULL, (value), napi_default, NULL} + +static inline void add_returned_status(napi_env env, + const char* key, + napi_value object, + char* expected_message, + napi_status expected_status, + napi_status actual_status); + +static inline void add_last_status( + napi_env env, const char* key, napi_value return_value); + +static const char theText[] = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; + +static int deleterCallCount = 0; + +static void deleteTheText( + node_api_basic_env env, void* data, void* finalize_hint) { + NODE_API_BASIC_ASSERT_RETURN_VOID( + data != NULL && strcmp(data, theText) == 0, "invalid data"); + + (void)finalize_hint; + free(data); + deleterCallCount++; +} + +static void noopDeleter( + node_api_basic_env env, void* data, void* finalize_hint) { + NODE_API_BASIC_ASSERT_RETURN_VOID( + data != NULL && strcmp(data, theText) == 0, "invalid data"); + (void)finalize_hint; + deleterCallCount++; +} + +static napi_value newBuffer(napi_env env, napi_callback_info info) { + napi_value theBuffer; + char* theCopy; + const unsigned int kBufferSize = sizeof(theText); + + NODE_API_CALL(env, + napi_create_buffer(env, sizeof(theText), (void**)(&theCopy), &theBuffer)); + NODE_API_ASSERT(env, theCopy, "Failed to copy static text for newBuffer"); + memcpy(theCopy, theText, kBufferSize); + + return theBuffer; +} + +static napi_value newExternalBuffer(napi_env env, napi_callback_info info) { + napi_value theBuffer; + char* theCopy = strdup(theText); + NODE_API_ASSERT( + env, theCopy, "Failed to copy static text for newExternalBuffer"); + NODE_API_CALL(env, + napi_create_external_buffer(env, + sizeof(theText), + theCopy, + deleteTheText, + NULL /* finalize_hint */, + &theBuffer)); + + return theBuffer; +} + +static napi_value getDeleterCallCount(napi_env env, napi_callback_info info) { + napi_value callCount; + NODE_API_CALL(env, napi_create_int32(env, deleterCallCount, &callCount)); + return callCount; +} + +static napi_value copyBuffer(napi_env env, napi_callback_info info) { + napi_value theBuffer; + NODE_API_CALL(env, + napi_create_buffer_copy(env, sizeof(theText), theText, NULL, &theBuffer)); + return theBuffer; +} + +static napi_value bufferHasInstance(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + napi_value theBuffer = args[0]; + bool hasInstance; + napi_valuetype theType; + NODE_API_CALL(env, napi_typeof(env, theBuffer, &theType)); + NODE_API_ASSERT(env, + theType == napi_object, + "bufferHasInstance: instance is not an object"); + NODE_API_CALL(env, napi_is_buffer(env, theBuffer, &hasInstance)); + NODE_API_ASSERT( + env, hasInstance, "bufferHasInstance: instance is not a buffer"); + napi_value returnValue; + NODE_API_CALL(env, napi_get_boolean(env, hasInstance, &returnValue)); + return returnValue; +} + +static napi_value bufferInfo(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + napi_value theBuffer = args[0]; + char* bufferData; + napi_value returnValue; + size_t bufferLength; + NODE_API_CALL(env, + napi_get_buffer_info( + env, theBuffer, (void**)(&bufferData), &bufferLength)); + NODE_API_CALL(env, + napi_get_boolean(env, + !strcmp(bufferData, theText) && bufferLength == sizeof(theText), + &returnValue)); + return returnValue; +} + +static napi_value staticBuffer(napi_env env, napi_callback_info info) { + napi_value theBuffer; + NODE_API_CALL(env, + napi_create_external_buffer(env, + sizeof(theText), + (void*)theText, + noopDeleter, + NULL /* finalize_hint */, + &theBuffer)); + return theBuffer; +} + +static napi_value invalidObjectAsBuffer(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + + napi_value notTheBuffer = args[0]; + napi_status status = napi_get_buffer_info(env, notTheBuffer, NULL, NULL); + NODE_API_ASSERT(env, + status == napi_invalid_arg, + "napi_get_buffer_info: should fail with napi_invalid_arg " + "when passed non buffer"); + + return notTheBuffer; +} + +static napi_value Init(napi_env env, napi_value exports) { + napi_value theValue; + + NODE_API_CALL( + env, napi_create_string_utf8(env, theText, sizeof(theText), &theValue)); + NODE_API_CALL( + env, napi_set_named_property(env, exports, "theText", theValue)); + + napi_property_descriptor methods[] = { + DECLARE_NODE_API_PROPERTY("newBuffer", newBuffer), + DECLARE_NODE_API_PROPERTY("newExternalBuffer", newExternalBuffer), + DECLARE_NODE_API_PROPERTY("getDeleterCallCount", getDeleterCallCount), + DECLARE_NODE_API_PROPERTY("copyBuffer", copyBuffer), + DECLARE_NODE_API_PROPERTY("bufferHasInstance", bufferHasInstance), + DECLARE_NODE_API_PROPERTY("bufferInfo", bufferInfo), + DECLARE_NODE_API_PROPERTY("staticBuffer", staticBuffer), + DECLARE_NODE_API_PROPERTY("invalidObjectAsBuffer", invalidObjectAsBuffer), + }; + + NODE_API_CALL(env, + napi_define_properties( + env, exports, sizeof(methods) / sizeof(methods[0]), methods)); + + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/packages/node-addon-examples/tests/buffers/addon.js b/packages/node-addon-examples/tests/buffers/addon.js new file mode 100644 index 00000000..2e73501f --- /dev/null +++ b/packages/node-addon-examples/tests/buffers/addon.js @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable no-undef */ +const addon = require("bindings")("addon.node"); + +const toLocaleString = (text) => { + return text + .toLocaleString() + .split(",") + .map((code) => String.fromCharCode(parseInt(code, 10))) + .join(""); +}; + +console.log(addon.newBuffer().toLocaleString(), addon.theText); +console.log(toLocaleString(addon.newExternalBuffer()), addon.theText); +console.log(addon.copyBuffer(), addon.theText); +let buffer = addon.staticBuffer(); +console.log(addon.bufferHasInstance(buffer), true); +console.log(addon.bufferInfo(buffer), true); +addon.invalidObjectAsBuffer({}); diff --git a/packages/node-addon-examples/tests/buffers/binding.gyp b/packages/node-addon-examples/tests/buffers/binding.gyp new file mode 100644 index 00000000..80f9fa87 --- /dev/null +++ b/packages/node-addon-examples/tests/buffers/binding.gyp @@ -0,0 +1,8 @@ +{ + "targets": [ + { + "target_name": "addon", + "sources": [ "addon.c" ] + } + ] +} diff --git a/packages/node-addon-examples/tests/buffers/package.json b/packages/node-addon-examples/tests/buffers/package.json new file mode 100644 index 00000000..d03151c7 --- /dev/null +++ b/packages/node-addon-examples/tests/buffers/package.json @@ -0,0 +1,14 @@ +{ + "name": "buffers-test", + "version": "0.0.0", + "description": "Tests of runtime buffer functions", + "main": "addon.js", + "private": true, + "dependencies": { + "bindings": "~1.5.0" + }, + "scripts": { + "test": "node --expose-gc addon.js" + }, + "gypfile": true +}