Skip to content

Add native Node.js bindings with napi-rs #48

Description

@aepfli

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:

  1. WASM integration - For browsers, Deno, Bun, universal compatibility
  2. 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

  • Create nodejs/ workspace member
  • Add napi-rs dependencies
  • Implement FlagEvaluator class
  • Add TypeScript type definitions
  • Write unit tests
  • Set up CI for multi-platform builds

Phase 2: Full API

  • Add updateState method
  • Add evaluate method
  • Implement type-specific evaluators (bool, string, int, float)
  • Add comprehensive error handling
  • Add targeting rule support

Phase 3: Distribution

  • Configure npm package
  • Build pre-built binaries for all platforms
  • Publish to npm as @openfeature/flagd-evaluator
  • Add installation documentation

Phase 4: Advanced Features

  • Async evaluation support
  • Streaming/batch evaluation
  • Worker thread support
  • Integration with OpenFeature Node.js SDK

Phase 5: Optimization

  • Benchmark vs WASM
  • Profile and optimize hot paths
  • Add performance tests to CI
  • Document performance characteristics

Questions for Discussion

  1. Package name: @openfeature/flagd-evaluator or separate package?
  2. API design: Match Python bindings API or Node.js-specific design?
  3. Versioning: Keep in sync with WASM or independent?
  4. Browser bundle: Should we also provide a bundled WASM + JS for browsers?
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions