The Principal Dev โ€“ Masterclass for Tech Leads

The Principal Dev โ€“ Masterclass for Tech Leads28-29 May

Join

Build NuGet NuGet MyGet Join the chat at https://gitter.im/sebastienros/jint

Jint

Jint is a Javascript interpreter for .NET which can run on any modern .NET platform as it supports .NET Standard 2.0 and .NET 4.6.2 targets (and later).

Use cases and users

Some users of Jint include RavenDB, EventStore, OrchardCore, ELSA Workflows, docfx, JavaScript Engine Switcher, and many more.

Supported features

ECMAScript 2015 (ES6)

Proper tail calls replace the calling strict-function frame, as required by ECMAScript. Consequently, intermediate tail callers are intentionally absent from error.stack and host stack telemetry.

ECMAScript 2016

ECMAScript 2017

ECMAScript 2018

ECMAScript 2019

ECMAScript 2020

ECMAScript 2021

ECMAScript 2022

ECMAScript 2023

ECMAScript 2024

ECMAScript 2025

ECMAScript proposals (no version yet)

Other

Web APIs (opt-in)

Beyond ECMAScript, Jint is growing a WHATWG-faithful set of web platform APIs โ€” the surface a script author expects from a browser or from Node: console, timers, TextEncoder, fetch and friends. It follows the published standards (console.spec.whatwg.org, webidl.spec.whatwg.org, fetch.spec.whatwg.org, โ€ฆ) rather than inventing a Jint-shaped variant, so scripts written against those standards behave the way their authors expect.

Everything here is opt-in and nothing is installed by default. An engine that does not ask for these APIs is byte-for-byte the engine it was before they existed: no extra globals, no extra work at construction, no behaviour change. That is deliberate โ€” an engine embedded in a workflow runner or a template renderer has no business exposing them, and outbound network access in particular is a decision a host has to make explicitly.

Requires .NET 8 or higher. The whole surface is compiled only for net8.0 and later; on net462, netstandard2.0 and netstandard2.1 the options and types simply do not exist.

// Everything except network access.
var engine = new Engine(options => options.UseWebApis());

// Or exactly what you want, with somewhere for console output to go.
engine = new Engine(options => options
    .UseWebApis(WebApiFeatures.Console)
    .UseConsole(Console.Out));

engine.Execute("console.log('hello %s', 'world')");

console output goes to a ConsoleSink, which defaults to ConsoleSink.Null and discards everything โ€” enabling the feature never starts writing to your process's standard output by surprise. ConsoleSink.FromTextWriter covers the common case; implement the abstract class to route records to your own logger, where the ConsoleLogLevel tells you how loud each one is. Every method emits exactly one Write call per record, so a console.table(rows) reaches your sink as one multi-line ASCII table rather than as a line per row, and the formatter never invokes a script-visible getter โ€” an accessor renders as [Getter], because a console must not be a way to run arbitrary script.

A global you registered yourself always wins: the install is non-clobbering, so if your host already exposes its own console (or any other name in the table below), enabling the feature leaves yours exactly as it is.

API Feature flag Status
console (log/warn/error/group/count/time/assert/trace/dir/table) WebApiFeatures.Console โœ” shipped
DOMException / QuotaExceededError (no flag โ€” installed whenever any feature is enabled) โœ” shipped
setTimeout / setInterval / clearTimeout / clearInterval / queueMicrotask WebApiFeatures.Timers โœ” shipped
TextEncoder (UTF-8) / TextDecoder (UTF-8, UTF-16LE/BE, every legacy single-byte encoding, x-user-defined; fatal, ignoreBOM, streaming) Encoding โœ” shipped
atob / btoa Base64 โœ” shipped
structuredClone (incl. { transfer } of ArrayBuffers, MessagePorts and streams) StructuredClone โœ” shipped
crypto.getRandomValues / crypto.randomUUID / crypto.subtle (SHA digests, HMAC, AES-GCM, RSA, ECDSA/ECDH, HKDF, PBKDF2, CryptoKey) WebApiFeatures.Crypto โœ” shipped
performance.now / timeOrigin / mark / measure / getEntries* / clearMarks / clearMeasures WebApiFeatures.Performance โœ” shipped
Event / EventTarget / CustomEvent / AbortController / AbortSignal Events โœ” shipped
URL / URLSearchParams / URLPattern Url โœ” shipped
Blob (incl. stream()) / File / FormData Files โœ” shipped
navigator.userAgent Navigator โœ” shipped
ReadableStream / WritableStream / TransformStream (all three transferable) / ByteLengthQueuingStrategy / CountQueuingStrategy Streams โœ” shipped
TextEncoderStream / TextDecoderStream Encoding and Streams โœ” shipped
CompressionStream / DecompressionStream (gzip, deflate, deflate-raw) Compression and Streams โœ” shipped
scheduler.postTask / scheduler.yield / TaskController / TaskSignal Scheduler โœ” shipped
requestIdleCallback / cancelIdleCallback / IdleDeadline IdleCallback โœ” shipped
MessageChannel / MessagePort / MessageEvent (incl. cross-engine ports and port transfer) / BroadcastChannel Messaging โœ” shipped
Worker (module workers only, over a host-supplied WorkerProvider) Workers โ€” opt-in on its own, and does nothing without a provider โœ” shipped
reportError (and the DiagnosticsSink behind it) Reporting โœ” shipped
addEventListener / removeEventListener / dispatchEvent / self on the global scope, with ErrorEvent / PromiseRejectionEvent and the error / unhandledrejection / rejectionhandled events GlobalEvents โœ” shipped
localStorage / sessionStorage / Storage Storage (not in Default โ€” see below) โœ” shipped
fetch / Headers / Request / Response Fetch โ€” opt-in on its own, see below โœ” shipped
EventSource / MessageEvent (server-sent events) EventSource โ€” opt-in on its own, see below โœ” shipped
WebSocket / CloseEvent (and the MessageEvent its messages arrive as) WebSocket โ€” opt-in on its own, see below โœ” shipped
caches / Cache / CacheStorage CacheApi โ€” opt-in on its own, see below โœ” shipped
FetchEvent and addEventListener('fetch', e => e.respondWith(โ€ฆ)) โ€” script-facing request handling FetchEvents โ€” opt-in on its own, see below โœ” shipped

WebApiFeatures.Default โ€” what UseWebApis() enables โ€” is every non-network feature that has landed. It grows as the table fills in, and it will never include fetch: network egress is always an explicit choice. Storage is one standing exception, for the reason in its own section below; CacheApi is another, for the neighbouring one โ€” a cache outlives the evaluation that filled it, so where its data goes, and what bounds it, is a decision you make rather than inherit; and FetchEvents is the third, because a script that can register a fetch listener can take over every request you route into the engine. Workers is outside Default for a different reason again: it needs a thread, and Jint never starts one โ€” see its own section below.

