Skip to content

napi_typeof reports compiled class constructors as napi_number (JS typeof says "function"), so addons reject classes passed as callbacks (better-sqlite3 initialize(SqliteError, …)) #10461

Closed

Description

@proggeramlug

Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. The Node-API host's napi_typeof classifies values with its own tag checks, which miss Perry's class-constructor representations. A compiled class value, or a class expression, is reported as number. JavaScript typeof on the same value correctly says function, and napi_new_instance on it works.

Reproduction

Needs a C compiler and Node's headers (include/node in the Node tarball). No npm packages.

addon.c

#include <node_api.h>

static napi_value TypeOf(napi_env env, napi_callback_info info) {
  static const char *names[] = {"undefined", "null", "boolean", "number", "string",
                                "symbol", "object", "function", "external", "bigint"};
  size_t argc = 1;
  napi_value argv[1], out;
  napi_valuetype type;
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  napi_typeof(env, argv[0], &type);
  napi_create_string_utf8(env, names[type], NAPI_AUTO_LENGTH, &out);
  return out;
}

static napi_value Construct(napi_env env, napi_callback_info info) {
  size_t argc = 1;
  napi_value argv[1], out;
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (napi_new_instance(env, argv[0], 0, NULL, &out) != napi_ok) napi_get_null(env, &out);
  return out;
}

NAPI_MODULE_INIT() {
  napi_value fn;
  napi_create_function(env, "typeOf", NAPI_AUTO_LENGTH, TypeOf, NULL, &fn);
  napi_set_named_property(env, exports, "typeOf", fn);
  napi_create_function(env, "construct", NAPI_AUTO_LENGTH, Construct, NULL, &fn);
  napi_set_named_property(env, exports, "construct", fn);
  return exports;
}

package.json

{
  "name": "napi-typeof-class",
  "private": true,
  "type": "module",
  "dependencies": { "demo-addon": "1.0.0" },
  "perry": {
    "compilePackages": ["demo-addon"],
    "nativeAddons": ["demo-addon"],
    "allow": { "compilePackages": ["*"] }
  }
}

node_modules/demo-addon/package.json

{ "name": "demo-addon", "version": "1.0.0", "main": "index.js" }

node_modules/demo-addon/index.js

module.exports = require("./addon.node");

main.ts

import addon from "demo-addon";

class Plain {}
class Tagged { tag = "made"; }
class MyError extends Error {}
const Expr = class {};
function decl() {}

for (const [name, value] of [
  ["class Plain", Plain], ["class Tagged", Tagged], ["class MyError extends Error", MyError],
  ["class expression", Expr], ["function decl", decl], ["arrow", () => 1],
  ["bound", decl.bind(null)], ["Map", Map], ["{}", {}],
] as [string, unknown][]) {
  console.log(name.padEnd(28), typeof value, addon.typeOf(value));
}
const t: any = addon.construct(Tagged);
console.log("napi_new_instance(Tagged)", t instanceof Tagged, t?.tag);
cc -shared -fPIC -I"$(dirname "$(dirname "$(command -v node)")")/include/node" addon.c \
   -o node_modules/demo-addon/addon.node
node main.ts
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o app && ./app

Expected (Node 26.5.1)

class Plain                  function function
class Tagged                 function function
class MyError extends Error  function function
class expression             function function
function decl                function function
arrow                        function function
bound                        function function
Map                          function function
{}                           object object
napi_new_instance(Tagged) true made

Actual (Perry)

class Plain                  function number
class Tagged                 function number
class MyError extends Error  function number
class expression             function number
function decl                function function
arrow                        function function
bound                        function function
Map                          function function
{}                           object object
napi_new_instance(Tagged) true made

The output is the same in no-auto and default auto-optimize builds.

Impact

Notes

  • napi_typeof (crates/perry-runtime/src/node_api_host/values.rs:411-447) tests js.is_number() || js.is_int32() before anything else and returns Function only for crate::closure::is_closure_ptr. Perry's own typeof (classify_value_typeof, crates/perry-runtime/src/builtins/arithmetic.rs:701) handles two more callable shapes that this misses:
    • a class ref encoded as INT32_TAG | class_id, reported as function when crate::object::is_class_id_registered(class_id) (arithmetic.rs:781-795);
    • a class object stamped OBJECT_TYPE_CLASS, via crate::object::is_class_object_ptr (arithmetic.rs:767-773).
      A class ref therefore lands in the Number arm. Suggested fix (inferred): derive napi_typeof from classify_value_typeof, keeping the host's External arm first, instead of duplicating the tag logic. Then audit napi_get_value_int32/napi_get_value_double/napi_coerce_to_number (values.rs:335 …), which would presumably accept a class ref as the number class_id (not verified).
  • napi_new_instance (functions.rs:307) and instanceof already handle class refs correctly, as the last line of the output shows, so only the classification is wrong.
  • Found while triaging Node-API host rejects addons that declare Node-API 9 or 10 (argon2 0.45, better-sqlite3 13, sharp 0.35), though they import no symbol the host lacks #10456. Related: runtime: Node-API (N-API) host for prebuilt napi addons — tracker #8523.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions