Exposing Native Classes
Expose native classes to JavaScript, bind their members, and manage instance ownership.
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:
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:
- Object wrapper — each native instance has a single wrapper object, so identity comparisons (
===) hold across the page. - Prototype methods — methods live on the prototype shared by all instances of the class.
- Instance properties — fields and properties become own properties of each wrapper.
- Type checks —
instanceofevaluates totrue, and the constructor'snameproperty matches the last segment of the path. - Receiver safety — calling a method with an invalid
thisobject or on a detached instance throws a TypeError (codeULJS_NOT_INSTANCEorULJS_DETACHED) and never reaches your C++ code.
Page script can create an instance, update fields, and call methods:
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_DETACHEDonce the receiver is gone.
This example registers a class without a constructor, using static factory methods to create instances:
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:
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. CallingUnbind()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:
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 calljs::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:
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:
- Specialize
HolderTraitsin namespaceultralightso the library can reach the underlying object. - Specialize
js::ClassHolderfor 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:
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:
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:
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:
- Clear the value— reset the stored
js::Valuein an explicit close method. - Store a weak value— store a
js::WeakValueinstead when the page keeps the object alive on its own.
🚧 Avoid Weak Callbacks
Never use a
js::WeakValuefor a page callback that only native code holds. The engine garbage-collects the callback because nothing on the page keeps it alive.