objc-js 1.0.4 → 1.2.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.
@@ -1,11 +1,313 @@
1
1
  #include "ObjcObject.h"
2
2
  #include "bridge.h"
3
3
  #include "pointer-utils.h"
4
+ #include "struct-utils.h"
4
5
  #include <Foundation/Foundation.h>
5
6
  #include <napi.h>
6
7
  #include <objc/objc.h>
8
+ #include <objc/message.h>
9
+ #include <memory>
10
+ #include <string_view>
11
+ #include <unordered_map>
7
12
  #include <vector>
8
13
 
14
+ // MARK: - Fast Path: Direct objc_msgSend (H1)
15
+
16
+ /**
17
+ * Check if a type code is eligible for the direct objc_msgSend fast path.
18
+ * All pointer-sized or smaller integer types, float, double, id, class, SEL,
19
+ * bool, and void are eligible. Structs, C strings, and pointers are not
20
+ * (structs need special handling, C strings need lifetime management).
21
+ */
22
+ static inline bool IsFastPathTypeCode(char typeCode) {
23
+ switch (typeCode) {
24
+ // Integer types (all fit in a register)
25
+ case 'c': case 'i': case 's': case 'l': case 'q':
26
+ case 'C': case 'I': case 'S': case 'L': case 'Q':
27
+ // Floating point
28
+ case 'f': case 'd':
29
+ // Bool
30
+ case 'B':
31
+ // Object types (pointer-sized)
32
+ case '@': case '#': case ':':
33
+ // Void (for return type only)
34
+ case 'v':
35
+ return true;
36
+ default:
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if a type code is eligible as a fast-path argument.
43
+ * Slightly more restrictive than return types — excludes void.
44
+ */
45
+ static inline bool IsFastPathArgTypeCode(char typeCode) {
46
+ return typeCode != 'v' && IsFastPathTypeCode(typeCode);
47
+ }
48
+
49
+ /**
50
+ * Convert a JS value to a register-sized integer for passing to objc_msgSend.
51
+ * Handles id (@), Class (#), SEL (:), bool (B), and all integer types.
52
+ * Returns the value as a uintptr_t that can be passed in a general register.
53
+ */
54
+ static inline uintptr_t JSValueToRegister(Napi::Env env,
55
+ const Napi::Value &value,
56
+ char typeCode,
57
+ const ObjcArgumentContext &context) {
58
+ switch (typeCode) {
59
+ case '@': {
60
+ id obj = ConvertToNativeValue<id>(value, context);
61
+ return reinterpret_cast<uintptr_t>(obj);
62
+ }
63
+ case '#': {
64
+ // Class is also an id
65
+ id obj = ConvertToNativeValue<id>(value, context);
66
+ return reinterpret_cast<uintptr_t>(obj);
67
+ }
68
+ case ':': {
69
+ SEL sel = ConvertToNativeValue<SEL>(value, context);
70
+ return reinterpret_cast<uintptr_t>(sel);
71
+ }
72
+ case 'B': {
73
+ bool b = ConvertToNativeValue<bool>(value, context);
74
+ return static_cast<uintptr_t>(b ? 1 : 0);
75
+ }
76
+ case 'c': return static_cast<uintptr_t>(
77
+ static_cast<unsigned char>(ConvertToNativeValue<char>(value, context)));
78
+ case 'i': return static_cast<uintptr_t>(
79
+ static_cast<unsigned int>(ConvertToNativeValue<int>(value, context)));
80
+ case 's': return static_cast<uintptr_t>(
81
+ static_cast<unsigned short>(ConvertToNativeValue<short>(value, context)));
82
+ case 'l': return static_cast<uintptr_t>(
83
+ static_cast<unsigned long>(ConvertToNativeValue<long>(value, context)));
84
+ case 'q': return static_cast<uintptr_t>(ConvertToNativeValue<long long>(value, context));
85
+ case 'C': return static_cast<uintptr_t>(ConvertToNativeValue<unsigned char>(value, context));
86
+ case 'I': return static_cast<uintptr_t>(ConvertToNativeValue<unsigned int>(value, context));
87
+ case 'S': return static_cast<uintptr_t>(ConvertToNativeValue<unsigned short>(value, context));
88
+ case 'L': return static_cast<uintptr_t>(ConvertToNativeValue<unsigned long>(value, context));
89
+ case 'Q': return static_cast<uintptr_t>(ConvertToNativeValue<unsigned long long>(value, context));
90
+ default:
91
+ throw Napi::TypeError::New(env, "Unsupported fast-path argument type");
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Convert a raw objc_msgSend integer return value to a JS value.
97
+ */
98
+ static inline Napi::Value RegisterToJSValue(Napi::Env env, uintptr_t raw,
99
+ char typeCode) {
100
+ switch (typeCode) {
101
+ case 'v': return env.Undefined();
102
+ case '@': case '#': {
103
+ id obj = reinterpret_cast<id>(raw);
104
+ if (obj == nil) return env.Null();
105
+ return ObjcObject::NewInstance(env, obj);
106
+ }
107
+ case ':': {
108
+ SEL sel = reinterpret_cast<SEL>(raw);
109
+ if (sel == nullptr) return env.Null();
110
+ return Napi::String::New(env, sel_getName(sel));
111
+ }
112
+ case 'B': return Napi::Boolean::New(env, raw != 0);
113
+ case 'c': return Napi::Number::New(env, static_cast<double>(static_cast<char>(raw)));
114
+ case 'i': return Napi::Number::New(env, static_cast<double>(static_cast<int>(raw)));
115
+ case 's': return Napi::Number::New(env, static_cast<double>(static_cast<short>(raw)));
116
+ case 'l': return Napi::Number::New(env, static_cast<double>(static_cast<long>(raw)));
117
+ case 'q': return Napi::Number::New(env, static_cast<double>(static_cast<long long>(raw)));
118
+ case 'C': return Napi::Number::New(env, static_cast<double>(static_cast<unsigned char>(raw)));
119
+ case 'I': return Napi::Number::New(env, static_cast<double>(static_cast<unsigned int>(raw)));
120
+ case 'S': return Napi::Number::New(env, static_cast<double>(static_cast<unsigned short>(raw)));
121
+ case 'L': return Napi::Number::New(env, static_cast<double>(static_cast<unsigned long>(raw)));
122
+ case 'Q': return Napi::Number::New(env, static_cast<double>(static_cast<unsigned long long>(raw)));
123
+ default: return env.Undefined();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Attempt direct objc_msgSend fast path for 0-3 non-float args.
129
+ * Returns true and sets result if handled; false to fall through to NSInvocation.
130
+ *
131
+ * On ARM64, objc_msgSend handles all non-struct returns (including float/double
132
+ * which go through SIMD registers). We cast to the appropriate function pointer
133
+ * type to ensure correct ABI behavior.
134
+ */
135
+ static bool TryFastMsgSend(Napi::Env env, id target, SEL selector,
136
+ const Napi::CallbackInfo &info,
137
+ NSMethodSignature *methodSignature,
138
+ const char *returnType, size_t expectedArgCount,
139
+ const char *classNameCStr, std::string_view selectorView,
140
+ Napi::Value &outResult) {
141
+ // Only handle 0-3 args on the fast path
142
+ if (expectedArgCount > 3) return false;
143
+
144
+ char returnTypeCode = *returnType;
145
+
146
+ // Check return type eligibility (also allow 'f' and 'd')
147
+ if (!IsFastPathTypeCode(returnTypeCode)) return false;
148
+
149
+ // Check all argument types and collect type codes
150
+ char argTypeCodes[3];
151
+ bool hasFloatArgs = false;
152
+ for (size_t i = 0; i < expectedArgCount; i++) {
153
+ const char *argType = SimplifyTypeEncoding(
154
+ [methodSignature getArgumentTypeAtIndex:i + 2]);
155
+ char code = *argType;
156
+ if (!IsFastPathArgTypeCode(code)) return false;
157
+ argTypeCodes[i] = code;
158
+ if (code == 'f' || code == 'd') hasFloatArgs = true;
159
+ }
160
+
161
+ // Build argument context for error messages
162
+ const ObjcArgumentContext context = {
163
+ .className = classNameCStr,
164
+ .selectorName = selectorView,
165
+ .argumentIndex = 0,
166
+ };
167
+
168
+ // Convert arguments. Float/double args need special handling on ARM64
169
+ // because they go in SIMD registers, so we need proper function pointer casts.
170
+ //
171
+ // For simplicity and correctness, we handle the common cases:
172
+ // - All-integer args (including id, SEL, etc.) with integer/void/bool/object return
173
+ // - All-integer args with float/double return
174
+ // - Float/double args require per-signature casts
175
+ //
176
+ // Strategy: cast objc_msgSend to the exact signature to ensure ABI correctness.
177
+
178
+ if (!hasFloatArgs) {
179
+ // All integer-register args: cast objc_msgSend based on arg count and return type
180
+ uintptr_t args[3];
181
+ for (size_t i = 0; i < expectedArgCount; i++) {
182
+ ObjcArgumentContext argCtx = context;
183
+ argCtx.argumentIndex = static_cast<int>(i);
184
+ args[i] = JSValueToRegister(env, info[i + 1], argTypeCodes[i], argCtx);
185
+ }
186
+
187
+ if (returnTypeCode == 'f') {
188
+ // Float return
189
+ float result;
190
+ switch (expectedArgCount) {
191
+ case 0:
192
+ result = ((float(*)(id, SEL))objc_msgSend)(target, selector);
193
+ break;
194
+ case 1:
195
+ result = ((float(*)(id, SEL, uintptr_t))objc_msgSend)(target, selector, args[0]);
196
+ break;
197
+ case 2:
198
+ result = ((float(*)(id, SEL, uintptr_t, uintptr_t))objc_msgSend)(
199
+ target, selector, args[0], args[1]);
200
+ break;
201
+ case 3:
202
+ result = ((float(*)(id, SEL, uintptr_t, uintptr_t, uintptr_t))objc_msgSend)(
203
+ target, selector, args[0], args[1], args[2]);
204
+ break;
205
+ }
206
+ outResult = Napi::Number::New(env, static_cast<double>(result));
207
+ return true;
208
+ } else if (returnTypeCode == 'd') {
209
+ // Double return
210
+ double result;
211
+ switch (expectedArgCount) {
212
+ case 0:
213
+ result = ((double(*)(id, SEL))objc_msgSend)(target, selector);
214
+ break;
215
+ case 1:
216
+ result = ((double(*)(id, SEL, uintptr_t))objc_msgSend)(target, selector, args[0]);
217
+ break;
218
+ case 2:
219
+ result = ((double(*)(id, SEL, uintptr_t, uintptr_t))objc_msgSend)(
220
+ target, selector, args[0], args[1]);
221
+ break;
222
+ case 3:
223
+ result = ((double(*)(id, SEL, uintptr_t, uintptr_t, uintptr_t))objc_msgSend)(
224
+ target, selector, args[0], args[1], args[2]);
225
+ break;
226
+ }
227
+ outResult = Napi::Number::New(env, result);
228
+ return true;
229
+ } else {
230
+ // Integer/pointer/void return
231
+ uintptr_t result;
232
+ switch (expectedArgCount) {
233
+ case 0:
234
+ result = ((uintptr_t(*)(id, SEL))objc_msgSend)(target, selector);
235
+ break;
236
+ case 1:
237
+ result = ((uintptr_t(*)(id, SEL, uintptr_t))objc_msgSend)(target, selector, args[0]);
238
+ break;
239
+ case 2:
240
+ result = ((uintptr_t(*)(id, SEL, uintptr_t, uintptr_t))objc_msgSend)(
241
+ target, selector, args[0], args[1]);
242
+ break;
243
+ case 3:
244
+ result = ((uintptr_t(*)(id, SEL, uintptr_t, uintptr_t, uintptr_t))objc_msgSend)(
245
+ target, selector, args[0], args[1], args[2]);
246
+ break;
247
+ }
248
+ outResult = RegisterToJSValue(env, result, returnTypeCode);
249
+ return true;
250
+ }
251
+ }
252
+
253
+ // For float/double arguments, we need exact ABI-correct casts.
254
+ // Handle the most common case: single double arg (e.g., numberWithDouble:)
255
+ if (expectedArgCount == 1 && argTypeCodes[0] == 'd') {
256
+ ObjcArgumentContext argCtx = context;
257
+ argCtx.argumentIndex = 0;
258
+ double arg0 = ConvertToNativeValue<double>(info[1], argCtx);
259
+ if (returnTypeCode == 'd') {
260
+ double result = ((double(*)(id, SEL, double))objc_msgSend)(target, selector, arg0);
261
+ outResult = Napi::Number::New(env, result);
262
+ } else if (returnTypeCode == 'f') {
263
+ float result = ((float(*)(id, SEL, double))objc_msgSend)(target, selector, arg0);
264
+ outResult = Napi::Number::New(env, static_cast<double>(result));
265
+ } else {
266
+ uintptr_t result = ((uintptr_t(*)(id, SEL, double))objc_msgSend)(target, selector, arg0);
267
+ outResult = RegisterToJSValue(env, result, returnTypeCode);
268
+ }
269
+ return true;
270
+ }
271
+
272
+ if (expectedArgCount == 1 && argTypeCodes[0] == 'f') {
273
+ ObjcArgumentContext argCtx = context;
274
+ argCtx.argumentIndex = 0;
275
+ float arg0 = ConvertToNativeValue<float>(info[1], argCtx);
276
+ if (returnTypeCode == 'd') {
277
+ double result = ((double(*)(id, SEL, float))objc_msgSend)(target, selector, arg0);
278
+ outResult = Napi::Number::New(env, result);
279
+ } else if (returnTypeCode == 'f') {
280
+ float result = ((float(*)(id, SEL, float))objc_msgSend)(target, selector, arg0);
281
+ outResult = Napi::Number::New(env, static_cast<double>(result));
282
+ } else {
283
+ uintptr_t result = ((uintptr_t(*)(id, SEL, float))objc_msgSend)(target, selector, arg0);
284
+ outResult = RegisterToJSValue(env, result, returnTypeCode);
285
+ }
286
+ return true;
287
+ }
288
+
289
+ // More than 1 float arg — fall through to NSInvocation (rare case)
290
+ return false;
291
+ }
292
+
293
+ // MARK: - Method Signature Cache
294
+
295
+ /**
296
+ * Cache for method signatures keyed by (Class, SEL) pair.
297
+ * Avoids redundant ObjC runtime calls for repeated $msgSend invocations
298
+ * on the same class/selector pair.
299
+ */
300
+ struct ClassSELHash {
301
+ size_t operator()(const std::pair<Class, SEL> &p) const {
302
+ auto h1 = std::hash<void *>{}((__bridge void *)p.first);
303
+ auto h2 = std::hash<void *>{}(p.second);
304
+ return h1 ^ (h2 << 1);
305
+ }
306
+ };
307
+
308
+ static std::unordered_map<std::pair<Class, SEL>, NSMethodSignature *, ClassSELHash>
309
+ methodSignatureCache;
310
+
9
311
  Napi::FunctionReference ObjcObject::constructor;
10
312
 
11
313
  void ObjcObject::Init(Napi::Env env, Napi::Object exports) {
@@ -13,6 +315,9 @@ void ObjcObject::Init(Napi::Env env, Napi::Object exports) {
13
315
  DefineClass(env, "ObjcObject",
14
316
  {
15
317
  InstanceMethod("$msgSend", &ObjcObject::$MsgSend),
318
+ InstanceMethod("$respondsToSelector", &ObjcObject::$RespondsToSelector),
319
+ InstanceMethod("$prepareSend", &ObjcObject::$PrepareSend),
320
+ InstanceMethod("$msgSendPrepared", &ObjcObject::$MsgSendPrepared),
16
321
  InstanceMethod("$getPointer", &ObjcObject::GetPointer),
17
322
  });
18
323
  constructor = Napi::Persistent(func);
@@ -37,8 +342,21 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
37
342
  return env.Null();
38
343
  }
39
344
 
40
- std::string selectorName = info[0].As<Napi::String>().Utf8Value();
41
- SEL selector = sel_registerName(selectorName.c_str());
345
+ // Stack-allocate selector string to avoid heap allocation in common case
346
+ size_t selectorLen = 0;
347
+ napi_get_value_string_utf8(env, info[0], nullptr, 0, &selectorLen);
348
+ char selectorBuf[256];
349
+ std::unique_ptr<char[]> selectorHeap;
350
+ const char *selectorCStr;
351
+ if (selectorLen < sizeof(selectorBuf)) {
352
+ napi_get_value_string_utf8(env, info[0], selectorBuf, sizeof(selectorBuf), nullptr);
353
+ selectorCStr = selectorBuf;
354
+ } else {
355
+ selectorHeap.reset(new char[selectorLen + 1]);
356
+ napi_get_value_string_utf8(env, info[0], selectorHeap.get(), selectorLen + 1, nullptr);
357
+ selectorCStr = selectorHeap.get();
358
+ }
359
+ SEL selector = sel_registerName(selectorCStr);
42
360
 
43
361
  if (![objcObject respondsToSelector:selector]) {
44
362
  Napi::Error::New(env, "Selector not found on object")
@@ -46,8 +364,18 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
46
364
  return env.Null();
47
365
  }
48
366
 
49
- NSMethodSignature *methodSignature =
50
- [objcObject methodSignatureForSelector:selector];
367
+ // Use cached method signature to avoid redundant ObjC runtime calls
368
+ auto cacheKey = std::make_pair(object_getClass(objcObject), selector);
369
+ auto cacheIt = methodSignatureCache.find(cacheKey);
370
+ NSMethodSignature *methodSignature;
371
+ if (cacheIt != methodSignatureCache.end()) {
372
+ methodSignature = cacheIt->second;
373
+ } else {
374
+ methodSignature = [objcObject methodSignatureForSelector:selector];
375
+ if (methodSignature != nil) {
376
+ methodSignatureCache[cacheKey] = methodSignature;
377
+ }
378
+ }
51
379
  if (methodSignature == nil) {
52
380
  Napi::Error::New(env, "Failed to get method signature")
53
381
  .ThrowAsJavaScriptException();
@@ -63,7 +391,7 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
63
391
  if (providedArgCount != expectedArgCount) {
64
392
  std::string errorMessageStr =
65
393
  std::format("Selector {} (on {}) expected {} argument(s), but got {}",
66
- selectorName, std::string(object_getClassName(objcObject)),
394
+ selectorCStr, std::string(object_getClassName(objcObject)),
67
395
  expectedArgCount, providedArgCount);
68
396
  const char *errorMessage = errorMessageStr.c_str();
69
397
  Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
@@ -77,34 +405,77 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
77
405
  }
78
406
  const char *returnType =
79
407
  SimplifyTypeEncoding([methodSignature methodReturnType]);
408
+ const bool isStructReturn = (*returnType == '{');
80
409
  const char *validReturnTypes = "cislqCISLQfdB*v@#:";
81
- if (strlen(returnType) != 1 ||
82
- strchr(validReturnTypes, *returnType) == nullptr) {
410
+ if (!isStructReturn &&
411
+ (strlen(returnType) != 1 ||
412
+ strchr(validReturnTypes, *returnType) == nullptr)) {
83
413
  Napi::TypeError::New(env, "Unsupported return type (pre-invoke)")
84
414
  .ThrowAsJavaScriptException();
85
415
  return env.Null();
86
416
  }
87
417
 
418
+ // Fast path: direct objc_msgSend for simple signatures (H1)
419
+ // Skip NSInvocation overhead for 0-3 simple args with simple return types.
420
+ if (!isStructReturn) {
421
+ const char* classNameCStr = object_getClassName(objcObject);
422
+ std::string_view selectorView(selectorCStr);
423
+ Napi::Value fastResult;
424
+ if (TryFastMsgSend(env, objcObject, selector, info, methodSignature,
425
+ returnType, expectedArgCount, classNameCStr, selectorView,
426
+ fastResult)) {
427
+ return fastResult;
428
+ }
429
+ }
430
+
88
431
  NSInvocation *invocation =
89
432
  [NSInvocation invocationWithMethodSignature:methodSignature];
90
433
  [invocation setSelector:selector];
91
434
  [invocation setTarget:objcObject];
92
435
 
93
436
  // Store all arguments to keep them alive until after invoke.
94
- // This is critical for string arguments where we pass a pointer to the
95
- // internal buffer of a std::string - if the string is destroyed before
96
- // invoke, the pointer becomes dangling.
97
- std::vector<ObjcType> storedArgs;
98
- storedArgs.reserve(info.Length() - 1);
437
+ // Small-buffer optimization: stack-allocate for common case (<=4 args),
438
+ // fall back to heap vector for larger argument counts.
439
+ constexpr size_t kSmallArgCount = 4;
440
+ ObjcType smallArgBuf[kSmallArgCount];
441
+ std::vector<ObjcType> heapArgBuf;
442
+ const bool useHeap = expectedArgCount > kSmallArgCount;
443
+ if (useHeap) {
444
+ heapArgBuf.reserve(expectedArgCount);
445
+ }
446
+
447
+ // Store struct argument buffers to keep them alive until after invoke.
448
+ std::vector<std::vector<uint8_t>> structBuffers;
449
+
450
+ // Use raw const char* / string_view to avoid heap allocation per call
451
+ // (strings are only needed for error messages, which are rare)
452
+ const char* classNameCStr = object_getClassName(objcObject);
453
+ std::string_view selectorView(selectorCStr);
99
454
 
100
455
  for (size_t i = 1; i < info.Length(); ++i) {
456
+ const size_t argIdx = i - 1;
101
457
  const ObjcArgumentContext context = {
102
- .className = std::string(object_getClassName(objcObject)),
103
- .selectorName = selectorName,
104
- .argumentIndex = (int)i - 1,
458
+ .className = classNameCStr,
459
+ .selectorName = selectorView,
460
+ .argumentIndex = (int)argIdx,
105
461
  };
106
462
  const char *typeEncoding =
107
463
  SimplifyTypeEncoding([methodSignature getArgumentTypeAtIndex:i + 1]);
464
+
465
+ if (IsStructTypeEncoding(typeEncoding)) {
466
+ // Struct argument: pack JS object into a byte buffer and set directly
467
+ auto buffer = PackJSValueAsStruct(env, info[i], typeEncoding);
468
+ [invocation setArgument:buffer.data() atIndex:i + 1];
469
+ structBuffers.push_back(std::move(buffer));
470
+ // Push a placeholder to keep indices aligned
471
+ if (useHeap) {
472
+ heapArgBuf.push_back(BaseObjcType{std::monostate{}});
473
+ } else {
474
+ smallArgBuf[argIdx] = BaseObjcType{std::monostate{}};
475
+ }
476
+ continue;
477
+ }
478
+
108
479
  auto arg = AsObjCArgument(info[i], typeEncoding, context);
109
480
  if (!arg.has_value()) {
110
481
  std::string errorMessageStr = std::format("Unsupported argument type {}",
@@ -113,7 +484,12 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
113
484
  Napi::TypeError::New(env, errorMessage).ThrowAsJavaScriptException();
114
485
  return env.Null();
115
486
  }
116
- storedArgs.push_back(std::move(*arg));
487
+ if (useHeap) {
488
+ heapArgBuf.push_back(std::move(*arg));
489
+ } else {
490
+ smallArgBuf[argIdx] = std::move(*arg);
491
+ }
492
+ ObjcType& stored = useHeap ? heapArgBuf.back() : smallArgBuf[argIdx];
117
493
  std::visit(
118
494
  [&](auto &&outer) {
119
495
  using OuterT = std::decay_t<decltype(outer)>;
@@ -124,15 +500,277 @@ Napi::Value ObjcObject::$MsgSend(const Napi::CallbackInfo &info) {
124
500
  std::visit(SetObjCArgumentVisitor{invocation, i + 1}, *outer);
125
501
  }
126
502
  },
127
- storedArgs.back());
503
+ stored);
128
504
  }
129
505
 
130
506
  [invocation invoke];
131
- // storedArgs goes out of scope here, after invoke has completed
507
+ // smallArgBuf/heapArgBuf and structBuffers go out of scope here, after invoke
508
+
509
+ if (isStructReturn) {
510
+ // Struct return: read bytes from invocation and convert to JS object
511
+ NSUInteger returnLength = [methodSignature methodReturnLength];
512
+ std::vector<uint8_t> returnBuffer(returnLength, 0);
513
+ [invocation getReturnValue:returnBuffer.data()];
514
+ return UnpackStructToJSValue(env, returnBuffer.data(), returnType);
515
+ }
516
+
132
517
  return ConvertReturnValueToJSValue(env, invocation, methodSignature);
133
518
  }
134
519
 
135
520
  Napi::Value ObjcObject::GetPointer(const Napi::CallbackInfo &info) {
136
521
  Napi::Env env = info.Env();
137
522
  return PointerToBuffer(env, objcObject);
523
+ }
524
+
525
+ // MARK: - $RespondsToSelector (H4: avoid double FFI round-trip)
526
+
527
+ Napi::Value ObjcObject::$RespondsToSelector(const Napi::CallbackInfo &info) {
528
+ Napi::Env env = info.Env();
529
+
530
+ if (info.Length() < 1 || !info[0].IsString()) {
531
+ Napi::TypeError::New(env, "$respondsToSelector requires a string argument")
532
+ .ThrowAsJavaScriptException();
533
+ return env.Null();
534
+ }
535
+
536
+ // Stack-allocate selector string (same pattern as $MsgSend)
537
+ size_t selectorLen = 0;
538
+ napi_get_value_string_utf8(env, info[0], nullptr, 0, &selectorLen);
539
+ char selectorBuf[256];
540
+ std::unique_ptr<char[]> selectorHeap;
541
+ const char *selectorCStr;
542
+ if (selectorLen < sizeof(selectorBuf)) {
543
+ napi_get_value_string_utf8(env, info[0], selectorBuf, sizeof(selectorBuf), nullptr);
544
+ selectorCStr = selectorBuf;
545
+ } else {
546
+ selectorHeap.reset(new char[selectorLen + 1]);
547
+ napi_get_value_string_utf8(env, info[0], selectorHeap.get(), selectorLen + 1, nullptr);
548
+ selectorCStr = selectorHeap.get();
549
+ }
550
+
551
+ SEL selector = sel_registerName(selectorCStr);
552
+ BOOL responds = [objcObject respondsToSelector:selector];
553
+ return Napi::Boolean::New(env, responds);
554
+ }
555
+
556
+ // MARK: - $PrepareSend / $MsgSendPrepared (H2: cache SEL + method signature)
557
+
558
+ /**
559
+ * $prepareSend(selectorName: string) -> External<PreparedSend>
560
+ *
561
+ * One-time call that resolves the selector, checks respondsToSelector:,
562
+ * looks up the method signature, and pre-computes fast-path eligibility.
563
+ * Returns an opaque External handle for use with $msgSendPrepared.
564
+ */
565
+ Napi::Value ObjcObject::$PrepareSend(const Napi::CallbackInfo &info) {
566
+ Napi::Env env = info.Env();
567
+
568
+ if (info.Length() < 1 || !info[0].IsString()) {
569
+ Napi::TypeError::New(env, "$prepareSend requires a string argument")
570
+ .ThrowAsJavaScriptException();
571
+ return env.Null();
572
+ }
573
+
574
+ // Extract selector string
575
+ size_t selectorLen = 0;
576
+ napi_get_value_string_utf8(env, info[0], nullptr, 0, &selectorLen);
577
+ char selectorBuf[256];
578
+ std::unique_ptr<char[]> selectorHeap;
579
+ const char *selectorCStr;
580
+ if (selectorLen < sizeof(selectorBuf)) {
581
+ napi_get_value_string_utf8(env, info[0], selectorBuf, sizeof(selectorBuf), nullptr);
582
+ selectorCStr = selectorBuf;
583
+ } else {
584
+ selectorHeap.reset(new char[selectorLen + 1]);
585
+ napi_get_value_string_utf8(env, info[0], selectorHeap.get(), selectorLen + 1, nullptr);
586
+ selectorCStr = selectorHeap.get();
587
+ }
588
+
589
+ SEL selector = sel_registerName(selectorCStr);
590
+
591
+ if (![objcObject respondsToSelector:selector]) {
592
+ Napi::Error::New(env, std::string("$prepareSend: selector not found: ") + selectorCStr)
593
+ .ThrowAsJavaScriptException();
594
+ return env.Null();
595
+ }
596
+
597
+ // Look up or cache method signature
598
+ auto cacheKey = std::make_pair(object_getClass(objcObject), selector);
599
+ auto cacheIt = methodSignatureCache.find(cacheKey);
600
+ NSMethodSignature *methodSignature;
601
+ if (cacheIt != methodSignatureCache.end()) {
602
+ methodSignature = cacheIt->second;
603
+ } else {
604
+ methodSignature = [objcObject methodSignatureForSelector:selector];
605
+ if (methodSignature != nil) {
606
+ methodSignatureCache[cacheKey] = methodSignature;
607
+ }
608
+ }
609
+ if (methodSignature == nil) {
610
+ Napi::Error::New(env, "$prepareSend: failed to get method signature")
611
+ .ThrowAsJavaScriptException();
612
+ return env.Null();
613
+ }
614
+
615
+ // Build PreparedSend handle
616
+ auto *prepared = new PreparedSend();
617
+ prepared->selector = selector;
618
+ prepared->methodSignature = methodSignature;
619
+ prepared->expectedArgCount = [methodSignature numberOfArguments] - 2;
620
+
621
+ const char *returnType = SimplifyTypeEncoding([methodSignature methodReturnType]);
622
+ prepared->returnType = returnType;
623
+ prepared->fastReturnTypeCode = *returnType;
624
+ prepared->isStructReturn = (*returnType == '{');
625
+
626
+ // Determine fast-path eligibility
627
+ bool canFast = !prepared->isStructReturn && IsFastPathTypeCode(prepared->fastReturnTypeCode)
628
+ && prepared->expectedArgCount <= 3;
629
+
630
+ bool hasFloatArgs = false;
631
+ prepared->argInfos.resize(prepared->expectedArgCount);
632
+ for (size_t i = 0; i < prepared->expectedArgCount; i++) {
633
+ const char *argType = SimplifyTypeEncoding(
634
+ [methodSignature getArgumentTypeAtIndex:i + 2]);
635
+ char code = *argType;
636
+ prepared->argInfos[i] = {code, code == '{'};
637
+ if (code == '{' || !IsFastPathArgTypeCode(code)) {
638
+ canFast = false;
639
+ }
640
+ if (code == 'f' || code == 'd') hasFloatArgs = true;
641
+ }
642
+
643
+ // Float args with >1 arg need special per-signature casts we only handle for 1 arg
644
+ if (hasFloatArgs && prepared->expectedArgCount > 1) {
645
+ canFast = false;
646
+ }
647
+
648
+ prepared->canUseFastPath = canFast;
649
+
650
+ // Return as External with destructor
651
+ return Napi::External<PreparedSend>::New(env, prepared,
652
+ [](Napi::Env, PreparedSend *p) { delete p; });
653
+ }
654
+
655
+ /**
656
+ * $msgSendPrepared(handle: External<PreparedSend>, ...args) -> any
657
+ *
658
+ * Sends a message using a pre-computed PreparedSend handle.
659
+ * Skips selector registration, respondsToSelector:, method signature lookup,
660
+ * and return type validation. Goes straight to fast path or NSInvocation.
661
+ */
662
+ Napi::Value ObjcObject::$MsgSendPrepared(const Napi::CallbackInfo &info) {
663
+ Napi::Env env = info.Env();
664
+
665
+ if (info.Length() < 1 || !info[0].IsExternal()) {
666
+ Napi::TypeError::New(env, "$msgSendPrepared requires a PreparedSend handle as first argument")
667
+ .ThrowAsJavaScriptException();
668
+ return env.Null();
669
+ }
670
+
671
+ PreparedSend *prepared = info[0].As<Napi::External<PreparedSend>>().Data();
672
+ if (!prepared) {
673
+ Napi::Error::New(env, "$msgSendPrepared: invalid handle")
674
+ .ThrowAsJavaScriptException();
675
+ return env.Null();
676
+ }
677
+
678
+ const size_t providedArgCount = info.Length() - 1;
679
+ if (providedArgCount != prepared->expectedArgCount) {
680
+ std::string errorMsg = std::format(
681
+ "$msgSendPrepared: expected {} arg(s), got {}",
682
+ prepared->expectedArgCount, providedArgCount);
683
+ Napi::Error::New(env, errorMsg).ThrowAsJavaScriptException();
684
+ return env.Null();
685
+ }
686
+
687
+ // Fast path: direct objc_msgSend
688
+ if (prepared->canUseFastPath) {
689
+ const char *classNameCStr = object_getClassName(objcObject);
690
+ std::string_view selectorView(sel_getName(prepared->selector));
691
+ Napi::Value fastResult;
692
+ if (TryFastMsgSend(env, objcObject, prepared->selector, info,
693
+ prepared->methodSignature, prepared->returnType,
694
+ prepared->expectedArgCount, classNameCStr, selectorView,
695
+ fastResult)) {
696
+ return fastResult;
697
+ }
698
+ }
699
+
700
+ // Slow path: NSInvocation
701
+ NSInvocation *invocation =
702
+ [NSInvocation invocationWithMethodSignature:prepared->methodSignature];
703
+ [invocation setSelector:prepared->selector];
704
+ [invocation setTarget:objcObject];
705
+
706
+ constexpr size_t kSmallArgCount = 4;
707
+ ObjcType smallArgBuf[kSmallArgCount];
708
+ std::vector<ObjcType> heapArgBuf;
709
+ const bool useHeap = prepared->expectedArgCount > kSmallArgCount;
710
+ if (useHeap) {
711
+ heapArgBuf.reserve(prepared->expectedArgCount);
712
+ }
713
+
714
+ std::vector<std::vector<uint8_t>> structBuffers;
715
+
716
+ const char *classNameCStr = object_getClassName(objcObject);
717
+ std::string_view selectorView(sel_getName(prepared->selector));
718
+
719
+ for (size_t i = 0; i < prepared->expectedArgCount; i++) {
720
+ const size_t jsArgIdx = i + 1; // info[0] is the handle
721
+ const ObjcArgumentContext context = {
722
+ .className = classNameCStr,
723
+ .selectorName = selectorView,
724
+ .argumentIndex = (int)i,
725
+ };
726
+ const char *typeEncoding = SimplifyTypeEncoding(
727
+ [prepared->methodSignature getArgumentTypeAtIndex:i + 2]);
728
+
729
+ if (IsStructTypeEncoding(typeEncoding)) {
730
+ auto buffer = PackJSValueAsStruct(env, info[jsArgIdx], typeEncoding);
731
+ [invocation setArgument:buffer.data() atIndex:i + 2];
732
+ structBuffers.push_back(std::move(buffer));
733
+ if (useHeap) {
734
+ heapArgBuf.push_back(BaseObjcType{std::monostate{}});
735
+ } else {
736
+ smallArgBuf[i] = BaseObjcType{std::monostate{}};
737
+ }
738
+ continue;
739
+ }
740
+
741
+ auto arg = AsObjCArgument(info[jsArgIdx], typeEncoding, context);
742
+ if (!arg.has_value()) {
743
+ Napi::TypeError::New(env, std::string("Unsupported argument type ") + typeEncoding)
744
+ .ThrowAsJavaScriptException();
745
+ return env.Null();
746
+ }
747
+ if (useHeap) {
748
+ heapArgBuf.push_back(std::move(*arg));
749
+ } else {
750
+ smallArgBuf[i] = std::move(*arg);
751
+ }
752
+ ObjcType &stored = useHeap ? heapArgBuf.back() : smallArgBuf[i];
753
+ std::visit(
754
+ [&](auto &&outer) {
755
+ using OuterT = std::decay_t<decltype(outer)>;
756
+ if constexpr (std::is_same_v<OuterT, BaseObjcType>) {
757
+ std::visit(SetObjCArgumentVisitor{invocation, i + 2}, outer);
758
+ } else if constexpr (std::is_same_v<OuterT, BaseObjcType *>) {
759
+ if (outer)
760
+ std::visit(SetObjCArgumentVisitor{invocation, i + 2}, *outer);
761
+ }
762
+ },
763
+ stored);
764
+ }
765
+
766
+ [invocation invoke];
767
+
768
+ if (prepared->isStructReturn) {
769
+ NSUInteger returnLength = [prepared->methodSignature methodReturnLength];
770
+ std::vector<uint8_t> returnBuffer(returnLength, 0);
771
+ [invocation getReturnValue:returnBuffer.data()];
772
+ return UnpackStructToJSValue(env, returnBuffer.data(), prepared->returnType);
773
+ }
774
+
775
+ return ConvertReturnValueToJSValue(env, invocation, prepared->methodSignature);
138
776
  }