Building a Tiny Custom JavaScript Runtime with Duktape and WebAssembly
This article explains how to create a lightweight, embeddable JavaScript runtime using Duktape, compile it to WebAssembly, expose custom APIs, and integrate it into a web-based login greeter, highlighting implementation steps, code examples, and potential use cases.
Background
This short experiment started after noticing the custom JS Runtime feature in the lightdm-webkit2-greeter plugin, which allows using web technologies to design login screens and interact with the OS via a set of JS APIs for actions like login, shutdown, and sleep.
The approach renders the system UI with WebKit and extends the JS runtime to bind native OS functions, similar to how Electron works but without a separate process, resulting in a smaller, more customizable solution.
Thoughts
Modifying a full‑featured engine like V8 or JavaScriptCore is extremely difficult, so a lightweight, portable engine is needed. Instead of directly binding OS functions, the author proposes first creating a Web‑based binding and running the runtime inside WebAssembly.
Demo
Start a JS Engine
Easy to port and modify.
Simple native‑JS interaction (type conversion, communication, event loop).
Small binary size and low memory footprint when compiled to WebAssembly.
The chosen engine is Duktape , an embeddable JavaScript engine focused on portability and low memory usage.
Duktape can be integrated by adding duktape.c, duktape.h, and duk_config.h to the build and using its API for two‑way calls between C and ECMAScript. The compiled size is under 64 KB and runs in 192 KB of memory.
Simple function to execute JavaScript:
extern "C" char* runScript(char* script){
duk_context *ctx = duk_create_heap_default();
duk_eval_string(ctx, script);
duk_pop(ctx); /* pop eval result */
duk_destroy_heap(ctx);
return "ok";
}Extend Some Runtime API
Implement I/O functionality.
static duk_ret_t native_print(duk_context *ctx) {
duk_push_string(ctx, " ");
duk_insert(ctx, 0);
duk_join(ctx, duk_get_top(ctx) - 1);
printf("%s
", duk_safe_to_string(ctx, -1));
return 0;
}Bind to the runtime.
duk_push_c_function(ctx, native_print, DUK_VARARGS);
duk_put_global_string(ctx, "print");The following diagram shows Duktape’s stack model:
Compile to WASM
Emscripten is used to compile the engine into a WebAssembly module and generate the JavaScript glue code.
Expose the JS execution function to the host environment.
int main(){
EM_ASM("console.log('wasm js runtime is ready!')");
EM_ASM("window.runScript = Module.cwrap('runScript','string',['string'])");
return 0;
}Specify exported functions during compilation.
CCOPTS += -s EXPORTED_FUNCTIONS=['_runScript','_main']Complete Makefile:
DUKTAPE_SOURCES = ./engine/duktape.c
CC = emcc
CCOPTS = -s DISABLE_EXCEPTION_CATCHING=0 -s ALLOW_MEMORY_GROWTH=1 -O3 --bind
CCOPTS += -s EXPORTED_RUNTIME_METHODS=["cwrap"]
CCOPTS += -s EXPORTED_FUNCTIONS=['_runScript','_main']
CCOPTS += -I./engine
BUILD = wasm/index.html
all: $(DUKTAPE_SOURCES) main.cpp
${CC} $(CFLAGS) $(CPPFLAGS) ${LDFLAGS} -o ${BUILD} ${DEFINES} ${CCOPTS} ${DUKTAPE_SOURCES} main.cpp ${CCLIBS}
run:
cd wasm && python3 -m http.server 8080Simple Test
make
make runThe resulting WASM bundle (glue code + binary) is just over 600 KB, which is acceptable for a single‑image deployment.
The custom JS runtime is now functional, offering:
Small size and on‑demand usage.
Complete isolation from the host JS runtime, enhancing security.
Good performance on the web because the WASM implementation runs outside the main JS thread.
Application Scenarios
JavaScript sandboxing.
Building plugin systems.
Porting the WASM artifact to WASI for true OS bindings.
References
Duktape – https://duktape.org/index.html
Emscripten documentation – https://emscripten.org/
How to build a plugin system on the web – https://www.figma.com/blog/how-we-built-the-figma-plugin-system/
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
