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.
- 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
- Erlang/OTP 24 or later
- CMake 3.10 or later
- C compiler (gcc, clang, or MSVC)
Add to your rebar.config:
{deps, [
{duktape, {git, "https://github.com/benoitc/erlang-duktape.git", {branch, "main"}}}
]}.Then run:
rebar3 compile%% 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')">>).- Context Management | Evaluation | Function Calls | CommonJS Modules
- Event Framework | Erlang Functions | CBOR Encoding/Decoding
- Utility | Metrics
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.
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.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).
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'">>).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}).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).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">>).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 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 timeoutRegister 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; };
">>).Load a CommonJS module and return its exports. Modules are cached - subsequent requires return the same exports object.
{ok, Exports} = duktape:require(Ctx, <<"math">>).The event framework enables bidirectional communication between JavaScript and Erlang.
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">>).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');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%% 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">>).Register Erlang functions that can be called synchronously from JavaScript.
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.
Duktape has built-in CBOR (Concise Binary Object Representation) support.
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}).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.
Get NIF information. Used to verify the NIF is loaded correctly.
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)]).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).| 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 | 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() |
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.
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.
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/1is optional but can be used for immediate cleanup - Reference counting ensures contexts are not destroyed while in use
Performance benchmarks on Apple M4 Pro, Erlang/OTP 28:
| 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 |
| 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 |
| 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 |
| 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 |
| 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 validationWhen running untrusted JavaScript code, be aware of these limitations:
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.
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 from Erlang.emit() are returned as binaries to prevent atom table exhaustion. Known log levels (debug, info, warning, error) remain atoms for ergonomics.
This project is licensed under the MIT License - see the LICENSE file for details.
Duktape is also licensed under the MIT License.