objc-js 1.2.1 → 1.3.1

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 CHANGED
@@ -36,6 +36,7 @@ The documentation is organized into several guides:
36
36
  - **[C Functions](./docs/c-functions.md)** - Calling C functions like NSLog, NSHomeDirectory, NSStringFromClass
37
37
  - **[Structs](./docs/structs.md)** - Passing and receiving C structs (CGRect, NSRange, etc.)
38
38
  - **[Subclassing Objective-C Classes](./docs/subclassing.md)** - Creating and subclassing Objective-C classes from JavaScript
39
+ - **[Blocks](./docs/blocks.md)** - Passing JavaScript functions as Objective-C blocks (closures)
39
40
  - **[Protocol Implementation](./docs/protocol-implementation.md)** - Creating delegate objects that implement protocols
40
41
  - **[API Reference](./docs/api-reference.md)** - Complete API documentation for all classes and functions
41
42
 
package/dist/index.js CHANGED
@@ -32,7 +32,12 @@ class NobjcLibrary {
32
32
  LoadLibrary(library);
33
33
  this.wasLoaded = true;
34
34
  }
35
- cls = new NobjcObject(GetClassObject(className));
35
+ const classObject = GetClassObject(className);
36
+ if (classObject === undefined) {
37
+ // Class not found. Make sure the class exists before trying to access it.
38
+ return undefined;
39
+ }
40
+ cls = new NobjcObject(classObject);
36
41
  classCache.set(className, cls);
37
42
  return cls;
38
43
  }
