Skip to main content

eBPF

Yeet scripts load a compiled eBPF object (.bpf.o), attach its programs, and talk to its maps from JavaScript — ring buffers, hash maps, arrays, LPM tries, bloom filters, per-CPU maps, and .data/.rodata/.bss globals — with keys and values typed end-to-end from the object's BTF.

The flow is always the same:

import probe from './probe.bpf.o';
import { RingBuf } from 'yeet:bpf';

// 1. declare the maps you want to reach (and any attach overrides), then start
const control = await probe
.bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 4096 })
.start(); // every program in the object auto-attaches by its ELF section

// 2. open a typed handle to a bound map and use it
const events = new RingBuf(control, "events");
// ringbuf records are decoded into an object keyed by the BTF struct name
await events.subscribe(({ event }) => console.log(event));

Importing

Any path ending in .bpf.o resolves to a default-exported BpfObject bound to that path:

import probe from './probe.bpf.o';

The ELF is not read at import time — it is loaded, verified, and attached when start() is called.

The map handle classes and builder are exported from yeet:bpf:

import {
BpfObject, BpfControl,
RingBuf, BpfSubscription, DataSec,
HashMap, LruHashMap, LpmTrie, ArrayMap, BloomFilter,
PercpuHashMap, LruPercpuHashMap, PercpuArrayMap,
} from 'yeet:bpf';

O BpfObject

A chainable builder representing a compiled eBPF object. Declare the maps you want to reach and any attach overrides, then call start() to load it. bind and attach both return this, so they chain.

M BpfObject.bind

bind(name: string, spec: { kind: string, ...opts }): BpfObject

Declares that the named map should be exposed to JavaScript. Every map you open a handle to must be bound before start() — a handle on an unbound map rejects when you call it, because no daemon-side service was created for it.

FieldTypeDescription
namestringMap name as it appears in the ELF.
kindstringRequired. The map type (see table below).
btf_structstring(RingBuf) Name of the record struct in the object's BTF; drives event decoding.
capacitynumber(RingBuf) In-process broadcast buffer size in entries, for fanning events out to subscribers. Defaults to a built-in value.

kind selects the map type and must match the map's actual kernel type. Each accepts several spellings:

Map typeAccepted kind values
Ring buffer"ringbuf" (canonical), "ring_buf"
Data section (.data/.rodata/.bss)"data"
Hash"hashmap", "hash_map", "hash"
LRU hash"lru_hashmap", "lru_hash_map", "lru_hash"
LPM trie"lpm_trie", "lpm"
Array"array"
Per-CPU hash"percpu_hashmap", "percpu_hash_map", "percpu_hash"
LRU per-CPU hash"lru_percpu_hashmap", "lru_percpu_hash_map", "lru_percpu_hash"
Per-CPU array"percpu_array"
Bloom filter"bloom_filter", "bloom"

Ring buffer records carry no __type annotation, so you name their struct explicitly with btf_struct. Key/value maps instead lift their types from the __type(key, …) / __type(value, …) annotations on the BPF C side — no btf_struct needed. Any field other than kind is passed through as an option; binding the same map twice throws.

probe
.bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 8192 })
.bind("flows", { kind: "hashmap" })
.bind(".rodata", { kind: "data" });

M BpfObject.attach

attach(progName: string, spec: object | null): BpfObject

Declares how the named program attaches. A non-null spec.kind selects one of four lanes; the program's own BPF type must match the kind you pass, or start() rejects. Pass null for the section-name auto-attach path.

You do not need to call attach for every program. On start() the daemon attaches all programs in the object (verified: a tracepoint program with no attach() call still fires). What attach() controls is the spec for programs that need or accept one:

  • kprobe / kretprobe, tracepoint, fentry / fexit, cgroup hooks, … auto-attach from their ELF section name. No attach() call is needed; if you list one anyway, pass null.
  • xdp and tcx attach with sane defaults (host namespace, all interfaces) — call attach() only to override the target.
  • perf and uprobe require an attach() spec — without one, start() rejects with an invalid-opts error.

Declaring the same program twice throws.

