Skip to content

Commit c964489

Browse files
committed
feat(bridge): replace koffi FFI with a NAPI addon (bridge.node)
The bridge now builds as a NAPI addon that Node loads directly via require() — no FFI library dependency. - napi_shim.c (~150 lines): exposes newSession/call/callBinary/ disposeSession with one marshalling pattern — JS strings copied into shim-owned buffers freed before return; string results are Go-owned reusable buffers V8 copies (napi_create_string_utf8); binary results copied via napi_create_buffer_copy. Nothing escapes the shim scope, everything synchronous on the JS thread. - tsgoChecker: require(bridge.node) instead of koffi.load; BridgeClient passes plain JS strings and consumes Buffers directly (no koffi.struct/decode/toCStr). - build-bridge.js: emits bridge.node for every platform, resolves node headers from the running Node (NAPI is ABI-stable), links -undefined dynamic_lookup on darwin and node.lib on win32 (TNB_NODE_LIB; release workflow downloads it per arch). - koffi removed from dependencies; check-lib-sync loader marker moved from koffi to bridge.node. - tools/triage-napi-fuzz.mjs: 9 pathological payloads x 2 paths + 60-call reusable-buffer stability — byte-identical round trips. Verified: koffi-vs-NAPI side-by-side smoke byte-identical; sim-nav 37,409 units new=0; sim-xfile 71/71; sim-edit THROWS 0; f2hl/f2r6/ emptyparity/refs-exportspec/display-tokens/computed-literal PASS; check:lib/enums/sourcefile-guard ok; volar suite 205/205.
1 parent 4dd1020 commit c964489

10 files changed

Lines changed: 336 additions & 97 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ jobs:
7777
- { target: linux-x64, runner: ubuntu-latest }
7878
- { target: linux-arm64, runner: ubuntu-24.04-arm }
7979
- { target: linux-arm, runner: ubuntu-latest, goos: linux, goarch: arm, goarm: "7", cc: arm-linux-gnueabihf-gcc, apt: gcc-arm-linux-gnueabihf }
80-
- { target: win32-x64, runner: ubuntu-latest, goos: windows, goarch: amd64, cc: x86_64-w64-mingw32-gcc, apt: gcc-mingw-w64-x86-64 }
81-
- { target: win32-arm64, runner: ubuntu-latest, goos: windows, goarch: arm64, cc: aarch64-w64-mingw32-gcc, llvm_mingw: "1" }
80+
- { target: win32-x64, runner: ubuntu-latest, goos: windows, goarch: amd64, cc: x86_64-w64-mingw32-gcc, apt: gcc-mingw-w64-x86-64, node_lib: "https://nodejs.org/dist/v24.15.0/win-x64/node.lib" }
81+
- { target: win32-arm64, runner: ubuntu-latest, goos: windows, goarch: arm64, cc: aarch64-w64-mingw32-gcc, llvm_mingw: "1", node_lib: "https://nodejs.org/dist/v24.15.0/win-arm64/node.lib" }
8282
runs-on: ${{ matrix.runner }}
8383
steps:
8484
- uses: actions/checkout@v4
@@ -109,6 +109,10 @@ jobs:
109109
curl -sSL https://github.com/mstorsjo/llvm-mingw/releases/download/20250417/llvm-mingw-20250417-ucrt-ubuntu-22.04-x86_64.tar.xz | sudo tar -xJ -C /opt
110110
echo "/opt/llvm-mingw-20250417-ucrt-ubuntu-22.04-x86_64/bin" >> "$GITHUB_PATH"
111111
112+
- name: Download node.lib (windows targets)
113+
if: matrix.node_lib
114+
run: curl -sSL "${{ matrix.node_lib }}" -o /tmp/node.lib
115+
112116
- name: Build bridge (stripped)
113117
run: node tools/build-bridge.js
114118
env:
@@ -118,6 +122,7 @@ jobs:
118122
CC: ${{ matrix.cc }}
119123
CGO_CFLAGS: ${{ matrix.cgo_cflags }}
120124
CGO_LDFLAGS: ${{ matrix.cgo_ldflags }}
125+
TNB_NODE_LIB: ${{ matrix.node_lib != null && '/tmp/node.lib' || '' }}
121126
CGO_ENABLED: 1
122127
TNB_STRIP: 1
123128

‎package-lock.json‎

Lines changed: 0 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,5 @@
4343
"build:ts": "node tools/patch-typescript.js && cd typescript && npm install && npm run build:compiler && npx hereby lkg",
4444
"build:bridge": "node tools/patch-tsgo.js && node tools/build-bridge.js"
4545
},
46-
"dependencies": {
47-
"koffi": "^2.16.0"
48-
}
46+
"dependencies": {}
4947
}

