@gjsify/napi 0.22.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.
@@ -0,0 +1,625 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/napi — bootstrap, addon loader, module registration, teardown seam.
3
+ //
4
+ // §3a bootstrap: gjsify_napi_install() reaches the running JSContext via the
5
+ // public GJS accessors (refs/gjs/gjs/context.h:79,84) and installs ONE
6
+ // JSNative `globalThis.__gjsifyNapiLoadAddon` (non-enumerable, deleted by the
7
+ // L1 after capture). From then on loadAddon(path) is a plain JS→JSNative
8
+ // call: exports is a normal return value, load failures are real JS
9
+ // exceptions. No global side-channel per load; naturally reentrant.
10
+ //
11
+ // §3b symbol binding: the shim self-promotes its own napi_* into the global
12
+ // dynamic-symbol scope (dlopen(self, RTLD_NOLOAD|RTLD_NOW|RTLD_GLOBAL)), the
13
+ // addon is dlopen'd RTLD_NOW|RTLD_LOCAL. A version script exports only
14
+ // napi_*/node_api_*/gjsify_napi_* (src/cc/napi.export.map).
15
+ //
16
+ // §3c registration discovery mirrors refs/bun BunProcess.cpp:391-800 and
17
+ // refs/deno ext/napi/lib.rs:688-907; API-version clamp mirrors
18
+ // refs/node/src/node_api.cc:44-54 (Node.js contributors, MIT).
19
+ //
20
+ // §3d teardown seam: g_object_weak_ref on the GjsContext — GObject weak
21
+ // notifications fire inside the parent-class dispose chain-up
22
+ // (refs/gjs/gjs/context.cpp:393), i.e. BEFORE GJS's final GC (:437) and
23
+ // BEFORE JS_DestroyContext (:471), while the JSContext is fully alive. The
24
+ // P0.0 probe logs exactly that.
25
+
26
+ #include "common.h"
27
+
28
+ // §3b portability: the addon loader is written against POSIX dlfcn
29
+ // (dlopen/dlsym/dladdr). Windows has no dlfcn — the equivalent is
30
+ // LoadLibraryEx/GetProcAddress/GetModuleHandleEx. The thin shims below keep
31
+ // load_addon_native single-source; the ONE semantic that does NOT port is the
32
+ // flat-namespace symbol self-promotion (ensure_napi_symbols_global) — Windows
33
+ // resolves an addon's napi_* imports via node-gyp's delay-load hook against the
34
+ // HOST .exe, not a global scope, so that function branches on _WIN32.
35
+ #ifdef _WIN32
36
+ #include <windows.h>
37
+ #else
38
+ #include <dlfcn.h>
39
+ #endif
40
+ #include <limits.h>
41
+ #include <stdlib.h>
42
+
43
+ #include <cerrno>
44
+ #include <cstdio>
45
+ #include <cstring>
46
+ #include <map>
47
+
48
+ #ifdef _WIN32
49
+ #define GJSIFY_NAPI_PATH_MAX _MAX_PATH
50
+ #else
51
+ #define GJSIFY_NAPI_PATH_MAX PATH_MAX
52
+ #endif
53
+
54
+ namespace {
55
+
56
+ // ---- POSIX dlfcn ⇄ Win32 loader abstraction (§3b portability) ----
57
+ // Uniform names so the loader body stays platform-agnostic; each maps to the
58
+ // native dynamic-loader primitive. Addon handles stay `void*` (HMODULE is a
59
+ // pointer type — reinterpret_cast at the Win32 call sites, no struct churn).
60
+
61
+ #ifdef _WIN32
62
+
63
+ // Win32 has no dlerror(); format the thread's last error into a message.
64
+ std::string shim_dl_error() {
65
+ DWORD err = GetLastError();
66
+ if (err == 0) {
67
+ return "unknown load error";
68
+ }
69
+ char* buf = nullptr;
70
+ DWORD n = FormatMessageA(
71
+ FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
72
+ FORMAT_MESSAGE_IGNORE_INSERTS,
73
+ nullptr, err, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
74
+ reinterpret_cast<char*>(&buf), 0, nullptr);
75
+ std::string msg = (n != 0 && buf != nullptr)
76
+ ? std::string(buf, n)
77
+ : ("Win32 error " + std::to_string(err));
78
+ if (buf != nullptr) {
79
+ LocalFree(buf);
80
+ }
81
+ // Trim the trailing CRLF FormatMessage appends.
82
+ while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) {
83
+ msg.pop_back();
84
+ }
85
+ return msg;
86
+ }
87
+
88
+ // LOAD_WITH_ALTERED_SEARCH_PATH: resolve the addon's sibling DLLs from its own
89
+ // directory — the RTLD_LOCAL-neighbourhood behaviour an addon expects, the same
90
+ // posture Node's uv_dlopen uses on Windows.
91
+ void* shim_dl_open_addon(const char* path) {
92
+ return reinterpret_cast<void*>(
93
+ LoadLibraryExA(path, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH));
94
+ }
95
+ void* shim_dl_symbol(void* handle, const char* name) {
96
+ return reinterpret_cast<void*>(
97
+ GetProcAddress(reinterpret_cast<HMODULE>(handle), name));
98
+ }
99
+ void shim_dl_close(void* handle) {
100
+ FreeLibrary(reinterpret_cast<HMODULE>(handle));
101
+ }
102
+ // _fullpath canonicalizes without requiring existence (LoadLibraryEx fails
103
+ // cleanly for a missing path); realpath's ENOENT check is thus deferred to open.
104
+ bool shim_realpath(const char* in, char* out, size_t out_sz) {
105
+ return _fullpath(out, in, static_cast<int>(out_sz)) != nullptr;
106
+ }
107
+
108
+ #else
109
+
110
+ void* shim_dl_open_addon(const char* path) {
111
+ // RTLD_LOCAL so the addon's own symbols do not pollute the global scope or
112
+ // collide with a second addon; LAZY (not NOW) mirrors Node's DLib default —
113
+ // real addons carry references never called on this host (node-gi's
114
+ // Node-only uv_* main-loop bridge), and eager binding would reject them.
115
+ return dlopen(path, RTLD_LAZY | RTLD_LOCAL);
116
+ }
117
+ void* shim_dl_symbol(void* handle, const char* name) {
118
+ return dlsym(handle, name);
119
+ }
120
+ void shim_dl_close(void* handle) { dlclose(handle); }
121
+ std::string shim_dl_error() {
122
+ const char* err = dlerror();
123
+ return err != nullptr ? std::string(err) : "unknown dlopen error";
124
+ }
125
+ bool shim_realpath(const char* in, char* out, size_t /* out_sz */) {
126
+ return realpath(in, out) != nullptr;
127
+ }
128
+
129
+ #endif
130
+
131
+ } // namespace
132
+
133
+ namespace gjsify_napi {
134
+
135
+ // Process-wide env registry — walked by the weak sweep callback and the
136
+ // idle finalizer drain (declared in common.h).
137
+ std::vector<napi_env>& env_registry() {
138
+ static std::vector<napi_env> envs;
139
+ return envs;
140
+ }
141
+
142
+ namespace {
143
+ // The single armed idle drain source (§5c finalization_scheduled guard).
144
+ guint finalizer_drain_source_id = 0;
145
+
146
+ gboolean drain_all_envs_idle(gpointer /* user_data */) {
147
+ finalizer_drain_source_id = 0; // allow re-arming from the drain itself
148
+ for (napi_env env : env_registry()) {
149
+ drain_finalizer_queue(env);
150
+ }
151
+ return G_SOURCE_REMOVE;
152
+ }
153
+ } // namespace
154
+
155
+ // Called from enqueue_finalizer — including from inside the GC sweep
156
+ // callback, where g_idle_add is safe (it only locks the GLib main context;
157
+ // no JSAPI runs). The drain executes on the main loop, NEVER inside GC
158
+ // (§5c invariant: user finalizers never run during GC).
159
+ void schedule_finalizer_drain() {
160
+ if (finalizer_drain_source_id == 0) {
161
+ finalizer_drain_source_id = g_idle_add(drain_all_envs_idle, nullptr);
162
+ }
163
+ }
164
+
165
+ static void cancel_finalizer_drain() {
166
+ if (finalizer_drain_source_id != 0) {
167
+ g_source_remove(finalizer_drain_source_id);
168
+ finalizer_drain_source_id = 0;
169
+ }
170
+ }
171
+
172
+ } // namespace gjsify_napi
173
+
174
+ namespace {
175
+
176
+ bool loader_installed = false;
177
+
178
+ // §3c static-constructor path: our exported napi_module_register may run
179
+ // DURING dlopen (old-style NAPI_MODULE constructors). Modules land here.
180
+ std::vector<napi_module*> pending_modules;
181
+
182
+ // realpath → env; a second loadAddon replays the same exports (Bun's
183
+ // DLHandleMap, BunProcess.cpp:599-676).
184
+ std::map<std::string, napi_env>& env_cache() {
185
+ static std::map<std::string, napi_env> cache;
186
+ return cache;
187
+ }
188
+
189
+ std::vector<napi_env>& all_envs() {
190
+ return gjsify_napi::env_registry();
191
+ }
192
+
193
+ // Throw a real JS exception from the loader (load failures are normal JS
194
+ // errors with real messages, §3a).
195
+ bool throw_load_error(JSContext* cx, const std::string& message) {
196
+ JS_ReportErrorUTF8(cx, "%s", message.c_str());
197
+ return false;
198
+ }
199
+
200
+ // §3d env-teardown seam + P0.0 probe. Fires while the JSContext is still
201
+ // alive (before the final GC + JS_DestroyContext) — exactly when Node-style
202
+ // env teardown must run (§5e).
203
+ void on_gjs_context_dispose(gpointer data, GObject* /* where_the_object_was */) {
204
+ auto* cx = static_cast<JSContext*>(data);
205
+ // Probe: JSRuntime-level and context-state calls must still succeed here.
206
+ JSRuntime* rt = JS_GetRuntime(cx);
207
+ const bool pending = JS_IsExceptionPending(cx);
208
+ fprintf(stderr,
209
+ "[gjsify-napi] P0.0 TEARDOWN PROBE: GjsContext weak notify fired; "
210
+ "JS_GetRuntime=%p exception_pending=%d — JSContext alive at the "
211
+ "env-teardown seam\n",
212
+ static_cast<void*>(rt), pending ? 1 : 0);
213
+ size_t count = all_envs().size();
214
+ // Teardown may run finalizers that trigger GC — the weak sweep callback
215
+ // must stay registered until every env released its weak refs.
216
+ for (napi_env env : all_envs()) {
217
+ env->teardown();
218
+ }
219
+ for (napi_env env : all_envs()) {
220
+ delete env; // frees bundles + remaining shells (no JS runs anymore)
221
+ }
222
+ all_envs().clear();
223
+ env_cache().clear();
224
+ // A still-armed idle drain must not fire into freed envs (and after
225
+ // dispose the registry is empty anyway — belt and braces).
226
+ gjsify_napi::cancel_finalizer_drain();
227
+ // Remove the weak sweep callback BEFORE GJS's final full GC +
228
+ // JS_DestroyContext (refs/gjs/gjs/context.cpp:437,471).
229
+ JS_RemoveWeakPointerZonesCallback(cx, gjsify_napi::weak_sweep_callback);
230
+ loader_installed = false;
231
+ fprintf(stderr,
232
+ "[gjsify-napi] P0.0 TEARDOWN PROBE: %zu env(s) torn down before "
233
+ "JS_DestroyContext\n",
234
+ count);
235
+ }
236
+
237
+ // §3b step 1+3: verify no foreign napi_* resolves globally, then promote our
238
+ // own symbols into the global scope so the addon's undefined napi_* bind to
239
+ // ours. Returns false with a thrown JS exception on failure.
240
+ bool ensure_napi_symbols_global(JSContext* cx) {
241
+ #ifdef _WIN32
242
+ // Windows has NO global flat symbol namespace, so there is nothing to
243
+ // promote: this shim DLL already exports napi_*/node_api_* via
244
+ // __declspec(dllexport) (js_native_api.h's NAPI_EXTERN on _WIN32). The OPEN
245
+ // wall is the other half — an UNMODIFIED node-gyp addon does not bind its
246
+ // napi_* imports to THIS DLL by name. node-gyp links Windows addons against
247
+ // a delay-loaded host import lib + win_delay_load_hook that resolves napi_*
248
+ // from the HOST executable (GetModuleHandle(NULL)) at first call; GJS's
249
+ // gjs.exe does not export the napi ABI, so unmodified-addon symbol binding
250
+ // needs the shim to satisfy that delay-load (a napi-exporting host EXE, or a
251
+ // delay-load-hook cooperation). Tracked in STATUS.md → N-API host
252
+ // cross-platform → Windows. Here we only assert our own exports resolve so
253
+ // the rest of the load path stays intact once that wall is solved.
254
+ HMODULE self = nullptr;
255
+ if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
256
+ GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
257
+ reinterpret_cast<LPCWSTR>(&napi_create_reference),
258
+ &self) == 0 ||
259
+ GetProcAddress(self, "napi_create_reference") == nullptr) {
260
+ return throw_load_error(
261
+ cx, "gjsify-napi: the shim DLL does not export the Node-API ABI "
262
+ "(napi_create_reference) — check the __declspec(dllexport) / "
263
+ ".def export configuration");
264
+ }
265
+ g_debug("gjsify-napi: shim exports the napi ABI (Windows: addon binding "
266
+ "relies on the delay-load host model — see STATUS.md)");
267
+ return true;
268
+ #else
269
+ void* ours = reinterpret_cast<void*>(&napi_create_reference);
270
+ void* found = dlsym(RTLD_DEFAULT, "napi_create_reference");
271
+ if (found == ours) {
272
+ // Already promoted (or GI loaded us RTLD_GLOBAL in the first place).
273
+ g_debug("gjsify-napi: napi_* already resolve globally; no promotion "
274
+ "needed");
275
+ return true;
276
+ }
277
+ if (found != nullptr) {
278
+ return throw_load_error(
279
+ cx,
280
+ "gjsify-napi: a foreign Node-API implementation already resolves "
281
+ "in this process (napi_create_reference found outside the shim); "
282
+ "refusing to load addons into a mixed-host process");
283
+ }
284
+ Dl_info info;
285
+ if (dladdr(ours, &info) == 0 || info.dli_fname == nullptr) {
286
+ return throw_load_error(
287
+ cx, "gjsify-napi: dladdr failed to locate the shim library");
288
+ }
289
+ // Re-opening an already-loaded DSO with RTLD_GLOBAL promotes its symbols
290
+ // into the global scope regardless of how GI loaded it.
291
+ void* self = dlopen(info.dli_fname, RTLD_NOLOAD | RTLD_NOW | RTLD_GLOBAL);
292
+ if (self == nullptr) {
293
+ const char* err = dlerror();
294
+ return throw_load_error(
295
+ cx, std::string("gjsify-napi: failed to promote shim symbols to "
296
+ "the global scope: ") +
297
+ (err != nullptr ? err : "unknown dlopen error"));
298
+ }
299
+ // Leaked on purpose: the shim must stay resident (refcount +1 is fine).
300
+ void* now = dlsym(RTLD_DEFAULT, "napi_create_reference");
301
+ if (now != ours) {
302
+ return throw_load_error(
303
+ cx, "gjsify-napi: symbol self-promotion did not take effect "
304
+ "(napi_create_reference still not ours)");
305
+ }
306
+ g_debug("gjsify-napi: promoted shim napi_* symbols to the global scope "
307
+ "(GI had loaded the shim RTLD_LOCAL)");
308
+ return true;
309
+ #endif // _WIN32
310
+ }
311
+
312
+ // The loadAddon JSNative (§3a): dlopen + three-path registration (§3c),
313
+ // one napi_env per addon, exports as the return value.
314
+ bool load_addon_native(JSContext* cx, unsigned argc, JS::Value* vp) {
315
+ JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
316
+ if (args.length() < 1 || !args[0].isString()) {
317
+ return throw_load_error(
318
+ cx, "gjsify-napi: loadAddon(path) expects a string path");
319
+ }
320
+ JS::RootedString path_str(cx, args[0].toString());
321
+ JS::UniqueChars path_utf8 = JS_EncodeStringToUTF8(cx, path_str);
322
+ if (!path_utf8) {
323
+ return false; // OOM, exception already pending
324
+ }
325
+
326
+ char resolved[GJSIFY_NAPI_PATH_MAX];
327
+ if (!shim_realpath(path_utf8.get(), resolved, sizeof(resolved))) {
328
+ return throw_load_error(
329
+ cx, std::string("gjsify-napi: cannot resolve addon path '") +
330
+ path_utf8.get() + "': " + strerror(errno));
331
+ }
332
+ const std::string key(resolved);
333
+
334
+ auto cached = env_cache().find(key);
335
+ if (cached != env_cache().end()) {
336
+ args.rval().setObject(*cached->second->exports);
337
+ return true;
338
+ }
339
+
340
+ if (!ensure_napi_symbols_global(cx)) {
341
+ return false;
342
+ }
343
+
344
+ // §3b step 2: the addon imports napi_*; its own symbols must not pollute
345
+ // the global scope or collide with a second addon → local, neighbourhood
346
+ // search (shim_dl_open_addon: RTLD_LAZY|RTLD_LOCAL on POSIX,
347
+ // LoadLibraryEx + LOAD_WITH_ALTERED_SEARCH_PATH on Windows). LAZY (POSIX)
348
+ // mirrors Node's own addon loader (node::DLib kDefaultFlags = RTLD_LAZY):
349
+ // real addons legitimately carry references to symbols that are never
350
+ // called on this host — node-gi's Node-only libuv main-loop bridge (uv_*)
351
+ // is exactly that — and eager binding would reject them at load. Every
352
+ // napi_*/node_api_* symbol still resolves (the shim defines the full ABI,
353
+ // stubs included), so the fail-loud posture is unchanged.
354
+ const size_t pending_before = pending_modules.size();
355
+ void* handle = shim_dl_open_addon(resolved);
356
+ if (handle == nullptr) {
357
+ return throw_load_error(
358
+ cx, std::string("gjsify-napi: failed to load addon '") + resolved +
359
+ "': " + shim_dl_error());
360
+ }
361
+
362
+ // §3c path 1: static-constructor registration during dlopen. Validate +
363
+ // move every newly registered module into a FIFO work queue; the queue
364
+ // is re-drained after each init call (re-entrancy loop — a register fn
365
+ // may register more, mirroring Bun's pending-module drain).
366
+ std::vector<napi_module*> static_mods;
367
+ auto drain_pending = [&]() -> const char* {
368
+ while (pending_modules.size() > pending_before) {
369
+ napi_module* mod = pending_modules[pending_before];
370
+ pending_modules.erase(pending_modules.begin() +
371
+ static_cast<long>(pending_before));
372
+ if (mod->nm_version != NAPI_MODULE_VERSION) {
373
+ return "legacy";
374
+ }
375
+ static_mods.push_back(mod);
376
+ }
377
+ return nullptr;
378
+ };
379
+ if (drain_pending() != nullptr) {
380
+ shim_dl_close(handle);
381
+ return throw_load_error(
382
+ cx, std::string("gjsify-napi: '") + resolved +
383
+ "' is a legacy (NAN/V8) addon (nm_version != 1); only "
384
+ "pure Node-API addons are supported");
385
+ }
386
+
387
+ // §3c path 2: per-addon API version; clamp exactly like
388
+ // node_napi_env__::New (refs/node/src/node_api.cc:44-54).
389
+ int32_t api_version = gjsify_napi::kNapiDefaultModuleApiVersion;
390
+ auto get_api_version = reinterpret_cast<node_api_addon_get_api_version_func>(
391
+ shim_dl_symbol(handle, "node_api_module_get_api_version_v1"));
392
+ if (get_api_version != nullptr) {
393
+ api_version = get_api_version();
394
+ }
395
+ if (api_version < gjsify_napi::kNapiDefaultModuleApiVersion) {
396
+ api_version = gjsify_napi::kNapiDefaultModuleApiVersion;
397
+ }
398
+ if (api_version == NAPI_VERSION_EXPERIMENTAL) {
399
+ shim_dl_close(handle);
400
+ return throw_load_error(
401
+ cx, std::string("gjsify-napi: '") + resolved +
402
+ "' targets the experimental Node-API version, which "
403
+ "@gjsify/napi Phase 0 refuses (in-GC finalizer regime "
404
+ "not implemented)");
405
+ }
406
+ if (api_version > gjsify_napi::kNapiVersionMax) {
407
+ shim_dl_close(handle);
408
+ return throw_load_error(
409
+ cx, std::string("gjsify-napi: '") + resolved +
410
+ "' requires Node-API version " +
411
+ std::to_string(api_version) +
412
+ ", but this runtime supports up to " +
413
+ std::to_string(gjsify_napi::kNapiVersionMax));
414
+ }
415
+
416
+ // §3c path 3: modern symbol-based registration.
417
+ napi_addon_register_func modern_init = nullptr;
418
+ if (static_mods.empty()) {
419
+ modern_init = reinterpret_cast<napi_addon_register_func>(
420
+ shim_dl_symbol(handle, "napi_register_module_v1"));
421
+ if (modern_init == nullptr) {
422
+ shim_dl_close(handle);
423
+ return throw_load_error(
424
+ cx,
425
+ std::string("gjsify-napi: '") + resolved +
426
+ "' is not a Node-API module (no napi_register_module_v1 "
427
+ "export and no self-registration during dlopen)");
428
+ }
429
+ }
430
+
431
+ // One napi_env per addon (node_api.cc:768-770).
432
+ GjsContext* gjs = gjs_context_get_current();
433
+ napi_env env = new napi_env__(cx, gjs, api_version, handle,
434
+ std::string("file://") + resolved);
435
+ all_envs().push_back(env);
436
+
437
+ JS::RootedObject exports_obj(cx, JS_NewPlainObject(cx));
438
+ if (!exports_obj) {
439
+ return false;
440
+ }
441
+
442
+ // Call init(s) under call-into-module discipline: fresh handle scope; a
443
+ // pending exception afterwards propagates as the JS throw it is (§6).
444
+ // Each init receives the current exports; a non-null return replaces it
445
+ // (node_api.cc:777-784). Static modules run in registration order,
446
+ // re-draining after each call (an init may register more).
447
+ napi_handle_scope scope = nullptr;
448
+ napi_open_handle_scope(env, &scope);
449
+ napi_value exports_v =
450
+ gjsify_napi::arena_push(env, JS::ObjectValue(*exports_obj));
451
+ bool pending = false;
452
+ bool legacy_reentry = false;
453
+ if (modern_init != nullptr) {
454
+ napi_value init_ret = modern_init(env, exports_v);
455
+ pending = JS_IsExceptionPending(cx);
456
+ if (!pending && init_ret != nullptr) {
457
+ exports_v = init_ret;
458
+ }
459
+ } else {
460
+ for (size_t i = 0; i < static_mods.size() && !pending; i++) {
461
+ napi_value init_ret =
462
+ static_mods[i]->nm_register_func(env, exports_v);
463
+ pending = JS_IsExceptionPending(cx);
464
+ if (!pending && init_ret != nullptr) {
465
+ exports_v = init_ret;
466
+ }
467
+ if (drain_pending() != nullptr) {
468
+ legacy_reentry = true;
469
+ break;
470
+ }
471
+ }
472
+ }
473
+ JS::RootedValue result_val(cx, gjsify_napi::napi_value_to_js(exports_v));
474
+ napi_close_handle_scope(env, scope);
475
+ if (pending) {
476
+ return false;
477
+ }
478
+ if (legacy_reentry) {
479
+ return throw_load_error(
480
+ cx, std::string("gjsify-napi: '") + resolved +
481
+ "' registered a legacy (nm_version != 1) module during "
482
+ "init; only pure Node-API addons are supported");
483
+ }
484
+ if (!result_val.isObject()) {
485
+ return throw_load_error(
486
+ cx, std::string("gjsify-napi: module init of '") + resolved +
487
+ "' returned a non-object exports value");
488
+ }
489
+
490
+ env->exports = &result_val.toObject();
491
+ env_cache()[key] = env;
492
+ args.rval().set(result_val);
493
+ return true;
494
+ }
495
+
496
+ } // namespace
497
+
498
+ // §3c path 1 entry — exported; may be called by addon static constructors
499
+ // during dlopen (node_api.h:36-45).
500
+ void NAPI_CDECL napi_module_register(napi_module* mod) {
501
+ pending_modules.push_back(mod);
502
+ }
503
+
504
+ // The file:// URL captured at load (node_api.cc:744-766). Pointer valid for
505
+ // the env's lifetime.
506
+ napi_status NAPI_CDECL node_api_get_module_file_name(
507
+ node_api_basic_env basic_env, const char** result) {
508
+ napi_env env = const_cast<napi_env>(basic_env);
509
+ GJSIFY_NAPI_CHECK_ENV(env);
510
+ GJSIFY_NAPI_CHECK_ARG(env, result);
511
+ *result = env->filename.c_str();
512
+ return gjsify_napi::clear_last_error(env);
513
+ }
514
+
515
+ napi_status NAPI_CDECL napi_get_version(node_api_basic_env basic_env,
516
+ uint32_t* result) {
517
+ napi_env env = const_cast<napi_env>(basic_env);
518
+ GJSIFY_NAPI_CHECK_ENV(env);
519
+ GJSIFY_NAPI_CHECK_ARG(env, result);
520
+ *result = gjsify_napi::kNapiVersionMax;
521
+ return gjsify_napi::clear_last_error(env);
522
+ }
523
+
524
+ // node_api.cc's napi_get_node_version returns a pointer to a static
525
+ // napi_node_version filled from the NODE_*_VERSION macros. We report the Node
526
+ // release line the shim targets (node 24, the tested runtime; see AGENTS.md
527
+ // "node v24"). The struct is static/process-lifetime — the contract is that
528
+ // the returned pointer stays valid (node_api.cc:731-742).
529
+ napi_status NAPI_CDECL napi_get_node_version(node_api_basic_env basic_env,
530
+ const napi_node_version** result) {
531
+ napi_env env = const_cast<napi_env>(basic_env);
532
+ GJSIFY_NAPI_CHECK_ENV(env);
533
+ GJSIFY_NAPI_CHECK_ARG(env, result);
534
+ static const napi_node_version version = {24, 0, 0, "node"};
535
+ *result = &version;
536
+ return gjsify_napi::clear_last_error(env);
537
+ }
538
+
539
+ NAPI_NO_RETURN void NAPI_CDECL napi_fatal_error(const char* location,
540
+ size_t location_len,
541
+ const char* message,
542
+ size_t message_len) {
543
+ // node_api.cc OnFatalError shape: `location: message` to stderr + abort.
544
+ fputs("FATAL ERROR: ", stderr);
545
+ if (location != nullptr) {
546
+ if (location_len == NAPI_AUTO_LENGTH) {
547
+ fputs(location, stderr);
548
+ } else {
549
+ fwrite(location, 1, location_len, stderr);
550
+ }
551
+ fputs(" ", stderr);
552
+ }
553
+ if (message != nullptr) {
554
+ if (message_len == NAPI_AUTO_LENGTH) {
555
+ fputs(message, stderr);
556
+ } else {
557
+ fwrite(message, 1, message_len, stderr);
558
+ }
559
+ }
560
+ fputs("\n", stderr);
561
+ fflush(stderr);
562
+ abort();
563
+ }
564
+
565
+ // ---- the GI bootstrap surface (§3a) ----
566
+
567
+ extern "C" gboolean gjsify_napi_install(void) {
568
+ GjsContext* gjs = gjs_context_get_current();
569
+ if (gjs == nullptr) {
570
+ g_warning("gjsify-napi: no current GjsContext — init() must be "
571
+ "called from JS running under GJS");
572
+ return FALSE;
573
+ }
574
+ auto* cx = static_cast<JSContext*>(gjs_context_get_native_context(gjs));
575
+ if (cx == nullptr) {
576
+ g_warning("gjsify-napi: GjsContext has no native JSContext");
577
+ return FALSE;
578
+ }
579
+ JS::RootedObject global(cx, JS::CurrentGlobalOrNull(cx));
580
+ if (!global) {
581
+ g_warning("gjsify-napi: no current JS global (not inside a realm)");
582
+ return FALSE;
583
+ }
584
+ // (Re-)define the bootstrap property when absent: consumers capture the
585
+ // native and delete the property (§3a), and init() is documented
586
+ // idempotent — a SECOND consumer (e.g. node-gi's gjs host mode after the
587
+ // L1 already captured) must be able to re-obtain the loader.
588
+ bool has_loader = false;
589
+ if (!JS_HasProperty(cx, global, "__gjsifyNapiLoadAddon", &has_loader)) {
590
+ g_warning("gjsify-napi: failed to probe __gjsifyNapiLoadAddon");
591
+ return FALSE;
592
+ }
593
+ if (!has_loader) {
594
+ JSFunction* fn = js::NewFunctionWithReserved(
595
+ cx, load_addon_native, 1, 0, "__gjsifyNapiLoadAddon");
596
+ if (fn == nullptr) {
597
+ g_warning("gjsify-napi: failed to create the loadAddon native");
598
+ return FALSE;
599
+ }
600
+ JS::RootedObject fn_obj(cx, JS_GetFunctionObject(fn));
601
+ // attrs 0 → non-enumerable, writable, configurable — consumers delete
602
+ // the property after capturing the function.
603
+ if (!JS_DefineProperty(cx, global, "__gjsifyNapiLoadAddon", fn_obj,
604
+ 0)) {
605
+ g_warning("gjsify-napi: failed to define __gjsifyNapiLoadAddon");
606
+ return FALSE;
607
+ }
608
+ }
609
+ if (loader_installed) {
610
+ return TRUE;
611
+ }
612
+ // §5b weak machinery: ONE process-wide sweep callback (GJS registers its
613
+ // own compartment variant the same way, gi/object.cpp:2503). Removed in
614
+ // on_gjs_context_dispose before the final GC.
615
+ if (!JS_AddWeakPointerZonesCallback(cx, gjsify_napi::weak_sweep_callback,
616
+ nullptr)) {
617
+ g_warning("gjsify-napi: failed to register the weak sweep callback");
618
+ return FALSE;
619
+ }
620
+ // §3d teardown seam: weak notification fires pre-final-GC,
621
+ // pre-JS_DestroyContext (probe in on_gjs_context_dispose).
622
+ g_object_weak_ref(G_OBJECT(gjs), on_gjs_context_dispose, cx);
623
+ loader_installed = true;
624
+ return TRUE;
625
+ }
@@ -0,0 +1,22 @@
1
+ ; SPDX-License-Identifier: MIT
2
+ ; @gjsify/napi — export hygiene, Windows (MSVC / clang-cl link.exe) counterpart
3
+ ; of napi.export.map (GNU version-script) and napi.exported-symbols.txt (ld64).
4
+ ;
5
+ ; Unlike the POSIX linkers, MSVC link.exe / lld-link take a .def EXPORTS list
6
+ ; and support NO glob patterns, so the napi_*/node_api_* ABI families cannot be
7
+ ; wildcarded here. They do not NEED to be: the vendored headers already mark
8
+ ; every ABI entry point __declspec(dllexport) on _WIN32 (js_native_api.h's
9
+ ; NAPI_EXTERN), so link.exe exports them automatically, and MSVC hides every
10
+ ; other (non-dllexport) symbol by DEFAULT — the mozjs-140 / gjs internals the
11
+ ; shim statically references are never re-exported. That already reproduces the
12
+ ; version-script's "napi_*/node_api_* visible, everything else local" contract.
13
+ ;
14
+ ; What the header macro does NOT cover is the Vala-generated GI surface: valac
15
+ ; emits plain C functions with no dllexport, yet g_module_open + g_module_symbol
16
+ ; (GetProcAddress) must resolve them from the typelib at import. This .def adds
17
+ ; exactly those three — the whole introspectable surface is GjsifyNapi.init()
18
+ ; (see src/vala/napi.vala) plus the extern-C bridge it calls.
19
+ EXPORTS
20
+ gjsify_napi_init
21
+ gjsify_napi_is_available
22
+ gjsify_napi_install
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # @gjsify/napi — export hygiene (§3b step 3).
3
+ #
4
+ # Only the Node-API ABI (napi_*/node_api_*) and the GObject/GI surface
5
+ # (gjsify_napi_*, incl. the Vala-generated type + namespace symbols GI
6
+ # resolves via dlsym) are exported; everything internal stays hidden. This is
7
+ # the counterpart of Node/Bun exporting the ABI from the main executable
8
+ # (Bun: src/symbols.dyn), realized as a shared-library version script.
9
+ {
10
+ global:
11
+ napi_*;
12
+ node_api_*;
13
+ gjsify_napi_*;
14
+ local:
15
+ *;
16
+ };
@@ -0,0 +1,15 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # @gjsify/napi — export hygiene, macOS (ld64) counterpart of napi.export.map.
3
+ #
4
+ # ld64's -exported_symbols_list keeps ONLY the listed symbols visible; the C
5
+ # ABI (napi_*/node_api_*) + the GObject/GI surface (gjsify_napi_*, incl. the
6
+ # Vala-generated type + namespace symbols GI resolves via dlsym) stay exported,
7
+ # everything else is hidden. Mach-O C symbols carry a leading underscore, and
8
+ # ld64 honours `*` wildcards here — so these three globs mirror the `global:`
9
+ # block of the GNU version-script exactly. This is what lets the shim
10
+ # self-promote its napi_* into the flat namespace (module.cc
11
+ # ensure_napi_symbols_global) so an addon built with `-undefined
12
+ # dynamic_lookup` binds its undefined napi_* to ours.
13
+ _napi_*
14
+ _node_api_*
15
+ _gjsify_napi_*