crypto.subtle carries all twelve operations โ€” digest, sign, verify, encrypt, decrypt, generateKey, importKey, exportKey, deriveBits, deriveKey, wrapKey and unwrapKey โ€” over SHA-1/256/384/512 (for digest and as every keyed algorithm's inner hash), HMAC, the AES family (AES-CTR, AES-CBC, AES-GCM and AES-KW, each at 128, 192 and 256 bits), the RSA family โ€” RSASSA-PKCS1-v1_5 and RSA-PSS for signatures, RSA-OAEP for encryption โ€” the elliptic curves P-256, P-384 and P-521 under ECDSA and ECDH, and HKDF and PBKDF2 for derivation. Keys are real CryptoKey objects with type, extractable, algorithm and usages, and generateKey for an asymmetric algorithm hands back a CryptoKeyPair, which is the plain { privateKey, publicKey } dictionary the specification defines rather than an interface. The key material is never reachable from script except through exportKey on an extractable key: raw bytes or a kty: "oct" JSON Web Key for a symmetric key, spki, pkcs8 or a kty: "RSA" JSON Web Key for an RSA one, and all four of those โ€” raw being the uncompressed point 04||X||Y โ€” for an elliptic-curve one.

The registries are per operation, and they are not symmetric. HKDF and PBKDF2 register importKey, deriveBits and the internal get key length and nothing else: there is nothing to generateKey (the key is the password, or the input keying material you already have) and nothing to exportKey, their import steps refusing any extractable that is not false. So a PBKDF2 key is a one-way door โ€” the bytes go in and only derived material comes out. ECDH registers deriveBits and never sign; ECDSA the reverse. And AES-KW registers wrapKey and unwrapKey and neither encrypt nor decrypt, which is the exact reverse of every other cipher: RFC 3394 wrapping takes a whole number of 64-bit blocks and carries an integrity check of its own, so encrypt({ name: 'AES-KW' }, โ€ฆ) is a NotSupportedError here and in a browser. That asymmetry is what wrapKey's double normalization is for โ€” the algorithm is normalized for wrapKey and, if that fails, for encrypt โ€” so wrapKey(format, key, kek, 'AES-KW') takes the first route and wrapKey(format, key, kek, { name: 'AES-GCM', iv }) (or AES-CBC, AES-CTR, RSA-OAEP) the second, with one method call either way.

Wrapping is exporting, so wrapKey refuses a key that is not extractable with an InvalidAccessError and needs the wrapKey usage on the wrapping key. unwrapKey is the mirror, and it is where extractable and keyUsages for the new key are decided โ€” which is what lets a server hand a script a key it can use and cannot export, ext: false in the wrapped JWK being honoured on the way in. For the jwk format the bytes wrapped are the UTF-8 of JSON.stringify of exactly the object exportKey('jwk', key) hands a script, and under AES-KW they are padded with spaces before the closing brace to a multiple of 8 โ€” the convention every implementation follows and the web-platform tests compute their expectation with, which the specification's own note permits ("implementations may choose to adapt the serialization to the constraints of the wrapping algorithm").

deriveKey is a composition rather than an algorithm of its own: it derives bits with one algorithm and imports them with another, so deriveKey(pbkdf2Params, password, { name: 'AES-GCM', length: 256 }, โ€ฆ) hands back exactly the key importKey('raw', โ€ฆ) would have made from the same bytes. What it will not do is derive an asymmetric key: RSA and the elliptic curves register importKey but not get key length, so a derivedKeyType naming one is a NotSupportedError before anything is derived โ€” which is what a browser answers too. The length argument of deriveBits is optional and nullable, and the three algorithms read a null differently: ECDH returns the whole shared secret (and truncates to a bit, not a byte, when you do give a length), while HKDF and PBKDF2 have no natural output size and refuse it with an OperationError. A length that is not a multiple of eight is likewise an OperationError for those two and a bit-exact truncation for ECDH, and a length of zero is the empty ArrayBuffer for all three.

The two shapes an embedder actually reaches for are both one call each. A password becomes a content key โ€” deriveKey({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, password, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) โ€” and two parties agree one, deriveKey({ name: 'ECDH', public: theirPublicKey }, myPrivateKey, 'HKDF', false, ['deriveBits']) followed by an HKDF expansion, which is the worked example the specification itself gives. Together with the signature side that was already here, a script can now do the whole of a JOSE flow in-engine: HS* under HMAC, RS* under RSASSA-PKCS1-v1_5, PS* under RSA-PSS and ES* under ECDSA for signing and verification, and PBKDF2 or ECDH-plus-HKDF for the key that protects the payload.

An ECDSA key carries only its curve: the hash lives in the EcdsaParams of each sign and verify call, so one P-256 key signs under SHA-256 and SHA-512 alike. Signatures are the raw r || s concatenation at the curve's field width (64, 96 and 132 bytes) that the Web Cryptography API defines โ€” not the DER SEQUENCE that .NET's ECDsa.SignData produces when asked for DSASignatureFormat.Rfc3279DerSequence, so a host verifying a script's signature with ECDsa must pass IeeeP1363FixedFieldConcatenation. An elliptic-curve JSON Web Key's x, y and d are fixed-width, as RFC 7518 ยง6.2 requires and unlike RSA's minimal-length integers, so a coordinate whose leading byte is zero keeps it; and an exported EC JWK carries no alg, exactly as the export steps say, though ES256/ES384/ES512 are honoured on the way in (ES512 names P-521 โ€” the number is the hash's).

Nothing here ever throws: a promise-returning WebIDL operation converts every failure into a rejection. An algorithm that is not registered for the operation is a NotSupportedError DOMException, a key used against its own usages, against another algorithm, or against the wrong half of its pair (signing with a public key) is an InvalidAccessError, a malformed JWK or a DER structure that is not what its format names is a DataError, a usage list an algorithm does not support is a SyntaxError, and anything an argument conversion refuses is a TypeError. An AES-GCM, AES-CBC or RSA-OAEP decryption that does not come out right, and an AES-KW unwrap whose integrity check fails, are each one OperationError carrying nothing about which part of the input was wrong โ€” a decrypt that could tell "the padding was malformed" from "the padding was fine" is a padding oracle. The work is synchronous, so the promise is already settled when you get it.

Where .NET's own cryptography is narrower than the specification the difference is visible, always as the OperationError the algorithm's own steps end in and always with a message naming the restriction:

TextDecoder understands every encoding the Encoding Standard names, with all of their labels, except the seven legacy multi-byte ones (Big5, EUC-JP, EUC-KR, GBK, gb18030, ISO-2022-JP and Shift_JIS). Those are recognized as labels and reported as an unsupported encoding, never decoded as something else. The label table and the single-byte index tables are generated from the standard's own data files; TextEncoder is UTF-8 only, as the standard requires.

WinterTC's Minimum Common API, member by member

The surface above is guided by WinterTC's Minimum Common Web Platform API, the Ecma TC55 standard that names the slice of the web platform a non-browser runtime should carry. The two tables below are that standard's own index โ€” ยง5.1 Common interfaces and ยง5.2 Common methods and properties of the 2025 snapshot โ€” one row per member, against the WebApiFeatures flag that provides it. Every flag named is part of WebApiFeatures.Default unless the row says otherwise, so a bare UseWebApis() gets you the whole list bar the rows that say it does not.

Five entries need more than a flag name, stated here rather than left to be discovered:

ยง5.3 Web workers does not require a runtime to support them at all, and asks for onerror, onunhandledrejection, onrejectionhandled and self on any global that does map to a WorkerGlobalScope. An ordinary Jint global does not map to one, so only self applies there and the three handlers are the ยง6 case above; a worker's global (WebApiFeatures.Workers, above) maps to one and carries all four, with onerror in HTML's legacy five-argument shape.

ยง5.1 Common interfaces

Member Defined by Provided by
AbortController DOM Events
AbortSignal DOM Events
Event DOM Events
EventTarget DOM Events
CustomEvent DOM Events
ErrorEvent HTML GlobalEvents
MessageChannel HTML Messaging
MessageEvent HTML Messaging (also EventSource, WebSocket)
MessagePort HTML Messaging
PromiseRejectionEvent HTML GlobalEvents
DOMException WebIDL no flag โ€” installed whenever any feature is
Headers Fetch Fetch, CacheApi or FetchEvents โ€” not in Default
Request Fetch Fetch, CacheApi or FetchEvents โ€” not in Default
Response Fetch Fetch, CacheApi or FetchEvents โ€” not in Default
FormData XHR Files
Blob File API Files
File File API Files
CompressionStream Compression Compression and Streams
DecompressionStream Compression Compression and Streams
ByteLengthQueuingStrategy Streams Streams
CountQueuingStrategy Streams Streams
ReadableStream Streams Streams
TransformStream Streams Streams
WritableStream Streams Streams
ReadableByteStreamController Streams Streams โ€” implemented, not a global
ReadableStreamBYOBReader Streams Streams โ€” implemented, not a global
ReadableStreamBYOBRequest Streams Streams โ€” implemented, not a global
ReadableStreamDefaultController Streams Streams โ€” implemented, not a global
ReadableStreamDefaultReader Streams Streams โ€” implemented, not a global
TransformStreamDefaultController Streams Streams โ€” implemented, not a global
WritableStreamDefaultController Streams Streams โ€” implemented, not a global
WritableStreamDefaultWriter Streams Streams โ€” implemented, not a global
TextDecoder Encoding Encoding
TextEncoder Encoding Encoding
TextDecoderStream Encoding Encoding and Streams
TextEncoderStream Encoding Encoding and Streams
URL URL Url
URLSearchParams URL Url
URLPattern URL Pattern Url
Crypto Web Crypto absent โ€” crypto has no interface object
CryptoKey Web Crypto Crypto
SubtleCrypto Web Crypto absent โ€” crypto.subtle has no interface object
Performance HR-Time absent โ€” performance has no interface object
WebAssembly.Global Wasm JS API absent โ€” declined
WebAssembly.Instance Wasm JS API absent โ€” declined
WebAssembly.Memory Wasm JS API absent โ€” declined
WebAssembly.Module Wasm JS API absent โ€” declined
WebAssembly.Table Wasm JS API absent โ€” declined
WebAssembly.Tag Wasm JS API absent โ€” declined
WebAssembly.Exception Wasm JS API absent โ€” declined
WebAssembly.CompileError Wasm JS API absent โ€” declined
WebAssembly.LinkError Wasm JS API absent โ€” declined
WebAssembly.RuntimeError Wasm JS API absent โ€” declined

ยง5.2 Common methods and properties

Member Defined by Provided by
globalThis ECMAScript the language โ€” always there
atob() HTML Base64
btoa() HTML Base64
clearTimeout() HTML Timers
clearInterval() HTML Timers
navigator.userAgent HTML Navigator
onerror HTML absent โ€” ยง6 asks for that; see above
onunhandledrejection HTML absent โ€” ยง6 asks for that; see above
onrejectionhandled HTML absent โ€” ยง6 asks for that; see above
queueMicrotask() HTML Timers
reportError() HTML Reporting
self HTML GlobalEvents
setTimeout() HTML Timers
setInterval() HTML Timers
structuredClone() HTML StructuredClone
fetch() Fetch Fetch โ€” not in Default
console Console Console
crypto Web Crypto Crypto
performance HR-Time Performance
WebAssembly.compile() Wasm JS API absent โ€” declined
WebAssembly.compileStreaming() Wasm Web API absent โ€” declined
WebAssembly.instantiate() Wasm JS API absent โ€” declined
WebAssembly.instantiateStreaming() Wasm Web API absent โ€” declined
WebAssembly.JSTag Wasm JS API absent โ€” declined
WebAssembly.validate() Wasm JS API absent โ€” declined

ยง7 asks that navigator.userAgent be a single opaque RFC 7231 product token identifying the runtime; Jint answers Jint/<version>, which Options.WebApi does not let you change โ€” a script that branches on the runtime should get the truth.

Enabling web APIs on an engine that already exists

options.WebApi.Features is read once, when the engine is built, so a pooled engine's feature set is fixed at construction โ€” which is awkward for a host that only discovers per request what the script it is about to run needs. engine.Advanced.EnableWebApis(...) is the per-engine counterpart of options.UseWebApis(...), for exactly that case:

var engine = pool.Rent();                       // built with WebApiFeatures.Console only

if (script.NeedsTimers)
{
    engine.Advanced.EnableWebApis(WebApiFeatures.Timers | WebApiFeatures.Events);
}

if (tenant.MayReachTheNetwork)
{
    // The optional delegate is handed this engine's own options group, so a feature that carries settings
    // can be configured at the moment it is enabled.
    engine.Advanced.EnableWebApis(WebApiFeatures.Fetch, w =>
        w.Fetch.UrlFilter = uri => tenant.Allows(uri));
}

// What the engine actually carries, after the feature closure has been expanded.
WebApiFeatures live = engine.Advanced.WebApiFeatures;   // Console | Timers | Events | Fetch | Url | Files | Streams

It runs the same code construction runs โ€” the same feature closure, the same per-engine state, the same lazy, non-clobbering globals โ€” so an engine enabled this way is the engine options.UseWebApis(...) would have built. Four things are worth knowing:

As at options time, only the principal realm is touched: a ShadowRealm still gets none of it.

Timers fire only while the engine is being pumped

Jint never starts a thread, and never a System.Threading.Timer, to run your script. A setTimeout callback runs on the first drain of the event loop at or after its due time, and a drain only happens where one always happened: at the end of an Execute/Evaluate, inside a blocking UnwrapIfPromise, while awaiting EvaluateAsync, or when the host calls engine.Advanced.ProcessTasks() itself.

var engine = new Engine(options => options.UseWebApis());

// Blocking: the drain waits out the timer and returns 42.
var answer = engine.Evaluate("(async () => { await new Promise(r => setTimeout(r, 100)); return 42; })()")
    .UnwrapIfPromise();

// Or without holding a thread.
answer = await engine.EvaluateAsync("(async () => { await new Promise(r => setTimeout(r, 100)); return 42; })()");

// Or from your own loop โ€” a game loop, a message pump โ€” where every turn provably runs on your thread.
engine.Execute("setTimeout(() => console.log('later'), 50);");
while (running) { engine.Advanced.ProcessTasks(); Thread.Sleep(5); }

An engine nobody pumps never fires a timer, which is what makes this safe in a request handler that returns as soon as the script does โ€” a scheduled callback cannot surprise you later, on a thread you did not expect, against state you have moved on from. The corollary is that a timer outlasting the wait around it is simply never reached: new Promise(r => setTimeout(r, 15000)) under the default ten-second UnwrapIfPromise times out, by design.

The engine's single job queue is the microtask queue: a due timer is promoted onto it only once it has run dry, so Promise.resolve().then(f) always beats setTimeout(g, 0), each timer gets its own microtask checkpoint, and no interval can starve promise reactions. Timers are scheduled against Options.WebApi.Timers.TimeProvider, so handing the engine a fake clock makes a suite that exercises them deterministic and instant; MaxActiveTimers (1000 by default) bounds how many a script may register at once and turns the excess into a catchable QuotaExceededError carrying the cap as quota and the count that would have been reached as requested. A callback that throws erupts out of whatever was pumping โ€” the same contract a promise reaction has โ€” and the rest of the queue runs on the next pump, unless you installed a DiagnosticsSink, in which case it is reported to that instead and the pump carries on.

Prioritized tasks: scheduler.postTask and scheduler.yield

scheduler.postTask(callback, { priority, signal, delay }) runs a callback as its own task and hands back a promise for what it returned; scheduler.yield() hands back a promise that resolves in a fresh continuation task, which is how a long piece of work breaks itself up and still resumes ahead of the work queued behind it. Priorities are the standard's three โ€” user-blocking > user-visible (the default) > background โ€” and a TaskController can abort a batch of tasks or reprioritize the ones still waiting, firing prioritychange at its TaskSignal as it does. Neither method ever throws: a bad argument, an aborted signal or a callback that throws all become a rejection of the promise you were handed.

Tasks run only while the engine is being pumped, exactly as timers do, and a delay rides the very same timer queue. Three ordering guarantees, which the tests pin: every microtask runs before the next task, whichever order the two were queued in; among the tasks pending together the highest priority wins, ties going to the oldest; and every runnable task runs before any due timer โ€” the one place this deliberately differs from a browser, which is free to make the opposite choice for a background task and typically does.

Events dispatch to one target, because there is no tree

EventTarget is constructible and Event, CustomEvent, AbortController and AbortSignal behave as the DOM standard describes them, with one reduction: the specification's dispatch walks an event path built from the target's ancestors in a node tree, and Jint has no node tree. The path is therefore always the single item ยซtargetยป โ€” which is exactly what the algorithm produces for the tree-less EventTargets the standard itself says author code creates. Everything that survives that reduction is the real algorithm: capture decides which of the two passes a listener runs in, stopPropagation() ends the dispatch after the current pass and stopImmediatePropagation() also skips the rest of it, once listeners are removed before they run, a duplicate (type, callback, capture) registration is ignored, and removeEventListener matches on the object identity your script passed.

A listener that throws erupts from dispatchEvent โ€” unless you set a DiagnosticsSink. The standard says to report the exception and carry on to the next listener, which needs somewhere to report to. Install a sink and that is exactly what happens; without one, swallowing the exception would lose it entirely, so it propagates instead โ€” the same contract a timer callback has. The dispatch state is unwound either way, so the event and the target stay usable.

The global scope has its own listener list, behind WebApiFeatures.GlobalEvents โ€” see global error events below.

AbortSignal.timeout(ms) schedules on the same queue the timers use, so it aborts only while the engine is being pumped and it counts against MaxActiveTimers; that queue exists whenever the Events feature does, whether or not you also asked for setTimeout. AbortSignal.any(signals) builds a composite that is retained by its sources until one of them aborts, at which point every link is dropped.

The performance timeline is bounded

performance.mark and performance.measure behave as User Timing describes them โ€” the whole overload matrix, mark names or raw timestamps, detail deep-copied through the same structured-clone algorithm structuredClone uses, and PerformanceEntry / PerformanceMark / PerformanceMeasure as real interface objects so entry instanceof PerformanceMark works. Entries come back from getEntries(), getEntriesByType(type) and getEntriesByName(name, type?) sorted by startTime, and clearMarks(name?) / clearMeasures(name?) remove them.

performance.mark('parse');
// ... work ...
const m = performance.measure('parse-to-now', 'parse');
console.log(m.duration);

One thing is deliberately not the browser's: the entry buffer holds 10,000 entries, not an unbounded number. A page's timeline dies with the page, and an engine embedded in a long-lived host has no such event โ€” while (true) performance.mark('x') would otherwise be a memory leak that no execution constraint describes. Once the buffer is full, further entries are dropped exactly as the Performance Timeline standard says a full buffer drops them: nothing throws, mark() and measure() still return the entry they built, and getEntries() simply stops growing until you clear it. There is no PerformanceObserver, and it is absent rather than present-and-throwing so feature detection takes its fallback path. Readings are not coarsened โ€” an embedded engine has no cross-origin data for a fine clock to help steal, and a host that wants a coarse one supplies a coarse TimeProvider.

navigator carries userAgent and nothing else. It reports Jint/<version> โ€” a single RFC 7231 product token with no comment component, so nothing about your operating system or your application leaks into it โ€” and it is there because WinterTC's Minimum Common API requires globalThis.navigator.userAgent of a conforming runtime. Everything else a browser's Navigator carries describes a user agent with a user, a document and a network stack, so it is absent rather than faked.

Channel messaging can span two engines

new MessageChannel() gives the usual entangled pair, and it behaves as the HTML Standard describes: a message is structured-cloned when it is posted โ€” so a DataCloneError is thrown synchronously at the postMessage call and a later mutation of the value cannot reach the message โ€” and delivered as an event-loop task, so every already-queued promise reaction runs first. A port's message queue starts disabled: nothing arrives until start() is called or onmessage is assigned, and addEventListener('message', โ€ฆ) on its own does not start it. { transfer: [buffer] } moves an ArrayBuffer instead of copying it, exactly as structuredClone does.

A MessagePort can be transferred too, which is how a script hands one channel's end over another channel:

var side = new MessageChannel();
side.port1.onmessage = e => log('reply: ' + e.data);
side.port1.postMessage('queued before the handover');   // waits; nobody owns the far end yet

port.postMessage('here is a private channel', [side.port2]);

The named port is detached: postMessage on it becomes a silent no-op and no event will ever fire on it again. Its side of the channel โ€” including every message still queued on it, and every message the peer posts while it is in transit โ€” travels in the message and is re-entangled with a fresh MessagePort created in the receiving realm, which arrives as event.ports[0]. The peer is untouched and never learns that the far end moved, so a port can be relayed through as many engines as you like and still talk to the one that created it. structuredClone(port, { transfer: [port] }) does the same thing into the current realm.

The specification's refusals are implemented as written: transferring a port through itself is a DataCloneError, naming one twice in a single transfer list or naming an already-closed or already-transferred one is a DataCloneError, transferring the port you are posting to dooms the message (the transfer happens, nothing is delivered, the channel is lost), and a port that is in the message but not in the transfer list is a DataCloneError โ€” a port is transferable, not serializable.

The same pair can also connect two engines, which is what makes a worker-style split possible without a second process:

var host = new Engine(o => o.UseWebApis());
var worker = new Engine(o => o.UseWebApis());

// Create the pair while neither engine is running, then give each half to its own engine.
var pair = host.Advanced.CreateMessagePortPair(worker);
host.SetValue("port", pair.Local);
worker.SetValue("port", pair.Remote);

worker.Execute("port.onmessage = e => port.postMessage(e.data.n * 2);");
host.Execute("port.onmessage = e => log(e.data); port.postMessage({ n: 21 });");

worker.Advanced.ProcessTasks();   // the worker's turn: it receives 21 and replies
host.Advanced.ProcessTasks();     // the host's turn: it receives 42

No JsValue ever crosses. postMessage serializes on the calling engine's thread into a record that holds nothing engine-affine โ€” primitives, byte arrays, lists, type tags โ€” and enqueues a job on the receiving engine's event loop, the one part of an engine any thread may touch. The deserialization, the MessageEvent and the listeners all run on whichever thread pumps that engine, so nothing on the receiver is touched off its own pump. The receiver must actually be pumped, exactly as for timers: an engine nobody pumps never delivers a message. And a RestoreGlobalSnapshot on either engine ends that channel permanently โ€” a port's listeners are closures over the cycle it was created in โ€” so a pooled engine wants a fresh pair per cycle.

Transferring a port works between engines too, and a transferred port is not tied to the two engines that happened to move it: engine A can create a pair, send one end to B, have B forward that same end to C without ever looking at it, and end up with A and C talking directly. The one thing that does cross in that case is the channel side โ€” a small object holding the message queue, a lock and the peer โ€” which is the same handle a sender has always held for its peer. A restore on the engine a transfer was in flight to ends that side rather than leaving it waiting, so the other end stops queueing into something that can never be drained.

BroadcastChannel rides the same Messaging flag and is the same machinery addressed by name instead of by peer. new BroadcastChannel('room') joins a name; postMessage(value) reaches every other channel of that name โ€” never the sender itself โ€” as one event-loop task each, in the order the channels were created. There is no start() and no message queue, so addEventListener('message', โ€ฆ) alone is enough to receive; there is no transfer list either, since a message with several destinations has nowhere to move a buffer to. A postMessage on a closed channel is an InvalidStateError DOMException, which is where it differs from a closed port's, and close() is what takes the channel out of earshot.

Which channels are in earshot of each other is one object โ€” a browser scopes it by agent cluster and origin, and BroadcastChannelBroker is Jint's answer to both:

var cluster = new BroadcastChannelBroker();

var producer = new Engine(o => { o.UseWebApis(); o.WebApi.Messaging.Broker = cluster; });
var consumer = new Engine(o => { o.UseWebApis(); o.WebApi.Messaging.Broker = cluster; });

consumer.Execute("new BroadcastChannel('jobs').onmessage = e => log(e.data);");
producer.Execute("new BroadcastChannel('jobs').postMessage({ id: 7 });");

consumer.Advanced.ProcessTasks();   // the consumer's turn: it receives { id: 7 }

Say nothing and each engine gets a private broker, so channels on one engine hear each other and nothing crosses an engine boundary โ€” including between two engines built from one shared Options, which has to be an explicit act. A broker is thread-safe, and as with ports no JsValue ever crosses: one serialization record is produced on the sender and each destination deserializes its own copy on its own pump. It holds its subscribers strongly, exactly as a browser keeps a channel alive until it is closed, so a broker shared between long-lived and short-lived engines wants the short-lived ones closed, restored or Dispose()d โ€” a RestoreGlobalSnapshot and an Engine.Dispose each end every channel that engine created.

new Worker(), with the thread supplied by you

WebApiFeatures.Workers gives a script the Worker constructor. Everything a worker needs already exists โ€” a second isolated global is a second Engine, the channel between them is the cross-engine port pair above, delivery on the receiver's own thread is its event loop โ€” except the one thing that must not: the thread. Jint never starts a thread to run script, so new Worker() is only implementable if you supply the execution resource. That is what WorkerProvider is. The engine owns the specification-shaped parts โ€” port entanglement, the worker global, message and error plumbing, ordering, terminate() semantics โ€” and you own every thread, every pump, and the worker engine's configuration.

This is the ordinary layering rather than a Jint improvisation: a browser is a host-supplied worker factory, with Chromium in the provider's role, and Deno, QuickJS, Moddable and GraalJS all draw the line in the same place. What is new here is that the boundary is a public extension point, which is the right consequence of Jint being a library and not a runtime.

var engine = new Engine(options => options.UseWebApis().UseWorkers(new ThreadPerWorker(workerRoot)));

engine.Execute("""
    const w = new Worker('./crunch.js', { type: 'module' });
    w.onmessage = e => log(e.data);
    w.postMessage({ rows: 10000 });
    """);

UseWorkers sets the flag and the provider together so the two cannot get out of step. With no provider the global is not installed at all, so typeof Worker === 'undefined' and a script can feature-detect โ€” the family's absent-rather-than-throwing convention, and better than a constructor that could only throw.

A provider decides three things: whether a worker may exist, what engine runs it, and which thread pumps it.

sealed class ThreadPerWorker(string root) : WorkerProvider
{
    // On the PARENT's thread, with the parent's script suspended mid-statement. Read the request; do not
    // run script, do not block, and do not fetch the worker's script โ€” that is the worker's own
    // IModuleLoader's job, on the worker's own pump. Return null to refuse (the script gets a SecurityError).
    public override Engine? CreateWorkerEngine(WorkerRequest request)
        => new Engine(request.CreateDefaultOptions().EnableModules(root));

    // Still the parent's thread, ports entangled and the start job queued. This is where you start pumping.
    public override void OnWorkerStarted(WorkerConnection c)
        => new Thread(() =>
        {
            while (!c.IsEnded)
            {
                c.Worker.Advanced.ProcessTasks();
                try { c.Worker.Advanced.WaitForScheduledWork(TimeSpan.FromSeconds(1), c.TerminationToken); }
                catch (OperationCanceledException) { }   // terminate() โ€” fall out via IsEnded
            }

            c.Worker.Dispose();                          // on the pumping thread, after the loop
        }) { IsBackground = true }.Start();

    // OnWorkerEnded is a SIGNAL ONLY, on whichever thread ended the connection โ€” frequently not the
    // worker's. Never Dispose() or ProcessTasks() the worker engine from it: a terminate() ends the
    // connection on the parent's thread while the worker thread sits inside ProcessTasks, and either call
    // would be the engine's concurrent-use exception thrown out of the middle of the parent's script. The
    // loop above needs nothing here โ€” it observes IsEnded and wakes on the token.
}

WaitForScheduledWork is what keeps an idle worker cheap and responsive: it parks until a job arrives from any thread or the worker's own next timer comes due, so a postMessage from the parent is picked up immediately rather than at the end of a polling interval. It does not pump โ€” you call ProcessTasks() yourself, which is the same division every other host-driven API in Jint keeps.

N workers on one existing loop โ€” a game frame, say โ€” works too, with one thing to get right: ProcessTasks drains until empty, so an unbounded worker handler would eat the frame. Give each worker a slice, which is exactly what OperationDeadlineConstraint is for (no-op Reset(), so it survives the per-entry constraint reset, and it erupts as a TimeoutException from ProcessTasks):

foreach (var c in live)
{
    var slice = (OperationDeadlineConstraint) c.HostState!;   // registered in CreateWorkerEngine
    slice.Begin(TimeSpan.FromMilliseconds(2), c.TerminationToken);
    try { c.Worker.Advanced.ProcessTasks(); }
    catch (TimeoutException) { /* overran its slice; resumes next frame */ }
    finally { slice.End(); }
}

What a worker inherits: restrictions yes, grants no. request.CreateDefaultOptions() gives you a fresh Options โ€” never the parent's instance โ€” carrying the parent's whole restrictive posture: the seven Options.Constraints values, Host.StringCompilationAllowed, AgentCanSuspend, Json.MaxParseDepth, the parser bounds, the module-graph limits and ResultLimits, plus a replay of every constraint factory the parent registered, so each engine gets its own constraint instances. Otherwise new Worker() would be an eval escape hatch out of a hardened parent. What it does not carry is anything that grants a capability: fetch, EventSource, WebSocket, Storage, CacheApi and FetchEvents are subtracted whatever the parent has, CLR interop and the module loader are yours to give deliberately, and nesting is off โ€” a worker gets neither the Workers flag nor the provider, because a worker that can spawn workers is a grant, by implication, of the thing that manufactures engines. Turning nesting on is two visible lines, by which you accept the accounting; request.Depth and request.LiveWorkerCount are there to bound the tree. It is a convenience and not a security boundary: if you built the parent's Options from a hardening helper, build the worker's from the same one.

Two per-engine backstops sit under all of that, neither of them the policy โ€” the provider is the policy: Options.WebApi.Workers.MaxWorkers (default 16) and MaxQueuedMessages (default 16384) each refuse with a QuotaExceededError rather than letting a script manufacture engines, or fill a queue nobody drains, faster than any host policy was written to notice.

terminate() and close() are not the same mechanism with a flag. terminate() closes both ends at once, discards what the parent had already posted, and cancels the connection's token โ€” after which the worker's script stops within the engine's amortized check interval (64 statements) on its own thread, and not at all while it is inside one of your CLR calls. That published bound is stronger than the field's: V8's TerminateExecution is frame-bounded in the same way. Cancellation skips JavaScript finally blocks, which is exactly what the standard's abort a running script prescribes. close() from inside the worker is the opposite in every respect the standard makes it so: the turn that called it runs to completion, and the parent-side queue drains rather than being discarded โ€” so postMessage(result); close();, the commonest idiom there is, still delivers whether or not the parent had pumped in between.

Errors reach you by three routes. A load or parse failure fires a plain Event named error at the Worker object โ€” no message, no ErrorEvent, which is what the standard's own step says and what libraries branch on โ€” and ends the connection as StartupFailed with IsFaulted and a CLR Error on it, so a host sees a startup failure without wiring anything at all. An uncaught runtime error fires an ErrorEvent at the Worker object carrying message, filename, lineno, colno and error: null โ€” null for every worker error, because the thrown value belongs to the worker's realm and its thread, and because the standard says so; a worker that wants its parent to have the real failure catches it and postMessages it, where the serializer's Error support is deliberate. Whether the parent is told at all is gated on the standard's notHandled, so a worker-side preventDefault(), or a worker global onerror returning true, stops the propagation โ€” and your DiagnosticsSink still sees every report either way, because a host's diagnostics channel is not something the script it is running may switch off. Unhandled promise rejections stay on the worker's own global and reach the parent through nothing at all, and a constraint failure is never an event: it erupts from whatever is pumping, because a worker's budget is yours to observe and not the parent script's.

Restore and dispose end connections, on either side. A RestoreGlobalSnapshot or an Engine.Dispose on the parent ends every connection it created (ParentRestored / ParentDisposed), and the same on a worker engine ends its own (WorkerRestored / WorkerDisposed) โ€” both endpoints, in both directions, because a worker connection is one object spanning two engines rather than two independent peers, and a one-sided close would leave the survivor paying a full structured clone per postMessage forever. The Worker object stays alive and becomes inert: postMessage a no-op, terminate() idempotent, no further events.

Two smaller truths worth knowing before you rely on them. type: 'module' is required โ€” the standard's own default is 'classic' and Jint refuses it with a TypeError that names the fix, for the reasons every non-browser runtime refuses it. And on an engine that is only ever pumped, ProcessTasks runs jobs raw, so a replayed TimeoutInterval never fires and MaxStatements becomes a lifetime budget; the worker budget that does work is the pair built for it โ€” OperationDeadlineConstraint for wall-clock and MemoryLimitConstraint for allocations, both armed once with Begin/End โ€” plus the termination token for stopping.

Sharing state between a parent and a worker

No modern JavaScript runtime lets two threads share one JS heap, and Jint enforces that rather than hoping: a second thread entering an engine gets an InvalidOperationException, because the engine's speed is its unsynchronized state. What you have instead, in order of how much is shared:

  1. Move a buffer โ€” postMessage(v, { transfer: [buf] }) is genuinely zero-copy and genuinely one-way.
  2. Move a channel โ€” MessagePort transfer, which is the shape Comlink-style RPC is built from; a transferable stream rides the same way.
  3. Share a CLR object. Hand the same .NET object to both engines with SetValue: each builds its own ObjectWrapper, so no JsValue crosses, while the object underneath is one instance whose mutations both sides see. It is strictly more than SharedArrayBuffer offers, with three obligations โ€” the object's thread-safety is entirely yours, there is no reactivity (that is what the port in (2) is for), and it must be data rather than a bridge, since calling into an engine another thread is inside is the admission exception. It only works inward: a JS-born object is an engine-affine C# instance, so it crosses by clone or by transfer and no other way.
  4. SharedArrayBuffer โ€” refused with a DataCloneError, exactly as postMessage refuses one in a page that is not cross-origin isolated. Atomics.waitAsync, which Jint already runs on the pump, is the sanctioned replacement.

Uncaught script errors go to a DiagnosticsSink

A script's failures are not all catchable by the host: a promise rejects with nobody listening, a setTimeout callback throws long after Execute returned, a script calls reportError. Options.WebApi.Diagnostics.Sink is the one place all of that arrives.

sealed class LogSink : DiagnosticsSink
{
    public override void Report(DiagnosticEvent report) =>
        logger.LogWarning("{Kind}: {Message}", report.Kind, report.Exception?.Message ?? report.Value.ToString());
}

var engine = new Engine(options => options.UseWebApis().UseDiagnostics(new LogSink()));

There are four kinds of report. ReportedError is what a script handed reportError(e). UnhandledPromiseRejection is HostPromiseRejectionTracker's two operations, told apart by RejectionHandled โ€” and it is additive: the long-standing engine.Advanced.PromiseRejectionTracker event still fires exactly as it did, first. UncaughtCallbackError is an exception that escaped a callback the engine invoked for you. WorkerError is a failure inside a Worker that neither the worker nor the Worker object handled; its Value is the message as a string, because the thrown value belongs to the worker's realm and cannot cross, and it is its own kind so that one sink wired for a parent and its workers can tell the report it already heard from the worker's side apart from this one.

That last one is the reason a sink changes behaviour, so install one deliberately. With no sink a timer callback or an event listener that throws erupts out of whatever was running it, because the alternative would be to lose the error entirely. With a sink there is somewhere for it to go, so the engine reports it and carries on โ€” which is what the standards actually specify (HTML invokes a timer handler with exception behaviour "report"; DOM's inner invoke reports a throwing listener and moves to the next one). Errors that exist to bound execution are never reported and always erupt: a timeout, a cancellation, the statement, memory and recursion budgets. DiagnosticsSink.Null is therefore not the same as no sink โ€” it means "carry on, and tell me nothing".

A sink alone is enough; the Reporting feature flag only decides whether script can call reportError, and reportError without a sink is a documented no-op that never throws. The sink is read once, when the engine is built. It is called synchronously, on the engine's thread, and must be thread-safe if the same Options builds engines that run concurrently. The JsValues on a report belong to the reporting engine: read them inside the call rather than stashing them.

โ€ฆand script can watch them too: global error events

WebApiFeatures.GlobalEvents gives the global scope addEventListener, removeEventListener, dispatchEvent and self (=== globalThis), plus the ErrorEvent and PromiseRejectionEvent interfaces, so a script written for a browser or a worker can watch its own failures:

self.addEventListener('error', e => {
    // e.message, e.filename, e.lineno, e.colno, e.error
});
self.addEventListener('unhandledrejection', e => { /* e.promise, e.reason */ });

error is fired for a reportError(e) call, for an exception that escaped a timer callback, and for one that escaped an event listener โ€” HTML's report an exception, whose step 5 this is. unhandledrejection and rejectionhandled are fired for the two operations of HostPromiseRejectionTracker.

The global object itself is not an EventTarget, and this feature does not make it one โ€” nothing about its prototype, its own properties or the inline caches over them changes. The listener list lives on a synthetic target the engine keeps beside its timers; the three operations are ordinary global functions bound to it, and event.target, event.currentTarget and a listener's this are the global object, which is what a browser reports. The one thing a script could notice is that they ignore their this, so const f = addEventListener; f('error', h) works where a browser raises Illegal invocation.

Four rules are worth knowing before you rely on it:

A script's own error listener is only as live as your sink. GlobalEvents is part of WebApiFeatures.Default, so a script written for a browser registers self.addEventListener('error', โ€ฆ) on a default web-API engine quite happily โ€” and then hears nothing when a timer callback or a listener throws, because firing the event is a step of reporting and an engine with no sink has nowhere to report to (a reportError call still fires the event, being itself a request to report). The browser-like recipe is options.UseDiagnostics(DiagnosticsSink.Null), or Options.WebApi.Diagnostics.Sink set directly: the failure is reported rather than erupting, the script's handler runs, the pump carries on, and nothing is written on your side. Give it a real sink instead the moment you want those failures in your log โ€” neither choice touches the constraints, which erupt past the event and the sink alike.

An exception thrown while a report is being dispatched goes to the sink alone and starts no second dispatch, which is HTML's re-entrancy rule. And a RestoreGlobalSnapshot drops the listeners: they are closures over the cycle that just ended, over globals the restore has replaced.

Idle callbacks: requestIdleCallback

requestIdleCallback(callback, { timeout }) runs a callback when the engine has nothing better to do, handing it an IdleDeadline with didTimeout and timeRemaining(). A browser means "the slack before the next frame is due"; Jint has no frames, so the mapping is stated plainly:

Driving the engine from your own loop

engine.Advanced.TimeUntilNextScheduledWork answers the question a host loop actually has โ€” when should I pump? โ€” and engine.Advanced.ProcessTasks() is the pump. There is deliberately no third method that drains for a budget: Jint never starts a thread, so the work always runs on your thread anyway; what was missing was the timing, not another way to run it.

while (running)
{
    var until = engine.Advanced.TimeUntilNextScheduledWork;
    if (until is null || until <= frameBudget)
    {
        engine.Advanced.ProcessTasks();
    }

    RenderFrame();
}

TimeSpan.Zero means there is work to run right now (a queued job, a due timer, a waiting idle callback); a positive span is how long until the earliest timed work โ€” a setTimeout, an AbortSignal.timeout(), a delayed postTask, a requestIdleCallback timeout, an Atomics.waitAsync deadline โ€” comes due; null means nothing is scheduled. It is available on every target framework, not just .NET 8: the atomics deadline it reports is a core-engine one. It describes the engine's own schedule and not the outside world, so a null is "nothing timed is pending" rather than "nothing will ever happen".

engine.Advanced.WaitForScheduledWork(timeout, token) is the answer to the work that arrives from a background thread. A loop with no frame of its own would otherwise have to sleep on a ceiling of its own choosing, and then a job enqueued from another thread โ€” an interop Task settling, an asynchronous module load completing, a message posted in from a second engine โ€” waits out that ceiling before anything notices it: such a job has no due time for TimeUntilNextScheduledWork to report, so polling was the only way to find it. The wait blocks until there is something worth pumping and returns true, or returns false when the ceiling runs out; the token ends it with an OperationCanceledException.

while (!token.IsCancellationRequested)
{
    engine.Advanced.ProcessTasks();

    try
    {
        engine.Advanced.WaitForScheduledWork(TimeSpan.FromMilliseconds(50), token);
    }
    catch (OperationCanceledException)
    {
        break;
    }
}

It does not pump โ€” ProcessTasks() is still the pump, and there is still no third method that drains for a budget. It is bounded internally by the engine's own next due time, so a setTimeout(f, 1) wakes it in about a millisecond rather than at the ceiling above, and the ceiling is only a backstop. Treat a true as a hint and re-check your own condition: spurious wakes are expected. The wait claims the engine for its whole duration, so a second thread asking for it โ€” or for anything else guarded โ€” gets the usual "This Engine is already in use by another thread", which is what makes one drainer per engine self-enforcing. WaitForScheduledWorkAsync is the same contract without holding a thread. Both are available on every target framework and are unaffected by which web APIs are enabled.

engine.Advanced.CreateAbortSignal(cancellationToken) bridges your cancellation into script: hand the returned AbortSignal to fetch, to scheduler.postTask, or to a listener the script adds itself. Requires the Events feature. Cancelling the token never runs script on the cancelling thread โ€” it enqueues, and the abort happens on the next pump, the same contract setTimeout has โ€” so an engine nobody pumps never observes it. A token that is already cancelled yields an already-aborted signal on the spot, so fetch(url, { signal }) rejects without issuing a request. The registration is released when the abort lands, when RestoreGlobalSnapshot ends the cycle, and when the engine is disposed, so a long-lived host token does not retain a finished engine. An inbound request has its own door onto the same bridge โ€” see Hosting a fetch handler, where the token you pass the invocation is the handler's request.signal.

A ShadowRealm does not get these globals. Only the principal realm's global object is touched, which is deliberately more conservative than a browser (where these APIs are [Exposed=*]); a host that wants them inside a shadow realm can install them through Host.InitializeShadowRealm.

fetch is opt-in on its own

UseWebApis() never enables fetch, and WebApiFeatures.Default will never include it. Network egress from script is a decision you make explicitly:

var engine = new Engine(options => options.UseWebApis().UseFetch(fetch =>
{
    fetch.AllowedSchemes.Remove("http");                     // https only
    fetch.UrlFilter = uri => uri.Host.EndsWith(".example.org", StringComparison.OrdinalIgnoreCase);
    fetch.MaxResponseBytes = 1024 * 1024;
    fetch.Timeout = TimeSpan.FromSeconds(5);
    fetch.HttpClient = myClient;                             // or HttpClientFactory, for a per-tenant client
}));

var body = engine.Evaluate("fetch('https://api.example.org/x').then(r => r.json())").UnwrapIfPromise();

Enabling it also brings Events, Url, Files and Streams, because fetch's own surface is built out of them: a Request always has an AbortSignal, its URL is a WHATWG URL, response.blob() answers with a Blob, and response.body is a ReadableStream.

Headers, Request and Response are the standard's own classes โ€” the full Headers (sorted, combined iteration, getSetCookie), method normalization, redirect modes, clone(), Response.error(), Response.redirect(), Response.json(). A FormData body is serialized as multipart/form-data with a cryptographically random boundary, and formData() reads one back โ€” as well as an application/x-www-form-urlencoded body โ€” rejecting with a TypeError for anything else. duplex is read and validated, because it decides whether a body may be a stream at all (see below). credentials, cache, mode, referrer and integrity are accepted and ignored, the same convention Node and workerd follow, because there is no origin, cookie jar or HTTP cache here to honour them with.

Bodies stream, in both directions

response.body is a real ReadableStream, and a network response is read on demand: the promise resolves as soon as the headers are in, and the socket is only read when a consumer asks for the next chunk.

const r = await fetch('https://api.example.org/big.ndjson');
for await (const chunk of r.body) {
    // one Uint8Array per read; the transport is not read again until this loop comes back for more
}

The stream's high water mark is zero, so a script that takes the response and never reads its body never touches the socket again โ€” and body.cancel(), or a reader's cancel(), drops the connection. Chunks are delivered as generation-stamped event-loop jobs, exactly like every other cross-thread completion in Jint, so nothing about a body arrives on a thread the host did not pump. MaxResponseBytes is enforced on the running total per chunk; because the promise has already resolved by the time a body byte is read, a body that breaks the cap errors the stream rather than rejecting the fetch promise (a Content-Length that already exceeds it is still refused before the promise settles).

The Body mixin sits on top of that and is unchanged in behaviour: text(), json(), arrayBuffer(), bytes() and blob() read the stream to the end and answer with the whole body, bodyUsed is the stream's disturbed flag, and a body that is disturbed or locked makes the next consumer reject. clone() tee()s the stream, so each half has its own queue and its own flags. A body built from bytes โ€” new Response('โ€ฆ'), a Blob, a BufferSource โ€” keeps its bytes and only materializes a stream if something reads body, so the common new Response(x).text() costs no stream at all.

Every body the engine hands out is a byte stream โ€” response.body, request.body and Blob.stream() alike, as Fetch and File API both require โ€” so getReader({ mode: 'byob' }) works on all three and a download loop can recycle one buffer instead of allocating per chunk. A default reader sees exactly what it always did: one Uint8Array per chunk.

Uploads stream too. A request body given as a ReadableStream goes to the wire as the script produces it, which is the standard's duplex: 'half' โ€” and, as the standard says, that member is compulsory for such a body:

const { readable, writable } = new TransformStream();
const done = fetch('https://api.example.org/ingest', {
    method: 'POST',
    duplex: 'half',           // required whenever body is a ReadableStream; omitting it is a TypeError
    body: readable,
});

const writer = writable.getWriter();
for (const row of rows) { await writer.write(encode(row)); }   // each write reaches the socket
await writer.close();
await done;

Backpressure runs the whole way through: the next chunk is not read from your stream until the previous one has been handed to the transport, so a slow server slows the script rather than filling memory. The request goes out chunked, because nothing can compute a Content-Length in advance.

Three honest reductions, all of them consequences of the transport being HttpClient. A body-preserving redirect fails the fetch โ€” which is what the standard itself prescribes for a body with no source, since the bytes are gone once sent; a 303, which drops the body with the method, is followed normally. Nothing checks the negotiated HTTP version, where a browser refuses a streaming upload over HTTP/1.1; this sends it chunked, as every server-side HTTP client does. And the body can be sent once โ€” if HttpClient needs to resend the request, the fetch fails rather than sending a truncated body.

Like every other web API, fetch settles only while the engine is being pumped: the promise resolves inside a blocking UnwrapIfPromise, an await of EvaluateAsync, or your own engine.Advanced.ProcessTasks() loop. The deadline is the one exception, and deliberately so โ€” it is enforced CLR-side, so an engine nobody pumps still lets go of its socket, and it covers the body as well as the headers. An in-flight request is cancelled by Engine.Advanced.RestoreGlobalSnapshot, and its promise never settles into the restored engine; a body still arriving when that happens has its connection dropped and its stream errored, so a host holding the response is told rather than left waiting.

Security. Enabling fetch gives the script your process's network position: anything the worker can reach, the script can reach. The defaults bound the resource questions โ€” 32 MiB per response body (counted after decompression, so a compression bomb is bounded too), 20 redirects, a 30-second deadline, 10 concurrent requests โ€” but they cannot know which hosts are legitimate. That is what UrlFilter is for, and a deployment running untrusted script wants one. Jint follows redirects itself with AllowAutoRedirect off precisely so that every hop is re-checked against the scheme list and your filter: a server you allow answering 302 Location: http://169.254.169.254/โ€ฆ does not get to launder the request past a first-hop check. Authorization, Cookie and Proxy-Authorization are stripped across an origin change, header values may not carry CR or LF, and a URL with credentials in it is refused. Every network-class failure is one TypeError saying only Failed to fetch, so a script cannot map your internal network by reading the failures apart; the real cause rides the error value and is readable by the host through JintException.TryGetClrException. See THREAT_MODEL.md TM-21 for the full analysis, including what these controls do not cover.

Streams, including byte streams

ReadableStream, WritableStream and TransformStream implement the WHATWG Streams Standard operation by operation: queuing strategies and desiredSize, the pull reentrancy rules, tee() with its composite cancellation, pipeTo() / pipeThrough() with preventClose / preventAbort / preventCancel and an AbortSignal, asynchronous iteration of a readable stream (for awaitโ€ฆof, and values({ preventCancel })), and ReadableStream.from() for any sync or async iterable. Every promise they hand out is an ordinary engine promise and every callback you supply โ€” start, pull, cancel, write, close, abort, transform, flush, size โ€” runs on the engine's thread from the same job queue that runs promise reactions, so the microtask ordering the standard prescribes is the ordering you get, and nothing here ever starts a thread.

Byte streams are there too. new ReadableStream({ type: 'bytes' }) gives its underlying source a ReadableByteStreamController, with byobRequest, autoAllocateChunkSize and a desiredSize counted in bytes; stream.getReader({ mode: 'byob' }) gives the consumer a ReadableStreamBYOBReader whose read(view, { min }) fills the buffer the caller supplied. Buffers are transferred across every crossing, exactly as the standard requires โ€” hand a view to enqueue() or to a BYOB read() and yours is detached, while the one you get back owns the memory โ€” so a read loop recycles one allocation instead of allocating per chunk. tee() on a byte stream produces two byte streams, and swaps between a default and a BYOB reader on the original depending on how each branch is being read.

The streams the engine hands out are byte streams too: response.body, request.body and Blob.stream() each give a ReadableByteStreamController behind the scenes, so getReader({ mode: 'byob' }) works on a network response, on a buffered body and on a blob โ€” and tee() on any of them produces two byte streams. Engine.Advanced.CreateReadableStream(Stream) is the one that is still an ordinary stream: it wraps a host Stream you already own, whose reads go into a buffer the bridge owns rather than into one a script supplied.

One deliberate reduction: only the five interfaces a script constructs by name are globals. ReadableStreamDefaultReader, ReadableStreamBYOBReader, WritableStreamDefaultWriter, the four controllers and ReadableStreamBYOBRequest exist as ordinary interface objects โ€” a reader's constructor is the real thing, and new on it behaves as the standard says โ€” but they are not installed on globalThis, where a browser would expose them.

All three streams are transferable

ReadableStream, WritableStream and TransformStream are transferable objects, so a stream can be handed to another engine and read there โ€” which is what makes a worker-style split able to pass a pipeline and not only a value:

var host = new Engine(o => o.UseWebApis());
var worker = new Engine(o => o.UseWebApis());
var pair = host.Advanced.CreateMessagePortPair(worker);
host.SetValue("port", pair.Local);
worker.SetValue("port", pair.Remote);

worker.Execute("""
    port.onmessage = async e => {
      for (const reader = e.data.getReader(); ; ) {
        const { value, done } = await reader.read();
        if (done) break;
        log(value);
      }
    };
    """);

host.Execute("""
    const rs = new ReadableStream({ start(c) { c.enqueue('a'); c.enqueue('b'); c.close(); } });
    port.postMessage(rs, [rs]);   // rs is now locked and no longer directly usable
    """);

The mechanism is the standard's "cross-realm transform", and it is a MessagePort underneath: transferring a readable stream creates an entangled pair, wires a writable side onto one port, pipes the original stream into it and sends the other port along with the message. The receiving engine builds a readable stream over the port that arrived. A WritableStream is the mirror image, and a TransformStream is both โ€” its two sides are transferred separately, so it costs two channels. structuredClone(stream, { transfer: [stream] }) does the same thing into the current realm, which is what the two phases composed give you.

Both engines have to be pumped, exactly as for a MessagePort and for timers: a chunk written on the sender is an event-loop task on the receiver, so an engine nobody pumps receives nothing. Backpressure crosses too โ€” the receiving side's high water mark is 0 and each read sends one pull back โ€” so the sender does not drain its source into the channel ahead of whoever is reading. Everything runs on each engine's own thread; nothing here starts one.

The standard's refusals are implemented as written: transferring a locked stream is a DataCloneError, transferring the same stream twice is a DataCloneError, and a stream that is in the message but not in the transfer list is a DataCloneError โ€” a stream is transferable and not serializable. After a transfer the original is locked and disturbed, because the pipe holds a reader (or a writer) on it, and a transferred TransformStream leaves both of its sides that way. Closing, erroring and cancelling all propagate across the boundary in both directions, and a chunk that cannot be structured-cloned fails that write and errors both ends.

One thing goes beyond the standard, deliberately. HTML drops a message posted into a channel whose far end has gone, silently, so a transferred stream whose message is never delivered โ€” or whose receiving engine calls RestoreGlobalSnapshot, or is disposed โ€” would leave the sender's pipe reading its source forever and writing into nothing. Jint ends the stream instead: the next write (or the next read on the receiving side) finds the channel gone and errors, which cancels the source. A pipe is never left running against a side nobody can reach.

Storage is opt-in on its own, and you decide where the data lives

localStorage and sessionStorage are the one non-network feature UseWebApis() does not turn on. Every other web API gives a script something to do; this one gives it somewhere to keep things, and a host that asked for "the web APIs" has not agreed to that. Ask for it by name:

// Two in-memory stores, per engine, gone when the engine is.
var engine = new Engine(options => options.UseStorage());

// Or your own store, which is what makes anything persist or be shared.
var tenantStorage = new MyDatabaseStorage(tenantId);
engine = new Engine(options => options.UseStorage(tenantStorage));

The whole Storage interface is there โ€” length, key, getItem, setItem, removeItem, clear โ€” and so is the named property access the standard defines alongside it, so storage.foo = 'bar', delete storage.foo, 'foo' in storage and Object.keys(storage) all work. Storage is a WebIDL legacy platform object, which decides the one rule that surprises people: an interface member always wins over a stored key of the same name, so storage.getItem is the method even after storage.setItem('getItem', 'x') โ€” and the key is still stored, and storage.getItem('getItem') still reads it back.

Where the data lives is entirely StorageProvider's business. Implement it over a file, a database, a per-tenant cache or a request-scoped dictionary; the engine implements the algorithms and never persists anything itself. With no provider each engine gets its own InMemoryStorageProvider โ€” nothing is shared between engines, nothing survives one, and Options.WebApi.Storage.MaxTotalBytes (5 MB by default) bounds it, turning an over-quota setItem into the catchable QuotaExceededError the standard names โ€” with quota and requested filled in, so a script can report how far over it went. Your own provider raises the same error by throwing StorageQuotaExceededException, and the constructor overload taking quota and requested is how it supplies those two numbers; anything else it throws reaches you unchanged rather than becoming a JavaScript error the script can swallow. A provider reached from engines that run concurrently must be thread-safe.

There is no storage event. Every mutating step ends in "broadcast", which notifies other browsing contexts sharing an origin โ€” a multi-context feature, and an engine has one context. StorageEvent is absent rather than present-and-never-firing, so feature detection sees the truth.

EventSource is a second, separate grant

Server-sent events are their own opt-in: UseFetch() does not enable EventSource, UseEventSource() does not enable fetch, and UseWebApis() enables neither.

var engine = new Engine(options => options.UseWebApis().UseEventSource(net =>
{
    net.UrlFilter = uri => uri.Host.EndsWith(".example.org", StringComparison.OrdinalIgnoreCase);
    net.MaxResponseBytes = 64 * 1024;   // the largest single event, not the largest stream
    net.MaxConcurrentRequests = 2;      // at most two streams open at once
}));

engine.Execute("""
    const events = new EventSource('https://api.example.org/updates');
    events.onmessage = e => console.log(e.lastEventId, e.data);
    """);

while (running) { engine.Advanced.ProcessTasks(); Thread.Sleep(5); }   // your loop, your thread

It is the standard's own object: url, readyState with CONNECTING/OPEN/CLOSED, onopen/onmessage/ onerror, close(), and addEventListener for the custom types an event: field names. The stream is parsed exactly as the specification writes it โ€” UTF-8 with a leading BOM stripped, CRLF/CR/LF line endings, data/event/id/retry fields, one leading space removed after the colon, comment lines as keep-alives, data values joined with a newline, and an event dispatched only at a blank line. withCredentials is accepted, remembered and ignored, the same treatment fetch gives credentials: there is no origin, cookie jar or credential store here for it to select.

It reads Options.WebApi.Fetch โ€” the same transport (HttpClient / HttpClientFactory) and the same policy (AllowedSchemes, UrlFilter, MaxRedirects) that fetch uses, so a filter you have already written covers both, and every redirect hop is re-checked exactly as it is for a fetch. Three of those settings mean something different for a stream:

Reconnection rides the timer queue, so it too happens only while you are pumping, and it counts against MaxActiveTimers. A stream that ends, or a network error, sets readyState back to CONNECTING, fires error, waits the server's retry: value (3 seconds by default) and connects again โ€” re-running the URL policy from scratch and sending Last-Event-ID so the server can resume. A response that is not 200 text/event-stream, a URL your policy refuses and an event over the size cap all fail the connection instead: readyState becomes CLOSED and nothing retries. close() cancels the request in flight, and so does Engine.Advanced.RestoreGlobalSnapshot โ€” after a restore the connection is gone, its pending reconnection with it, and nothing from the ended cycle is ever dispatched into the restored engine.

Security. Everything TM-21 says about destinations applies here, and one thing more: a connection is long-lived and reconnects on a delay the server chooses, with no deadline to end it. See THREAT_MODEL.md TM-22 โ€” the short version is to bound the engine's own lifetime for untrusted script rather than pooling an engine a script may leave streaming.

WebSocket is the third separate grant

Sockets are their own opt-in, exactly as fetch and EventSource are: UseWebSocket() enables none of the other two, they enable no sockets, and UseWebApis() enables none of the three. They share their settings, not their permission.

var engine = new Engine(options => options.UseWebApis().UseWebSocket(net =>
{
    net.AllowedSchemes.Remove("http");                   // wss only
    net.UrlFilter = uri => uri.Host.EndsWith(".example.org", StringComparison.OrdinalIgnoreCase);
    net.MaxResponseBytes = 1024 * 1024;                  // the largest single message
    net.MaxConcurrentRequests = 2;                       // at most two sockets open at once
}));

engine.Execute("""
    const ws = new WebSocket('wss://api.example.org/feed', ['v2']);
    ws.onopen = () => ws.send('hello');
    ws.onmessage = e => console.log(e.data);
    ws.onclose = e => console.log(e.code, e.reason, e.wasClean);
    """);

while (running) { engine.Advanced.ProcessTasks(); Thread.Sleep(5); }   // your loop, your thread

It is the standard's own object: url, readyState with the four ready-state constants, bufferedAmount, protocol and extensions as the handshake negotiated them, binaryType switching binary messages between Blob (the default, as in a browser) and ArrayBuffer, send of a string, Blob, ArrayBuffer or a view over one, close(code, reason) with the standard's validation, the onopen/onmessage/onerror/onclose handlers, and the full EventTarget surface underneath them. A close lands as the real CloseEvent โ€” code, reason, wasClean โ€” with RFC 6455's 1000 for a clean close and 1006 when the transport simply died. Every event dispatches from the engine's job queue on the engine's thread, only while you pump.

It reads Options.WebApi.Fetch, with the scheme list read in its WebSocket sense: http admits ws and https admits wss โ€” so a policy written for fetch carries over โ€” and naming ws or wss outright works too, for a host that wants sockets and not fetches. The UrlFilter is shown the ws: URL the script asked for, which fails safe: a filter that tests uri.Scheme == "https" refuses every socket rather than admitting one it was never shown. There is no per-hop re-check because there are no hops โ€” the WHATWG handshake forbids redirects outright. Three settings shift meaning the same way they do for an event stream:

The lifecycle is fenced exactly as a fetch is: the realm and the event-loop generation are captured at construction, so Engine.Advanced.RestoreGlobalSnapshot closes the socket and nothing from the ended cycle โ€” no message, no close event โ€” is ever dispatched into the restored engine. An execution constraint that erupts through a handler stays a constraint: it is never flattened into an error event a script could swallow.

Security. See THREAT_MODEL.md TM-24 for the full analysis. The short version: everything TM-21 says about destinations applies, the channel is bidirectional โ€” admit only destinations you would let the script write to โ€” and a socket is long-lived by design with a peer that can keep it alive indefinitely, so bound the engine's lifetime for untrusted script rather than pooling an engine a script may leave connected.

Hosting a fetch handler

Headers, Request and Response work in both directions, so an engine can be a request handler: you hand it an HttpRequestMessage and get an HttpResponseMessage back, with the script in between written exactly the way a Cloudflare Workers or Deno script is.

// worker.js
export default {
    async fetch(request) {
        const body = await request.json();
        return Response.json({ echoed: body, path: new URL(request.url).pathname });
    }
};
var engine = new Engine(options => options.UseWebApis(
    WebApiFeatures.Events | WebApiFeatures.Url | WebApiFeatures.Files | WebApiFeatures.Timers));

engine.Advanced.SetFetchHandler(engine.Modules.Import("./worker.js"));

using var response = await engine.Advanced.InvokeFetchHandlerAsync(request);

SetFetchHandler accepts three shapes, tried in this order: a function; an object with a callable fetch property (called with that object as this, so a handler written as a method can reach its siblings); or an object with a default property matching either โ€” which is what a module namespace is, so the export default { fetch } convention above is registered in one call. A script that has no modules registers its handler just as directly:

engine.Execute("function handle(request) { return new Response('hi'); }");
engine.Advanced.SetFetchHandler(engine.GetValue("handle"));

There is no feature flag for registering a handler this way, and doing so never grants network access. (The script-facing form below does have one, because letting a script claim the route is a different decision.) What the object model needs is the three features its own interfaces are built out of โ€” a Request has an AbortSignal, its URL is a WHATWG URL, response.blob() answers with a Blob โ€” so an engine built without Events | Url | Files refuses the registration with a message naming them. Registering the handler is itself what installs the Headers, Request and Response globals, lazily and without replacing anything already under those names; fetch is deliberately not installed, because handling an inbound request is no reason to let the script make outbound ones. (options.UseFetch() satisfies the requirement too, and does grant them.) One ordering consequence: those globals appear when you register the handler, so a module that builds a Response at top level has to be evaluated after the registration โ€” the ordinary shape, where the handler builds its response when it runs, does not care.

Only the request is passed. A Workers handler takes (request, env, ctx); per-request host state reaches this one the way it always has, through engine.Advanced.AddLazyGlobal(...) or a host function reading engine.Advanced.HostDefined.

The script-facing form: addEventListener('fetch', โ€ฆ)

Enable WebApiFeatures.FetchEvents and the script registers the handler itself, with no host call at all โ€” which is what a service worker and a Cloudflare Workers script written in the older, non-module style look like. The host keeps calling exactly the same InvokeFetchHandler / InvokeFetchHandlerAsync it already does:

// worker.js
addEventListener('fetch', event => {
    event.respondWith(handle(event.request));

    // Fire-and-forget work: it keeps the event alive, and it is just jobs you pump.
    event.waitUntil(recordHit(event.request.url));
});

async function handle(request) {
    const body = await request.json();
    return Response.json({ echoed: body, path: new URL(request.url).pathname });
}
var engine = new Engine(options => options.UseWebApis(WebApiFeatures.FetchEvents));
engine.Execute(File.ReadAllText("worker.js"));

using var response = await engine.Advanced.InvokeFetchHandlerAsync(request);

The flag is its own grant, and is not Fetch. Naming it does not enable fetch and enabling fetch does not name it: one lets the script reach out to the network, the other routes requests you already have in to the script, and coupling them would contradict the refusal SetFetchHandler already makes. What it does imply is GlobalEvents (where addEventListener comes from, which brings Events), Url and Files โ€” the same closure the object model needs โ€” and it installs Headers, Request and Response while the engine is being built, so unlike the SetFetchHandler door a module may construct a Response at top level.

A handler registered with SetFetchHandler always wins. The listeners are the fallback for an engine that has none, never an override of the one you chose; clearing the handler with null is how you hand the route over deliberately. HasFetchHandler stays strictly about SetFetchHandler for the same reason โ€” it is your record of what you did, and a script must not be able to change the answer. To ask whether a request can be served, invoke and let it fail.

Not responding is a failure, like every other. A dispatch that reaches no event.respondWith(...) โ€” no listener called it, or the ones that ran threw โ€” fails the operation with an InvalidOperationException, because an embedded engine has no network for an unanswered request to fall through to. A listener that throws behaves as it does anywhere else on an EventTarget: with a DiagnosticsSink it is reported and the dispatch carries on, so a later listener may still answer; with no sink it propagates, and the invoke turns it into the operation's failure. (A listener that answers and then throws therefore serves its response on an engine with a sink and fails on one without โ€” with nowhere to report to, preferring the response would lose the exception entirely.) A constraint firing is neither, and erupts past both.

FetchEvent carries request, respondWith(r) and waitUntil(f), and two reductions from the Service Workers Standard are deliberate. There is no ExtendableEvent interface object โ€” waitUntil is a member of FetchEvent.prototype, the flat shape Workers exposes โ€” and there is no timed-out flag, so the event stays extendable exactly as long as its own promises are pending and no longer; respondWith after the dispatch, or waitUntil once nothing is outstanding, is an InvalidStateError. respondWith may be called once, stops the dispatch for every later listener, and takes a Response or a promise of one. A waitUntil promise that rejects is reported once, through unhandledrejection and the sink, rather than vanishing โ€” attaching the lifetime reaction is what would otherwise have made it look handled. preloadResponse, clientId, resultingClientId, replacesClientId and handled are absent rather than faked, since every one of them describes a service worker registration that does not exist here. One timing difference from the handler form: respondWith puts its argument through PromiseResolve, so even the most synchronous listener needs one turn of the pump.

Two invoke shapes, and the difference is who owns the thread. InvokeFetchHandlerAsync is the one an ASP.NET Core host wants โ€” await it and the continuations run wherever the await resumes. InvokeFetchHandler returns a FetchHandlerOperation and runs nothing on its own: you pump the engine and watch the operation, which is the shape a game loop or a UI thread needs, because then every turn provably runs where you decided. It is the same pair Engine.Modules.ImportAsync / StartImport offers, for the same reason.

var operation = engine.Advanced.InvokeFetchHandler(request);
while (!operation.IsCompleted)
{
    engine.Advanced.ProcessTasks();     // your loop, your thread
}

using var response = operation.GetResult();

A handler answering a Response synchronously produces an operation that is already complete; one answering a promise โ€” every async handler, and anything that awaits a timer or an outbound fetch โ€” needs turns. As everywhere else in these APIs, a setTimeout inside a handler only fires while somebody is pumping.

A failing handler is never turned into a 500 for you. What a failure means on the wire is a policy question only the host can answer, so it arrives as the exception it was: a JavaScriptException when the handler threw, a PromiseRejectedException when its promise rejected, the constraint's own exception when an execution constraint fired, an InvalidOperationException when the handler answered with something that is not a Response (including Response.error(), which represents a network error and has no status line). All of Jint's own exceptions derive from JintException, so that is the one type to catch:

try
{
    using var handled = await engine.Advanced.InvokeFetchHandlerAsync(request, context.RequestAborted);
    // ... copy it onto the real response
}
catch (JintException ex)
{
    logger.LogWarning(ex, "the script handler failed");
    context.Response.StatusCode = StatusCodes.Status500InternalServerError;
}

With the polled shape the same failures arrive through the operation instead โ€” IsFaulted, Error, or rethrown by GetResult() โ€” including a failure of the invoke call itself, so a host written to poll-then-GetResult never has to guard the start call as well.

The handler runs under the engine's execution constraints like every other host entry, so MaxStatements, TimeoutInterval, LimitMemory and any custom Constraint bound one invocation. Read Execution Constraints before relying on that: the budget is per entry into the engine, so each turn you pump afterwards gets a fresh one โ€” a wall-clock bound over the whole request is what Jint.Constraints.OperationDeadlineConstraint is for, bracketed around the invoke and the pump. The CancellationToken taken by InvokeFetchHandlerAsync behaves exactly as EvaluateAsync's does where the await is concerned: it is observed at event-loop continuation boundaries and does not preempt the interpreter, so bounding a handler that never yields is a constraint's job.

Telling the handler the client is gone

Pass the token you already hold and the handler's request.signal becomes a real AbortSignal instead of one that can never fire โ€” which is what a script written for Workers or Deno expects, and what an outbound fetch(upstream, { signal: request.signal }) needs in order to stop:

var operation = engine.Advanced.InvokeFetchHandler(request, context.RequestAborted);

InvokeFetchHandlerAsync's existing CancellationToken does this too. That is deliberately an addition to what an existing parameter does rather than a second parameter: one token on a request invocation means "this request has been abandoned", which is exactly what HttpContext.RequestAborted means and exactly what request.signal is for. Passing nothing, or CancellationToken.None, gives precisely the engine you had before.

Four things follow from where the abort happens:

The polled shape is the one to prefer when a handler is meant to answer on abort. With the awaitable shape the same token also ends the await, and the two are not ordered against each other: cancelling mid-flight normally throws OperationCanceledException out of the call before the handler has had the turn in which to notice. With InvokeFetchHandler you decide when to stop pumping, so you can give it that turn.

The FetchEvent route carries the same signal, so a Workers-shaped script reading event.request.signal is told the same truth.

Request bodies are read in full before the handler runs โ€” bodies here are buffered, not streamed. The awaitable shape reads them without blocking; the polled shape reads them on the calling thread, so buffer the content yourself (a ByteArrayContent) if it is still arriving off a socket. On the way out, Content-Length and Transfer-Encoding are dropped: those describe the message your HTTP stack is actually writing, and a script's claim about a body it is not sending would be a response-splitting primitive. Everything else is copied verbatim, with equally-named headers kept apart โ€” several Set-Cookies stay several.

ASP.NET Core: a sandboxed per-request script handler

A documented sample rather than a package: Jint takes no dependency on ASP.NET Core, and this is all the glue there is. The engine comes from a pool the host owns โ€” one engine per concurrent request, since an Engine is not thread-safe โ€” and every one of them has had SetFetchHandler called on it.

var scriptOptions = new Options()
    .UseWebApis(WebApiFeatures.Events | WebApiFeatures.Url | WebApiFeatures.Files | WebApiFeatures.Timers)
    .MaxStatements(100_000)
    .LimitMemory(16 * 1024 * 1024)
    .TimeoutInterval(TimeSpan.FromSeconds(2));

app.Map("/{**path}", async (HttpContext context, EnginePool pool, ILogger<Program> logger) =>
{
    using var lease = pool.Rent();                       // an engine with the handler already registered

    var buffer = new MemoryStream();
    await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);

    var inbound = new HttpRequestMessage(new HttpMethod(context.Request.Method), context.Request.GetEncodedUrl())
    {
        Content = new ByteArrayContent(buffer.ToArray()),
    };

    foreach (var header in context.Request.Headers)
    {
        // A content header is refused by the request collection and accepted by the content's, which is how
        // the two halves of System.Net.Http's split are told apart. Jint merges them back into one list.
        if (!inbound.Headers.TryAddWithoutValidation(header.Key, (IEnumerable<string>) header.Value))
        {
            inbound.Content.Headers.TryAddWithoutValidation(header.Key, (IEnumerable<string>) header.Value);
        }
    }

    HttpResponseMessage handled;
    try
    {
        handled = await lease.Engine.Advanced.InvokeFetchHandlerAsync(inbound, context.RequestAborted);
    }
    catch (JintException ex)
    {
        // The host's policy, not Jint's: a script failure is a 500 here, a rendered error page elsewhere.
        logger.LogWarning(ex, "the script handler failed");
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return;
    }

    using (handled)
    {
        context.Response.StatusCode = (int) handled.StatusCode;
        foreach (var header in handled.Headers.Concat(handled.Content.Headers))
        {
            context.Response.Headers[header.Key] = new StringValues(header.Value.ToArray());
        }

        await handled.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
    }
});

Two things worth keeping when you adapt it. The engine must not be shared between concurrent requests, and a pooled one should have engine.Advanced.RestoreGlobalSnapshot(...) called on it between leases so one request's globals are not visible to the next. Take that snapshot after registering the handler, since registering is what installs Request/Response/Headers and a restore returns the global object to its state at capture; the handler itself is host state and survives the restore either way, so it never needs re-registering. And bound the script: the constraints above are what stand between a rented engine and a handler that decides to loop forever. Note that the context.RequestAborted already being passed to the invoke is what makes the handler's request.signal fire when the client disconnects โ€” see Telling the handler the client is gone.

Text and compression transform streams need two flags

TextEncoderStream, TextDecoderStream, CompressionStream and DecompressionStream are each one standard's algorithm running inside the Streams Standard's machinery, so each needs both flags: the text pair wants Encoding | Streams, the compression pair Compression | Streams. Naming one half installs neither global, which is the honest answer for feature detection โ€” and UseWebApis() enables all four anyway. None of them is a TransformStream subclass: like a browser, each is its own interface exposing a readable and a writable, and the transform behind it is never handed to script.

const bytes = textReadable.pipeThrough(new TextEncoderStream()).pipeThrough(new CompressionStream('gzip'));
const text = bytes.pipeThrough(new DecompressionStream('gzip')).pipeThrough(new TextDecoderStream());

TextEncoderStream carries the standard's leading-surrogate slot, so a surrogate pair split across two chunks is reassembled into the one scalar value it denotes rather than becoming two U+FFFDs, and TextDecoderStream is the same decoder TextDecoder uses with stream: true โ€” a UTF-8 sequence, a UTF-16 code unit or a byte order mark split across chunks all decode as if the bytes had arrived in one piece. A fatal decoder errors both sides with a TypeError, as the standard prescribes.

For compression, note that the standard's deflate is RFC 1950's ZLIB container, named that way for consistency with HTTP Content-Encoding; raw RFC 1951 DEFLATE is the separate deflate-raw. Jint maps them onto ZLibStream and DeflateStream accordingly (and gzip onto GZipStream), so bytes produced here are the bytes every other implementation expects. brotli, which the standard's enumeration also names, is not implemented and โ€” like any unsupported value โ€” raises a TypeError. Input a format rejects (a bad header, a failed CRC32 or ADLER32, a malformed block) errors both sides with a TypeError; two truncation cases the standard also calls errors are the documented exception, because .NET exposes no incremental inflater that could report them: a stream that ends mid-member closes cleanly instead, and bytes following a complete member are ignored. A stream that ends with no compressed bytes at all is refused.

Bridging a stream to System.IO.Stream

Engine.Advanced connects the two worlds in both directions, so a host never has to write the pump itself:

var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Streams | WebApiFeatures.Encoding));

// A .NET stream the script can read, with backpressure: nothing is read until the queue wants a chunk.
engine.SetValue("input", engine.Advanced.CreateReadableStream(File.OpenRead("in.txt")));

// A .NET stream the script can write; `await writer.close()` is its proof the bytes were flushed.
engine.SetValue("output", engine.Advanced.CreateWritableStream(File.Create("out.txt")));

// And a script stream read back into .NET โ€” here, a file transformed through a script TransformStream.
var upperCased = engine.Evaluate("""
    input.pipeThrough(new TransformStream({
      transform(chunk, controller) {
        const text = new TextDecoder().decode(chunk);
        controller.enqueue(new TextEncoder().encode(text.toUpperCase()));
      }
    }))
    """);

var copy = engine.Advanced.StartReadableStreamCopy(upperCased, File.Create("shouted.txt"));
while (!copy.IsCompleted)
{
    engine.Advanced.ProcessTasks();   // the host owns the turns
}

Console.WriteLine($"{copy.GetResult()} bytes");   // throws PromiseRejectedException if the copy failed

The threading contract is the whole design, and it is the same one the timers have. Everything that touches the engine happens on the engine's thread, from an event-loop job stamped with the cycle it was registered in; everything that touches your Stream happens on whichever thread the BCL's asynchronous I/O completes on, and produces nothing but bytes and exceptions. Three consequences:

