inwasm
TypeScript icon, indicating that this package has built-in type declarations

0.0.13 • Public • Published

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).

What is InWasm good for?

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 with cargo 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 as IWasmBytes<T extends IWasmDefinition>
  • MODULE - WebAssembly.Module, typed as IWasmModule<T extends IWasmDefinition>
  • INSTANCE - WebAssembly.Instance, typed as IWasmInstance<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 containing InWasm 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 containing InWasm calls are guaranteed to execute from import.
  • InWasm may not be indirected, it must be called for every given wasm definition as InWasm({...}). 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 {...} to InWasm, e.g. InWasm({... definition details go here ...}). Using an object identifier like const 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.

Testing with InWasm

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.

Readme

Keywords

none

Package Sidebar

Install

npm i inwasm

Weekly Downloads

35

Version

0.0.13

License

MIT

Unpacked Size

299 kB

Total Files

45

Last publish

Collaborators

  • jerch