Jeremy NguyenJeremy Nguyen

Using JS String Builtins in WebAssembly from Clang-compiled C code

Using the JS String Builtins feature in C code compiled to WebAssembly

  • By Jeremy Nguyen
webassembly
c
View on GitHub

In the past, writing a function in WebAssembly that could output a JavaScript string would have been pretty difficult, probably involving a wrapper function that decodes the bytes from the pointer and its length. However, due to the relatively new WebAssembly feature, JavaScript String Builtins, it's actually pretty simple now. This feature even has pretty decent support in JavaScript runtimes, being available in Chrome 130, Firefox 134, Safari 26.2, Node 24.5.0, and Deno 2.1.12

Here's the .wat code on how to do it:

;; example.wat
(module
  (type $i16_array (array (mut i16)))

  (import "wasm:js-string" "fromCharCodeArray"
    (func $fromCharCodeArray
      (param (ref null $i16_array) i32 i32)
      (result (ref extern))))

  (func (export "hello") (result (ref extern))
    (call $fromCharCodeArray
      (array.new_fixed $i16_array 12
        (i32.const 72)  ;; H
        (i32.const 101) ;; e
        (i32.const 108) ;; l
        (i32.const 108) ;; l
        (i32.const 111) ;; o
        (i32.const 32)  ;; ' '
        (i32.const 119) ;; 'w'
        (i32.const 111) ;; 'o'
        (i32.const 114) ;; 'r'
        (i32.const 108) ;; 'l'
        (i32.const 100) ;; 'd'
        (i32.const 33)) ;; '!'
      (i32.const 0)
      (i32.const 12))
  )
)

Compile this with wasm-as ./example.wat -o ./example.wasm --enable-reference-types --enable-gc, and then run it with Node using the following script. Since it doesn't need any imports, you can even import directly from the binary using WebAssembly ESM integration.

// index.js
import { hello } from "./example.wasm";

console.log(hello());
> node index.js
Hello world!
(node:69543) ExperimentalWarning: Importing WebAssembly module instances is an experimental feature and might change at any time

...except that's probably not a viable solution for most WebAssembly users. After all, if this is all we wanted to do, we might as well have written the code in AssemblyScript and have a more comfortable syntax. The biggest value proposition of WebAssembly is the ability to use preexisting libraries in other languages and be able to port them to JavaScript.

So, how would we do this in C?

Clang

Clang, fortunately, supports compiling to WebAssembly and can be used with the Reference Types WebAssembly feature. However, it currently isn't compatible with a good portion of the JavaScript string built-in functions. You can read more about it here on this GitHub issue: llvm/llvm-project#136594.

In short, of the thirteen functions available,3 six have a signature that can be represented in Clang.4 An additional two functions (fromCharCodeArray and intoCharCodeArray) rely on WebAssembly arrays implemented by the GC feature, which don't really make sense in the context of C. The remaining 11 functions cannot be represented in Clang due to the usage of the (ref extern) type. In Clang, reference types are defined by the __externref_t typedef, which corresponds to externref in WebAssembly. (ref extern) is a subtype of externref that explicitly excludes null. Using the typical __asm__ fallback does not work either. As Sam Clegg put it: "non-nullable reference types are not yet expressible in C or assembly.5"

More simply, if you attempt to compile and run this code, you'll receive an error during runtime.

// example.c
#include "stdint.h"

__externref_t js_from_code_point(uint32_t codePoint)
    __attribute__((import_module("wasm:js-string"),
                   import_name("fromCodePoint")));

__attribute__((export_name("hello"))) __externref_t hello()
{
  return js_from_code_point('H');
}
CompileError: example.wasm: WebAssembly.Module(): Imported builtin function "wasm:js-string" "fromCodePoint" has incorrect signature @+29

As far as I know, there's no way around this with Clang alone. However, one way you can attempt to get around this restriction is by using the Binaryen tool wasm-merge, which allows you to merge Wasm binaries together.