Backpressure is the standard's own, in both directions: HighWaterMark chunks are read ahead, writer.ready stops being resolved once that many are queued, and a copy has one chunk in flight at a time. A failure on your stream errors the script's stream with a TypeError whose message names the exception's type but not its text โ€” the exception itself rides the error value, where JintException.TryGetClrException can read it and the script cannot.

What a script may write to a host stream is a BufferSource, a Blob or a string (UTF-8 encoded). Anything else is a TypeError rather than a stringification, because [object Object] silently appended to your file is not a failure mode worth having.

There is deliberately no adapter presenting a script's ReadableStream as a System.IO.Stream. Its Read would have to drive the engine from whichever thread called it, which is the one thing a single-threaded engine cannot allow; the copy operation above is the same capability with the thread question answered.

Node compatibility (opt-in)

Not everything a script expects is a web standard. NodeStyleModuleLoader (npm-style packages) resolves bare specifiers the way Node does; UseNodeProcess() adds the other thing a script written for Node reaches for, a process object โ€” most often to read process.env.NODE_ENV or to branch on process.platform.

var engine = new Engine(options => options.UseNodeProcess(p =>
{
    p.EnvironmentVariableAllowlist = ["NODE_ENV"];
    p.EnvironmentOverrides = new Dictionary<string, string> { ["NODE_ENV"] = "production" };
}));