kind: "uprobe"

Attaches a uprobe/uretprobe to a user-space binary. Entry vs. return is taken from the program's section name (uprobe/uretprobe), not the spec.

FieldTypeRequiredDescription
binarystringyesBare name resolved via ld.so.cache (e.g. libssl.so.1.1) or an absolute path.
symbolstringnoSymbol to resolve (e.g. SSL_read). If omitted, offset is a raw ELF offset.
offsetnumbernoAdded to the resolved symbol offset (default 0).
pidnumbernoAttach only to this PID. If omitted, attaches to every process mapping the binary.
probe.attach("trace_ssl", { kind: "uprobe", binary: "libssl.so.1.1", symbol: "SSL_read" });

kind: "perf"

Arms a perf event and runs the program on each sample. One program fans out to one perf event per requested CPU.

FieldTypeRequiredDescription
eventobjectyes{ kind: "software", name } or { kind: "hardware", name }.
cpunumber[]noCPU indices to arm. Omit for every online CPU.
targetobjectnoWhich tasks to observe (see below). Defaults to all PIDs in the host namespace.
sampleobjectno{ freq: N } (≈N samples/sec) or { period: N } (every N events). Omit for a non-sampling counter.

Software event names: cpu_clock, task_clock, page_faults, context_switches, cpu_migrations, page_faults_min, page_faults_maj, alignment_faults, emulation_faults.

Hardware event names: cpu_cycles, instructions, cache_references, cache_misses, branch_instructions, branch_misses, bus_cycles, stalled_cycles_frontend, stalled_cycles_backend, ref_cpu_cycles.

target is one of:

  • { kind: "pids", ns?, pids? }pids is an array (omit for all); ns is a namespace scope.
  • { kind: "cgroup", path } — observe every task in the cgroup at path.
probe.attach("on_tick", {
kind: "perf",
event: { kind: "software", name: "cpu_clock" },
sample: { freq: 99 },
target: { kind: "pids", ns: { pid: 1234 } },
});

kind: "xdp" / kind: "tcx"

Network attachments. Both take a namespace scope via ns/ifindex. tcx additionally takes order.

FieldTypeRequiredDescription
ns"host" | { pid } | { path }noNetwork namespace to attach in. Defaults to the host.
ifindexnumber[]noInterface indices. Omit for all interfaces.
order"default" | "before" | "after"no(tcx only) Placement in the TCX chain. Default lets the kernel choose.
probe.attach("ingress", { kind: "tcx", ifindex: [2], order: "before" });

Namespace scope

