docs

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().

C
#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:

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().

C
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.

C
#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:

JavaScript
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:

🚧 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 like ulJSValueToNumber() and ulJSValueToString() coerce values using JavaScript rules and can execute page script methods like valueOf(). Always verify argc and check argument types with ulJSValueGetType() 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.

C
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.

C
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.

C
#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().

C
#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.

🚧 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. Call ulJSPromiseResolverResolve() and ulJSPromiseResolverReject() on the Renderer's thread onlyβ€” from any other thread, settle the Promise with ulJSPromiseResolverComplete().

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.

Closing an Instance Early

To release scarce native resources before garbage collection runs, detach the instance with ulJSObjectDetachInstance().

C
#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.

C
#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:

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.