Embedding
How a C program uses staticjs: what each object is, who owns what, and how to pass values in and get them out. include/staticjs.h is the reference. Every declaration there has a comment, and this guide does not repeat them all. Each section names the example in examples/ that shows it in a whole program.
The pieces
Each stage is a state machine that its caller drives. None blocks, reads a file or a clock, or calls back for more input:
source bytes --push--> parser --events--> compiler --> program
program + fuel --push--> evaluator --> value --+--> the value API: kinds, elements, properties, entries
+--> writer --> text| Object | Made by | Ended by | Lifetime and sharing |
|---|---|---|---|
sjs_context | sjs_context_init | sjs_context_destroy | Owns the allocator that values are made with. Destroy it last, once every value, program and stage made with it is gone. One thread at a time. |
sjs_program | sjs_compile, or sjs_compiler_finish | sjs_program_release | Reference counted (sjs_program_retain). Immutable: compile once, evaluate many times, also on several evaluators at once. |
sjs_vm (evaluator) | sjs_vm_init | sjs_vm_destroy | Runs one evaluation at a time; keep it and start the next evaluation on it. It holds the clock and the random generator. |
sjs_value | the evaluator, or the value API | sjs_release | Reference counted, immutable once shared. A value of one context never meets another context. |
sjs_writer | sjs_writer_init | sjs_writer_destroy | Writes one value as text. |
sjs_parser, sjs_compiler | sjs_parser_init, sjs_compiler_init | ..._destroy | The two halves of sjs_compile, for a source that arrives in pieces. |
Most calls return an sjs_result; the evaluator's sjs_vm_run returns an sjs_run, and the readers (sjs_length, sjs_array_at, ...) return what they read:
| Result | Means |
|---|---|
SJS_OK | done |
SJS_NEED_SPACE | the writer filled the buffer: take its bytes and call again |
SJS_ERR_PROGRAM | the program is not staticjs, or its evaluation faulted: read the stage's sjs_error |
SJS_ERR_LIMIT | an sjs_limits value was reached |
SJS_ERR_NOMEM | the allocator returned NULL |
SJS_ERR_NOT_FOUND | no such name, key, index or error |
SJS_ERR_INVALID | the API was misused: a null handle, a call out of order, a value from another context. A program never causes it. |
Contexts and allocators
staticjs allocates only through the allocator you give the context: two functions and a self pointer. free gets back the size and alignment that alloc was asked for, so an arena or a pool needs no headers of its own. The library asks for 16-byte alignment.
static void *allocate(void *self, size_t size, size_t alignment); /* NULL when refused */
static void release(void *self, const sjs_allocator_free_params *params); /* ptr, size, alignment */
static const sjs_allocator_vtable heap = { allocate, release };
sjs_context *context;
sjs_context_config config = { .allocator = { &my_state, &heap }, .limits = SJS_LIMITS_DEFAULT };
sjs_context_init(&context, &config);An allocator that refuses gives SJS_ERR_NOMEM from the call that asked, or an SJS_ERROR_MEMORY error from a stage. Nothing is leaked, and the context can go on. sjs_context_live_bytes gives what the context's values, programs and stages hold now: 0 when everything is released. That makes a cheap check for leaks in an embedder's tests. examples/eval.c counts the peak through its allocator.
Limits
Every construct a program controls has a limit in sjs_limits, set per context. SJS_LIMITS_DEFAULT gives:
| Limit | Default | Bounds |
|---|---|---|
max_source_bytes | 1 MiB | bytes pushed to one parser |
max_token_bytes | 64 KiB | one identifier, number, string or template piece |
max_nesting | 256 | brackets, operators, functions, blocks and ifs open at once |
max_lookahead | 256 | tokens of an arrow function's parameter list, defaults included |
max_held_events | 65,536 | events of a const's pattern held until its value is read |
max_stack | 4,096 | evaluator value slots |
max_frames | 256 | evaluator frames: calls and looping built-ins |
max_value_items | 1,048,576 | elements of an array, object or set; a map's entries count two each |
max_string_bytes | 16 MiB | bytes of one string |
max_bigint_bits | 262,144 | bits of one BigInt (about 79,000 decimal digits) |
max_regex_states | 1,048,576 | states one regular expression's automaton may hold |
A program that reaches a limit is refused, or faults, with an SJS_ERROR_LIMIT error: never a crash, and never a partial value. The limits bound memory, and fuel bounds an evaluation's time. Lower both for programs you do not trust.
Compiling
A program is compiled for a goal:
SJS_GOAL_EXPRESSION: one expression, read as JavaScript reads(source). Its value is the expression's.SJS_GOAL_MODULE: imports,constdeclarations and exports. Its value is its namespace, an object of its exports with keys sorted as JavaScript sorts a namespace's.
sjs_program *program;
sjs_error error = { 0 };
sjs_compile_params params = {
.compiler = { .context = context, .goal = SJS_GOAL_EXPRESSION,
.globals = names, .global_count = 2, /* optional: see Globals */
.modules = { &state, &modules_vtable } }, /* optional: see Modules */
.source = { text, length },
.error = &error, /* optional */
};
sjs_result r = sjs_compile(¶ms, &program);
if (r == SJS_ERR_PROGRAM) {
/* error.code, error.at.line, error.at.column, error.message */
} else if (r != SJS_OK) {
/* SJS_ERR_NOMEM or SJS_ERR_INVALID: no program, and error is not filled */
}An SJS_ERR_PROGRAM result fills error, whose code is one of:
error.code | Means |
|---|---|
SJS_ERROR_SYNTAX | not JavaScript: JavaScript refuses it too |
SJS_ERROR_UNSUPPORTED | JavaScript that staticjs leaves out: a loop, var, this, an assignment where staticjs has none, a lookbehind |
SJS_ERROR_NAME | a name bound nowhere or bound twice, a module the host does not have, an export the module does not have |
SJS_ERROR_LIMIT | a limit was reached |
SJS_ERROR_MEMORY | the allocator refused |
error.at is where the error is: the offset in bytes, and the line and column, counted from 1, the column in bytes. error.message is a fixed English phrase owned by the library. error.subject names the token or name concerned, when there is one. It points into the stage that reported the error and is valid only until that stage is called again or destroyed. sjs_compile destroys its parser and compiler before it returns, so do not read subject from its error. To report it (this name is not bound (rows)), compile with the parser and the compiler yourself (see Streaming), and read sjs_compiler_error or sjs_parser_error before destroying them.
Evaluating, on fuel
Fuel is the evaluator's measure of work: one unit per instruction, and per element for the built-ins that loop. You push it: sjs_vm_run(vm, fuel) runs until the program is done, until it faults, or until it has used the fuel it was given, and then returns. An evaluation that ran out of fuel is paused, and the next sjs_vm_run resumes it where it stopped.
The fuel pushed in total bounds the work of an evaluation, and the same program on the same inputs uses the same fuel on every machine. Each call does a bounded piece of work and returns, so an evaluation shares its thread with other evaluations and with an event loop.
sjs_vm *vm;
sjs_vm_init(&vm, &(sjs_vm_config){ .context = context });
sjs_vm_start(vm, &(sjs_run_params){ .program = program, .globals = values, .global_count = 2 });
sjs_run status;
while ((status = sjs_vm_run(vm, 1000)) == SJS_RUN_PAUSED) {
if (sjs_vm_fuel_used(vm) > budget) { sjs_vm_reset(vm); break; } /* give up */
/* other work */
}
if (status == SJS_RUN_DONE) {
sjs_value result;
sjs_vm_take_result(vm, &result); /* yours: sjs_release it */
} else if (status == SJS_RUN_FAULT) {
sjs_error error;
sjs_vm_error(vm, &error); /* error.subject is valid until the next call on vm */
}- Every evaluation ends. There are no loops but the built-ins over collections, and code jumps only forward. A function cannot call itself, and a function that is already running cannot be entered again. Fuel bounds the time an evaluation takes, not whether it ends.
- A pause costs nothing. An evaluation stops anywhere, inside a callback of
maporreducetoo, and resumes on the nextsjs_vm_runas if it had not stopped. A run may use up to 2 units more than it was given. - Reuse the evaluator. Start the next evaluation on the same
sjs_vmonce you have taken the result;sjs_vm_resetabandons a paused one and releases what it holds. One evaluator runs one evaluation at a time; a program may run on several evaluators at once. - How much fuel. A filter such as
event.f > 25, witheventa global, takes 5 units, and a callback over an array a few units per element:xs.map((x) => x * 2)over 10 numbers takes 55.examples/eval.c --statsprints what a program used; Performance has more costs.
examples/stream.c runs a module in slices of 500 units.
Fuel in an event loop
One slice per turn of an event loop leaves the loop its timers and sockets in between. With libuv, an idle handle calls back once per turn:
typedef struct {
uv_idle_t idle;
sjs_vm *vm;
uint64_t budget;
} evaluation;
static void slice(uv_idle_t *idle) {
evaluation *e = idle->data;
sjs_run status = sjs_vm_run(e->vm, 10000);
if (status == SJS_RUN_PAUSED && sjs_vm_fuel_used(e->vm) < e->budget) return; /* again next turn */
uv_idle_stop(idle);
finish(e, status); /* take the result or the error, or sjs_vm_reset past the budget */
}
/* after sjs_vm_start(e->vm, ...) */
uv_idle_init(loop, &e->idle);
e->idle.data = e;
uv_idle_start(&e->idle, slice);Each evaluation in flight needs an evaluator of its own. To bound a turn by time, run small slices until it is up, as the wasm guide does in JavaScript.
Values
An sjs_value is a small struct. Its kind is one of SJS_UNDEFINED, SJS_NULL, SJS_BOOLEAN (value.boolean), SJS_NUMBER (value.as.number), SJS_STRING, SJS_BIGINT, SJS_ARRAY, SJS_OBJECT, SJS_SET, SJS_MAP, SJS_ITERATOR, SJS_FUNCTION, SJS_REGEXP and SJS_TEMPORAL (a Temporal value or a Date). Strings and the kinds after them point to a reference-counted block.
Ownership. Copying the struct does not retain the value. Who holds a reference:
| You get a value from | It is |
|---|---|
sjs_vm_take_result, sjs_string_new, sjs_bigint_new, sjs_array_new, sjs_object_new, sjs_set_new, sjs_map_new, sjs_function_native | yours: sjs_release it |
sjs_array_at, sjs_set_at, sjs_object_at, sjs_object_get, sjs_map_at, sjs_map_get | borrowed, valid while its container is: sjs_retain it to keep it longer |
a native's call->arguments | borrowed for the call |
| You give a value to | It |
|---|---|
sjs_array_append, sjs_object_put, sjs_set_add, sjs_map_set | takes over your reference (on an error, you keep it) |
sjs_run_params.globals | borrows it until the evaluation ends |
a native's call->result | takes over your reference: sjs_retain a borrowed value before returning it |
a modules adapter's find | borrows the exports while the compiler runs; the program retains what it imports |
sjs_writer_config.value | borrows it until the writer is destroyed |
sjs_release and sjs_retain never recurse, however deeply a value nests.
Building values. A new array, object, set or map can be added to while you hold its only reference:
sjs_value event, name;
sjs_object_new(context, 2, &event);
sjs_string_new(context, SJS_STR("reading"), &name); /* UTF-8, copied */
sjs_object_put(context, &event, &(sjs_property){ SJS_STR("name"), name });
sjs_object_put(context, &event, &(sjs_property){ SJS_STR("f"), sjs_number(79) });sjs_object_put sets a key as JavaScript does on a fresh object: a key that is already there keeps its place, and keys that are array indices come first. It refuses the key __proto__ with SJS_ERR_INVALID. A set takes primitives only. A map's key is any primitive, compared as SameValueZero. sjs_bigint_new reads a BigInt's text as BigInt(text) does.
Reading values. sjs_length gives the elements of an array, the properties of an object, the members of a set or the entries of a map. sjs_array_at, sjs_object_at (in property order), sjs_object_get (by key), sjs_set_at and sjs_map_at (in insertion order) and sjs_map_get read them. sjs_string_text gives a string's UTF-8. sjs_bigint_text writes a BigInt's decimal digits into an sjs_buffer of at least sjs_bigint_text_bound bytes. A value a program froze or sealed refuses to be added to (SJS_ERR_INVALID), as JavaScript would refuse it.
Writing values as text
The writer writes a value as JavaScript source that evaluates to an equal value. It writes JSON where JSON can say it, and otherwise undefined, NaN, -0, 10n, new Set([...]), new Map([[k, v], ...]), [...].values() for an iterator, and Temporal.PlainDate.from("2024-01-31") or new Date("2024-01-31T00:00:00.000Z"). It fills a buffer of any size and resumes where it stopped:
sjs_writer *writer;
sjs_writer_init(&writer, &(sjs_writer_config){ .context = context, .value = value,
.function_text = SJS_STR("<function>") });
uint8_t bytes[4096];
sjs_result r;
do {
sjs_buffer buffer = { bytes, sizeof bytes, 0 };
r = sjs_writer_write(writer, &buffer);
consume(bytes, buffer.length);
} while (r == SJS_NEED_SPACE);
sjs_writer_destroy(writer);A function has no source to write: without function_text, the writer refuses one with SJS_ERR_INVALID.
Globals
A program reads the host's data through globals: names given to the compiler, and values given for each evaluation, in the same order. Compile once, evaluate with new values each time:
static const sjs_str names[] = { SJS_STR("event"), SJS_STR("limit") };
sjs_compile_params params = {
.compiler = { .context = context, .goal = SJS_GOAL_EXPRESSION, .globals = names, .global_count = 2 },
.source = SJS_STR("event.data.f > limit"),
};
/* ... for each event: */
sjs_value values[] = { event, sjs_number(25) };
sjs_vm_start(vm, &(sjs_run_params){ program, values, 2 });A global is a value like any other. A program cannot change it: a method that would change it faults, as the host holds it too. A function a program makes keeps the globals of the evaluation that made it, as a JavaScript closure keeps its bindings: a function returned as the result and passed to a later evaluation, with other values for those names or none, still reads the values it was made with. Such a function holds those globals, and their values stay alive while it does. A name the program reads that is neither a global, nor declared, nor a built-in is refused when compiled, never at run time.
Native functions
A native function is the one way a program calls into the host: a lookup in the host's tables for a user's plugin, or a tool for a program an agent wrote. Make one with sjs_function_native and pass it as a global:
/* rate(currency): one unit of a currency, in euros, from the host's table. */
static sjs_result rate(void *self, sjs_native_call *call) {
const rate_row *row = self;
if (call->count == 1 && call->arguments[0].kind == SJS_STRING) {
sjs_str code = sjs_string_text(call->arguments[0]);
for (size_t i = 0; i < rate_count; i++) {
if (strlen(row[i].currency) == code.length && memcmp(row[i].currency, code.ptr, code.length) == 0) {
call->result = sjs_number(row[i].euros);
return SJS_OK;
}
}
}
call->fault = SJS_STR("rate() takes a currency the host has a rate for");
return SJS_ERR_PROGRAM; /* faults the evaluation */
}
static const sjs_native_vtable rate_vtable = { rate };
sjs_value rate_fn;
sjs_function_native(context, (sjs_native){ (void *)rates, &rate_vtable }, &rate_fn);A native is called synchronously and must return quickly: fuel does not count its time. It must not change anything a program can see. The library cannot check that rule, so a native that keeps it keeps the program deterministic. Its arguments are borrowed. Never add to one, even one with a single reference. A native that returns SJS_ERR_PROGRAM faults the evaluation: the error's message is "a native function failed", and its subject is the native's fault. SJS_ERR_NOMEM and SJS_ERR_LIMIT fault it as running out of memory or reaching a limit, and any other result as "the evaluation failed".
Modules
A module imports another by name, through a modules adapter you give the compiler. The adapter answers with the exports of a module that has already been compiled and evaluated, so imports cannot form a cycle:
static sjs_result find_module(void *self, sjs_str name, sjs_value *exports) {
struct my_modules *m = self;
/* look `name` up; SJS_ERR_NOT_FOUND when there is none */
*exports = found; /* borrowed: the program retains what it imports */
return SJS_OK;
}
static const sjs_modules_vtable modules_vtable = { find_module };Evaluating a module gives its namespace, which is what find answers for it later. find may also answer with an object you built (sjs_object_new), in any order: the compiler sorts it into a namespace. The exports must be an object, and one with an array-index key or a __proto__ key is refused as unsupported. Any result of find other than SJS_OK, SJS_ERR_NOMEM included, refuses the import as a name error ("there is no module of this name"). A namespace is a value. To call a function a module exports, read it from the namespace (sjs_object_get) and pass it to an expression as a global (celsius(event.data.f) > limit). There is no call from C into a staticjs function: a program calls it.
The clock and the random seed
The library reads no clock and no entropy. Temporal.Now, Date.now() and new Date() read a clock the host pushes into the evaluator, and Math.random draws from a seed the host sets. A program that reads either before it is set faults.
sjs_vm_set_clock(vm, &(sjs_epoch){ .seconds = 1706695200, .nanoseconds = 0 }); /* 2024-01-31T10:00Z */
sjs_vm_advance_clock(vm, 90ull * 60 * 1000000000); /* 90 minutes on */
sjs_vm_set_seed(vm, 7); /* Math.random gives what `node --random-seed=7` gives */The clock does not move while a program runs: two reads with no call between them agree. Both stay set across evaluations, pauses and sjs_vm_reset, so a second evaluation continues the first one's random sequence. Set the seed before each sjs_vm_start to make every evaluation draw the same values. To move a paused evaluation elsewhere, save and restore both with sjs_vm_clock and sjs_vm_set_clock, and sjs_vm_random and sjs_vm_set_random.
Streaming: the parser, the compiler and sinks
sjs_compile is a parser feeding a compiler. Drive them yourself to compile a source as it arrives, or to see its syntax:
sjs_compiler *compiler;
sjs_compiler_init(&compiler, &(sjs_compiler_config){ .context = context, .goal = SJS_GOAL_MODULE });
sjs_parser *parser;
sjs_parser_config pc = { allocator, limits, SJS_GOAL_MODULE, sjs_compiler_sink(compiler) };
sjs_parser_init(&parser, &pc);
sjs_result r = SJS_OK;
while (r == SJS_OK && more) r = sjs_parser_push(parser, next_chunk()); /* any size, down to one byte */
if (r == SJS_OK) r = sjs_parser_finish(parser);
sjs_program *program;
sjs_compiler_finish(compiler, &program);
sjs_parser_destroy(parser);
sjs_compiler_destroy(compiler);The parser takes its allocator and limits from its own config, not from the context: give it the allocator and the limits you gave the context. sjs_compile does so for you. The parser's memory comes from that allocator directly, so sjs_context_live_bytes does not count it.
The parser sends a program to a sink as a stream of events, in evaluation order: operands before their operator, a const's value before its pattern. The compiler is a sink. Your own sink can count, lint or format, and pass each event on to the compiler. Each event names its syntax node (node) and where that node starts (start), so a sink can tell which subtree an event closes without counting events. The header lists every event and its order. When sjs_parser_push or sjs_parser_finish returns SJS_ERR_PROGRAM, the compiler may have refused an event: ask sjs_compiler_error first, then sjs_parser_error.
examples/stream.c pushes a module in 7-byte chunks through a counting sink.
Inspecting an evaluation
A paused evaluation can say where it is, and a compiled program what it holds: for a debugger, a profiler, a step-by-step view, or a host that reports where a program ran out of fuel. Every inspection call reads on demand. An evaluation that nobody inspects pays nothing for them.
/* Where is it? Frame 0 is the program, the last the innermost. */
for (uint32_t i = 0; i < sjs_vm_frame_count(vm); i++) {
sjs_frame frame;
sjs_vm_frame(vm, i, &frame);
if (frame.kind == SJS_FRAME_LOOP) /* map, reduce, Array.from, ... */
printf("%u:%u %.*s, element %u of %u\n", frame.at.line, frame.at.column,
(int)frame.name.length, frame.name.ptr, frame.index, frame.length);
else /* code: the next instruction, or the call it waits on */
printf("%u:%u\n", frame.at.line, frame.at.column);
}- Frames exist while an evaluation is paused, and after a fault, where they show where it faulted, until the next
sjs_vm_startorsjs_vm_reset. A function frame gives the source position of the instruction it runs next, or of the call it waits on. A loop frame gives where its built-in was called, the built-in's name ("map", or"Array"and"from"), and, for a loop that passes over its elements in order, how many it has passed of how many. - Functions:
sjs_program_functiongives each function of a program, its main code first: where its code begins, its parameters, and the shape the compiler tagged it with ("PLUCK"forx => x.key), or none. A callback with a shape runs in C (Performance).
Run on one unit of fuel at a time, and the frames trace an evaluation step by step.
Threads
A context, and everything made with it, is used by one thread at a time. The library has no global mutable state, so separate contexts can run on separate threads with no locking.
Examples
| Example | Shows |
|---|---|
examples/hello.c | the smallest whole program |
examples/eval.c | a command-line evaluator: error reports, fuel, the clock, the seed |
examples/host.c | a library module, a user's plugin compiled once and run per record, globals, a native, a module imported by name, the clock |
examples/stream.c | chunks pushed to the parser, a sink before the compiler, slices of fuel, a small output buffer |
examples/wasm/ | the same from JavaScript, through staticjs.wasm |