Duktape
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 compileQuick 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 | Evaluation | Function Calls | CommonJS Modules
- Event Framework | Erlang Functions | CBOR Encoding/Decoding
- Utility | Metrics
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 timeoutCommonJS 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: debugComplete 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/1is 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 validationSecurity 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.