InWasm - Inline WebAssembly for Typescript.
InWasm is a small bundler for inline standalone wasm libraries. It compiles and bundles the wasm source code inplace. Example with Typescript:
// src/xy.wasm.ts
import { InWasm, OutputMode, OutputType } from 'inwasm';
const getAdderInstance = InWasm({
name: 'adder',
type: OutputType.INSTANCE,
mode: OutputMode.SYNC,
srctype: 'C',
exports: {
add: (a: number, b: number) => 0
},
code: `
int add(int a, int b) {
return a + b;
}`
});
const adderInstance = getAdderInstance(); // optional argument: importObject
// use the wasm instance:
console.log(adderInstance.exports.add(23, 42));
Before we can actually use the JS script generated by the TS compiler, we need to run another build step to also compile the wasm source code:
# compile wasm source, may pull SDKs (depends on config settings)
inwasm lib/xy.wasm.js
# finally run the module, should output 65
node lib/xy.wasm.js
It is important to note, that the compile script will overwrite the original JS script (inplace rewrite). This is normally not an issue for TS projects (already has separated source files). If directly applied to JS sources, it will "destroy" your source files - currently you have to do a manual copy step with all nasty consequences for module imports and such. The inplace rewrite may be lifted in the future once the story, how to deal with plain JS sources, got sorted out.
The final JS file can be further processed or distributed as any other JS script.
How does it work internally?
The compile script inwasm
loads the JS files as normal module (currently no ESM support!)
and catches errors thown by InWasm
to get a hold of the callstack and the source code positions.
Next it evaluates the provided wasm definition (thats the literal object argument of InWasm
)
and calls the compiler backend for the srctype
with the content of code
.
If the compilation succeeds, the generated wasm binary gets base64 encoded and wrapped
into a runtime definition, which finally replaces the original wasm definition.
At runtime InWasm
returns a getter for the requested output type and mode. The getter approach helps
to lift the initialization burden of the wasm types from the loading phase (lazy eval).
Furthermore the getter memorizes decoded bytes and wasm modules (act as static singletons).
InWasm
good for?
What is TL;DR: Tiny standalone wasm helpers down to just one exposed function. Anything bigger - probably not, unless you love to write tons of glue code yourself.
The main purpose of the library is to provide an easy way for embedding small standalone wasm helpers. Note that beside the wasm bootstrapping there is no glue code provided, thus it cannot replace more complex solutions with additional JS bridging output (like emscripten with its runtime extensions, wasm-bindgen etc).
Due to missing glue code, things are very bare metal and you have to provide access and type wrappers
yourself (e.g. directly reading from wasm memory segments). This "embedded programming style" may sound
like a big drawback, but in fact TS/JS is often fast enough to get most things done in a timely fashion,
and just lacks proper performance at a certain point of some data conversion.
Thats where InWasm
can provide a much faster drop-in alternative to a pure TS/JS implementation,
normally with much smaller package footprint than a bigger wasm integration. InWasm
itself is at
~800 Bytes bundled, plus base64 string data of the generated binaries. Of course this further depends on
the amount of your own glue code.
Sidenote: Technically it would be possible to create a similar embedding experience for bigger wasm integrations. Well, that is questionable for several reasons, e.g. wasm files will grow really big, and inlining those in JS with base64 is a bad idea. Furthermore it would need proper interfacing of the additional JS glue code, which is definitely out of scope for this library.
For bigger wasm integrations you are better served with the high level interfaces offered by emscripten and/or rust with wasm-bindgen.
Supported Types and Modes
Source Types (srctype
):
-
'C'
- emscripten C, compiled as standalone wasm -
'Clang-C'
- using clang from emscripten SDK -
'Zig'
- preinstalled or autoinstall, compiled as freestanding -
'wat'
- compiled with wat2wasm -
'Rust'
- must be preinstalled currently withcargo
in PATH -
'custom'
- any custom build script
... TODO: document srctype extensions on wasm definitions, C++ runners ...
Output Types (type
):
-
BYTES
- Uint8Array (raw wasm bytes), typed asIWasmBytes<T extends IWasmDefinition>
-
MODULE
- WebAssembly.Module, typed asIWasmModule<T extends IWasmDefinition>
-
INSTANCE
- WebAssembly.Instance, typed asIWasmInstance<T extends IWasmDefinition>
Output Modes (mode
):
-
SYNC
- Getter bootstraps output type synchronously and returns it directly. -
ASYNC
- Getter bootstraps output type with asynchronous interfaces and returns a promise resolving to the output type (e.g.Promise<IWasmInstance<T>>
).
Note that SYNC output mode works only reliable in NodeJS and a web worker context, as browsers may restrict synchronous wasm module or instance creation in the main context.
Types & Type Inference
... WIP, types & inference patterns are still subject to change ...
InWasm
Coding Restrictions
Due to the way the compile script inwasm
works by partial execution and a macro like source code replacement,
there are some coding restrictions:
- In general modules containing
InWasm
calls should be endpoints in the module dependency tree (not containing complicated imports itself, no cycling imports). They may not import other modules containingInWasm
calls, or compilation will fail. - All
InWasm
occurences must execute on import of the JS file. This can easily be achieved by always declaring them on top level respectively always reachable from there (also allowed in nested non-branching structures). Declaring them behind conditional branching will not work, if not all branches containingInWasm
calls are guaranteed to execute from import. -
InWasm
may not be indirected, it must be called for every given wasm definition asInWasm({...})
. Doing the following will not work:const a = (def) => InWasm(def); a(def1); a(def2);
. - The wasm definition argument must be provided as a real object literal
{...}
toInWasm
, e.g.InWasm({... definition details go here ...})
. Using an object identifier likeconst def = {...}; InWasm(def);
will not work. While technically not needed, this is currently enforced by the compile script to keep proper TS type inference working.
Config Options
... WIP, more config settings yet to come, env overrides still partially broken ...
With a file inwasm.config.js
in your project root you can configure some settings of inwasm
:
// default - autoinstall zig and emsdk
// (no config file needed, if you are good to go with these)
module.exports = {
zig: {
version: 'master', // pulled sdk version
store: 'project' // where to store: 'project' or 'inwasm' folder
},
emsdk: {
version: 'latest',
store: 'project'
},
};
// alternatively with custom paths to preinstalled sdks
module.exports = {
zig: {
binary: '/path/to/zig' // zig binary path (use '$PATH' or 'zig' if in PATH)
},
emsdk: {
path: '/path/to/emsdk' // emsdk installed elsewhere
},
};
For on-the-fly config overrides it is possible to use env variables, where the name of the env variable is derived from the config object keys:
# config.zig.version --> INWASM_ZIG_VERSION
# config.emsdk.path --> INWASM_EMSDK_PATH
# and so forth ...
# example: set custom zig binary override
INWASM_ZIG_BINARY=/path/to/zig inwasm lib/*wasm.js
Imports / Exports
Imports and exports can be directly attached to the wasm definition:
const importObj = { env: {...} };
InWasm({
// exports should be inlined for proper type inference
exports: {
exportedFunctionName: type_stub_of_function,
...
},
// imports should be referenced, otherwise object is lost at runtime
imports: importObj
});
Exported function names are used to populate EXPORT directives of the compiler,
where supported (not applied for srctype wat
and custom
).
Exported values are not used for anything else beside type inference by TS, thus only need to reflect the proper type inteface, e.g. for an exported function it is enough to stub a function with the right argument types and return type.
Imported names are not further evaluated in the compiler runners beside a memory
entry. Currently only clang exposes an interface to declare imported symbol names
upfront (--allow-undefined-file switch), which is not yet applied by inwasm
.
Thus you have to take care yourself to match expected imports later at runtime.
The importObj
should not be declared inline, unless you provide a similarly shaped
object at runtime by other means. (Note: every JS declaration within a wasm definition
will be lost at runtime.)
At runtime importsObj
should be provided as argument to the getter of IWasmInstance
or any manual instance creation.
Note on other import/export symbol types than memory or functions - WASM furthermore
allows types of WebAssembly.Global and WebAssembly.Table to be imported or exported.
Your success here may vary, as some compilers dont support them equally, thus inwasm
does not further deal with those.
Memory Settings
The runtime memory of the wasm module can be configured by applying a memory
entry either
in exports
(compiled as exported memory) or in imports.env
(compiled as imported memory):
-
exported memory:
InWasm({ ... exports: { ... memory: WebAssembly.Memory({initial: 1, maximum: 1}), ... }, ... });
-
imported memory:
const importObj = { env: { ... memory: WebAssembly.Memory({initial: 1, maximum: 1}), ... } }; InWasm({ ... imports: importObj, ... });
If no memory setting was given to a wasm definition, the module will default to exported memory of a certain size for most compilers, unless otherwise stated by other means (e.g. an explicit memory import directive within the code). Most of the time this will lead to much bigger initial memory allocations than needed at runtime (up to several MBs depending on compiler), or even wrong maximum limits. Therefore it is almost always a good idea to declare the memory explicitly to keep the footprint as small as possible.
Very small wasm functions might not need any memory at all. This edge case can occur for pure reentrant functions, that dont rely on any outer state (global static data), do no stack or heap interactions and handle all needed data through arguments and the return value. (Also note that the term "stack" might be misleading with wasm, as the stack is not used the same way as on other architectures, e.g. wasm-native local variables dont live on that "stack".) Such a no-memory mode is not directly possible with some compilers, as they assume some sort of memory being attached, even if unused. It still can be faked with some compilers by importing or exporting a 0-page memory, which should strip any memory notion from the wasm file, if it is really free of any memory access (double check the final wat file). Always do proper runtime checks with such an aggressive optimization.
Set stack size to zero:
- emscripten: add switch
'-s TOTAL_STACK=0'
- clang: add switch
'-Wl,-z,stack-size=0'
- zig: add switch
'--stack 0'
- rust: add switch
'-Clink-args="-z stack-size=0"'
Builtins for memory.size
and memory.grow
(useful for writing own allocator):
- emscripten/Clang:
__builtin_wasm_memory_size(0)
,__builtin_wasm_memory_grow(0, delta)
- zig:
@wasmMemorySize(0)
,@wasmMemoryGrow(0, delta)
- rust:
memory_size(0)
,memory_grow(0, delta)
where delta
is the number of memory pages to be added and 0
the memory identifier
(currently restricted to just one memory).
WASM features
Due to the way inwasm
operates your nodejs version of the build process should support any WASM feature
used in your definitions (you can test support with the npm package wasm-check).
If you are bound to an older node version or use cutting edge features, you can try to enable missing features with these node cmdline switches:
- Reference types (--experimental-wasm-anyref)
- BigInt between js and wasm (--experimental-wasm-bigint)
- Bulk memory operations (--experimental-wasm-bulk-memory)
- Exceptions (--experimental-wasm-eh)
- Multi values (--experimental-wasm-mv)
- Tail recursion calls (--experimental-wasm-return-call)
- Saturated (non-trapping) conversions from float to int (--experimental-wasm-sat-f2i-conversions)
- Sign/zero extensions (--experimental-wasm-se)
- SIMD (--experimental-wasm-simd)
- Threads (--experimental-wasm-threads)
- Type reflection (--experimental-wasm-type-reflection)
Currently the compiler runners are not yet fully prepared to apply additional wasm features correctly to all build steps and thus might break for certain non-default features. This will be sorted out with the next PRs.
InWasm
Testing with InWasm
declarations should not be used directly in mocha tests, as the needed code transformations may not
run on import by inwasm
, if placed inside of mocha's test suite calls like describe
or it
,
thus mocha would always end up with a must run "inwasm"
error. Also running inwasm
on such a test file
will create various errors for unresolved mocha defines. (This repo uses a very limited mocha_shim
to overcome this, but thats not meant for general purpose testing needs.)
Instead place the InWasm
definitions into a separate module imported by the tests and run inwasm
on the separate modules before calling mocha on the test files.
Isolated testing and debugging of the wasm code itself is currently very limited. A future version may provide better support, until then use close unit/API testing on TS/JS side. Since things are currently really bare metal, this might even involve writing your own console logging shims.
Development
The source repo contains two node package folders:
-
/inwasm
- inwasm package with cli tool and definitions -
/testproject
- main test package for different compiler runners/SDKs
Since /testproject
depends on /inwasm
, initialize in this order:
git clone https://github.com/jerch/inwasm.git
# setup inwasm first
cd inwasm/inwasm
npm install
# then the testproject
cd ../testproject
npm install
TODO
- ESM support
- more tests
- option to write to different file
- better docs
State
Still alpha, use at your own risk. Tested to work on Linux, macOS and Windows.