Experimental APIs
The @scriptc/experimental package holds APIs that are still being designed. Each export has a Node.js implementation, so a program that uses them runs unchanged under Node.js, and scriptc compiles them into the program. Their behavior may change between releases.
Add @scriptc/experimental to your project's dependencies.
LifetimeRegion
Instances of ProgramLifetime classes are never freed, which suits a program that builds one syntax tree or symbol table and exits. A long-running program, such as a compiler in watch mode, builds a new generation of these objects on every rebuild. A LifetimeRegion lets it free each generation once it is no longer needed.
import type { ProgramLifetime } from "@scriptc/types";
import { LifetimeRegion } from "@scriptc/experimental";
class SyntaxNode implements ProgramLifetime {
readonly children: SyntaxNode[] = [];
parent: SyntaxNode | undefined = undefined;
}
function parse(depth: number): SyntaxNode {
const node = new SyntaxNode();
for (let i = 0; depth > 0 && i < 4; i++) {
const child = parse(depth - 1);
child.parent = node;
node.children.push(child);
}
return node;
}
function count(node: SyntaxNode): number {
return node.children.reduce((sum, child) => sum + count(child), 1);
}
let previous: LifetimeRegion | undefined;
for (let build = 1; build <= 3; build++) {
const region = new LifetimeRegion();
const tree = region.run(() => parse(8));
console.log(`build ${build}: ${count(tree)} nodes`);
// The previous build's tree is no longer referenced.
previous?.release();
previous = region;
}Under Node.js, run calls its callback and release does nothing:
$ node rebuild.ts
build 1: 87381 nodes
build 2: 87381 nodes
build 3: 87381 nodesThe scriptc executable prints the same. With SCRIPTC_REGION_STATS=1 it also reports each region it frees:
$ scriptc build rebuild.ts -o rebuild >/dev/null
$ SCRIPTC_REGION_STATS=1 ./rebuild
build 1: 87381 nodes
build 2: 87381 nodes
scriptc region 1: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); cycles 0.0 ms, walk 14.9 ms, free 2.6 ms
build 3: 87381 nodes
scriptc region 2: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); cycles 0.0 ms, walk 8.9 ms, free 2.4 ms
scriptc regions: 3 created, 2 released, 2 freed, 0 pinned, 0 kept
scriptc region heap passes: 0API
new LifetimeRegion()- Creates a region. Creating a region also retries the released regions that could not be freed yet.
region.run(fn)- Calls
fnwith the region as the current region and returns its result.ProgramLifetimeinstances created during the call belong to the region, and so do those that async functions called during it create after anawait(see Async work). Calls nest: aruninside the callback makes another region current until it returns, and the previous region is current again however the callback finishes, including when it throws. region.release()- Requests that the region be freed. scriptc frees it as soon as nothing outside the region refers to any of its instances, which may be at once, at a later release or region creation, or never. A region is never freed while a
runcall on it is active, or while async work started inside it has not finished. Afterrelease(),runstill calls its callback, and the instances it creates belong to the default region.
ProgramLifetime instances created outside every run call belong to the default region, which is never freed. Ordinary objects (instances of classes without the contract, arrays, maps, closures) are reference counted as always; a region frees the ones that only its instances refer to.
When a region is freed
When a region is released, scriptc checks that every reference to its instances comes from the region itself: from its own instances, or from ordinary objects that only the region refers to. It then frees the instances and those ordinary objects together, and removes the WeakMap and WeakSet entries keyed by the freed instances.
A released region stays allocated, and its instances stay usable, while anything else refers to one of them:
- a variable, a module variable, or a local of a function that is still running;
- an ordinary object that something outside the region refers to, including an array,
MaporSetelement and a closure's captured variable; - an instance of another region, until that region is freed;
- an instance of the default region, which is never freed;
- a promise, generator or
Errorthat holds an instance, until it is no longer referenced.
Async work
An async function called inside run keeps the region for its whole run: the instances it creates after each await belong to the region too, as do those of the async functions it calls. A server can give every request its own region, even while requests interleave at their awaits:
import type { ProgramLifetime } from "@scriptc/types";
import { LifetimeRegion } from "@scriptc/experimental";
class Part implements ProgramLifetime {
readonly text: string;
previous: Part | undefined = undefined;
constructor(text: string) {
this.text = text;
}
}
const read = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
async function handle(id: number): Promise<string> {
let last: Part | undefined;
for (let i = 0; i < 1000; i++) {
if (i % 100 === 0) await read(); // the next chunk of the request body
const part = new Part(`${id}.${i}`);
part.previous = last;
last = part;
}
return `request ${id}: last part ${last!.text}`;
}
async function serve(id: number): Promise<string> {
const region = new LifetimeRegion();
try {
return await region.run(() => handle(id));
} finally {
region.release();
}
}
Promise.all([1, 2, 3].map(serve)).then((responses) => {
for (const response of responses) console.log(response);
});Each request's 1000 parts are freed when the request releases its region:
$ scriptc build requests.ts -o requests >/dev/null
$ SCRIPTC_REGION_STATS=1 ./requests >/dev/null
scriptc region 1: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc region 2: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc region 3: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc regions: 3 created, 3 released, 3 freed, 0 pinned, 0 kept
scriptc region heap passes: 0A region released while async work started inside it is still pending stays allocated until that work has finished, and the work keeps creating its instances in the region. Callbacks that the event loop calls (timers, queueMicrotask, event listeners) and the bodies of async generators run outside the region; synchronous generators run in the region of the code that resumes them.
Threads
Each thread has its own regions and its own default region. A region belongs to the thread that created it: on another thread, run makes that thread's default region current and release does nothing. A region that holds an instance published with @scriptc/threads is never freed, because other threads may refer to it.
Cost
Programs that never construct a LifetimeRegion compile exactly as they would without the package. In programs that do, every retain and release of a ProgramLifetime instance that belongs to a region updates a count of the region chunk that holds it. Instances of the default region are not counted, and until the program creates its first region, a retain or release only tests whether counting has started.
Freeing a region visits each of its instances and the ordinary objects they own. Each thread keeps up to 8 MiB of the memory of freed regions for its next regions, so a program that frees a region per request or per batch does not map and fault in new pages every time. A region that the check would keep may be kept only by unreachable cycles of ordinary objects that refer into it, so before keeping it scriptc runs the cycle collector over the thread's heap and checks again.
SCRIPTC_REGION_STATS=1 prints one line per release attempt, freed, kept (with the number of references the check could not explain) or pinned (published), and at exit a summary per thread and the number of those cycle-collector passes over the thread's heap.
InternTable
Programs that run the same work on several worker threads often build the same cache on every thread: interned types, strings, resolved modules. An InternTable is one named table of published values that every thread of a scriptc program shares, so each value is created once and every thread reads the same object.
import { Worker, isMainThread, parentPort } from "node:worker_threads";
import { InternTable } from "@scriptc/experimental";
class Shape {
readonly name: string;
readonly parts: readonly Shape[];
constructor(name: string, parts: readonly Shape[]) {
this.name = name;
this.parts = parts;
}
}
// Module scope: every thread that evaluates this line opens the same table.
const shapes = new InternTable<string, Shape>("example.shapes");
function shape(name: string, parts: readonly Shape[] = []): Shape {
return shapes.getOrInsertComputed(name, () => new Shape(name, parts));
}
function build(): string {
const point = shape("point");
const line = shape("line", [point, point]);
const square = shape("square", [line, line, line, line]);
return `${square.name}: ${square.parts.length} lines, ${shapes.size} shapes`;
}
if (isMainThread) {
for (let i = 0; i < 2; i++) {
new Worker(new URL(import.meta.url)).on("message", (text: string) => console.log(text));
}
} else {
parentPort!.postMessage(`worker: ${build()}`);
}Each thread that evaluates new InternTable(...) opens the same table. Under Node.js every thread has its own table, so each worker builds its own three shapes; in the scriptc executable both workers use one table and read the same shape objects. Both print the same:
$ node intern.ts
worker: square: 4 lines, 3 shapes
worker: square: 4 lines, 3 shapes
$ scriptc build intern.ts -o intern >/dev/null
$ ./intern
worker: square: 4 lines, 3 shapes
worker: square: 4 lines, 3 shapesMethods
new InternTable<K, V>(name)- Opens the table called
name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare likeMapkeys; arrays compare by their elements in the same way and can have at most 65536 elements. table.get(key),table.has(key),table.size- Read the table.
table.getOrInsert(key, value)- Returns the value stored under
key. When there is none, publishesvalue, stores it and returns it. table.getOrInsertComputed(key, callback)- Returns the value stored under
key. When there is none, callscallback(key), publishes its result and stores it, unless a value was stored underkeymeanwhile (by the callback, or in scriptc builds by another thread), and returns the stored value.
A key's value never changes once stored, and there is no set or delete. Stored values and everything they reach are immutable in both runtimes. A value that publish refuses makes the call throw the same TypeError and stores nothing.
scriptc requires the name to be a string literal and every construction of one name to use the same type arguments, since all threads share the table's layout. Values are numbers, booleans, strings, bigints, arrays, Maps, Sets, objects and class instances.
Thread behavior
No lock is held while a callback runs. Two threads that miss the same key at the same time may both call the callback; one result is stored, and both calls return it. A program behaves the same under scriptc and Node.js when:
- A stored value does not depend on which thread created it, for example because the callback computes it from the key and other stored values.
- It does not compare identities or ids across threads.
- A thread does not depend on another thread's inserts:
sizeandhasreflect every thread's inserts in scriptc and the calling thread's under Node.js.
State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a ThreadSlots family, a module-level Map keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these.
Memory
Lookups take no lock and write nothing. Inserts lock one of 64 segments of the table for a probe and a few stores. Stored values are immortal in scriptc builds: they stay allocated until the process exits, including a value a callback computed for a key that another thread stored first. Programs that never construct an InternTable do not run any of its code.
ThreadSlots
Threads that share values through an InternTable often compute more about each shared value later: resolved members, cached results, flags. That state differs per thread, so it cannot live in the shared value, which is immutable. A ThreadSlots family holds one value per key and thread, like a WeakMap that each thread has its own copy of. A thread sees only the values it set, including for keys that every thread shares.
import { Worker, isMainThread, parentPort, threadId } from "node:worker_threads";
import { InternTable, ThreadSlots } from "@scriptc/experimental";
class Shape {
readonly name: string;
constructor(name: string) {
this.name = name;
}
}
class ShapeState {
uses = 0;
readonly thread: number;
constructor(thread: number) {
this.thread = thread;
}
}
const shapes = new InternTable<string, Shape>("example.slot-shapes");
// Module scope: every thread that evaluates this line opens the same family.
const states = new ThreadSlots<Shape, ShapeState>("example.shape-states");
function use(name: string): ShapeState {
const shape = shapes.getOrInsertComputed(name, () => new Shape(name));
let state = states.get(shape);
if (state === undefined) {
state = new ShapeState(threadId);
states.set(shape, state);
}
state.uses++;
return state;
}
if (isMainThread) {
for (let i = 0; i < 2; i++) {
new Worker(new URL(import.meta.url)).on("message", (text: string) => console.log(text));
}
} else {
use("square");
const state = use("square");
parentPort!.postMessage(`worker: ${state.uses} uses, own state ${state.thread === threadId}`);
}In the scriptc executable both workers read the same Shape object from the table, and each keeps its own ShapeState for it. Both runtimes print the same:
$ node slots.ts
worker: 2 uses, own state true
worker: 2 uses, own state true
$ scriptc build slots.ts -o slots >/dev/null
$ ./slots
worker: 2 uses, own state true
worker: 2 uses, own state trueSlot methods
new ThreadSlots<K, V>(name)- Opens the family called
name. Within one thread every construction with the same name refers to the same family. slots.get(key),slots.has(key)- Read the calling thread's value for
key. slots.set(key, value)- Sets the calling thread's value for
keyand returns the slots. It never writes the key, so it works on published keys. slots.delete(key)- Removes the calling thread's value for
keyand returns whether there was one.
Under Node.js each family is a WeakMap per thread.
Keys and values
scriptc requires:
- the name to be a string literal, and every construction of one name to use the same type arguments;
- the key type to be a class declared in the program; keys may be instances of its subclasses;
- the value type to be a class, an object type, an array, a
Mapor aSet; - the methods to be called through a module-level
constinitialized withnew ThreadSlots<K, V>("name"), as in the example. A family's storage is found by its name when the program is compiled.
Slot cost
Each instance of a key class, and of every class in its hierarchy, carries a 4-byte slot index, 0 until a value is set for it. The first set for a key assigns the index; indices are never reused. get and has read the index, a page of the thread's family and the entry, with no lock, hash or atomic update. A family stores values in pages of 1024 entries, allocated as the thread sets values in them.
A family holds a reference to each of the thread's values. A value is released when it is replaced or deleted, when its key is freed, and when the thread exits. Keys that are freed with a LifetimeRegion keep their values until the thread exits, a value that belongs to a region keeps the region allocated while the family holds it, and a value that refers to its own key keeps the key alive. Programs that never construct a ThreadSlots do not run any of its code, and their classes have no slot index.
InlineArray
InlineArray<T> marks an array field of a ProgramLifetime class whose elements scriptc keeps in the instance itself, after its other fields, instead of in an array object of their own. It suits a program with millions of short arrays that never change once built, such as the children of each node of a syntax tree: an ordinary array costs a separate allocation with a header, and a reference to it in the instance.
import type { ProgramLifetime } from "@scriptc/types";
import type { InlineArray } from "@scriptc/experimental";
class Token implements ProgramLifetime {
readonly text: string;
constructor(text: string) {
this.text = text;
}
}
class TokenList implements ProgramLifetime {
readonly pos: number;
readonly tokens: InlineArray<Token>;
constructor(pos: number, tokens: readonly Token[]) {
this.pos = pos;
this.tokens = tokens;
}
}
function width(list: TokenList): number {
let total = 0;
for (const token of list.tokens) total += token.text.length;
return total;
}
const list = new TokenList(0, ["let", "x", "=", "1"].map((text) => new Token(text)));
console.log(list.tokens.length, width(list), list.tokens[0]!.text);
console.log(list.tokens.map((token) => token.text).join(" "));Both runtimes print the same:
$ node lists.ts
4 6 let
let x = 1
$ scriptc build lists.ts -o lists >/dev/null
$ ./lists
4 6 let
let x = 1Field rules
scriptc requires:
Tto be aProgramLifetimeclass, and the field to be an instance field of aProgramLifetimeclass;- the constructor of the class that declares the field to write it once, from one of its parameters. A subclass constructor passes one of its own parameters there through
super(...). Nothing else writes the field; - the array passed to the constructor to be one that nothing else keeps: a new array (an array literal, a copy such as
slice(),[...items]orArray.from(items), or the result of a function that returns only such arrays or its own argument), a local array at its last use (calls that only read it or add to it may come first, and a loop may pass it and then assign a new array to the local), a parameter whose callers each pass such an array, an array that an InlineArray field already holds, or aconstarray that is never written, such as a shared empty array; - the arrays fields hold never to be written, through a read of the field (which the
readonlytype allows only through a cast) or through any other reference. scriptc follows these arrays through locals, parameters, return values, fields, the elements of arrays, Maps and Sets, and closures, and reports one that reaches a write, a dynamic value (also inside the fields of an object that becomes one), a call through a function value (also inside an array, Map or Set passed there), or a library call other than printing andJSON.stringifywithout a replacer. The report names the field read the array may come from; - the arrays fields hold, and their reads, not to be compared by identity: with
===or!==, as anindexOforincludesargument, or as a Map key or Set element. Two reads of the field need not give the same array. Compare the elements instead, or keep a copy made withslice()where identity matters; - instances of the class not to be constructed through a class value. They may become dynamic values, for example through a conversion to
unknownor an interface several classes implement, when the dynamic code that sees them uses the class's own members and does not read the field: scriptc follows such values through locals, parameters, return values, fields, the elements of arrays, Maps and Sets, the members of dynamic objects and arrays, calls through function values, and the callbacks of array methods such asmapandforEach, and finds the first that reaches a read or write of the field's name or of a computed key, a checked cast to a record, array or collection type that has a record member of the field's name or holds a dynamic value, a call through a value no compiled function reaches (also as an array method's callback), or a library call other than printing,JSON.stringifywithout a replacer,publish, a worker'sworkerData, and class tests and casts. A dynamic view of an inline field would read it as a new array each time, so when such an operation exists the field keeps its elements in an ordinary array, which behaves as under Node.js, and scriptc reports a warning (SC6105). The report names the operation.
scriptc reports a field that does not keep these rules (SC7009).
Reads
Indexing the field, reading its length and iterating it with for...of read the elements from the instance, directly or through a const local used only in those ways. Any other use, such as passing the field to a function or calling one of its methods, gets an array of the elements, which is frozen in scriptc builds. Under Node.js the field holds the array the constructor stored. The two agree because the field rules keep that array from being written.
Inline array cost
Each element takes 4 bytes in the instance, and the field's own slot holds the count. The constructor copies the elements from the array it is passed, so that array can be released. The elements start at the same offset in every class of the hierarchy, after the largest set of fixed fields. A read that gets an array allocates a small header that refers to the instance's elements, and frees it when the read's value is released.
The elements stay in an ordinary array object, and the field behaves like any other array field, in builds with sanitizers, in programs that use LifetimeRegion, and on platforms other than 64-bit Linux and macOS.
Load hook
@scriptc/experimental/register is a preload that makes Node.js refuse a program the way a scriptc build does, before the program runs. Use it while developing so node and scriptc agree on what the program may load.
$ node --require @scriptc/experimental/register app.ts--import works the same way. Node.js 24 or newer is required.
The hook applies the refusals in @scriptc/experimental/refusals.json, the same data the compiler uses:
- Importing a builtin module that scriptc does not support fails with the compiler's code and message (
SC1010). - Loading a native addon (
.node) fails with the compiler's code and message (SC2020). process.getBuiltinModuleof an unsupported builtin throws the error a compiled program throws for that call.
A program can therefore run under Node.js with the hook, fail where the build fails, and give the same code and message. Running both in CI catches a refusal before a binary is built or deployed.
scriptc refuses code generation from strings, which Node.js does only with --disallow-code-generation-from-strings. The hook cannot set the flag for the process it joins, so add it to NODE_OPTIONS:
$ NODE_OPTIONS=--disallow-code-generation-from-strings node --require @scriptc/experimental/register app.tsThe hook prints a warning to stderr when the flag is missing, or when the running Node.js version differs from the release the refusal data describes.