"Everyone has a different path to reach their goal"
Following this I decided to write down some thoughts about FFI with bun js.
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
.
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 :/
#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 overwritingundefined
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.
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.