Writing Wren's JavaScript glue in Odin
Wren's JavaScript engine is the Odin engine (src/engine/jsrt, through the QuickJS C API of src/engine/jscapi) compiled
to one WebAssembly module (build/js/wren_js.wasm), run by Wasmtime. Around the engine sits the glue: the code that
implements the web platform's objects as native functions of that API and wires them to the browser's wr_* imports. All
of it is Odin (native/js/odin/; the binding tables are generated into bindings_gen.odin). No C of ours is compiled any
more: the module is linked by the wasm-ld that ships with Odin, with no C library (wj/libc.odin).
This document is the rule book: where things go, how to declare them, how to add a domain. The generic ABI that will replace the hand-written transmission layer is in odin-abi.md.
The layout
native/js/odin/ package odin_glue: the ported domains, one file per former .c (web_url.odin, dom_style.odin...)
ifaces.odin interface indices several domains share (resolved by name, cached)
bindings_gen.odin GENERATED by src/tools/bindgen (-emit:glue): the trampolines (wjg_*), the dictionaries (wjd_*), the member
and interface tables, wj_install_bindings
jsb/ package jsb: members bound to shared declarations (native/js/odin_abi/api_decl) and the
wr_* import that carries each, by comptime bridges (odin-abi.md)
wj/ package wj: the one helper layer every domain imports
wj.odin core types (JSValue = u64, Context, Runtime, Native/Getter/Setter)
quickjs.odin the static inlines of quickjs.h reimplemented on the NAN box (the API itself: backend_jsrt.odin)
engine.odin the engine's structs mirrored (Obj, Realm, Interface, Listener, Buf), its globals
(wj_the_realm, wj_scratch, wj_interfaces, wj_class_*), obj_of / web_set_impl / realm_*
host.odin what a domain calls of the engine and of the runtime that is not a value or a string helper
wr_imports.odin GENERATED (src/tools/bindgen, from api/*.widl): every browser import wr_*
strings.odin Str, WJ_FETCH_STRING (fetch_string), scratch helpers, JS-string constructors
values.odin promises as values, blobs, FormData, Headers, streams, signals, structured clone
errors.odin throwers (wj_throw_*) and error values (web_dom_exception...)
dom.odin dom_internal.h: Dom_Impl (mirrored) and the dom_core.c helpers
libc.odin libc / libm (libc_malloc / libc_free, strlen, memcmp, snprintf, fmod...)
convert.odin Web IDL conversions (was engine/qjs_convert.c): wj_to_str, wj_to_int32, wj_to_handle...
buffer.odin BufferSource helpers (was runtime/buffer.c): wj_to_buffer_source, wj_*_from_host...
promise.odin browser promises (was runtime/promise.c): wj_promise_from_host, wj_host_promise_textThe C that used to sit beside the glue is gone: gen/bindings.c (replaced by bindings_gen.odin), wj_shim.c (struct
layout asserts) and QuickJS-ng itself (native/js/{engine,jit,patches}). Two rules came out of the merge:
- the layer (
wj/) cannot call the glue (odin_glueimportswj) and a symbol cannot be declaredforeignwhere the same build defines it in Odin (one LLVM module: "Non-unique linking name"). A procedure of the glue that the layer calls is stored inwj.hooks(wj/hooks.odin) bywj_web_preinit; a variable the layer reads (wj_dispatch_trusted) is defined inwj/. A domain of the glue calls another domain unqualified, neverwj.<name>; jsb.hosttakes up to 14 arguments (jsb/hostn.odin);jsb.host_nis the same overload set.
Every symbol is declared once. odin check native/js/odin -target:freestanding_wasm32 -no-entry-point fails with
"Redeclaration of ..." the moment a second declaration appears; that is the point.
The WebAssembly JavaScript API (a page's own modules) is hand-written: webassembly-api.md.
The engine (WJ_JSRT)
wj.WJ_JSRT is a constant (true): the layer sits on the Odin runtime. wj/backend_jsrt.odin aliases the names of the
QuickJS C API to the procedures of src/engine/jscapi (the API implemented on src/engine/jsrt). The API types
(Context, Native, Class_Def, ...) are defined once in jscapi/types.odin and aliased in wj.
-define:JSCAPI_EXPORT=true also exports the API under its C names, which a few foreign declarations of wj/ resolve to.
Details and contracts: ../engine/jscapi.md.
Where a helper goes
| What it is | Where |
|---|---|
| a function of the QuickJS C API | implemented in src/engine/jscapi, aliased in wj/backend_jsrt.odin |
a quickjs.h static inline / macro (JS_NewInt32, JS_IsString, JS_UNDEFINED) | wj/quickjs.odin, reimplemented (NAN-box bit operations); never foreign |
a wj_* / web_* function of the C that stays | wj/host.odin (objects, realm, events, iterators), wj/values.odin (values), wj/errors.odin (throwers), wj/dom.odin (dom_internal.h) |
a helper of wj.h you port to Odin (a former engine/runtime .c everyone calls) | its own file in wj/ (like convert.odin), @(export, link_name = "<C name>") so the remaining C links against it |
a browser import wr_* | nowhere by hand: wj/wr_imports.odin is generated (see below) |
| a field of an engine struct | the mirror in wj/engine.odin / wj/dom.odin, plus its offset in wj_shim.c and the #assert block of engine.odin |
| a helper only one domain uses | @(private = "file") in that domain |
| a helper two domains use | wj/ if it is generic (strings, values), native/js/odin/<theme>.odin (package odin_glue) if it is about the domains (ifaces.odin) |
A domain calls everything of the layer qualified: wj.wj_to_str, wj.JS_NewStringLen, wj.wr_URL_get. Nothing of
wj is redeclared in a domain.
How to declare a browser import
You do not. src/tools/bindgen emits native/js/odin/wj/wr_imports.odin from api/*.widl on every build.bat. If an import is missing, it is missing from the API description: add it to the .widl (and the host side in
src/page), run build.bat, and it appears as wj.wr_<Name>.
Better still, do not call it by hand: declare the function in native/js/odin_abi/api_decl/<module>.odin and call
jsb.host(&out, api_decl.X, wj.wr_X, args...) (or bind the member with jsb.getter / jsb.method /
jsb.function): the strings, the result buffer, the status and the wire shape are then the compiler's
(odin-abi.md, "How to migrate a domain"). web_url.odin is written that way.
The generated conventions, which a hand-written call follows:
- handles are
u64, booleansi32(relative ? 1 : 0), enumsi32, strings(ptr: [^]u8, len: u32), string results(_buf: [^]u8, _cap: u32) -> i32(the full length, or a negativewr_Error); - callbacks are a function-table index
i32:wj.table_index(rawptr(my_export)); - the calling convention is
"contextless": a proc-typed parameter that receives awr_*import must beproc "contextless" (...), notproc "c"(wj.fetch_string(wj.wr_Window_origin)works as is).
How to add a domain
- Write
native/js/odin/<x>.odin,package odin_glue,import "../odin/wj". For what only carries a call to the host, declare it in api_decl and bind it with jsb (odin-abi.md, the 10 steps). - Name a hand-written member as the generated table (
bindings_gen.odin) expects:wjr_<Interface>_<member>,proc "c", thewjtypes. Error messages matter: WPT and the test pages compare them. - Interface indices by name, once:
wj.iface("Element")cached in a file-private variable (orifaces.odin). odin check native/js/odin -target:freestanding_wasm32 -no-entry-point -default-to-nil-allocator, thenbuild.bat, then the tests of the domain (thejs,jsdom,jswebweb tests, the WPT folders; see the end).
Pitfalls (each one was hit)
- A global needs a link-time constant initializer. The glue is an object with no Odin startup code: a file-scope
variable whose initializer is not a constant is left zero. Numbers, enums, structs of numbers,
[N]string{...}and[N]cstring{"a", "b"}are constant; anilamong cstring literals is not (popover_statesread as all-zero:HTMLElement.popoverreturned""). Use""instead ofnil, or build the value in the domain's preinit. - C varargs take C types.
wj.wj_throw_type,wj.JS_ThrowTypeError,wj.snprintfare#c_vararg: passcstring,[^]u8,i32,f64. An Odinstring(akind := cond ? "a" : "b"is one) goes as a two-word struct and prints garbage: writekind: cstring = ....%.*stakes ani32length then a pointer. - A C string stops at the first NUL. A JS string may contain one; when the value matters (custom element names,
keys), keep the
JSValue(JS_ToString) instead of acstringround trip. Str.ptris[^]u8(may be nil for a nullable). Pass it to an import aswj.cstr_bytes(s.ptr); compare withwj.str_eq(p, "literal")(accepts[^]u8orcstring).wj.wj_this/wj.wj_unwrapreturn^wj.Obj: reado.handle,o.impldirectly (orwj.handle_of(o)/wj.impl_of(o)whenomay be nil).wj.obj_handle(v)/wj.obj_impl(v)take aJSValue.- Do not reimplement the NAN-box float encoding:
wj.new_float64(ctx, d)goes through the engine'sJS_NewNumber(an Odin copy of__JS_NewFloat64miscompiled a 64-bit wrapping subtraction).JS_IsStringincludes ropes,JS_IsBigIntshort big ints: usewj.is_string/wj.is_big_int, never a single-tag compare. - Two heaps. What the C freed with
free()is freed withwj.libc_free; engine-owned data made forwj_new_objectuseswj.malloc/wj.free(the engine's allocator); JS-owned bytes (JS_ToCStringLen,js_malloc) go back to the engine (JS_FreeCString,js_free).
Struct mirrors and the shim
The engine's structs (wj_obj, wj_realm, wj_interface, wj_listener, wj_buf, wj_str, dom_impl,
web_extracted) are plain wasm32 C (natural alignment, no bitfields): Odin's default layout reproduces them, so the glue
reads wj.wj_the_realm.protos[i] or o.impl in place, without a call. The first ports went through one-line C
accessors per field (three shims, 13 accessors); they are gone.
The mirrors were once checked against the C structs by wj_shim.c (_Static_assert(offsetof(...) == N)); the C structs
are gone with the C, and engine.odin still asserts the layout on the Odin side (#assert(offset_of(Obj, impl) == 36)),
so a change of a mirrored struct stops the build.
Building
src/tools/buildjscompilesnative/js/odin(the glue, with the engine ofsrc/engine) to one wasm32 object (odin build -build-mode:obj -target:freestanding_wasm32 -no-entry-point -default-to-nil-allocator -o:size) and links it with thewasm-ldof Odin'sbinfolder (--no-entry --gc-sections). There is no C library:wj/libc.odinhasmalloc/free(base:runtime's emmalloc overmemory.grow), the string functions,strtod, an Odinsnprintfoveranyvalues andldexp;sin,cosandpoware imports of the host (modulelibm,src/wasm/wasi.odin);expis fdlibm's, insrc/engine/jsrt/b_number.odin. A symbol that is declaredforeignand not defined is silently an importenv.*of the module: check the imports ofwren_js_raw.wasmafter a change (onlywasi_snapshot_preview1.{clock_time_get,random_get},libm.*and thewr_*modules are legitimate).build.batruns bindgen first, sowr_imports.odinis always current.
Testing a ported domain
Build: cmd /c build.bat. Then, as relevant to the domain, the subjects of the web tests: js, jsdom, jsweb,
jsload, workers, netapi, media (build\e2e_test.exe, docs/testing.md), build/test262 -quick, and src/tools/wpt -Only /<folder>/ -TimeoutMs 30000
for the WPT folders of the domain (the folders must be in wpt/suites.txt). A WPT "regression" is relative to
wpt/expectations, which may be older than the code: compare with a build of the commit before the port before
concluding (url's worker exclude= variants and webstorage's storage_session_window_noopener were already
different before any port).