Jint

August 10, 2026 · View on GitHub

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

  • Run JavaScript inside your .NET application in a safe sand-boxed environment
  • Expose native .NET objects and functions to your JavaScript code (get database query results as JSON, call .NET methods, etc.)
  • Support scripting in your .NET application, allowing users to customize your application using JavaScript (like Unity games)

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

Supported features

ECMAScript 2015 (ES6)

  • ✔ ArrayBuffer
  • ✔ Arrow function expression
  • ✔ Binary and octal literals
  • ✔ Class support
  • ✔ DataView
  • ✔ Destructuring
  • ✔ Default, rest and spread
  • ✔ Enhanced object literals
  • for...of
  • ✔ Generators
  • ✔ Template strings
  • ✔ Lexical scoping of variables (let and const)
  • ✔ Map and Set
  • ✔ Modules and module loaders
  • ✔ Promises (Experimental, API is unstable)
  • ✔ Reflect
  • ✔ Proxies
  • ✔ Symbols
  • ❌ Tail calls
  • ✔ Typed arrays
  • ✔ Unicode
  • ✔ Weakmap and Weakset

ECMAScript 2016

  • Array.prototype.includes
  • await, async
  • ✔ Block-scoping of variables and functions
  • ✔ Exponentiation operator **
  • ✔ Destructuring patterns (of variables)

ECMAScript 2017

  • Object.values, Object.entries and Object.getOwnPropertyDescriptors
  • ✔ Shared memory and atomics

ECMAScript 2018

  • ✔ Asynchronous iteration
  • Promise.prototype.finally
  • ✔ RegExp named capture groups
  • ✔ Rest/spread operators for object literals (...identifier)
  • ✔ SharedArrayBuffer

ECMAScript 2019

  • Array.prototype.flat, Array.prototype.flatMap
  • String.prototype.trimStart, String.prototype.trimEnd
  • Object.fromEntries
  • Symbol.description
  • ✔ Optional catch binding

ECMAScript 2020

  • BigInt
  • export * as ns from
  • for-in enhancements
  • globalThis object
  • import
  • import.meta
  • ✔ Nullish coalescing operator (??)
  • ✔ Optional chaining
  • Promise.allSettled
  • String.prototype.matchAll

ECMAScript 2021

  • ✔ Logical Assignment Operators (&&= ||= ??=)
  • ✔ Numeric Separators (1_000)
  • AggregateError
  • Promise.any
  • String.prototype.replaceAll
  • WeakRef
  • FinalizationRegistry

ECMAScript 2022

  • ✔ Class Fields
  • ✔ RegExp Match Indices
  • ✔ Top-level await
  • ✔ Ergonomic brand checks for Private Fields
  • .at()
  • ✔ Accessible Object.prototype.hasOwnProperty (Object.hasOwn)
  • ✔ Class Static Block
  • ✔ Error Cause

ECMAScript 2023

  • ✔ Array find from last
  • ✔ Change Array by copy
  • ✔ Hashbang Grammar
  • ✔ Symbols as WeakMap keys

ECMAScript 2024

  • ✔ ArrayBuffer enhancements - ArrayBuffer.prototype.resize and ArrayBuffer.prototype.transfer
  • Atomics.waitAsync
  • ✔ Ensuring that strings are well-formed - String.prototype.ensureWellFormed and String.prototype.isWellFormed
  • ✔ Grouping synchronous iterables - Object.groupBy and Map.groupBy
  • Promise.withResolvers
  • ✔ Regular expression flag /v

ECMAScript 2025

  • ✔ 16-bit floating point numbers (float16), Requires NET 8 or higher, Float16Array, Math.f16round()
  • ✔ Array.fromAsync
  • ✔ Import attributes
  • ✔ Iterator helper methods
  • ✔ JSON modules
  • Promise.try
  • RegExp.escape()
  • ✔ Regular expression pattern modifiers (inline flags)
  • ✔ Duplicate named capture groups
  • ✔ Set methods (intersection, union, difference, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom)

ECMAScript proposals (no version yet)

  • ✔ Await Dictionary (Promise.allKeyed, Promise.allSettledKeyed)
  • ✔ Decorators (@decorator syntax for classes, methods, fields, and accessors)
  • Error.isError
  • Error.prototype.stack accessor (error-stack-accessor)
  • ✔ Explicit Resource Management (using and await using)
  • ✔ Immutable Arraybuffers
  • ✔ Import Bytes (import x from './file' with { type: 'bytes' })
  • ✔ Iterator Chunking (Iterator.prototype.chunks, Iterator.prototype.windows)
  • ✔ Iterator Includes (Iterator.prototype.includes)
  • ✔ Iterator Join (Iterator.prototype.join)
  • ✔ Iterator Sequencing
  • ✔ Joint Iteration
  • ✔ JSON.parse source text access
  • Math.sumPrecise
  • ShadowRealm
  • Temporal
  • Uint8Array to/from base64
  • Upsert

Other

  • Further refined .NET CLR interop capabilities
  • Constraints for execution (recursion, memory usage, duration)

