docs

Exposing Native Classes

Expose native classes to JavaScript, bind their members, and manage instance ownership.

On this page

You can expose native C++ classes to JavaScript so page script can call into your application using real objects.

Page script can instantiate your class with new and call prototype methods like any JavaScript class— object identity is preserved across calls.

You control who owns each instance, choosing between borrowed pointers and smart pointers.

Defining a Class

Call api.DefineClass<T>(path) to expose a native class at that path on the API namespace. The call returns a js::ClassBuilder so you can chain member declarations on it:

C++
class Database {
 public:
  explicit Database(std::string path);

  std::string Query(std::string sql);
  double timeout = 30;

  std::string path() const;
  void set_path(std::string p);
};

void RegisterDatabase(js::API& api) {
  api.DefineClass<Database>("Database")
     .Constructor<std::string>()
     .Method("query", &Database::Query)
     .Field("timeout", &Database::timeout)
     .Property("path", &Database::path, &Database::set_path);
}

The class registers when the builder goes out of scope— normally at the end of the statement.

Builder Methods

The builder provides methods to declare constructors, prototype methods, and properties:

Builder Call What the Page Sees
Constructor<Args...>() A constructor callable with new that converts arguments to C++ types.
Method(name, &T::fn) A method on the prototype. It can run asynchronously by returning a js::Task or taking a trailing js::Resolver.
Field(name, &T::member) A read-write property on each instance, or read-only for const members.
Property(name, getter, setter) A property backed by getter and setter accessors. It is read-only when the setter is omitted.
StaticMethod(name, fn) A function on the constructor object itself.
js::Overload<Sig>(&T::fn) Selects a specific member function overload to pass to Method().

Argument Types

Member types are inferred directly from member pointers. A member type with no bridge conversion causes a compile error.

When the page passes an argument of the wrong type, the call throws a TypeError and native code never runs.

What the Page Sees

When JavaScript interacts with a bound class, the engine wraps each native instance in a standard JavaScript object:

Page script can create an instance, update fields, and call methods:

JavaScript
const db = new myApp.Database("save.db");
db.timeout = 60;
const rows = db.query("SELECT * FROM saves");
console.log(db instanceof myApp.Database);  // true

db.query(42);
// TypeError: myApp.Database.query(string): argument 1: expected string,
//            got number

Static Methods and Classes Without a Constructor

StaticMethod() binds a function directly to the constructor function (such as myApp.SaveFile.open()).

If you omit Constructor(), calling new on the page throws a TypeError (code ULJS_NO_CTOR). This lets you require callers to create instances through static factory methods that return a holder.

To call a member function on an object you own, pass the receiver instance to StaticMethod().

🚧 Receiver Lifetime

The receiver object must outlive every page because class definitions are never unloaded. If you cannot guarantee its lifetime, pass a shared holder to keep the receiver alive. You can also pass a weak holder, which throws ULJS_DETACHED once the receiver is gone.

This example registers a class without a constructor, using static factory methods to create instances:

C++
class SaveFile {
 public:
  static std::unique_ptr<SaveFile> Open(std::string path);
  std::string name() const;
};

void RegisterSaveFile(js::API& api, Settings& settings) {
  api.DefineClass<SaveFile>("SaveFile")  // no Constructor: `new` throws
     .StaticMethod("open", &SaveFile::Open)
     .Property("name", &SaveFile::name)
     .StaticMethod("defaultPath", settings, &Settings::SavePath);
}

Page script calls the static methods on the constructor to open a file and read its properties:

JavaScript
const save = myApp.SaveFile.open(myApp.SaveFile.defaultPath());
console.log(save.name);

One Class per Type

📘 Class Definitions Are Program-Wide

Ultralight registers each C++ class once per process. A later call to DefineClass() for the same type attaches the existing class definition to a new path and cannot add or modify members. Calling Unbind() removes that path from the registry, but the class definition itself remains.

Who Owns an Instance

An instance crosses to the page as a reference to the native object. The C++ type passed decides whether the page shares ownership, takes ownership, or borrows the object.

C++ Type Ownership When Destroyed
T* Borrowed. Native code keeps ownership. By native code. Keep it alive while pages can use it, or detach its wrapper before freeing it.
RefPtr<T> / std::shared_ptr<T> Shared between native code and the wrapper. When the last owner releases it (the wrapper is collected, its page goes away, or js::Detach() runs).
std::unique_ptr<T> Moved to the wrapper, leaving the native pointer empty. Cannot be a parameter of an exposed C++ function. When the wrapper is collected or its page goes away.
js::Eternal<T> Never owned. Never.
WeakPtr<T> / std::weak_ptr<T> with js::Bind() Locked into a strong pointer for each call (for a js::Task, until the task finishes). By native code. Calls after the object is destroyed throw a TypeError with code ULJS_DETACHED.

