Host API in Depth​

The quickstart covers what a host needs on its first day. This page is the rest of the engine's host API, one topic per section: registering functions and the direct entry for hot ones, live bindings to C++ variables and exactly what writes through them, running files, per-execution variables, calling script and reading its state back, every error surface, the clock scripts read, and how many threads a parallel region runs on.

Registering C++ functions​

C++
// Typed: signature drives conversion and overload resolution
eng->add_function("square", [](jai::script_int n) { return n * n; });
eng->execute("square(12)").as_int();      // 144

// Variadic: you receive the raw argument vector
eng->add_variadic_function("sum_all", [&eng](const std::vector<jai::script_value>& args) {
    jai::script_int total = 0;
    for (auto& a : args) total += a.as_int();
    return jai::script_value(total, eng.get());
});
eng->execute("sum_all(1, 2, 3, 4)").as_int();   // 10

Registering the same name twice with different signatures creates an overload set resolved by argument types at the call site. Use add_variadic_function for print-shaped functions and optional parameters.

A direct entry for a hot host function​

A typed add_function binding over plain values already has a fast call path. A variadic function goes through its std::function with an argument vector on every call. For one that scripts call in tight loops, give the engine an entry over the arguments where they are: add_host_direct(name, arity, invoke, target). A VM call site whose callee is the function the name binds then calls invoke(target, args, arity) for a call of exactly arity arguments, none of them a reference; an arity of jai::engine::host_direct_any_arity gives a variadic function's entry, which takes any call of 1 to 8 arguments. Every other call (another argument count, a reference argument, the interpreter, a name the host registers again or a script rebinds) calls the function, so invoke must return what the function returns for those arguments. Sharing one body between the two keeps them equal:

C++
// one body for both: the function and its direct entry
static jai::checked_result<jai::script_value> label_of(const void* target, const jai::script_value* args, size_t count) {
    auto* eng = static_cast<jai::engine*>(const_cast<void*>(target));
    if (count != 1) return jai::script_value(std::string("?"), eng);
    return jai::script_value(std::string(args[0].is_int() ? "int" : args[0].is_float() ? "float" : "other"), eng);
}

eng->add_variadic_function("label", [e = eng.get()](const std::vector<jai::script_value>& args) {
    return label_of(e, args.data(), args.size());
});
eng->add_host_direct("label", 1, &label_of, eng.get());   // after the function: it keys on the function "label" binds now
eng->execute("label(2.5)").as_string();                  // "float"

A typed binding takes the fast path whenever no parameter is a non-const reference to a value (a T& that writes back): const T& parameters take it too, and a const std::string&, a bound object's T& or const T&, a const std::vector<jai::script_value>& and a const jai::script_value& are the script's own values, never copies. A static method a dynamic_binder binds converts exactly as add_function does.

More registration and inspection calls​

CallWhat it does
add_function_with_arity(name, fn, arity)Registers a raw jai::script_function (it receives the argument vector) that takes exactly arity arguments; SIZE_MAX takes any count.
add_overloaded_function(name, arg_count, fn)Adds a raw script_function of arg_count arguments to name's overload set, creating the set on the first call.
add_custom_conversion<T>(from, to)Teaches the engine to cross a C++ T it has no binding for: from(const script_value&) -> T and to(const T&) -> script_value are what the engine uses wherever it converts a T.
add_vector_conversion<T>(), add_map_conversion<K, V>()Registers the std::vector<T> or std::map<K, V> conversion ahead of the first binding that names it (bindings register the shapes they name by themselves).
registered_class_names()Every class the engine can name, host-bound and script-declared, sorted.
has_registered_class<T>()Whether a dynamic_binder bound the C++ type T on this engine.
get_backend_type(), get_backend_name()The engine's backend (jai::backend_type::vm unless set_backend chose another) and its name.
text_of(v)A value's text as print and string + v spell it, a class's own to_string() included; a failing to_string()'s error is the result.
app_data_dir(app), app_cache_dir(app), engine::executable_path()The platform's per-user data and cache folders for an app called app (what the script builtins answer, through the resolvers app_directory_resolvers(data, cache) installs; they need the filesystem privilege), and the running executable in generic spelling (empty when the platform cannot say). Queries only: nothing is created.