engine.Evaluate("process.env.NODE_ENV"); // "production"

Not one environment variable is readable until you list it. EnvironmentVariableAllowlist is empty by default, so process.env starts out an empty object, and only the names on it are ever looked up โ€” the engine never enumerates the environment block, so a variable you did not name is not read, not copied and not reachable from the engine at all. EnvironmentOverrides supplies values of your own, which win over the real environment and are filtered by the same allowlist, so a test fixture cannot accidentally widen what a script can see; an entry whose value is null hides the variable rather than falling through to the real one.

Member What it answers
process.env The allowed variables, materialized once when process is first touched โ€” not a live view. Writes, and delete, are script-local: they never reach the real environment.
process.platform "win32", "darwin" or "linux" for the platform you are actually on; settable, e.g. to one of Node's other values.
process.version "v0.0.0-jint" by default โ€” deliberately not a Node version. Settable for a dependency that insists on one.
process.versions { jint: "<assembly version>" }. There is no node key, so feature detection has something truthful to find.
process.argv An empty array. Jint launched no process and the script has no path of its own.
process.cwd() WorkingDirectory, "/" by default. Never the real current directory, which names a deployment layout and often a user account.
process.nextTick(cb, ...args) Queues cb onto the engine's job queue with the arguments forwarded, so it runs after the current script and before any timer.
process.hrtime.bigint() A monotonic reading in nanoseconds, for measuring elapsed time. The legacy tuple form process.hrtime() is absent.

exit, abort, kill and chdir are absent rather than throwing: an embedded script must not be able to act on the host process, and a missing function lets a script's own typeof check take its other branch where a throwing one would not. process is a plain object, not Node's EventEmitter, so there is no on/emit either. One divergence worth knowing: Node drains its next-tick queue ahead of the promise microtask queue, while Jint has a single job queue, so a nextTick callback and a .then() reaction run in registration order.

The global is installed lazily and non-clobbering, exactly like the web APIs โ€” a process you registered yourself is left alone whichever order the calls were made in โ€” and a ShadowRealm does not get it. Every option is read once, when UseNodeProcess returns, so one Options instance stays safe to share across engines. Unlike the web APIs above, this needs no particular target framework: it compiles for every one Jint targets.

node: builtin modules (opt-in)

After resolution, the next wall a package published for Node runs into is import 'node:path'. UseNodeBuiltinModules() supplies the ones that are pure string utilities โ€” no file system, no process, no network, no clock โ€” and nothing else.

var engine = new Engine(options => options
    .EnableModules(new NodeStyleModuleLoader(@"C:\app"))
    .UseNodeBuiltinModules());

engine.Modules.Import("./main.js"); // main.js, and anything in node_modules, may import 'node:path'
Module What it provides
node:path resolve, normalize, isAbsolute, join, relative, dirname, basename, extname, format, parse, toNamespacedPath, sep, delimiter, and both flavours as posix and win32. matchesGlob is absent โ€” it is the one member that is not string arithmetic.
node:path/posix, node:path/win32 The two flavours as modules of their own.
node:querystring parse/decode, stringify/encode, escape, unescape. unescapeBuffer is absent: it answers with a Buffer.
node:url URL and URLSearchParams (the engine's own WHATWG implementations, re-exported), fileURLToPath, pathToFileURL, domainToASCII, domainToUnicode. The legacy url.parse/url.resolve/url.format API is absent.

Nothing that touches a platform resource is provided, and that is not a gap to be filled later. node:fs, node:buffer, node:crypto, node:os, node:child_process, node:http and their kind are deliberately absent, so a script feature-detecting one takes its other branch instead of walking into a stub. An unknown node: specifier fails with a message naming what is available.

Both spellings work โ€” import 'path' as well as import 'node:path' โ€” and both name one module, because a builtin outranks a node_modules package of the same name exactly as it does in Node (ESM_RESOLVE, PACKAGE_RESOLVE step 3). Set AllowUnprefixedSpecifiers = false for a tree that really does depend on an npm package called path, url or querystring.

A module you register yourself wins: engine.Modules.Add("node:path", โ€ฆ) โ€” or Add("path", โ€ฆ), which resolves to the same key โ€” replaces the builtin, and is also how you supply one of the modules Jint does not provide. Everything that is not a builtin keeps going to whichever loader you configured, in whichever order you called the two methods, and options.Modules.ModuleLoader still reads back exactly what you set.

Two options are worth setting. Platform decides which flavour node:path defaults to and defaults to the platform you are on, the same answer process.platform gives. WorkingDirectory is what path.resolve() and path.relative() use where Node reads process.cwd(); like the process shim's, it defaults to "/" and never answers the real current directory.

node:querystring and node:url need .NET 8 or newer, because both build on the engine's WHATWG URL implementation. node:path is available on every target framework Jint has.

The Cache API stores through a provider you supply

caches is the Service Workers Standard's CacheStorage โ€” open, has, delete, keys, match โ€” with the full Cache behind it: match / matchAll with ignoreSearch, ignoreMethod and ignoreVary, put, add, addAll, delete, keys, the request-matching algorithm including Vary, and the batch semantics that make a failed addAll store nothing at all.

Jint implements the object model and delegates the storage:

var engine = new Engine(options => options.UseCacheApi(cache =>
{
    cache.Provider = myProvider;   // omit for a private in-memory store per engine
}));

var body = engine.Evaluate(
    "(async () => {" +
    "  const cache = await caches.open('v1');" +
    "  await cache.put('https://example.org/a', new Response('hi'));" +
    "  return (await cache.match('https://example.org/a')).text();" +
    "})()").UnwrapIfPromise();

A CacheStorageProvider opens, lists and deletes named caches; each CacheStore lists its entries and applies one CacheWrite โ€” the removals and the additions of a whole operation together, so a provider that writes it in a transaction gets the standard's all-or-nothing behaviour for free. A request/response pair crosses that seam as CacheEntry, a plain CLR record with no engine reference in it, so it can go into a dictionary, a file, SQL or Redis. Everything is called on the engine's thread, synchronously, and any exception becomes a rejection: CacheQuotaExceededException as the QuotaExceededError a browser raises โ€” optionally carrying quota and requested โ€” anything else as a TypeError whose cause your host can read with JintException.TryGetClrException.

Enabling it also brings Headers, Request and Response (and Events, Url, Files under them), because a cache is a list of request/response pairs. It does not bring the network: cache.add and cache.addAll fetch, so they additionally need UseFetch and reject with a TypeError naming it until they have it โ€” and when they do have it, they go through the very same policy, so your UrlFilter, scheme list, size cap and deadline bound them exactly as they bound a fetch the script wrote itself. Every other Cache method works with no network at all, which is what lets a host populate a cache from its own data and a script read it back.

The default store has no quota. Left unconfigured, each engine gets a private InMemoryCacheStorageProvider that grows until the process runs out of memory, and its contents survive RestoreGlobalSnapshot โ€” a restore reverts global bindings, not host storage. A deployment running untrusted script implements the provider itself: that is where a size limit, an eviction policy and a per-tenant partition belong. See THREAT_MODEL.md TM-23 for the full analysis.

Performance

You can check out the engine comparison results, bear in mind that every use case is different and benchmarks might not reflect your real-world usage.

Embedding performance

Notes for hosts that project their own objects into script, pool engines, or bound execution. Each of these is a cost model rather than a rule; the XML documentation on the named APIs has the detail.

Migrating CLR array projections. CLR arrays now default to ArrayConversionMode.Copy. This is a breaking compatibility change from the live-view default introduced in 4.14: the safer default gives script a native, independent JsArray snapshot (Array.isArray is true) that it can mutate and resize without changing the host's array, and later CLR-side mutations are not visible through that snapshot. The default recent-wrapper cache means repeated crossings of the same CLR array reuse that first snapshot while it remains cached, so JavaScript identity and script-side mutations persist across those reads; TrackObjectWrapperIdentity extends that identity for the wrapper-map lifetime, while disabling both caches restores a fresh snapshot per crossing.

Hosts that intentionally need shared mutable state can preserve the previous behavior explicitly:

var engine = new Engine(options =>
    options.Interop.ArrayConversion = ArrayConversionMode.LiveView);

LiveView avoids the copy and its allocation, and exposes a fixed-size array-like wrapper. Reads stay connected to the CLR array, but selecting LiveView does not grant write authority: script mutations that would reach the backing array remain denied unless AllowClrWrite() is separately enabled. With both opt-ins, element writes change the CLR array. Repeated wrappers over the same CLR array compare equal by target identity even with caches disabled; the wrapper caches determine whether the same wrapper instance is reused. It is not a native JavaScript array (Array.isArray is false), and resizing operations throw. Multidimensional and non-zero-based CLR arrays remain unsupported in either mode: non-empty instances fail during conversion, while zero-length instances currently fall through as empty snapshots. Choose Copy for isolation and JavaScript-array compatibility; opt into LiveView when live observation and avoiding the initial O(N) copy and allocation are required, and authorize writes separately. Once a snapshot is cached, its native JavaScript-array element access can be as fast as or faster than traversing the live wrapper.

Projecting host data. Subclassing ObjectInstance is the most expensive way to expose data. Such a receiver gets no own-property inline caching โ€” every own read reaches your GetOwnProperty and allocates the PropertyDescriptor it returns. Cheaper options, in order of preference:

Lazy values. PropertyFlag.CustomJsValue is the supported hook for a property whose value is computed on every read: a PropertyDescriptor subclass overriding CustomValue keeps working under the read inline caches, because every caching lane re-reads the flag on each hit and caches the descriptor reference rather than a value snapshot. When the value is lazy only once, use PropertyDescriptor.CreateLazy(state, factory) instead โ€” it memoizes the produced value and then stops being custom-valued, which readmits the property to the member-write fast path and the global-identifier cache that a permanently custom-valued descriptor is declined by; store it wherever you store descriptors (SetOwnProperty or GetOwnProperty on a host subclass, FastSetProperty, a hand-rolled global). It is the descriptor-shaped member of the same family as JsObjectLayout.AddLazy (records) and JsObjectShape (prototypes), and it does not exempt you from the rule above them: storing any raw descriptor under a string key still moves a shape-mode object to the dictionary representation. For a whole global that may never be touched, Options.AddLazyGlobal defers building the value until script reads the name, and engine.Advanced.AddLazyGlobal does the same on an engine that already exists โ€” which is what you need when the value comes from the request you are about to serve rather than from process-wide configuration. Both install the property eagerly, so in, hasOwnProperty and Object.keys(globalThis) see the name without building anything; only reading the value runs the factory, once. The per-engine overload receives its engine, so unlike an Options-registered factory it may capture engine-affine state โ€” and where it would do nothing but capture, the overload taking the state, engine.Advanced.AddLazyGlobal(name, state, static (e, s) => ...), hands it to a static factory instead. That matters only because this registration is per engine: a capturing factory costs a display class and a delegate for every global on every engine you build, which on FreshEngineGlobalsBenchmark's forty-global row is 32 bytes per global. There is deliberately no Options counterpart โ€” a registration made there is recorded once for the process and replayed per engine, so its closure is already a one-off.

Per-request state behind an engine. Every host-facing factory in this API receives the engine and nothing else, which is a problem when the value depends on the request rather than on process-wide configuration. engine.Advanced.HostDefined closes that gap: an opaque object? the engine never reads or interprets โ€” the [[HostDefined]] field the specification reserves on a Realm Record โ€” so a factory that captures nothing can still reach the scope it is running in. The alternative embedders reach for is a static ConditionalWeakTable<Engine, IServiceProvider>, which takes its internal write lock every time a host associates state with an engine, across every tenant in the process.

// once per process โ€” the factory captures nothing, so one Options serves every engine
var options = new Options()
    .AddLazyGlobal("user", static engine =>
        JsValue.FromObject(engine, ((RequestContext) engine.Advanced.HostDefined!).User));

// per request, before you run anything
engine.Advanced.HostDefined = requestContext;

It is the principal realm's field, not the current one, so it answers the same value inside a ShadowRealm callback as outside. A shadow realm is a distinct realm and gets its own, empty by specification โ€” deliberate, since propagating the outer request's services into sandboxed code is exactly the ambient authority a shadow realm exists to withhold; Host.InitializeShadowRealm is the hook if you do want to populate it. The value dies with the engine, and nothing in the engine clears it: a restore (below) does not touch it, so a pooled engine keeps it across one and you replace it yourself, typically right after restoring.

Sparse data. Hosts that read deep chains off optional data โ€” input.Address.City.length, where any link may be absent โ€” usually install an IReferenceResolver so a nullish base yields a value instead of throwing. Register NullPropagatingReferenceResolver.Instance rather than writing that class yourself: the engine recognizes the singleton and serves the propagation inline, with no interface call and no pooled Reference per nullish read, which an equivalent hand-written resolver cannot get. Pass ReferenceResolverInterests.NullishPropertyBase alongside it so every unrelated read lane stays armed. The boundary is that reads propagate: a call on a nullish base still throws, and a host that needs callable substitution or unresolvable-identifier handling writes its own resolver and forgoes the inline lane.

Prepared scripts and engine reuse. Engine.PrepareScript / PrepareModule return an object that is reusable and thread-safe: prepare once at startup and feed the same Prepared<T> to as many engines, on as many threads, as you like. The engine's own per-node caches are a separate matter โ€” they are engine-owned and engage only on the second evaluation of a given script on a given engine, so a host that builds a fresh engine per operation never reaches them by design. Note the mirror image if you pool engines instead: a warmed call site holds a reference to the last receiver it served until it caches a different one, so pooled engines can keep host objects alive between runs.

Sharing a module graph across pooled engines. A host whose templates or plugins are ES modules pays for that graph per engine: IModuleLoader.LoadModule is called by every engine that imports a module, so the obvious loader re-reads and re-parses the whole graph for every engine in the pool. Prepare each module once per build instead โ€” cache the Prepared<AstModule> (AstModule being Acornima's Module, which an alias keeps apart from Jint's runtime Module) keyed by module location, and hand it to the overload that takes an already prepared AST, ModuleFactory.BuildSourceTextModule(engine, in prepared). Name the prepared module exactly as the engine would: Engine.PrepareModule(code, source) takes the name up front, and it becomes Module.Location and therefore the referencingModuleLocation echoed back into IModuleLoader.Resolve for that module's own imports โ€” so a name derived by hand that differs from the engine's breaks relative-import resolution with nothing to point at. ModuleFactory.LocationOf(resolved) is that rule; call it rather than reimplementing it.

Single-flight the cache. ConcurrentDictionary.GetOrAdd does not run its factory under a lock, so N engines filling a pool concurrently each prepare the same module and N-1 results are prepared only to be discarded. Wrapping the value in a Lazy<T> โ€” whose default thread-safety mode is exactly "one factory run, everyone else waits" โ€” is enough:

using AstModule = Acornima.Ast.Module; // Jint.Runtime.Modules.Module is a different type

private readonly ConcurrentDictionary<string, Lazy<Prepared<AstModule>>> _prepared
    = new(StringComparer.Ordinal);

public Module LoadModule(Engine engine, ResolvedSpecifier resolved)
{
    var location = ModuleFactory.LocationOf(resolved);
    var prepared = _prepared.GetOrAdd(location,
        static key => new Lazy<Prepared<AstModule>>(() => Engine.PrepareModule(ReadSource(key), key))).Value;

    return ModuleFactory.BuildSourceTextModule(engine, in prepared);
}

Invalidate by build, not by file. The prepared ASTs are derived from a compilation, so key the cache to the same identity the rest of your host already uses for that compilation and let a rebuild drop the whole set at once, rather than expiring entries per file and serving a graph half of which is stale.

Be clear about what the thread-safety of Prepared<T> buys. It is safe to share and safe to run concurrently, which is what lets one cache serve the pool โ€” it does not make the engines shareable (one engine, one thread) and it does not make the module registry shared: every engine still builds, links and evaluates its own module records from the shared ASTs, and engine.Modules is per engine. That per-engine cost is real, and it is what the pool pays no matter how much preparation is shared.

StaticAnalysis is a trade with a break-even, not a speed-up. Preparing with new ModulePreparationOptions { StaticAnalysis = false } skips the pass that pre-publishes interpreter state onto the parsed tree, leaving preparation at roughly the cost of a plain parse; each engine then rebuilds lazily what the pass would have published once for all of them. Measured on ModuleGraphEmbeddingBenchmark's ten-module graph, preparation cost -47.6% time and -27.5% allocation, while each engine materializing that graph from the shared cache paid +4.8% time and +9.5% allocation โ€” break-even, on that graph, at about ten engines on time and about two on allocation. A long-lived pool should therefore keep the default (true); the option is for preparation that sits on a latency-critical path โ€” a cold start, a rebuild in a dev loop โ€” or for a host whose prepared programs outnumber the engines that ever run them. ScriptPreparationOptions.StaticAnalysis is the same option for scripts.

Registering only what a script uses. When the ambient API is large and scripts touch little of it, prepare with ScriptPreparationOptions.CollectReferencedGlobals and read Prepared<T>.ReferencedGlobals: the free identifiers the program actually references, resolved per binding site, as an immutable set you can intersect with your registry โ€” including the CLR-side context you would otherwise build speculatively, which lazy globals cannot defer. Honor HasDirectEvalCall: a program with direct eval can reference anything, so install everything for those.

Reusing a configured engine. If you build a fresh engine per evaluation only because you need a clean global, engine.Advanced.CaptureGlobalSnapshot() and RestoreGlobalSnapshot(snapshot) are the cheaper route: capture once after your SetValue calls and module setup, then restore between evaluations. Restore reverts the global object's own properties, its prototype and extensibility, and the top-level let/const/class declarations (which nothing else can clear, so a script with a top-level let can otherwise only be run once per engine); it also clears the RegExp.$1-style legacy statics and resets the interop wrapper caches. The per-node caches above are deliberately kept, so the next run starts warm. Keep the snapshot in a field beside the engine and put the restore in a finally โ€” a script that throws still declared its globals, so restoring only on the success path hands them to the next caller. engine.Advanced.WithRestoredGlobals(snapshot, action) is exactly that try/finally in one call, so the restore cannot be left off the throwing path; it adds nothing else, and in particular no isolation the restore does not already give you.

Choosing between this and AddLazyGlobal is a question of engine lifetime: a fresh-engine-per-evaluation host wants lazy globals (nothing to restore โ€” the win is never building what the script does not read); a pooled host wants the snapshot. They compose: restore returns a global that was still lazy at capture to its unmaterialized state, so a pooled engine keeps both benefits.

Restore also ends the previous cycle on the event loop. Queued jobs are discarded, and โ€” because discarding cannot reach work that has not been enqueued yet โ€” any promise registered before the restore is dropped when it settles instead of resuming its continuation, so a fire-and-forget async function suspended on a host Task never wakes up against the restored globals. Register a promise that is meant to outlive a restore after it. Restore refuses (InvalidOperationException) while an evaluation is in progress, including an EvaluateAsync/ExecuteAsync/InvokeAsync whose Task you still hold. What it cannot fence is you calling back in: invoking a function a previous evaluation handed you runs it against the restored surface.

It is a configuration-reuse primitive, not an isolation boundary โ€” mutations of Object.prototype and other intrinsics, of object graphs behind restored bindings, of host CLR state, plus Symbol.for registrations and registered modules, all survive a restore, so mutually distrusting scripts still need separate engines. Note that surviving intrinsic pollution is a surviving binding, not just a surviving property: bare identifiers resolve through the global's prototype chain, so Object.prototype.leaked = 1 in one evaluation makes leaked a name the next one can read with no qualifier. (The global's own [[Prototype]] is captured and restored, so a setPrototypeOf(globalThis, โ€ฆ) is reverted.) A snapshot also keeps its engine and every captured value strongly reachable, so do not cache one past that engine's lifetime.