@@ -55,6 +60,9 @@ class NobjcObject {
55
60
  // Return true for the special Symbol to enable unwrapping
56
61
  if (p === NATIVE_OBJC_OBJECT)
57
62
  return true;
63
+ // Return true for inspect symbols so console.log uses custom inspect
64
+ if (p === customInspectSymbol)
65
+ return true;
58
66
  // guard against other symbols
59
67
  if (typeof p === "symbol")
60
68
  return Reflect.has(target, p);
@@ -117,6 +125,10 @@ class NobjcObject {
117
125
  // object (avoids triggering proxy 'has' trap which would be a second FFI call)
118
126
  const selector = NobjcMethodNameToObjcSelector(methodName);
119
127
  if (!target.$respondsToSelector(selector)) {
128
+ // special case since JS checks for `.then` on Promise objects
129
+ if (methodName === "then")
130
+ return undefined;
131
+ // Otherwise, throw an error
120
132
  throw new Error(`Method ${methodName} not found on object`);
121
133
  }
122
134
  method = NobjcMethod(object, methodName);
@@ -127,6 +139,9 @@ class NobjcObject {
127
139
  };
128
140
  // Create the proxy
129
141
  const proxy = new Proxy(object, handler);
142
+ // Set custom inspect on the native object so console.log works through the Proxy.
143
+ // Runtimes (Node, Bun) bypass Proxy traps during inspect and read the target directly.
144
+ object[customInspectSymbol] = () => proxy.toString();
130
145
  // Store proxy → native mapping in WeakMap for O(1) unwrap (bypasses Proxy traps)
131
146
  nativeObjectMap.set(proxy, object);
132
147
  // Return the proxy
@@ -137,6 +152,20 @@ function unwrapArg(arg) {
137
152
  if (arg && typeof arg === "object") {
138
153
  return nativeObjectMap.get(arg) ?? arg;
139
154
  }
155
+ // Wrap function arguments so that when called from native (e.g., as ObjC blocks),
156
+ // the native ObjcObject args are automatically wrapped in NobjcObject proxies.
157
+ if (typeof arg === "function") {
158
+ const wrapped = function (...nativeArgs) {
159
+ for (let i = 0; i < nativeArgs.length; i++) {
160
+ nativeArgs[i] = wrapObjCObjectIfNeeded(nativeArgs[i]);
161
+ }
162
+ return unwrapArg(arg(...nativeArgs));
163
+ };
164
+ // Preserve the original function's .length so the native layer can read it
165
+ // (used to infer block parameter count when extended encoding is unavailable)
166
+ Object.defineProperty(wrapped, "length", { value: arg.length });
167
+ return wrapped;
168
+ }
140
169
  return arg;
141
170
  }
142
171
  function wrapObjCObjectIfNeeded(result) {
package/package.json CHANGED
@@ -30,7 +30,9 @@
30
30
  "prebuild": "node-gyp clean && node-gyp configure",
31
31
  "build": "npm run build-native && npm run build-scripts && npm run build-source",
32
32
  "pretest": "npm run build",
33
- "test": "bun test",
33
+ "test": "bun run test:bun",
34
+ "test:bun": "bun run build && bun test",
35
+ "test:node": "bun run build && npx vitest run",
34
36
  "test:native": "bun test tests/test-native-code.test.ts",
35
37
  "test:js": "bun test tests/test-js-code.test.ts",
36
38
  "test:string-lifetime": "bun test tests/test-string-lifetime.test.ts",
@@ -44,7 +46,7 @@
44
46
  "prebuild:x64": "prebuildify --napi --strip --arch x64 -r node",
45
47
  "prebuild:all": "bun run prebuild:arm64 && bun run prebuild:x64"
46
48
  },
47
- "version": "1.2.1",
49
+ "version": "1.3.1",
48
50
  "description": "Objective-C bridge for Node.js",
49
51
  "main": "dist/index.js",
50
52
  "dependencies": {
@@ -57,7 +59,8 @@
57
59
  "node-gyp": "^12.1.0",
58
60
  "prebuildify": "^6.0.1",
59
61
  "prettier": "^3.7.4",
60
- "typescript": "^5.9.3"
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^4.0.18"
61
64
  },
62
65
  "gypfile": true,
63
66
  "patchedDependencies": {
Binary file
@@ -4,11 +4,18 @@
4
4
  #include <napi.h>
5
5
  #include <objc/objc.h>
6
6
  #include <objc/runtime.h>
7
+ #include <objc/message.h>
7
8
  #include <optional>
8
9
  #include <variant>
9
10
  #include <unordered_map>
10
11
  #include <vector>
11
12
 
13
+ // objc_retain/objc_release are part of the stable ObjC ABI (macOS 10.12+)
14
+ // but not declared in public headers. We use them for manual reference counting
15
+ // since ARC is not enabled for .mm files in this project.
16
+ extern "C" id objc_retain(id value);
17
+ extern "C" void objc_release(id value);
18
+
12
19
  #ifdef __OBJC__
13
20
  @class NSMethodSignature;
14
21
  #else
@@ -51,6 +58,14 @@ public:
51
58
  // This better be an Napi::External<id>! We lost the type info at runtime.
52
59
  Napi::External<id> external = info[0].As<Napi::External<id>>();
53
60
  objcObject = *(external.Data());
61
+ // Retain the ObjC object so it stays alive as long as this JS wrapper
62
+ // exists. Without this, ARC/autorelease can deallocate the object while
63
+ // JS still holds a reference, causing Use-After-Free crashes (SIGTRAP)
64
+ // in completion handler callbacks and other async contexts.
65
+ // Note: ARC is not enabled for .mm files in this project (the -fobjc-arc
66
+ // flag is in OTHER_CFLAGS, not OTHER_CPLUSPLUSFLAGS), so __strong has
67
+ // no effect — we must manage retain/release manually.
68
+ if (objcObject) objc_retain(objcObject);
54
69
  return;
55
70
  }
56
71
  // If someone tries `new ObjcObject()` from JS, forbid it:
@@ -58,7 +73,12 @@ public:
58
73
  .ThrowAsJavaScriptException();
59
74
  }
60
75
  static Napi::Object NewInstance(Napi::Env env, id obj);
61
- ~ObjcObject() = default;
76
+ ~ObjcObject() {
77
+ if (objcObject) {
78
+ objc_release(objcObject);
79
+ objcObject = nil;
80
+ }
81
+ }
62
82
 
63
83
  private:
64
84
  Napi::Value $MsgSend(const Napi::CallbackInfo &info);
@@ -2,6 +2,7 @@
2
2
  #include "bridge.h"
3
3
  #include "pointer-utils.h"
4
4
  #include "struct-utils.h"
5
+ #include "nobjc_block.h"
5
6
  #include <Foundation/Foundation.h>
6
7
  #include <napi.h>
7
8
  #include <objc/objc.h>
@@ -153,6 +154,8 @@ static bool TryFastMsgSend(Napi::Env env, id target, SEL selector,
153
154
  const char *argType = SimplifyTypeEncoding(
154
155
  [methodSignature getArgumentTypeAtIndex:i + 2]);
155
156
  char code = *argType;
157
+ // Block args (@?) need special handling — bail out of fast path
158
+ if (code == '@' && argType[1] == '?') return false;
156
159
  if (!IsFastPathArgTypeCode(code)) return false;
157
160
  argTypeCodes[i] = code;
158
161
  if (code == 'f' || code == 'd') hasFloatArgs = true;
@@ -476,6 +479,28 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
476
479
  continue;
477
480
  }
478
481
 
482
+ // Block argument: convert JS function to ObjC block
483
+ if (IsBlockTypeEncoding(typeEncoding) && info[i].IsFunction()) {
484
+ // Get extended encoding from method_getTypeEncoding() which preserves @?<...>
485
+ // NSMethodSignature strips the extended encoding, so we use the runtime directly.
486
+ // Argument index in NSInvocation is i+1 (0=self, 1=_cmd, 2+=user args)
487
+ std::string extEncoding = GetExtendedBlockEncoding(
488
+ object_getClass(objcObject), selector, i + 1);
489
+ const char *blockEncoding = extEncoding.empty()
490
+ ? [methodSignature getArgumentTypeAtIndex:i + 1]
491
+ : extEncoding.c_str();
492
+ id block = CreateBlockFromJSFunction(env, info[i], blockEncoding);
493
+ if (env.IsExceptionPending()) return env.Null();
494
+ [invocation setArgument:&block atIndex:i + 1];
495
+ // Store block as id in arg buffer to keep it alive until after invoke
496
+ if (useHeap) {
497
+ heapArgBuf.push_back(BaseObjcType{block});
498
+ } else {
499
+ smallArgBuf[argIdx] = BaseObjcType{block};
500
+ }
501
+ continue;
502
+ }
503
+
479
504
  auto arg = AsObjCArgument(info[i], typeEncoding, context);
480
505
  if (!arg.has_value()) {
481
506
  std::string errorMessageStr = std::format("Unsupported argument type {}",
@@ -634,6 +659,10 @@ Napi::Value ObjcObject::$PrepareSend(const Napi::CallbackInfo &info) {
634
659
  [methodSignature getArgumentTypeAtIndex:i + 2]);
635
660
  char code = *argType;
636
661
  prepared->argInfos[i] = {code, code == '{'};
662
+ // Block args (@?) need slow path for JS function → block conversion
663
+ if (code == '@' && argType[1] == '?') {
664
+ canFast = false;
665
+ }
637
666
  if (code == '{' || !IsFastPathArgTypeCode(code)) {
638
667
  canFast = false;
639
668
  }
@@ -738,6 +767,26 @@ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
738
767
  continue;
739
768
  }
740
769
 
770
+ // Block argument: convert JS function to ObjC block
771
+ if (IsBlockTypeEncoding(typeEncoding) && info[jsArgIdx].IsFunction()) {
772
+ // Get extended encoding from method_getTypeEncoding() which preserves @?<...>
773
+ // Argument index in NSInvocation is i+2 (0=self, 1=_cmd, 2+=user args)
774
+ std::string extEncoding = GetExtendedBlockEncoding(
775
+ object_getClass(objcObject), prepared->selector, i + 2);
776
+ const char *blockEncoding = extEncoding.empty()
777
+ ? [prepared->methodSignature getArgumentTypeAtIndex:i + 2]
778
+ : extEncoding.c_str();
779
+ id block = CreateBlockFromJSFunction(env, info[jsArgIdx], blockEncoding);
780
+ if (env.IsExceptionPending()) return env.Null();
781
+ [invocation setArgument:&block atIndex:i + 2];
782
+ if (useHeap) {
783
+ heapArgBuf.push_back(BaseObjcType{block});
784
+ } else {
785
+ smallArgBuf[i] = BaseObjcType{block};
786
+ }
787
+ continue;
788
+ }
789
+
741
790
  auto arg = AsObjCArgument(info[jsArgIdx], typeEncoding, context);
742
791
  if (!arg.has_value()) {
743
792
  Napi::TypeError::New(env, std::string("Unsupported argument type ") + typeEncoding)
@@ -208,7 +208,7 @@ inline ffi_type* ParseStructEncoding(const char* encoding, size_t* outSize,
208
208
 
209
209
  if (*ptr == '}') break;
210
210
 
211
- std::string fieldEncoding = SkipOneFieldEncoding(ptr);
211
+ std::string fieldEncoding = SkipOneTypeEncoding(ptr);
212
212
  NOBJC_LOG("ParseStructEncoding: parsing field '%s'", fieldEncoding.c_str());
213
213
 
214
214
  // Recursively get FFI type for this field
@@ -271,6 +271,14 @@ inline ffi_type* GetFFITypeForEncoding(const char* encoding, size_t* outSize,
271
271
 
272
272
  char firstChar = simpleEncoding[0];
273
273
 
274
+ // Handle block type (@?) — blocks are pointers
275
+ if (firstChar == '@' && simpleEncoding[1] == '?') {
276
+ if (outSize) {
277
+ *outSize = sizeof(void*);
278
+ }
279
+ return &ffi_type_pointer;
280
+ }
281
+
274
282
  // Handle structs and unions
275
283
  if (firstChar == '{') {
276
284
  return ParseStructEncoding(simpleEncoding, outSize, allocatedTypes);
@@ -3,10 +3,15 @@
3
3
 
4
4
  #include "memory-utils.h"
5
5
  #include "protocol-storage.h"
6
+ #include "constants.h"
7
+ #include "debug.h"
6
8
  #include <cstring>
9
+ #include <condition_variable>
7
10
  #include <functional>
11
+ #include <mutex>
8
12
  #include <napi.h>
9
13
  #include <optional>
14
+ #include <CoreFoundation/CoreFoundation.h>
10
15
 
11
16
  #ifdef __OBJC__
12
17
  @class NSInvocation;
@@ -106,6 +111,46 @@ struct ForwardingCallbacks {
106
111
  CallbackType callbackType;
107
112
  };
108
113
 
114
+ // MARK: - Shared Helpers
115
+
116
+ /**
117
+ * Pump the CFRunLoop until a completion flag is set.
118
+ * Used by protocol forwarding, subclass forwarding, and block invocation
119
+ * to wait for cross-thread JS callbacks to complete.
120
+ *
121
+ * @param mutex Mutex protecting the isComplete flag
122
+ * @param isComplete Flag set to true when the JS callback completes
123
+ * @param label Optional label for debug logging (nullptr to disable)
124
+ */
125
+ inline void PumpRunLoopUntilComplete(std::mutex &mutex, bool &isComplete,
126
+ const char *label = nullptr) {
127
+ int iterations = 0;
128
+ while (true) {
129
+ {
130
+ std::unique_lock<std::mutex> lock(mutex);
131
+ if (isComplete) break;
132
+ }
133
+ iterations++;
134
+ if (label && iterations % nobjc::kRunLoopDebugLogInterval == 0) {
135
+ NOBJC_LOG("%s: Still waiting... (%d iterations)", label, iterations);
136
+ }
137
+ CFRunLoopRunInMode(kCFRunLoopDefaultMode, nobjc::kRunLoopPumpInterval, true);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Create a ThreadSafeFunction for method forwarding.
143
+ * Shared factory for protocol, subclass, and block TSFN creation.
144
+ *
145
+ * @param env Napi environment
146
+ * @param fn The JS function to wrap
147
+ * @param name Resource name for debugging
148
+ */
149
+ inline Napi::ThreadSafeFunction CreateMethodTSFN(
150
+ Napi::Env env, const Napi::Function &fn, const std::string &name) {
151
+ return Napi::ThreadSafeFunction::New(env, fn, name, 0, 1, [](Napi::Env) {});
152
+ }
153
+
109
154
  // MARK: - Common Implementation
110
155
 
111
156
  /**
@@ -135,22 +135,8 @@ void ForwardInvocationCommon(NSInvocation *invocation,
135
135
  }
136
136
 
137
137
  // Wait for callback by pumping CFRunLoop
138
- int iterations = 0;
139
-
140
- while (true) {
141
- {
142
- std::unique_lock<std::mutex> lock(completionMutex);
143
- if (isComplete) {
144
- break;
145
- }
146
- }
147
- iterations++;
148
- if (iterations % nobjc::kRunLoopDebugLogInterval == 0) {
149
- NOBJC_LOG("ForwardInvocationCommon: Still waiting... (%d iterations)",
150
- iterations);
151
- }
152
- CFRunLoopRunInMode(kCFRunLoopDefaultMode, nobjc::kRunLoopPumpInterval, true);
153
- }
138
+ PumpRunLoopUntilComplete(completionMutex, isComplete,
139
+ "ForwardInvocationCommon");
154
140
  // Data cleaned up in callback
155
141
  }
156
142
 
@@ -158,16 +158,7 @@ bool FallbackToTSFN(Napi::ThreadSafeFunction &tsfn, InvocationData *data,
158
158
  }
159
159
 
160
160
  // Wait for callback by pumping CFRunLoop
161
- CFTimeInterval timeout = 0.001; // 1ms per iteration
162
- while (true) {
163
- {
164
- std::unique_lock<std::mutex> lock(completionMutex);
165
- if (isComplete) {
166
- break;
167
- }
168
- }
169
- CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout, true);
170
- }
161
+ PumpRunLoopUntilComplete(completionMutex, isComplete);
171
162
 
172
163
  return true;
173
164
  }