Skip to content

benoitc/erlang-duktape

Repository files navigation

Duktape

CI Hex.pm

Duktape JavaScript engine for Erlang.

This library embeds the Duktape JavaScript engine (v2.7.0) as an Erlang NIF, allowing you to evaluate JavaScript code directly from Erlang.

Features

  • Execute JavaScript code from Erlang
  • Bidirectional type conversion between Erlang and JavaScript
  • Multiple isolated JavaScript contexts
  • CommonJS module support
  • Execution timeouts to prevent infinite loops
  • Event framework for JS ↔ Erlang communication
  • Register Erlang functions callable from JavaScript
  • console.log/info/warn/error/debug support
  • Memory metrics and manual garbage collection
  • Thread-safe with automatic resource cleanup
  • No external dependencies - Duktape is embedded

Requirements

  • Erlang/OTP 24 or later
  • CMake 3.10 or later
  • C compiler (gcc, clang, or MSVC)

Installation

Add to your rebar.config:

{deps, [
    {duktape, {git, "https://github.com/benoitc/erlang-duktape.git", {branch, "main"}}}
]}.

Then run:

rebar3 compile

Quick Start

%% Create a JavaScript context
{ok, Ctx} = duktape:new_context().

%% Evaluate JavaScript code
{ok, 42} = duktape:eval(Ctx, <<"21 * 2">>).
{ok, <<"hello">>} = duktape:eval(Ctx, <<"'hello'">>).

