Files
cell/docs/c-modules.md
2026-02-21 03:01:26 -06:00

17 KiB

title, description, weight, type
title description weight type
Writing C Modules Extending ƿit with native code 50 docs

ƿit makes it easy to extend functionality with C code. C files in a package are compiled into a dynamic library and can be imported like any other module.

Basic Structure

A C module exports a single function that returns a JavaScript value:

// mymodule.c
#include "cell.h"

#define CELL_USE_NAME js_mypackage_mymodule_use

static JSValue js_add(JSContext *js, JSValue self, int argc, JSValue *argv) {
  double a = js2number(js, argv[0]);
  double b = js2number(js, argv[1]);
  return number2js(js, a + b);
}

static JSValue js_multiply(JSContext *js, JSValue self, int argc, JSValue *argv) {
  double a = js2number(js, argv[0]);
  double b = js2number(js, argv[1]);
  return number2js(js, a * b);
}

static const JSCFunctionListEntry js_funcs[] = {
  MIST_FUNC_DEF(mymodule, add, 2),
  MIST_FUNC_DEF(mymodule, multiply, 2),
};

CELL_USE_FUNCS(js_funcs)

Symbol Naming

The exported function must follow this naming convention:

js_<package>_<filename>_use

Where:

  • <package> is the package name with / and . replaced by _
  • <filename> is the C file name without extension

Examples:

  • mypackage/math.c -> js_mypackage_math_use
  • gitea.pockle.world/john/lib/render.c -> js_gitea_pockle_world_john_lib_render_use
  • mypackage/internal/helpers.c -> js_mypackage_internal_helpers_use
  • mypackage/game.ce (AOT actor) -> js_mypackage_game_program

Actor files (.ce) use the _program suffix instead of _use.

Internal modules (in internal/ subdirectories) follow the same convention — the internal directory name becomes part of the symbol. For example, internal/os.c in the core package has the symbol js_core_internal_os_use.

Note: Having both a .cm and .c file with the same stem at the same scope is a build error.

Required Headers

Include cell.h for all ƿit integration:

#include "cell.h"

This provides:

  • QuickJS types and functions
  • Conversion helpers
  • Module definition macros

Conversion Functions

JavaScript <-> C

// Numbers
double js2number(JSContext *js, JSValue v);
JSValue number2js(JSContext *js, double g);

// Booleans
int js2bool(JSContext *js, JSValue v);
JSValue bool2js(JSContext *js, int b);

// Strings (must free with JS_FreeCString)
const char *JS_ToCString(JSContext *js, JSValue v);
void JS_FreeCString(JSContext *js, const char *str);
JSValue JS_NewString(JSContext *js, const char *str);

Blobs

// Get blob data (returns pointer, sets size in bytes)
void *js_get_blob_data(JSContext *js, size_t *size, JSValue v);

// Get blob data in bits
void *js_get_blob_data_bits(JSContext *js, size_t *bits, JSValue v);

// Create new stone blob from data
JSValue js_new_blob_stoned_copy(JSContext *js, void *data, size_t bytes);

// Check if value is a blob
int js_is_blob(JSContext *js, JSValue v);

Function Definition Macros

JSC_CCALL

Define a function with automatic return value:

JSC_CCALL(mymodule_greet,
  const char *name = JS_ToCString(js, argv[0]);
  char buf[256];
  snprintf(buf, sizeof(buf), "Hello, %s!", name);
  ret = JS_NewString(js, buf);
  JS_FreeCString(js, name);
)

JSC_SCALL

Shorthand for functions taking a string first argument:

JSC_SCALL(mymodule_strlen,
  ret = number2js(js, strlen(str));
)

MIST_FUNC_DEF

Register a function in the function list:

MIST_FUNC_DEF(prefix, function_name, arg_count)

Module Export Macros

CELL_USE_FUNCS

Export an object with functions:

static const JSCFunctionListEntry js_funcs[] = {
  MIST_FUNC_DEF(mymod, func1, 1),
  MIST_FUNC_DEF(mymod, func2, 2),
};

CELL_USE_FUNCS(js_funcs)

CELL_USE_INIT

For custom initialization:

CELL_USE_INIT(
  JSValue obj = JS_NewObject(js);
  // Custom setup...
  return obj;
)

Complete Example

// vector.c - Simple 2D vector operations
#include "cell.h"
#include <math.h>

#define CELL_USE_NAME js_mypackage_vector_use

JSC_CCALL(vector_length,
  double x = js2number(js, argv[0]);
  double y = js2number(js, argv[1]);
  ret = number2js(js, sqrt(x*x + y*y));
)

JSC_CCALL(vector_normalize,
  double x = js2number(js, argv[0]);
  double y = js2number(js, argv[1]);
  double len = sqrt(x*x + y*y);
  if (len > 0) {
    JS_FRAME(js);
    JS_ROOT(result, JS_NewObject(js));
    JS_SetPropertyStr(js, result.val, "x", number2js(js, x/len));
    JS_SetPropertyStr(js, result.val, "y", number2js(js, y/len));
    JS_RestoreFrame(_js_ctx, _js_gc_frame, _js_local_frame);
    ret = result.val;
  }
)

JSC_CCALL(vector_dot,
  double x1 = js2number(js, argv[0]);
  double y1 = js2number(js, argv[1]);
  double x2 = js2number(js, argv[2]);
  double y2 = js2number(js, argv[3]);
  ret = number2js(js, x1*x2 + y1*y2);
)

static const JSCFunctionListEntry js_funcs[] = {
  MIST_FUNC_DEF(vector, length, 2),
  MIST_FUNC_DEF(vector, normalize, 2),
  MIST_FUNC_DEF(vector, dot, 4),
};

CELL_USE_FUNCS(js_funcs)

Usage in ƿit:

var vector = use('vector')

var len = vector.length(3, 4)  // 5
var n = vector.normalize(3, 4) // {x: 0.6, y: 0.8}
var d = vector.dot(1, 0, 0, 1) // 0

Build Process

C files are automatically compiled when you run:

cell --dev build

Each C file is compiled into a per-file dynamic library at a content-addressed path in ~/.cell/build/<hash>. A manifest is written for each package so the runtime can find dylibs without rerunning the build pipeline — see Dylib Manifests.

Compilation Flags (cell.toml)

Use the [compilation] section in cell.toml to pass compiler and linker flags:

[compilation]
CFLAGS = "-Isrc -Ivendor/include"
LDFLAGS = "-lz -lm"

Include paths

Relative -I paths are resolved from the package root:

CFLAGS = "-Isdk/public"

If your package is at /path/to/mypkg, this becomes -I/path/to/mypkg/sdk/public.

Absolute paths are passed through unchanged.

The build system also auto-discovers include/ directories — if your package has an include/ directory, it is automatically added to the include path. No need to add -I$PACKAGE/include in cell.toml.

Library paths

Relative -L paths work the same way:

LDFLAGS = "-Lsdk/lib -lmylib"

Target-specific flags

Add sections named [compilation.<target>] for platform-specific flags:

[compilation]
CFLAGS = "-Isdk/public"

[compilation.macos_arm64]
LDFLAGS = "-Lsdk/lib/osx -lmylib"

[compilation.linux]
LDFLAGS = "-Lsdk/lib/linux64 -lmylib"

[compilation.windows]
LDFLAGS = "-Lsdk/lib/win64 -lmylib64"

Available targets: macos_arm64, macos_x86_64, linux, linux_arm64, windows.

Sigils

Use sigils in flags to refer to standard directories:

  • $LOCAL — absolute path to .cell/local (for prebuilt libraries)
  • $PACKAGE — absolute path to the package root
CFLAGS = "-I$PACKAGE/vendor/include"
LDFLAGS = "-L$LOCAL -lmyprebuilt"

Example: vendored SDK

A package wrapping an external SDK with platform-specific shared libraries:

mypkg/
├── cell.toml
├── wrapper.cpp
└── sdk/
    ├── public/
    │   └── mylib/
    │       └── api.h
    └── lib/
        ├── osx/
        │   └── libmylib.dylib
        └── linux64/
            └── libmylib.so
[compilation]
CFLAGS = "-Isdk/public"

[compilation.macos_arm64]
LDFLAGS = "-Lsdk/lib/osx -lmylib"

[compilation.linux]
LDFLAGS = "-Lsdk/lib/linux64 -lmylib"
// wrapper.cpp
#include "cell.h"
#include <mylib/api.h>
// ...

Platform-Specific Code

Use filename suffixes for platform variants:

audio.c           # default
audio_playdate.c  # Playdate
audio_emscripten.c # Web/Emscripten

ƿit selects the appropriate file based on the target platform.

Multi-File C Modules

If your module wraps a C library, place the library's source files in a src/ directory. Files in src/ are compiled as support objects and linked into your module's dylib — they are not treated as standalone modules.

mypackage/
  rtree.c       # module (exports js_mypackage_rtree_use)
  src/
    rtree.c      # support file (linked into rtree.dylib)
    rtree.h      # header

