nodejs: add crypto APIs and relocatable npm prebuilds

This commit is contained in:
Adrien Beraud committed 2026-10-26 23:57:50 -04:00
1 parent 3223742d70
commit a040bfeebb
13 files changed
+703 -30

No files matched your search

+3 -1
View File
@@ -1,2 +1,4 @@
node_modules/
build/
build*/
prebuilds/
dist/
+1 -1
View File
@@ -20,7 +20,7 @@ execute_process(
COMMAND_ERROR_IS_FATAL ANY
)
add_library(opendht_node MODULE addon.cpp ${CMAKE_JS_SRC})
add_library(opendht_node MODULE addon.cpp crypto.cpp ${CMAKE_JS_SRC})
target_compile_definitions(opendht_node PRIVATE NAPI_VERSION=8 NAPI_CPP_EXCEPTIONS)
target_include_directories(opendht_node PRIVATE
${CMAKE_JS_INC}
+44 -23
View File
@@ -1,5 +1,6 @@
#include <napi.h>
#include <opendht.h>
#include "crypto.h"
#include <algorithm>
#include <atomic>
#include <chrono>
@@ -14,18 +15,7 @@ namespace {
constexpr size_t maxQueuedEvents = 1024;
constexpr size_t maxResultBytes = 16 * 1024 * 1024;
template<typename Callback>
Napi::Value
guarded(const Napi::CallbackInfo& info, Callback&& callback)
{
try {
return callback();
} catch (const Napi::Error&) {
throw;
} catch (const std::exception& error) {
throw Napi::Error::New(info.Env(), error.what());
}
}
using opendht_node::guarded;
uint32_t
unsignedNumber(Napi::Value value, uint32_t maximum, const char* name)
@@ -88,6 +78,9 @@ struct ValueData
, seq(value.seq)
, data(value.data)
, userType(value.user_type)
, owner(value.owner ? value.owner->getLongId().toString() : "")
, isSigned(value.isSigned())
, isEncrypted(value.isEncrypted())
{}
Napi::Object toJS(Napi::Env env) const
@@ -98,16 +91,22 @@ struct ValueData
result.Set("seq", Napi::Number::New(env, seq));
result.Set("data", Napi::Buffer<uint8_t>::Copy(env, data.data(), data.size()));
result.Set("userType", Napi::String::New(env, userType));
result.Set("owner", owner.empty() ? Napi::Value(env.Null()) : Napi::Value(Napi::String::New(env, owner)));
result.Set("isSigned", Napi::Boolean::New(env, isSigned));
result.Set("isEncrypted", Napi::Boolean::New(env, isEncrypted));
return result;
}
size_t size() const { return sizeof(ValueData) + data.size() + userType.size(); }
size_t size() const { return sizeof(ValueData) + data.size() + userType.size() + owner.size(); }
uint64_t id;
uint16_t type;
uint16_t seq;
dht::Blob data;
std::string userType;
std::string owner;
bool isSigned;
bool isEncrypted;
};
struct Event
@@ -399,6 +398,7 @@ struct RunnerState
std::atomic_size_t queuedBytes {0};
bool envClosing = false;
bool started = false;
bool hasIdentity = false;
bool finished = false;
uint64_t nextOperation = 1;
std::string closeError;
@@ -436,6 +436,8 @@ public:
InstanceMethod("bootstrap", &Runner::bootstrap),
InstanceMethod("get", &Runner::get),
InstanceMethod("put", &Runner::put),
InstanceMethod("putSigned", &Runner::putSigned),
InstanceMethod("putEncrypted", &Runner::putEncrypted),
InstanceMethod("listen", &Runner::listen),
InstanceMethod("cancelListen", &Runner::cancelListen),
InstanceMethod("ref", &Runner::ref),
@@ -462,9 +464,12 @@ private:
config.dht_config.node_config.network = unsignedNumber(options.Get("network"), UINT32_MAX, "network");
if (options.Has("persistPath"))
config.dht_config.node_config.persist_path = text(options.Get("persistPath"), "persistPath");
if (options.Has("identity"))
config.dht_config.id = opendht_node::readIdentity(options.Get("identity"));
const auto service = std::to_string(port);
state_->runner.run(ipv4.c_str(), ipv6.c_str(), service.c_str(), config);
state_->started = true;
state_->hasIdentity = bool(config.dht_config.id.first);
state_->dispatcher.Ref(info.Env());
return info.Env().Undefined();
});
@@ -560,10 +565,24 @@ private:
});
}
Napi::Value put(const Napi::CallbackInfo& info)
Napi::Value put(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Plain); }
Napi::Value putSigned(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Signed); }
Napi::Value putEncrypted(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Encrypted); }
enum class PutMode { Plain, Signed, Encrypted };
Napi::Value putValue(const Napi::CallbackInfo& info, PutMode mode)
{
return guarded(info, [&]() -> Napi::Value {
state_->requireRunning();
if (mode != PutMode::Plain && !state_->hasIdentity)
throw Napi::Error::New(info.Env(), "Signed and encrypted puts require an identity");
std::shared_ptr<dht::crypto::PublicKey> recipient;
if (mode == PutMode::Encrypted) {
recipient = opendht_node::readEncryptionKey(info[3]);
}
const auto key = infoHash(info[0]);
if (!info[1].IsTypedArray() || info[1].As<Napi::TypedArray>().TypedArrayType() != napi_uint8_array)
throw Napi::TypeError::New(info.Env(), "data must be a Uint8Array");
@@ -594,15 +613,16 @@ private:
state_->operations.emplace(operation, PendingOperation {deferred, false, {}});
const std::weak_ptr<RunnerState> state = state_;
try {
state_->runner.put(key,
value,
dht::DoneCallbackSimple([state, operation](bool ok) {
if (const auto owner = state.lock())
owner->send(
std::make_unique<Event>(Event {Event::Kind::Done, operation, ok, {}}));
}),
dht::time_point::max(),
permanent);
dht::DoneCallbackSimple done = [state, operation](bool ok) {
if (const auto owner = state.lock())
owner->send(std::make_unique<Event>(Event {Event::Kind::Done, operation, ok, {}}));
};
if (mode == PutMode::Signed)
state_->runner.putSigned(key, value, std::move(done), permanent);
else if (mode == PutMode::Encrypted)
state_->runner.putEncrypted(key, recipient, value, std::move(done), permanent);
else
state_->runner.put(key, value, std::move(done), dht::time_point::max(), permanent);
} catch (...) {
state_->operations.erase(operation);
throw;
@@ -706,6 +726,7 @@ hash(const Napi::CallbackInfo& info)
Napi::Object
initialize(Napi::Env env, Napi::Object exports)
{
opendht_node::initializeCrypto(env, exports);
exports.Set("NativeRunner", Runner::define(env));
exports.Set("hash", Napi::Function::New(env, hash));
exports.Set("version", Napi::String::New(env, dht::version()));
+306
View File
@@ -0,0 +1,306 @@
#include "crypto.h"
#include <algorithm>
#include <cmath>
#include <variant>
namespace opendht_node {
namespace {
using PublicKey = std::shared_ptr<dht::crypto::PublicKey>;
using PrivateKey = std::shared_ptr<dht::crypto::PrivateKey>;
using Certificate = std::shared_ptr<dht::crypto::Certificate>;
using Identity = dht::crypto::Identity;
using Handle = std::variant<PublicKey, PrivateKey, Certificate, Identity>;
constexpr napi_type_tag cryptoTag {0x37cdf3e4648e4cb5ULL, 0x964fb031062c245aULL};
Handle&
handle(Napi::Value value)
{
bool matches = false;
if (!value.IsExternal() || napi_check_object_type_tag(value.Env(), value, &cryptoTag, &matches) != napi_ok
|| !matches)
throw Napi::TypeError::New(value.Env(), "Invalid OpenDHT crypto handle");
return *value.As<Napi::External<Handle>>().Data();
}
template<typename Type>
Type
read(Napi::Value value)
{
if (auto result = std::get_if<Type>(&handle(value)))
return *result;
throw Napi::TypeError::New(value.Env(), "Wrong OpenDHT crypto handle type");
}
Napi::Value
wrap(Napi::Env env, Handle value)
{
auto owner = std::make_unique<Handle>(std::move(value));
auto result = Napi::External<Handle>::New(env, owner.get(), [](Napi::Env, Handle* data) { delete data; });
owner.release();
if (napi_type_tag_object(env, result, &cryptoTag) != napi_ok)
throw Napi::Error::New(env, "Could not tag OpenDHT crypto handle");
return result;
}
dht::Blob
bytes(Napi::Value value)
{
if (!value.IsTypedArray() || value.As<Napi::TypedArray>().TypedArrayType() != napi_uint8_array)
throw Napi::TypeError::New(value.Env(), "Expected a Uint8Array");
auto array = value.As<Napi::Uint8Array>();
if (array.ElementLength() > 16 * 1024 * 1024)
throw Napi::RangeError::New(value.Env(), "Crypto input exceeds 16 MiB");
dht::Blob result(array.ElementLength());
if (!result.empty())
std::copy_n(array.Data(), result.size(), result.data());
return result;
}
std::string
string(Napi::Value value)
{
if (!value.IsString())
throw Napi::TypeError::New(value.Env(), "Expected a string");
auto result = value.As<Napi::String>().Utf8Value();
if (result.find('\0') != std::string::npos)
throw Napi::TypeError::New(value.Env(), "Crypto strings must not contain NUL bytes");
return result;
}
Napi::Buffer<uint8_t>
buffer(Napi::Env env, const dht::Blob& value)
{
return Napi::Buffer<uint8_t>::Copy(env, value.data(), value.size());
}
PublicKey
publicKey(const Handle& value)
{
if (auto key = std::get_if<PublicKey>(&value))
return *key;
if (auto key = std::get_if<PrivateKey>(&value))
return (*key)->getSharedPublicKey();
if (auto certificate = std::get_if<Certificate>(&value))
return (*certificate)->getSharedPublicKey();
return std::get<Identity>(value).second->getSharedPublicKey();
}
class GenerateWorker : public Napi::AsyncWorker
{
public:
GenerateWorker(Napi::Env env, bool identity, std::string name, bool rsa, unsigned bits)
: Napi::AsyncWorker(env, "OpenDHT crypto generation")
, deferred_(Napi::Promise::Deferred::New(env))
, identity_(identity)
, name_(std::move(name))
, rsa_(rsa)
, bits_(bits)
{}
Napi::Promise promise() const { return deferred_.Promise(); }
void Execute() override
{
try {
if (identity_)
result_ = rsa_ ? dht::crypto::generateIdentity(name_, {}, bits_)
: dht::crypto::generateEcIdentity(name_);
else
result_ = std::make_shared<dht::crypto::PrivateKey>(rsa_ ? dht::crypto::PrivateKey::generate(bits_)
: dht::crypto::PrivateKey::generateEC());
} catch (const std::exception& error) {
SetError(error.what());
}
}
void OnOK() override { deferred_.Resolve(wrap(Env(), std::move(result_))); }
void OnError(const Napi::Error& error) override { deferred_.Reject(error.Value()); }
private:
Napi::Promise::Deferred deferred_;
bool identity_;
std::string name_;
bool rsa_;
unsigned bits_;
Handle result_;
};
Napi::Value
generate(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
const auto kind = string(info[0]);
const auto name = string(info[1]);
const auto algorithm = string(info[2]);
if (algorithm != "rsa" && algorithm != "ec")
throw Napi::TypeError::New(info.Env(), "algorithm must be rsa or ec");
if (!info[3].IsNumber())
throw Napi::TypeError::New(info.Env(), "bits must be a number");
const auto bits = info[3].As<Napi::Number>().DoubleValue();
if (!std::isfinite(bits) || std::floor(bits) != bits || bits < 2048 || bits > 8192)
throw Napi::RangeError::New(info.Env(), "RSA bits must be an integer between 2048 and 8192");
if (kind != "private" && kind != "identity")
throw Napi::TypeError::New(info.Env(), "Invalid crypto generation type");
auto worker = std::make_unique<GenerateWorker>(info.Env(), kind == "identity", name, algorithm == "rsa", bits);
auto promise = worker->promise();
worker->Queue();
worker.release();
return promise;
});
}
Napi::Value
import(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
const auto kind = string(info[0]);
auto data = bytes(info[1]);
if (data.empty())
throw Napi::TypeError::New(info.Env(), "Crypto imports must not be empty");
if (kind == "private")
return wrap(info.Env(), std::make_shared<dht::crypto::PrivateKey>(data, string(info[2])));
if (kind == "public")
return wrap(info.Env(), std::make_shared<dht::crypto::PublicKey>(data));
if (kind == "certificate")
return wrap(info.Env(), std::make_shared<dht::crypto::Certificate>(data));
throw Napi::TypeError::New(info.Env(), "Invalid crypto import type");
});
}
Napi::Value
serialize(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
auto& value = handle(info[0]);
if (auto key = std::get_if<PrivateKey>(&value))
return buffer(info.Env(), (*key)->serialize(string(info[1])));
if (auto certificate = std::get_if<Certificate>(&value))
return buffer(info.Env(), (*certificate)->getPacked());
dht::Blob result;
readPublicKey(info[0])->pack(result);
return buffer(info.Env(), result);
});
}
Napi::Value
getPublicKey(const Napi::CallbackInfo& info)
{
return guarded(info, [&] { return wrap(info.Env(), publicKey(handle(info[0]))); });
}
Napi::Value
getId(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
const auto key = publicKey(handle(info[0]));
if (!info[1].IsBoolean())
throw Napi::TypeError::New(info.Env(), "Expected a boolean");
return Napi::String::New(info.Env(),
info[1].As<Napi::Boolean>().Value() ? key->getLongId().toString()
: key->getId().toString());
});
}
Napi::Value
sign(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
return buffer(info.Env(), read<PrivateKey>(info[0])->sign(bytes(info[1])));
});
}
Napi::Value
verify(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
return Napi::Boolean::New(info.Env(), readPublicKey(info[0])->checkSignature(bytes(info[1]), bytes(info[2])));
});
}
Napi::Value
encrypt(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
const auto key = readEncryptionKey(info[0]);
return buffer(info.Env(), key->encrypt(bytes(info[1])));
});
}
Napi::Value
decrypt(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
return buffer(info.Env(), read<PrivateKey>(info[0])->decrypt(bytes(info[1])));
});
}
Napi::Value
identity(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
auto key = read<PrivateKey>(info[0]);
auto certificate = read<Certificate>(info[1]);
if (key->getPublicKey().getLongId() != certificate->getLongId())
throw Napi::TypeError::New(info.Env(), "Identity key and certificate do not match");
return wrap(info.Env(), Identity {std::move(key), std::move(certificate)});
});
}
Napi::Value
identityPart(const Napi::CallbackInfo& info)
{
return guarded(info, [&]() -> Napi::Value {
const auto value = readIdentity(info[0]);
if (!info[1].IsBoolean())
throw Napi::TypeError::New(info.Env(), "Expected a boolean");
return info[1].As<Napi::Boolean>().Value() ? wrap(info.Env(), value.first) : wrap(info.Env(), value.second);
});
}
} // namespace
dht::crypto::Identity
readIdentity(Napi::Value value)
{
return read<Identity>(value);
}
std::shared_ptr<dht::crypto::PublicKey>
readPublicKey(Napi::Value value)
{
return read<PublicKey>(value);
}
std::shared_ptr<dht::crypto::PublicKey>
readEncryptionKey(Napi::Value value)
{
const auto key = readPublicKey(value);
const auto algorithm = gnutls_pubkey_get_pk_algorithm(key->pk, nullptr);
if (algorithm != GNUTLS_PK_RSA
#if GNUTLS_VERSION_NUMBER >= 0x030804
&& algorithm != GNUTLS_PK_RSA_OAEP
#endif
)
throw Napi::TypeError::New(value.Env(), "Encryption requires an RSA public key");
return key;
}
void
initializeCrypto(Napi::Env env, Napi::Object exports)
{
exports.Set("cryptoGenerate", Napi::Function::New(env, generate));
exports.Set("cryptoImport", Napi::Function::New(env, import));
exports.Set("cryptoSerialize", Napi::Function::New(env, serialize));
exports.Set("cryptoPublicKey", Napi::Function::New(env, getPublicKey));
exports.Set("cryptoId", Napi::Function::New(env, getId));
exports.Set("cryptoSign", Napi::Function::New(env, sign));
exports.Set("cryptoVerify", Napi::Function::New(env, verify));
exports.Set("cryptoEncrypt", Napi::Function::New(env, encrypt));
exports.Set("cryptoDecrypt", Napi::Function::New(env, decrypt));
exports.Set("cryptoIdentity", Napi::Function::New(env, identity));
exports.Set("cryptoIdentityPart", Napi::Function::New(env, identityPart));
}
} // namespace opendht_node
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <napi.h>
#include <opendht/crypto.h>
namespace opendht_node {
template<typename Callback>
Napi::Value
guarded(const Napi::CallbackInfo& info, Callback&& callback)
{
try {
return callback();
} catch (const Napi::Error&) {
throw;
} catch (const std::exception& error) {
throw Napi::Error::New(info.Env(), error.what());
}
}
dht::crypto::Identity readIdentity(Napi::Value value);
std::shared_ptr<dht::crypto::PublicKey> readPublicKey(Napi::Value value);
std::shared_ptr<dht::crypto::PublicKey> readEncryptionKey(Napi::Value value);
void initializeCrypto(Napi::Env env, Napi::Object exports);
} // namespace opendht_node
+58
View File
@@ -0,0 +1,58 @@
'use strict';
module.exports = native => {
const handles = new WeakMap();
function handle(value, Type) {
if (!(value instanceof Type) || !handles.has(value)) throw new TypeError(`Expected ${Type.name}`);
return handles.get(value);
}
function wrap(Type, nativeHandle) {
const result = Object.create(Type.prototype);
handles.set(result, nativeHandle);
return result;
}
function generate(kind, name, options = {}) {
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Invalid crypto options');
return native.cryptoGenerate(kind, name, options.algorithm ?? 'ec', options.bits ?? 2048);
}
class PublicKey {
constructor(data) { handles.set(this, native.cryptoImport('public', data)); }
serialize() { return native.cryptoSerialize(handle(this, PublicKey)); }
getId() { return native.cryptoId(handle(this, PublicKey), false); }
getLongId() { return native.cryptoId(handle(this, PublicKey), true); }
checkSignature(data, signature) { return native.cryptoVerify(handle(this, PublicKey), data, signature); }
encrypt(data) { return native.cryptoEncrypt(handle(this, PublicKey), data); }
}
class PrivateKey {
constructor(data, password = '') { handles.set(this, native.cryptoImport('private', data, password)); }
static generate(options) { return generate('private', '', options).then(value => wrap(PrivateKey, value)); }
serialize(password = '') { return native.cryptoSerialize(handle(this, PrivateKey), password); }
getPublicKey() { return wrap(PublicKey, native.cryptoPublicKey(handle(this, PrivateKey))); }
sign(data) { return native.cryptoSign(handle(this, PrivateKey), data); }
decrypt(data) { return native.cryptoDecrypt(handle(this, PrivateKey), data); }
}
class Certificate {
constructor(data) { handles.set(this, native.cryptoImport('certificate', data)); }
serialize() { return native.cryptoSerialize(handle(this, Certificate)); }
getPublicKey() { return wrap(PublicKey, native.cryptoPublicKey(handle(this, Certificate))); }
getId() { return native.cryptoId(handle(this, Certificate), false); }
getLongId() { return native.cryptoId(handle(this, Certificate), true); }
}
class Identity {
constructor(privateKey, certificate) {
handles.set(this, native.cryptoIdentity(handle(privateKey, PrivateKey), handle(certificate, Certificate)));
}
static generate(name = 'dhtnode', options) { return generate('identity', name, options).then(value => wrap(Identity, value)); }
get privateKey() { return wrap(PrivateKey, native.cryptoIdentityPart(handle(this, Identity), true)); }
get certificate() { return wrap(Certificate, native.cryptoIdentityPart(handle(this, Identity), false)); }
}
return { PublicKey, PrivateKey, Certificate, Identity, handle };
};
+44
View File
@@ -4,12 +4,51 @@ import { EventEmitter } from 'node:events';
export const version: string;
export function hash(data: string | Uint8Array): string;
export interface KeyOptions {
algorithm?: 'ec' | 'rsa';
bits?: number;
}
export class PublicKey {
constructor(data: Uint8Array);
serialize(): Buffer;
getId(): string;
getLongId(): string;
checkSignature(data: Uint8Array, signature: Uint8Array): boolean;
encrypt(data: Uint8Array): Buffer;
}
export class PrivateKey {
constructor(data: Uint8Array, password?: string);
static generate(options?: KeyOptions): Promise<PrivateKey>;
serialize(password?: string): Buffer;
getPublicKey(): PublicKey;
sign(data: Uint8Array): Buffer;
decrypt(data: Uint8Array): Buffer;
}
export class Certificate {
constructor(data: Uint8Array);
serialize(): Buffer;
getPublicKey(): PublicKey;
getId(): string;
getLongId(): string;
}
export class Identity {
constructor(privateKey: PrivateKey, certificate: Certificate);
static generate(name?: string, options?: KeyOptions): Promise<Identity>;
readonly privateKey: PrivateKey;
readonly certificate: Certificate;
}
export interface RunOptions {
port?: number;
ipv4?: string;
ipv6?: string;
network?: number;
persistPath?: string;
identity?: Identity;
}
export interface PutOptions {
@@ -25,6 +64,9 @@ export interface Value {
seq: number;
data: Buffer;
userType: string;
owner: string | null;
isSigned: boolean;
isEncrypted: boolean;
}
export interface Listener {
@@ -39,6 +81,8 @@ export class DhtRunner extends EventEmitter implements AsyncDisposable {
bootstrap(host: string, port: number): void;
get(key: string): Promise<Value[]>;
put(key: string, data: Uint8Array, options?: PutOptions): Promise<boolean>;
putSigned(key: string, data: Uint8Array, options?: PutOptions): Promise<boolean>;
putEncrypted(key: string, data: Uint8Array, recipient: PublicKey, options?: PutOptions): Promise<boolean>;
listen(key: string, callback: (value: Value, expired: boolean) => void): Listener;
close(): Promise<void>;
ref(): this;
+13 -3
View File
@@ -1,7 +1,10 @@
'use strict';
const native = require(process.env.OPENDHT_NODE_BINDING || './build/Release/opendht.node');
const native = process.env.OPENDHT_NODE_BINDING
? require(process.env.OPENDHT_NODE_BINDING)
: require('node-gyp-build')(__dirname);
const { EventEmitter } = require('node:events');
const { PublicKey, PrivateKey, Certificate, Identity, handle } = require('./crypto')(native);
function errorReporter(owner) {
return error => { owner.deref()?.emit('error', error); };
@@ -38,13 +41,20 @@ class DhtRunner extends EventEmitter {
off(event, listener) { return this.removeListener(event, listener); }
removeAllListeners(...args) { super.removeAllListeners(...args); this.#updateOwner(); return this; }
run(options) { this.#native.run(options); }
run(options) {
if (options?.identity !== undefined) options = { ...options, identity: handle(options.identity, Identity) };
this.#native.run(options);
}
isRunning() { return this.#native.isRunning(); }
getBoundPort(family = 4) { return this.#native.getBoundPort(family); }
getNodeId() { return this.#native.getNodeId(); }
bootstrap(host, port) { this.#native.bootstrap(host, port); }
get(key) { return this.#track(this.#native.get(key)); }
put(key, data, options) { return this.#track(this.#native.put(key, data, options)); }
putSigned(key, data, options) { return this.#track(this.#native.putSigned(key, data, options)); }
putEncrypted(key, data, recipient, options) {
return this.#track(this.#native.putEncrypted(key, data, options, handle(recipient, PublicKey)));
}
listen(key, callback) {
const token = this.#native.listen(key, callback);
@@ -68,4 +78,4 @@ class DhtRunner extends EventEmitter {
[Symbol.asyncDispose]() { return this.close(); }
}
module.exports = { DhtRunner, hash: native.hash, version: native.version };
module.exports = { DhtRunner, PublicKey, PrivateKey, Certificate, Identity, hash: native.hash, version: native.version };
+14 -1
View File
@@ -9,11 +9,12 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.5.0"
"node-gyp-build": "^4.8.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"cmake-js": "^8.0.0",
"node-addon-api": "^8.5.0",
"typescript": "^5.9.3"
},
"engines": {
@@ -299,6 +300,7 @@
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
@@ -311,6 +313,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+12 -1
View File
@@ -6,20 +6,31 @@
"license": "MIT",
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"crypto.js",
"README.md",
"prebuilds/"
],
"engines": {
"node": ">=22.9.0"
},
"scripts": {
"build": "cmake-js compile",
"bundle": "node scripts/bundle.js",
"package": "npm run bundle && npm pack --pack-destination dist",
"test:package": "node scripts/test-package.js",
"test": "node --test test/*.test.js",
"typecheck": "tsc --noEmit --strict --target ES2022 --module Node16 --moduleResolution Node16 test/types.ts"
},
"dependencies": {
"node-addon-api": "^8.5.0"
"node-gyp-build": "^4.8.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"cmake-js": "^8.0.0",
"node-addon-api": "^8.5.0",
"typescript": "^5.9.3"
}
}
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const repository = path.dirname(root);
const input = path.resolve(process.argv[2] || path.join(root, 'build/Release/opendht.node'));
const target = `${process.platform}-${process.arch}`;
const output = path.join(root, 'prebuilds', target);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'opendht-bundle-'));
const run = (command, args) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
function copyNotices(dependencies) {
const directory = path.join(output, 'licenses');
fs.mkdirSync(directory, { recursive: true });
fs.copyFileSync(path.join(repository, 'LICENSE'), path.join(directory, 'OpenDHT-LICENSE.txt'));
const copied = new Set();
for (const dependency of dependencies) {
const real = fs.realpathSync(dependency);
if (!path.relative(repository, real).startsWith('..')) continue;
let name;
let notices;
if (process.platform === 'darwin') {
const match = real.match(/^(.*\/Cellar\/([^/]+)\/[^/]+)\//);
if (!match) throw new Error(`Cannot locate license notices for ${dependency}`);
name = match[2];
notices = fs.readdirSync(match[1]).filter(file => /^(COPYING|LICENSE|COPYRIGHT)([._-]|$)/i.test(file))
.map(file => path.join(match[1], file));
} else if (process.platform === 'linux') {
let owner;
try { owner = run('dpkg-query', ['-S', real]); }
catch { owner = run('dpkg-query', ['-S', real.replace(/^\/usr\//, '/')]); }
name = owner.split('\n')[0].split(': ')[0].split(':')[0];
notices = [path.join('/usr/share/doc', name, 'copyright')];
} else {
const installed = process.env.VCPKG_INSTALLED_DIR;
const triplet = process.env.VCPKG_DEFAULT_TRIPLET || 'x64-windows-release';
if (!installed) throw new Error('VCPKG_INSTALLED_DIR is required for Windows dependency notices');
const share = path.join(installed, triplet, 'share');
for (const entry of fs.readdirSync(share)) {
const copyright = path.join(share, entry, 'copyright');
if (fs.existsSync(copyright)) fs.copyFileSync(copyright, path.join(directory, `${entry}-copyright.txt`));
}
break;
}
if (!notices.length) throw new Error(`Missing license notices for ${name}`);
if (copied.has(name)) continue;
for (const notice of notices) fs.copyFileSync(notice, path.join(directory, `${name}-${path.basename(notice)}.txt`));
copied.add(name);
}
}
function fixMachO(file) {
const commands = run('otool', ['-l', file]);
const rpaths = [...commands.matchAll(/cmd LC_RPATH\s+cmdsize \d+\s+path (.+?) \(offset \d+\)/g)];
for (const rpath of new Set(rpaths.map(match => match[1]))) run('install_name_tool', ['-delete_rpath', rpath, file]);
const dependencies = [...run('otool', ['-L', file]).matchAll(/^\s+(.+?) \(compatibility version/gm)];
for (const [, dependency] of dependencies) {
if (!/^(@loader_path\/|\/usr\/lib\/|\/System\/Library\/)/.test(dependency)) {
throw new Error(`Non-relocatable dependency in ${path.basename(file)}: ${dependency}`);
}
}
run('codesign', ['--force', '--sign', '-', file]);
}
try {
if (!['darwin', 'linux', 'win32'].includes(process.platform)) throw new Error(`Unsupported platform ${target}`);
if (!fs.existsSync(input)) throw new Error(`Build the Release addon first: ${input}`);
if (process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime) {
throw new Error('Linux prebuilds currently require glibc');
}
const directories = [path.dirname(input), ...(process.env.OPENDHT_LIBRARY_PATH || '').split(path.delimiter).filter(Boolean)];
const dependencyList = path.join(temporary, 'dependencies.json');
run('cmake', [`-DADDON=${input}`, `-DOUTPUT=${dependencyList}`, `-DSEARCH_DIRECTORIES=${directories.join(';')}`,
'-P', path.join(__dirname, 'dependencies.cmake')]);
const dependencies = JSON.parse(fs.readFileSync(dependencyList, 'utf8'));
fs.rmSync(output, { recursive: true, force: true });
fs.mkdirSync(output, { recursive: true });
const binary = path.join(output, process.platform === 'linux' ? 'node.napi.glibc.node' : 'node.napi.node');
fs.copyFileSync(input, binary);
if (process.platform === 'darwin') {
run('dylibbundler', ['-b', '-of', '-ns', '-x', binary, '-d', `${output}/`, '-p', '@loader_path/', '-s', path.dirname(input)]);
for (const file of fs.readdirSync(output)) fixMachO(path.join(output, file));
} else {
for (const dependency of dependencies) fs.copyFileSync(dependency, path.join(output, path.basename(dependency)));
if (process.platform === 'linux') {
for (const file of fs.readdirSync(output)) run('patchelf', ['--set-rpath', '$ORIGIN', path.join(output, file)]);
}
}
copyNotices(dependencies);
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify({
platform: process.platform,
arch: process.arch,
napi: 8,
libraries: fs.readdirSync(output).filter(file => /\.(node|dll|dylib)$|\.so(\.|$)/.test(file)).sort(),
}, null, 2) + '\n');
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
console.log(`Bundled ${target}: ${output}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
+39
View File
@@ -0,0 +1,39 @@
cmake_minimum_required(VERSION 3.21)
if(NOT EXISTS "${ADDON}" OR NOT DEFINED OUTPUT)
message(FATAL_ERROR "ADDON and OUTPUT are required")
endif()
set(pre_exclude)
set(post_exclude)
if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
list(APPEND pre_exclude "^/usr/lib/" "^/System/Library/")
list(APPEND post_exclude "^/usr/lib/" "^/System/Library/")
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")
list(APPEND pre_exclude "^ld-linux" "^lib(c|m|dl|rt|pthread|resolv|util|gcc_s|stdc\\+\\+)\\.so")
elseif(CMAKE_HOST_WIN32)
list(APPEND pre_exclude "[Nn][Oo][Dd][Ee]\\.exe" "[Aa][Pp][Ii]-[Mm][Ss]-.*" "[Ee][Xx][Tt]-[Mm][Ss]-.*")
list(APPEND post_exclude ".*[Ss][Yy][Ss][Tt][Ee][Mm]32[/\\\\].*")
endif()
file(GET_RUNTIME_DEPENDENCIES
MODULES "${ADDON}"
DIRECTORIES ${SEARCH_DIRECTORIES}
RESOLVED_DEPENDENCIES_VAR dependencies
UNRESOLVED_DEPENDENCIES_VAR unresolved
CONFLICTING_DEPENDENCIES_PREFIX conflicts
PRE_EXCLUDE_REGEXES ${pre_exclude}
POST_EXCLUDE_REGEXES ${post_exclude}
)
if(unresolved OR conflicts_FILENAMES)
message(FATAL_ERROR "Unresolved dependencies: ${unresolved}; conflicting dependencies: ${conflicts_FILENAMES}")
endif()
set(result "[]")
set(index 0)
foreach(dependency IN LISTS dependencies)
file(TO_CMAKE_PATH "${dependency}" dependency)
string(JSON result SET "${result}" ${index} "\"${dependency}\"")
math(EXPR index "${index} + 1")
endforeach()
file(WRITE "${OUTPUT}" "${result}\n")
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const npm = process.env.npm_execpath;
if (!npm) throw new Error('Run this check with npm run test:package');
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
let archive = process.argv[2] ? path.resolve(process.argv[2]) : path.join(root, 'dist',
JSON.parse(execFileSync(process.execPath, [npm, 'pack', '--json', '--pack-destination', 'dist'],
{ cwd: root, encoding: 'utf8' }))[0].filename);
if (fs.statSync(archive).isDirectory()) {
const archives = fs.readdirSync(archive).filter(file => file.endsWith('.tgz'));
if (archives.length !== 1) throw new Error('Expected exactly one npm archive');
archive = path.join(archive, archives[0]);
}
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), 'opendht-consumer-'));
const env = { ...process.env, PREBUILDS_ONLY: '1' };
for (const variable of ['OPENDHT_NODE_BINDING', 'NODE_PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'DYLD_FALLBACK_LIBRARY_PATH']) delete env[variable];
const excluded = [path.join(root, 'build'), ...(process.env.OPENDHT_LIBRARY_PATH || '').split(path.delimiter).filter(Boolean)];
const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
if (pathKey) env[pathKey] = env[pathKey].split(path.delimiter)
.filter(entry => !excluded.some(directory => path.resolve(entry).toLowerCase().startsWith(path.resolve(directory).toLowerCase())))
.join(path.delimiter);
try {
execFileSync(process.execPath, [npm, 'install', '--prefix', consumer, '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', archive],
{ cwd: consumer, env, stdio: 'inherit' });
const installed = path.join(consumer, 'node_modules', '@opendht', 'node');
execFileSync(process.execPath, ['-e', 'console.log(require("node-gyp-build").path(process.cwd()))'],
{ cwd: installed, env, stdio: 'inherit' });
fs.cpSync(path.join(root, 'test'), path.join(installed, 'test'), { recursive: true });
execFileSync(process.execPath, [npm, 'test'], { cwd: installed, env, stdio: 'inherit' });
console.log(`Installed archive verified: ${archive}`);
} finally {
fs.rmSync(consumer, { recursive: true, force: true });
}