From 94e20fcd39e90cf39bf5f947543d03dfacc5a0ac Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:42:57 -0700 Subject: [PATCH 1/2] Node-API-JSI: rethrow native errors from host functions as their JS value Native code throws Napi::Error, a C++ exception carrying the JavaScript error object. JSI reports any std::exception that escapes a host function as a fresh Error("Exception in HostFunction: " + what()), so on the JSI backend a polyfill's TypeError reached scripts as a plain Error with a prefixed message: `instanceof TypeError` was false and messages no longer compared equal, unlike every other backend. Route every host-function trampoline (Function::New callbacks, class constructors, static and instance methods and accessors) through a helper that catches Napi::Error and rethrows jsi::JSError with the original value, the same conversion Napi::Eval already does in the other direction (#246). Regression test: "native exceptions reach scripts as the thrown error object, with its class and message, on every engine". --- Core/Node-API-JSI/Include/napi/napi-inl.h | 65 +++++++++++++++-------- Tests/UnitTests/Scripts/tests.ts | 26 +++++++++ 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/Core/Node-API-JSI/Include/napi/napi-inl.h b/Core/Node-API-JSI/Include/napi/napi-inl.h index 14fb745c..9f869f1f 100644 --- a/Core/Node-API-JSI/Include/napi/napi-inl.h +++ b/Core/Node-API-JSI/Include/napi/napi-inl.h @@ -1321,11 +1321,25 @@ inline const T* TypedArrayOf::Data() const { namespace details { + // Native code throws Napi::Error, a C++ exception carrying the JavaScript error object. JSI + // reports any std::exception that escapes a host function as a fresh + // Error("Exception in HostFunction: " + what()), which loses the class (TypeError, RangeError, + // DOMException...), the exact message and any extra properties. Rethrow it as the JS value, + // which is what every other Node-API backend delivers, so scripts observe the same exception. + template + inline jsi::Value CallHost(jsi::Runtime& rt, Call&& call) { + try { + return call(); + } catch (const Napi::Error& error) { + throw jsi::JSError{rt, jsi::Value{rt, static_cast(error.Value())}}; + } + } + template struct Function { static inline jsi::Value Callback(napi_env env, const jsi::Value& thisVal, const jsi::Value* args, size_t count, void* data, Callable cb) { CallbackInfo callbackInfo{env, thisVal, args, count, {}, data}; - return {env->rt, cb(callbackInfo)}; + return CallHost(env->rt, [&]() -> jsi::Value { return {env->rt, cb(callbackInfo)}; }); } }; @@ -1333,8 +1347,7 @@ namespace details struct Function { static inline jsi::Value Callback(napi_env env, const jsi::Value& thisVal, const jsi::Value* args, size_t count, void* data, Callable cb) { CallbackInfo callbackInfo{env, thisVal, args, count, {}, data}; - cb(callbackInfo); - return {}; + return CallHost(env->rt, [&]() -> jsi::Value { cb(callbackInfo); return {}; }); } }; } @@ -2295,8 +2308,10 @@ ObjectWrap::DefineClass(napi_env env, [env, newTarget, data](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { CallbackInfo callbackInfo{env, thisVal, args, count, *newTarget, data}; // TODO: use prototype to wrap object? - thisVal.getObject(rt).setProperty(rt, env->native_name, jsi::Object::createFromHostObject(rt, std::make_shared(callbackInfo))); - return {}; + return details::CallHost(rt, [&]() -> jsi::Value { + thisVal.getObject(rt).setProperty(rt, env->native_name, jsi::Object::createFromHostObject(rt, std::make_shared(callbackInfo))); + return {}; + }); })).getObject(rt).getFunction(rt)}; *newTarget = {rt, static_cast(constructor)}; @@ -2316,28 +2331,26 @@ ObjectWrap::DefineClass(napi_env env, if (p.staticVoidMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, - [env, method{p.staticVoidMethod}, data{p.data}](jsi::Runtime& /*rt*/, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - (*method)({env, thisVal, args, count, nullptr, data}); - return {}; + [env, method{p.staticVoidMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { + return details::CallHost(rt, [&]() -> jsi::Value { (*method)({env, thisVal, args, count, nullptr, data}); return {}; }); })); } else if (p.staticMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.staticMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return {rt, (*method)({env, thisVal, args, count, nullptr, data})}; + return details::CallHost(rt, [&]() -> jsi::Value { return {rt, (*method)({env, thisVal, args, count, nullptr, data})}; }); })); } else if (p.staticGetter != nullptr || p.staticSetter != nullptr) { if (p.staticGetter != nullptr) { descriptor.setProperty(rt, "get", jsi::Function::createFromHostFunction(rt, name, 0, [env, getter{p.staticGetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count)-> jsi::Value { - return {rt, (*getter)({env, thisVal, args, count, nullptr, data})}; + return details::CallHost(rt, [&]() -> jsi::Value { return {rt, (*getter)({env, thisVal, args, count, nullptr, data})}; }); })); } if (p.staticSetter != nullptr) { descriptor.setProperty(rt, "set", jsi::Function::createFromHostFunction(rt, name, 0, [env, setter{p.staticSetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count)-> jsi::Value { - (*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); - return {}; + return details::CallHost(rt, [&]() -> jsi::Value { (*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); return {}; }); })); } } else if (!p.staticValue.IsEmpty()) { @@ -2346,30 +2359,38 @@ ObjectWrap::DefineClass(napi_env env, } else if (p.instanceVoidMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceVoidMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; - (nativeObject->*method)({env, thisVal, args, count, nullptr, data}); - return {}; + return details::CallHost(rt, [&]() -> jsi::Value { + T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; + (nativeObject->*method)({env, thisVal, args, count, nullptr, data}); + return {}; + }); })); } else if (p.instanceMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; - return {rt, (nativeObject->*method)({env, thisVal, args, count, nullptr, data})}; + return details::CallHost(rt, [&]() -> jsi::Value { + T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; + return {rt, (nativeObject->*method)({env, thisVal, args, count, nullptr, data})}; + }); })); } else if (p.instanceGetter != nullptr || p.instanceSetter != nullptr) { if (p.instanceGetter != nullptr) { descriptor.setProperty(rt, "get", jsi::Function::createFromHostFunction(rt, name, 0, [env, getter{p.instanceGetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; - return {rt, (nativeObject->*getter)({env, thisVal, args, count, nullptr, data})}; + return details::CallHost(rt, [&]() -> jsi::Value { + T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; + return {rt, (nativeObject->*getter)({env, thisVal, args, count, nullptr, data})}; + }); })); } if (p.instanceSetter != nullptr) { descriptor.setProperty(rt, "set", jsi::Function::createFromHostFunction(rt, name, 0, [env, setter{p.instanceSetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; - (nativeObject->*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); - return {}; + return details::CallHost(rt, [&]() -> jsi::Value { + T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; + (nativeObject->*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); + return {}; + }); })); } } else if (!p.instanceValue.IsEmpty()) { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 654c60b6..f63da334 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1291,6 +1291,32 @@ describe("URL", function () { }); // URL.createObjectURL / revokeObjectURL (blob: URL registry) +describe("native exceptions", function () { + it("reach scripts as the thrown error object, with its class and message, on every engine", function () { + // A polyfill throwing Napi::TypeError from native code must arrive as that TypeError. The JSI + // adapter used to deliver a generic Error("Exception in HostFunction: ...") instead. + let fromMethod: any; + try { + URL.createObjectURL({} as any); + } catch (e) { + fromMethod = e; + } + expect(fromMethod).to.be.an.instanceof(TypeError); + expect(fromMethod.message).to.match(/not a Blob/); + expect(fromMethod.message).to.not.match(/HostFunction/); + + let fromConstructor: any; + try { + new URL("not a url"); + } catch (e) { + fromConstructor = e; + } + expect(fromConstructor).to.be.an.instanceof(TypeError); + expect(fromConstructor.message).to.match(/Invalid URL/); + expect(fromConstructor.message).to.not.match(/HostFunction/); + }); +}); + describe("URL.createObjectURL", function () { this.timeout(0); From 3d5df7da20ee65acc0fd34c76b5f82f3d4431e4a Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 22:04:15 -0700 Subject: [PATCH 2/2] Node-API-JSI: propagate pending host callback exceptions JS-to-native callbacks can report an error either by throwing Napi::Error or by calling ThrowAsJavaScriptException, which records an exception in the environment. The JSI trampoline converted only the first form, allowing pending exceptions to escape their JavaScript call site and surface later as uncaught dispatcher failures.\n\nCheck and clear the pending slot before every JSI host callback returns, then throw its original JavaScript value through JSI. Add a reentrant callback regression that exercises the pending-exception form without depending on a particular polyfill. --- Core/Node-API-JSI/Include/napi/napi-inl.h | 40 +++++++++++++---------- Tests/UnitTests/Scripts/tests.ts | 8 +++++ Tests/UnitTests/Shared/Shared.cpp | 5 +++ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Core/Node-API-JSI/Include/napi/napi-inl.h b/Core/Node-API-JSI/Include/napi/napi-inl.h index 9f869f1f..91cccede 100644 --- a/Core/Node-API-JSI/Include/napi/napi-inl.h +++ b/Core/Node-API-JSI/Include/napi/napi-inl.h @@ -1321,15 +1321,19 @@ inline const T* TypedArrayOf::Data() const { namespace details { - // Native code throws Napi::Error, a C++ exception carrying the JavaScript error object. JSI - // reports any std::exception that escapes a host function as a fresh - // Error("Exception in HostFunction: " + what()), which loses the class (TypeError, RangeError, - // DOMException...), the exact message and any extra properties. Rethrow it as the JS value, - // which is what every other Node-API backend delivers, so scripts observe the same exception. + // Native callbacks report JavaScript errors either by throwing Napi::Error or by leaving an + // exception pending after ThrowAsJavaScriptException(). Convert both forms at the host-function + // boundary so JSI observes the original JavaScript value, matching the other Node-API backends. template - inline jsi::Value CallHost(jsi::Runtime& rt, Call&& call) { + inline jsi::Value CallHost(napi_env env, jsi::Runtime& rt, Call&& call) { try { - return call(); + auto result = call(); + if (!env->last_exception.isUndefined()) { + auto error = std::move(env->last_exception); + env->last_exception = jsi::Value::undefined(); + throw jsi::JSError{rt, jsi::Value{rt, error}}; + } + return result; } catch (const Napi::Error& error) { throw jsi::JSError{rt, jsi::Value{rt, static_cast(error.Value())}}; } @@ -1339,7 +1343,7 @@ namespace details struct Function { static inline jsi::Value Callback(napi_env env, const jsi::Value& thisVal, const jsi::Value* args, size_t count, void* data, Callable cb) { CallbackInfo callbackInfo{env, thisVal, args, count, {}, data}; - return CallHost(env->rt, [&]() -> jsi::Value { return {env->rt, cb(callbackInfo)}; }); + return CallHost(env, env->rt, [&]() -> jsi::Value { return {env->rt, cb(callbackInfo)}; }); } }; @@ -1347,7 +1351,7 @@ namespace details struct Function { static inline jsi::Value Callback(napi_env env, const jsi::Value& thisVal, const jsi::Value* args, size_t count, void* data, Callable cb) { CallbackInfo callbackInfo{env, thisVal, args, count, {}, data}; - return CallHost(env->rt, [&]() -> jsi::Value { cb(callbackInfo); return {}; }); + return CallHost(env, env->rt, [&]() -> jsi::Value { cb(callbackInfo); return {}; }); } }; } @@ -2308,7 +2312,7 @@ ObjectWrap::DefineClass(napi_env env, [env, newTarget, data](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { CallbackInfo callbackInfo{env, thisVal, args, count, *newTarget, data}; // TODO: use prototype to wrap object? - return details::CallHost(rt, [&]() -> jsi::Value { + return details::CallHost(env, rt, [&]() -> jsi::Value { thisVal.getObject(rt).setProperty(rt, env->native_name, jsi::Object::createFromHostObject(rt, std::make_shared(callbackInfo))); return {}; }); @@ -2332,25 +2336,25 @@ ObjectWrap::DefineClass(napi_env env, if (p.staticVoidMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.staticVoidMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { (*method)({env, thisVal, args, count, nullptr, data}); return {}; }); + return details::CallHost(env, rt, [&]() -> jsi::Value { (*method)({env, thisVal, args, count, nullptr, data}); return {}; }); })); } else if (p.staticMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.staticMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { return {rt, (*method)({env, thisVal, args, count, nullptr, data})}; }); + return details::CallHost(env, rt, [&]() -> jsi::Value { return {rt, (*method)({env, thisVal, args, count, nullptr, data})}; }); })); } else if (p.staticGetter != nullptr || p.staticSetter != nullptr) { if (p.staticGetter != nullptr) { descriptor.setProperty(rt, "get", jsi::Function::createFromHostFunction(rt, name, 0, [env, getter{p.staticGetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count)-> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { return {rt, (*getter)({env, thisVal, args, count, nullptr, data})}; }); + return details::CallHost(env, rt, [&]() -> jsi::Value { return {rt, (*getter)({env, thisVal, args, count, nullptr, data})}; }); })); } if (p.staticSetter != nullptr) { descriptor.setProperty(rt, "set", jsi::Function::createFromHostFunction(rt, name, 0, [env, setter{p.staticSetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count)-> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { (*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); return {}; }); + return details::CallHost(env, rt, [&]() -> jsi::Value { (*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); return {}; }); })); } } else if (!p.staticValue.IsEmpty()) { @@ -2359,7 +2363,7 @@ ObjectWrap::DefineClass(napi_env env, } else if (p.instanceVoidMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceVoidMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { + return details::CallHost(env, rt, [&]() -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; (nativeObject->*method)({env, thisVal, args, count, nullptr, data}); return {}; @@ -2368,7 +2372,7 @@ ObjectWrap::DefineClass(napi_env env, } else if (p.instanceMethod != nullptr) { descriptor.setProperty(rt, "value", jsi::Function::createFromHostFunction(rt, name, 0, [env, method{p.instanceMethod}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { + return details::CallHost(env, rt, [&]() -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; return {rt, (nativeObject->*method)({env, thisVal, args, count, nullptr, data})}; }); @@ -2377,7 +2381,7 @@ ObjectWrap::DefineClass(napi_env env, if (p.instanceGetter != nullptr) { descriptor.setProperty(rt, "get", jsi::Function::createFromHostFunction(rt, name, 0, [env, getter{p.instanceGetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { + return details::CallHost(env, rt, [&]() -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; return {rt, (nativeObject->*getter)({env, thisVal, args, count, nullptr, data})}; }); @@ -2386,7 +2390,7 @@ ObjectWrap::DefineClass(napi_env env, if (p.instanceSetter != nullptr) { descriptor.setProperty(rt, "set", jsi::Function::createFromHostFunction(rt, name, 0, [env, setter{p.instanceSetter}, data{p.data}](jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count) -> jsi::Value { - return details::CallHost(rt, [&]() -> jsi::Value { + return details::CallHost(env, rt, [&]() -> jsi::Value { T* nativeObject{Unwrap(env, thisVal.getObject(rt))}; (nativeObject->*setter)({env, thisVal, args, count, nullptr, data}, {env, {rt, args[0]}}); return {}; diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index f63da334..47dbaf45 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -8,6 +8,7 @@ Mocha.reporter('spec'); declare const hostPlatform: string; declare const hostEngine: string; declare const setExitCode: (code: number) => void; +declare const throwPendingAfterCallback: (callback: () => void) => void; describe("AbortController", function () { @@ -1315,6 +1316,13 @@ describe("native exceptions", function () { expect(fromConstructor.message).to.match(/Invalid URL/); expect(fromConstructor.message).to.not.match(/HostFunction/); }); + + it("propagate from the pending exception slot after a reentrant callback", function () { + let callbackRan = false; + expect(() => throwPendingAfterCallback(() => { callbackRan = true; })) + .to.throw(TypeError, "pending exception after callback"); + expect(callbackRan).to.equal(true); + }); }); describe("URL.createObjectURL", function () { diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 1c7e9ff7..13728e36 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -99,6 +99,11 @@ TEST(JavaScript, All) "setExitCode"); env.Global().Set("setExitCode", setExitCodeCallback); + env.Global().Set("throwPendingAfterCallback", Napi::Function::New(env, [](const Napi::CallbackInfo& info) { + info[0].As().Call({}); + Napi::TypeError::New(info.Env(), "pending exception after callback").ThrowAsJavaScriptException(); + })); + env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); env.Global().Set("hostEngine", Napi::Value::From(env, NAPI_JAVASCRIPT_ENGINE)); });