Performance

  • Because Jint neither generates any .NET bytecode nor uses the DLR it runs relatively small scripts really fast
  • If you repeatedly run the same script, you should prepare it for execution using Engine.PrepareScript or Engine.PrepareModule, cache the returned Prepared<...> object and feed it to Jint instead of the content string
  • You should prefer running engine in strict mode, it improves 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.

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:

  • For fixed-shape records, do not subclass at all: JsObject.Create(engine, layout, values) and JsObject.CreateFromEntries build straight into the hidden-class representation, so every object sharing a JsObjectLayout shares one hidden class and a script reading a batch of them keeps a monomorphic inline cache. A record with expensive members most items never have read — a body that must be parsed, a field that must be decoded — declares them with JsObjectLayout.CreateBuilder().AddLazy(name, factory) and passes the raw payload as the lazySlotState argument of JsObject.Create: the factory runs on the first read that observes that member's value and the result is memoized on the object, while enumerating keys, in and hasOwnProperty never run it. The object stays a hidden-class object throughout.

  • For CLR objects, engine.SetValue(name, obj) wraps them in ObjectWrapper, whose member resolution and compiled accessors are cached process-wide on the TypeResolver.

  • For the prototypes those objects sit behind, declare the members once per process with JsObjectShape and create one object per engine with Instantiate: members materialize only when a script touches them, and because the engine stores and versions the whole member set itself, a shaped prototype can serve the prototype-method inline cache — which an ObjectInstance subclass used as a prototype can never do.

  • For a live indexed collection — a DOM NodeList, a result window, any list computed on demand — derive from ArrayLikeObject rather than assembling the property model yourself. You implement two members, uint Length and bool TryGetIndex(uint index, out JsValue value); the base class derives everything else (index and length descriptors, enumeration order, the existence and value hooks, WebIDL-shaped delete / defineProperty refusals) and the engine keys two lanes on the type, so list[i], Array.prototype generics, for-of / spread / Array.from / destructuring and JSON.stringify each cost one TryGetIndex per element with no descriptor and no key allocation. It is array-like, not an array: Array.isArray stays false by design, the same answer a browser gives for a NodeList. If your backing store can test containment more cheaply than it can produce an element, also override protected virtual bool HasIndex(uint index), and in / hasOwnProperty / Object.keys / delete stop projecting elements they only ever discard. Reach for it when the collection is live; when it is a snapshot, copying into a JsArray once is cheaper still — and give it a JsObjectShape prototype, per the bullet above, for the collection's own methods.

  • If you must subclass, override TryGetOwnPropertyValue so an own read hands the value over with no descriptor at all, and ProbeOwnProperty so existence and enumerability questions (in, Object.keys, spread, JSON.stringify) are answered without materializing one either. Both carry an obligation to agree with GetOwnProperty, and neither is re-verified on the hot path — a ProbeOwnProperty that wrongly reports a key as absent drops it from every enumeration, silently. Run your integration suite once with host-contract verification on and every such disagreement throws instead, naming the type, the key and both answers:

    // before the first use of any Jint type — the flag is read once, at type initialization
    AppContext.SetSwitch("Jint.EnableHostContractVerification", true);
    

    It also checks a declared PropertyAccessSemantics.Ordinary, an ArrayLikeObject's HasIndex, and an IObjectConverter registered with AddObjectConverter(converter, handledTypes) converting a type it did not declare. Turn it on in a test or staging host, never in production: the checks deliberately redo the work the hooks exist to avoid. A Debug build of Jint has them on already and needs no switch.

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.

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; convert to CLR values (ToObject()) to move data.

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

Engine instances are not thread-safe and they should not accessed from multiple threads simultaneously.

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. In this example for instance a new Person instance is manipulated from JavaScript.

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

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

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

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 any .NET class 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 optionally pass custom assemblies to load types from.

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

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 pointDefaultWhat it coversReusable example
Options.Temporal.TimeZoneProvider (ITimeZoneProvider)DefaultTimeZoneProvider (BCL TimeZoneInfo + Windows↔IANA mapping)UTC offsets, DST transitions, IANA canonicalizationNodaTimeZoneProvider.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 namesIcuCldrProvider.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).

Execution Constraints

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

  • Scripts should not use more than X memory.
  • Scripts should only run for a maximum amount of time.

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);
}

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++");
    }
}

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:

  • your own ModuleLoader.Resolve, as referencingModuleLocation, whenever that module imports something relative;
  • error.stack, and any SyntaxError a malformed module raises, through SourceLocation.SourceFile;
  • the debugger, which keys breakpoints on it — new BreakPoint(location, line, column);
  • import.meta.url, if you report it. Jint leaves import.meta to the host, so it is your Host.GetImportMetaProperties override that puts the location there.

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.

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

  • Manipulate CLR objects from JavaScript, including:
    • Single values
    • Objects
      • Properties
      • Methods
    • Delegates
    • Anonymous objects
  • Convert JavaScript values to CLR objects
    • Primitive values
    • Object -> expando objects (IDictionary<string, object> and dynamic)
    • Array -> object[]
    • Date -> DateTime
    • number -> double
    • string -> string
    • boolean -> bool
    • RegExp -> Regex
    • Function -> Delegate
  • Extensions methods

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 carrying its message, which the script can try/catch like any other. The predicate overload decides per exception, so you can let the script handle what it should handle and keep the rest fatal.

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.

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.

Security

The following features provide you with a secure, sand-boxed environment to run user scripts.

  • Define memory limits, to prevent allocations from depleting the memory.
  • Enable/disable usage of BCL to prevent scripts from invoking .NET code.
  • Limit number of statements to prevent infinite loops.
  • Limit depth of calls to prevent deep recursion calls.
  • Define a timeout, to prevent scripts from taking too long to finish.

Branches and releases