Important
gojs is pre-1.0 (v0.x) and not yet production-ready. It is tagged and
go get-able, but still actively evolving: language and library coverage has
gaps, and the public API surface will change (types, options, and function
signatures may break between minor v0.x releases without notice).
It is suitable for experiments, prototypes, learning, and other non-serious
projects — not for anything where stability or completeness matters. Pin a
specific v0.x tag if you depend on it, and expect to update your code as
things move toward a stable v1.
An embeddable, sandbox-first JavaScript (ECMAScript) runtime for Go. Pure Go, zero dependencies, no cgo.
gojs is a companion to GoLua: the same capability-gated, provider-based sandboxing philosophy, applied to JavaScript. Good fits include plugin systems, user scripting, configuration runtimes, automation, and running untrusted snippets under tight host control.
Status: a full lexer, parser, and AST feed a bytecode VM (with a tree-walking evaluator as the fallback and differential oracle) that runs modern JavaScript at high conformance — ~99.99% of the runnable Test262 suite (Proxy, Reflect, the whole TypedArray/ArrayBuffer family, SharedArrayBuffer + multi-agent Atomics, BigInt, WeakRef/FinalizationRegistry, generators, async/await, and a conformant RegExp engine all pass at 100% of runnable). A few areas are intentionally out of scope — see Limitations.
- Modern JS syntax —
let/const, arrow functions, classes (withextends/super, private#fields,static, getters/setters, instance fields), destructuring with defaults and rest, spread, template & tagged-template literals, optional chaining (?.), nullish coalescing (??), logical assignment (&&=||=??=), exponentiation, BigInt literals, computed keys, and labeledbreak/continue. - Full statement set —
if/else,for,for-in,for-of,while,do-while,switch,try/catch/finally(incl. optional catch binding),throw, and Automatic Semicolon Insertion. - Built-ins —
Object,Function,Array,String,Number,Boolean,BigInt,Symbol(well-known symbols),Map,Set,WeakMap,WeakSet,WeakRef,FinalizationRegistry,Promise,Proxy,Reflect, the full TypedArray family +ArrayBuffer,SharedArrayBuffer,DataView, andAtomics,Math,JSON, a conformantRegExpengine, theErrorhierarchy, and the global helpers (parseInt,parseFloat,isNaN,isFinite,encodeURIComponent, …). - Own RegExp engine — a pure-Go, ECMAScript-conformant matcher
(
jsregexp) with backreferences, lookahead/lookbehind, named groups, theu/vUnicode modes, and\p{…}property escapes over Unicode 17.0 data — the things Go's RE2 deliberately cannot do — with a step budget so untrusted patterns can't hang the host. (An opt-in RE2 backend is available for linear-time matching of simple, trusted patterns.) - Bytecode VM — hot functions compile to a compact stack bytecode; the original tree-walking evaluator remains as an always-correct fallback (for not-yet-lowered nodes) and as a differential oracle. Both engines produce identical results; the VM is the default.
- Generators & async/await —
function*,yield,yield*,asyncfunctions,await, and async arrows, all driven cooperatively on the single VM thread (anawaitis ayieldto a promise-driven runner), so ordering matches real engines. - Shared memory & multi-agent Atomics —
SharedArrayBufferplusAtomics.wait/waitAsync/notifycoordinate across multiple agents (each a separate interpreter on its own goroutine), so worker-style shared-memory code runs correctly. - Event loop & timers —
setTimeout,setInterval,clearTimeout,clearInterval,setImmediate, andqueueMicrotask, all serialized on a single event-loop goroutine so callbacks never race with script code. - Context cancellation — every evaluation threads a
context.Context;Close()cancels it and drains all timer goroutines. - Precise diagnostics — every token and AST node carries a source span (line/column/offset) for underline-quality error messages.
- TypeScript — run
.ts/.tsxdirectly. The optionaltspackage transpiles TypeScript to JavaScript in-process (embedding a hoisting of Microsoft's typescript-go), sogojs run app.tsand embedded TypeScript just work. Type-stripping is checker-free; the gojs core stays dependency-free (only importingtspulls the compiler in). See thetypescriptexample. - No cgo, no C dependencies, single static binary.
Like GoLua, gojs is closed by default. Access to host facilities is granted
by installing providers; a New() with no options cannot print, read the
clock, or schedule timers.
| Provider | Controls | Default implementation |
|---|---|---|
PrintProvider |
all console.* output |
NewDefaultPrintProvider() |
TimeProvider |
Date / performance.now clock source |
NewDefaultTimeProvider() |
TimerProvider |
setTimeout / setInterval scheduling |
NewDefaultTimerProvider() |
ModuleProvider |
require(specifier) module loading |
NewMapModuleProvider, NewDirModuleProvider |
OsProvider |
process env / cwd / exit / platform / arch / pid |
NewDefaultOsProvider(), NewFilteredOsProvider(filter) |
NetProvider |
outbound dialing/DNS for fetch/sse/websocket |
NewDefaultNetProvider() (pass-through; wrap to allowlist/deny) |
Resource use is bounded with WithLimits(Limits{MaxCallDepth, MaxSteps}):
recursion raises a catchable RangeError, and the step budget is an
uncatchable abort that stops runaway loops. See the limits example.
Each provider is a small Go interface, so you can supply your own — route
console.* through your logger, present a fixed clock for deterministic tests,
or back timers with your own scheduler:
type PrintProvider interface {
Print(ctx context.Context, msg string) // console.log/info/debug
Warn(ctx context.Context, msg string) // console.warn/error
}Additional hardening is available through WithSecurity(Security{…}):
DisableEval, DisableFunctionCtor, DisableProtoMutation,
StrictModulesOnly.
Concurrent host work (HTTP, filesystem, DB, subprocess) integrates through a single-threaded event-loop model — the interpreter is never touched from more than one goroutine. A provider does its blocking work on its own goroutine, then posts exactly one continuation back onto the VM goroutine:
cap := vm.NewPromiseCapability() // create a pending promise on the VM goroutine
go func() {
body, err := http.Get(url) // blocking work off the VM goroutine
if err != nil {
cap.Reject(vm.NewError("Error", err.Error())) // marshals back to the loop
return
}
cap.Resolve(gojs.String(body))
}()
return cap.Promise // hand the promise to the scriptEnqueue, QueueMicrotask, ResolvePromise/RejectPromise, and RunLoop
round out the API. All JavaScript — the initial program and every callback —
runs on one goroutine, so objects, environments, and closures never need locks.
go get github.com/iceisfun/gojs
# CLI
go install github.com/iceisfun/gojs/cmd/gojs@latestpackage main
import (
"log"
"github.com/iceisfun/gojs"
)
func main() {
vm := gojs.New(
gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()),
gojs.WithTimerProvider(gojs.NewDefaultTimerProvider()),
)
defer vm.Close()
_, err := vm.RunString("example.js", `
const nums = [1, 2, 3, 4];
const evens = nums.filter(n => n % 2 === 0);
console.log("sum of evens:", evens.reduce((a, b) => a + b, 0));
setTimeout(() => console.log("done"), 10);
`)
if err != nil {
log.Fatal(err)
}
}The embedding flow is gojs.New → RunString → Close. RunString parses,
evaluates the top level, then drains the event loop (so pending timers and
promise microtasks run) before returning.
go install github.com/iceisfun/gojs/cmd/gojs@latest
gojs run app.js # run a JavaScript file
gojs run app.ts # TypeScript, transpiled in-process
gojs run --permissive app.ts # tolerate TypeScript syntax errorsMalformed TypeScript is rejected with a file:line:column error by default
(safer when the output is about to run); --permissive (or ts.Permissive()
when embedding) restores ts.transpileModule's tolerant behavior. Runtime error
stacks report the original .ts position via source maps.
The runner resolves require()/imports against the entry file's directory
(TypeScript modules are transpiled on load) and installs the standalone-runner
capabilities: console, timers, and a Node-like process (argv, env,
stdout.write, exit, hrtime, nextTick). These are ordinary host
capabilities — process is the host/process package, which an
embedder installs (and sandboxes) explicitly per VM.
Move values and control across the Go/JavaScript boundary with a small API.
Expose a Go function to scripts:
vm.SetGlobal("shout", vm.NewFunction("shout", func(args []gojs.Value) (gojs.Value, error) {
s, _ := vm.ToString(args[0])
return gojs.String(strings.ToUpper(s)), nil
}))
vm.RunString("s.js", `console.log(shout("hi"))`) // HIReturning an error from a HostFunc throws it into the script; wrap a value
with gojs.NewThrow(...) to throw a specific JS object.
Call a script function from Go:
vm.RunString("lib.js", `function add(a, b) { return a + b; }`)
sum, _ := vm.Call(vm.GetGlobal("add"), gojs.Undefined, gojs.Number(2), gojs.Number(3))
fmt.Println(vm.ToGo(sum)) // 5Convert values both ways with vm.FromGo (Go → JS: scalars, []any,
map[string]any) and vm.ToGo / vm.ToString (JS → Go).
gojs is designed to run untrusted JavaScript. The posture is defense in depth, and every layer is off by default:
- No ambient authority. A bare
gojs.New()has no way to print, read the clock, schedule timers, or load modules. Scripts see only pure-computation built-ins. Capabilities are granted one provider at a time, and each provider is an interface you can implement to mediate, log, or refuse individual operations. - No dynamic code by default.
evaland theFunctionconstructor are gated bySecurity{DisableEval, DisableFunctionCtor}; the CLI--sandboxmode turns them off entirely. - Prototype-pollution guard.
DisableProtoMutationfreezes mutation of built-in prototypes so untrusted code can't tamper with the shared realm. - Bounded resources.
WithLimits(Limits{MaxCallDepth, MaxSteps})caps recursion (catchableRangeError) and total evaluation steps (an uncatchable abort), and every evaluation honors acontext.Contextdeadline. A hostilewhile (true) {}is stopped by the step budget or the context, not by luck. - Single-threaded by construction. JavaScript never runs on two goroutines
at once, so a script cannot exploit host-side data races.
Close()cancels the context and drains outstanding timers and coroutines.
vm := gojs.New(
gojs.WithSecurity(gojs.Security{
DisableEval: true,
DisableFunctionCtor: true,
DisableProtoMutation: true,
}),
gojs.WithLimits(gojs.Limits{MaxCallDepth: 512, MaxSteps: 5_000_000}),
)See the sandbox and limits examples
for end-to-end setups.
Implemented intrinsics and globals (see Limitations for gaps):
| Area | Coverage |
|---|---|
Object |
literals, descriptors, keys/values/entries, assign, freeze/seal, create, getPrototypeOf, is, groupBy |
Array |
literals, iteration (map/filter/reduce/…), sort, flat, ES2023 toSorted/toReversed/toSpliced/with, from/of, Array.isArray |
String |
full method set incl. match/matchAll/replace/replaceAll/split (regex-aware), templates, padStart/padEnd |
Number/Math |
numeric methods, toFixed/toPrecision/toString(radix), the Math.* surface |
JSON |
parse/stringify with replacer (function + array), reviver, toJSON, cycle detection |
RegExp |
own ECMAScript engine: exec/test/Symbol.{match,replace,split,…}, all flags, backreferences, lookahead/lookbehind, named groups, u/v modes, \p{…} (jsregexp); opt-in RE2 backend |
BigInt |
arbitrary-precision integers, literals, asIntN/asUintN, mixed-type guards |
| Binary data | full TypedArray family, ArrayBuffer (resizable), SharedArrayBuffer (growable), DataView, Atomics (incl. multi-agent wait/waitAsync/notify) |
Proxy/Reflect |
all traps and the corresponding Reflect.* operations, realm-aware |
| Collections | Map, Set, WeakMap, WeakSet, WeakRef, FinalizationRegistry, and Promise (with the microtask queue) |
Symbol |
well-known symbols (iterator, asyncIterator, hasInstance, …) |
| Errors | full Error hierarchy (incl. AggregateError), subclassable |
| Globals | parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, Date, Promise, timers |
gojs can run TypeScript with no external tooling. The ts package
transpiles .ts/.tsx to JavaScript in-process — it embeds
github.com/iceisfun/typescript, a
hoisting of Microsoft's typescript-go compiler out of its internal/ packages —
and runs the result on the VM. Importing ts is what pulls that dependency into
a build; embeddings that only run JavaScript keep the zero-dependency core.
import "github.com/iceisfun/gojs/ts"
// Self-contained script:
vm := gojs.New(gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()))
ts.RunString(vm, "app.ts", `const n: number = 21; console.log(n * 2);`) // 42
// Multi-file: ts.Provider wraps any ModuleProvider so .ts modules are
// transpiled on load; import/require between them resolve through it.
vm = gojs.New(
gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()),
gojs.WithModuleProvider(ts.Provider(gojs.NewDirModuleProvider("./src"))),
)
vm.RunString("<entry>", `require("./main.ts")`)From the CLI:
gojs run app.ts # transpile + run a TypeScript entry fileTranspilation is checker-free (the isolatedModules model): type
annotations, interfaces, generics, and class visibility are erased/lowered, and
enum/const enum/namespace are lowered to runnable JavaScript — but the
program is not type-checked (the goal is to run TypeScript). Runtime error
stacks are source-mapped back to the original .ts line/column. Frontend-only
features that need a code transform — JSX and decorators — are out of
scope (gojs runs TypeScript as a scripting language, not a build tool) and are
rejected up front with a clear error rather than mis-compiled. See the
typescript example.
Runnable programs live under examples/, each with its own README:
| Example | Shows |
|---|---|
basic |
Run a script and read back its result |
expose_go |
Expose Go functions and data to JavaScript |
call_js |
Call script functions from Go |
providers |
Custom PrintProvider + timers on the event loop |
modules |
Intercept require() with a ModuleProvider |
typescript |
Run TypeScript (transpiled in-process) with imports |
regexp_engine |
Choose the RegExp backend (conformant vs RE2) |
limits |
Bound recursion and CPU with Limits |
sandbox |
Hardened sandbox: no I/O, no eval, timeout on runaway |
fetch |
The fetch API over a NetProvider (host/fetch) |
websocket |
A browser-compatible WebSocket client (host/websocket) |
sse |
Server-Sent Events / EventSource (host/sse) |
go run ./examples/basic
go run ./examples/expose_go
go run ./examples/providersgojs is organized into layered packages with no import cycles:
Source → lexer → parser → ast → interp ──► bytecode compiler → VM (default)
│
└────► tree-walking evaluator (fallback + oracle)
↑
Providers
| Package | Purpose |
|---|---|
token |
Lexical token types, categories, keywords, source spans |
lexer |
Tokenizes source (regex/division disambiguation, templates, ASI) |
ast |
AST node definitions (Expr/Stmt) with positions |
parser |
Recursive-descent + precedence-climbing parser |
interp |
The runtime: values, objects, environments, the bytecode compiler + VM, the tree-walking evaluator, built-ins, providers, event loop |
jsregexp |
Standalone ECMAScript RegExp engine (embeddable on its own) |
The root package gojs re-exports the common surface of interp. The bytecode
VM lowers hot function bodies to a stack machine and falls back to the
tree-walker per-subtree for not-yet-lowered constructs, so a compiled body is
always correct; the two engines are validated to produce identical results
across the whole suite.
gojs/
├── doc.go package overview (pkg.go.dev landing)
├── gojs.go root package: re-exported public surface
├── token/ token kinds, source positions, spans
├── lexer/ lexical scanner
├── ast/ AST node types
├── parser/ recursive-descent parser
├── interp/ evaluator, values, built-ins, providers, event loop, host API
├── ts/ optional TypeScript support (transpile + run; pulls in typescript-go)
├── cmd/gojs/ command-line runner (runs .js and .ts)
├── examples/ runnable embedding examples (each with a README)
└── tests/
├── harness/ behavioral JS conformance suite (self-asserting programs)
├── doctest/ documentation examples run as tests
└── test262/ optional Test262 runner (gated behind GOJS_T262)
go test ./... # full suite (fast; Test262 is skipped)
go test ./... -race # race detector across the event loop and coroutines
go test ./tests/harness # just the JS behavioral suiteThe behavioral suite runs real JavaScript programs that assert their own
results, so a regression surfaces as a failing Go test with the thrown value.
The optional Test262 runner is gated behind
the GOJS_T262 environment variable (pointing at a local checkout) so the
default go test ./... stays fast and hermetic.
The workflow is find-fix-test: when you hit a behavior that diverges from the
spec, first reproduce it as a failing test in tests/harness, then fix the
engine until it passes. Keep go test ./... -race green. Intentional
divergences from the spec (chiefly the code-point-indexed string model) are
recorded in NOTES-divergences.md so they aren't
mistaken for bugs. New built-ins and language features should land with harness
coverage.
Coverage is high (~99.99% of runnable Test262), but some areas are deliberately out of scope or approximated:
- Strings are indexed by Unicode code point, not UTF-16 code unit. This is a
deliberate divergence:
.length,charCodeAt, and friends count code points, and astral characters/lone surrogates don't round-trip through UTF-16-observing APIs the way a browser engine's do. SeeNOTES-divergences.md. - ES modules — CommonJS
require()works through aModuleProvider(see themodulesandtypescriptexamples), and dynamicimport()resolves against a module provider. Native top-level ESimport/exportmodule linking is not run directly; TypeScript's ES modules run by transpiling to CommonJS. eval/Function(...)dynamic code is supported but gated off by default under the sandbox security posture (DisableEval/DisableFunctionCtor).- Not implemented (out of scope for now):
Intl,Temporal, the explicit-resource-management proposal (DisposableStack/using), and a small set of other stage-track proposals. Tests for these are skipped, not failed. Atomics.waiton a non-blocking agent ([[CanBlock]] = false) is not modeled — a gojs agent always can block.
gojs is measured against the official Test262
suite. Current standing: ~99.99% of runnable tests pass, with entire domains
at 100% of runnable — including Proxy, Reflect, the TypedArray/ArrayBuffer
family, SharedArrayBuffer + multi-agent Atomics, RegExp, BigInt,
WeakRef, and FinalizationRegistry. The handful of remaining failures are
deferred non-bugs (a legacy Annex-B .caller misfeature and one upstream-broken
proposal test); the large skip buckets are unimplemented proposals (Temporal,
etc.), not failures. The bytecode VM and the tree-walker are cross-checked to
produce identical results across the whole suite.
See LICENSE.