Every script_value you construct yourself needs the engine pointer: jai::script_value(3.14159, eng.get()), or use the convenience factories eng->make_value(v) / eng->make_null() / eng->make_array() / eng->make_map().

Globals: copies vs live bindings​

C++
eng->add_global("PI", jai::script_value(3.14159, eng.get()));   // copied in

int hp = 100;
eng->add_global_ref("hp", hp);                    // a live binding: no copy
eng->execute("hp = hp - 25;");
std::cout << "hp after script damage: " << hp << "\n";
hp = 1;                                           // and the script sees C++ writes
std::cout << "script sees: " << eng->execute("hp").as_int() << "\n";
output
hp after script damage: 75
script sees: 1

add_global_ref on a primitive (int8..64, unsigned variants, float, double, bool, char, std::string) creates a cpp_bound value: a typed pointer box that reads the live C++ variable and writes through to it. For non-primitive types it falls back to a non-owning aliasing shared_ptr. The caller must guarantee the object outlives the engine (documented in the header, engine.hpp add_global_ref).

What write-through covers​

The write-through matrix holds on both backends (the interpreter and the VM, chapter 3); the test suite holds them to it:

  • Name-level assignment and compound assignment write through: hp = hp + 50;, hp += 7;, ++hp, hp-- all mutate the C++ variable, at every width: int8 through uint64, float/double, and std::string +=.
  • A reference to the name aliases the C++ variable: after auto& r = hp; (or int& r = hp;), r = 5, r += 1, ++r, and a script function taking int& that writes (bump(hp)) all store into C++ through the bound type's conversion, and the name stays bound, so later writes from either side are seen by the other. A field of a bound object (auto& h = player.hp;) aliases the C++ member the same way.
  • Reads are always live: change the C++ variable between executes and the script sees the new value.
  • Mixed conversions keep the binding: boundInt += 2.5 converts (truncates) and still writes through.
  • A bound char is a width-1 bound int. It reads as its code point.

Where the binding deliberately drops (detach rules)​

  • clone() of a bound primitive detaches. It snapshots the value. A plain copy of the script_value stays bound.
  • Every store detaches. Storing a bound value into an array element, map slot, or object member, whether by literal construction ([hp], {"k": hp}), assignment, push, or an implicit-this member store, snapshots the value at store time. Write-through belongs to the registered name (and references/captures/by-ref params bound to it), never to stored copies.

The exact-width as<T&> aliasing rules​

A non-const as<T&> on a bound value gives you a live reference into the C++ variable (zero-copy, reads and writes are real), but only when the binding's width and signedness exactly match the script-side type:

as<T&> requestaliases live when the bound C++ type is
as<jai::script_int&>exactly int64_t (or script_int)
as<jai::script_float&>exactly double
as<std::string&>std::string
as<jai::script_bool&>bool

Anything else (an int32_t behind as<script_int&>, a float behind as<script_float&>) throws a catchable jai::runtime_error, never std::bad_variant_access, and never silently reads the wrong bytes.

C++
jai::script_int gold = 10;
eng->add_global_ref("gold", gold);
auto v = eng->execute("gold");                    // a bound alias, not a snapshot
v.as<jai::script_int&>() += 5;
std::cout << "gold after as<script_int&> += 5: " << gold << "\n";

int32_t narrow = 5;
eng->add_global_ref("narrow", narrow);
try {
    eng->execute("narrow").as<jai::script_int&>();   // an int32_t is not a script_int
} catch (const jai::runtime_error& e) {
    std::cout << "int32 as<script_int&>: " << e.what() << "\n";
}
eng->execute("narrow += 7;");                     // by name every width works
std::cout << "narrow after script += 7: " << narrow << "\n";
output
gold after as<script_int&> += 5: 15
int32 as<script_int&>: cannot bind a non-const script reference to a C++-bound variable of a different width
narrow after script += 7: 12