Constraints and options. An Options instance is meant to be shared across engines, including concurrent ones โ€” the built-in constraint helpers register a factory, so each engine gets its own counter and its own deadline. (Sharing it is not required: building an Options per scope is fine when your globals depend on scoped state.) Watch for the sentinel trap: MaxStatements(int.MaxValue), LimitMemory(long.MaxValue) and TimeoutInterval(TimeSpan.MaxValue) register no constraint at all and remove any previously registered one of that kind, so spelling "effectively unlimited" that way leaves you with no limit rather than a large one. LimitMemory is a managed-allocation budget, not a retained-heap or process-memory quota. It accounts only while an engine thread is actively executing the operation, including synchronous host callbacks, and carries the accumulated total with promise and module continuations when they resume on another thread. It does not charge unrelated allocations while an async operation is suspended.

Values do not cross engines. A JsValue that is an ObjectInstance holds a hard reference to the engine and realm that created it, and passing one to a different engine is not supported โ€” it is neither validated nor made safe. Prepared<Script> / Prepared<Module> and ModuleBuilder are the supported ways to share work between engines. For a script result, prefer engine.Advanced.ConvertResult(value, limits): it copies arrays, typed arrays, array buffers, maps, sets and enumerable own properties into a detached CLR data graph while incrementally enforcing depth, cumulative property/element count, individual string length, aggregate characters and binary bytes. Cycles, functions and symbols are rejected. A CLR wrapper is already host-owned, so conversion returns its target without walking or limiting that target's graph.

Bound untrusted output. Options.ResultLimits defaults to ResultLimits.Unlimited for compatibility. ResultLimits.Conservative is an opt-in starting point, not a complete sandbox profile:

var engine = new Engine(options =>
{
    options.ResultLimits = ResultLimits.Conservative;
    options.TimeoutInterval(TimeSpan.FromSeconds(2));
    options.MaxStatements(50_000);
    options.LimitMemory(16_000_000);
});

var value = engine.Evaluate(source);
var result = engine.Advanced.ConvertResult(value);

The same option bounds Jint's JsonSerializer and script-visible JSON.stringify; per-call overloads can use a tighter policy. JSON output counts escaped UTF-16 characters before appending and exact UTF-8 bytes before touching an IBufferWriter<byte>. Conversion and serialization run under execution constraints because getters, proxy traps, toJSON, replacers and error stack accessors can execute script. Limits do not make host code interruptible: pair them with time, statement, cancellation, memory and stack constraints plus an outer worker deadline.

Evaluate, ConvertResult, JsonSerializer, and bounded error rendering are separate top-level entries. If evaluation plus result handling is one request, bracket every phase with the same OperationDeadlineConstraint Begin/End pair; otherwise each phase receives a fresh ordinary per-entry budget.

For CLR conversion, MaxPropertyCount is the structural-work and container-allocation bound. Shared references that are not cycles are copied once per occurrence, so a graph can amplify as it is detached; string, character and binary-byte limits do not bound that shape by themselves.

Jint cannot impose these limits inside external serializers. System.Text.Json, Newtonsoft.Json, a configured Interop.SerializeToJson delegate, custom logging/diagnostic formatters, and serialization of a returned CLR wrapper target remain the host's responsibility. The delegate's returned JSON text is capped before Jint copies it, but any work or allocation the delegate performed to produce that string has already happened. Standard Exception.ToString() and debugger frontends are external sinks too; use JavaScriptException.GetJavaScriptErrorString(limits) for bounded script-error text.

Profiling scripts (opt-in)

When a script is slow and you want to know which of its functions is responsible, opt the engine in and bracket the run. The profiler is evented, not sampling โ€” it records at the call boundary on the engine's own thread, because inspecting a running engine from another thread is not something Jint supports.

var engine = new Engine(options => options.Profiling.Enabled = true);

engine.Advanced.StartProfiling();
engine.Execute(script);
var profile = engine.Advanced.StopProfiling();

using var file = File.Create("run.speedscope.json");
profile.WriteSpeedscopeJson(file);

Drop the file onto speedscope.app โ€” it is written in that tool's evented profile format, in nanoseconds. The ScriptProfile is also readable directly: Frames (name, file, line, column), Events (open/close plus a timestamp), Truncated and Duration. It holds strings and numbers only, so keeping a profile does not keep the engine that produced it alive.

Worth knowing before you read a profile:

StartProfiling() throws InvalidOperationException when Options.Profiling.Enabled is false, which is the default โ€” an engine running untrusted script can refuse profiling outright.

Discussion

Join the chat on Gitter or post your questions with the jint tag on stackoverflow.

Video

Here is a short video of how Jint works and some sample usage

https://docs.microsoft.com/shows/code-conversations/sebastien-ros-on-jint-javascript-interpreter-net

Thread-safety

An Engine is single-operation, not thread-safe. Public host entries fail fast with InvalidOperationException when another thread is using the same engine or while one of its async APIs is still outstanding; callers are never silently serialized. Same-thread re-entry from a host callback is supported for synchronous APIs. A JavaScript callback converted to a CLR delegate may also be dispatched to another thread by the host: while host code is running, or while the async operation that received the callback remains outstanding, Jint reserves the engine against unrelated callers and transfers ownership to that callback one turn at a time. Such an authorized transfer may wait for the current callback turn; ordinary public callers still fail immediately rather than being serialized. Starting an async engine API from inside an active engine call is rejected: the callback must return before ownership can transfer safely. A top-level awaited async operation may resume on a different thread after ownership transfers. Background task and module completions may enqueue work safely, but they do not grant permission for other host calls while the owning async operation is pending.

Keep one engine exclusively assigned to one request or operation at a time. If engines are pooled, await EvaluateAsync, ExecuteAsync, InvokeAsync, ImportAsync, or UnwrapIfPromiseAsync before returning an engine to the pool. Direct mutation through engine-owned objects such as engine.Global remains subject to the same contract and cannot be guarded by the Engine entry-point check. Dispose also fails fast while an operation owns the engine; await or finish that operation before disposing. This is observable during exception unwinding too, so a using scope must not outlive an async engine operation.

Examples

This example defines a new value named log pointing to Console.WriteLine, then runs a script calling log('Hello World!').

var engine = new Engine()
    .SetValue("log", new Action<object>(Console.WriteLine));
    
engine.Execute(@"
    function hello() { 
        log('Hello World');
    };
 
    hello();
");

Here, the variable x is set to 3 and x * x is evaluated in JavaScript. The result is returned to .NET directly, in this case as a double value 9.

var square = new Engine()
    .SetValue("x", 3) // define a new variable
    .Evaluate("x * x") // evaluate a statement
    .ToObject(); // converts the value to .NET

You can also directly pass POCOs or anonymous objects and use them from JavaScript. Direct writes through projected CLR objects are disabled by default. Opt in with AllowClrWrite() when scripts should be able to change CLR fields, properties, indexers, dictionaries, lists, or arrays:

var p = new Person {
    Name = "Mickey Mouse"
};

var engine = new Engine(options => options.AllowClrWrite())
    .SetValue("p", p)
    .Execute("p.Name = 'Minnie'");

Assert.AreEqual("Minnie", p.Name);

This is a breaking default change. Applications upgrading from a version where CLR writes were enabled by default must add AllowClrWrite() to preserve that behavior. The option only controls direct writes through Jint's projected-object wrappers; it does not make a projected object immutable. CLR methods and registered extension methods remain callable and can mutate host state:

var engine = new Engine(options => options.AddExtensionMethods(typeof(MyExtensions)));

Do not expose mutating methods to untrusted scripts when method side effects are outside the intended capability set.

You can invoke JavaScript function reference

var result = new Engine()
    .Execute("function add(a, b) { return a + b; }")
    .Invoke("add",1, 2); // -> 3

or directly by name

var engine = new Engine()
   .Execute("function add(a, b) { return a + b; }");

engine.Invoke("add", 1, 2); // -> 3

Accessing .NET assemblies and classes

You can allow an engine to access types from the core assembly that contains System.Object by configuring the engine instance like this:

var engine = new Engine(cfg => cfg.AllowClr());

Then you have access to the System namespace as a global value. Here is how it's used in the context on the command line utility:

jint> var file = new System.IO.StreamWriter('log.txt');
jint> file.WriteLine('Hello World !');
jint> file.Dispose();

And even create shortcuts to common .NET methods

jint> var log = System.Console.WriteLine;
jint> log('Hello World !');
=> "Hello World !"

When allowing the CLR, you can instead pass the exact assemblies from which namespace lookup may resolve types.

var engine = new Engine(cfg => cfg
    .AllowClr(typeof(Bar).Assembly)
);

AllowedAssemblies is a closed namespace-discovery allow-list. Namespace lookup admits only effectively public types: a top-level type must be public, and every declaring type in a nested chain must be public. Supplying an assembly does not implicitly add the core runtime, Jint, the calling assembly, or the executing assembly. Add every assembly whose public types must be discoverable:

var engine = new Engine(cfg => cfg.AllowClr(
    typeof(object).Assembly,
    typeof(Bar).Assembly));

This is a security-relevant compatibility change. Earlier releases could resolve core runtime and Jint types outside a narrow AllowedAssemblies list. Hosts that intentionally relied on that fallback must now list the required assemblies. AllowClr() without arguments continues to add the assembly containing System.Object. Passing an explicitly empty assembly array adds nothing, so a dynamically computed allow-list fails closed.

This list is not a complete sandbox for everything a discovered type can do. An admitted API may itself load assemblies, resolve types, access files, start processes, or return powerful objects. For untrusted scripts, prefer leaving CLR access disabled; otherwise combine the minimum assembly set with a positive TypeResolver.MemberFilter, purpose-built projected capabilities, and process isolation.

The boundary applies to System and importNamespace, including nested and generic type definitions. TypeResolver.MemberFilter is also consulted for each discovered type and for nested types. Generic type arguments must already be TypeReference values; passing one does not make its assembly discoverable. An explicitly exported TypeReference, such as the example below, remains a host-granted capability even when its assembly is absent from AllowedAssemblies, while its constructors, static members, and nested types still obey MemberFilter. Converting that explicit reference to a System.Type object and back through clrHelper preserves the same capability; an unrelated System.Type object must satisfy the namespace policy.

AllowGetType exposes instance object.GetType and the type-widening clrHelper.unwrap, typeOf, typeToObject, and objectToType operations. This means clrHelper.unwrap now throws unless AllowGetType is enabled. The helper operations cannot widen to a type rejected by the namespace policy. Jint filters the static System.Type.GetType(string) family from ordinary member resolution, but AllowGetType is not a general type-resolution or reflection sandbox: other APIs admitted by the host can carry equivalent authority. AllowSystemReflection must be enabled before namespace lookup may resolve System.Reflection types; leave both options disabled for untrusted scripts.

and then to assign local namespaces the same way System does it for you, use importNamespace

jint> var Foo = importNamespace('Foo');
jint> var bar = new Foo.Bar();
jint> log(bar.ToString());

adding a specific CLR type reference can be done like this

engine.SetValue("TheType", TypeReference.CreateTypeReference<TheType>(engine));

and used this way

jint> var o = new TheType();

Generic types are also supported. Here is how to declare, instantiate and use a List<string>:

jint> var ListOfString = System.Collections.Generic.List(System.String);
jint> var list = new ListOfString();
jint> list.Add('foo');
jint> list.Add(1); // automatically converted to String
jint> list.Count; // 2

Intercepting access to .NET objects

ECMAScript Proxy traps can be implemented in .NET by deriving from ProxyHandler and creating the proxy via engine.Advanced.CreateProxy (or CreateRevocableProxy). A trap returning null forwards the operation to the target, exactly like an absent trap on a JavaScript handler object, and all proxy invariants are enforced on trap results. Combine with SetWrapObjectHandler to transparently intercept every wrapped .NET object. Note that proxy.method() fires the Get trap (then calls the returned value) โ€” the Apply trap only fires when the proxy itself is invoked, so intercepting method calls means returning a wrapping function from Get. new Proxy(target, handlerObject) from script remains the JavaScript-side equivalent.

class LoggingHandler : ProxyHandler
{
    public override JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver)
    {
        Console.WriteLine($"get {property}");
        return null; // forward to the target
    }

    public override bool? Has(ObjectInstance target, JsValue property)
    {
        Console.WriteLine($"has {property}");
        return null;
    }
}

