Foreign Function Interface (FFI)
Nexus interoperates with WebAssembly modules, allowing extension with functions written in Rust, C, or other languages that compile to WASM.
Importing WASM Modules
Load a WASM module with import external:
import external "math.wasm"
The module’s exports become available for binding.
External Bindings
Bind a WASM export to a Nexus name:
export external add_ints = "add" : (a: i64, b: i64) -> i64
external internal_helper = "helper" : (x: i64) -> unit
exportmakes the binding visible to other modules- The string literal after
=is the WASM export name - The type after
:must be an arrow type
Generic External Bindings
Polymorphic externals require explicit type parameters:
export external length = "array_length" : <T>(arr: &[| T |]) -> i64
Using an undeclared type variable (e.g., T without <T>) is a type error. This prevents typos from silently becoming type variables.
Type Mapping
| Nexus Type | WASM Type | Notes |
|---|---|---|
i64 |
i64 |
Direct |
float / f64 |
f64 |
Direct |
i32 |
i32 |
Via i64 casting |
f32 |
f32 |
Via float casting |
bool |
i32 |
0 = false, 1 = true |
string |
i64 |
Packed as (offset, length) pair |
unit |
(none) | No WASM parameter generated |
| Records | i64 |
Heap pointer |
Example
import external "utils.wasm"
external process_data = "process" : (val: float) -> float
let main = fn () -> unit require { PermConsole } do
inject stdio.system_handler do
let result = process_data(val: 42.0)
Console.println(val: string.from_float(val: result))
end
return ()
end
How to Write Bindings
A WASM module that exports functions with the correct signatures can be used from Nexus via external declarations. This section documents the ABI contract that the WASM module must satisfy, the Nexus-side declaration patterns, and how the compiler transforms types at the boundary.
FFI Parameter Encoding
The compiler transforms certain Nexus types when crossing the FFI boundary. Internal calls use packed representations, but external calls unpack them:
| Nexus Type | WASM Signature (external) | Notes |
|---|---|---|
i64 |
1x i64 |
Direct |
i32 |
1x i32 |
Direct |
float / f64 |
1x f64 |
Direct |
f32 |
1x f32 |
Direct |
bool |
1x i32 |
0 = false, 1 = true |
string |
2x i32 (ptr, len) |
Unpacked from internal i64 |
unit |
(none) | No parameter generated |
String parameters are the critical case. Internally, Nexus represents strings as a packed i64 ((offset << 32) | length). At the FFI boundary, the compiler automatically unpacks this into two i32 arguments — a pointer into linear memory and a byte length. The WASM export must accept these two i32s, not a single i64.
String return values go the other direction: the WASM export returns a packed i64 using the same (offset << 32) | length encoding. The caller is responsible for allocating memory and writing UTF-8 bytes into linear memory before packing the result.
Bool values are encoded as i32 in both directions — 0 for false, 1 for true.
Labeled Argument Reordering
Nexus uses labeled (named) arguments, but WASM functions are positional. The compiler converts labeled arguments to positional parameters sorted by label name (lexicographic order). This matters when the WASM export’s parameter order must match.
For example:
external write = "write_buf" : (content: string, offset: i64) -> i64
The WASM signature for write_buf will be (i32, i32, i64) -> i64 — the content string (unpacked to ptr + len) comes before offset, because "content" < "offset" lexicographically.
If you declare:
external send = "send_msg" : (to: i64, msg: string) -> bool
The WASM signature is (i32, i32, i64) -> i32 — msg (→ ptr, len) before to, because "msg" < "to".
When writing a WASM module, order your export’s parameters alphabetically by the label names used in the Nexus declaration.
WASM Module Requirements
A WASM module used via FFI must:
- Export named functions matching the WASM names in
externaldeclarations. - Use the correct parameter encoding as described above — especially the string split.
- Share linear memory with the Nexus caller. String pointers reference offsets in this shared memory.
- Export
allocate(i32) -> i32if the module returns strings or allocates memory that the caller reads. The Nexus runtime calls this to allocate space for data that crosses the boundary.
Declaring Bindings
Primitive Functions
When the WASM export uses only numeric types, the declaration is straightforward:
import external "mylib.wasm"
export external clamp = "clamp_i64" : (val: i64, lo: i64, hi: i64) -> i64
export external is_even = "is_even" : (val: i64) -> bool
The Nexus name (left of =) and the WASM export name (string literal) are independent.
String Functions
Declare string on the Nexus side — the compiler generates the two-parameter split:
import external "mylib.wasm"
external char_count = "char_count" : (s: string) -> i64
external repeat = "str_repeat" : (s: string, n: i64) -> string
A WASM export for char_count must have the signature (i32, i32) -> i64, and str_repeat must have (i32, i32, i64) -> i64. You never declare the split manually.
Wrapping with Opaque Types
For stateful resources backed by handles, wrap the raw i64 in an opaque type with linear ownership:
import external "mylib.wasm"
export opaque type Counter = Counter(id: i64)
external __counter_new = "counter_new" : (initial: i64) -> i64
external __counter_inc = "counter_inc" : (id: i64) -> i64
external __counter_free = "counter_free" : (id: i64) -> bool
/// Creates a new counter with the given initial value.
export let new = fn (initial: i64) -> %Counter do
let id = __counter_new(initial: initial)
let c = Counter(id: id)
let %lc = c
return %lc
end
/// Increments the counter. Consumes and returns the handle.
export let inc = fn (counter: %Counter) -> { value: i64, counter: %Counter } do
let Counter(id: id) = counter
let val = __counter_inc(id: id)
let c = Counter(id: id)
let %lc = c
return { value: val, counter: %lc }
end
/// Reads the current value without consuming the handle.
export let value = fn (counter: &Counter) -> i64 do
let Counter(id: id) = counter
return __counter_inc(id: id)
end
/// Frees the counter. Consumes the linear handle.
export let free = fn (counter: %Counter) -> unit do
let Counter(id: id) = counter
let _ = __counter_free(id: id)
return ()
end
The patterns at work:
opaque type— hides the constructor from importers. Only this module can construct/destructureCounter.%Counter(linear) — the type system enforces that every counter is eventually freed. You cannot silently drop it.&Counter(borrow) — read-only access without consuming the handle.- Consume-and-return — mutating operations destructure the handle, call the FFI function, then reconstruct and return. This preserves linear ownership across the boundary.
The WASM module is responsible for managing the actual state behind the handle (e.g., an ID-keyed table). Nexus only sees the i64 handle value.
Linking
The compiler resolves import external "mylib.wasm" at build time via wasm-merge. The referenced .wasm file path is relative to the importing .nx file. After merging, the final binary has no unresolved imports — all external functions are inlined.
See WASM and WASI for details on memory layout, the allocator protocol, and the full ABI specification.