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 +69 -0
- package/package.json +77 -0
- package/src/adapters/bun.ts +183 -0
- package/src/adapters/deno.ts +259 -0
- package/src/adapters/hints.ts +7 -0
- package/src/adapters/koffi.ts +249 -0
- package/src/adapters/node.ts +177 -0
- package/src/define.ts +36 -0
- package/src/index.ts +24 -0
- package/src/types.ts +83 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# unffi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/unffi) [](./LICENSE) [](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
|
+
}
|