Skip to content

Commit 2ae426a

Browse files
authored
ffi: support Bun and Node.js backends (#6)
Load bun:ffi or node:ffi at startup so the same packed structs work under both runtimes. Pointers flow through a Pointer = number | bigint type so callers stay agnostic to the runtime: Bun returns number, Node returns bigint. Retain every packed pointer target in a WeakMap keyed by the owning buffer. Otherwise the GC may free strings, nested buffers, and array storage while the parent struct still holds raw pointers, producing use-after-free when the struct is passed to native code.
1 parent 0a3fd4a commit 2ae426a

12 files changed

Lines changed: 216 additions & 46 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# bun-ffi-structs
22

3-
TypeScript FFI struct library for Bun. Define and pack/unpack C-style structs with memory layout control for FFI calls.
3+
TypeScript struct-packing library for Bun and Node.js `node:ffi` workflows. Define and pack/unpack C-style structs with memory layout control for FFI calls.
44

55
## Features
66

@@ -22,6 +22,8 @@ TypeScript FFI struct library for Bun. Define and pack/unpack C-style structs wi
2222
bun install bun-ffi-structs
2323
```
2424

25+
Using the package on Node.js currently requires `node:ffi` support to be enabled, for example with `--experimental-ffi --allow-ffi` on supported builds.
26+
2527
For local development, use `scripts/link-dev.sh <target-project-root>` to symlink this package into another project's node_modules.
2628

2729
## Usage

src/ffi.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Pointer } from "./types.js"
2+
3+
interface FfiBackend {
4+
ptr(value: ArrayBufferLike | ArrayBufferView): Pointer
5+
toArrayBuffer(pointer: Pointer, offset: number | undefined, length: number): ArrayBuffer
6+
}
7+
8+
interface BunFfiModule {
9+
ptr(value: ArrayBufferLike | ArrayBufferView): number
10+
toArrayBuffer(pointer: number, offset: number | undefined, length: number): ArrayBuffer
11+
}
12+
13+
interface NodeFfiModule {
14+
getRawPointer(source: ArrayBuffer): bigint
15+
toArrayBuffer(pointer: bigint, length: number, copy?: boolean): ArrayBuffer
16+
}
17+
18+
const FFI_LOAD_ERROR = "bun-ffi-structs requires Bun or Node.js with node:ffi enabled (--experimental-ffi --allow-ffi)."
19+
20+
const backend = await loadBackend()
21+
22+
async function loadBackend(): Promise<FfiBackend> {
23+
if (typeof process !== "undefined" && "bun" in process.versions) {
24+
return createBunBackend(await importModule<BunFfiModule>("bun:ffi"))
25+
}
26+
27+
try {
28+
return createNodeBackend(await importModule<NodeFfiModule>("node:ffi"))
29+
} catch (error) {
30+
throw new Error(FFI_LOAD_ERROR, {
31+
cause: error instanceof Error ? error : undefined,
32+
})
33+
}
34+
}
35+
36+
function importModule<T>(specifier: string): Promise<T> {
37+
return import(specifier).then((module) => (module as { default?: T }).default ?? (module as T))
38+
}
39+
40+
function createBunBackend(bun: BunFfiModule): FfiBackend {
41+
return {
42+
ptr: bun.ptr,
43+
toArrayBuffer(pointer, offset, length) {
44+
return bun.toArrayBuffer(toBunPointer(pointer), offset, length)
45+
},
46+
}
47+
}
48+
49+
function createNodeBackend(nodeFfi: NodeFfiModule): FfiBackend {
50+
return {
51+
ptr(value) {
52+
if (ArrayBuffer.isView(value)) {
53+
return nodeFfi.getRawPointer(value.buffer as ArrayBuffer) + BigInt(value.byteOffset)
54+
}
55+
56+
if (value instanceof ArrayBuffer) {
57+
return nodeFfi.getRawPointer(value)
58+
}
59+
60+
throw new TypeError("node:ffi ptr() only supports ArrayBuffer and ArrayBufferView values.")
61+
},
62+
toArrayBuffer(pointer, offset, length) {
63+
return nodeFfi.toArrayBuffer(toBigIntPointer(pointer) + BigInt(offset ?? 0), length, false)
64+
},
65+
}
66+
}
67+
68+
function toBigIntPointer(pointer: Pointer): bigint {
69+
return typeof pointer === "bigint" ? pointer : BigInt(pointer)
70+
}
71+
72+
function toBunPointer(pointer: Pointer): number {
73+
return typeof pointer === "bigint" ? Number(pointer) : pointer
74+
}
75+
76+
export const ptr = backend.ptr
77+
export const toArrayBuffer = backend.toArrayBuffer

src/structs_ffi.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { ptr, toArrayBuffer } from "bun:ffi"
1+
import { ptr, toArrayBuffer } from "./ffi.js"
22
import type {
3+
Pointer,
34
PrimitiveType,
45
PointyObject,
56
ObjectPointerDef,
@@ -24,6 +25,7 @@ function fatalError(...args: any[]): never {
2425
}
2526

2627
export const pointerSize = process.arch === "x64" || process.arch === "arm64" ? 8 : 4
28+
const isBun = typeof process !== "undefined" && "bun" in process.versions
2729

2830
const typeSizes: Record<PrimitiveType, number> = {
2931
u8: 1,
@@ -95,6 +97,7 @@ export function allocStruct(structDef: StructDef<any, any>, options?: AllocStruc
9597

9698
const pointer = length > 0 ? ptr(subBuffer) : null
9799
pointerPacker(view, arrayMeta.arrayOffset, pointer)
100+
retainPointerTarget(buffer, subBuffer)
98101
arrayMeta.lengthPack(view, arrayMeta.lengthOffset, length)
99102
}
100103

@@ -228,14 +231,18 @@ function primitivePackers(type: PrimitiveType) {
228231
unpack = (view: DataView, off: number) => view.getFloat64(off, true)
229232
break
230233
case "pointer":
231-
pack = (view: DataView, off: number, val: bigint | number) => {
234+
pack = (view: DataView, off: number, val: Pointer) => {
232235
pointerSize === 8
233236
? view.setBigUint64(off, val ? BigInt(val) : 0n, true)
234237
: view.setUint32(off, val ? Number(val) : 0, true)
235238
}
236-
unpack = (view: DataView, off: number): number => {
237-
const bint = pointerSize === 8 ? view.getBigUint64(off, true) : BigInt(view.getUint32(off, true))
238-
return Number(bint)
239+
unpack = (view: DataView, off: number): Pointer => {
240+
if (pointerSize === 8) {
241+
const value = view.getBigUint64(off, true)
242+
return isBun ? Number(value) : value
243+
}
244+
245+
return view.getUint32(off, true)
239246
}
240247
break
241248
default:
@@ -248,6 +255,31 @@ function primitivePackers(type: PrimitiveType) {
248255

249256
const { pack: pointerPacker, unpack: pointerUnpacker } = primitivePackers("pointer")
250257

258+
const retainedPointerTargets = new WeakMap<ArrayBufferLike, unknown[]>()
259+
260+
function retainPointerTarget(owner: ArrayBufferLike, target: unknown) {
261+
const retained = retainedPointerTargets.get(owner)
262+
if (retained) {
263+
retained.push(target)
264+
} else {
265+
retainedPointerTargets.set(owner, [target])
266+
}
267+
}
268+
269+
function retainIfPointerTargets(owner: ArrayBufferLike, target: ArrayBufferLike) {
270+
if (retainedPointerTargets.has(target)) {
271+
retainPointerTarget(owner, target)
272+
}
273+
}
274+
275+
function isNullPointer(pointer: Pointer | null | undefined): boolean {
276+
return pointer == null || pointer === 0 || pointer === 0n
277+
}
278+
279+
function toItemCount(length: number | bigint): number {
280+
return typeof length === "bigint" ? Number(length) : length
281+
}
282+
251283
export function packObjectArray(val: (PointyObject | null)[]) {
252284
const buffer = new ArrayBuffer(val.length * pointerSize)
253285
const bufferView = new DataView(buffer)
@@ -299,8 +331,15 @@ export function defineStruct<const Fields extends readonly StructField[], const
299331
size = pointerSize
300332
align = pointerSize
301333
pack = (view: DataView, off: number, val: string | null) => {
302-
const bufPtr = val ? ptr(encoder.encode(val + "\0")) : null
334+
if (!val) {
335+
pointerPacker(view, off, null)
336+
return
337+
}
338+
339+
const bytes = encoder.encode(val + "\0")
340+
const bufPtr = ptr(bytes)
303341
pointerPacker(view, off, bufPtr)
342+
retainPointerTarget(view.buffer, bytes)
304343
}
305344
unpack = (view: DataView, off: number) => {
306345
// TODO: Unpack CString from pointer
@@ -312,8 +351,15 @@ export function defineStruct<const Fields extends readonly StructField[], const
312351
size = pointerSize
313352
align = pointerSize
314353
pack = (view: DataView, off: number, val: string | null) => {
315-
const bufPtr = val ? ptr(encoder.encode(val)) : null // No null terminator
354+
if (!val) {
355+
pointerPacker(view, off, null)
356+
return
357+
}
358+
359+
const bytes = encoder.encode(val) // No null terminator
360+
const bufPtr = ptr(bytes)
316361
pointerPacker(view, off, bufPtr)
362+
retainPointerTarget(view.buffer, bytes)
317363
}
318364
// Initial unpack returns pointer; will be replaced if lengthOf field exists
319365
unpack = (view: DataView, off: number) => {
@@ -347,6 +393,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
347393
}
348394
const nestedBuf = typeOrStruct.pack(val, options)
349395
pointerPacker(view, off, ptr(nestedBuf))
396+
retainPointerTarget(view.buffer, nestedBuf)
350397
}
351398
unpack = (view, off) => {
352399
throw new Error("Not implemented yet")
@@ -360,6 +407,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
360407
const nestedView = new Uint8Array(nestedBuf)
361408
const dView = new Uint8Array(view.buffer)
362409
dView.set(nestedView, off)
410+
retainIfPointerTargets(view.buffer, nestedBuf)
363411
}
364412
unpack = (view, off) => {
365413
const slice = view.buffer.slice(off, off + size)
@@ -412,6 +460,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
412460
bufferView.setUint32(i * arrayElementSize, num, true)
413461
}
414462
pointerPacker(view, off, ptr(buffer))
463+
retainPointerTarget(view.buffer, buffer)
415464
}
416465
unpack = null!
417466
needsLengthOf = true
@@ -430,6 +479,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
430479
def.packInto(val[i], bufferView, i * arrayElementSize, options)
431480
}
432481
pointerPacker(view, off, ptr(buffer))
482+
retainPointerTarget(view.buffer, buffer)
433483
}
434484
unpack = (view, off) => {
435485
throw new Error("Not implemented yet")
@@ -450,6 +500,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
450500
primitivePack(bufferView, i * arrayElementSize, val[i])
451501
}
452502
pointerPacker(view, off, ptr(buffer))
503+
retainPointerTarget(view.buffer, buffer)
453504
}
454505
unpack = null!
455506
needsLengthOf = true
@@ -464,6 +515,7 @@ export function defineStruct<const Fields extends readonly StructField[], const
464515

465516
const packedView = packObjectArray(val)
466517
pointerPacker(view, off, ptr(packedView.buffer))
518+
retainPointerTarget(view.buffer, packedView.buffer)
467519
}
468520
unpack = () => {
469521
// TODO: implement unpack for class pointers
@@ -583,11 +635,11 @@ export function defineStruct<const Fields extends readonly StructField[], const
583635
const ptrAddress = pointerUnpacker(view, off)
584636
const length = lengthOfField.unpack(view, off + relativeOffset)
585637

586-
if (ptrAddress === 0) {
638+
if (isNullPointer(ptrAddress)) {
587639
return null
588640
}
589641

590-
const byteLength = typeof length === "bigint" ? Number(length) : length
642+
const byteLength = toItemCount(length)
591643

592644
if (byteLength === 0) {
593645
return ""
@@ -604,19 +656,20 @@ export function defineStruct<const Fields extends readonly StructField[], const
604656
requester.unpack = (view, off) => {
605657
const result = []
606658
const length = lengthOfField.unpack(view, off + relativeOffset)
659+
const itemCount = toItemCount(length)
607660
const ptrAddress = pointerUnpacker(view, off)
608661

609-
if (ptrAddress === 0n && length > 0) {
662+
if (isNullPointer(ptrAddress) && itemCount > 0) {
610663
throw new Error(`Array field ${requester.name} has null pointer but length ${length}.`)
611664
}
612-
if (ptrAddress === 0n || length === 0) {
665+
if (isNullPointer(ptrAddress) || itemCount === 0) {
613666
return []
614667
}
615668

616-
const buffer = toArrayBuffer(ptrAddress, 0, length * elemSize)
669+
const buffer = toArrayBuffer(ptrAddress, 0, itemCount * elemSize)
617670
const bufferView = new DataView(buffer)
618671

619-
for (let i = 0; i < length; i++) {
672+
for (let i = 0; i < itemCount; i++) {
620673
result.push(primitiveUnpack(bufferView, i * elemSize))
621674
}
622675
return result
@@ -628,19 +681,20 @@ export function defineStruct<const Fields extends readonly StructField[], const
628681
requester.unpack = (view, off) => {
629682
const result = []
630683
const length = lengthOfField.unpack(view, off + relativeOffset)
684+
const itemCount = toItemCount(length)
631685
const ptrAddress = pointerUnpacker(view, off)
632686

633-
if (ptrAddress === 0n && length > 0) {
687+
if (isNullPointer(ptrAddress) && itemCount > 0) {
634688
throw new Error(`Array field ${requester.name} has null pointer but length ${length}.`)
635689
}
636-
if (ptrAddress === 0n || length === 0) {
690+
if (isNullPointer(ptrAddress) || itemCount === 0) {
637691
return []
638692
}
639693

640-
const buffer = toArrayBuffer(ptrAddress, 0, length * elemSize)
694+
const buffer = toArrayBuffer(ptrAddress, 0, itemCount * elemSize)
641695
const bufferView = new DataView(buffer)
642696

643-
for (let i = 0; i < length; i++) {
697+
for (let i = 0; i < itemCount; i++) {
644698
result.push(def.from(bufferView.getUint32(i * elemSize, true)))
645699
}
646700
return result

src/tests/char-star-unpacking.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, describe, it } from "bun:test"
2+
import { ptr } from "../ffi.js"
23
import { defineStruct } from "../structs_ffi.js"
3-
import { ptr } from "bun:ffi"
44

55
describe("char* automatic unpacking", () => {
66
it("should automatically unpack char* to string when lengthOf field exists", () => {
@@ -164,9 +164,13 @@ describe("char* automatic unpacking", () => {
164164
})
165165
const unpacked = PointerStruct.unpack(packed)
166166

167-
// Without lengthOf, should return the pointer value (number)
168-
expect(typeof unpacked.data).toBe("number")
169-
expect(unpacked.data).toBeGreaterThan(0)
167+
// Without lengthOf, should return the raw pointer value.
168+
expect(["number", "bigint"]).toContain(typeof unpacked.data)
169+
if (typeof unpacked.data === "bigint") {
170+
expect(unpacked.data).toBeGreaterThan(0n)
171+
} else {
172+
expect(unpacked.data).toBeGreaterThan(0)
173+
}
170174
expect(unpacked.someOtherField).toBe(42)
171175
})
172176

src/tests/field-validation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, describe, it } from "bun:test"
2-
import { toArrayBuffer } from "bun:ffi"
2+
import { toArrayBuffer } from "../ffi.js"
33
import { defineEnum, defineStruct } from "../structs_ffi.js"
44

55
describe("field validation", () => {

src/tests/graphemes.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, describe, it } from "bun:test"
2-
import { toArrayBuffer } from "bun:ffi"
2+
import { toArrayBuffer } from "../ffi.js"
33
import { defineStruct } from "../structs_ffi.js"
4+
import type { Pointer } from "../types.js"
45

56
describe("string packing with graphemes and emojis", () => {
67
it("should pack and unpack char* with byte length (not character count) for ASCII", () => {
@@ -113,11 +114,10 @@ describe("string packing with graphemes and emojis", () => {
113114
data: v,
114115
length: Buffer.byteLength(v),
115116
}),
116-
reduceValue: (v: { data: number; length: bigint }) => {
117-
if (v.data === 0 || v.length === 0n) {
117+
reduceValue: (v: { data: Pointer; length: bigint }) => {
118+
if (v.data === 0 || v.data === 0n || v.length === 0n) {
118119
return ""
119120
}
120-
// @ts-ignore - toArrayBuffer pointer type issue
121121
const buffer = toArrayBuffer(v.data, 0, Number(v.length))
122122
return new TextDecoder().decode(buffer)
123123
},
@@ -143,11 +143,10 @@ describe("string packing with graphemes and emojis", () => {
143143
data: v,
144144
length: Buffer.byteLength(v),
145145
}),
146-
reduceValue: (v: { data: number; length: bigint }) => {
147-
if (v.data === 0 || v.length === 0n) {
146+
reduceValue: (v: { data: Pointer; length: bigint }) => {
147+
if (v.data === 0 || v.data === 0n || v.length === 0n) {
148148
return ""
149149
}
150-
// @ts-ignore - toArrayBuffer pointer type issue
151150
const buffer = toArrayBuffer(v.data, 0, Number(v.length))
152151
return new TextDecoder().decode(buffer)
153152
},

0 commit comments

Comments
 (0)