unffi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # unffi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/unffi?style=flat-square)](https://www.npmjs.com/package/unffi) [![license](https://img.shields.io/npm/l/unffi?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/rajaniraiyn/unffi/ci.yml?style=flat-square&label=CI)](https://github.com/rajaniraiyn/unffi/actions)
4
+
5
+ Universal FFI for Bun, Deno, and Node.js — one schema, runtime-native performance.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install unffi
11
+ pnpm add unffi
12
+ yarn add unffi
13
+ bun add unffi
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```ts
19
+ import { dlopen, t } from 'unffi'
20
+
21
+ // Compile: cc -shared -o libgreeter.dylib greeter.c
22
+ // char* greet(const char* name);
23
+ // void on_greet(void (*cb)(const char*), const char* name);
24
+
25
+ await using lib = await dlopen('./libgreeter', {
26
+ greet: {
27
+ args: [t.cstring],
28
+ returns: t.cstring,
29
+ },
30
+ on_greet: {
31
+ args: [t.fn([t.cstring], t.void), t.cstring],
32
+ returns: t.void,
33
+ },
34
+ })
35
+
36
+ console.log(lib.symbols.greet('world'))
37
+ // → "Hello, world!"
38
+
39
+ lib.symbols.on_greet((msg) => console.log(msg), 'unffi')
40
+ // → "Hello, unffi!"
41
+
42
+ // lib.close() called automatically via await using (Symbol.asyncDispose)
43
+ ```
44
+
45
+ ## Runtimes
46
+
47
+ | Runtime | FFI backend | Notes |
48
+ |---------|-------------|-------|
49
+ | Bun | `bun:ffi` | Zero overhead, native types |
50
+ | Deno | `Deno.dlopen` | `usize`/`isize` platform types |
51
+ | Node.js | `node:ffi` (Node 26+) → koffi | koffi is an optional peer dep, installed automatically |
52
+
53
+ ## How it works
54
+
55
+ `unffi` ships separate adapter modules for each runtime and uses `package.json` export conditions (`"bun"`, `"deno"`, `"node"`) so the runtime loads the right adapter with no branching in your code. Each adapter translates the shared `t.xxx` type tokens into whatever representation that runtime expects — `bun:ffi` type strings, `Deno.NativeType`, or koffi signatures. On Node 26+, the native `node:ffi` module is preferred; koffi is the fallback for older Node versions.
56
+
57
+ ## Runtime-specific types
58
+
59
+ Import from `unffi/types` to access types not in the universal core.
60
+
61
+ - `t.bun.i64_fast` — i64 as `number` instead of `bigint` when Bun can fit it safely
62
+ - `t.deno.usize` / `t.deno.isize` — pointer-width integers on the current platform
63
+ - `t.koffi.struct(layout)` — define a C struct layout for koffi on Node.js
64
+
65
+ Full API docs: [rajaniraiyn.github.io/unffi](https://rajaniraiyn.github.io/unffi)
66
+
67
+ ## License
68
+
69
+ MIT
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "unffi",
3
+ "version": "0.1.0",
4
+ "description": "Universal FFI for Bun, Deno, and Node.js — one schema, runtime-native performance",
5
+ "workspaces": ["docs"],
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "bun": {
10
+ "types": "./dist/adapters/bun.d.ts",
11
+ "default": "./dist/adapters/bun.js"
12
+ },
13
+ "deno": {
14
+ "types": "./dist/adapters/deno.d.ts",
15
+ "default": "./dist/adapters/deno.js"
16
+ },
17
+ "node": {
18
+ "types": "./dist/adapters/node.d.ts",
19
+ "default": "./dist/adapters/node.js"
20
+ },
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./types": {
25
+ "types": "./dist/types.d.ts",
26
+ "default": "./dist/types.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsgo -b tsconfig.json",
35
+ "build:clean": "tsgo -b --clean tsconfig.json && tsgo -b tsconfig.json",
36
+ "typecheck": "tsgo -b tsconfig.json --dry",
37
+ "dev": "tsgo -b --watch tsconfig.json",
38
+ "test": "bun test",
39
+ "test:watch": "bun test --watch",
40
+ "test:coverage": "bun test --coverage",
41
+ "typecheck:tests": "tsgo --project tsconfig.test.json"
42
+ },
43
+ "optionalDependencies": {
44
+ "koffi": "^2.9.0 || ^3.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "^1.3.0",
48
+ "@types/deno": "^2.7.0",
49
+ "@types/node": "^18.0.0",
50
+ "@typescript/native-preview": "latest"
51
+ },
52
+ "peerDependencies": {
53
+ "typescript": ">=5.2.0 <6"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "typescript": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "engines": {
61
+ "bun": ">=1.0.0",
62
+ "node": ">=18.0.0"
63
+ },
64
+ "keywords": [
65
+ "ffi",
66
+ "bun",
67
+ "deno",
68
+ "node",
69
+ "native",
70
+ "dylib",
71
+ "unjs"
72
+ ],
73
+ "license": "MIT",
74
+ "trustedDependencies": [
75
+ "koffi"
76
+ ]
77
+ }
@@ -0,0 +1,183 @@
1
+ import { dlopen as bunDlopen, FFIType, JSCallback, CString, ptr as bunPtr } from 'bun:ffi'
2
+ import type { SymbolsSchema, InferLibrary } from '../define.js'
3
+ import type { CCallback, CType, CTypeKind, CoreT } from '../types.js'
4
+ import { t as coreT } from '../types.js'
5
+ import { runtimeHint } from './hints.js'
6
+
7
+ export type { InferLibrary }
8
+
9
+ export interface BunT extends CoreT {
10
+ readonly bun: {
11
+ /**
12
+ * 64-bit signed integer that returns `number` when the value fits in a
13
+ * safe integer, `bigint` otherwise. Same as `FFIType.i64_fast`.
14
+ */
15
+ readonly i64_fast: CType<number | bigint>
16
+ /**
17
+ * 64-bit unsigned integer that returns `number` when the value fits in a
18
+ * safe integer, `bigint` otherwise. Same as `FFIType.u64_fast`.
19
+ */
20
+ readonly u64_fast: CType<number | bigint>
21
+ /** NAPI environment pointer — for interop with Node-API addons. */
22
+ readonly napi_env: CType<unknown>
23
+ /** NAPI value — for interop with Node-API addons. */
24
+ readonly napi_value: CType<unknown>
25
+ }
26
+ }
27
+
28
+ const coreFFITypes: Record<CTypeKind, FFIType> = {
29
+ void: FFIType.void,
30
+ bool: FFIType.bool,
31
+ i8: FFIType.int8_t,
32
+ i16: FFIType.int16_t,
33
+ i32: FFIType.int32_t,
34
+ i64: FFIType.int64_t,
35
+ u8: FFIType.uint8_t,
36
+ u16: FFIType.uint16_t,
37
+ u32: FFIType.uint32_t,
38
+ u64: FFIType.uint64_t,
39
+ f32: FFIType.float,
40
+ f64: FFIType.double,
41
+ cstring: FFIType.cstring,
42
+ pointer: FFIType.ptr,
43
+ buffer: FFIType.ptr,
44
+ function: FFIType.function,
45
+ }
46
+
47
+ const bunFFITypes: Record<string, FFIType> = {
48
+ 'bun:i64_fast': FFIType.i64_fast,
49
+ 'bun:u64_fast': FFIType.u64_fast,
50
+ 'bun:napi_env': FFIType.napi_env,
51
+ 'bun:napi_value': FFIType.napi_value,
52
+ }
53
+
54
+ const allFFITypes: Record<string, FFIType> = { ...coreFFITypes, ...bunFFITypes }
55
+
56
+ function getFFIType(kind: string): FFIType {
57
+ const type = allFFITypes[kind]
58
+ if (type !== undefined) return type
59
+ throw new Error(`[unffi/bun] Unsupported FFI type "${kind}". ${runtimeHint(kind, 'bun')}`)
60
+ }
61
+
62
+ const bunExtensions = {
63
+ i64_fast: { kind: 'bun:i64_fast' } as unknown as CType<number | bigint>,
64
+ u64_fast: { kind: 'bun:u64_fast' } as unknown as CType<number | bigint>,
65
+ napi_env: { kind: 'bun:napi_env' } as unknown as CType<unknown>,
66
+ napi_value: { kind: 'bun:napi_value' } as unknown as CType<unknown>,
67
+ }
68
+
69
+ export const t: BunT = Object.assign({}, coreT, { bun: bunExtensions })
70
+
71
+ export function dlopen<const S extends SymbolsSchema>(path: string, schema: S): InferLibrary<S> {
72
+ const bunSymbols: Record<string, { args: FFIType[]; returns: FFIType; nonblocking?: boolean }> = {}
73
+
74
+ for (const [name, def] of Object.entries(schema)) {
75
+ bunSymbols[name] = {
76
+ args: def.args.map((a: CType<unknown>) => getFFIType(a.kind)),
77
+ returns: getFFIType(def.returns.kind),
78
+ ...(def.async && { nonblocking: true }),
79
+ }
80
+ }
81
+
82
+ const lib = bunDlopen(path, bunSymbols)
83
+ // NO FinalizationRegistry around JSCallbacks. C owns the function
84
+ // pointer indefinitely — a library can stash a callback (logger, atexit,
85
+ // signal handler) and invoke it long after the JS function is unreachable.
86
+ // GC-driven free would race with a live C-side pointer → use-after-free.
87
+ // Lifetime is bound to the library and freed in close().
88
+ const callbacks = new Map<string, JSCallback>()
89
+
90
+ const symbols: Record<string, (...args: unknown[]) => unknown> = {}
91
+
92
+ for (const [name, def] of Object.entries(schema)) {
93
+ const rawFn = (lib.symbols as Record<string, (...a: unknown[]) => unknown>)[name]!
94
+
95
+ const cstringInIdx = def.args
96
+ .map((a: CType<unknown>, i: number) => (a.kind === 'cstring' ? i : -1))
97
+ .filter((i: number) => i !== -1)
98
+ const bufferInIdx = def.args
99
+ .map((a: CType<unknown>, i: number) => (a.kind === 'buffer' ? i : -1))
100
+ .filter((i: number) => i !== -1)
101
+ const callbackIdx = def.args
102
+ .map((a: CType<unknown>, i: number) => (a.kind === 'function' ? i : -1))
103
+ .filter((i: number) => i !== -1)
104
+ const returnsCstring = def.returns.kind === 'cstring'
105
+
106
+ if (
107
+ cstringInIdx.length === 0 &&
108
+ bufferInIdx.length === 0 &&
109
+ callbackIdx.length === 0 &&
110
+ !returnsCstring
111
+ ) {
112
+ symbols[name] = rawFn
113
+ continue
114
+ }
115
+
116
+ symbols[name] = (...args: unknown[]) => {
117
+ const wrapped = args.length === def.args.length ? args.slice() : [...args]
118
+
119
+ for (const i of cstringInIdx) {
120
+ const v = wrapped[i]
121
+ if (typeof v === 'string') wrapped[i] = Buffer.from(v + '\0')
122
+ }
123
+
124
+ for (const i of bufferInIdx) {
125
+ const v = wrapped[i]
126
+ if (v != null && typeof v === 'object' && ArrayBuffer.isView(v) && v.byteLength > 0) {
127
+ wrapped[i] = bunPtr(v as NodeJS.TypedArray)
128
+ }
129
+ }
130
+
131
+ for (const i of callbackIdx) {
132
+ const cb = def.args[i] as CCallback<readonly CType<unknown>[], CType<unknown>>
133
+ const userFn = wrapped[i] as (...a: unknown[]) => unknown
134
+ const cbCstrIdx = cb.argTypes
135
+ .map((a: CType<unknown>, j: number) => (a.kind === 'cstring' ? j : -1))
136
+ .filter((j: number) => j !== -1)
137
+
138
+ const wrappedFn = cbCstrIdx.length === 0
139
+ ? userFn
140
+ : (...cbArgs: unknown[]) => {
141
+ for (const j of cbCstrIdx) {
142
+ const p = cbArgs[j]
143
+ cbArgs[j] = p == null ? null : new CString(p as number & { __pointer__: null }).toString()
144
+ }
145
+ return userFn(...cbArgs)
146
+ }
147
+
148
+ const jsCb = new JSCallback(wrappedFn, {
149
+ args: cb.argTypes.map((a: CType<unknown>) => getFFIType(a.kind)),
150
+ returns: getFFIType(cb.returnType.kind),
151
+ })
152
+ callbacks.set(`${name}:${i}`, jsCb)
153
+ wrapped[i] = jsCb.ptr
154
+ }
155
+
156
+ const result = rawFn(...wrapped)
157
+
158
+ if (returnsCstring) {
159
+ if (result instanceof Promise) {
160
+ return result.then((r) => (r instanceof String ? r.toString() : r))
161
+ }
162
+ if (result instanceof String) return result.toString()
163
+ }
164
+ return result
165
+ }
166
+ }
167
+
168
+ let closed = false
169
+ function close() {
170
+ if (closed) return // idempotent: safe to call after `using` already disposed
171
+ closed = true
172
+ for (const cb of callbacks.values()) cb.close()
173
+ callbacks.clear()
174
+ lib.close()
175
+ }
176
+
177
+ return {
178
+ symbols: symbols as InferLibrary<S>['symbols'],
179
+ close,
180
+ [Symbol.dispose]: close,
181
+ [Symbol.asyncDispose]() { return Promise.resolve(close()) },
182
+ }
183
+ }
@@ -0,0 +1,259 @@
1
+ import type { SymbolsSchema, InferLibrary } from '../define.js'
2
+ import type { CCallback, CType, CTypeKind, CoreT } from '../types.js'
3
+ import { t as coreT } from '../types.js'
4
+ import { runtimeHint } from './hints.js'
5
+
6
+ export type { InferLibrary }
7
+
8
+ export interface DenoT extends CoreT {
9
+ readonly deno: {
10
+ /** Pointer-sized unsigned integer (64-bit on 64-bit systems) → `bigint` */
11
+ readonly usize: CType<bigint>
12
+ /** Pointer-sized signed integer (64-bit on 64-bit systems) → `bigint` */
13
+ readonly isize: CType<bigint>
14
+ /**
15
+ * Compose a struct C type. Passed by value to/from FFI as a
16
+ * BufferSource → Uint8Array. The `fields` argument is a list of
17
+ * unffi CType<*> tokens; their `kind`s are translated to Deno's
18
+ * NativeType under the hood.
19
+ *
20
+ * Example:
21
+ * const Point = t.deno.struct([t.f32, t.f32])
22
+ */
23
+ struct<const Fields extends readonly CType<any>[]>(fields: Fields): CType<Uint8Array>
24
+
25
+ /**
26
+ * Zero-copy: return a Deno pointer to a TypedArray's memory.
27
+ * Pass the result to symbols typed as `t.pointer`.
28
+ * Equivalent to `Deno.UnsafePointer.of(view)`.
29
+ */
30
+ ptrOf(view: ArrayBufferView | ArrayBuffer): Deno.PointerValue
31
+
32
+ /**
33
+ * Read a UTF-8 NUL-terminated string from a pointer.
34
+ * Useful for symbols that return `t.pointer` instead of `t.cstring`
35
+ * (e.g. when you need offset arithmetic).
36
+ */
37
+ readCString(pointer: Deno.PointerValue, offset?: number): string
38
+
39
+ /**
40
+ * Zero-copy read of `byteLength` bytes at `pointer + offset` as an ArrayBuffer.
41
+ */
42
+ readArrayBuffer(pointer: Deno.PointerValue, byteLength: number, offset?: number): ArrayBuffer
43
+ }
44
+ }
45
+
46
+ const coreDenoTypes: Record<CTypeKind, Deno.NativeResultType> = {
47
+ void: 'void',
48
+ bool: 'bool',
49
+ i8: 'i8', i16: 'i16', i32: 'i32', i64: 'i64',
50
+ u8: 'u8', u16: 'u16', u32: 'u32', u64: 'u64',
51
+ f32: 'f32', f64: 'f64',
52
+ cstring: 'pointer',
53
+ pointer: 'pointer',
54
+ buffer: 'buffer',
55
+ function: 'function',
56
+ }
57
+
58
+ const denoExtraTypes: Record<string, Deno.NativeResultType> = {
59
+ 'deno:usize': 'usize',
60
+ 'deno:isize': 'isize',
61
+ }
62
+
63
+ const allDenoTypes: Record<string, Deno.NativeResultType> = { ...coreDenoTypes, ...denoExtraTypes }
64
+
65
+ const StructDef = Symbol('unffi.deno.struct')
66
+ type StructCType = CType<Uint8Array> & { readonly [StructDef]: Deno.NativeStructType }
67
+
68
+ function isStructCType(t: CType<any>): t is StructCType {
69
+ return (t as StructCType)[StructDef] !== undefined
70
+ }
71
+
72
+ function getDenoType(type: CType<any>): Deno.NativeType {
73
+ if (isStructCType(type)) return type[StructDef]
74
+ const kind = type.kind
75
+ const mapped = allDenoTypes[kind]
76
+ if (mapped !== undefined && mapped !== 'void') return mapped as Deno.NativeType
77
+ if (mapped === 'void') return mapped as unknown as Deno.NativeType
78
+ throw new Error(`[unffi/deno] Unsupported FFI type "${kind}". ${runtimeHint(kind, 'deno')}`)
79
+ }
80
+
81
+ function getDenoResultType(type: CType<any>): Deno.NativeResultType {
82
+ if (isStructCType(type)) return type[StructDef]
83
+ const kind = type.kind
84
+ const mapped = allDenoTypes[kind]
85
+ if (mapped !== undefined) return mapped
86
+ throw new Error(`[unffi/deno] Unsupported FFI result type "${kind}".`)
87
+ }
88
+
89
+ const denoExtensions: DenoT['deno'] = {
90
+ usize: { kind: 'deno:usize' } as unknown as CType<bigint>,
91
+ isize: { kind: 'deno:isize' } as unknown as CType<bigint>,
92
+
93
+ struct<const Fields extends readonly CType<any>[]>(fields: Fields): CType<Uint8Array> {
94
+ const struct: Deno.NativeStructType = {
95
+ struct: fields.map((f) => getDenoType(f)),
96
+ }
97
+ const token = { kind: 'pointer' as CTypeKind, [StructDef]: struct }
98
+ return token as unknown as CType<Uint8Array>
99
+ },
100
+
101
+ ptrOf(view) { return Deno.UnsafePointer.of(view as ArrayBufferView) },
102
+ readCString(pointer, offset) {
103
+ if (pointer === null) throw new Error('[unffi/deno] readCString called with null pointer')
104
+ return Deno.UnsafePointerView.getCString(pointer, offset)
105
+ },
106
+ readArrayBuffer(pointer, byteLength, offset) {
107
+ if (pointer === null) throw new Error('[unffi/deno] readArrayBuffer called with null pointer')
108
+ return Deno.UnsafePointerView.getArrayBuffer(pointer, byteLength, offset)
109
+ },
110
+ }
111
+
112
+ export const t: DenoT = Object.assign({}, coreT, { deno: denoExtensions })
113
+
114
+ declare const TextEncoder: { new (): { encode(input: string): Uint8Array } }
115
+ const enc = new TextEncoder()
116
+
117
+ function encodeCStringPtr(s: string): { ptr: Deno.PointerValue; bytes: Uint8Array } {
118
+ const bytes = enc.encode(s + '\0')
119
+ return { ptr: Deno.UnsafePointer.of(bytes), bytes }
120
+ }
121
+
122
+ function decodeCStringResult(ptr: Deno.PointerValue): string | null {
123
+ if (ptr === null) return null
124
+ return Deno.UnsafePointerView.getCString(ptr)
125
+ }
126
+
127
+ export function dlopen<const S extends SymbolsSchema>(path: string, schema: S): InferLibrary<S> {
128
+ const denoSymbols: Record<string, Deno.ForeignFunction> = {}
129
+
130
+ for (const [name, def] of Object.entries(schema)) {
131
+ denoSymbols[name] = {
132
+ parameters: def.args.map((a) => getDenoType(a)),
133
+ result: getDenoResultType(def.returns),
134
+ ...(def.async && { nonblocking: true }),
135
+ }
136
+ }
137
+
138
+ let lib: ReturnType<typeof Deno.dlopen>
139
+ try {
140
+ lib = Deno.dlopen(path, denoSymbols)
141
+ } catch (e) {
142
+ if (e instanceof Deno.errors.PermissionDenied) {
143
+ throw new Error(
144
+ '[unffi] Deno FFI requires the --allow-ffi permission flag.\n' +
145
+ ' Run your script with: deno run --allow-ffi <script.ts>\n' +
146
+ ' Docs: https://docs.deno.com/runtime/fundamentals/ffi/',
147
+ )
148
+ }
149
+ throw e
150
+ }
151
+
152
+ // NO FinalizationRegistry on UnsafeCallbacks. C may retain the
153
+ // pointer after the JS function becomes unreachable (stored handler,
154
+ // signal/atexit hook), so GC-driven free races with a live C-side caller.
155
+ // Lifetime is bound to the library, freed in close().
156
+ const callbacks = new Set<Deno.UnsafeCallback>()
157
+ const wrappedSymbols: Record<string, (...args: unknown[]) => unknown> = {}
158
+
159
+ for (const [name, def] of Object.entries(schema)) {
160
+ const rawFn = (lib.symbols as Record<string, (...a: unknown[]) => unknown>)[name]
161
+
162
+ if (!rawFn) continue
163
+
164
+ const cstringInIdx = def.args
165
+ .map((a, i) => (a.kind === 'cstring' ? i : -1))
166
+ .filter((i) => i !== -1)
167
+ const callbackIdx = def.args
168
+ .map((a, i) => (a.kind === 'function' ? i : -1))
169
+ .filter((i) => i !== -1)
170
+ const returnsCstring = def.returns.kind === 'cstring'
171
+
172
+ if (cstringInIdx.length === 0 && callbackIdx.length === 0 && !returnsCstring) {
173
+ wrappedSymbols[name] = rawFn
174
+ continue
175
+ }
176
+
177
+ wrappedSymbols[name] = (...args: unknown[]) => {
178
+ const wrapped: unknown[] = [...args]
179
+ const keepAlive: Uint8Array[] = []
180
+
181
+ for (const i of cstringInIdx) {
182
+ const v = wrapped[i]
183
+ if (typeof v === 'string') {
184
+ const { ptr, bytes } = encodeCStringPtr(v)
185
+ keepAlive.push(bytes)
186
+ wrapped[i] = ptr
187
+ }
188
+ // If the caller already passed a Deno.PointerValue (advanced path), leave it.
189
+ }
190
+
191
+ for (const i of callbackIdx) {
192
+ const cb = def.args[i] as CCallback<readonly CType<unknown>[], CType<unknown>>
193
+ const userFn = wrapped[i] as (...a: unknown[]) => unknown
194
+
195
+ const cbCstrIdx = cb.argTypes
196
+ .map((a, j) => (a.kind === 'cstring' ? j : -1))
197
+ .filter((j) => j !== -1)
198
+ const cbReturnsCstring = cb.returnType.kind === 'cstring'
199
+
200
+ const cbKeepAlive: Uint8Array[] = []
201
+
202
+ const inner = (cbCstrIdx.length === 0 && !cbReturnsCstring)
203
+ ? userFn
204
+ : (...cbArgs: unknown[]) => {
205
+ for (const j of cbCstrIdx) {
206
+ const p = cbArgs[j] as Deno.PointerValue
207
+ cbArgs[j] = p === null ? null : Deno.UnsafePointerView.getCString(p)
208
+ }
209
+ const r = userFn(...cbArgs)
210
+ if (cbReturnsCstring && typeof r === 'string') {
211
+ const bytes = enc.encode(r + '\0')
212
+ cbKeepAlive.push(bytes)
213
+ return Deno.UnsafePointer.of(bytes)
214
+ }
215
+ return r
216
+ }
217
+
218
+ const unsafeCb = new Deno.UnsafeCallback(
219
+ {
220
+ parameters: cb.argTypes.map((a) => getDenoType(a)),
221
+ result: getDenoResultType(cb.returnType),
222
+ } as Deno.UnsafeCallbackDefinition,
223
+ inner as Deno.UnsafeCallbackFunction,
224
+ )
225
+ callbacks.add(unsafeCb)
226
+ wrapped[i] = unsafeCb.pointer
227
+ }
228
+
229
+ const result = rawFn(...wrapped)
230
+
231
+ if (returnsCstring) {
232
+ if (result instanceof Promise) {
233
+ return result.then((r) => decodeCStringResult(r as Deno.PointerValue))
234
+ }
235
+ return decodeCStringResult(result as Deno.PointerValue)
236
+ }
237
+
238
+ // Touch keepAlive after the call so the optimiser cannot drop it.
239
+ void keepAlive.length
240
+ return result
241
+ }
242
+ }
243
+
244
+ let closed = false
245
+ function close() {
246
+ if (closed) return // idempotent: safe to call after `using` already disposed
247
+ closed = true
248
+ for (const cb of callbacks) cb.close()
249
+ callbacks.clear()
250
+ lib.close()
251
+ }
252
+
253
+ return {
254
+ symbols: wrappedSymbols as InferLibrary<S>['symbols'],
255
+ close,
256
+ [Symbol.dispose]: close,
257
+ [Symbol.asyncDispose]() { return Promise.resolve(close()) },
258
+ }
259
+ }
@@ -0,0 +1,7 @@
1
+ export function runtimeHint(kind: string, self: 'bun' | 'deno' | 'node'): string {
2
+ if (self !== 'bun' && kind.startsWith('bun:')) return 'This is a Bun-specific type — run with Bun. See https://bun.sh/docs/api/ffi'
3
+ if (self !== 'deno' && kind.startsWith('deno:')) return 'This is a Deno-specific type — run with Deno. See https://docs.deno.com/runtime/fundamentals/ffi/'
4
+ if (self !== 'node' && kind.startsWith('node:')) return 'This is a Node.js-specific type — run with Node.js.'
5
+ if (self !== 'node' && kind.startsWith('koffi:')) return 'This is a koffi-specific type — run with Node.js and install koffi. See https://koffi.dev'
6
+ return 'Unknown type kind.'
7
+ }
@@ -0,0 +1,249 @@
1
+ import koffi, { type IKoffiLib, type IKoffiCType } from 'koffi'
2
+ import type { SymbolsSchema, InferLibrary } from '../define.js'
3
+ import type { CCallback, CType, CTypeKind, CoreT } from '../types.js'
4
+ import { t as coreT } from '../types.js'
5
+ import { runtimeHint } from './hints.js'
6
+
7
+ export type { InferLibrary }
8
+
9
+ type KoffiTypeSpec = Parameters<IKoffiLib['symbol']>[1]
10
+
11
+ type KoffiStructDef = Parameters<typeof koffi.struct>[0] extends infer P
12
+ ? P extends Record<string, unknown> ? P : Record<string, KoffiTypeSpec>
13
+ : Record<string, KoffiTypeSpec>
14
+
15
+ // koffi.d.ts overlapping overloads make Parameters<typeof koffi.array>[2] resolve wrong
16
+ type KoffiArrayHint = 'Array' | 'Typed' | 'String'
17
+
18
+ const nativeKoffiType = new WeakMap<CType<unknown>, IKoffiCType>()
19
+
20
+ function brand<T>(native: IKoffiCType): CType<T> {
21
+ const ct = { kind: 'koffi:native' as CTypeKind } as CType<T>
22
+ nativeKoffiType.set(ct as CType<unknown>, native)
23
+ return ct
24
+ }
25
+
26
+ export interface KoffiT extends CoreT {
27
+ readonly koffi: {
28
+ /** UTF-16 string — for Windows APIs that use wide strings (koffi `str16`). */
29
+ readonly str16: CType<string>
30
+ /** Pointer-sized unsigned integer, returns `bigint` (koffi `uintptr_t`). */
31
+ readonly uintptr: CType<bigint>
32
+ /** Pointer-sized signed integer, returns `bigint` (koffi `intptr_t`). */
33
+ readonly intptr: CType<bigint>
34
+ /** Build a struct type from a field map.
35
+ *
36
+ * ```ts
37
+ * const Point = t.koffi.struct<{ x: number; y: number }>({ x: 'float64', y: 'float64' })
38
+ * ``` */
39
+ struct<T = Record<string, unknown>>(def: KoffiStructDef): CType<T>
40
+ struct<T = Record<string, unknown>>(name: string, def: KoffiStructDef): CType<T>
41
+ /** Build a fixed-length array type. */
42
+ array<T = unknown>(ref: CType<T> | KoffiTypeSpec, len: number, hint?: KoffiArrayHint): CType<T[]>
43
+ /** Build a pointer-to-type. */
44
+ pointer<T>(ref: CType<T> | KoffiTypeSpec): CType<T>
45
+ /** Mark a parameter as output-only (caller passes a 1-element array; koffi writes index 0). */
46
+ out<T>(ref: CType<T> | KoffiTypeSpec): CType<[T]>
47
+ /** Mark a parameter as input+output. */
48
+ inout<T>(ref: CType<T> | KoffiTypeSpec): CType<[T]>
49
+ /** Opaque pointer type (foreign handle). */
50
+ opaque(name?: string): CType<unknown>
51
+ /** Alias a type under a new name. */
52
+ alias<T>(name: string, ref: CType<T> | KoffiTypeSpec): CType<T>
53
+ /** Encode a JS string into an ArrayBuffer of the given type (default `str` / UTF-8). */
54
+ encode(value: string, type?: KoffiTypeSpec): ArrayBuffer
55
+ /** Decode a pointer / buffer back to a JS value. */
56
+ decode<T = unknown>(ref: unknown, type: CType<T> | KoffiTypeSpec): T
57
+ /** Free memory allocated by `koffi.alloc` or returned as `disposable`. */
58
+ free(ref: unknown): void
59
+ /** Direct access to the raw koffi module (escape hatch). */
60
+ readonly raw: typeof koffi
61
+ }
62
+ }
63
+
64
+ const coreKoffiTypes: Record<Exclude<CTypeKind, 'function'>, KoffiTypeSpec> = {
65
+ void: 'void',
66
+ bool: 'bool',
67
+ i8: 'int8', i16: 'int16', i32: 'int32', i64: 'int64',
68
+ u8: 'uint8', u16: 'uint16', u32: 'uint32', u64: 'uint64',
69
+ f32: 'float32', f64: 'float64',
70
+ cstring: 'str', // koffi auto-encodes/decodes UTF-8 string ↔ char* (verified)
71
+ pointer: 'void *',
72
+ buffer: 'void *', // TypedArrays are passed zero-copy (verified — fill_buf mutates caller's Int32Array)
73
+ }
74
+
75
+ const koffiNamedExtras: Record<string, KoffiTypeSpec> = {
76
+ 'koffi:str16': 'str16',
77
+ 'koffi:uintptr': 'uintptr_t',
78
+ 'koffi:intptr': 'intptr_t',
79
+ }
80
+
81
+ export function getKoffiType(type: CType<unknown> | string): KoffiTypeSpec {
82
+ if (typeof type !== 'string') {
83
+ const native = nativeKoffiType.get(type as CType<unknown>)
84
+ if (native !== undefined) return native
85
+ return resolveKind(type.kind)
86
+ }
87
+ return resolveKind(type)
88
+ }
89
+
90
+ function resolveKind(kind: string): KoffiTypeSpec {
91
+ if (kind in coreKoffiTypes) return coreKoffiTypes[kind as keyof typeof coreKoffiTypes]
92
+ if (kind in koffiNamedExtras) return koffiNamedExtras[kind]!
93
+ if (kind === 'function') return 'void *'
94
+ if (kind === 'koffi:native') throw new Error('[unffi/koffi] Internal: koffi:native type missing IKoffiCType payload')
95
+ throw new Error(`[unffi/koffi] Unsupported FFI type "${kind}". ${runtimeHint(kind, 'node')}`)
96
+ }
97
+
98
+ function toSpec(ref: CType<unknown> | KoffiTypeSpec): KoffiTypeSpec {
99
+ if (typeof ref === 'string') return ref
100
+ if (ref !== null && typeof ref === 'object' && '__brand' in (ref as object)) return ref as IKoffiCType
101
+ return getKoffiType(ref as CType<unknown>)
102
+ }
103
+
104
+ function asPointer(ref: CType<unknown> | KoffiTypeSpec): IKoffiCType {
105
+ return koffi.pointer(toSpec(ref))
106
+ }
107
+
108
+ const koffiExtensions: KoffiT['koffi'] = {
109
+ str16: { kind: 'koffi:str16' } as unknown as CType<string>,
110
+ uintptr: { kind: 'koffi:uintptr' } as unknown as CType<bigint>,
111
+ intptr: { kind: 'koffi:intptr' } as unknown as CType<bigint>,
112
+
113
+ struct<T>(a: string | KoffiStructDef, b?: KoffiStructDef): CType<T> {
114
+ const native = (typeof a === 'string')
115
+ ? koffi.struct(a, b!)
116
+ : koffi.struct(a as KoffiStructDef)
117
+ return brand<T>(native)
118
+ },
119
+
120
+ array<T>(ref: CType<T> | KoffiTypeSpec, len: number, hint?: KoffiArrayHint): CType<T[]> {
121
+ const native = hint === undefined
122
+ ? koffi.array(toSpec(ref), len)
123
+ : koffi.array(toSpec(ref), len, hint)
124
+ return brand<T[]>(native)
125
+ },
126
+
127
+ pointer<T>(ref: CType<T> | KoffiTypeSpec): CType<T> {
128
+ return brand<T>(koffi.pointer(toSpec(ref)))
129
+ },
130
+
131
+ out<T>(ref: CType<T> | KoffiTypeSpec): CType<[T]> {
132
+ return brand<[T]>(koffi.out(asPointer(ref)))
133
+ },
134
+
135
+ inout<T>(ref: CType<T> | KoffiTypeSpec): CType<[T]> {
136
+ return brand<[T]>(koffi.inout(asPointer(ref)))
137
+ },
138
+
139
+ opaque(name?: string): CType<unknown> {
140
+ return brand<unknown>(name === undefined ? koffi.opaque() : koffi.opaque(name))
141
+ },
142
+
143
+ alias<T>(name: string, ref: CType<T> | KoffiTypeSpec): CType<T> {
144
+ return brand<T>(koffi.alias(name, toSpec(ref)))
145
+ },
146
+
147
+ encode(value: string, type: KoffiTypeSpec = 'str'): ArrayBuffer {
148
+ const slot = koffi.sizeof(type)
149
+ const size = slot > 0 ? Math.max(slot, value.length + 1) : value.length + 1
150
+ const buf = new ArrayBuffer(size)
151
+ koffi.encode(buf, type, value)
152
+ return buf
153
+ },
154
+
155
+ decode<T>(ref: unknown, type: CType<T> | KoffiTypeSpec): T {
156
+ return koffi.decode(ref, toSpec(type)) as T
157
+ },
158
+
159
+ free: koffi.free,
160
+ raw: koffi,
161
+ }
162
+
163
+ export const t: KoffiT = Object.assign({}, coreT, { koffi: koffiExtensions })
164
+
165
+ type CallbackDef = { i: number; cb: CCallback<readonly CType<unknown>[], CType<unknown>> }
166
+
167
+ let cbCounter = 0
168
+
169
+ export function dlopen<const S extends SymbolsSchema>(path: string, schema: S): InferLibrary<S> {
170
+ const lib = koffi.load(path)
171
+ const symbols: Record<string, (...args: unknown[]) => unknown> = {}
172
+ const registered: IKoffiCType[] = []
173
+
174
+ for (const [name, def] of Object.entries(schema)) {
175
+ // Per-callback proto-pointer types, indexed by arg position.
176
+ const callbackDefs: CallbackDef[] = []
177
+ const cbPointerTypes: Record<number, IKoffiCType> = {}
178
+
179
+ const argTypes: KoffiTypeSpec[] = def.args.map((a: CType<unknown>, i: number) => {
180
+ if (a.kind === 'function') {
181
+ const cb = a as CCallback<readonly CType<unknown>[], CType<unknown>>
182
+ callbackDefs.push({ i, cb })
183
+ const proto = koffi.proto(
184
+ `__unffi_cb_${name}_${i}_${++cbCounter}`,
185
+ getKoffiType(cb.returnType),
186
+ cb.argTypes.map((tt: CType<unknown>) => getKoffiType(tt)),
187
+ )
188
+ const ptr = koffi.pointer(proto)
189
+ cbPointerTypes[i] = ptr
190
+ return ptr
191
+ }
192
+ return getKoffiType(a)
193
+ })
194
+
195
+ const retType = getKoffiType(def.returns)
196
+ const fn = lib.func(name, retType, argTypes)
197
+
198
+ symbols[name] = def.async
199
+ ? (...callArgs: unknown[]) => {
200
+ const wrapped = wrapCallbacks(callArgs, callbackDefs, cbPointerTypes, registered)
201
+ return new Promise<unknown>((resolve, reject) =>
202
+ fn.async(...wrapped, (err: Error | null, result: unknown) =>
203
+ err ? reject(err) : resolve(result),
204
+ ),
205
+ )
206
+ }
207
+ : (...callArgs: unknown[]) => fn(...wrapCallbacks(callArgs, callbackDefs, cbPointerTypes, registered))
208
+ }
209
+
210
+ // NO FinalizationRegistry on koffi callbacks. C owns the function
211
+ // pointer indefinitely (stored callbacks, signal handlers, etc.); GC-driven
212
+ // unregister races with a live C-side caller. Lifetime is bound to the
213
+ // library and released in close().
214
+ let closed = false
215
+ function close() {
216
+ if (closed) return // idempotent: safe to call after `using` already disposed
217
+ closed = true
218
+ for (const reg of registered) {
219
+ try { koffi.unregister(reg as unknown as Parameters<typeof koffi.unregister>[0]) } catch { /* already gone */ }
220
+ }
221
+ registered.length = 0
222
+ const maybeLib = lib as unknown as { unload?: () => void }
223
+ if (typeof maybeLib.unload === 'function') maybeLib.unload()
224
+ }
225
+
226
+ return {
227
+ symbols: symbols as InferLibrary<S>['symbols'],
228
+ close,
229
+ [Symbol.dispose]: close,
230
+ [Symbol.asyncDispose]() { return Promise.resolve(close()) },
231
+ }
232
+ }
233
+
234
+ function wrapCallbacks(
235
+ args: unknown[],
236
+ defs: CallbackDef[],
237
+ ptrTypes: Record<number, IKoffiCType>,
238
+ registered: IKoffiCType[],
239
+ ): unknown[] {
240
+ if (defs.length === 0) return args
241
+ const wrapped = [...args]
242
+ for (const { i } of defs) {
243
+ const userFn = args[i] as (...a: unknown[]) => unknown
244
+ const reg = koffi.register(userFn, ptrTypes[i]!)
245
+ registered.push(reg as unknown as IKoffiCType)
246
+ wrapped[i] = reg
247
+ }
248
+ return wrapped
249
+ }
@@ -0,0 +1,177 @@
1
+ import type { SymbolsSchema, InferLibrary } from '../define.js'
2
+ import type { CCallback, CType, CTypeKind, CoreT } from '../types.js'
3
+ import { dlopen as koffiDlopen, t as koffiT, type KoffiT } from './koffi.js'
4
+ import { runtimeHint } from './hints.js'
5
+
6
+ export type { InferLibrary, KoffiT }
7
+
8
+ // node:ffi ABI broken in Node 26.3.0 (every call reports "expected 0 arguments").
9
+ // Bump once a release ships the fix.
10
+ export const NODE_FFI_STABLE_VERSION = Infinity
11
+
12
+ export const t: KoffiT = koffiT
13
+
14
+ const nodeMajor = parseInt(process.versions.node.split('.')[0] ?? '0', 10)
15
+
16
+ type FfiState = 'unavailable' | 'needs-flag' | 'available-but-incomplete' | 'available'
17
+
18
+ interface NodeFFIFunctionDef {
19
+ parameters: readonly string[]
20
+ result: string
21
+ }
22
+ interface NodeFFIDynamicLibrary {
23
+ readonly functions: Record<string, (...args: unknown[]) => unknown>
24
+ registerCallback(def: NodeFFIFunctionDef, fn: (...args: unknown[]) => unknown): unknown
25
+ unregisterCallback(handle: unknown): void
26
+ close(): void
27
+ }
28
+ interface NodeFFIModule {
29
+ dlopen(path: string, schema: Record<string, NodeFFIFunctionDef>): {
30
+ lib: NodeFFIDynamicLibrary
31
+ functions: Record<string, (...args: unknown[]) => unknown>
32
+ }
33
+ }
34
+
35
+ let nodeFfi: NodeFFIModule | null = null
36
+
37
+ async function detectNodeFFI(): Promise<FfiState> {
38
+ if (nodeMajor < 26) return 'unavailable'
39
+
40
+ try {
41
+ // @ts-expect-error — node:ffi has no published type definitions yet
42
+ const mod = (await import('node:ffi')) as { default: NodeFFIModule } & NodeFFIModule
43
+ nodeFfi = (mod.default ?? mod) as NodeFFIModule
44
+ return nodeMajor >= NODE_FFI_STABLE_VERSION ? 'available' : 'available-but-incomplete'
45
+ } catch (e: unknown) {
46
+ const code = (e as NodeJS.ErrnoException).code
47
+ if (code === 'ERR_EXPERIMENTAL_FEATURE_NOT_ENABLED') return 'needs-flag'
48
+ if (code === 'ERR_UNKNOWN_BUILTIN_MODULE' && nodeMajor >= 26) return 'needs-flag'
49
+ return 'unavailable'
50
+ }
51
+ }
52
+
53
+ const ffiState = await detectNodeFFI()
54
+
55
+ if (ffiState === 'needs-flag') throw new Error(
56
+ '[unffi] Node.js native FFI is available in this version but requires the --experimental-ffi flag.\n' +
57
+ ' Run your script with: node --experimental-ffi <script.mjs>\n' +
58
+ ' Docs: https://nodejs.org/api/ffi.html',
59
+ )
60
+
61
+ if (ffiState === 'available-but-incomplete') {
62
+ process.emitWarning(
63
+ `node:ffi is available in Node ${process.versions.node} but its function-call ABI is not ` +
64
+ 'yet complete (wrapped functions report "expected 0 arguments"). Falling back to koffi. ' +
65
+ 'Upgrade to a newer Node release once node:ffi stabilises.',
66
+ 'UnffiWarning',
67
+ )
68
+ }
69
+
70
+ const coreNodeFfiTypes: Record<CTypeKind, string> = {
71
+ void: 'void',
72
+ bool: 'bool',
73
+ i8: 'int8', i16: 'int16', i32: 'int32', i64: 'int64',
74
+ u8: 'uint8', u16: 'uint16', u32: 'uint32', u64: 'uint64',
75
+ f32: 'float32', f64: 'float64',
76
+ cstring: 'string',
77
+ pointer: 'pointer',
78
+ buffer: 'buffer',
79
+ function: 'function',
80
+ }
81
+
82
+ function nodeFfiTypeFor(kind: string): string {
83
+ const mapped = coreNodeFfiTypes[kind as CTypeKind]
84
+ if (mapped !== undefined) return mapped
85
+ throw new Error(`[unffi/node] Unsupported FFI type "${kind}". ${runtimeHint(kind, 'node')}`)
86
+ }
87
+
88
+ type CallbackDef = { i: number; cb: CCallback<readonly CType<unknown>[], CType<unknown>> }
89
+
90
+ function nativeDlopen<const S extends SymbolsSchema>(path: string, schema: S): InferLibrary<S> {
91
+ const ffi = nodeFfi
92
+ if (!ffi) throw new Error('[unffi/node] node:ffi module not loaded')
93
+
94
+ const ffiSchema: Record<string, NodeFFIFunctionDef> = {}
95
+ const callbackDefs: Record<string, CallbackDef[]> = {}
96
+
97
+ for (const [name, def] of Object.entries(schema)) {
98
+ ffiSchema[name] = {
99
+ parameters: def.args.map((a: CType<unknown>) => nodeFfiTypeFor(a.kind)),
100
+ result: nodeFfiTypeFor(def.returns.kind),
101
+ }
102
+ const cbs = def.args
103
+ .map((a: CType<unknown>, i: number) =>
104
+ a.kind === 'function' ? { i, cb: a as CCallback<readonly CType<unknown>[], CType<unknown>> } : null,
105
+ )
106
+ .filter((x): x is CallbackDef => x !== null)
107
+ if (cbs.length > 0) callbackDefs[name] = cbs
108
+ }
109
+
110
+ const handle = ffi.dlopen(path, ffiSchema)
111
+ const liveCallbacks: unknown[] = []
112
+ const symbols: Record<string, (...args: unknown[]) => unknown> = {}
113
+
114
+ for (const [name, def] of Object.entries(schema)) {
115
+ const rawFn = handle.functions[name]
116
+ if (!rawFn) throw new Error(`[unffi/node] symbol "${name}" not found in ${path}`)
117
+ const cbs = callbackDefs[name]
118
+
119
+ if (def.async) {
120
+ symbols[name] = cbs
121
+ ? (...callArgs: unknown[]) =>
122
+ Promise.resolve().then(() => rawFn(...wrapCallbacks(handle.lib, callArgs, cbs, liveCallbacks)))
123
+ : (...callArgs: unknown[]) => Promise.resolve().then(() => rawFn(...callArgs))
124
+ } else {
125
+ symbols[name] = cbs
126
+ ? (...callArgs: unknown[]) => rawFn(...wrapCallbacks(handle.lib, callArgs, cbs, liveCallbacks))
127
+ : (rawFn as (...a: unknown[]) => unknown)
128
+ }
129
+ }
130
+
131
+ // NO FinalizationRegistry on node:ffi callbacks. C owns the
132
+ // function pointer indefinitely; GC-driven unregister races with a live
133
+ // C-side caller. Lifetime is bound to the library, freed in close().
134
+ let closed = false
135
+ function close() {
136
+ if (closed) return // idempotent: safe to call after `using` already disposed
137
+ closed = true
138
+ for (const h of liveCallbacks) handle.lib.unregisterCallback(h)
139
+ liveCallbacks.length = 0
140
+ handle.lib.close()
141
+ }
142
+
143
+ return {
144
+ symbols: symbols as InferLibrary<S>['symbols'],
145
+ close,
146
+ [Symbol.dispose]: close,
147
+ [Symbol.asyncDispose]() { return Promise.resolve(close()) },
148
+ }
149
+ }
150
+
151
+ function wrapCallbacks(
152
+ lib: NodeFFIDynamicLibrary,
153
+ args: unknown[],
154
+ defs: CallbackDef[],
155
+ liveCallbacks: unknown[],
156
+ ): unknown[] {
157
+ const wrapped = [...args]
158
+ for (const { i, cb } of defs) {
159
+ const handle = lib.registerCallback(
160
+ {
161
+ parameters: cb.argTypes.map((a: CType<unknown>) => nodeFfiTypeFor(a.kind)),
162
+ result: nodeFfiTypeFor(cb.returnType.kind),
163
+ },
164
+ args[i] as (...a: unknown[]) => unknown,
165
+ )
166
+ liveCallbacks.push(handle)
167
+ wrapped[i] = handle
168
+ }
169
+ return wrapped
170
+ }
171
+
172
+ export function dlopen<const S extends SymbolsSchema>(path: string, schema: S): InferLibrary<S> {
173
+ if (ffiState === 'available') return nativeDlopen(path, schema)
174
+ return koffiDlopen(path, schema)
175
+ }
176
+
177
+ export type { CoreT }
package/src/define.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { CType, CCallback, InferCType, InferTuple } from './types.js'
2
+
3
+ export interface SymbolDef {
4
+ readonly args: readonly CType<any>[]
5
+ readonly returns: CType<any>
6
+ readonly async?: boolean
7
+ }
8
+
9
+ export type SymbolsSchema = Record<string, SymbolDef>
10
+
11
+ type InferReturn<S extends SymbolDef> =
12
+ S['async'] extends true
13
+ ? Promise<InferCType<S['returns']>>
14
+ : InferCType<S['returns']>
15
+
16
+ type MapArg<T extends CType<any>> =
17
+ T extends CCallback<infer A extends readonly CType<any>[], infer R extends CType<any>>
18
+ ? (...args: InferTuple<A>) => InferCType<R>
19
+ : InferCType<T>
20
+
21
+ type MapArgs<T extends readonly CType<any>[]> =
22
+ T extends readonly []
23
+ ? []
24
+ : T extends readonly [infer H extends CType<any>, ...infer R extends CType<any>[]]
25
+ ? [MapArg<H>, ...MapArgs<R>]
26
+ : { [K in keyof T]: T[K] extends CType<any> ? MapArg<T[K]> : never }
27
+
28
+ export type InferSymbolFn<S extends SymbolDef> =
29
+ (...args: MapArgs<[...S['args']]>) => InferReturn<S>
30
+
31
+ export type InferLibrary<S extends SymbolsSchema> = {
32
+ readonly symbols: { readonly [K in keyof S]: InferSymbolFn<S[K]> }
33
+ close(): void
34
+ [Symbol.dispose](): void
35
+ [Symbol.asyncDispose](): Promise<void>
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Fallback entry for environments that don't resolve export conditions.
2
+ // Bun, Deno, and Node should get their dedicated adapters via package.json exports.
3
+ export { t } from './types.js'
4
+ export type { CType, CCallback, CTypeKind, Ptr, InferCType, InferTuple } from './types.js'
5
+ export type { SymbolDef, SymbolsSchema, InferLibrary, InferSymbolFn } from './define.js'
6
+
7
+ import type { SymbolsSchema, InferLibrary } from './define.js'
8
+
9
+ export async function dlopen<const S extends SymbolsSchema>(
10
+ path: string,
11
+ schema: S,
12
+ ): Promise<InferLibrary<S>> {
13
+ if ('Bun' in globalThis) {
14
+ const { dlopen: bunDlopen } = await import('./adapters/bun.js')
15
+ return bunDlopen(path, schema)
16
+ }
17
+ if ('Deno' in globalThis) {
18
+ const { dlopen: denoDlopen } = await import('./adapters/deno.js')
19
+ return denoDlopen(path, schema)
20
+ }
21
+ // Fallback: node adapter (tries native node:ffi, then koffi) — Bun → Deno → Node → koffi
22
+ const { dlopen: nodeDlopen } = await import('./adapters/node.js')
23
+ return nodeDlopen(path, schema)
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ export interface CType<T> {
2
+ readonly _type: T
3
+ readonly kind: CTypeKind
4
+ }
5
+
6
+ export type CTypeKind =
7
+ | 'void' | 'bool'
8
+ | 'i8' | 'i16' | 'i32' | 'i64'
9
+ | 'u8' | 'u16' | 'u32' | 'u64'
10
+ | 'f32' | 'f64'
11
+ | 'cstring' | 'pointer' | 'buffer'
12
+ | 'function'
13
+
14
+ declare const PtrBrand: unique symbol
15
+ export type Ptr = bigint & { readonly [PtrBrand]: true }
16
+
17
+ function c<T>(kind: CTypeKind): CType<T> {
18
+ return { kind } as unknown as CType<T>
19
+ }
20
+
21
+ export type InferCType<T extends CType<any>> = T extends CType<infer U> ? U : never
22
+
23
+ export type InferTuple<T extends readonly CType<any>[]> =
24
+ T extends readonly []
25
+ ? []
26
+ : T extends readonly [infer H extends CType<any>, ...infer R extends CType<any>[]]
27
+ ? [InferCType<H>, ...InferTuple<R>]
28
+ : { [K in keyof T]: T[K] extends CType<infer U> ? U : never }
29
+
30
+ export interface CCallback<
31
+ Args extends readonly CType<any>[],
32
+ Ret extends CType<any>,
33
+ > extends CType<(...args: InferTuple<Args>) => InferCType<Ret>> {
34
+ readonly kind: 'function'
35
+ readonly argTypes: Args
36
+ readonly returnType: Ret
37
+ }
38
+
39
+ export interface CoreT {
40
+ readonly void: CType<void>
41
+ readonly bool: CType<boolean>
42
+ readonly i8: CType<number>
43
+ readonly i16: CType<number>
44
+ readonly i32: CType<number>
45
+ readonly i64: CType<bigint>
46
+ readonly u8: CType<number>
47
+ readonly u16: CType<number>
48
+ readonly u32: CType<number>
49
+ readonly u64: CType<bigint>
50
+ readonly f32: CType<number>
51
+ readonly f64: CType<number>
52
+ readonly cstring: CType<string>
53
+ readonly pointer: CType<Ptr | null>
54
+ readonly buffer: CType<ArrayBufferView>
55
+ fn<const Args extends readonly CType<any>[], const Ret extends CType<any>>(
56
+ args: Args,
57
+ returns: Ret,
58
+ ): CCallback<Args, Ret>
59
+ }
60
+
61
+ export const t: CoreT = {
62
+ void: c<void>('void'),
63
+ bool: c<boolean>('bool'),
64
+ i8: c<number>('i8'),
65
+ i16: c<number>('i16'),
66
+ i32: c<number>('i32'),
67
+ i64: c<bigint>('i64'),
68
+ u8: c<number>('u8'),
69
+ u16: c<number>('u16'),
70
+ u32: c<number>('u32'),
71
+ u64: c<bigint>('u64'),
72
+ f32: c<number>('f32'),
73
+ f64: c<number>('f64'),
74
+ cstring: c<string>('cstring'),
75
+ pointer: c<Ptr | null>('pointer'),
76
+ buffer: c<ArrayBufferView>('buffer'),
77
+ fn<const Args extends readonly CType<any>[], const Ret extends CType<any>>(
78
+ args: Args,
79
+ returns: Ret,
80
+ ): CCallback<Args, Ret> {
81
+ return { kind: 'function', argTypes: args, returnType: returns } as unknown as CCallback<Args, Ret>
82
+ },
83
+ }