ns (and the perf target's ns) names a PID or network namespace by:

  • "host" — the host namespace (the default when ns is omitted).
  • { pid: 1234 } — the namespace that PID 1234 lives in.
  • { path: "/proc/1234/ns/net" } — a namespace by path.

(Resolving a namespace by container name arrives with yeet:container.) Under the hood these desugar to the daemon's { handle, ifindex } / { handle, pids } wire shapes; you can pass those explicitly instead, but not alongside the sugar.

M BpfObject.start

start(): Promise<BpfControl>

Loads the ELF, runs the verifier, binds the declared maps, and attaches every program. Resolves to a BpfControl handle. Rejects on failure — common causes: bad ELF path, verifier rejection, a missing attach spec for a perf/uprobe program, a kind/program mismatch, or missing CAP_BPF.

O BpfControl

The handle returned by start(). Pass it to a map handle constructor, and call stop() to tear the object down.

P BpfControl.id

id: string

Identifier of the running instance. Map handles capture it to route their operations.

M BpfControl.stop

stop(): Promise<void>

Detaches all programs and tears down the loaded object and its map services.

Map handles

Open a typed handle to a bound map by constructing the class for its kind with (control, name):

import { HashMap, RingBuf } from 'yeet:bpf';

const flows = new HashMap(control, "flows");
const events = new RingBuf(control, "events");

Keys and values are typed end-to-end from the map's BTF: a struct becomes a plain object whose fields keep their C types — __u64BigInt, __u8[N]Uint8Array, and scalars / strings / nested structs / arrays map to their JS equivalents. The __type(key, …) / __type(value, …) annotations on the BPF C side drive the lift.

The constructor never validates the kind — it just records (control.id, name). The native bindings reject with InvalidMapKind the first time you call a method whose map isn't the kind you constructed.

O RingBuf

Streams BPF_MAP_TYPE_RINGBUF events into JavaScript.

M RingBuf.subscribe

subscribe(cb: (event: any) => void, onError?: (err: Error) => void): Promise<BpfSubscription>

Subscribes cb to events. Each record is decoded via the map's btf_struct and delivered as an object keyed by that struct name — a record of struct event { … } arrives as { event: { …fields } }, so destructure it: subscribe(({ event }) => …). Fields keep their BTF types (__u64BigInt, __u8[N]Uint8Array, char[N] → a NUL-terminated string, …).

Resolves to a BpfSubscription once the daemon-side pump is wired up; setup failures reject the Promise. Runtime errors (e.g. a slow consumer lagging the broadcast and dropping events) flow to onError, which defaults to logging the message via console.error.

O BpfSubscription

Returned by RingBuf.subscribe.

M BpfSubscription.unsubscribe

unsubscribe(): Promise<void>

Stops delivery and releases the daemon-side subscription.

O HashMap / LruHashMap

HashMap wraps BPF_MAP_TYPE_HASH; LruHashMap wraps BPF_MAP_TYPE_LRU_HASH. The JS surface is identical — the choice of class is the choice of kernel semantics: on a full map update() rejects with E2BIG for HashMap but silently evicts the least-recently-used entry for LruHashMap (whose lookup/iteration also refresh recency).

MethodSignatureDescription
lookuplookup(key): Promise<value | undefined>Point lookup; undefined on a clean miss.
updateupdate(key, value): Promise<void>BPF_ANY — insert or overwrite.
deletedelete(key): Promise<void>Rejects with NotFound if the key is absent.
entriesentries({ batchSize? }): AsyncIterator<[key, value]>Cursor-paged async iteration over all pairs.
updateBatchupdateBatch(pairs): Promise<void>Bulk insert of [key, value] pairs in one syscall (kernel ≥5.6); atomic per batch.
deleteBatchdeleteBatch(keys): Promise<void>Bulk delete (kernel ≥5.6).
lookupBatchlookupBatch(count?): Promise<[key, value][]>Kernel-batched page fetch — fast, not resumable across calls (kernel ≥5.6).
drainBatchdrainBatch(count?): Promise<[key, value][]>Atomic "fetch and delete up to count" (kernel ≥5.6).
infoinfo(): { map_type, key_size, value_size, max_entries }Cached kernel map metadata.
Iteration under churn

Kernel iteration order is unstable across concurrent modification — entries inserted mid-walk may or may not appear, and entries deleted between the scan and a follow-up lookup are silently skipped. For correctness under churn, collect stale keys during entries() and delete them in a separate pass.

O ArrayMap

Wraps BPF_MAP_TYPE_ARRAY. The key is always a __u32 index and the slot count is fixed at max_entries. The kernel rejects deletes on arrays, so there is no delete/deleteBatch/drainBatch — clear a slot by writing a zero value.

MethodSignatureDescription
lookuplookup(index): Promise<value | undefined>Lookup by __u32 index; out-of-range rejects.
updateupdate(index, value): Promise<void>Write a slot.
entriesentries({ batchSize? }): AsyncIterator<[index, value]>Async iteration over all slots.
updateBatchupdateBatch(pairs): Promise<void>Bulk write (kernel ≥5.6).
lookupBatchlookupBatch(count?): Promise<[index, value][]>Kernel-batched page fetch (kernel ≥5.6).
infoinfo(): { map_type, key_size, value_size, max_entries }Kernel map metadata.

This is distinct from the .data/.rodata/.bss arrays that back BPF globals — those use DataSec.

O LpmTrie

Wraps BPF_MAP_TYPE_LPM_TRIE — keys are (prefixlen, data) pairs and the kernel matches the longest prefix on lookup. Methods mirror HashMap (lookup, update, delete, entries, updateBatch, deleteBatch, lookupBatch, info) except drainBatch, which the kernel's trie ops don't implement — use entries() + deleteBatch() to drain.

Keys accept two shapes:

  • A string — a full-length byte-prefix match. Each charCodeAt(i) becomes one byte, so input is interpreted as ASCII / Latin-1; multi-byte UTF-8 is not supported here.
  • An explicit { prefixlen, data } object — prefixlen in bits, data the byte array. Use this for non-ASCII keys.
const acl = new LpmTrie(control, "acl");
await acl.update("10.0.0.0", { action: 1 }); // string key
await acl.lookup({ prefixlen: 24, data: [10, 0, 0, 0] });

O BloomFilter

Wraps BPF_MAP_TYPE_BLOOM_FILTER — a probabilistic set with no keys and no removal. contains may report false positives but never false negatives.

MethodSignatureDescription
insertinsert(value): Promise<void>Set the bits for value. Idempotent.
containscontains(value): Promise<boolean>true if value was likely inserted, false if definitely not.
infoinfo(): { map_type, value_size, max_entries }Metadata (no key_size — bloom is keyless).

O Per-CPU maps

PercpuHashMap (BPF_MAP_TYPE_PERCPU_HASH), LruPercpuHashMap (…_LRU_PERCPU_HASH), and PercpuArrayMap (…_PERCPU_ARRAY) split values across CPUs:

  • lookup(key) resolves to an array of values, one per possible CPU in CPU-index order (undefined on a miss). Aggregate on the caller side — e.g. vals.reduce((a, b) => a + b, 0n) for a per-CPU counter.
  • update(key, valuesPercpu) takes an array whose length must equal info().num_cpus.
  • entries() yields [key, [v_cpu0, v_cpu1, …]].
  • info() additionally reports num_cpus.

Kernel-batched paths (lookupBatch/updateBatch/deleteBatch/drainBatch) are not available on per-CPU maps — use entries() for full scans. The two hash variants also have delete(key); the array variant does not (arrays can't delete). LruPercpuHashMap evicts on a full update like its non-percpu sibling, with per-CPU LRU bookkeeping.

O DataSec

Reads and writes the .data / .rodata / .bss sections that back the object's BPF globals.

M DataSec.read

read(sym?: string): Promise<any>

With a symbol name, reads that global; with no argument, reads the whole section as a typed composite (BigInt for __u64, Uint8Array for __u8[N], numbers / strings / nested objects / arrays for the rest). Returns undefined for a genuine miss.

M DataSec.patch

patch(sym: string, value: any): Promise<void>
patch(values: object): Promise<void>

Partial update — only the leaves you name are written; unspecified bytes are left as-is. Atomic per call: if any field fails to lift, nothing changes. Pass a single (sym, value) to write one symbol (scalar or sub-tree), or an object to write several at once.

const cfg = new DataSec(control, ".rodata");
await cfg.patch("target_pid", 1234n);
await cfg.patch({ sample_rate: 100, enabled: 1 });

Full example

import probe from './execsnoop.bpf.o';
import { RingBuf, HashMap } from 'yeet:bpf';

const control = await probe
.bind("events", { kind: "ringbuf", btf_struct: "exec_event", capacity: 8192 })
.bind("counts", { kind: "hashmap" }) // per-pid exec counter
.start(); // tracepoint auto-attaches by section

const events = new RingBuf(control, "events");
const counts = new HashMap(control, "counts");

// records arrive keyed by the BTF struct name; fields keep their C types
const sub = await events.subscribe(({ exec_event: e }) => {
console.log(`${e.comm} (pid ${e.pid}) exec'd ${e.filename}`);
});

// the isolate exits once the top-level module settles, so hold it open —
// here, until the first SIGINT-equivalent teardown you wire up. A bare
// setTimeout does NOT keep it alive; await a promise instead.
await new Promise(resolve => {
setTimeout(async () => {
for await (const [pid, n] of counts.entries()) console.log(pid, n);
await sub.unsubscribe();
await control.stop();
resolve();
}, 10_000);
});