JavaScript in C
Call JavaScript from C, expose native callbacks, and pass data across the bridge.
On this page
π Preview API
The JavaScript C API ships as a preview in Ultralight 2.0. It may still change after 2.0.
The Ultralight C API lets you call JavaScript functions from C and expose native functions to page script.
This page covers what differs in Cβ read About JavaScript Interop and Extending JavaScript with Native API first for the underlying concepts.
Including <Ultralight/CAPI.h> gives you all five JavaScript headers automatically.
Differences from C++
The JavaScript C API replaces C++ templates, exceptions, and automatic type conversion with opaque handles and explicit checks.
| C++ Feature | C API Equivalent | How It Differs |
|---|---|---|
js::API |
ulCreateJSAPI() and ulDestroyJSAPI() |
You keep the handle alive while pages need the API (see What Destroying a Handle Does). |
app["add"] = lambda |
ulJSAPIBindFunction() with ULJSFunctionCallback |
Callbacks receive raw arguments without automatic arity or type checks. |
Strict Value::Or() and To<T>() |
ulJSValueGetType(), then ulJSValueToNumber() or ulJSValueToString() |
Conversions follow JavaScript coercion rules, so check types first to enforce strict conversion. |
Struct, vector, and enum marshaling with js::TypeTraits |
None | Build objects property by property, or parse JSON with ulCreateJSValueFromJSON(). |
js::Result and js::Error |
NULL or false return with a ULJSValue* exception out-parameter |
Page exceptions and the library's own errors write to the out-parameter. |
js::Error::WithCode() |
ulCreateJSError() and a "code" property |
Set the "code" property explicitly using ulJSObjectSetProperty(). |
js::Resolver |
ULJSPromiseResolver |
You own the resolver handle and must settle and destroy it. |
js::Task, co_await, and js::Await() |
None | Chain callbacks using ulJSRunOnWorker(), ulJSPromiseResolverComplete(), or ulJSPromiseThen(). |
DefineClass<T>() |
ulCreateJSClass() and ulJSAPIRegisterClass() |
Instance ownership is set by an adopt flag or a custom holder. |
js::BindingGuard |
None | Call ulJSAPIUnbind() to remove a binding manually. |
JavaScript Handle Ownership
JavaScript handles follow the general conventions described in Managing Memory and Ownership, Handling Errors, and Passing NULL Handlesβ this table shows what those rules mean for JavaScript calls.
| Handle | Who Owns It |
|---|---|
| Every handle a library function returns (values, properties, global objects, contexts, and errors) | You own the handle. Call ulDestroyJSValue() or ulDestroyJSContext() when finished. Each property read in a chain yields an owned handle that you must destroy. |
Callback arguments, this_value, and ctx |
The library owns them. They remain valid only during the callback. Call ulCreateJSValueRef() or ulCreateJSContextRef() to keep one longer. |
| Handles passed into library functions (arguments, property values, and resolve values) | They stay yours. The library only borrows them during the call, so destroy them when you're finished with them. |
Handles your callback returns or stores in its *exception parameter |
The library takes ownership. Do not destroy them. (An exception that a library call stores in your variable remains yours to destroy.) |
Calling a Page Function
To call a JavaScript function on the page, look up the function property on the global object and invoke it with ulJSFunctionCall().
#include <Ultralight/CAPI.h>
#include <stdio.h>
/* Set with ulViewSetDOMReadyCallback(view, OnDOMReady, NULL, NULL). */
static void OnDOMReady(void* user_data, ULView caller,
unsigned long long frame_id, bool is_main_frame,
ULString url) {
(void)user_data;
(void)frame_id;
(void)url;
if (!is_main_frame)
return;
ULJSContext ctx = ulViewGetJSContext(caller);
if (!ctx)
return;
ULJSValue global = ulJSContextGetGlobalObject(ctx);
ULJSValue fn = ulJSObjectGetProperty(global, "computeTotal", NULL);
ULJSValue args[2] = { ulCreateJSValueNumber(ctx, 3),
ulCreateJSValueNumber(ctx, 4) };
ULJSValue exception = NULL;
ULJSValue result = ulJSFunctionCall(fn, NULL, args, 2, &exception);
if (ulJSValueGetType(result) == kULJSType_Number) {
double total = 0;
ulJSValueToNumber(result, &total, NULL);
printf("total: %g\n", total); /* total: 7 */
} else if (exception) {
ULJSErrorDetails details = {0};
if (ulJSValueGetErrorDetails(exception, &details) && details.message)
printf("script threw: %s (line %u)\n",
ulStringGetData(details.message), details.line);
ulJSErrorDetailsRelease(&details);
ulDestroyJSValue(exception);
}
ulDestroyJSValue(result);
ulDestroyJSValue(args[0]);
ulDestroyJSValue(args[1]);
ulDestroyJSValue(fn);
ulDestroyJSValue(global);
ulDestroyJSContext(ctx);
}
Keep these details in mind when calling a page function:
ulViewGetJSContext()returnsNULLwhen the main frame cannot run scripts. This happens when JavaScript is disabled or sandboxed. Each page receives its own context, so fetch a fresh context handle after every navigation.- Passing
NULLforthis_valueuses the global object. The function runs with the page's global object asthis. - Inspect thrown errors with
ulJSValueGetErrorDetails(). It populatesULJSErrorDetailswith owned strings. Release them withulJSErrorDetailsRelease().
For the underlying concepts, see Calling into the Page.
Evaluating a Script
To evaluate a script expression and read its result as text without acquiring a context, call ulViewEvaluateScript().
ULString script = ulCreateString("document.title");
ULString title = ulViewEvaluateScript(view, script, NULL);
printf("%s\n", ulStringGetData(title));
ulDestroyString(script);
The strings returned by ulViewEvaluateScript() for both the result and the exception are borrowed strings owned by the View. Each call overwrites an internal buffer, so you must never destroy them. Copy them with ulCreateStringFromCopy() to keep them longer. See Keeping a Borrowed Value.
To evaluate scripts and receive typed JavaScript values instead of text, call ulJSContextEvaluate(). It takes a ULJSContext and returns an owned ULJSValue handle that you must release with ulDestroyJSValue().
Exposing a Function
To expose a native function to page script, register a callback with ulJSAPIBindFunction(), attach the API to your View, and keep the ULJSAPI handle alive for as long as pages need it.
#include <Ultralight/CAPI.h>
static ULJSAPI g_api = NULL;
/* app.add(...numbers) */
static ULJSValue OnAdd(void* user_data, ULJSContext ctx, ULJSValue this_value,
const ULJSValue* args, size_t argc,
ULJSValue* exception) {
(void)user_data;
(void)this_value;
double sum = 0;
for (size_t i = 0; i < argc; i++) {
if (ulJSValueGetType(args[i]) != kULJSType_Number) {
*exception = ulCreateJSMarshalError(ctx, "app.add", i + 1, NULL,
"number", args[i]);
return NULL;
}
double n = 0;
ulJSValueToNumber(args[i], &n, NULL);
sum += n;
}
return ulCreateJSValueNumber(ctx, sum);
}
void SetupApp(ULView view) {
g_api = ulCreateJSAPI("app");
ulJSAPIBindFunction(g_api, "add", OnAdd, NULL, NULL);
ulJSAPISetConstantString(g_api, "version", "2.1.0");
if (ulViewAttachJSAPI(view, g_api, kULJSAPIAttachFlags_None, NULL, 0)) {
ULString url = ulCreateString("file:///app.html");
ulViewLoadURL(view, url);
ulDestroyString(url);
}
}
void ShutdownApp(void) {
ulDestroyJSAPI(g_api); /* detaches the API from every View */
g_api = NULL;
}
Page script accesses the bound properties and functions through the global object:
app.version; // "2.1.0"
app.add(2, 3); // 5
app.add(2, 'three');
// TypeError: app.add: argument 2: expected number, got 'three'
Follow these rules for return values, errors, and cleanup:
- Callbacks return an owned handle. Return an owned
ULJSValuehandle, orNULLforundefined. To throw an error, store an owned error handle in*exceptionand returnNULL. ulCreateJSMarshalError()builds the standard argumentTypeError. It sets the error code to"ULJS_BAD_ARG", allowing page script to check the code without parsing the message.- Free binding state in
destroy_user_data. This hook runs on the Renderer's thread once no page holds its functions, during the lastulDestroyJSAPI()call or a laterulUpdate().
π§ Validate Argument Count and Types
The library performs no automatic count or type validation on callback arguments, so reading
args[1]when the page passed only one argument reads past the array. Conversion functions likeulJSValueToNumber()andulJSValueToString()coerce values using JavaScript rules and can execute page script methods likevalueOf(). Always verifyargcand check argument types withulJSValueGetType()before reading or converting values.
Throwing an Error with a Code
To throw an error with a custom error code, set a "code" property on the error object before assigning it to *exception.
static ULJSValue OnOpen(void* user_data, ULJSContext ctx, ULJSValue this_value,
const ULJSValue* args, size_t argc,
ULJSValue* exception) {
(void)user_data;
(void)this_value;
if (argc < 1 || ulJSValueGetType(args[0]) != kULJSType_String) {
ULJSValue error = ulCreateJSError(ctx, kULJSErrorType_TypeError,
"path must be a string");
ULJSValue code = ulCreateJSValueStringFromCString(ctx, "APP_BAD_PATH");
ulJSObjectSetProperty(error, "code", code, kULJSPropertyAttributes_None,
NULL);
ulDestroyJSValue(code);
*exception = error;
return NULL;
}
return ulCreateJSValueBoolean(ctx, true);
}
Setting the "code" property lets page script check error.code directly without parsing the message. For underlying concepts, see Throwing JavaScript Errors from Native Code.
Binding Properties, Constants, and Events
Live Properties
To bind a live property that runs getter and setter callbacks, call ulJSAPIBindProperty(). Passing NULL for the setter makes the property read-only. If your setter callback returns false, the assignment throws a TypeError.
Constant Values
To expose constant values that page script cannot modify, call ulJSAPISetConstantNumber(), ulJSAPISetConstantBoolean(), ulJSAPISetConstantString(), or ulJSAPISetConstantJSON().
Emitting Events
To emit an event to page script from any thread, call ulJSAPIEmitEventJSON() with arguments formatted as a JSON array string.
ulJSAPIEmitEventJSON(g_api, "saved", "[\"slot1.dat\"]");
The renderer delivers the event to each page during a later ulUpdate() callβ see Emitting Events for how page script subscribes to them.
Keeping a Page Callback
To store a JavaScript callback passed from page script, verify that the argument is callable, retain it with ulCreateJSValueRef(), and verify that the page is alive before invoking it later.
#include <Ultralight/CAPI.h>
static ULJSValue g_on_score = NULL;
/* app.onScore(callback) */
static ULJSValue OnSetScoreListener(void* user_data, ULJSContext ctx,
ULJSValue this_value,
const ULJSValue* args, size_t argc,
ULJSValue* exception) {
(void)user_data;
(void)this_value;
if (argc < 1 || !ulJSValueIsCallable(args[0])) {
*exception = ulCreateJSMarshalError(ctx, "app.onScore", 1, NULL,
"function", argc ? args[0] : NULL);
return NULL;
}
ulDestroyJSValue(g_on_score);
g_on_score = ulCreateJSValueRef(args[0]);
return NULL;
}
/* Later, on the Renderer's thread: */
void ReportScore(double score) {
if (!ulJSValueIsAlive(g_on_score)) { /* its page is gone */
ulDestroyJSValue(g_on_score);
g_on_score = NULL;
return;
}
ULJSContext ctx = ulJSValueGetContext(g_on_score);
ULJSValue arg = ulCreateJSValueNumber(ctx, score);
ulDestroyJSValue(ulJSFunctionCall(g_on_score, NULL, &arg, 1, NULL));
ulDestroyJSValue(arg);
ulDestroyJSContext(ctx);
}
A retained function handle does not keep the page alive. Always call ulJSValueIsAlive() before invoking the callbackβ if the page navigated away, destroy the handle and discard it.
Destroying a handle whose page is gone remains safe and necessary to prevent memory leaks.
Exposing Async Functions
To expose an asynchronous function that returns a Promise to the page, bind a callback with ulJSAPIBindAsyncFunction().
#include <Ultralight/CAPI.h>
#include <stdlib.h>
typedef struct {
ULJSPromiseResolver resolver;
ULJSContext ctx; /* the completion callback gets no context */
char path[256];
bool ok;
} SaveJob;
/* A worker thread: no JavaScript here. */
static void SaveWork(void* user_data) {
SaveJob* job = (SaveJob*)user_data;
job->ok = job->path[0] != '\0'; /* write the file here */
}
/* The Renderer's thread, during a later ulUpdate(). */
static void SaveComplete(void* user_data) {
SaveJob* job = (SaveJob*)user_data;
if (job->ok) {
ULJSValue result = ulCreateJSValueBoolean(job->ctx, true);
ulJSPromiseResolverResolve(job->resolver, result);
ulDestroyJSValue(result);
} else {
ULJSValue error = ulCreateJSError(job->ctx, kULJSErrorType_Error,
"disk full");
ulJSPromiseResolverReject(job->resolver, error);
ulDestroyJSValue(error);
}
}
/* Runs once, even when the work is discarded. */
static void FreeSaveJob(void* user_data) {
SaveJob* job = (SaveJob*)user_data;
ulDestroyJSPromiseResolver(job->resolver); /* rejects if never settled */
ulDestroyJSContext(job->ctx);
free(job);
}
/* ulJSAPIBindAsyncFunction(g_api, "save", OnSave, NULL, NULL); */
static void OnSave(void* user_data, ULJSContext ctx, ULJSValue this_value,
const ULJSValue* args, size_t argc,
ULJSPromiseResolver resolver) {
(void)user_data;
(void)this_value;
SaveJob* job = (SaveJob*)calloc(1, sizeof(SaveJob));
job->resolver = resolver;
job->ctx = ulCreateJSContextRef(ctx);
size_t len = 0;
if (argc > 0 && ulJSValueGetType(args[0]) == kULJSType_String &&
ulJSValueGetUTF8(args[0], job->path, sizeof(job->path) - 1, &len,
NULL))
job->path[len] = '\0';
ulJSRunOnWorker(SaveWork, SaveComplete, job, FreeSaveJob);
}
The page receives a Promise immediately, while your callback receives an owned ULJSPromiseResolver handle to settle it. For underlying concepts, see Async Callbacks.
- Retain a context reference for the completion callback. The
ulJSRunOnWorker()completion callback receives onlyuser_data, so store an owned context reference fromulCreateJSContextRef()in your job struct. - Free job state in
destroy_user_data. This callback still runs if a Renderer shutdown discards queued work. - Settle a Promise from another thread with
ulJSPromiseResolverComplete(). It invokes a callback on the Renderer's thread during a laterulUpdate()to create the value. - Run code without a Promise using
ulJSContextPostTask(). It runs during a laterulUpdate()and is skipped if the page is gone.
π§ Settle and Destroy Resolvers Carefully
Call
ulDestroyJSPromiseResolver()exactly once as the resolver's last use. Failing to destroy it leaks memory and leaves the page waiting forever, while destroying an unsettled resolver rejects the Promise with an Error. CallulJSPromiseResolverResolve()andulJSPromiseResolverReject()on the Renderer's thread onlyβ from any other thread, settle the Promise withulJSPromiseResolverComplete().
Waiting on a Page Promise
To wait for a Promise from the page to settle, call ulJSPromiseThen(). Your callback runs on the Renderer's thread during a later ulUpdate() when the Promise settles.
If result is NULL and rejected is true, the page went away before the Promise settled rather than page script rejecting it.
Exposing Native Classes
To expose a native class to page script, define it with ulCreateJSClass(), register callbacks for its constructor, methods, and properties, and register it with ulJSAPIRegisterClass()β see Exposing Native Classes for the underlying concepts.
In C, instance ownership depends on how you create the wrapper rather than C++ smart pointer types.
| How Wrapper Is Created | Owner | Lifetime Rule |
|---|---|---|
new in page script (returned by your constructor callback) |
The wrapper | The class destructor runs after garbage collection or when the page is gone, during a later ulUpdate(). |
ulCreateJSObjectWithClass() with adopt set to true |
The wrapper | The class destructor runs after garbage collection or when the page is gone, during a later ulUpdate(). |
ulCreateJSObjectWithClass() with adopt set to false |
You | You must keep the instance alive until its page is gone, or detach it before freeing. |
ulCreateJSObjectWithClassHolder() |
Your holder | The holder's destroy callback frees the instance. |
- Set a class destructor for owned instances. If you don't set one with
ulJSClassSetDestructor(), owned instances are never destroyed. - Never adopt the same instance pointer twice. If you adopt it into two owning wrappers (such as across two contexts or two classes), the library destroys it twice.
- Retain your
ULJSClasshandle. Functions that wrap, detach, or look up instances later take the class definition.
Closing an Instance Early
To release scarce native resources before garbage collection runs, detach the instance with ulJSObjectDetachInstance().
#include <Ultralight/CAPI.h>
#include <stdio.h>
static ULJSClass g_log_class = NULL; /* kept after ulJSAPIRegisterClass() */
/* The class destructor (ulJSClassSetDestructor()). */
static void LogFileDestroy(void* user_data, void* instance) {
(void)user_data;
fclose((FILE*)instance);
}
/* logFile.close() */
static ULJSValue LogFileClose(void* user_data, ULJSContext ctx,
void* instance, ULJSValue this_value,
const ULJSValue* args, size_t argc,
ULJSValue* exception) {
(void)user_data;
(void)ctx;
(void)instance;
(void)args;
(void)argc;
(void)exception;
void* file = ulJSObjectDetachInstance(this_value, g_log_class, NULL);
if (file)
LogFileDestroy(NULL, file); /* the destructor won't run for it now */
return NULL;
}
Subsequent calls to methods or properties on a detached wrapper throw a TypeError with code ULJS_DETACHED. For general patterns, see Closing an Instance Early.
Sharing Binary Data
To share native bytes with page script without copying, wrap your memory in a ULBuffer and pass it to ulCreateJSArrayBufferFromBuffer()β see Binary Data for the underlying concepts.
#include <Ultralight/CAPI.h>
#include <stdlib.h>
static void FreePixels(void* user_data, void* data) {
(void)user_data;
free(data);
}
ULJSValue SharePixels(ULJSContext ctx, void* pixels, size_t size) {
ULBuffer buffer = ulCreateBuffer(pixels, size, NULL, FreePixels);
ULJSValue array_buffer = ulCreateJSArrayBufferFromBuffer(ctx, buffer);
ulDestroyBuffer(buffer); /* the ArrayBuffer keeps its own reference */
return array_buffer;
}
Remember these rules about buffer lifetime and cleanup:
- The destruction callback runs when the last reference is released. This typically happens during a later
ulUpdate(), after theArrayBufferis garbage-collected or detached. - Detach the buffer before freeing native memory. Call
ulJSArrayBufferDetach()to disconnect the page from memory before you free it in native code. - Accessing buffer bytes prevents detaching. Once you call
ulJSArrayBufferGetBytes(),ulJSArrayBufferGetBuffer(), orulJSTypedArrayGetInfo(), neither native code nor the page can detach theArrayBufferβ any later call toulJSArrayBufferDetach()returnsfalse. - Page-owned bytes are freed when the page goes away. This happens even while you hold a pointer or
ULBufferhandleβ copy them withulCreateBufferFromCopy()to keep them longer.
Using JavaScriptCore Directly
To call JavaScriptCore API functions directly, call ulViewLockJSContext() to acquire the page's underlying JSContextRef. This locks the JavaScript VM for the current thread, so pair every call with ulViewUnlockJSContext()β see Using JavaScriptCore Directly.
To convert between DOM element handles and their JavaScript object wrappers, call ulDOMElementGetJSValue() and ulDOMElementFromJSValue() from <Ultralight/CAPI/CAPI_DOMElement.h>. Both functions return owned handles that you must destroy when finished. For details, see DOM Access in C.