アヲタロウ 🥑


"Everyone has a different path to reach their goal"

Following this I decided to write down some thoughts about FFI with bun js.

CString vs JS-String

We want to pass an JavaScript UTF-8 String over to our foreign code.

While strings in JS always know their own length, C strings are just array's of data, terminated by zero. An conversion seems to be necessary and whenever we need to convert something we witness the epic fight between Native FFI and FFI + Napi.

Native FFI

Let us start with the native bun FFI API and an plain sample in C:

// bunffi.c
#include <stdio.h>

void print(const char* text) {
  puts(text);
}
// test.js
import { cc, FFIType } from "bun:ffi";
import source from "./bunffi.c" with { type: "file" };

const {
  symbols: { print },
} = cc({
  source,
  symbols: {
    print: {
        args: [FFIType.cstring],
    },
  }
});

const b = Buffer.from('Hello 🥑');

print(b);
console.log(b)

Output:

Hello 🥑
Buffer(10) [ 72, 101, 108, 108, 111, 32, 240, 159, 165, 145 ]

The conversion will be done by Buffer.from that is reaching out for the length, does a copy, and holds the data.

While the c-part remains very plain, we get hard issues on the JS end since out binding print will not accept anything but a Buffer. And a Buffer on the other Hand performs not with the natural JS at all.

So we need to encapsulate the conversion somewhere.

Bun offers CString for better CString handling. But be aware:

  const cs = new CString(ptr(Buffer.from('Hello?!')));
  
  console.log(cs); // works!
  print(cs);       // not working

The sole purpose of this CString class is to act as result container for C Bindings that return const char* or other string pointers.

In other words: It's a one way Road :/

FFI + NAPI

#include <node/node_api.h>
#include <stdio.h>

void print(napi_value text, napi_env env) {
  size_t size = 0;
  napi_get_value_string_utf8(env, text, NULL, size, &size);

  char *buff = calloc(size+1, sizeof(char));
  napi_get_value_string_utf8(env, text, buff, size+1, &size);

  puts(buff);
  
  free(buff);
}
const {
  symbols: { print },
} = cc({
  print: {
    args: ['napi_value', 'napi_env'],
  },
});

const str = 'Hello 🥑';
print(str);
console.log(str);

Output:

Hello 🥑
Hello 🥑

napi_env will always be overwritten by buns env variables. So the best thing is to pass it to the end of each binding. Since overwriting undefined hurts nobody, you can just 'hide' that parameter away.

This time the entire conversion is moved to the C side with also takes away the controll away from the user.

While this might be negative, the outcome works perfectly. We use native strings as input. The function integrates entirely into the JS context.

Summary

The strong point of the bun FFI is to give you everything as native as possible. But 'native' might not always deliver the demanded results.
At least nobody needs to learn C in order to use the foreign function.

The NAPI version moves all the time consuming conversion tasks over to the C side. This might not have an big impact on the overall performance, but also allows an fluent JS integration which might be what users expect.
The down side is, that most people dont can or want to write an proper NAPI binding on the foreign side. Code Generators might fit into this hole.

Unfortunately CString fails to be the best of both sides. Thats odd, but maybe this will change in the future.