‎patches/typescript-go/overlay/bridge/bridge.go‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Package main is a Proof-of-Concept NAPI/FFI bridge for typescript-go.
22
//
3-
// It builds as a C-shared library (`go build -buildmode=c-shared`) and exposes
4-
// a tiny C ABI that Node.js can load via an FFI library (koffi). The bridge
3+
// It builds as a NAPI addon (`go build -buildmode=c-shared` → bridge.node,
4+
// see napi_shim.c) that Node.js loads directly via require(). The bridge
55
// reuses the exact same api.Session / project.Session machinery that the IPC
66
// stdio server uses — every type/symbol/source-file query goes through
77
// Session.HandleRequest, so there is zero handler duplication. The only thing
@@ -12,17 +12,17 @@
1212
// - BridgeCallBinary -> raw bytes (for getSourceFile etc.), single-copy into a Node Buffer
1313
//
1414
// Memory model: both paths write into reusable C buffers. Calls are fully
15-
// synchronous, so koffi copies the data out at the FFI boundary before the
16-
// next call reuses the buffer. Zero per-call malloc, zero leaks.
15+
// synchronous, so the NAPI shim copies the data out to V8 before the next call
16+
// reuses the buffer. Zero per-call malloc, zero leaks.
1717
package main
1818

1919
/*
2020
#include <stdint.h>
2121
#include <stdlib.h>
2222
#include <string.h>
2323
24-
// BridgeBinary is returned by value from BridgeCallBinary. Layout must match
25-
// the koffi struct declared on the JS side: { void* data; int64_t len; }.
24+
// BridgeBinary is returned by value from BridgeCallBinary. Layout matches
25+
// napi_shim.c's declaration: { void* data; int64_t len; }.
2626
struct BridgeBinary { void* data; long long len; };
2727
*/
2828
import "C"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// napi_shim.c — NAPI entry for the TNB cgo bridge (replaces the koffi FFI).
2+
//
3+
// Node loads bridge.node directly via require(); no FFI library needed.
4+
//
5+
// Marshalling contract (deliberately tiny — see the design discussion):
6+
// - Shapes: strings in, string-or-Buffer out, int64 session handle. Nothing
7+
// else ever crosses the boundary (envelopes are JSON, parsed in JS).
8+
// - Inputs: JS strings are copied into shim-owned buffers (C.GoString copies
9+
// again on the Go side), freed before the shim returns.
10+
// - String results: Go-owned reusable buffer (resultBuf in bridge.go),
11+
// copied into V8 by napi_create_string_utf8. Nothing to free — Go keeps
12+
// ownership and recycles it on the next call.
13+
// - Binary results: Go-owned reusable buffer (binBuf), copied by
14+
// napi_create_buffer_copy. Same ownership rule.
15+
// - Everything synchronous on the JS thread. No async NAPI, no callbacks,
16+
// no thread-safe functions.
17+
18+
#include <node_api.h>
19+
#include <stdint.h>
20+
#include <stdlib.h>
21+
22+
// cgo-exported Go entry points (bridge.go).
23+
struct BridgeBinary { void* data; long long len; };
24+
extern char* BridgeNewSession(char* cwd);
25+
extern char* BridgeCall(int64_t session, char* method, char* paramsJson);
26+
extern struct BridgeBinary BridgeCallBinary(int64_t session, char* method, char* paramsJson);
27+
extern void BridgeDisposeSession(int64_t session);
28+
29+
// Required string argument → fresh NUL-terminated buffer (caller frees).
30+
// Returns NULL with nothing set if conversion fails (napi raises then).
31+
static char* arg_string(napi_env env, napi_value v, bool* ok) {
32+
*ok = false;
33+
size_t len = 0;
34+
if (napi_get_value_string_utf8(env, v, NULL, 0, &len) != napi_ok) return NULL;
35+
char* buf = (char*)malloc(len + 1);
36+
if (!buf) {
37+
napi_throw_error(env, NULL, "tnb bridge: out of memory");
38+
return NULL;
39+
}
40+
if (napi_get_value_string_utf8(env, v, buf, len + 1, NULL) != napi_ok) {
41+
free(buf);
42+
return NULL;
43+
}
44+
*ok = true;
45+
return buf;
46+
}
47+
48+
// Optional string argument: NULL for null/undefined.
49+
static char* arg_string_opt(napi_env env, napi_value v, bool* ok) {
50+
napi_valuetype t = napi_undefined;
51+
if (napi_typeof(env, v, &t) != napi_ok) { *ok = false; return NULL; }
52+
if (t == napi_null || t == napi_undefined) { *ok = true; return NULL; }
53+
return arg_string(env, v, ok);
54+
}
55+
56+
// Session handle: accept Number or BigInt.
57+
static int64_t arg_int64(napi_env env, napi_value v) {
58+
napi_valuetype t = napi_undefined;
59+
napi_typeof(env, v, &t);
60+
if (t == napi_bigint) {
61+
int64_t out = 0;
62+
bool lossless = false;
63+
napi_get_value_bigint_int64(env, v, &out, &lossless);
64+
return out;
65+
}
66+
double d = 0;
67+
napi_get_value_double(env, v, &d);
68+
return (int64_t)d;
69+
}
70+
71+
static napi_value js_string(napi_env env, const char* s) {
72+
napi_value out = NULL;
73+
napi_create_string_utf8(env, s, NAPI_AUTO_LENGTH, &out);
74+
return out;
75+
}
76+
77+
// newSession(cwd: string): string (JSON envelope with the session handle)
78+
static napi_value fn_new_session(napi_env env, napi_callback_info info) {
79+
napi_value argv[1];
80+
size_t argc = 1;
81+
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
82+
bool ok;
83+
char* cwd = arg_string(env, argv[0], &ok);
84+
if (!ok) return NULL;
85+
char* out = BridgeNewSession(cwd);
86+
free(cwd);
87+
return js_string(env, out);
88+
}
89+
90+
// Shared argument extraction for call / callBinary.
91+
// Returns false with any allocated memory already freed.
92+
static bool call_args(napi_env env, napi_callback_info info, int64_t* session, char** method, char** params) {
93+
napi_value argv[3];
94+
size_t argc = 3;
95+
*method = NULL;
96+
*params = NULL;
97+
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
98+
*session = arg_int64(env, argv[0]);
99+
bool ok;
100+
*method = arg_string(env, argv[1], &ok);
101+
if (!ok) return false;
102+
*params = arg_string_opt(env, argv[2], &ok);
103+
if (!ok) {
104+
free(*method);
105+
*method = NULL;
106+
return false;
107+
}
108+
return true;
109+
}
110+
111+
// call(session, method, paramsJson | null): string (JSON envelope)
112+
static napi_value fn_call(napi_env env, napi_callback_info info) {
113+
int64_t session;
114+
char* method;
115+
char* params;
116+
if (!call_args(env, info, &session, &method, &params)) return NULL;
117+
char* out = BridgeCall(session, method, params);
118+
free(method);
119+
free(params);
120+
return js_string(env, out);
121+
}
122+
123+
// callBinary(session, method, paramsJson | null): Buffer | null
124+
static napi_value fn_call_binary(napi_env env, napi_callback_info info) {
125+
int64_t session;
126+
char* method;
127+
char* params;
128+
if (!call_args(env, info, &session, &method, &params)) return NULL;
129+
struct BridgeBinary res = BridgeCallBinary(session, method, params);
130+
free(method);
131+
free(params);
132+
if (res.len <= 0 || res.data == NULL) {
133+
napi_value nul;
134+
napi_get_null(env, &nul);
135+
return nul;
136+
}
137+
napi_value buf = NULL;
138+
void* dst = NULL;
139+
// V8 copies the bytes; Go keeps the source (reusable binBuf).
140+
napi_create_buffer_copy(env, (size_t)res.len, res.data, &dst, &buf);
141+
return buf;
142+
}
143+
144+
// disposeSession(session): void
145+
static napi_value fn_dispose_session(napi_env env, napi_callback_info info) {
146+
napi_value argv[1];
147+
size_t argc = 1;
148+
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
149+
BridgeDisposeSession(arg_int64(env, argv[0]));
150+
napi_value undef;
151+
napi_get_undefined(env, &undef);
152+
return undef;
153+
}
154+
155+
static bool set_fn(napi_env env, napi_value exports, const char* name, napi_callback cb) {
156+
napi_value fn = NULL;
157+
if (napi_create_function(env, name, NAPI_AUTO_LENGTH, cb, NULL, &fn) != napi_ok) return false;
158+
return napi_set_named_property(env, exports, name, fn) == napi_ok;
159+
}
160+
161+
napi_value napi_register_module_v1(napi_env env, napi_value exports) {
162+
set_fn(env, exports, "newSession", fn_new_session);
163+
set_fn(env, exports, "call", fn_call);
164+
set_fn(env, exports, "callBinary", fn_call_binary);
165+
set_fn(env, exports, "disposeSession", fn_dispose_session);
166+
return exports;
167+
}

0 commit comments

Comments
 (0)