%% Evaluate with variable bindings
{ok, 30} = duktape:eval(Ctx, <<"x * y">>, #{x => 5, y => 6}).

%% Define and call functions
{ok, _} = duktape:eval(Ctx, <<"function add(a, b) { return a + b; }">>).
{ok, 7} = duktape:call(Ctx, add, [3, 4]).

%% CommonJS modules
ok = duktape:register_module(Ctx, <<"utils">>, <<"
    exports.greet = function(name) {
        return 'Hello, ' + name + '!';
    };
">>).
{ok, <<"Hello, World!">>} = duktape:eval(Ctx, <<"require('utils').greet('World')">>).

API Reference

Context Management

new_context() -> {ok, context()} | {error, term()}

Create a new JavaScript context. Contexts are isolated - variables and functions defined in one context are not visible in others.

Contexts are automatically cleaned up when garbage collected, but you can also explicitly destroy them with destroy_context/1.

new_context(Opts) -> {ok, context()} | {error, term()}

Create a new JavaScript context with options.

Options:

  • handler => pid(): Process to receive events from JavaScript. The handler will receive messages of the form {duktape, Type, Data} where Type is a binary (e.g., <<"custom">>) or atom (for log events: log) and Data is the event payload.
{ok, Ctx} = duktape:new_context(#{handler => self()}),
{ok, _} = duktape:eval(Ctx, <<"console.log('hello')">>),
receive
    {duktape, log, #{level := info, message := <<"hello">>}} ->
        io:format("Got log message~n")
end.

destroy_context(Ctx) -> ok | {error, term()}

Explicitly destroy a JavaScript context. This is optional - contexts are automatically cleaned up on garbage collection. Calling destroy on an already-destroyed context is safe (idempotent).

Evaluation

eval(Ctx, Code) -> {ok, Value} | {error, term()}

Evaluate JavaScript code and return the result of the last expression. Uses default timeout of 5000ms.

{ok, 3} = duktape:eval(Ctx, <<"1 + 2">>).
{ok, <<"hello">>} = duktape:eval(Ctx, <<"'hello'">>).
{error, {js_error, _}} = duktape:eval(Ctx, <<"throw 'oops'">>).

eval(Ctx, Code, Timeout) -> {ok, Value} | {error, term()}

eval(Ctx, Code, Bindings) -> {ok, Value} | {error, term()}

With an integer or infinity as third argument, sets execution timeout in milliseconds. With a map, sets variable bindings.

%% With timeout (100ms)
{error, timeout} = duktape:eval(Ctx, <<"while(true){}">>, 100).
{ok, 42} = duktape:eval(Ctx, <<"21 * 2">>, 1000).
{ok, 42} = duktape:eval(Ctx, <<"21 * 2">>, infinity).  %% No timeout

%% With bindings (uses default 5000ms timeout)
{ok, 30} = duktape:eval(Ctx, <<"x * y">>, #{x => 5, y => 6}).

eval(Ctx, Code, Bindings, Timeout) -> {ok, Value} | {error, term()}

Evaluate with both variable bindings and explicit timeout.

{ok, 30} = duktape:eval(Ctx, <<"x * y">>, #{x => 5, y => 6}, 1000).
{error, timeout} = duktape:eval(Ctx, <<"while(x){}">>, #{x => true}, 100).

Function Calls

call(Ctx, FunctionName) -> {ok, Value} | {error, term()}

Call a global JavaScript function with no arguments. Uses default timeout of 5000ms.

{ok, _} = duktape:eval(Ctx, <<"function getTime() { return Date.now(); }">>).
{ok, Timestamp} = duktape:call(Ctx, <<"getTime">>).

call(Ctx, FunctionName, Timeout) -> {ok, Value} | {error, term()}

call(Ctx, FunctionName, Args) -> {ok, Value} | {error, term()}

With an integer or infinity as third argument, sets execution timeout. With a list, passes arguments to the function.

%% With timeout
{ok, _} = duktape:eval(Ctx, <<"function slow() { while(true){} }">>).
{error, timeout} = duktape:call(Ctx, slow, 100).

%% With args (uses default 5000ms timeout)
{ok, _} = duktape:eval(Ctx, <<"function add(a, b) { return a + b; }">>).
{ok, 7} = duktape:call(Ctx, <<"add">>, [3, 4]).
{ok, 7} = duktape:call(Ctx, add, [3, 4]).

call(Ctx, FunctionName, Args, Timeout) -> {ok, Value} | {error, term()}

Call a function with both arguments and explicit timeout.

{ok, 7} = duktape:call(Ctx, add, [3, 4], 1000).
{ok, 7} = duktape:call(Ctx, add, [3, 4], infinity).  %% No timeout

CommonJS Modules

register_module(Ctx, ModuleId, Source) -> ok | {error, term()}

Register a CommonJS module with source code. The module can then be loaded with require/2 or via require() in JavaScript.

ok = duktape:register_module(Ctx, <<"math">>, <<"
    exports.add = function(a, b) { return a + b; };
    exports.multiply = function(a, b) { return a * b; };
">>).

require(Ctx, ModuleId) -> {ok, Exports} | {error, term()}

Load a CommonJS module and return its exports. Modules are cached - subsequent requires return the same exports object.

{ok, Exports} = duktape:require(Ctx, <<"math">>).

Event Framework

The event framework enables bidirectional communication between JavaScript and Erlang.

send(Ctx, Event, Data) -> {ok, Value} | ok | {error, term()}

Send data to a registered JavaScript callback. If JavaScript code has registered a callback using Erlang.on(event, fn), this function will call that callback with the provided data.

Returns {ok, Result} where Result is the return value of the callback, or ok if no callback is registered for the event.

{ok, Ctx} = duktape:new_context(),
%% JavaScript registers a callback
{ok, _} = duktape:eval(Ctx, <<"
    var received = null;
    Erlang.on('data', function(d) { received = d; return 'got it'; });
">>),
%% Erlang sends data to the callback
{ok, <<"got it">>} = duktape:send(Ctx, data, #{value => 42}),
{ok, #{<<"value">> := 42}} = duktape:eval(Ctx, <<"received">>).

JavaScript API

The Erlang global object provides the following methods:

Erlang.emit(type, data) - Send an event to the Erlang handler process.

Erlang.emit('custom_event', {key: 'value', count: 42});

The handler receives: {duktape, <<"custom_event">>, #{<<"key">> => <<"value">>, <<"count">> => 42}}

Erlang.log(level, ...args) - Send a log message to the Erlang handler.

Erlang.log('info', 'User logged in:', userId);
Erlang.log('warning', 'Rate limit exceeded');
Erlang.log('error', 'Connection failed:', error);
Erlang.log('debug', 'Request details:', request);

The handler receives: {duktape, log, #{level => info, message => <<"User logged in: 123">>}}

Erlang.on(event, callback) - Register a callback for events from Erlang.

Erlang.on('config_update', function(config) {
    applyConfig(config);
    return 'applied';
});

Erlang.off(event) - Unregister a callback.

Erlang.off('config_update');

Console Object

A standard console object is available that wraps Erlang.log:

console.log('Hello, world!');      // level: info
console.info('Information');        // level: info
console.warn('Warning message');    // level: warning
console.error('Error occurred');    // level: error
console.debug('Debug info');        // level: debug

Complete Example

%% Create context with event handler
{ok, Ctx} = duktape:new_context(#{handler => self()}),

%% Set up JavaScript callback
{ok, _} = duktape:eval(Ctx, <<"
    var messages = [];
    Erlang.on('message', function(msg) {
        messages.push(msg);
        console.log('Received:', msg.text);
        return messages.length;
    });
">>),

%% Send from Erlang
{ok, 1} = duktape:send(Ctx, message, #{text => <<"Hello">>}),
{ok, 2} = duktape:send(Ctx, message, #{text => <<"World">>}),

%% Receive console.log events
receive {duktape, log, #{message := <<"Received: Hello">>}} -> ok end,
receive {duktape, log, #{message := <<"Received: World">>}} -> ok end,

%% Verify messages were stored
{ok, [#{<<"text">> := <<"Hello">>}, #{<<"text">> := <<"World">>}]} =
    duktape:eval(Ctx, <<"messages">>).

Erlang Functions

Register Erlang functions that can be called synchronously from JavaScript.

register_function(Ctx, Name, Fun) -> ok | {error, term()}

Register an Erlang function callable from JavaScript. The function receives a list of arguments passed from JavaScript.

Supports both anonymous functions and {Module, Function} tuples. The function must accept a single argument (the list of JS arguments).

{ok, Ctx} = duktape:new_context(),

%% Register with anonymous function
ok = duktape:register_function(Ctx, greet, fun([Name]) ->
    <<"Hello, ", Name/binary, "!">>
end),
{ok, <<"Hello, World!">>} = duktape:eval(Ctx, <<"greet('World')">>).

%% Register with {Module, Function} tuple
ok = duktape:register_function(Ctx, my_func, {my_module, my_function}).

Multiple Arguments:

ok = duktape:register_function(Ctx, add, fun(Args) ->
    lists:sum(Args)
end),
{ok, 10} = duktape:eval(Ctx, <<"add(1, 2, 3, 4)">>).

Nested Calls (Erlang functions calling each other):

ok = duktape:register_function(Ctx, double, fun([N]) -> N * 2 end),
{ok, _} = duktape:eval(Ctx, <<"function quadruple(n) { return double(double(n)); }">>),
{ok, 20} = duktape:eval(Ctx, <<"quadruple(5)">>).

Error Handling:

Erlang exceptions are converted to JavaScript errors:

ok = duktape:register_function(Ctx, fail, fun(_) ->
    error(something_bad)
end),
%% JavaScript can catch the error
{ok, _} = duktape:eval(Ctx, <<"
    try {
        fail();
    } catch (e) {
        console.log('Caught:', e.message);
    }
">>).

Note: Registered functions are stored in the calling process's dictionary. The process that registers the function must also be the one that calls eval/call.

CBOR Encoding/Decoding

Duktape has built-in CBOR (Concise Binary Object Representation) support.

cbor_encode(Ctx, Value) -> {ok, binary()} | {error, term()}

Encode an Erlang value to CBOR binary. The value is first converted to a JavaScript value, then encoded to CBOR.

{ok, Ctx} = duktape:new_context(),
{ok, Bin} = duktape:cbor_encode(Ctx, #{name => <<"Alice">>, age => 30}).

cbor_decode(Ctx, Binary) -> {ok, Value} | {error, term()}

Decode a CBOR binary to an Erlang value. The CBOR is decoded to a JavaScript value, then converted to Erlang.

{ok, Decoded} = duktape:cbor_decode(Ctx, Bin),
%% #{<<"name">> => <<"Alice">>, <<"age">> => 30}

CBOR type mappings follow the same rules as regular Erlang ↔ JavaScript type conversions.

Utility

info() -> {ok, string()}

Get NIF information. Used to verify the NIF is loaded correctly.

Metrics

get_memory_stats(Ctx) -> {ok, Stats} | {error, term()}

Get memory statistics for a JavaScript context. Returns a map with:

Key Description
heap_bytes Current allocated bytes in the Duktape heap
heap_peak Peak memory usage since context creation
alloc_count Total number of allocations
realloc_count Total number of reallocations
free_count Total number of frees
gc_runs Number of garbage collection runs triggered
{ok, Ctx} = duktape:new_context(),
{ok, _} = duktape:eval(Ctx, <<"var x = []; for(var i=0; i<1000; i++) x.push(i);">>),
{ok, Stats} = duktape:get_memory_stats(Ctx),
io:format("Heap: ~p bytes, Peak: ~p bytes~n",
          [maps:get(heap_bytes, Stats), maps:get(heap_peak, Stats)]).

gc(Ctx) -> ok | {error, term()}

Trigger garbage collection on a JavaScript context. Forces Duktape's mark-and-sweep garbage collector to run.

{ok, Ctx} = duktape:new_context(),
{ok, _} = duktape:eval(Ctx, <<"var x = {}; x = null;">>),
ok = duktape:gc(Ctx),
{ok, #{gc_runs := 1}} = duktape:get_memory_stats(Ctx).

Type Conversions

Erlang to JavaScript

Erlang JavaScript
integer() number
float() number
binary() string
true true
false false
null null
undefined undefined
other atoms string
list() array (or string if iolist)
map() object
tuple() array

JavaScript to Erlang

JavaScript Erlang
number (integer) integer()
number (float) float()
NaN nan (atom)
Infinity infinity (atom)
-Infinity neg_infinity (atom)
string binary()
true true
false false
null null
undefined undefined
array list()
object map()

Error Handling

JavaScript errors are returned as {error, {js_error, Message}} where Message is a binary containing the error message and stack trace.

{error, {js_error, <<"ReferenceError: x is not defined", _/binary>>}} =
    duktape:eval(Ctx, <<"x + 1">>).

Contexts remain usable after errors - you can continue to evaluate code in the same context.

Thread Safety

All context operations are thread-safe. Multiple Erlang processes can share a context, though operations are serialized via a mutex. For maximum parallelism, create separate contexts for concurrent workloads.

Resource Management

Contexts are managed as Erlang NIF resources with automatic cleanup:

  • Contexts are garbage collected when no Erlang process holds a reference
  • Multiple processes can share a context safely
  • Explicit destroy_context/1 is optional but can be used for immediate cleanup
  • Reference counting ensures contexts are not destroyed while in use

Benchmarks

Performance benchmarks on Apple M4 Pro, Erlang/OTP 28:

Core Operations

Benchmark Ops/sec Mean (ms) P95 (ms) P99 (ms)
eval_simple 1,866 0.536 0.561 0.581
eval_complex 1,712 0.584 0.613 0.654
eval_bindings_small (5 vars) 1,815 0.551 0.602 0.650
eval_bindings_large (50 vars) 1,292 0.774 0.852 0.890
call_no_args 1,736 0.576 0.626 0.672
call_with_args (5 args) 1,730 0.578 0.624 0.683
call_many_args (20 args) 1,604 0.624 0.666 0.730
type_convert_simple 1,842 0.543 0.574 0.648
type_convert_array (1000 elem) 1,616 0.619 0.656 0.732
type_convert_nested 1,773 0.564 0.599 0.670
context_create 1,960 0.510 0.540 0.609
module_require_cached 1,636 0.611 0.642 0.712

Erlang Function Registration

Benchmark Ops/sec Mean (ms) P95 (ms) P99 (ms)
register_function_simple 1,794 0.557 0.582 0.680
register_function_complex_args 1,616 0.619 0.658 1.091
register_function_nested (5 calls) 1,445 0.692 0.746 0.817
register_function_many_calls (10) 9,682 1.033 1.114 1.268

Event Framework

Benchmark Ops/sec Mean (ms) P95 (ms) P99 (ms)
event_emit 1,488 0.672 0.717 0.804
event_send 1,787 0.560 0.584 0.659
console_log 1,486 0.673 0.712 0.794

CBOR Encoding/Decoding

Benchmark Ops/sec Mean (ms) P95 (ms) P99 (ms)
cbor_encode_simple 1,920 0.521 0.545 0.615
cbor_encode_complex 1,833 0.545 0.589 0.653
cbor_decode_simple 1,891 0.529 0.558 0.647
cbor_roundtrip 1,853 0.540 0.577 0.676

Concurrency

Benchmark Ops/sec Mean (ms) P95 (ms) P99 (ms)
concurrent_same_context (10 procs) 45,306 2.207 2.439 2.543
concurrent_many_contexts (10 procs) 26,253 3.809 4.185 4.491

Run benchmarks yourself:

./run_bench.sh              # Run all benchmarks
./run_bench.sh eval_simple  # Run specific benchmark
./run_bench.sh --smoke      # Quick validation

Security Considerations

When running untrusted JavaScript code, be aware of these limitations:

Execution Timeouts

All eval and call functions support execution timeouts to prevent infinite loops:

%% Default timeout is 5000ms
{error, timeout} = duktape:eval(Ctx, <<"while(true){}">>, 100).

%% Use infinity for no timeout (only for trusted code)
{ok, _} = duktape:eval(Ctx, Code, infinity).

After a timeout, the context remains valid and can be reused for subsequent calls.

Memory Limits

Duktape does not have built-in memory limits. JavaScript code can allocate unbounded memory.

Recommendation: For untrusted code, monitor memory usage via get_memory_stats/1 and destroy contexts that exceed limits.

Event Types

Event types from Erlang.emit() are returned as binaries to prevent atom table exhaustion. Known log levels (debug, info, warning, error) remain atoms for ergonomics.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Duktape is also licensed under the MIT License.

About

Duktape JavaScript engine for Erlang.

Resources

License

Stars

Watchers

Forks

Packages

No packages published