var engine = new Engine(options =>
{
    // wrap every interop object in a logging proxy
    options.SetWrapObjectHandler((e, obj, type) =>
        e.Advanced.CreateProxy(ObjectWrapper.Create(e, obj, type), new LoggingHandler()));
});

Internationalization

You can enforce what Time Zone or Culture the engine should use when locale JavaScript methods are used if you don't want to use the computer's default values.

This example forces the Time Zone to Pacific Standard Time.

var PST = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");
var engine = new Engine(cfg => cfg.LocalTimeZone(PST));
    
engine.Execute("new Date().toString()"); // Wed Dec 31 1969 16:00:00 GMT-08:00

This example is using French as the default culture.

var FR = CultureInfo.GetCultureInfo("fr-FR");
var engine = new Engine(cfg => cfg.Culture(FR));
    
engine.Execute("new Number(1.23).toString()"); // 1.23
engine.Execute("new Number(1.23).toLocaleString()"); // 1,23

Extending Temporal and Intl with custom providers

Jint ships English-only / ISO-only defaults to keep the binary small. Non-English CLDR data and full IANA timezone DST history are reachable via two pluggable providers:

Extension point Default What it covers Reusable example
Options.Temporal.TimeZoneProvider (ITimeZoneProvider) DefaultTimeZoneProvider (BCL TimeZoneInfo + Windowsโ†”IANA mapping) UTC offsets, DST transitions, IANA canonicalization NodaTimeZoneProvider.cs โ€” uses NodaTime TZDB for full historical accuracy
Options.Intl.CldrProvider (ICldrProvider) DefaultCldrProvider (English + .NET CultureInfo) locale names, currencies, units, plural rules, calendar month/era names IcuCldrProvider.cs โ€” uses ICU4N for full CLDR coverage

The provider files in Jint.Tests.Test262/ are MIT-licensed copy-and-modify templates, not a stable API contract. They are also exactly how the test suite reaches its current test262 conformance numbers.

To wire them into your project, add the same NuGet packages (NodaTime, ICU4N), copy the provider files in, and register them on Engine options:

var engine = new Engine(options =>
{
    options.Temporal.TimeZoneProvider = new NodaTimeZoneProvider();
    options.Intl.CldrProvider          = new IcuCldrProvider();
});

A separate ICalendarProvider for non-ISO calendar arithmetic (Islamic UmAlQura, Persian astronomical, Chinese/Dangi at extreme dates) is on the roadmap; until then non-ISO calendars use System.Globalization.Calendar subclasses, which constrains some date ranges (see Jint/Native/Temporal/NonIsoCalendars.cs).

Running untrusted code

ForUntrustedCode applies Jint's opt-in hardened profile. It disables CLR namespace and reflection access, registered extension methods, module loading, debugger handling, blocking Atomics.wait, writes through projected CLR objects, live views over CLR arrays, and eval/function constructors that compile strings. It also enables the native stack-overflow guard.

Core execution limits are required rather than guessed: a useful budget depends on the request and workload, and a security API must not silently turn saturated sentinels into "unlimited." Parser, module-graph, and result limits have conservative finite defaults that should still be tuned for the host:

var limits = new UntrustedCodeLimits(
    timeoutInterval: TimeSpan.FromSeconds(1),       // one Engine entry
    maxStatements: 100_000,                        // one Engine entry
    memoryLimit: 16_000_000,                       // one Engine entry
    maxRecursionDepth: 64,
    maxArraySize: 10_000,
    regexTimeout: TimeSpan.FromMilliseconds(250),
    promiseTimeout: TimeSpan.FromSeconds(1),
    maxOperationDuration: TimeSpan.FromSeconds(2),
    maxSourceLength: 100_000,
    maxNodeCount: 25_000,
    maxModuleCount: 50,
    maxTotalModuleSourceBytes: 1_000_000,
    maxModuleGraphDepth: 10,
    maxModuleResolutionHops: 200,
    resultLimits: new ResultLimits(
        maxDepth: 16,
        maxPropertyCount: 10_000,
        maxStringLength: 100_000,
        maxOutputCharacters: 1_000_000,
        maxOutputBytes: 2_000_000));

// Build once and share concurrently. ForUntrustedCode records an immutable profile declaration;
// every Engine gets a private effective snapshot and construction never mutates sharedOptions.
var sharedOptions = new Options().Strict().ForUntrustedCode(limits);
using var engine = new Engine(sharedOptions);

using (limits.BeginOperation(engine, cancellationToken))
{
    try
    {
        var value = engine.Evaluate(source);
        var json = new JsonSerializer(engine).Serialize(value).AsString();
        return Encoding.UTF8.GetBytes(json); // keep the transport's own response-byte cap too
    }
    catch (JavaScriptException exception)
    {
        return Encoding.UTF8.GetBytes(exception.GetJavaScriptErrorString(limits.ResultLimits));
    }
}

TimeoutInterval and statement count reset around each top-level engine entry. BeginOperation requires a cancellable host token and spans one cumulative wall-clock deadline and managed-allocation budget across evaluation/import, callbacks, ConvertResult, JsonSerializer, and bounded error rendering. Ordinary statement limits intentionally remain per-entry. Like Jint's other constraints, these are cooperative: they cannot preempt a host callback that does not return. Keep an outer worker/request deadline as the hard stop. Scopes cannot overlap, cannot end while async work still owns the engine, and failed disposal remains retryable after that work completes.

Prepared scripts and modules carry the regex timeout selected when they were prepared. When preparing untrusted code for reuse, pass the same limit explicitly:

var prepared = Engine.PrepareScript(source, new ScriptPreparationOptions
{
    ParsingOptions = new ScriptParsingOptions { RegexTimeout = limits.RegexTimeout }
});

Configuration order cannot reopen profile-controlled settings. The profile is applied to the private engine snapshot before construction and reapplied after user Configure callbacks; registered extension methods, custom converters/factories, detailed errors, unsafe CLR policies, loaders, and programmatic modules are removed. User callbacks remain an honestly reported JINTSEC031 host capability. Mutating the shared source Options after engines are being constructed is still unsupported; finish configuration before sharing it. The compatibility tradeoffs are intentional: projected CLR arrays become snapshots, projected CLR members are read-only, registered extension methods are removed, dynamic string compilation and module imports fail, and Atomics.wait raises a JavaScript TypeError. Projected methods/delegates and callbacks deliberately exposed by the host remain capabilities. The profile is defense in depth, not process isolation; mutually distrusting scripts should still use separate engines in disposable least-privileged workers with OS CPU, memory, filesystem, network, and lifetime controls.

Execution Constraints

Execution constraints are used during script execution to ensure that requirements around resource consumption are met, for example:

Validating options for untrusted scripts

Jint does not change its general-purpose defaults when a host runs untrusted source. A host can inspect its fully configured Options before constructing an engine and enforce the built-in untrusted-script policy explicitly:

var options = new Options()
    .MaxStatements(100_000)
    .LimitMemory(4_000_000)
    .TimeoutInterval(TimeSpan.FromSeconds(2))
    .CancellationToken(requestAborted)
    .Constraint(static () => new OperationDeadlineConstraint())
    .LimitRecursion(64)
    .DisableStringCompilation()
    .AllowClrWrite(false)
    .MaxArraySize(100_000)
    .RegexTimeoutInterval(TimeSpan.FromSeconds(1));

options.AgentCanSuspend = false;
options.Constraints.StackOverflowGuard = true;
options.Constraints.PromiseTimeout = TimeSpan.FromSeconds(1);
options.Interop.ArrayConversion = ArrayConversionMode.Copy;
options.Parsing.MaxSourceLength = 100_000;
options.Parsing.MaxNodeCount = 25_000;
options.ResultLimits = ResultLimits.Conservative;

var report = options.ValidateSecurityConfiguration(SecurityConfigurationPolicy.UntrustedScripts);
foreach (var diagnostic in report.Diagnostics)
{
    logger.LogWarning(
        "{Code} {Severity}: {Message} {Guidance}",
        diagnostic.Code,
        diagnostic.Severity,
        diagnostic.Message,
        diagnostic.Guidance);
}

// Throws SecurityConfigurationException if the report contains an error.
var engine = new Engine(options.EnsureSecurityConfiguration(SecurityConfigurationPolicy.UntrustedScripts));
var effectiveReport = engine.Advanced.ValidateSecurityConfiguration(SecurityConfigurationPolicy.UntrustedScripts);

Validation is read-only: it does not apply a hardened profile or change normal engine defaults, so it also works for Options assembled by direct property assignment or by application-specific helpers. Diagnostic codes are stable and results are ordered by code for deterministic logging. Warnings identify settings that need host-specific review (for example a custom module loader); they remain in the report but do not make EnsureSecurityConfiguration throw. Treat a validated Options as immutable and pass it directly to Engine(Options). Public engine-construction callbacks registered by Configure, SetTypeConverter, or UseHostFactory produce JINTSEC031; inspect engine.Advanced.ValidateSecurityConfiguration() after construction to validate their final effects without replaying them. Custom constraint factories remain supported under their existing contract and are invoked only by engine construction, never by diagnostics.

Codes Coverage
JINTSEC001-009, 036-037 Statement, memory, per-entry timeout, cancellation, and cumulative operation deadline
JINTSEC010-014 Recursion, native stack guard, legacy stack lane, and Atomics.wait suspension
JINTSEC015-020, 030, 048-055, 057 CLR access policy, GetType/unwrap, reflection, writes, array crossing, extension methods, error disclosure, callbacks, and compatibility mode
JINTSEC021-022, 041-046, 050, 056 Module loader trust, count/bytes/depth/hops, destination policy, per-load reset semantics, and detailed errors
JINTSEC023-029, 032 Debugger, arrays, regex, promise waits, and shared constraint instances
JINTSEC031 User-provided deferred engine configuration
JINTSEC033-035, 038-040 Actual parsing/preparation regex settings, source and AST limits, and source retention
JINTSEC047 Result conversion and Jint JSON serialization limits; external serializers, responses, and logs remain host responsibilities

A report with no findings is not proof of sandboxing. Jint remains in-process; worker isolation, least privilege, request/response limits, callback authorization, and correct operation bracketing are host architecture responsibilities.

Explicit ScriptParsingOptions / ModuleParsingOptions can override the engine regex timeout, and prepared programs embed their preparation-time timeout instead of consulting the engine. Validate the actual pair before direct parsing, and validate every preparation configuration before preparing:

options.EnsureSecurityConfiguration(scriptParsingOptions);
scriptPreparationOptions.EnsureSecurityConfiguration();
modulePreparationOptions.EnsureSecurityConfiguration();

The default preparation options use Jint's 10-second regex timeout and unbounded parser size limits. Set RegexTimeout, MaxSourceLength, and MaxNodeCount on the actual nested parsing options used for untrusted source.

You can configure them via the options:

