Bindgen
Bindgen for Bun
The bindings generator reads every *.bind.ts file under src/ and generates
the glue between JavaScript and Rust: the C++ that checks and converts
arguments with JavaScriptCore's own API, the Rust that receives them already
converted, and the TypeScript types src/js sees.
A binding's Rust code never sees a CallFrame, never converts a value by hand,
and cannot keep a pointer into JavaScript-owned memory for longer than it is
valid: the borrow checker refuses.
The generator lives in src/codegen/bindgen*.ts, the Rust it generates against in
src/jsc/bindgen.rs, and the C++ in src/jsc/bindings/Bindgen/ and
src/jsc/bindings/BunIDL*.h.
The body of a binding is always Rust. $cpp and $newCppFunction reach a function written in C++ from src/js.
Functions#
Describe the function in a .bind.ts file next to the Rust file:
import * as b from "../codegen/bindgen-lib.ts";
export default b.module({ rustPath: "crate::bindgen_test" });
export const add = b.fn({
args: {
a: b.i32.wrap,
b: b.i32.wrap.nullable.default(-1),
},
returns: b.i32,
capability: "heap",
throws: true,
});and write it:
use crate::bindgen::{self, JsHeap};
pub fn add<'a>(heap: &JsHeap<'a>, a: i32, b: i32) -> bindgen::Result<'a, i32> {
a.checked_add(b)
.ok_or_else(|| heap.error(format_args!("Integer overflow while adding")))
}b.module says where the file's functions are; an export named
requiredAndOptionalArg is required_and_optional_arg there. A rustPath key on
a function gives its full path instead. The arguments after the last that is not written
.optional, .nullable, .falsyAsNull, .emptyStringAsNull, .otherAsNull, .ifPassed or .default() are the ones
TypeScript lets a caller leave out. The function's .length is the number of the others,
unless length sets it.
A function of a node:* module names what Node's own source expects of an argument:
expected: { data: ["Buffer", "TypedArray", "DataView", "string"] }. A value the argument's type
refuses then throws Node's ERR_INVALID_ARG_TYPE(name, expected, actual), "The "data" argument must
be of type string or an instance of Buffer, TypedArray, or DataView. Received null", and not "data
must be a string, ArrayBuffer or ArrayBufferView". What the type takes is the same. A string in the
place of the list is the whole message: Node's C++ writes its own out that way, and so do the Bun APIs
that have a message of their own. A dictionary named in expected reports what is not an object as the
argument, and not under the dictionary's name. A range has its own errors, so it is not named in expected.
Nothing else is written by hand, except once per crate: src/<crate>/generated_bindgen.rs
includes the generated file, the crate root declares mod generated_bindgen, and a
build.rs like src/semver_jsc/build.rs says where the generated file is, for a cargo check
outside of bun bd. If the Rust signature does not match the schema, the generated code does
not compile.
To reach the function:
- from
src/js:$bindgen("bindgen_test.bind.ts", "add"), which is typed from the schema. An argument's type is what it takes, so one that coerces (.loose,.wrap,.enforceRange()) isany. A.nullableresult, and a.nullablegetter without a setter, isT | null; - from a C++ static property table:
Bun::Bindgen::Generated::BindgenTest::jsAddinGeneratedBindgenTest.h. The table gives its.length, and the schema'slengthis not read; - from Rust, or from C++ that compares host functions:
export: "Bun__CronJob__onPromiseResolve"gives the host function C linkage under that name. The generated header declares it, and in Rust it is aJSHostFn,crate::generated_bindgen::cron::Bun__CronJob__onPromiseResolve, forJSFunction::createandJSValue::then. What makes the function gives its.length, solengthis refused.
passThis: true adds this_value: JsLocal<'a> after the capability, and passCallee: true adds callee: JsLocal<'a>, the
function that was called, after it.
b.value({ returns: b.i32 }) is for what src/js reads once: $bindgen calls the Rust function and gives what it returns,
and no JS function is made.
The caller's source location is global.get_caller_src_loc(): inside a host function vm.topCallFrame is its own frame.
Namespaces#
b.namespace is a plain object that holds functions, like Bun.semver. Each is written as
the argument of b.fn is:
export const semver = b.namespace({
satisfies: {
args: { version: b.string.loose.utf8, range: b.string.loose.utf8 },
returns: b.bool,
throws: true,
},
order: { .. },
});Bun::Bindgen::Generated::SemverObject::createSemver makes the object. It has the signature of
a PropertyCallback of a static property table, so the object is made when the property is
first read. $bindgen("SemverObject.bind.ts", "semver") makes one for src/js.
A namespace that can itself be called, like Bun.hash, names the function that a call runs:
b.namespace({ wyhash: { .. }, crc32: { .. } }, { call: "wyhash" }). The object is then a
function named after the namespace.
A function with overloads is an array of its signatures. A function of a namespace has no passThis, passCallee or export.
What a function may do: capability#
What a function can do to the JS heap is a parameter it is given, so it is visible in the schema, in the signature and to the compiler.
capability | First parameter | The function can |
|---|---|---|
"none" (default) | none | compute |
"heap" | &JsHeap<'a> | also allocate JS values and describe an error to throw |
"js" | &mut JsScope<'a> | also run JavaScript, or anything that can call back into native code |
Ask for the least that works. The less a function can do, the more it is handed
directly: a function that cannot run JavaScript gets &[u8] for a typed array
and &mut self for its object, because nothing can invalidate either while it
runs.
The compiler holds a body to its capability as far as this API goes. The older API has
other ways to a &JSGlobalObject (VirtualMachine::get().global(), a field of the struct), and a
body that takes one of those to run JavaScript has stepped around the check.
Much of the older API takes a &JSGlobalObject only to allocate or to throw. A "heap"
function calls it with heap.global(), which is unsafe: nothing the global object is passed
to may run JavaScript, and the compiler cannot see that. Read what is called all the way down
before writing the SAFETY comment. ToString of an object, a property that is looked up or
set and not defined, and a call to a constructor found on the global object all run JavaScript.
Throwing tells the debugger, which can run code, so after a throw the body returns without
reading what it borrowed. A mutates: true method does not throw: what the debugger runs can call
a method of the same object while the &mut self is live. It returns the error, which is thrown
after the borrow ends. Every heap.create_* that returns a Result throws at once, except
create_utf16_string_from_vec, so a mutates: true method calls none of the others.
// SAFETY: parsing runs no JavaScript. Nor does making the result: its keys are primitives, and its properties are
// defined and not set.
let global = unsafe { heap.global() };throws: true makes the return type a Result. The error is a
bindgen::Error<'a>, made by heap.error(..), heap.type_error(..),
heap.range_error(..), heap.error_with_code(ErrorCode::OUT_OF_RANGE, ..), heap.invalid_arguments(..),
heap.string_too_long(), heap.memory_allocation_failed() or
bindgen::Error::from_value(value) (? makes one of a JsError or an AllocError), or any
type that implements bindgen::IntoError (bun_sys::Error,
SystemError, JsError and a Box of one do), which lets a capability: "none" function fail too.
cx.global() is the &JSGlobalObject, for code that takes one. It borrows all of
cx. Such a function can return JsResult<T>, and a plain JSValue under
returns: b.any.
Promises: rejects#
A function that returns a promise reports an error by rejecting one. With rejects: true, what the body
throws and what the conversion of an argument throws become the reason of a rejected promise
(JSPromise::rejectedPromiseWithCaughtException), so the body returns Err like any other.
Too few arguments: requiredArguments#
An argument that was not passed is undefined, so f() and f(undefined) are the same call and the type of the
argument reports it. With requiredArguments: 1, a call that has fewer arguments than that throws ERR_MISSING_ARGS
before any is converted, as WebIDL asks of a Web API: URL.revokeObjectURL(), and fetch(), which has rejects
and so rejects with it. The message is Node's, The "url" argument must be specified. It names all the
requiredArguments, however many the call has, as the first signature of overloads calls them. TypeScript and
.length count them as required. The key also goes on a method and on construct.
Overloads#
b.overloads(a, b, ..) is one JS function with several signatures. Each names its
own Rust function, which gets exactly the types of that signature:
export const read = b.overloads(
{ args: { path: b.string.utf8, options: ReadOptions.default({}) }, rustPath: "crate::read_path" },
{ args: { fd: b.i32, buffer: b.bytes({ from: ["buffer"] }), offset: b.u32.default(0) }, rustPath: "crate::read_fd" },
);passThis, passCallee, export, rejects and requiredArguments belong to the JS function, so every signature gives them the same value.
Use it when the type of one argument says what the others are. A union for each argument would accept combinations that mean nothing, and leave the body to sort them out.
The generated function starts with every signature as a candidate and looks at the arguments from left to right:
- It keeps the candidates whose type at that position takes the kind of the argument.
- An argument that was not passed is
undefined. Sof(x)andf(x, undefined)are the same call. It keeps the candidates where the argument can be left out, or if there are none, those whose type takes every kind. - A candidate with no argument at that position takes only
undefined. - If no candidate takes the argument, those that have an argument at that position stay, and the next argument decides.
- When the arguments run out, the candidate written first wins. Its conversion reports whatever is wrong.
Choosing converts nothing and runs no JavaScript. A signature that no call can reach is an error when the schema is loaded.
Types#
Conversions are strict unless the schema says otherwise: b.i32 takes a number
that is an integer in range, and nothing else. A failed strict or
.validateInteger conversion, or a .loose one of an integer, throws
ERR_INVALID_ARG_TYPE or ERR_OUT_OF_RANGE naming the argument or the dictionary
member; .wrap, .enforceRange and the other .loose conversions throw what WebIDL does. An argument that was not passed is undefined and is converted like
any other: nothing looks at how many there are, except .ifPassed, requiredArguments and a body that takes b.any.variadic.
| Schema | Rust, as an argument | Takes |
|---|---|---|
b.bool | bool | a boolean; .loose is ToBoolean |
b.u8 b.u16 b.u32 b.u64 b.usize b.i32 | the same | a number that is an integer in range; see below |
b.f64 | f64 | a number; .loose is ToNumber; see below |
b.string | &bun_core::String | a string; .loose is ToString |
b.string.owned | bun_core::String | as b.string |
b.string.view | bun_core::StringView<'a> | as b.string |
b.string.utf8 | &'a [u8] | the same, and a String object |
b.string.utf8.owned | bun_core::Utf8Bytes<'static> | as b.string.utf8 |
b.bufferSource | JsLocal<'a, BufferSource> | a typed array, DataView or ArrayBuffer that is not shared |
b.bufferSource.arrayBuffer | ExternalShared<JSCArrayBuffer> | the same; the bytes of a view are copied |
b.bytes(options) | &'a [u8], and others | see Bytes |
b.byteSpan | &'a [u8] | not a JS value: bytes from C++ |
b.blob | ExternalShared<Blob> | a Blob |
b.callable | JsLocal<'a, Callable> | a function, or a proxy of one |
b.object | JsLocal<'a, JSObject> | an object |
b.jsArray | JsLocal<'a, JSArray> | an array, as itself |
b.any | JsLocal<'a> | anything |
b.any.variadic | &'a [JsLocal<'a>] | this argument and every one after it; only the last argument |
b.null | () | null or undefined; only in a union |
b.undefined | () | undefined; only in a union |
b.promisePtr("crate::T") | *mut T | the context of a promise reaction; see below |
b.nativePromiseContext("crate::T") | Option<NonNull<T>> | the same, when it is a NativePromiseContext cell |
b.array(T) | Vec<T> | an array; T holds no JS value, which the GC would not see |
b.enumeration(values) | a generated enum | one of the strings |
b.dictionary(members) | & a generated struct | an object that is not an array |
b.union(alternatives) | a generated enum | see Unions and kinds |
b.class(options) | &'a T | an instance |
One JS type has one name; a modifier picks the Rust form. The last four are named
types: each is called what it is exported as, and has to be exported. It is used by
schemas of the crate it is generated into. A member or an alternative also names a C++
field, so one that is a C++ keyword is refused: a member takes a rustName.
An array is what Array.isArray says is one, Array.prototype too, except a proxy of an array: that is an object
like any other. Only a dictionary refuses the proxy along with the array.
Only the promise calls a reaction, with the context it was given, so nothing tests that argument.
b.promisePtr is the *mut T that JSValue::then was given. b.nativePromiseContext is what take of the cell
returns: None if the pointer was taken back before the promise settled. No argument after it has a conversion that
throws: nothing would release the pointer. A generic T is written as its instantiation.
These come last, in this order (b.any already takes undefined and null, so only .falsyAsNull, .emptyStringAsNull and .ifPassed apply to it):
.optional:undefinedmeans absent;Option<T>..nullable:nullorundefinedmeans absent;Option<T>..falsyAsNull: every falsy value means absent;Option<T>..emptyStringAsNull:undefined,nullor""means absent, as forJSValue::get_truthy;Option<T>..otherAsNull: a value of a kind the type does not take, or that it refuses (what is not an object, a shared buffer), means absent and nothing is thrown, as if the type were in a union withignored: b.anyafter it;Option<T>. It is for an argument or a member that is ignored unless it is of the type. What is inside a value is still reported: the members of a dictionary, the elements of an array. It is not for a type that throws for a value of a kind it takes: an integer for1.5(b.f64.otherAsNulltakes every number), an enumeration for an unknown string (b.string.allowStringObject.otherAsNulltakes every string), a dictionary withoutallowArrayfor an array. Nor is it for a type that takes every kind, like a.looseone or anonObjectAsEmptydictionary. A union writesignored: b.anyas its last alternative..ifPassed: absent only if the call has no such argument, andundefinedis converted like any other value;Option<T>. It is for a function wheref()andf(undefined)both work and differ, likealert. Wheref()is an error, seerequiredArguments. It also goes on a type that takesundefined, likeb.any. It is only for an argument, and not for one of an overload..default(value): what to use when absent;T..nullable.default(value)also applies tonull.
.withValue on an argument also gives the body the argument itself, (T, JsLocal<'a>): for a body that reads the
internal fields of an instance, or returns the argument when there is nothing to change. It comes after all of these,
and not with .ifPassed.
As results, b.string.utf8 is anything that is AsRef<[u8]>, b.array(T) anything that
is IntoIterator, and b.array(b.string) anything that is AsRef<[bun_core::String]>: one call makes the array. b.bufferSource as a result is what
heap.create_uninitialized_uint8_array(length), heap.create_uint8_array(bytes),
heap.create_buffer(bytes), heap.create_uint8_array_from_box(bytes) or
heap.create_buffer_from_box(bytes) made. The last two take over the allocation, and the two
before them copy. b.jsArray as a result is what heap.create_array(items, to_js::u8) made.
These are results only:
| Schema | Rust | JavaScript gets |
|---|---|---|
b.u64.asBigInt | u64 | always a BigInt |
b.f64.asDate | f64 | a Date with that time value |
b.string.local | JsLocal<'a, JSString> | the string the body made: heap.create_latin1_string(bytes), heap.create_utf16_string_from_vec(units), heap.create_utf8_string(bytes) or heap.common_string(CommonString::..) |
b.record(T) | anything that is IntoIterator of (key, T) | an object with an own property for each pair; a key is UTF-8 and one that comes again takes the new value, T holds no JS value, and it is not a member or an alternative |
A body that returns b.any makes the value with what the generated code calls:
bindgen::to_js::record, to_js::array, to_js::utf8 and the others of that module. An empty
object is heap.create_empty_object().
Numbers#
A BigInt is refused unless the schema says .allowBigInt, which is for an argument whose
type is number | bigint. b.f64.loose.allowBigInt is ToNumeric, and a BigInt becomes the
double nearest to it. b.f64.allowDate also takes a Date, as its time value, which is NaN for an invalid one. On an integer:
.allowBigInt: a BigInt in the same range too. It is how ab.u64above 253 gets in. It goes with the strict conversion and with.loose; the others refuse a BigInt..loose:ToNumberfirst, orToNumericwith.allowBigInt, then as strict as the default..wrap: the WebIDL conversion,ToNumberthen wrap modulo the width..enforceRange(min, max): WebIDL[EnforceRange]..validateInteger(min, max): Node'svalidateInteger, with its exact errors. Use it when implementing Node.js APIs.
Strings#
b.string.utf8 is the one to reach for. When the JS string is 8-bit and ASCII the
slice points at the string's own bytes; otherwise it is transcoded into a buffer
in the generated function's frame, which holds 128 bytes and is sized for the worst
case up front: beyond 64 Latin-1 characters or 42 UTF-16 code units it allocates.
Nothing needs freeing. An unpaired surrogate becomes U+FFFD. .loose comes before
.utf8. A String object, which b.string refuses, is converted with ToString, so
its toString runs.
b.string.utf8.owned is for a body that stores the string: a
bun_core::Utf8Bytes<'static>, which shares the JS string's buffer when it is 8-bit
and ASCII and is a transcoded copy otherwise.
b.string shares the JS string's storage. As an argument it is a &bun_core::String. A
body that keeps it takes b.string.owned, which comes after .loose or .allowStringObject. Inside .optional, a union or an array, and as the value of a setter,
it is held by value. The string belongs to the
JS thread: see "Cross-thread string hazards" in src/CLAUDE.md before sending
one anywhere. b.string.allowStringObject also takes a String object, as .utf8 does.
b.string.view, which comes after .allowStringObject, is for a body that only reads the
characters, in Latin-1 or UTF-16: a bun_core::StringView<'a>, and a &bun_core::StringView<'_> as an argument of this type alone. It takes no reference to the
storage, and text.slice(..) is read in place, where b.string would copy it into a new string. The frame holds the JS string, so it is for an argument, alone, in a union or
with a modifier, and not in an array.
A class is a type too: import it from its .bind.ts file. As an argument it is a &'a of the struct.
The argument's slot in the frame holds the instance, so it is only for an argument, alone, in a union, or with one of the modifiers above except .default().
Dictionaries#
export const BracesOptions = b.dictionary({
tokenize: b.bool.loose.default(false),
parse: b.bool.loose.default(false),
});Each member is read, then converted, in declaration order, once. A member
whose type does not accept undefined is required. A member can be written
{ type, rustName, altNames, expected } to rename its field, to accept other spellings, or to report a wrong type as
a function's expected does.
A dictionary whose members can all be absent implements Default. As an argument, with
.default() or without, the struct stays in the frame of the thunk and the body borrows it:
options: &BracesOptions, or Option<&BracesOptions> with one of the modifiers that make it an Option. As a member, with .withValue, as the value of a setter, or inside a union or an array, it is held by value.
A member is looked up on the object and its prototype chain, stopping before
the calling realm's Object.prototype, so a polluted Object.prototype cannot
configure a Bun API. For a plain object, where the own properties are therefore
the whole answer, the member is read straight out of the object's property table. The two paths are indistinguishable to JavaScript;
test/internal/bindgen.test.ts holds them to that.
Returning a dictionary makes an object with room for its members inline and puts
each one on it. An .optional member that is None is left out.
The second argument of b.dictionary takes:
| Key | What it does |
|---|---|
allowArray | an array is read like any other object, not refused |
nonObjectAsEmpty | a value that is not an object gives every member its default; with "typeof" so does a function, as in Node's if (typeof options !== "object") options = null; every member must be optional or have a default |
fromJS | also generates Name::from_js(global, value), for a body that has a JSValue; one that holds JS values is lent to a closure, Name::from_js(global, value, |converted| ..) |
reportedAs | what an error calls the dictionary, which defaults to the name of the type: "options" makes a member options.level, and "" makes it level, for a Node function that validates under the bare name (validateUint32(N, "N")) |
nullPrototype | as a result, the object has no prototype, and the inline room of Object.create(null) |
Enumerations#
export const Formatter = b.enumeration(["highlight-javascript", "highlight-javascript-redacted", "escape-powershell"]);generates enum Formatter { HighlightJavascript, .. }; capitals in a row are a word, so "ECONNRESET" is Econnreset. An entry can be a list of
spellings; the first is the one returned to JavaScript. It also takes a String object,
except in a union. Returning an enumeration allocates nothing after the first time.
Values that make no identifier, or the same one, are written as a record, whose keys are the variants:
b.enumeration({ Rgb: "rgb", RgbArray: "[rgb]" }).
Unions and kinds#
Where there is a choice, between the alternatives of a union or between overloads, it is made on the kind of the value, with the cheapest test that tells the kinds in play apart:
| Looking at | Kinds |
|---|---|
| the bits of the value | undefined, null, boolean, number |
| the type byte in the cell's header | string, BigInt, array, ArrayBuffer, typed array or DataView, String object |
JSCell::isCallable | a function, or a proxy of one |
the cell's ClassInfo | an instance of a class, a Date |
A strict type takes the kinds its row under Types says. A type that coerces or
reports its own error takes every kind: .loose, .wrap, .enforceRange,
.validateInteger, b.any, b.bytes, b.object, a dictionary. So does an argument written .falsyAsNull,
.emptyStringAsNull or .otherAsNull.
A value goes to the first alternative, in written order, that takes its kind. That
alternative then converts it, and reports what is wrong with it: 1.5 given to
b.union({ integer: b.i32, text: b.string }) "must be an integer", not "must be of type integer or string".
The buffer types refuse some values of their kinds (a shared buffer); those go on to the next alternative that takes
the kind and does not refuse the same. What is not an object skips a dictionary or b.object in the same way, unless that is the last
alternative to take its kind, which then reports it. A value no alternative takes throws ERR_INVALID_ARG_TYPE listing them all.
So the order only matters between alternatives that overlap, and one that takes every value goes last. An alternative that can never be chosen is an error when the schema is loaded. Choosing tests whether the value is a cell once, loads the type byte once and repeats no test. Strings as UTF-8, dictionaries and unions are then converted in the place they end up in.
A union is returned as what the alternative it holds is. One that is only returned owns its
strings: b.string.utf8 in it is a Vec<u8>.
Buffers#
b.bufferSource is the buffer itself. JavaScript can detach, resize or transfer it, so the
body looks the bytes up when it needs them:
let before = buffer.with_bytes(&cx.heap, sum);
callback.call(cx, JsLocal::UNDEFINED, &[])?;
let after = buffer.with_bytes(&cx.heap, sum);Calling callback.call(cx, ..) inside the closure does not compile. A detached or
out-of-bounds view is empty. A shared buffer is refused, unless the type is
b.bufferSource.allowShared, as with WebIDL's [AllowShared]. A resizable one is taken either
way. To only read one, there is b.bytes.
buffer.with_bytes_mut(heap, |bytes| ..) is for a buffer the function writes. It is unsafe:
another argument can be the same memory, as in hash(data, data), so the closure must not use
bytes that were borrowed from one.
Bytes#
Many functions take "a string or a buffer" and want its bytes. They differ in which values they
take, in what becomes of anything else, and in how long they need the bytes. b.bytes takes
those as named options. The combinations that are build errors are listed at the end.
export const gzipSync = b.fn({
args: { data: b.bytes({ from: ["string", "buffer"] }), options: ZlibCompressionOptions.default({}) },
returns: b.bufferSource,
capability: "heap",
throws: true,
});fn gzip_sync<'a>(heap: &JsHeap<'a>, data: &[u8], options: &ZlibCompressionOptions<'a>) -> bindgen::Result<'a, JsLocal<'a, BufferSource>>| Option | Values | Meaning |
|---|---|---|
from | "string", "buffer", "blob" | what is taken without ToString |
otherwise | "throw", "toString" | anything else |
refuse | "undefined", "null" | with "toString": these throw, and are not "undefined" and "null" |
refuse | "stringObject" | with "throw": "string" is only a string |
string | "utf8", "asIs" | a string is its UTF-8, or stays a bun_core::String |
shared | "borrowed", "copied" | the bytes of a shared buffer |
held | "borrowed", "handle", "copied", "pinned" | what the body gets |
"string" is a string or a String object. "buffer" is a typed array, DataView,
ArrayBuffer or SharedArrayBuffer. "blob" is a Blob, or an output of Bun.build(), whose
bytes are in memory: a file, here or in S3, throws.
held | string | Rust |
|---|---|---|
"borrowed" | "utf8" | &'a [u8] |
"handle" | "utf8" | JsBytes<'a> |
"handle" | "asIs" | StringOrJsBytes<'a> |
"copied" | "utf8" | Vec<u8> |
"pinned" | "utf8" | PinnedBytes |
Borrowed bytes are where they are. A string is converted into the frame of the host function, unless it is stored as ASCII already. JavaScript can detach, resize or free a buffer, so this is for a function that cannot run it.
A handle is for a function that can. JsBytes::get borrows the bytes from the JsHeap, and the
compiler refuses whatever takes the &mut JsScope until the slice is gone:
fn parse<'a>(cx: &mut JsScope<'a>, input: JsBytes<'a>) -> JsResult<JSValue> {
let tree = parse_tree(input.get(&cx.heap))?; // no JavaScript here
let global = cx.global(); // `tree` owns what it kept, or this does not compileEach get reads the pointer and the length of a buffer again. With string: "asIs" the body
gets either String, in Latin-1 or UTF-16, or Bytes, the handle. That also tells a string
from a buffer.
Copied bytes are the body's. Pinned bytes are for a job on another thread to read after
the call returns: a string is thread-isolated, a buffer is a GC-rooted PinnedArrayBuffer, and a
resizable one is copied. A PinnedBytes is Send, derefs to [u8], and is dropped on the JS
thread.
A shared buffer is borrowed like any other, as JSC's own toBase64 and indexOf read one, and
another thread that writes it during the call changes what the body reads. shared: "copied" is
for a body that reads a byte again and counts on what it was, such as a parser that scans ahead
and comes back. JSC's sort copies for the same reason. With string: "asIs" the body calls
JsBytes::get_or_copy_if_shared instead.
The kind of the value is checked, and a string converted, in the order of the arguments. The
bytes of a buffer are read, copied or pinned after every argument is converted. It is not a
member of a dictionary, a union or an array. held: "borrowed" can be .optional or
.nullable: an Option<&'a [u8]>. string: "asIs" can be .nullable: an Option<StringOrJsBytes<'a>>.
An option that cannot apply is a build error: refuse of undefined or null without "toString",
of a String object with it or where no string is taken, string where no
string is taken, shared where no buffer is taken or held: "copied" copies it anyway. So is
what no function needs yet, which is not written: "asIs" without "handle" or that copies a
shared buffer, "pinned" that takes a Blob or copies a shared buffer, a "pinned" argument
next to another or next to a "borrowed" one, and an absent "copied", "pinned" or "handle" other
than .nullable of "asIs".
Values and lifetimes#
A JS value in Rust is a JsLocal<'a>: a JSValue tied to the host call it belongs
to. It is Copy, one register, and not rooted, because JavaScriptCore scans the
stack. The lifetime is what stops it from being stored in a struct, captured by a
task or sent to a thread. JsLocal<'a, T> is one known to be a JSString,
JSObject, Callable or BufferSource. value.to_js() gives the JSValue;
JsLocal::new(heap, value) goes the other way.
What outlives a call:
- one of a class's
internalFields(below), which the GC visits; - a
Strong, made withStrong::create(value.to_js(), cx.global())and read withJsLocal::new(heap, strong.get()). It is made, read and dropped on the JS thread.
A list of values goes in a MarkedArgumentBuffer, never a Vec or a Box: the GC does
not look inside heap allocations. The compiler does not check this one.
Classes#
export const Glob = b.class({
rustPath: "crate::api::glob::Glob",
construct: {
args: { pattern: b.string.utf8.owned },
},
proto: {
match: {
args: { str: b.string.utf8 },
returns: b.bool,
},
},
});A member is a function with a receiver. It is one of the struct's own methods, which the generated code calls by name:
pub(crate) struct Glob {
pattern: bun_core::Utf8Bytes<'static>,
}
impl Glob {
pub(crate) fn constructor(pattern: bun_core::Utf8Bytes<'static>) -> Self {
Glob { pattern }
}
pub(crate) fn r#match(&self, str: &[u8]) -> bool {
bun_glob::r#match(self.pattern.slice(), str).matches()
}
}| Parameter, in order | When |
|---|---|
| the receiver | not in static or construct |
this_value: JsWrapper<'a, Self> | passThis: true |
heap: &JsHeap<'a>, cx: &mut JsScope<'a> | capability "heap", "js" |
structure: JsStructure<'a, Self> | construct with passStructure: true |
one for each of args |
Arguments, results, capability and throws are those of a function. The generated code is in
crate::generated_bindgen, so a member is at least pub(crate).
The struct, the payload, is stored inside the JS object, the wrapper, so the
two have one lifetime and one allocation. It is dropped when the object is
collected. Drop gets no capability, so it cannot touch the JS heap, and no Rust
runs on the GC's threads, except estimated_size.
If a timer, another thread or a C library can still point at the struct after its JS object
is collected, make the class refCounted.
| Key | What it declares |
|---|---|
rustPath | the struct |
refCounted | the struct is a separate allocation that can outlive the object |
finalize | with refCounted: call fn finalize(&self) when the object is collected |
final | with refCounted: false lets hand-written C++ subclass JSName |
overridesToJS | with refCounted: impl JsClass is not generated; write it by hand with js_Name::to_js |
forBind | the receiver of every member is a function bound to an instance |
rustTraits | traits to bring into scope, for members that are trait methods |
prototypeBase | "Error": the prototype inherits from Error.prototype, and its name is the class's |
configurable | true makes the native properties configurable |
enumerable | false makes the native properties non-enumerable |
writable | false makes the native functions read-only. It is not the writable of a cached getter |
memoryCost | fn memory_cost(&self) -> usize, for a heap snapshot |
estimatedSize | fn estimated_size(&self) -> usize, for the GC |
construct | new Name(..). Without it JavaScript cannot make one; Rust can, with payload.into_js(heap). |
call | Name(..) without new, which otherwise throws. It is fn call(..), with no receiver. |
internalFields | JS values the object holds |
proto | properties of the prototype |
static | properties of the constructor: native functions and accessors without cache |
calledFromCpp | member functions of JSName that are defined in Rust |
calledFromRust | member functions of JSName that hand-written C++ defines |
Code that has the global object and no capability makes one with payload.to_js(global), which returns a
JSValue, or payload.into_js_wrapper(global), which returns the JsWrapper.
A class that JavaScript names but cannot make, like Crypto, declares construct: "illegal":
new Crypto() throws Illegal constructor, and there is no constructor in Rust.
A constructor that sets an internal field needs the object. With passStructure: true it creates
the object itself, from the structure of new.target, and returns it. structure.create takes the
struct, or a RefPtr to the struct of a refCounted class.
pub(crate) fn constructor<'a>(
cx: &mut JsScope<'a>,
structure: JsStructure<'a, Self>,
text: JsLocal<'a>,
) -> JsWrapper<'a, Self> {
let this_value = structure.create(&cx.heap, Self { .. });
js_Note::text_set_cached(this_value.to_js(), cx.global(), text.to_js());
this_value
}A property of proto is one of these.
| Property | What it is |
|---|---|
{ args, returns, capability, throws, .. } | a native function; privateSymbol keys it by a private name of src/js |
{ getter: T, capability, throws, passThis } | an accessor; with setter it can be assigned |
{ getter: T, cache: true | "field", .. } | an accessor that returns an internal field |
{ internalField: "field", setter } | an accessor that reads the field, and with setter stores any value, without calling Rust |
{ builtin: true, length } | the export of that name in src/js/builtins/<class name>.ts |
{ constant: 3 } | a read-only integer, like a WebIDL const |
[{ rustName, args, .. }, ..] | a native function with overloads |
The properties are in the order of the schema, which for-in and Object.keys show. The Rust
function is named after the property. rustName is for a name that is taken: the overloads of a
function, two members of proto and static that share a name, and a property whose name is already
a native function of the struct (close is do_close beside a native close). Two keys can
also have the same rustName. The getter of x is get_x and its setter is set_x, and a leading
_ of the property is dropped: _idleStart is get_idle_start.
setter: true gives the setter the getter's capability, and setter: { capability: "js" } its own. An
accessor of static ignores its receiver, so a subclass inherits it. configurable and enumerable on a getter
or a constant override the class's.
A key that starts with @@ is a well-known symbol: "@@dispose" defines prototype[Symbol.dispose]. The function is
writable, configurable and not enumerable, like the built-in ones, whatever the class says. Its name is
[Symbol.dispose] and its Rust function is dispose. name overrides the function's name: Node's
Timeout.prototype[Symbol.dispose].name is "". Only a function can have an @@ key.
A native function throws ERR_INVALID_THIS when this is not an instance. With invalidThis: "undefined" it returns
undefined instead, as some of Node's methods do.
b.self is the class itself as an argument, a result or a getter of its own members. It can also be .optional or
.nullable. A result that is an instance is a JsWrapper, a JsLocal or a JSValue.
The constructor has a PropertyCallback, in the namespace of the file, for the static property
table of the object that has the class:
Glob Bun::Bindgen::Generated::Glob::getGlobConstructor DontDelete|PropertyCallbackRust gets it with js_Glob::get_constructor(global). In C++, the generated header gives the structure,
prototype and constructor: Bun::Bindgen::structureForClass<JSGlob>(globalObject), prototypeForClass<JSGlob> and
constructorForClass<JSGlob>. createWrapper<JSBlob>(globalObject, wrapped) is a new instance of a refCounted class.
The receiver#
Every member takes &self, a setter too, and mutable state is in Cell and JsCell. The struct implements
bun_jsc::JsClass, so value.as_class_ref::<Glob>() works, and js_Glob::from_js(value) is the struct if value
is an instance. js_Glob::from_js_direct(value) returns None for an instance of a subclass or one with own
properties, either of which may override a member. wrapper.payload_ptr() is the address of the struct, which
never changes, and payload.wrapper_value() returns the object that owns the struct.
A member with mutates: true takes &mut self, and so does every setter of its class. JavaScript that ran
during another member could call it on the same object, so no member of that class has capability: "js". The class
is not an argument type, has no estimatedSize, and does not implement JsClass.
internalFields#
internalFields: ["callback"] on CronJob generates these in the module js_CronJob, next to the schema's types.
this_value is the object: this_value.to_js() in a member with passThis: true.
| Function | What it does |
|---|---|
js_CronJob::callback_get_cached(this_value) | the field, or None if it is empty |
js_CronJob::callback_set_cached(this_value, global, v) | sets it; JSValue::ZERO empties it |
js_CronJob::callback_take_cached(this_value, global) | reads and empties it |
Every field starts empty. A heap snapshot shows what it holds as a property of its name.
In C++ the field is m_callback, a JSC::WriteBarrier<JSC::Unknown>. In a record each field names its type: b.any
is what a list declares, and b.cell(class, header) is a JSC::WriteBarrier of that JSCell subclass, which only
hand-written C++ uses.
internalFields: { failReason: b.any, pipeDone: b.cell("JSC::JSPromise", "JavaScriptCore/JSPromise.h") },JSC::JSPromise* done = sinkController->m_pipeDone.get(); // null if it is emptyjs_Name has no functions for a b.cell field, and no getter or internalField property uses one. Rust reaches it
through a calledFromRust member function.
A getter with cache: true declares a field of its own name, with the same functions, and
returns it. While the field is empty the getter calls Rust and stores the result, so emptying
tag makes the next read compute it again.
cache: "tag" on another getter declares nothing: it returns that field, of a getter or of
internalFields, and fills it from its own Rust function.
With writable: true any value assigned to the property replaces the cached one, without calling Rust.
With setter and passThis, set_tag replaces the cached value itself, with tag_set_cached.
Calls between C++ and Rust#
Hand-written C++ and the class's Rust code call each other through member functions of the generated class, so neither
side declares an extern "C" function for them.
export const ReadableSinkController = b.class({
proto: {
end: { returns: b.any, capability: "js", throws: true, calledFromCpp: true },
write: { args: { data: SinkChunk }, returns: b.any, capability: "js", throws: true, calledFromCpp: true },
},
calledFromCpp: {
isDetached: { returns: b.bool },
writeBytes: { args: { bytes: b.byteSpan }, returns: b.any, capability: "js", throws: true },
},
calledFromRust: {
onClose: { args: { reason: b.any, sinkClosed: b.bool }, throws: true },
setPipeDone: { args: { done: b.any } },
},
});The generated class declares them:
// Defined in Rust. On exception, a JSValue result is empty.
JSC::JSValue end(JSC::JSGlobalObject*);
JSC::JSValue write(JSC::JSGlobalObject*, JSC::JSValue data);
bool isDetached();
JSC::JSValue writeBytes(JSC::JSGlobalObject*, std::span<const uint8_t> bytes);
// Defined in hand-written C++, called from Rust.
void onClose(JSC::JSGlobalObject*, JSC::JSValue reason, bool sinkClosed);
void setPipeDone(JSC::JSValue done);In both directions globalObject is the first parameter if the function has a capability or throws.
calledFromCpp: true on a function of proto also makes it a member function. It takes a JSC::JSValue for each
argument and converts it as a call from JS does, so sinkController->write(globalObject, chunk) is
controller.write(chunk) without the property lookup. It has no overloads, .variadic or rejects.
The class's calledFromCpp holds functions that only C++ calls and that are not properties. Nothing is converted: an
argument is b.any, b.bool, a number or b.byteSpan, and the result is b.any, b.bool, a number or nothing.
A function that throws returns b.any or nothing.
b.byteSpan is bytes that are not in a JS object: a std::span<const uint8_t> that Rust gets as &'a [u8].
calledFromRust declares member functions that hand-written C++ defines. An argument is b.any, b.bool or a number, and
the result is b.any or nothing. Rust calls them
in the module js_ReadableSinkController, with the object first, and with throws: true gets a JsResult.
js_ReadableSinkController::on_close(this_value, global, reason, true)?;
js_ReadableSinkController::set_pipe_done(this_value, done);refCounted#
With refCounted: true the struct is a separate, refcounted heap allocation and the object holds one ref:
export const CronJob = b.class({
rustPath: "crate::api::cron::CronJob",
refCounted: true,
finalize: true,
internalFields: ["callback", "pendingPromise", "cron"],
proto: {
cron: { internalField: "cron" },
stop: { returns: b.self, passThis: true },
"@@dispose": { returns: b.self, passThis: true, rustName: "stop" },
},
});impl CronJob {
pub(crate) fn finalize(&self) {
self.this_value.with_mut(|v| v.finalize());
}
pub(crate) fn stop<'a>(&self, this_value: JsWrapper<'a, Self>) -> JsWrapper<'a, Self> {
Self::self_stop(self.self_ref.get().this_ptr(), self.global.bun_vm());
this_value
}
}- The struct is intrusively refcounted:
bun_ptr::CellRefCounted,RefCountedorThreadSafeRefCounted. mutates: trueis an error.constructreturns a*mut Self, whose ref the object takes over.- The pointer in the object never changes and is never null. A class with a closed or detached state tracks it in a field of its own.
finalizeruns before the object's ref is dropped.Dropruns when the last ref is dropped, which can be much later.- C++ reads the pointer to the struct with
wrapper->wrapped(). - A heap snapshot reports the object's size plus
size_ofthe struct.
Native callbacks have no capability. What they need to reach the JS object takes the global object:
| Function | What it does |
|---|---|
js_CronJob::to_js(ptr, global) | makes the object, which takes over the caller's ref |
js_CronJob::from_js(value) | the struct, if value is an instance |
CronJob::to_js_ptr(ptr, global) is to_js as an unsafe fn, and CronJob::to_js_nonnull(ptr, global) takes a
NonNull. The struct also implements bun_jsc::JsClass, so value.as_class_ref::<CronJob>() works. To get
back to its JS object the struct stores a JsRef, and calls finalize() on it from the class's finalize.
Memory#
The GC counts the object. Two keys report the extra memory the struct owns. With estimatedSize, the struct of a
refCounted class is counted too.
| Key | Rust | Who calls it |
|---|---|---|
estimatedSize | fn estimated_size(&self) -> usize | the GC: when the object is created, and each time it is marked |
memoryCost | fn memory_cost(&self) -> usize | heap snapshots; without it they use estimated_size |
A marking thread calls estimated_size while JavaScript runs. It reads only fields that do not
change once the object is created, or atomics: if the size changes, keep it in an atomic field, like
Blob::m_memoryCost in WebKit. memory_cost runs on the JS thread and can read anything.
forBind#
test and describe are functions with properties. JavaScript only sees a function bound to the
instance, with the class's prototype as its prototype. With forBind: true, members are called with
that function as this, and &self is its boundThis. The prototype inherits from Function.prototype.
Tests#
test/internal/bindgen.test.ts: conversions with hostile inputs, throughsrc/jsc/bindgen_test.bind.ts. A schema named*_test.bind.tsis only part of a build with assertions (a debug build, CI's ASAN lane), so nothing it binds ships;$bindgenof one isundefinedelsewhere, and its Rust module is#[cfg(debug_assertions)]. What that schema does not use is covered by the suites of the APIs that do.test/integration/bun-types/bun-types.test.ts:packages/bun-typespromises nothing that a schema breaks. Every call the published types allow has arguments the schema takes, every result they name is one it gives, and no object has a member that the schema's dictionary or class lacks, but for those the test lists. A new schema file is listed inPUBLISHED_ASthere, with what the published types call its exports.