Add Native Node.js Bindings with NAPI-RS
Summary
Add native Node.js bindings using napi-rs as an alternative to the WASM-based integration. This would provide better performance, simpler API, and more idiomatic JavaScript/TypeScript integration while sharing the same core flag evaluation logic.
Current State: WASM-based Integration
Currently, Node.js users can use the evaluator through WASM:
Pros:
- ✅ Same WASM binary works across all JavaScript runtimes (Node.js, Deno, Bun, browsers)
- ✅ Guaranteed consistent behavior
- ✅ Single artifact to maintain
Cons:
- ❌ Requires host function setup (9 functions)
- ❌ Manual memory management
- ❌ WASM instantiation overhead
- ❌ Less idiomatic JavaScript
- ❌ No native npm package
- ❌ Harder to debug
Example complexity:
// WASM approach (current)
const fs = require('fs');
const wasmBuffer = fs.readFileSync('flagd_evaluator.wasm');
// Define 9 host functions
const imports = {
host: {
get_current_time_unix_seconds: () => BigInt(Math.floor(Date.now() / 1000))
},
__wbindgen_placeholder__: {
__wbg_getRandomValues_1c61fac11405ffdc: (typedArrayPtr, bufferPtr) => {
// Fill random bytes...
},
// ... 7 more functions
}
};
const { instance } = await WebAssembly.instantiate(wasmBuffer, imports);
// Load flag configuration
const config = {
flags: {
myFlag: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "on"
}
}
};
// Manual memory management for update_state and evaluate...
Proposed: Native Node.js Bindings
Add a native Node.js addon using napi-rs that compiles Rust directly to a Node.js native module.
Benefits:
- ✅ Native performance - No WASM overhead, direct Rust ↔ V8 calls
- ✅ Idiomatic JavaScript - Natural JS objects and promises
- ✅ Simple installation -
npm install @openfeature/flagd-evaluator
- ✅ No host functions - No manual memory management
- ✅ TypeScript support - Auto-generated type definitions
- ✅ Better error handling - Native JS errors with stack traces
- ✅ Async/await support - Native promises
- ✅ Tree-shakeable - Better for bundlers
Example simplicity:
// NAPI approach (proposed)
const { FlagEvaluator } = require('@openfeature/flagd-evaluator');
const evaluator = new FlagEvaluator();
evaluator.updateState({
flags: {
myFlag: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "on"
}
}
});
const result = evaluator.evaluateBool("myFlag", {}, false);
console.log(result); // true
// That's it!
Implementation Approach
Architecture
flagd-evaluator/
├── lib/ # Core Rust library (existing)
│ └── src/
│ ├── lib.rs # WASM exports
│ ├── evaluation.rs
│ └── ..
└── nodejs/ # New: Node.js bindings crate
├── Cargo.toml # napi-rs dependencies
├── package.json # npm package metadata
├── build.rs # Build script
└── src/
└── lib.rs # napi-rs bindings
Technology Choice: NAPI-RS
Why napi-rs?
- ✅ Most modern and actively maintained
- ✅ Excellent TypeScript support (auto-generated
.d.ts)
- ✅ Cross-platform pre-built binaries
- ✅ Zero Node.js version dependency (N-API is stable)
- ✅ Great tooling and documentation
- ✅ Used by major projects (SWC, Rspack, etc.)
Alternatives considered:
neon - Older, less TypeScript support
node-bindgen - Less mature ecosystem
Package Structure
nodejs/Cargo.toml:
[package]
name = "flagd-evaluator-node"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = "2.16"
napi-derive = "2.16"
flagd-evaluator = { path = "..", default-features = false }
serde_json = "1.0"
[build-dependencies]
napi-build = "2.1"
nodejs/package.json:
{
"name": "@openfeature/flagd-evaluator",
"version": "0.1.0",
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "flagd-evaluator",
"triples": {
"defaults": true,
"additional": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-pc-windows-msvc"
]
}
},
"scripts": {
"build": "napi build --platform --release",
"build:debug": "napi build --platform",
"prepublishOnly": "napi prepublish -t npm",
"test": "node test.js"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
}
}
nodejs/src/lib.rs:
use napi::bindgen_prelude::*;
use napi_derive::napi;
use serde_json::Value;
#[napi(object)]
pub struct FlagResult {
pub value: Value,
pub variant: Option<String>,
pub reason: String,
pub flag_metadata: Option<Object>,
}
/// FlagEvaluator class for stateful flag evaluation
#[napi]
pub struct FlagEvaluator {
state: Option<flagd_evaluator::model::ParsingResult>,
}
#[napi]
impl FlagEvaluator {
#[napi(constructor)]
pub fn new() -> Result<Self> {
Ok(FlagEvaluator { state: None })
}
/// Update flag configuration
#[napi]
pub fn update_state(&mut self, config: Object) -> Result<Object> {
// Convert config to JSON and parse
let config_json: Value = serde_json::from_str(&config.to_string())
.map_err(|e| Error::from_reason(e.to_string()))?;
let config_str = serde_json::to_string(&config_json)
.map_err(|e| Error::from_reason(e.to_string()))?;
let parsing_result = flagd_evaluator::model::ParsingResult::parse(&config_str)
.map_err(|e| Error::from_reason(e.to_string()))?;
self.state = Some(parsing_result);
// Return success response
let mut response = Object::new();
response.set("success", true)?;
Ok(response)
}
/// Evaluate a flag
#[napi]
pub fn evaluate(&self, flag_key: String, context: Object) -> Result<FlagResult> {
let state = self.state.as_ref()
.ok_or_else(|| Error::from_reason("No state loaded. Call updateState() first."))?;
let flag = state.flags.get(&flag_key)
.ok_or_else(|| Error::from_reason(format!("Flag not found: {}", flag_key)))?;
let context_json: Value = serde_json::from_str(&context.to_string())
.map_err(|e| Error::from_reason(e.to_string()))?;
let result = flagd_evaluator::evaluation::evaluate_flag(
flag,
&context_json,
&state.flag_set_metadata
);
Ok(FlagResult {
value: result.value,
variant: result.variant,
reason: result.reason.to_string(),
flag_metadata: None, // Convert from HashMap if needed
})
}
/// Evaluate boolean flag
#[napi]
pub fn evaluate_bool(&self, flag_key: String, context: Object, default_value: bool) -> Result<bool> {
let state = self.state.as_ref()
.ok_or_else(|| Error::from_reason("No state loaded"))?;
let flag = state.flags.get(&flag_key)
.ok_or_else(|| Error::from_reason(format!("Flag not found: {}", flag_key)))?;
let context_json: Value = serde_json::from_str(&context.to_string())
.map_err(|e| Error::from_reason(e.to_string()))?;
let result = flagd_evaluator::evaluation::evaluate_bool_flag(
flag,
&context_json,
&state.flag_set_metadata
);
match result.value {
Value::Bool(b) => Ok(b),
_ => Ok(default_value),
}
}
/// Evaluate string flag
#[napi]
pub fn evaluate_string(&self, flag_key: String, context: Object, default_value: String) -> Result<String> {
// Similar to evaluate_bool...
todo!()
}
/// Evaluate integer flag
#[napi]
pub fn evaluate_int(&self, flag_key: String, context: Object, default_value: i64) -> Result<i64> {
// Similar to evaluate_bool...
todo!()
}
/// Evaluate float flag
#[napi]
pub fn evaluate_float(&self, flag_key: String, context: Object, default_value: f64) -> Result<f64> {
// Similar to evaluate_bool...
todo!()
}
}
API Design
Basic Flag Evaluation (CommonJS)
const { FlagEvaluator } = require('@openfeature/flagd-evaluator');
const evaluator = new FlagEvaluator();
// Load flag configuration
evaluator.updateState({
flags: {
myFlag: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "on"
}
}
});
// Evaluate flag
const result = evaluator.evaluateBool("myFlag", {}, false);
console.log(result); // true
Basic Flag Evaluation (ESM)
import { FlagEvaluator } from '@openfeature/flagd-evaluator';
const evaluator = new FlagEvaluator();
evaluator.updateState({ /* config */ });
const result = evaluator.evaluateBool("myFlag", {}, false);
TypeScript Support
Auto-generated type definitions:
import { FlagEvaluator, FlagResult } from '@openfeature/flagd-evaluator';
const evaluator = new FlagEvaluator();
evaluator.updateState({
flags: {
myFlag: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "on"
}
}
});
// Fully typed
const boolResult: boolean = evaluator.evaluateBool("myFlag", {}, false);
const stringResult: string = evaluator.evaluateString("theme", {}, "light");
// Full evaluation result
const fullResult: FlagResult = evaluator.evaluate("myFlag", {});
console.log(fullResult.value);
console.log(fullResult.variant);
console.log(fullResult.reason);
Targeting Rules
const { FlagEvaluator } = require('@openfeature/flagd-evaluator');
const evaluator = new FlagEvaluator();
evaluator.updateState({
flags: {
premiumFeature: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "off",
targeting: {
"if": [
{ "==": [{ "var": "tier" }, "premium"] },
"on",
"off"
]
}
}
}
});
// User with premium tier
const premium = evaluator.evaluateBool(
"premiumFeature",
{ tier: "premium" },
false
);
console.log(premium); // true
// User without premium tier
const free = evaluator.evaluateBool(
"premiumFeature",
{ tier: "free" },
false
);
console.log(free); // false
Custom Operators in Targeting
const evaluator = new FlagEvaluator();
evaluator.updateState({
flags: {
betaFeature: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "off",
targeting: {
"if": [
// Semantic version check
{ "sem_ver": [{ "var": "appVersion" }, ">=", "2.0.0"] },
"on",
"off"
]
}
},
adminPanel: {
state: "ENABLED",
variants: { on: true, off: false },
defaultVariant: "off",
targeting: {
"if": [
// String prefix check
{ "starts_with": [{ "var": "email" }, "admin@"] },
"on",
"off"
]
}
}
}
});
// Version-based flag
const hasBeta = evaluator.evaluateBool(
"betaFeature",
{ appVersion: "2.1.0" },
false
);
console.log(hasBeta); // true
// Email-based flag
const isAdmin = evaluator.evaluateBool(
"adminPanel",
{ email: "admin@example.com" },
false
);
console.log(isAdmin); // true
Distribution
Pre-built Binaries
Use napi-rs CI to build for multiple platforms:
Supported platforms:
- Linux: x64 (glibc & musl), ARM64 (glibc & musl)
- macOS: x64 (Intel), ARM64 (Apple Silicon)
- Windows: x64, ARM64
GitHub Actions workflow:
name: Build Native Modules
on:
release:
types: [published]
jobs:
build:
strategy:
matrix:
settings:
- host: ubuntu-latest
target: x86_64-unknown-linux-gnu
- host: ubuntu-latest
target: aarch64-unknown-linux-gnu
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
- host: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build
run: |
cd nodejs
npm install
npm run build
- name: Upload
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.settings.target }}
path: nodejs/*.node
Installation
# Install from npm
npm install @openfeature/flagd-evaluator
# Install from yarn
yarn add @openfeature/flagd-evaluator
# Install from pnpm
pnpm add @openfeature/flagd-evaluator
Automatic platform detection:
- npm automatically downloads the correct pre-built binary for the user's platform
- Fallback to building from source if no pre-built binary available
Package Size
Expected sizes:
- Native module: ~2-3 MB per platform (smaller than WASM)
- npm package: ~500 KB (tarball, platform-specific binary downloaded on install)
Performance Comparison
Preliminary estimates (to be benchmarked):
| Approach |
Initialization |
Single Evaluation |
10k Evaluations |
Memory |
| WASM |
~50-100ms |
~0.05ms |
~500ms |
~5 MB |
| Native (NAPI) |
~1-5ms |
~0.01ms |
~100ms |
~2 MB |
Expected improvements:
- 5-10x faster initialization
- 3-5x faster individual evaluations
- 50% less memory usage
- Better integration with Node.js event loop
Compatibility
Both approaches can coexist:
- WASM integration - For browsers, Deno, Bun, universal compatibility
- Native NAPI - For Node.js server-side performance
Package strategy:
{
"name": "@openfeature/flagd-evaluator",
"exports": {
".": {
"node": "./native.js", // Native for Node.js
"default": "./wasm.js" // WASM for other environments
}
}
}
Use Cases
Native bindings are ideal for:
- ✅ Server-side Node.js applications
- ✅ CLI tools built with Node.js
- ✅ Electron apps
- ✅ Performance-critical applications
- ✅ Large-scale flag evaluations
WASM is better for:
- ✅ Browser environments
- ✅ Deno/Bun compatibility
- ✅ Maximum portability
- ✅ Sandboxed environments
Implementation Checklist
Phase 1: Basic Bindings
Phase 2: Full API
Phase 3: Distribution
Phase 4: Advanced Features
Phase 5: Optimization
Questions for Discussion
- Package name:
@openfeature/flagd-evaluator or separate package?
- API design: Match Python bindings API or Node.js-specific design?
- Versioning: Keep in sync with WASM or independent?
- Browser bundle: Should we also provide a bundled WASM + JS for browsers?
- OpenFeature integration: How to integrate with the Node.js OpenFeature SDK?
Related
Success Criteria
Native Node.js bindings are successful if:
- ✅ Installation is simple:
npm install @openfeature/flagd-evaluator
- ✅ Performance is >3x faster than WASM approach
- ✅ API is idiomatic JavaScript/TypeScript (similar to Python bindings)
- ✅ Pre-built binaries work on all major platforms
- ✅ TypeScript types are accurate and helpful
- ✅ npm package size is reasonable (<5 MB)
- ✅ Works with Node.js 18+ (LTS versions)
- ✅ Integrates cleanly with OpenFeature SDK
Add Native Node.js Bindings with NAPI-RS
Summary
Add native Node.js bindings using napi-rs as an alternative to the WASM-based integration. This would provide better performance, simpler API, and more idiomatic JavaScript/TypeScript integration while sharing the same core flag evaluation logic.
Current State: WASM-based Integration
Currently, Node.js users can use the evaluator through WASM:
Pros:
Cons:
Example complexity:
Proposed: Native Node.js Bindings
Add a native Node.js addon using napi-rs that compiles Rust directly to a Node.js native module.
Benefits:
npm install @openfeature/flagd-evaluatorExample simplicity:
Implementation Approach
Architecture
Technology Choice: NAPI-RS
Why napi-rs?
.d.ts)Alternatives considered:
neon- Older, less TypeScript supportnode-bindgen- Less mature ecosystemPackage Structure
nodejs/Cargo.toml:
nodejs/package.json:
{ "name": "@openfeature/flagd-evaluator", "version": "0.1.0", "main": "index.js", "types": "index.d.ts", "napi": { "name": "flagd-evaluator", "triples": { "defaults": true, "additional": [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "aarch64-pc-windows-msvc" ] } }, "scripts": { "build": "napi build --platform --release", "build:debug": "napi build --platform", "prepublishOnly": "napi prepublish -t npm", "test": "node test.js" }, "devDependencies": { "@napi-rs/cli": "^2.18.0" } }nodejs/src/lib.rs:
API Design
Basic Flag Evaluation (CommonJS)
Basic Flag Evaluation (ESM)
TypeScript Support
Auto-generated type definitions:
Targeting Rules
Custom Operators in Targeting
Distribution
Pre-built Binaries
Use napi-rs CI to build for multiple platforms:
Supported platforms:
GitHub Actions workflow:
Installation
Automatic platform detection:
Package Size
Expected sizes:
Performance Comparison
Preliminary estimates (to be benchmarked):
Expected improvements:
Compatibility
Both approaches can coexist:
Package strategy:
{ "name": "@openfeature/flagd-evaluator", "exports": { ".": { "node": "./native.js", // Native for Node.js "default": "./wasm.js" // WASM for other environments } } }Use Cases
Native bindings are ideal for:
WASM is better for:
Implementation Checklist
Phase 1: Basic Bindings
nodejs/workspace memberFlagEvaluatorclassPhase 2: Full API
updateStatemethodevaluatemethodPhase 3: Distribution
@openfeature/flagd-evaluatorPhase 4: Advanced Features
Phase 5: Optimization
Questions for Discussion
@openfeature/flagd-evaluatoror separate package?Related
@swc/core- Fast TypeScript/JavaScript compiler@rspack/core- Fast bundler@napi-rs/canvas- Canvas implementationoxc-parser- JavaScript parserSuccess Criteria
Native Node.js bindings are successful if:
npm install @openfeature/flagd-evaluator