The same rules apply to non-const T& parameters of add_function bindings: a bound argument of exactly-matching width aliases the live variable (the callee's writes land in C++). A plain script argument works as in C++: the callee gets a copy of the value the argument names, and when it returns (or throws) its final value is stored back into that argument with the store a script = makes, so the variable's declared type converts or rejects it with the usual text.

C++
eng->add_function("heal", [](int64_t& hp, int64_t amount) { hp = std::min<int64_t>(hp + amount, 100); });
eng->add_function("tag", [](std::string& name) { name += " (healed)"; });
jaiscript (with the host above)
int hp = 40;
heal(hp, 25);             // hp is 65
var party = [10, 20];
heal(party[1], 90);       // party[1] is 100
tag(player.name);         // a field, a map value, a C++ property (through its setter), v.x of a vector
heal(hp + 1, 5);          // a temporary: the callee works on a copy, nothing is stored back

Every value kind writes back the same way: integers, floats, bool, std::string, std::vector, std::map, jai::script_value, std::shared_ptr<T>, enums and the host types a math adapter binds. A registered class taken as T& is the live object itself and needs no store. Any lvalue a script T& parameter binds is written back (a variable, a field, an element, a map value, a static, a reference, a C++ property) and so is a math component (v.x); a literal or a temporary is passed as a copy and nothing is stored. A const T& parameter is a read and costs nothing extra, and a binding with no non-const T& parameter has no write-back step: the choice is made when the function is bound.

Running a script file​

C++
eng->add_include_path("scripts");           // where import/include look after the file's own folder
eng->execute_file("scripts/game.jai");      // parse (or load scripts/.cache/game.jai.jaibite), check, run

execute_file attributes every node to the file's canonical path, whatever spelling you pass: check listings, stack traces and debugger breakpoints all name it. It keeps a parsed copy in a .cache folder beside the source (game.jai gets .cache/game.jai.jaibite); add .cache/ to your .gitignore, send the caches elsewhere with eng->jaibite_cache_directory(dir, root) when the scripts are read-only (jaibite), or turn the cache off with eng->jaibite_cache(false). Functions, classes and globals the file declares stay in the engine for later calls.

Per-execution variables​

execute(source, instance_variables) and execute_file(path, instance_variables) run one execution with extra names in scope. jai::instance_variables is a std::unordered_map<std::string, jai::script_value>; a non-empty map becomes a scope over the globals for that one execution and is discarded when it returns.

C++
eng->execute("var counter = 0;");                        // no overlay: a global

jai::instance_variables locals{{"increment", eng->make_value(int64_t{5})}};
eng->execute("counter = counter + increment;", locals);  // reads the overlay, writes the global
eng->execute("var temp = 1;", locals);                   // temp is declared in the overlay

eng->execute("counter").as_int();                        // 5
eng->execute("increment");                               // raises: the overlay is gone
eng->execute("temp");                                    // raises: temp died with the overlay
  • It lasts one execute. The script reads the supplied names; one shadows a global of the same name for that execution only. Assigning to an existing global still writes the global.
  • Declarations land in the overlay. With a non-empty map, a top-level var x = 10; is declared in the overlay and disappears with it; nothing is copied back to the globals. An empty map is the plain execute(source), whose top-level declarations are globals. The checker treats the supplied names as declared.
  • It is not dynamic scope. A function resolves its free names through the scope it was created in, never its caller's. A function defined by an earlier execute does not see a later execute's overlay; only code from the same execute does.
  • Signal receivers use it. A script-text receiver on a jai::signal runs this way on every emit: the emit's arguments become an overlay named by the signal's parameter_names, so they never leak into the globals (Connecting from script).

Calling script from C++​

A script function is a value. Look it up by name and call it with a vector of arguments; the result is a checked_result, not an exception:

jaiscript
int damage_for(int level) { return level * 3; }
C++
// examples/embed_quickstart makes these calls
jai::script_value fn = eng->get_variable("damage_for");
jai::checked_result<jai::script_value> r = fn.as_function()({eng->make_value(int64_t{4})});
if (!r) { throw std::runtime_error(eng->format_error(r)); }   // "Division by zero", ...
int64_t dmg = r.value().as_int();                                  // 12
  • Errors. Every failure comes back in the result: a run-time error, a script throw nothing caught (its text), an import the call reaches whose file is missing or whose edited module fails its check (Hot Reload). eng->format_error(r) is the text and eng->format_stack_trace() the script stack. The call never throws for what the script did, and the engine is ready for the next call, so a result check is all a frame loop needs (Error surfaces lists which calls report and which throw).
  • The budget. A call made while no script runs is a host entry: it gets a fresh execution-budget deadline, like execute() (Safety Rails).
  • external_call_guard. Needed only when the call can happen while a script is running: from inside a host function the script called, or from a signal the script emitted. Construct jai::engine::external_call_guard guard(eng.get()); around the call so reference parameters do not bind against the script call in flight. A call from your own loop needs nothing; the guard is harmless there.
  • A typed wrapper. auto damage = jai::convert_script_function<int64_t(int64_t)>(fn); gives a std::function for callbacks. It is the throwing flavor: every failure inside it (a run-time error, a script throw nothing caught, a failed import) leaves the call as a jai::runtime_error carrying the text (Division by zero); a host function that calls it from script hands that error back to the script as its own. A null value converts to an empty std::function (test it with if (cb); calling it throws std::bad_function_call), and any other value that is not a function makes the conversion throw.

Reading script state back​

Values die with their engine. A plain script_value that C++ receives (a bound function's argument, a signal's argument, a value read out of a container, a copy of a held value) is a borrow, valid for the call like a C++ reference parameter, and costs nothing to pass. execute, execute_file, get_variable and eng->hold(v) return a jai::held_value: a script_value the host may keep until its engine is destroyed. It never keeps the engine alive. When the engine is destroyed it first releases every held payload while it still runs script (a held instance's destructor runs then); afterwards the held value is outlived: valid() is false, get_engine() is null, every read (as<T>(), as_int(), to_string() and the other accessors) throws jai::runtime_error with Value outlived its engine., and copying, assigning or destroying it is safe. A value belongs to its engine's thread. Another thread may copy, read, assign or drop one only while the engine is idle, with the hand-off synchronized as for any C++ object (a join, a mutex, a queue the engine's thread drains); destroying the engine while another thread uses one of its values is a race. Debug builds assert when an object dies on another thread while its engine runs.

Values cross engines by copy. A value of another engine passed to add_global, make_value, execute's instance variables or a script function's arguments (a convert_script_function wrapper) enters as this engine's own: numbers, strings and math values copy, and arrays and maps convert element by element, so the two engines never share a container. An object, a function or a reference into the other engine throws Cannot pass an object from another engine: values cross engines by copy (numbers, strings, math values and arrays and maps of them). (with a function or a reference in place of an object), and an outlived held value throws Value outlived its engine.

get_variable(name) reads a global; as<T>() converts it. How a container comes out follows its declared type: array<int>, array<string> and the other typed arrays are raw buffers that copy out as a std::vector, and only array<var> has a script_value vector to view:

jaiscript
int score = 7;
array<string> log = ["spawned", "hit"];
map<string, int> kills = {"orc": 3, "troll": 1};
array<var> loot = [12, "rope", 2.5];
C++
// examples/embed_quickstart makes these calls
int64_t score = eng->get_variable("score").as_int();
auto log   = eng->get_variable("log").as<std::vector<std::string>>();              // a typed array copies out
auto kills = eng->get_variable("kills").as<std::map<std::string, int64_t>>();

jai::script_value loot = eng->get_variable("loot");   // hold the value: as_array() views into it
const std::vector<jai::script_value>& items = loot.as_array();       // array<var> only

jai::script_value log_value = eng->get_variable("log");
log_value.unchecked_array_node()->for_each(eng.get(), [](const jai::script_value& line) {
    std::cout << line.as_string() << "\n";                         // any array kind, no copy of the buffer
});

as_array() on a typed array raises an error that names the type and the extraction that works (array<string> is a typed buffer: ... extract as<std::vector<std::string>>). unchecked_array_node() requires is_array(). A bound C++ object comes back with as<std::shared_ptr<Monster>>(), sharing the live object; a host function that takes bound_array<int64_t>& mutates a typed array in place (core/bound_array.hpp). Writing back is add_global(name, value), or an execute that assigns.

Error surfaces​

Each way of calling into script has one failure route. A call that returns a checked_result reports every failure in the result and never throws for what the script did; a call that returns a plain value throws for every failure. Which flavor a call is never depends on what went wrong:

CallA failure
execute(source), execute(source, instance_variables), execute(jaibite&), execute_file(path), execute_file_source, jaibite(source)throws
a std::function from convert_script_function<Sig>(fn), or the std::function parameter of a bound host functionthrows (jai::runtime_error)
fn.as_function()(args) on a script function, a lambda, a static method or a reflect::bind valuein the checked_result
coroutine_handle::next(engine*, n)in the checked_result
extend_checked(...) / extend(...)in the result / throws
a script receiver of a C++ signal (a function, a lambda or a source snippet)throws out of the emit (the text a direct call of the receiver throws); the receivers after it do not run

The failures covered are the same for both flavors: a run-time error, a script throw nothing caught, a parse or check error, an import whose file is missing or whose edited module fails its check. A call made from inside running script (a host function the script called, calling back into script) is part of that script's run: its failure travels on to the script, which can catch it. A signal emit is such a call: a receiver's failure leaves the emit, through every emit it is nested in, to the C++ code or the script that fired the signal.

Checking is strict, so the first surface you meet is a load error: the script is rejected before a single statement runs.

what() is the whole accumulated listing, compiler style: every error in the source, not just the first:

C++
try {
    eng->execute("int a = missing_one; int b = missing_two;");
} catch (const jai::static_check_error& e) {
    std::cout << e.what();                        // every error in the source, compiler style
}
output
static check failed:
<script>:1:9: error: use of undeclared identifier 'missing_one'
    int a = missing_one; int b = missing_two;
            ^
<script>:1:30: error: use of undeclared identifier 'missing_two'
    int a = missing_one; int b = missing_two;
                                 ^

Names your host injects at run time aren't in the script's text, so the checker cannot see them. extern declares one without defining it, and the same script becomes a runtime error instead, catchable and raised only if control flow actually reaches the name:

C++
eng->execute("extern function spawn_wave; spawn_wave(3);");
// throws jai::script_exception: "'spawn_wave' is declared extern but never defined"

(what() is the error's text, the same text a script catch sees.)

The other two surfaces are unchanged by checking, and the engine survives all of them:

C++
eng->execute("throw \"from script\";"); // uncaught script throw -> jai::script_exception: "from script"
eng->execute("var x = ;");              // throws jai::parse_error:
                                        //   "parse failed:\n<script>:1:9: error: expected expression\n..."
eng->execute("40 + 2").as_int();        // 42

Every one of them derives from std::exception, so one catch (const std::exception&) is a valid backstop (put one around the host's own script calls: an exception that leaves main ends the process with its buffered output unflushed): jai::static_check_error for load-time type and name errors, jai::parse_error for syntax, and jai::script_exception for anything raised while running, an uncaught script throw included (it derives from jai::runtime_error). jai::parse_error and jai::runtime_error share the base jai::exception, which carries a location(); jai::static_check_error derives from std::runtime_error directly. Its listing is the location. eng->format_stack_trace() / eng->last_stack_trace() give the script stack of the most recent failure. For a throw two calls deep:

C++
try {
    eng->execute("function inner() { throw \"boom\"; }\n"
                 "function outer() { inner(); }\n"
                 "outer();");
} catch (const jai::script_exception& e) {
    std::cout << e.what() << "\n" << eng->format_stack_trace();
}
output
boom
  at inner (<script>:1)
  at outer (<script>:2)
  at <script> (<script>:3)

For errors that surface where nothing can catch them (a destructor that fails while the engine tears down its program-scope variables), install a sink with eng->set_script_error_handler(...). The default sink is std::cerr (engine.hpp, set_script_error_handler).

One subtlety worth knowing: some recoverable parse errors make the parser synchronize and return null instead of throwing, so the robust contract to code against is "execute() returns and the engine stays usable", not "every bad input throws".

Constant arguments​

Some arguments must never come from data: the SQL text of a query, a shell command, a template that is itself code. engine::constant_argument(callable, index, refusal) declares that argument index of a host function ("run_sql") or of a bound method ("Database.query") only the program's own constants may fill. A constant is one of these forms: a string literal; a const variable whose initializer is a constant; a ternary whose arms are constants (the condition is free); a + of constants; an array literal of them. A template string with a splice is not one, nor is anything read, computed or passed in.

C++
engine* raw = eng.get();
eng->add_function("run_sql", [raw](const std::string& sql, const jai::script_value& value) -> std::string {
    if (!raw->constant_argument_admits("run_sql", 0, sql)) {
        throw std::runtime_error("SQL text must be a constant; pass values as parameters (?)");
    }
    return execute_with_parameter(sql, value);      // the text is the program's, the value only binds
});
eng->constant_argument("run_sql", 0, "SQL text must be a constant; pass values as parameters (?)");   // before any script loads
jaiscript (with the host above)
const string FIND = "SELECT * FROM posts WHERE id = ?";
run_sql(FIND, id);                                            // a const
run_sql(newest ? "SELECT * FROM posts ORDER BY id DESC" : "SELECT * FROM posts", 0);
run_sql("SELECT * FROM posts WHERE title = '" + title + "'", 0);   // a load error

The guarantee has two halves. At load the checker refuses any other argument, with the location and the host's refusal: argument 1 of 'run_sql' is not a constant: SQL text must be a constant; pass values as parameters (?); for a method it does so wherever it knows the receiver's class. At run time nothing depends on the checker: as every unit loads (in any check mode, a .jaibite included) the engine gathers the constant texts its calls pass to the declared argument, and constant_argument_admits(callable, index, text) answers whether a text is one of them. A text the program does not spell, however it arrived (a var path, a function value, reflect::invoke), is refused; a text equal to one of the program's constants is that constant, harmless by construction. The host raises its refusal when the answer is no. Declare constant arguments before any script loads: a unit that loaded earlier was never gathered, so its texts are refused.

The time source​

Every time:: call a script makes (Time in the language guide) reads one engine-owned clock, and the host can replace it. Three providers make up the source: monotonic (a steady reading, the clock behind time::now(), time::engine_elapsed(), time::execute_elapsed() and every stopwatch), wall (nanoseconds since the Unix epoch in UTC, behind time::unix_time() and every date) and sleep (what a sleep that blocks does: the builtin outside a suspendable run). An empty provider selects the system one: steady_clock, system_clock, this_thread::sleep_for.

The source lives on eng->time(), declared in <jaiscript/core/engine_time.hpp> (its own header because it brings in <chrono>, which a host that never touches the clock does not need to parse). A virtual clock is the whole integration for tests, replays, netcode and deterministic simulation. The script sees exactly the time the host says it is:

C++
std::chrono::nanoseconds sim_now{0};
const auto epoch = std::chrono::nanoseconds(1700000000000000000LL);   // 2023-11-14T22:13:20Z
eng->time().source(
    [&] { return sim_now; },                                          // monotonic
    [&] { return epoch + sim_now; },                                  // wall
    [&](std::chrono::nanoseconds d) { sim_now += d; });              // sleep advances the simulation
sim_now += std::chrono::milliseconds(1500);
eng->execute("print(time::now(), ' ', time::utc_now().to_string());");
output
1.5 2023-11-14T22:13:21.500Z

The engine reads its providers on a strict budget. Each time:: call reads the provider it needs once, and only when it is called; constructing a stopwatch or a date reads nothing. Outside those calls the engine reads the monotonic provider in exactly one place: at a host entry (an execute()/execute_file()/jaibite run, or a script function or coroutine the host invokes while no script is running), and only after a loaded program names time::execute_elapsed, whose answer is measured from that reading. Nested executes, includes and imports belong to the entry that runs them. A stateful provider (a replay feeding recorded readings) therefore sees the same sequence of reads on every run of the same program.

The execution budget and the import check window run on the real steady clock whatever the source, so a virtual clock can neither stall nor trip them. The engine keeps its own sample of that clock, read at each host entry (the reading the budget deadline starts from, and time::execute_elapsed too while the source is the system clock) and at the budget check's periodic samples. While the monotonic provider is the system one, a time:: read and a sleep's wake refresh the sample as well; a host provider never does.

time::engine_elapsed() counts from the system clock's reading when the engine was constructed; on a host monotonic provider it is that provider's reading, so the host decides where zero is. eng->time().source({}, {}, {}) restores the system clocks. The host reads the same clock scripts do through eng->time().monotonic_now(), wall_now() and sleep_for(duration), and eng->engine_elapsed() and execute_elapsed(). A parallel body cannot call time:: at all (the clock would make it differ from its serial run), so providers are only ever called on the engine's thread.

The HTTP transport​

The spider:: client (spider::catch, sling, get, post and spider::Response) is opt-in, and it sends nothing by itself: the host supplies the HTTP stack, the way it can supply the clock. Three calls make a working client:

C++
#include <jaiscript/stdlib/spider.hpp>
#include <jaiscript/net/http_socket_transport.hpp>   // or your own jai::http_transport

jai::stdlib::register_spider(*eng);                  // the functions, spider::Response, the "network" permission
eng->http_transport(std::make_shared<jai::net::http_socket_transport>());
eng->enable("network");                              // registered disabled: the host grants it

A transport implements one call of jai::http_transport (<jaiscript/core/http_transport.hpp>): http_reply exchange(const http_request&). The request carries the method, an absolute http or https URL, the headers, the body and the seconds left for the call; the reply is the status, every header line as it arrived, the decoded body, or an error text when the exchange failed ("timed out" when the time ran out, which the client reports as timed out after N s). A transport follows no redirect: the library applies the options, the redirect policy and the error texts above it, so every transport behaves alike, and an exception thrown out of exchange is reported like an error text. exchange runs on the script's thread and blocks it until the answer is complete. Without a transport, a call raises spider::catch: no HTTP transport installed: the host provides one with engine::http_transport(); without the permission, Insufficient privilege: network.

Two transports ship as opt-in headers that nothing in the library includes, so a host that never sends HTTP compiles no socket:

  • <jaiscript/net/http_socket_transport.hpp>: jai::net::http_socket_transport, HTTP/1.1 over Winsock or POSIX sockets, one connection per call, bodies by Content-Length, chunked or the close, every wait bounded by the timeout (name resolution excepted). An https URL needs a jai::net::tls_layer passed to its constructor; without one it fails with HTTPS needs a transport with TLS: this one speaks plain HTTP only.
  • <jaiscript/net/openssl_tls.hpp>: jai::net::openssl_tls, that layer on OpenSSL 1.1.1 or 3.x (TLS 1.2 and up, SNI, the chain and the host name verified). Its constructor takes the trust setup, a function of the SSL_CTX*; without one it uses OpenSSL's default certificate paths. Include it only where OpenSSL is linked.

What each host installs:

Host and platformTransportHTTPS
runner, jai_spider on WindowsWinHTTP (tools/jaiscript/http_transport.hpp)yes: the system's TLS and certificate store
runner, jai_spider on Linux and macOS, built with OpenSSL foundsockets with openssl_tlsyes: OpenSSL's default certificate paths
runner, jai_spider on Linux and macOS, built without OpenSSLsocketsno: an https URL raises the plain-HTTP error above
a host that links OpenSSL, on any platform including Android and iOSsockets with openssl_tls, its trust setup filling the store from the platform (the Windows ROOT store, SecTrust on Apple platforms, /system/etc/security/cacerts on Android)yes

On Windows, JAISCRIPT_HTTP_TRANSPORT=socket in the environment makes the runner and jai_spider install the socket transport instead (plain HTTP only there), which is how the gate runs the socket code on Windows too.

Suspendable runs​

execute() runs a program to its end. A program that waits (a cutscene, an AI's routine, a level's waves) runs as a run instead: eng->start(...) runs it until a sleep (sleep in the language guide) and hands back a jai::script_run, and the host resumes the run when the time has passed, from the line after that sleep. The type is declared in <jaiscript/core/script_run.hpp> (it brings in <chrono>). The script reads the same time source the host drives:

C++
std::chrono::nanoseconds now{0};
eng->time().source([&] { return now; }, {}, {});
jai::script_run run = eng->start(R"(
    for (int wave = 1; wave <= 3; ++wave) {
        print("wave {} at {}", wave, time::now());
        sleep(2.0);
    }
    "cleared";
)");
while (!run.done()) {
    now = run.wake_at();   // a game moves its clock each frame and resumes; this jumps to the wake time
    run.resume();
}
std::cout << run.result().value().to_string() << "\n";
output
wave 1 at 0.0
wave 2 at 2.0
wave 3 at 4.0
cleared
CallDoes
eng->start(source) start_file(path) start_file_source(path, content) start(bite) start(callable, args)Starts a run and runs it now, to its first sleep or its end. The run is what execute, execute_file, execute_file_source, a jaibite's run or a call of the function value would be; a jaibite must outlive its run.
run.state() run.done()script_run::status::suspended, completed or failed; done() is either of the last two.
run.wake_at()When the sleep that suspended the run ends, as a reading of the time source's monotonic clock; zero once the run is done.
run.resume()Continues the run to its next sleep or its end and returns its state. Before wake_at() it returns suspended at once, having read the clock and switched nothing, so a host may call it every frame, or read wake_at() and call it when due.
run.cancel()Ends a suspended run: its sleep raises the terminal Run cancelled, so no catch runs, every frame unwinds and its destructors run, and the run is failed.
run.result()The run's value once completed, its error once failed (eng->format_error(run.result()) spells it).
~script_run()Cancels a run still suspended. script_run is move-only.
  • Failure. A run from source, a file or a jaibite fails as execute() does: the start or resume() that ends it throws the same exception, the run is then failed with its text in result(), and eng->format_stack_trace() lists its frames. A callable's run reports its failure through result() alone, as calling the function value would.
  • Runs are independent. Several runs can wait at once and resume in any order, and the host's own executes, calls and other runs go on between them: each run keeps its frames, its locals, its host entry, its budget and its debugger step. A host function a run calls may start a run of its own, which suspends back into that host function.
  • Each resume is a host entry. It starts with a fresh execution budget, memory allowance and step budget, and time asleep never counts against them. time::execute_elapsed() counts from the start of the run.
  • Where a run suspends. Only where script alone is between the host and the sleep. A sleep reached through native code (a host function's callback, a destructor, a signal receiver, an include or import's file, hot_reload_migrate, a builtin's callback) blocks on the time source's sleep provider, and the run reports one warning through set_script_warning_handler. execute() and a host's call of a function value never suspend: their sleep blocks.
  • One thread. A run resumes only on the thread that started it: resume() or cancel() on another throws jai::runtime_error A run resumes only on the thread that started it. Resuming or cancelling a run from code that run itself is running throws The run is running: it resumes only while suspended; a moved-from script_run throws The script_run holds no run from resume(), cancel() and result(), and start of a value that is not a function throws Cannot start a run of a value that is not a function.
  • Native stacks. Each run executes on a native fiber of its own, whose stack reserves what the starting thread's stack reserves and commits pages as it grows behind a guard page, so the native-stack guard stops a deep recursion at the depth it would on that thread. C++ exceptions never cross a fiber's edge: one the run raises is carried out and thrown on the host's side. Fibers and run state are pooled: once warm, start allocates nothing.
  • Teardown. Destroying the engine cancels every run still suspended; a script_run that outlives its engine stays done.
  • The debugger. A breakpoint in a run stops it as it would any script, and the host's resume continues it. A suspended run is not a stop, and a step over a sleep ends at the run's next statement once it resumes, never in another entry's code.

Parallel worker count​

A parallel region (Parallel Loops) runs on at most eng->parallel_thread_count() threads, the calling thread among them. The default is jai::thread_pool::performance_core_count(), at most std::thread::hardware_concurrency() - 1 and at least 1, because a region waits for its slowest chunk. eng->parallel_thread_count(n) sets an exact count; 0 restores the default. Results never depend on it.

thread_pool::performance_core_count() (<jaiscript/thread_pool.hpp>) counts the cores of the fastest class the platform reports: on Windows the physical cores at the highest efficiency class, on Apple platforms hw.perflevel0.physicalcpu, on Linux the physical cores (distinct core_ids) among the CPUs an Intel hybrid chip lists in /sys/devices/cpu_core/cpus, or among every online CPU on any other machine. Anywhere else, or when the query fails, it answers std::thread::hardware_concurrency(). The answer is between 1 and hardware_concurrency(), detected on the first call and stored.

The engine builds its pool with jai::thread_pool pool{count, jai::thread_pool::fast_threads}: each worker asks the system never to throttle it to save power (EcoQoS on Windows, user-initiated QoS on Apple platforms), since a region finishes with its slowest worker. A pool built without the flag leaves its threads as the system schedules them.

Practical notes​

  • Reuse engines. Engine creation plus first-execute lazy init costs on the order of a hundred warm script executions, so an engine-per-evaluation architecture pays mostly overhead. Costs are quantified in Performance.
  • execute() re-parses only novel strings. A 64-entry LRU source cache makes repeated execute(same_string) skip the parse (and on the VM, the compile). For hot scripts use a jaibite (see the jaibite chapter).
  • eng->set_output_stream(stream) redirects print(); pass nullptr to restore std::cout.
  • Include paths for script include/import: eng->add_include_path(dir).
  • Checking is always strict. Porting a loosely typed codebase? [[warn_mode]]; at the top of a file collects its diagnostics as warnings instead of gating, and the attribute does the same for one block or statement; extern declares the names you inject at run time. Full story in Static Checking.