Let's start with some more WAT. This will be a module, wasm-js-string.wat, that only exports the JS string builtins, but wraps them in a function that has a signature usable by Clang. This works because (ref extern) is a subtype of externref. Note that the exports all have a "_" prefix; this is because wasm-merge also merges the exports together, and we want to prune those after merging. The documentation claims that wasm-metadce should be used to do this,6 but that seems complicated, so I think we can get away with just using wasm-opt (which we'll want to do anyway to get rid of the wrapper functions).

;; wasm-js-string.wat
(module
  (import "wasm:js-string" "fromCodePoint"
    (func $fromCodePoint (param i32) (result (ref extern)))
  )
  (import "wasm:js-string" "concat"
    (func $concat (param externref externref) (result (ref extern)))
  )
  (func (export "_fromCodePoint") (param $codePoint i32) (result externref)
    (call $fromCodePoint (local.get $codePoint))
  )
  (func (export "_concat") (param $a externref) (param $b externref) (result externref)
    (call $concat (local.get $a) (local.get $b))
  )
)

Then, in the C code, we simply declare them as normal imports. Notice the name: you can't use the wasm: prefix in modules, so these are instead wasm-js-string. Here, we just create a function called hello that returns the string "Hello World!". While in this code I'm iterating through the string using the null terminator, I did not notice any compile-time optimizations being made when I replaced it with __builtin_strlen.

// example.c
#include "stdint.h"

__externref_t js_from_code_point(uint32_t codePoint)
    __attribute__((import_module("wasm-js-string"),
                   import_name("_fromCodePoint")));

__externref_t js_concat(__externref_t a, __externref_t b)
    __attribute__((import_module("wasm-js-string"), import_name("_concat")));

const char *str = "Hello world!";

__attribute__((export_name("hello"))) __externref_t hello() {
  __externref_t output = __builtin_wasm_ref_null_extern();
  for (int i = 0; str[i] != '\0'; i++) {
    __externref_t js_char = js_from_code_point((unsigned char)str[i]);
    if (i == 0) {
      output = js_char;
    } else {
      output = js_concat(output, js_char);
    }
  }
  return output;
}

Now, for the excessively complicated way of compiling this code, we begin by compiling the C with clang: clang --target=wasm32 --no-standard-libraries -Wl,--no-entry -o ./example.wasm ./example.c. The --no-standard-libraries is not necessary here; you could use Emscripten or wasi-libc for the sysroot; it's only added for brevity.

We have to then compile that earlier wasm-js-string.wat into a .wasm file using wasm-as: wasm-as ./wasm-js-string.wat -o ./wasm-js-string.wasm --enable-reference-types --enable-gc .

Afterwards, these two WebAssembly binaries must be merged together. Note the order: the module that is imported from has to be first. wasm-merge ./wasm-js-string.wasm wasm-js-string ./example.wasm example -o ./example-merged.wasm --enable-reference-types --enable-gc.

Finally, we run wasm-opt on the merged file to get the final binary. The only necessary argument is --remove-exports="_*", which removes the exported wrapper functions. From what I could tell -O3 was the only argument needed to get the file to the smallest size I observed. I hoped one of the wasm-opt's string arguments such as --string-gathering, --string-lifting, --string-lowering, etc. would somehow figure out that the function returned the same string every time and optimize it simply to an imported global string constant, but it could not figure that out. However, it was able to get rid of the unnecessary wrapper functions, which is very pleasing.

wasm-opt \
  --enable-gc \
  --enable-reference-types \
  --remove-exports="_*" \
  -O3 \
  ./example.wasm \
  -o ./example.wasm

And finally, once again, we can do what we already did before and run it in Node.

// index.js
import { hello } from "./example.wasm";

console.log(hello());
> node index.js
Hello World!
(node:61231) ExperimentalWarning: Importing WebAssembly module instances is an experimental feature and might change at any time

Here is the text form of the final Wasm binary. The interesting bit is that it builds the string directly as a JavaScript string; typically, you would allocate some string in WebAssembly memory, decode it to a JavaScript string, and then free it. It looks fairly similar to the actual C implementation; hopefully in the future, it'll be able to be compiled down to a more straightforward syntax.

