Part II · Concepts · Chapter 2
Native functions
The seam between Jsonnet and JavaScript — how to export one, how it is named, what it may be passed, and why it only runs once.
Native functions are what let a manifest ask your cloud provider a question while it is being evaluated, instead of being handed an answer that someone pasted in. They are the only impure thing in a render.
Exporting a function
Every export from a plugin’s entry point other than _spellcraft_metadata becomes a native function. Export it bare, or as an array naming its parameters:
// Names given explicitly. Always correct.
exports.resourceName = [function (name) {
return `${this.environment}-${name}`;
}, 'name'];
// Names inferred from the source. Convenient, but see below.
exports.timestamp = () => new Date().toISOString();
Prefer the array form. Jsonnet calls native functions by parameter name, so the names have to be recovered somehow — and the bare form recovers them by parsing the function’s own source, which cannot work for minified code or destructured parameters. When inference cannot produce a usable name, SpellCraft raises at load time and tells you which export to annotate, rather than registering something that fails later at a call site in someone else’s manifest.
Namespacing
Functions register as <package-name>:<export>, so two plugins can both export client without colliding. Your module.libsonnet must therefore use the fully qualified name:
{
resourceName(name):: std.native("@you/your-plugin:resourceName")(name),
}
This is the single most common mistake when writing a first plugin. std.native("resourceName") resolves to nothing, and Jsonnet reports it as only functions can be called, got null — pointing at your libsonnet rather than at the missing prefix. The module generator interpolates your package name into the template so a new plugin starts out correct.
Jsonnet will not pass an object or an array into a native function — it raises “native extensions can only take primitives”. Serialise on the way in and parse inside the function.
The idiomatic workaround, used throughout the official plugins:
{
api(clientObj, method, params={}):: std.native("@c6fc/spellcraft-plugins:aws.auth.aws")(
std.manifestJsonEx(clientObj, ''),
method,
std.manifestJsonEx(params, ''),
),
}
exports.aws = [async function (client, method, params) {
client = JSON.parse(client);
params = JSON.parse(params);
// ...
}, 'client', 'method', 'params'];
Return values carry no such restriction: a native function may return any JSON-serialisable structure, and usually does.
The emphasis is on serialisable, and SpellCraft checks it on the way out, because the layer underneath does not: a cyclic object crashes the process outright, and a NaN or an Infinity is written into the bridge’s output as nan/inf, surfacing later as a JSON parse error that names neither the native nor the number. All three — a cycle, a non-finite number anywhere inside the result, and a BigInt — are refused instead, naming the native that produced the value. None of them is an exotic thing for ordinary code to return: 0/0, a byte count from a database driver, an SDK response that points back at itself.
Asynchrony
A native function may be async. Jsonnet’s evaluation waits for the promise, so from the manifest’s point of view an API call is an ordinary expression:
local aws = (import "@c6fc/spellcraft-plugins/module.libsonnet").aws.auth;
{ "account.json": aws.getCallerIdentity() }
There is no await in Jsonnet and none is needed.
Memoisation
Results are cached per (name, arguments) for the life of a render. Calling getCallerIdentity() in forty places costs one API call, and a timestamp taken twice within a render agrees with itself.
This has a consequence worth internalising: side effects fire once. A native function that creates a bucket will not create it again later in the same render, even from a different call site. If you need work to happen on a schedule rather than on demand, that is a signal it belongs in an init hook or a lifecycle event instead of a native function.
Reaching shared context
Inside a native function, this is the shared function context — everything every loaded plugin contributed through functionContext, plus spellframe, the frame itself.
exports.whereAmI = [function () {
return this.spellframe.activePath;
}];
Use an ordinary function () {} declaration to reach it. An arrow function captures this from module scope, where it is empty — a mistake that fails silently, returning undefined rather than raising.