Bindgen

Bindgen for Bun

This document is for maintainers and contributors to Bun, and describes internal implementation details.

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. These do overlapping work:

  • "Classes generator", converting *.classes.ts for custom classes.
  • "JS2Native", allowing ad-hoc calls from src/js to native code ($rust, $cpp).
  • #[bun_jsc::host_fn] functions, which take a CallFrame.

Functions#

Describe the function in a .bind.ts file next to the Rust file:

src/jsc/bindgen_test.bind.ts
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:

src/jsc/bindgen_test.rs
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. length sets the function's .length, which is otherwise the number of arguments up to the last that is not written .optional, .nullable, .falsyAsNull or .default(). The ones after that are the ones TypeScript lets a caller leave out.

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.

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()) is any. A .nullable result, and a .nullable getter without a setter, is T | null;
  • from a C++ static property table: Bun::Bindgen::Generated::BindgenTest::jsAdd in GeneratedBindgenTest.h.

Namespaces#

b.namespace is a plain object that holds functions, like Bun.semver. Each is written as the argument of b.fn is:

src/semver_jsc/SemverObject.bind.ts
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.

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.

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.

capabilityFirst parameterThe function can
"none" (default)nonecompute
"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()), 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. A method that has to call one takes capability: "js" and borrows with with_mut (see the receiver).

// 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.illegal_constructor(), 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.

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" },
);

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:

  1. It keeps the candidates whose type at that position takes the kind of the argument.
  2. An argument that was not passed is undefined. So f(x) and f(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.
  3. A candidate with no argument at that position stays.
  4. If no candidate takes the argument, all of them stay, and the next argument decides.
  5. When the arguments run out, the candidate written first wins. Its conversion reports whatever is wrong. So of two signatures that start the same, the longer one goes first.

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.

SchemaRust, as an argumentTakes
b.boolboola boolean; .loose is ToBoolean
b.u8 b.u16 b.u32 b.u64 b.usize b.i32the samea number that is an integer in range; see below
b.f64f64a number; .loose is ToNumber; see below
b.string&bun_core::Stringa string; .loose is ToString
b.string.utf8&'a [u8]the same, and a String object
b.string.utf8.ownedbun_core::Utf8Bytes<'static>as b.string.utf8
b.bufferSourceJsLocal<'a, BufferSource>a typed array, DataView or ArrayBuffer that is not shared
b.bufferSource.arrayBufferExternalShared<JSCArrayBuffer>the same; the bytes of a view are copied
b.bytes(options)&'a [u8], and otherssee Bytes
b.blobExternalShared<Blob>a Blob
b.classRef(name)&'a Tan instance of a class of a .classes.ts file; see below
b.anyJsLocal<'a>anything
b.null()null or undefined; only in a union
b.array(T)Vec<T>an array; T holds no JS value, which the GC would not see
b.enumeration(values)a generated enumone of the strings
b.dictionary(members)& a generated structan object that is not an array
b.union(alternatives)a generated enumsee Unions and kinds
b.class(options)JsWrapper<'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.

These come last, in this order (b.any already takes undefined and null, so none applies to it):

  • .optional: undefined means absent; Option<T>.
  • .nullable: null or undefined means absent; Option<T>.
  • .falsyAsNull: every falsy value means absent; Option<T>.
  • .default(value): what to use when absent; T. .nullable.default(value) also applies to null.

As results, b.string.utf8 is anything that is AsRef<[u8]> and b.array(T) anything that is IntoIterator. 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.

These are results only:

SchemaRustJavaScript gets
b.u64.asBigIntu64always a BigInt
b.f64.asDatef64a Date with that time value
b.string.localJsLocal<'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. On an integer:

  • .allowBigInt: a BigInt in the same range too. It is how a b.u64 above 253 gets in. It goes with the strict conversion and with .loose; the others refuse a BigInt.
  • .loose: ToNumber first, or ToNumeric with .allowBigInt, then as strict as the default.
  • .wrap: the WebIDL conversion, ToNumber then wrap modulo the width.
  • .enforceRange(min, max): WebIDL [EnforceRange].
  • .validateInteger(min, max): Node's validateInteger, 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, which a body that keeps it clones. 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.classRef("TCPSocket") is a &'a of the struct that class wraps, crate::generated_classes::TCPSocket. The argument's slot in the frame holds the instance, so it is only for an argument, alone or in .nullable or a union. In a function that can run JavaScript the struct must be one whose own methods take &self, since they can be called while the body holds the &.

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 } to rename its field or to accept other spellings. 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. Inside .optional, 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:

KeyWhat it does
allowArrayan array is read like any other object, not refused
nonObjectAsEmptya 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
fromJSalso generates Name::from_js(global, value), for a #[host_fn] body; one that holds JS values is lent to a closure, Name::from_js(global, value, |converted| ..)
nullPrototypeas 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, .. }. An entry can be a list of spellings; the first is the one returned to JavaScript. Returning an enumeration allocates nothing after the first time.

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 atKinds
the bits of the valueundefined, null, boolean, number
the type byte in the cell's headerstring, BigInt, array, ArrayBuffer, typed array or DataView, String object
the cell's ClassInfoan instance of a class

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, a dictionary.

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, b.blob and b.classRef refuse some values of their kinds (a shared buffer, a detached Blob, an instance that wraps nothing); those go on to the next alternative that takes the kind and does not refuse the same. 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 kind 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>>
OptionValuesMeaning
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 whose bytes are in memory: a file, here or in S3, throws.

heldstringRust
"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 compile

Each 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. Only held: "borrowed" can be .optional or .nullable: an Option<&'a [u8]>.

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, and a "pinned" argument next to another or next to a "borrowed" one.

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 with value.to_strong(heap) and read with JsLocal::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#

The keys are mostly those of .classes.ts, with types. klass is static and values is internalFields.

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,
    },
  },
});

generates a trait, which the struct implements:

pub(crate) struct Glob {
    pattern: bun_core::Utf8Bytes<'static>,
}

impl GlobClass for Glob {
    fn constructor(pattern: bun_core::Utf8Bytes<'static>) -> Self {
        Glob { pattern }
    }

    fn r#match<'a>(&self, str: &'a [u8]) -> bool {
        bun_glob::r#match(self.pattern.slice(), str).matches()
    }
}

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 collector's threads. The collector is not told of memory the payload owns outside the object: there is no estimatedSize yet.

KeyWhat it declares
rustPaththe struct
constructnew Name(..). Without it JavaScript cannot make one; Rust can, with payload.into_js(heap).
protoproperties of the prototype
staticproperties of the constructor: native functions and accessors without setter or cache
internalFieldsJS values the object holds
configurablefalse makes the native properties non-configurable

Code that has the global object and no capability makes one with payload.into_js_value(global), which returns a JSValue.

A class that JavaScript names but cannot make, like Crypto, declares construct: { capability: "heap", throws: true }, and its constructor returns Err(heap.illegal_constructor()).

A property of proto is one of:

PropertyWhat it is
{ args, returns, capability, throws, .. }a native function; privateSymbol keys it by a private name of src/js
{ getter: T, capability, throws }an accessor; with setter: true it can be assigned
{ getter: T, cache: true | "field", .. }an accessor that returns an internal field; no setter
{ builtin: true, length }the export of that name in src/js/builtins/<class name>.ts
[{ 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 names it otherwise, which tells apart the overloads of a function, and two members of proto and static that share a name. An accessor of static ignores its receiver, so a subclass inherits it.

If any member has throws: true, the trait has a type Error<'a>: bindgen::IntoError<'a> that they all fail with. b.self is the class itself as an argument, a result or a getter of its own members.

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|PropertyCallback

The receiver#

The method isIt receives
capability: "none" or "heap"&self
…with mutates: true&mut self
capability: "js"this: JsWrapper<'a, Self>

A method that can run JavaScript can be re-entered, by a callback that calls another method of the same object. A &mut self held across that would be undefined behaviour, so it gets the wrapper instead and borrows the payload for as long as nothing else can run:

fn notify<'a>(this: JsWrapper<'a, Self>, cx: &mut JsScope<'a>) -> bindgen::Result<'a, i32> {
    if let Some(listener) = this.listener_field(&cx.heap).as_callable() {
        listener.call(cx, this.as_value(), &[])?;
    }
    Ok(this.with(&cx.payloads, |counter| counter.count))
}

this.with(&cx.payloads, ..) and this.with_mut(&mut cx.payloads, ..) borrow one field of cx; running JavaScript needs all of it. cx.heap is another field, so allocating or viewing a buffer inside the closure is fine.

internalFields#

internalFields: ["listener"] generates this.listener_field(heap) and this.set_listener_field(heap, value) on JsWrapper<'a, T>. A &self or &mut self method that needs them asks for the wrapper too, with passThis: true. The wrapper reaches the internal fields and not the payload.

Every field starts as undefined. A heap snapshot shows what it holds as a property of its name.

A getter with cache: true declares a field of its own name, with the same two functions, and returns it. While the field is undefined the getter calls Rust and stores the result, so this.set_tag_field(heap, JsLocal::UNDEFINED) makes the next read of tag 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.

Staying alive#

To keep an object alive while native work is pending, hold a JsWrapperStrong<T> (this.to_strong(heap)) with the work and drop it on the JS thread when the work completes.

Tests#

  • test/internal/bindgen.test.ts: conversions with hostile inputs, through src/jsc/bindgen_test.bind.ts. A schema named *_test.bind.ts is only part of a build with assertions (a debug build, CI's ASAN lane), so nothing it binds ships; $bindgen of one is undefined elsewhere, 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-types promises 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. A new schema file is listed in PUBLISHED_AS there, with what the published types call its exports.