var engine = new Engine(options => {

    // Limit memory allocations to 4 MB
    options.LimitMemory(4_000_000);

    // Set a timeout to 4 seconds.
    options.TimeoutInterval(TimeSpan.FromSeconds(4));

    // Set limit of 1000 executed statements.
    options.MaxStatements(1000);

    // Use a cancellation token.
    options.CancellationToken(cancellationToken);
}

LimitMemory measures managed bytes allocated while Jint actively executes one top-level operation. Promise reactions, EvaluateAsync / InvokeAsync, and asynchronous module loading keep the originating budget across thread hops. Synchronous host callbacks are included; allocations performed by background work before it hands a result back to Jint are not. The limit is not retained heap, unmanaged memory, or a process-wide quota, and initial source parsing happens before the execution constraint starts. Use an operating-system memory limit for a hard worker boundary.

The runtime capability is explicit through MemoryLimitConstraint.Accuracy. It is MemoryLimitAccuracy.ExecutionThread when the per-thread allocation counter is available; otherwise execution fails with PlatformNotSupportedException rather than silently skipping enforcement.

Like other execution constraints, the allocation budget normally resets for each top-level entry. To cover a host operation made from several calls with one budget, retrieve the engine-owned constraint and bracket those calls:

var engine = new Engine(options => options.LimitMemory(4_000_000));
var memory = engine.Constraints.Find<MemoryLimitConstraint>()!;

memory.Begin();
try
{
    engine.Execute(initialization);
    engine.Invoke("render", input);
}
finally
{
    memory.End();
}

The memory scope is part of the engine's single-operation ownership contract. Its mutable state and diagnostics fail fast with InvalidOperationException while another thread or an outstanding async API owns the engine. When the bracket contains EvaluateAsync, InvokeAsync, or ImportAsync, await that operation before calling End; the async owner carries the same memory state through every continuation.

You can also write a custom constraint by deriving from the Constraint base class:

public abstract class Constraint
{
    /// Called before script is run and useful when you use an engine object for multiple executions.
    public abstract void Reset();

    // Called before each statement to check if your requirements are met; if not - throws an exception.
    public abstract void Check();
}

For example we can write a constraint that stops scripts when the CPU usage gets too high:

class MyCPUConstraint : Constraint
{
    public override void Reset()
    {
    }

    public override void Check()
    {
        var cpuUsage = GetCPUUsage();

        if (cpuUsage > 0.8) // 80%
        {
            throw new OperationCancelledException();
        }
    }
}

var engine = new Engine(options =>
{
    options.Constraint(new MyCPUConstraint());
});

Bounding an operation that makes several calls into the engine

Every call into the engine โ€” Execute, Evaluate, Invoke, Call โ€” is a complete run, and the engine resets the constraints around each one. TimeoutInterval therefore bounds one call, not a sequence of them: a loop such as foreach (var row in rows) engine.Invoke("render", row); gives every row the full interval to itself, so the loop as a whole is unbounded.

When the thing you need to bound is your own operation โ€” a whole render, a whole request โ€” rather than one call, use OperationDeadlineConstraint. Its budget is started and stopped by you, and the engine's per-call reset leaves it alone:

// one instance per engine, and you keep the reference
var deadline = new OperationDeadlineConstraint();
var engine = new Engine(options => options.Constraint(deadline));

deadline.Begin(TimeSpan.FromSeconds(2), cancellationToken);
try
{
    foreach (var row in rows)
    {
        engine.Invoke("render", row);
    }
}
finally
{
    deadline.End();
}

The whole loop shares the two seconds. When it runs out, the next call fails with a TimeoutException โ€” the same exception the built-in timeout throws. If the token is cancelled, the call fails with a real OperationCanceledException carrying that token, so cancellation you asked for stays distinguishable from a script failure. Outside a Begin/End pair the constraint is inert, which makes it safe to leave registered on a pooled engine between operations. The two constraints are independent, so you can keep a TimeoutInterval as a per-call ceiling alongside it.

When you reuse the engine and want to use cancellation tokens you have to reset the token before each call of Execute:

var engine = new Engine(options =>
{
    options.CancellationToken(new CancellationToken(true));
});

var constraint = engine.Constraints.Find<CancellationConstraint>();

for (var i = 0; i < 10; i++) 
{
    using (var tcs = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
    {
        constraint.Reset(tcs.Token);

        engine.SetValue("a", 1);
        engine.Execute("a++");
    }
}

Surviving an unbounded recursion

A script that recurses without bound can exhaust the native stack of the thread it is running on, and that ends the host process: no exception, nothing to catch, nothing in the log but an exit code. Every route into a function body can do it โ€” a call, new, a getter or setter, a valueOf/toString coercion, a Proxy trap, a callback a built-in invokes, a host delegate that calls back into the engine.

var engine = new Engine(options =>
{
    // Only opt out when every script is trusted and independently bounded.
    options.Constraints.StackOverflowGuard = false;
});

The guard is on by default. Every entry into a script function first checks that the native stack has not been used up, and an unbounded recursion raises RangeError: Maximum call stack size exceeded while there is still room to unwind โ€” an ordinary JavaScript error, catchable by the script itself and by your catch (JavaScriptException), with the engine still usable afterwards. It measures the remaining stack rather than counting calls, so it covers all of those routes and adapts to the thread the engine runs on: a host that provisions a larger stack gets proportionally more depth, rather than the frame count someone had to guess in advance.

A proper tail call in a strict function is exempt, and does not need the guard: it replaces the caller's frame instead of stacking one on top, so the recursion consumes no native stack however deep it runs. The check sits on the entries that do add a frame, so a strict tail recursion neither pays for it nor is stopped by it. Sloppy-mode recursion, a call out of tail position, and the non-call routes above are what remain in scope.

The check runs at every entry into a script function and is not free. The benchmark gate measured recursion-heavy workloads (Fib, DeepSum, Tak) roughly 1.5โ€“3% slower with it on, while hot shallow calls stayed within run-to-run noise. Jint accepts that default cost because terminating the host process is worse. Set StackOverflowGuard to false only when every script is trusted and independently bounded.

options.LimitRecursion(n) answers a different question and composes with it. The recursion limit counts frames and is checked before the callee is entered, so where it is configured it is what fires; the guard answers only when no limit was set, or when the limit was set higher than the thread's stack can actually hold โ€” which is easy to do by accident, since how many frames a stack holds is not something a host can see. A third and older setting, options.Constraints.MaxExecutionStackCount, continues the call chain on a fresh thread instead of throwing; it is checked at call expressions only, so it does not cover the other routes above, and it takes precedence over the guard when both are set.

Using Modules

You can use modules to import and export variables from multiple script files:

var engine = new Engine(options =>
{
    options.EnableModules(@"C:\Scripts");
});

var ns = engine.Modules.Import("./my-module.js");

var value = ns.Get("value").AsString();

By default, the module resolution algorithm will be restricted to the base path specified in EnableModules, and there is no package support. However you can provide your own packages in two ways.

Defining modules using JavaScript source code:

engine.Modules.Add("user", "export const name = 'John';");

var ns = engine.Modules.Import("user");

var name = ns.Get("name").AsString();

Defining modules using the module builder, which allows you to export CLR classes and values from .NET:

// Create the module 'lib' with the class MyClass and the variable version
engine.Modules.Add("lib", builder => builder
    .ExportType<MyClass>()
    .ExportValue("version", 15)
);

// Create a user-defined module and do something with 'lib'
engine.Modules.Add("custom", @"
    import { MyClass, version } from 'lib';
    const x = new MyClass();
    export const result = x.doSomething();
");

// Import the user-defined module; this will execute the import chain
var ns = engine.Modules.Import("custom");

// The result contains "live" bindings to the module
var id = ns.Get("result").AsInteger();

Note that you don't need to EnableModules if you only use modules created using Engine.Modules.Add.

If you serve the same modules from a pool of engines, see Sharing a module graph across pooled engines under Embedding performance: a loader that caches prepared modules keeps every engine but the first from parsing them again.

How a module is named

Every module has a location โ€” the string it knows itself by, Module.Location โ€” and for a module a loader produced, that string is ModuleFactory.LocationOf(resolved): the LocalPath of an absolute file: uri, and otherwise ResolvedSpecifier.Key, exactly as your Resolve wrote it. It is never null.

It is worth getting right because four things read it:

So a loader serving modules over a transport should key them by the whole url rather than by a path. A module that knows itself as /lib/entry.js has no origin left for its own ./dep.js to resolve against; one that knows itself as http://localhost/lib/entry.js resolves it the way a browser would. The key is used verbatim rather than Uri.AbsoluteUri, which canonicalizes โ€” strips a default port, lowercases the host, collapses dot segments, percent-encodes โ€” and whose .NET Framework and .NET Core parsers disagree on parts of that, so a canonicalized name would differ per target framework and could drift from the key the module map is cached under. Loading from disk is unaffected: a file: uri keeps its path, which is what DefaultModuleLoader resolves against a filesystem base path.

One consequence to weigh if the url carries anything sensitive. This string reaches script through new Error().stack, so a loader keying modules by https://svc:s3cr3t@internal.corp/lib/a.js?apikey=... hands untrusted script the credential and the api key. A browser's import.meta.url carries the query too, so this follows from naming a module by its url at all; keep secrets out of the key, or out of the url.

If you name modules yourself โ€” preparing each one once and sharing the Prepared<AstModule> across pooled engines, per Sharing a module graph across pooled engines โ€” call ModuleFactory.LocationOf for the name rather than deriving it by hand. A name that differs from the engine's own answer breaks relative-import resolution with nothing to point at.

Loading modules asynchronously

IModuleLoader.LoadModule must return a parsed module immediately, so a loader that fetches module source over I/O โ€” HTTP, a dev server, an asset pipeline โ€” would have to block the calling thread. Implement IAsyncModuleLoader instead, or derive from the AsyncModuleLoader template, and no thread is held while the fetch is in flight:

internal sealed class HttpModuleLoader : AsyncModuleLoader
{
    private readonly HttpClient _client = new() { BaseAddress = new Uri("http://localhost:5173/") };

    // Resolution stays synchronous โ€” only fetching an unseen module is allowed to take time
    public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest)
    {
        var baseUri = new Uri(referencingModuleLocation ?? _client.BaseAddress!.ToString());
        var uri = new Uri(baseUri, moduleRequest.Specifier);
        return new ResolvedSpecifier(moduleRequest, uri.AbsoluteUri, uri, SpecifierType.RelativeOrAbsolute);
    }

    protected override Task<string> LoadModuleContentsAsync(Engine engine, ResolvedSpecifier resolved, CancellationToken cancellationToken)
        => _client.GetStringAsync(resolved.Uri);
}

var engine = new Engine(options => options.EnableModules(new HttpModuleLoader()));

var ns = await engine.Modules.ImportAsync("./main.js");

Static imports inside an asynchronously loaded module are fetched the same way, transitively, before anything is linked; a module wanted by several importers is fetched once. A loader failure โ€” a faulted task, or ModuleLoadCompletion.SetError โ€” becomes a rejected promise rather than an exception on the calling thread, and a dynamic import() in script keeps working with nothing blocked:

// Returns immediately; the promise settles on a later turn of the event loop
engine.Execute("import('./late.js').then(ns => log(ns.value));");

For full control over when and where the load finishes, implement IAsyncModuleLoader directly and settle the ModuleLoadCompletion whenever the content arrives, from any thread:

public void LoadModuleAsync(Engine engine, ResolvedSpecifier resolved, ModuleLoadCompletion completion)
{
    // e.g. a Unity coroutine, a callback-based transport, a worker queue
    StartCoroutine(Fetch(resolved.Uri, onSuccess: completion.SetSource, onError: completion.SetError));
}

The engine is single-threaded, and settling the completion does not change that: the outcome is queued and applied when the engine is next given a turn. On a thread the engine must not block โ€” a game loop, a UI thread โ€” drive the import from your own loop instead of awaiting it:

// once
var import = engine.Modules.StartImport("./main.js");

// every frame
engine.Advanced.ProcessTasks();
if (import.IsCompleted)
{
    var ns = import.GetResult();   // throws PromiseRejectedException if the load or evaluation failed
}

The synchronous Engine.Modules.Import still works with an async loader, but the calling thread is what drives the event loop while the loads are in flight โ€” so it deadlocks if the loader's own completions need that same thread. Prefer ImportAsync or StartImport there. Existing synchronous IModuleLoader implementations are unaffected; async loading is opt-in.

A completion settled before LoadModuleAsync returns โ€” a cache hit, source already in hand โ€” is the exception to all of the above: the load finishes on the calling stack, no event loop involved, so a graph made entirely of such answers keeps the blocking Import fully synchronous, exactly as if a synchronous IModuleLoader had served it. AsyncModuleLoader does this automatically whenever LoadModuleContentsAsync returns an already-completed task.

Bounding and restricting module graphs

When running untrusted or semi-trusted scripts, you can limit the size and shape of the module graph and restrict which modules may be loaded:

var engine = new Engine(options =>
{
    options.EnableModules("/scripts");

    // Numeric limits โ€” all default to unlimited (int.MaxValue / long.MaxValue).
    options.Modules.MaxModuleCount = 50;                   // distinct modules per engine lifetime
    options.Modules.MaxTotalModuleSourceBytes = 1_000_000; // cumulative UTF-8 source bytes
    options.Modules.MaxModuleGraphDepth = 10;              // import-chain depth per graph load
    options.Modules.MaxModuleResolutionHops = 200;         // Resolve calls per import operation

    // URI/path allowlist policy
    options.Modules.LoadPolicy = new ModuleAllowlistPolicy
    {
        AllowedSchemes = { "file" },                      // only file:// URIs
        AllowedFileRoots = { "/scripts" },                // must be under this directory
        AllowBareSpecifiers = true,                       // allow programmatic modules
    };
});

Counting semantics:

Limit Scope What counts
MaxModuleCount Engine lifetime Each distinct successfully registered module record. Duplicates, cached lookups, and coalesced async fetches do not recount. Programmatic modules (Engine.Modules.Add) participate.
MaxTotalModuleSourceBytes Engine lifetime UTF-8 byte count of source text for string-based modules; exact byte length for byte[] modules. Prepared modules, exports-only modules, and custom Module records whose original encoded size is unknowable charge 0 bytes โ€” this is a known limitation.
MaxModuleGraphDepth Per graph load Conservative import-chain depth (root = 1). Cycles are collapsed into strongly connected components but every module in a cycle contributes to its component's weight; Jint then enforces the longest weighted path through the resulting acyclic graph. The answer is independent of source traversal and async completion order.
MaxModuleResolutionHops Per import operation Each actual Resolve call caused by an import. Cached [[LoadedModules]] hits do not resolve and do not count. Registration indexing does not consume hops. Budget resets per Import/StartImport call, so pooled engines never fail from accumulated operations.

Failure behavior: Exceeding a numeric limit throws ModuleGraphLimitException, a JintException subclass that is not JavaScriptException and propagates like a constraint violation โ€” it cannot be caught by script and is not turned into a promise rejection. Invalid finite limits (โ‰ค 0) throw ArgumentException at engine construction. Policy denials throw ModuleResolutionException and follow existing sync/rejection behavior.

Policy composition: ModuleAllowlistPolicy applies AND across configured dimensions (schemes, hosts, origins, file roots) and OR within each list. An unconfigured dimension imposes no restriction. A file target cannot satisfy a configured host/origin dimension, and a non-file target cannot satisfy a configured file-root dimension, so cross-kind mismatches are denied rather than ignored. Bare/no-URI specifiers are denied by default when any dimension is configured; set AllowBareSpecifiers = true to permit them. File roots and resolved files are canonicalized before a separator-aware lexical containment check; symbolic links and Windows reparse points are not resolved, so allowed roots must not contain attacker-controlled links. The policy applies to the final ResolvedSpecifier; DefaultModuleLoader's base-path restriction runs independently as an earlier check. Transport redirects occur inside a custom loader and are not visible to Jint's resolver, so that loader must apply the same policy and its own redirect limit to every redirect target.

Asynchronous Execution

Jint supports non-blocking execution of JavaScript that involves async/await and Promises. This is important in ASP.NET Core and other environments where blocking a thread while waiting for I/O can cause thread-pool exhaustion.

EvaluateAsync / ExecuteAsync / InvokeAsync

Use EvaluateAsync when you want to evaluate JavaScript code that may return a Promise (e.g., an async function call). The method awaits Promise settlement without blocking any thread โ€” the calling thread is released back to the pool while I/O is in flight:

var engine = new Engine();

// Expose an async .NET method to JavaScript
engine.SetValue("fetchData", new Func<string, Task<string>>(async url =>
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}));

// EvaluateAsync properly awaits the Promise returned by the async IIFE
var result = await engine.EvaluateAsync("""
    (async () => {
        const data = await fetchData('https://example.com/api');
        return data;
    })()
    """);

Console.WriteLine(result.AsString());

ExecuteAsync and InvokeAsync follow the same pattern:

// Execute a script that may produce a Promise and await its completion
await engine.ExecuteAsync("async function init() { ... } init()");

// Invoke a named async function and await its result
var value = await engine.InvokeAsync("myAsyncFunction", arg1, arg2);

By default, async execution respects Options.Constraints.PromiseTimeout. You can also pass a CancellationToken:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var result = await engine.EvaluateAsync("(async () => await fetchData(url))()", cancellationToken: cts.Token);

UnwrapIfPromiseAsync

When you already hold a JsValue that may be a Promise โ€” for example returned from engine.Invoke(...), value.Call(...), or a property access โ€” use UnwrapIfPromiseAsync to await it without blocking:

// Obtain a JsValue that may be a Promise
var jsValue = engine.Invoke("computeAsync", someArg);

// Await it asynchronously (non-blocking)
var result = await jsValue.UnwrapIfPromiseAsync();

// With cancellation support
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await jsValue.UnwrapIfPromiseAsync(cts.Token);

If jsValue is not a Promise it is returned immediately. If it is a rejected Promise a PromiseRejectedException is thrown.

The synchronous UnwrapIfPromise is still available for scenarios where blocking is acceptable (e.g., CPU-bound scripts with no I/O), but UnwrapIfPromiseAsync should be preferred in any async call chain.

Task/ValueTask to Promise Interop (Experimental)

When the TaskInterop experimental feature is enabled, .NET Task and ValueTask return values are automatically converted to JavaScript Promises. This allows JavaScript code to await or .then() the results of .NET async methods without any manual wrapping:

var engine = new Engine(options =>
{
    options.ExperimentalFeatures = ExperimentalFeature.TaskInterop;
});

engine.SetValue("fetchData", new Func<string, Task<string>>(async url =>
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}));

// .NET Task is automatically converted to a JavaScript Promise
var result = engine.Evaluate("fetchData('https://example.com/api').then(data => data)");
result = result.UnwrapIfPromise();

Without TaskInterop, .NET Tasks passed to JavaScript are exposed as opaque CLR objects. With it enabled, they become native Promises that support await, .then(), and .catch().

You can configure the timeout for promise resolution via Options.Constraints.PromiseTimeout:

var engine = new Engine(options =>
{
    options.ExperimentalFeatures = ExperimentalFeature.TaskInterop;
    options.Constraints.PromiseTimeout = TimeSpan.FromSeconds(10);
});

.NET Interoperability

Direct writes through projected CLR objects are disabled by default. Assignments to CLR fields, properties, indexers, dictionary/list entries, and live array elements require AllowClrWrite(). In sloppy JavaScript a blocked assignment is ignored; in strict JavaScript it throws a TypeError. Calls are outside this option's scope: instance, static, and extension methods can still mutate CLR state.

Error handling

A CLR exception thrown by host code

By default an exception thrown by host code โ€” a delegate you registered, a reflected member, a proxy trap โ€” is not converted into anything. It bubbles straight out of Execute/Evaluate/Invoke to you, with its message, .NET stack trace and inner exceptions untouched, and the script gets no chance to catch it:

var engine = new Engine();
engine.SetValue("parse", new Action<string>(s => new XmlDocument().LoadXml(s)));

// throws System.Xml.XmlException, with its own stack trace
engine.Evaluate("parse('<not xml')");

CatchClrExceptions changes that: the exception becomes a JavaScript Error which the script can try/catch like any other. Its message is A host operation failed. by default; the original message, stack trace and inner exceptions remain host-only. The predicate overload decides per exception, so you can let the script handle what it should handle and keep the rest fatal. Cancellation, timeout, memory, statement and recursion-limit exceptions always remain control flow and are never converted into script errors.

var engine = new Engine(options => options.CatchClrExceptions(e => e is not OperationCanceledException));

Getting the original exception back

Once an exception has been turned into a JavaScript error, the error is what travels โ€” so when the script does not catch it and it reaches you as a JavaScriptException, the message alone is rarely enough to diagnose anything. JintException.TryGetClrException gives you the exception itself:

try
{
    engine.Evaluate(script);
}
catch (JavaScriptException e) when (JintException.TryGetClrException(e, out var clrException))
{
    logger.LogError(clrException, "host call failed while running {Script}", name);
}

It survives nested frames, a script catching and rethrowing the same error, module evaluation and a rejected promise (PromiseRejectedException), and it follows the standard cause chain, so a script that rewraps with throw new Error(msg, { cause: err }) keeps it too. A script that throws something unrelated instead keeps nothing, which is correct โ€” it discarded the original.

The exception is host-only. It is CLR state on the error object rather than a JavaScript property, so a script can neither read it nor strip it, and it does not appear in Object.getOwnPropertyNames, Reflect.ownKeys or JSON.stringify. Note that the error object holds the exception, and everything the exception's object graph reaches, for as long as the script keeps the error reachable.

If you would rather your logging pipeline find it without asking, ChainClrExceptions also hangs it in the InnerException chain, so ToString() and any chain-walking logger render your own frames alongside the JavaScript ones. It is off by default because it puts host stack traces into whatever consumes that string โ€” treat it the way an ASP.NET application treats developer exception details.

var engine = new Engine(options => options
    .CatchClrExceptions()
    .ChainClrExceptions());

Two related accessors work the same way: JintException.TryGetJavaScriptLocation and TryGetJavaScriptCallStack give the JavaScript position for both JavaScriptException and a bubbled CLR exception, and TryGetClrType/TryGetClrMemberName report the type and member behind a failed interop resolution. To shape what the script sees โ€” rewrite the message, attach an error code โ€” use DecorateClrExceptionErrors.

Detailed development errors

Host exception messages, module loader failures, and CLR method/constructor resolution details are redacted by default because they commonly contain secrets, filesystem paths, URLs, CLR type names and candidate signatures. ExposeDetailedErrors restores the previous development-friendly messages across all three surfaces:

var engine = new Engine(options => options
    .CatchClrExceptions()
    .EnableModules(loader)
    .ExposeDetailedErrors());

Use it only when scripts and their error output are trusted. For narrower opt-ins, set options.Interop.ExposeDetailedExceptionMessages, options.Interop.ExposeDetailedResolutionErrors, or options.Modules.ExposeDetailedLoadErrors individually. These settings affect only script-visible messages; TryGetClrException, TryGetClrType, TryGetClrMemberName, and the CLR error decorators retain full host-side diagnostics under the safe defaults. ModuleLoadCompletion.SetError(string) remains an explicit script-facing message; use SetError(Exception) when the detail must be retained for the host and redacted from script. DecorateClrExceptionErrors also runs once for a module loader exception's final script-visible error, so the same host-side decorator can attach a safe error code to interop and module failures.

Raising an error from host code

To fail a host function in a way the script can catch, throw a JavaScriptException. The overload taking a CLR exception records it for TryGetClrException, so the script sees an ordinary Error while you keep the original:

engine.SetValue("parse", new Action<string>(s =>
{
    try
    {
        new XmlDocument().LoadXml(s);
    }
    catch (XmlException e)
    {
        throw new JavaScriptException(engine.Intrinsics.Error, "XML parsing failed", e);
    }
}));

Do not throw the exception projected into the script instead (new JavaScriptException(JsValue.FromObject(engine, e))). That value is not an Error โ€” it has no stack and fails instanceof Error โ€” and it hands the running script the exception's members, including its .NET stack trace and inner exceptions.

Code coverage (opt-in)

An engine can count what it executes, so a host can tell which parts of a script actually ran โ€” a test harness reporting coverage of embedded rules, a CI gate over business scripts, a dead-code audit.

var engine = new Engine(options => options.Coverage.Enabled = true);

engine.Execute("function f(n) { if (n > 0) { return 'positive'; } return 'other'; } f(1); f(2);", "rules.js");

foreach (var source in engine.Advanced.GetCoverage().Sources)
{
    foreach (var entry in source.Entries)
    {
        // rules.js  line 1  Statement  x2   ...
        Console.WriteLine($"{source.Name} line {entry.Start.Line} {entry.Kind} x{entry.HitCount}");
    }
}

engine.Advanced.ResetCoverage(); // start a fresh measurement

Coverage is collected through the same per-statement path the debugger and the exact execution constraints use, so an engine collecting it runs the instrumented interpreter path and its tight-loop optimizations are disarmed โ€” measured code is not byte-for-byte the code an uninstrumented engine runs. That is the normal bargain for statement-level coverage, and the reason the option is off by default. An engine that leaves it off pays nothing: the per-statement path the counting rides on is not armed at all.

Security

Jint is an in-process interpreter, not an operating-system security boundary. Running untrusted scripts safely requires explicit limits, a minimal host capability surface, fresh engines across trust domains, and process-level isolation. See the threat model for the supported boundaries, known limitations, and a hardened deployment baseline.

Parsing happens before execution constraints start, so hosts accepting untrusted source should bound it separately:

var engine = new Engine(options =>
{
    options.Parsing.MaxSourceLength = 100_000;
    options.Parsing.MaxNodeCount = 25_000;
});

These limits apply to Execute/Evaluate, eval, function constructors, ShadowRealm evaluation, debugger evaluation, module builders, and JavaScript or JSON source returned by module loaders. MaxSourceLength counts UTF-16 code units (string.Length), not encoded bytes. It includes generated source-offset padding and the wrapper generated by a function constructor; byte-backed module transports are checked after decoding. MaxNodeCount counts the AST nodes Acornima completes. Crossing either limit throws ParsingLimitException, which is a host resource-limit exception and is not converted to a catchable JavaScript error.

Both limits default to null for compatibility, meaning unlimited. Per-call ScriptParsingOptions and ModuleParsingOptions can set tighter limits, and static preparation accepts them through ScriptPreparationOptions.ParsingOptions and ModulePreparationOptions.ParsingOptions. Executing an already prepared script or module does not parse or recheck its AST; dynamic source compiled by that code uses the tighter of its preparation limits and the engine limits. When asynchronous module source arrives on another thread, its parser limits and the originating LimitMemory operation both remain attached to the engine turn that builds it; concurrent host access continues to fail fast until an owning async import completes.

These controls bound source representation and final AST size, not parser CPU for every adversarial grammar shape, encoded response bytes, module count, graph depth, redirects, or network access. Enforce transport and module-graph policy in the host and keep hostile parsing inside a disposable worker with OS-level memory and CPU limits.

Migration note โ€” Atomics.wait: synchronous suspension is disabled by default. Calling Atomics.wait on a default engine throws a JavaScript TypeError before registering a waiter, while Atomics.waitAsync remains available. A worker-like host that deliberately runs Jint where blocking is acceptable can restore the previous behavior explicitly:

var engine = new Engine(options => options.AgentCanSuspend = true);

Do not enable this on a request, UI, or event-loop thread: a script can call Atomics.wait without a timeout and block that thread indefinitely.

Branches and releases

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.