The module file (rtree.c) includes the library header and uses cell.h as usual. The support files are plain C — they don't need any cell macros.

GC Safety

ƿit uses a Cheney copying garbage collector. Any JS allocation — JS_NewObject, JS_NewString, JS_NewInt32, JS_SetPropertyStr, js_new_blob_stoned_copy, etc. — can trigger GC, which moves heap objects to new addresses. Bare C locals holding JSValue become dangling pointers after any allocating call. This is not a theoretical concern — it causes real crashes that are difficult to reproduce because they depend on heap pressure.

Checklist (apply to EVERY C function you write or modify)

  1. Count the JS_New*, JS_SetProperty*, and js_new_blob* calls in the function
  2. If there are 2 or more, the function MUST use JS_FRAME / JS_ROOT / JS_RETURN
  3. Every JSValue held in a C local across an allocating call must be rooted

When you need rooting

If a function creates one heap object and returns it immediately, no rooting is needed:

JSC_CCALL(mymod_name,
  ret = JS_NewString(js, "hello");
)

If a function creates an object and then sets properties on it, you must root it — each JS_SetPropertyStr call is an allocating call that can trigger GC:

// UNSAFE — will crash under GC pressure:
JSValue obj = JS_NewObject(js);
JS_SetPropertyStr(js, obj, "x", JS_NewInt32(js, 1));  // can GC → obj is stale
JS_SetPropertyStr(js, obj, "y", JS_NewInt32(js, 2));  // obj may be garbage
return obj;

// SAFE:
JS_FRAME(js);
JS_ROOT(obj, JS_NewObject(js));
JS_SetPropertyStr(js, obj.val, "x", JS_NewInt32(js, 1));
JS_SetPropertyStr(js, obj.val, "y", JS_NewInt32(js, 2));
JS_RETURN(obj.val);

Patterns

Object with properties — the most common pattern in this codebase:

JS_FRAME(js);
JS_ROOT(result, JS_NewObject(js));
JS_SetPropertyStr(js, result.val, "width", JS_NewInt32(js, w));
JS_SetPropertyStr(js, result.val, "height", JS_NewInt32(js, h));
JS_SetPropertyStr(js, result.val, "pixels", js_new_blob_stoned_copy(js, data, len));
JS_RETURN(result.val);

Array with loop — root the element variable before the loop, then reassign .val each iteration:

JS_FRAME(js);
JS_ROOT(arr, JS_NewArray(js));
JS_ROOT(item, JS_NULL);
for (int i = 0; i < count; i++) {
  item.val = JS_NewObject(js);
  JS_SetPropertyStr(js, item.val, "index", JS_NewInt32(js, i));
  JS_SetPropertyStr(js, item.val, "data", js_new_blob_stoned_copy(js, ptr, sz));
  JS_SetPropertyNumber(js, arr.val, i, item.val);
}
JS_RETURN(arr.val);

WARNING — NEVER put JS_ROOT inside a loop. JS_ROOT declares a JSGCRef local and calls JS_PushGCRef(&name), which pushes its address onto a linked list. Inside a loop the compiler reuses the same stack address, so on iteration 2+ the list becomes self-referential (ref->prev == ref). When GC triggers it walks the chain and hangs forever. This bug is intermittent — it only manifests when GC happens to run during the loop — making it very hard to reproduce.

Nested objects — root every object that persists across an allocating call:

JS_FRAME(js);
JS_ROOT(outer, JS_NewObject(js));
JS_ROOT(inner, JS_NewArray(js));
// ... populate inner ...
JS_SetPropertyStr(js, outer.val, "items", inner.val);
JS_RETURN(outer.val);

Inside JSC_CCALL — use JS_RestoreFrame and assign to ret:

JSC_CCALL(mymod_make,
  JS_FRAME(js);
  JS_ROOT(obj, JS_NewObject(js));
  JS_SetPropertyStr(js, obj.val, "x", number2js(js, 42));
  JS_RestoreFrame(_js_ctx, _js_gc_frame, _js_local_frame);
  ret = obj.val;
)

C Argument Evaluation Order (critical)

In C, the order of evaluation of function arguments is unspecified. This interacts with the copying GC to create intermittent crashes that are extremely difficult to diagnose.

// UNSAFE — crashes intermittently:
JS_FRAME(js);
JS_ROOT(obj, JS_NewObject(js));
JS_SetPropertyStr(js, obj.val, "format", JS_NewString(js, "rgba32"));
//                     ^^^^^^^ may be evaluated BEFORE JS_NewString runs
// If JS_NewString triggers GC, the already-read obj.val is a dangling pointer.

