yeet:btf
yeet:btf queries the kernel's BTF — the type information the running kernel carries about its own structs, unions, enums, and functions. Use it to look up a type's full layout, resolve a member's byte offset, or produce the exact deref-and-read plan to reach a deeply nested field. It is the type oracle a script (or an AI generating one) consults so it never hand-codes struct offsets or pointer arithmetic that break on the next kernel.
import btf from 'yeet:btf';
const t = await btf.type('task_struct');
console.log(t.kind, t.size, t.members.length); // struct 9472 251
const { offset, type } = await btf.offsetOf('task_struct', 'pid');
console.log(offset, type.name); // 2464 int
// The hops a bounded interpreter runs to read sock->sk_socket->file->f_inode->i_ino:
const plan = await btf.walk('sock', 'sk_socket.file.f_inode.i_ino');
console.log(plan.hops);
// [ { op: 'deref', at: 640, width: 8 },
// { op: 'deref', at: 40, width: 8 },
// { op: 'deref', at: 0, width: 8 },
// { op: 'load', at: 64, width: 8 } ]
Every op is asynchronous and answered daemon-side, off the V8 thread, against a single process-wide index parsed on first use from the running kernel (or a sidecar — see BTF source). The parse is shared by every isolate and unloaded when it goes idle, so a script that never touches BTF pays nothing.
The exact offsets and sizes above are illustrative — they are kernel- and config-specific. That is the whole point: query them at runtime rather than baking them into a script.
Importing
import btf, { type, expand, offsetOf, walk } from 'yeet:btf';
| Export | Kind | Description |
|---|---|---|
type | function | The full description of a named type — layout, members, values |
expand | function | The full description behind a nested reference — by id, or by name |
offsetOf | function | A member's pointer-free byte offset within a type |
walk | function | The deref-and-read plan to reach a member across pointers |
The default export bundles all four (btf.type, btf.expand, btf.offsetOf, btf.walk).
Concepts
BTF source
The index is built from one of two sources, chosen by the daemon at startup:
| Source | When | Loaded from |
|---|---|---|
| Running kernel (default) | no override | the kernel's own vmlinux BTF |
| Custom sidecar | yeetd --btf-custom-path <file> | a .btf file or a vmlinux image with a .BTF section |
Use --btf-custom-path to query a kernel other than the host's — a target you are building a probe for, a stripped kernel with BTF shipped separately, or a captured vmlinux for reproducible offsets in a test. The same path also seeds libbpf's CO-RE relocations for .bpf.o loads, so a probe and its yeet:btf queries agree on one type universe.
Type names and kinds
A query names a type by its BTF name (task_struct, sk_buff, pid_t). A name can be shared across kinds — a struct foo and a typedef foo, say — so each query accepts an optional kind to disambiguate. Omit it when the name is unique; supply it when a bare name is AMBIGUOUS_TYPE.
await btf.type('pid_t'); // unique → the typedef
await btf.type('sigval', { kind: 'union' }); // pin the union over the fwd/typedef
Every type carries a kind tag, one of:
void · int · float · ptr · array · struct · union · enum · fwd · typedef · const · volatile · restrict · typetag · func · funcproto · var · datasec · other
The kind decides which fields a TypeDesc carries: members for a struct/union, values for an enum, elem/nelems for an array, params/ret for a function prototype, target for anything that points at or wraps one other type (a pointer, typedef, or qualifier).
One-level references
To keep a result from dragging a type's entire transitive definition across the wire, a nested type appears as a lightweight TypeRef — just { id, kind, name, size }, not the full body. Pass the ref's id to expand to open it:
const t = await btf.type('task_struct');
const seRef = t.members.find(m => m.name === 'se').type; // TypeRef, kind "struct"
const se = await btf.expand(seRef.id); // expand it → full sched_entity
This is how a walk over a struct's whole embedded closure descends: kernel structs are full of anonymous nested unions and structs (name absent — sock_common alone holds eight anonymous unions), and with no name to re-query, the id is the only handle a branch has. expand takes named refs too — by id, or by the name itself as a convenience — but the id path is the one that always works, and being unambiguous it never needs a kind hint.
The access plan
walk is the reason this module exists. Given a dotted member path, it returns the ordered hops a bounded interpreter — a small, verifiable read machine like the one in a walk-VM probe — executes to arrive at the field:
deref— read a pointer ofwidthbytes at offsetat, follow it, and open a fresh offset window at the target.load— the terminal scalar read:widthbytes at offsetatin the current window.field— a terminal that lands on an aggregate (a struct/array with no single scalar to read); only itsatoffset is given.
Because the daemon composes the pointer arithmetic, the interpreter never does — it just runs the hops. That is what lets an AI discover a kernel's layout and emit a correct, bounded probe DSL without embedding brittle offsets.
const plan = await btf.walk('task_struct', 'mm.owner.comm');
for (const h of plan.hops) {
// feed each hop to your read machine
}
console.log(plan.type.name); // resolved terminal type
offsetOf vs walk
offsetOf is the shortcut for the common case: a member reachable without crossing a pointer, so a single byte offset answers it. It rejects POINTER_IN_PATH the moment a path would dereference — reach for walk there.
await btf.offsetOf('task_struct', 'se.sum_exec_runtime'); // { offset, type } — all in one object
await btf.offsetOf('task_struct', 'mm.owner'); // rejects POINTER_IN_PATH (mm is a pointer)
await btf.walk('task_struct', 'mm.owner'); // the deref chain instead
A walk whose path never crosses a pointer collapses to a single load or field hop — the same offset offsetOf would return.
Bitfields
A member declared as a C bitfield (unsigned flag : 1) can't be addressed by byte offset alone. Both walk and a struct member's MemberDesc carry a BitField with bit_offset and bit_size. In a walk plan the terminal load reads the member's aligned storage unit and bit_offset is relative to that unit, so a high bit position never pushes the read past the type — mask and shift the loaded word to extract the value.
const plan = await btf.walk('task_struct', 'flags'); // depends on the kernel's task_struct
if (plan.bitfield) {
const { bit_offset, bit_size } = plan.bitfield;
const mask = ((1n << BigInt(bit_size)) - 1n) << BigInt(bit_offset);
}
Caching and lifetime
The first query parses the whole kernel BTF into a compact, shared index (~5 MiB for a full vmlinux) and every later query is answered from it. After a spell with no queries the index is unloaded and its memory returned; the next query rebuilds it transparently. Nothing to open or close — the module is stateless from a script's point of view.
F type
type(name: string, opts?: { kind?: string }): Promise<TypeDesc>
The full TypeDesc of the named type: its kind, size, and the kind-specific body (members, enum values, array element, function signature, or referent). Pass kind to disambiguate a name shared across kinds. Rejects UNKNOWN_TYPE (with near-miss suggestions) when no type matches, or AMBIGUOUS_TYPE when several do and no kind was given.
const s = await btf.type('sk_buff');
console.log(s.size, s.members.map(m => m.name));
const e = await btf.type('bpf_prog_type');
console.log(e.values.slice(0, 3)); // [{ name: 'BPF_PROG_TYPE_UNSPEC', value: 0 }, …]
F expand
expand(target: number | string): Promise<TypeDesc>
The full TypeDesc behind a nested TypeRef — the re-expansion step for walking beyond one level. A number is a ref's id, the only way into an anonymous struct or union (which has no name for type to resolve); ids come from results (member.type.id, desc.target.id, desc.elem.id) and are stable for the lifetime of the loaded index, so memoize by id when walking a closure. A string is accepted as a convenience for named refs — it resolves like type but takes no kind hint, so a name shared across kinds rejects AMBIGUOUS_TYPE; reach for type there. An id outside the index rejects UNKNOWN_ID.
const sc = await btf.type('sock_common');
const anon = sc.members.filter(m => !m.name); // inline unions — no name to re-query
const u = await btf.expand(anon[0].type.id);
console.log(u.members.map(m => `${m.name}@${m.offset}`)); // ['skc_addrpair@0', …]
F offsetOf
offsetOf(type: string, path: string): Promise<FieldOffset>
The pointer-free byte offset of the dotted member path within type, plus a TypeRef to the member's resolved type, as a FieldOffset. Anonymous unions and structs along the path are transparent — name only the members you care about. Rejects POINTER_IN_PATH if the path crosses a pointer (use walk), NO_MEMBER (listing the siblings) for an unknown segment, or NOT_COMPOSITE when a segment isn't a struct/union.
const { offset, type } = await btf.offsetOf('task_struct', 'se.sum_exec_runtime');
// offset is the byte displacement; type is a TypeRef to u64 (or similar)
F walk
walk(type: string, path: string): Promise<LoadPlan>
The full access plan to reach path from type: the ordered hops a bounded interpreter runs, the resolved terminal type, and a bitfield position when the target is a bitfield. Each crossed pointer becomes a deref hop that opens a fresh offset window; the walk ends in a load (scalar) or field (aggregate) hop. See The access plan. Rejects with the same path errors as offsetOf except POINTER_IN_PATH — walk is the pointer-crossing form.
const plan = await btf.walk('sock', 'sk_socket.file.f_inode.i_ino');
// plan.hops → [deref, deref, deref, load]; plan.type → the i_ino type
Data types
I TypeDesc
The full description of one type, returned by type and expand. Fields irrelevant to the kind are omitted.
interface TypeDesc {
id: number; // this type's BTF id
kind: string; // see Type names and kinds
name?: string; // absent for an anonymous type
size?: number; // byte size, when the kind has one
encoding?: IntEncoding; // int — how the integer reads
members?: MemberDesc[]; // struct / union
values?: EnumValue[]; // enum
target?: TypeRef; // ptr / typedef / const / volatile / restrict / typetag / func → its referent
params?: ParamDesc[]; // funcproto
ret?: TypeRef; // funcproto return type
elem?: TypeRef; // array element type
nelems?: number; // array element count
}
I TypeRef
A lightweight one-level reference to a type — enough to name it in a member list or a result without its full body. Re-query by name to expand it.
interface TypeRef {
id: number; // the referenced type's BTF id
kind: string;
name?: string; // absent for an anonymous type
size?: number;
}
I IntEncoding
How an int type reads, carried on its TypeDesc as independent flags — test a field rather than matching a string. An unsigned integer has all three false.
interface IntEncoding {
signed: boolean;
bool: boolean; // C _Bool
char: boolean;
}
I MemberDesc
One member of a struct or union in a TypeDesc.
interface MemberDesc {
name?: string; // absent for an anonymous member
offset: number; // byte offset within the aggregate
bitfield?: BitField; // present only for bitfield members
type: TypeRef; // the member's type
}
I ParamDesc
One parameter of a function prototype (kind: "funcproto").
interface ParamDesc {
name?: string; // absent for an unnamed parameter
type: TypeRef;
}
I EnumValue
One enumerator of an enum (kind: "enum").
interface EnumValue {
name: string;
value: number;
}
I FieldOffset
Returned by offsetOf.
interface FieldOffset {
offset: number; // pointer-free byte offset of the member
type: TypeRef; // the member's resolved type
}
I LoadPlan
Returned by walk.
interface LoadPlan {
hops: Hop[]; // run in order to reach the field
type: TypeRef; // the resolved terminal type
bitfield?: BitField; // present only when the target is a bitfield
}
I Hop
One step of an access plan, tagged by op. See The access plan.
type Hop =
| { op: 'deref'; at: number; width: number } // read a pointer at `at`, follow it, open a fresh window
| { op: 'load'; at: number; width: number } // terminal scalar read of `width` bytes at `at`
| { op: 'field'; at: number }; // terminal aggregate — offset only, nothing to read
at is always relative to the current window: a fresh window opens after each deref, so offsets restart from 0 following one.
I BitField
A bitfield's position within its loaded storage unit. See Bitfields.
interface BitField {
bit_offset: number; // bits from the start of the loaded unit
bit_size: number; // width in bits
}
Errors
Every function rejects with a plain object — not an Error — of the shape { code, message }. Match on code, not on the message string (the message is human-facing and carries repair hints — near-miss type names, the available members at a failing path segment):
try {
await btf.offsetOf('task_struct', 'mm.owner');
} catch (e) {
if (e.code === 'POINTER_IN_PATH') {
const plan = await btf.walk('task_struct', 'mm.owner'); // recover with the deref chain
}
}
code | Raised when |
|---|---|
INVALID_ARGS | An argument is missing or the wrong type (a non-string name, type, or path; an expand target that is neither an id nor a name) |
UNKNOWN_TYPE | No type matches the name (message suggests near-miss names) |
UNKNOWN_ID | expand was given an id outside the loaded index |
AMBIGUOUS_TYPE | The name is shared across kinds and no kind was given |
NO_MEMBER | A path segment names no member of its container (message lists the siblings) |
NOT_COMPOSITE | A path segment tries to descend into a non-struct/union |
POINTER_IN_PATH | offsetOf was given a path that crosses a pointer — use walk |
OFFSET_OVERFLOW | A member's bit offset exceeds the index's 24-bit packing limit (a malformed or pathological type) |
BTF_LOAD_FAILED | The kernel BTF (or the --btf-custom-path sidecar) could not be loaded or parsed |
SERIALIZE_FAILED | A result could not be serialized back to the isolate |
Recipes
Build a deref plan for a bounded interpreter
import btf from 'yeet:btf';
// Turn a human-readable field path into hops a read machine can run.
const plan = await btf.walk('task_struct', 'mm.owner.comm');
const program = plan.hops.map(h =>
h.op === 'deref' ? ['DEREF', h.at, h.width]
: h.op === 'load' ? ['LOAD', h.at, h.width]
: ['FIELD', h.at]
);
// hand `program` to your walk-VM probe
Resolve a member offset without crossing a pointer
const { offset } = await btf.offsetOf('sk_buff', 'len');
// `offset` is the byte displacement to read `len` directly off an sk_buff pointer
Enumerate a struct's members
const t = await btf.type('inode');
for (const m of t.members) {
console.log(`${m.offset}\t${m.name}\t${m.type.name ?? '(anon ' + m.type.kind + ')'}`);
}
Classify every scalar in a struct's embedded closure
// One expand per distinct id, memoized — anonymous unions flatten into
// their parent at an accumulated offset, and encoding flags decide the
// read: signed, boolean, or plain unsigned.
const seen = new Map();
const open = (id) => seen.get(id) ?? seen.set(id, btf.expand(id)).get(id);
const QUALIFIERS = new Set(['typedef', 'const', 'volatile', 'restrict', 'typetag']);
async function peel(id) {
let d = await open(id);
while (QUALIFIERS.has(d.kind) && d.target) d = await open(d.target.id);
return d;
}
async function scalars(desc, base = 0, out = []) {
for (const m of desc.members ?? []) {
if (m.bitfield) continue;
const inner = await peel(m.type.id);
if (!m.name && (inner.kind === 'union' || inner.kind === 'struct')) {
await scalars(inner, base + m.offset, out); // flatten the anonymous member
} else if (inner.kind === 'int') {
out.push({ name: m.name, off: base + m.offset, size: inner.size, ...inner.encoding });
}
}
return out;
}
console.log(await scalars(await btf.type('sock_common')));
Discover an enum's values
const e = await btf.type('bpf_map_type');
const byName = Object.fromEntries(e.values.map(v => [v.name, v.value]));
console.log(byName.BPF_MAP_TYPE_HASH);
Disambiguate a name shared across kinds
try {
await btf.type('sigval');
} catch (e) {
if (e.code === 'AMBIGUOUS_TYPE') {
await btf.type('sigval', { kind: 'union' });
}
}
Query a target kernel other than the host
# start the daemon against a captured or shipped BTF
yeetd --btf-custom-path ./vmlinux-6.1-arm64.btf
// queries now answer against that kernel's types, matching your .bpf.o CO-RE loads
const plan = await btf.walk('sk_buff', 'dev.name');