An exposed C++ function can return an owned instance or a borrowed pointer:

C++
class Enemy {
 public:
  std::string name;
};

void RegisterEnemyAPI(js::API& api, Enemy& boss) {
  api.DefineClass<Enemy>("Enemy").Field("name", &Enemy::name);

  // The page owns each new Enemy (std::unique_ptr is the default holder).
  api["spawnEnemy"] = [] { return std::make_unique<Enemy>(); };

  // A borrow: the page sees `boss`, and you keep ownership.
  api["boss"] = [&boss]() -> Enemy* { return &boss; };
}

🚧 Lifetime of Borrowed Instances

A raw T* returned to the page is borrowed— the object must outlive every page that can use it. Otherwise, native code must call js::Detach() on the instance before freeing it.

Canonical Holders

Every class has a canonical holder— the smart pointer type its owned instances use to cross to the page. By default, it is RefPtr<T> if T derives from RefCounted, or std::unique_ptr<T> otherwise.

Passing an instance to API::Emit() or Resolver::Resolve() requires the canonical holder. A raw T* cannot cross to the page for a later delivery.

The library releases the owned instances it holds on the Renderer's thread during a later Renderer::Update().

🚧 Navigation Releases Owned Instances

Navigating away from a page releases the instances it owns, including pages kept in the back-forward cache. When a user restores that page, JavaScript calls on those instances throw a TypeError with code ULJS_DETACHED.

Choosing Another Holder

To select a different smart pointer, specialize js::ClassHolder<T> before defining the class:

C++
template <> struct ultralight::js::ClassHolder<Database> {
  using type = std::shared_ptr<Database>;
};

Constructor() works only when the canonical holder is RefPtr<T>, std::unique_ptr<T>, or std::shared_ptr<T>.

Custom Smart Pointers

To use a custom smart pointer (eg, an engine type) as a holder, you'll need two specializations:

  1. Specialize HolderTraits in namespace ultralight so the library can reach the underlying object.
  2. Specialize js::ClassHolder for the class as shown above.

Program-Lifetime Objects

For singletons and engine subsystems that should never be destroyed, set js::Eternal<T> as the canonical holder.

Omit Constructor() so page script cannot create instances, and expose the singleton instance as a property on your API namespace.

Specialize js::ClassHolder with js::Eternal and bind the singleton instance to a property:

C++
class GameWorld {
 public:
  static GameWorld& instance();
};

template <> struct ultralight::js::ClassHolder<GameWorld> {
  using type = js::Eternal<GameWorld>;
};

void RegisterWorld(js::API& api) {
  api.DefineClass<GameWorld>("GameWorld");  // no Constructor: `new` throws
  api["world"] = js::Eternal(&GameWorld::instance());
}

Closing an Instance Early

JavaScript garbage collection runs at unpredictable times. Any class holding a scarce resource (such as an open file or a network socket) should provide an explicit close method built on js::Detach().

Calling js::Detach<T>(wrapper) releases the native instance from its wrapper and returns the canonical holder— destroying that holder frees the instance. Later JavaScript calls on that detached wrapper throw a TypeError with code ULJS_DETACHED.

You should also detach borrowed (T*) instances before native code frees them. Because each page has its own wrapper, detach the wrapper on every page the instance was given to.

This close method detaches the wrapper during a method call:

C++
class Connection {
 public:
  void Close(js::CallInfo info) {
    js::Detach<Connection>(info.this_value().ToValue());  // frees this
    // Don't touch members after the Detach.
  }
};

void RegisterConnection(js::API& api) {
  api.DefineClass<Connection>("Connection")
     .Constructor<>()
     .Method("close", &Connection::Close);
}

Calls on the wrapper throw after it is closed:

JavaScript
const conn = new myApp.Connection();
conn.close();
conn.close();  // TypeError with code "ULJS_DETACHED"

Pending Async Calls

If a call to one of this instance's async methods is still pending (a method returning js::Task or taking a trailing js::Resolver), js::Detach() detaches the wrapper but returns an empty holder. This applies only to async methods on the instance itself, not to other exposed C++ functions.

The instance stays alive until that call's Promise settles. The library then releases it on the Renderer's thread during a later Renderer::Update().

Ownership Cycles

A native instance that stores a js::Value referencing its own wrapper (or a page callback that holds the wrapper) is never garbage-collected until the page unloads.

To release the instance sooner, use one of these approaches:

🚧 Avoid Weak Callbacks

Never use a js::WeakValue for a page callback that only native code holds. The engine garbage-collects the callback because nothing on the page keeps it alive.