The compiler is free to evaluate obj.val into a register, then call JS_NewString. If JS_NewString triggers GC, the object moves to a new address. The rooted obj is updated by GC, but the register copy is not — it still holds the old address. JS_SetPropertyStr then writes to freed memory.

Fix: always separate the allocating call into a local variable:

// SAFE:
JS_FRAME(js);
JS_ROOT(obj, JS_NewObject(js));
JSValue fmt = JS_NewString(js, "rgba32");
JS_SetPropertyStr(js, obj.val, "format", fmt);
// obj.val is read AFTER JS_NewString completes — guaranteed correct.

This applies to any allocating function used as an argument when another argument references a rooted .val:

// ALL of these are UNSAFE:
JS_SetPropertyStr(js, obj.val, "pixels", js_new_blob_stoned_copy(js, data, len));
JS_SetPropertyStr(js, obj.val, "x", JS_NewFloat64(js, 3.14));
JS_SetPropertyStr(js, obj.val, "name", JS_NewString(js, name));

// SAFE versions — separate the allocation:
JSValue pixels = js_new_blob_stoned_copy(js, data, len);
JS_SetPropertyStr(js, obj.val, "pixels", pixels);
JSValue x = JS_NewFloat64(js, 3.14);
JS_SetPropertyStr(js, obj.val, "x", x);
JSValue s = JS_NewString(js, name);
JS_SetPropertyStr(js, obj.val, "name", s);

Functions that allocate (must be separated): JS_NewString, JS_NewFloat64, JS_NewInt64, JS_NewObject, JS_NewArray, JS_NewCFunction, js_new_blob_stoned_copy

Functions that do NOT allocate (safe inline): JS_NewInt32, JS_NewUint32, JS_NewBool, JS_NULL, JS_TRUE, JS_FALSE

Macros

Macro Purpose
JS_FRAME(js) Save the GC frame. Required before any JS_ROOT.
JS_ROOT(name, init) Declare a JSGCRef and root its value. Access via name.val.
JS_LOCAL(name, init) Declare a rooted JSValue (GC updates it through its address).
JS_RETURN(val) Restore the frame and return a value.
JS_RETURN_NULL() Restore the frame and return JS_NULL.
JS_RETURN_EX() Restore the frame and return JS_EXCEPTION.
JS_RestoreFrame(...) Manual frame restore (for JSC_CCALL bodies that use ret =).

Error return rules

  • Error returns before JS_FRAME can use plain return JS_ThrowTypeError(...) etc.
  • Error returns after JS_FRAME must use JS_RETURN_EX() or JS_RETURN_NULL() — never plain return, which would leak the GC frame.

Migrating from gc_mark

The old mark-and-sweep GC had a gc_mark callback in JSClassDef for C structs that held JSValue fields. This no longer exists. The copying GC needs to know the address of every pointer to update it when objects move.

If your C struct holds a JSValue that must survive across GC points, root it for the duration it's alive:

typedef struct {
  JSValue callback;
  JSLocalRef callback_lr;
} MyWidget;

// When storing:
widget->callback = value;
widget->callback_lr.ptr = &widget->callback;
JS_PushLocalRef(js, &widget->callback_lr);

// When done (before freeing the struct):
// The local ref is cleaned up when the frame is restored,
// or manage it manually.

In practice, most C wrappers hold only opaque C pointers (like SDL_Window*) and never store JSValues in the struct — these need no migration.

Static Declarations

Keep internal functions and variables static:

static int helper_function(int x) {
  return x * 2;
}

static int module_state = 0;

This prevents symbol conflicts between packages.

Troubleshooting

Missing header / SDK not installed

If a package wraps a third-party SDK that isn't installed on your system, the build will show:

module.c: fatal error: 'sdk/header.h' file not found (SDK not installed?)

Install the required SDK or skip that package. These warnings are harmless — other packages continue building normally.

CFLAGS not applied

If your cell.toml has a [compilation] section but flags aren't being picked up, check:

  1. The TOML syntax is valid (strings must be quoted)
  2. The section header is exactly [compilation] (not [compile] etc.)
  3. Target-specific sections use valid target names: macos_arm64, macos_x86_64, linux, linux_arm64, windows

API changes from older versions

If C modules fail with errors about function signatures:

  • JS_IsArray takes one argument (the value), not two — remove the context argument
  • Use JS_GetPropertyNumber / JS_SetPropertyNumber instead of JS_GetPropertyUint32 / JS_SetPropertyUint32
  • Use JS_NewString instead of JS_NewAtomString
  • There is no undefined — use JS_IsNull and JS_NULL only