;; example.wat
(module
 (type $0 (func (param i32) (result (ref extern))))
 (type $1 (func (param externref externref) (result (ref extern))))
 (type $2 (func (result externref)))
 (import "wasm:js-string" "fromCodePoint" (func $fimport$0 (type $0) (param i32) (result (ref extern))))
 (import "wasm:js-string" "concat" (func $fimport$1 (type $1) (param externref externref) (result (ref extern))))
 (global $global$0 (mut i32) (i32.const 65536))
 (memory $0 2)
 (data $0 (i32.const 65536) "Hello World!")
 (data $1 (i32.const 65554) "\01")
 (export "memory" (memory $0))
 (export "hello" (func $0))
 (func $0 (type $2) (result externref)
  (local $0 i32)
  (local $1 i32)
  (local $2 (ref extern))
  (local $3 externref)
  (global.set $global$0
   (local.tee $0
    (i32.sub
     (global.get $global$0)
     (i32.const 16)
    )
   )
  )
  (i32.store offset=12
   (local.get $0)
   (i32.const 0)
  )
  (loop $label
   (if
    (i32.load8_s
     (local.tee $1
      (i32.add
       (i32.load
        (i32.const 65552)
       )
       (i32.load offset=12
        (local.get $0)
       )
      )
     )
    )
    (then
     (local.set $2
      (call $fimport$0
       (i32.load8_u
        (local.get $1)
       )
      )
     )
     (local.set $3
      (if (result (ref extern))
       (i32.load offset=12
        (local.get $0)
       )
       (then
        (call $fimport$1
         (local.get $3)
         (local.get $2)
        )
       )
       (else
        (local.get $2)
       )
      )
     )
     (i32.store offset=12
      (local.get $0)
      (i32.add
       (i32.load offset=12
        (local.get $0)
       )
       (i32.const 1)
      )
     )
     (br $label)
    )
   )
  )
  (global.set $global$0
   (i32.add
    (local.get $0)
    (i32.const 16)
   )
  )
  (local.get $3)
 )
)

Benchmarking

Performance is probably not the main concern, but still, let's try to compare it. You can see the benchmarking setup here: jeremy-code/c-js-string-builtins/tree/benchmark. I compiled it with the wasi-libc sysroot just in case using __builtin_strlen could produce more optimized code.

The binary using the JS string builtins is similar to what has already been discussed. The other binary is using a more typical method of using the TextDecoder API to decode the bytes directly from the WebAssembly memory.

// example-import.c
#include "constants.h"

#include "stdint.h"

__externref_t decode_string_to_js(const char *buf_ptr, uint32_t length)
    __attribute__((import_module("./helpers.js"),
                   import_name("decodeStringToJs")));

__attribute__((export_name("hello"))) __externref_t hello()
{
  return decode_string_to_js(BIG_STRING, __builtin_strlen(BIG_STRING));
}
// helpers.js
export const memory = new WebAssembly.Memory({ initial: 2 });

const textDecoder = new TextDecoder();

/**
 * @param {number} buf_ptr
 * @param {number} length
 */
export const decodeStringToJs = (buf_ptr, length) =>
  textDecoder.decode(new Uint8Array(memory.buffer, buf_ptr, length));

The benchmark setup is simply the default settings of Vitest and tinybench. They're both using WebAssembly ESM intgration, so that shouldn't be a contributing factor. Here are the results on an M1 MacBook Air running Node.js v26.13.0

Benchmark results
namehzminmaxmeanp75p99p995p999rmesamples
Imported TextDecoder1,924,080.730.00040.21350.00050.00050.00090.00110.0023±0.30%1853049
JS string builtins
1,719.540.54321.71030.5890.57541.03231.28281.5151±0.73%1698

Yikes, that's pretty bad, but probably to be expected. There probably aren't many people using this for it to become better optimized, especially compared to the TextDecoder API. The feature is also relatively new, so it may improve in the future.

Conclusion

JS String Builtins can be used in WebAssembly. Using them in C source code, however, is a pretty complicated process that I hope in the future can be simplified more. As of right now, it seems that using the string builtins is a good deal slower compared to TextDecoder; hopefully this improves as time passes.

PreviousNative, isomorphic, asynchronous decompression in JavaScript

Comments

There are currently zero comments. Leave a comment on Mastodon or any Fediverse-compatible instance.