A Ruby wrapper for QuickJS to run JavaScript codes via Ruby with a smaller footprint.
gem install quickjs
gem 'quickjs'require 'quickjs'
Quickjs.eval_code('const fn = (n, pow) => n ** pow; fn(2,8);') # => 256
Quickjs.eval_code('const fn = (name) => `Hi, ${name}!`; fn("Itadori");') # => "Hi, Itadori!"
Quickjs.eval_code("[1,2,3]") #=> [1, 2, 3]
Quickjs.eval_code("({ a: '1', b: 1 })") #=> { 'a' => '1', 'b' => 1 }Options
Quickjs.eval_code(code,
memory_limit: 1024 ** 3, # 1GB memory limit
max_stack_size: 1024 ** 2, # 1MB max stack size
)# Label shown in JS stack traces (default: "<code>")
Quickjs.eval_code(code, filename: 'my_script.js')# eval_code will be interrupted after 1 sec (default: 100 msec)
Quickjs.eval_code(code, timeout_msec: 1_000)Quickjs.eval_code(code, features: [::Quickjs::MODULE_STD, ::Quickjs::POLYFILL_FILE])| Constant | Description |
|---|---|
MODULE_STD |
QuickJS std module |
MODULE_OS |
QuickJS os module |
FEATURE_TIMEOUT |
setTimeout / setInterval managed by CRuby |
POLYFILL_INTL |
Intl API (DateTimeFormat, NumberFormat, PluralRules, Locale) |
POLYFILL_FILE |
W3C File API (Blob and File) |
POLYFILL_ENCODING |
Encoding API (TextEncoder and TextDecoder) |
POLYFILL_URL |
URL API (URL and URLSearchParams) |
POLYFILL_CRYPTO |
Web Crypto API (crypto.getRandomValues, crypto.randomUUID, crypto.subtle); combine with POLYFILL_ENCODING for stringβbuffer conversion |
Accepts the same options as Quickjs.eval_code.
vm = Quickjs::VM.new
vm.eval_code('const a = { b: "c" };')
vm.eval_code('a.b;') #=> "c"
vm.eval_code('a.b = "d";')
vm.eval_code('a.b;') #=> "d"Parsing large JS bundles is the dominant cost of a fresh evaluation. compile parses once and returns a Quickjs::Runnable wrapping the serialized bytecode; run(on:) executes it on any VM of the same QuickJS build, skipping the parser. Useful when the same bundle is evaluated repeatedly across short-lived VMs (test environments, page-per-VM web emulators).
runnable = Quickjs::VM.new.compile(File.read('big_bundle.js'), filename: 'big_bundle.js')
vm = Quickjs::VM.new
runnable.run(on: vm) # use the given VM (no parse cost)
runnable.run # spin up a fresh VM with default options
runnable.run(on: { features: [::Quickjs::POLYFILL_INTL] }) # ad-hoc VM with optionsRunnable#to_s returns the underlying bytecode as a frozen ASCII-8BIT String, suitable for caching to memory or disk. Quickjs::Runnable.new(bytecode_string) reconstructs a Runnable from that blob β validation happens lazily at run time, so a corrupt or wrong-build blob surfaces as Quickjs::RuntimeError when executed. The bytecode format is tied to the QuickJS build, so include the gem version in your cache key if you persist across upgrades.
vm = Quickjs::VM.new
vm.eval_code('function add(a, b) { return a + b; }')
vm.call('add', 1, 2) #=> 3
vm.call(:add, 1, 2) #=> 3 (Symbol also works)
# Nested functions β preserves `this` binding
vm.eval_code('const counter = { n: 0, inc() { return ++this.n; } }')
vm.call('counter.inc') #=> 1
vm.call('counter.inc') #=> 2
# Keys with special characters via bracket notation
vm.eval_code("const obj = {}; obj['my-fn'] = x => x * 2;")
vm.call('obj["my-fn"]', 21) #=> 42
# Async functions are automatically awaited
vm.eval_code('async function fetchVal() { return 42; }')
vm.call('fetchVal') #=> 42vm = Quickjs::VM.new
# Equivalent to `import { default: aliasedDefault, member: member } from './exports.esm.js';`
vm.import({ default: 'aliasedDefault', member: 'member' }, from: File.read('exports.esm.js'))
vm.eval_code("aliasedDefault()") #=> Exported `default` of the ESM is called
vm.eval_code("member()") #=> Exported `member` of the ESM is called
# import { member, defaultMember } from './exports.esm.js';
vm.import(['member', 'defaultMember'], from: File.read('exports.esm.js'))
# import DefaultExport from './exports.esm.js';
vm.import('DefaultExport', from: File.read('exports.esm.js'))
# import * as all from './exports.esm.js';
vm.import('* as all', from: File.read('exports.esm.js'))By default each imported binding is attached to globalThis under its own name so later eval_code / call can see it. Pass code_to_expose: to replace that step with your own JS β useful for renaming, attaching the import somewhere other than globalThis, or skipping the global assignment entirely for side-effect-only imports.
# Rename on the way in
vm.import('Imported', from: File.read('exports.esm.js'),
code_to_expose: 'globalThis.RenamedImported = Imported;')
vm.eval_code('RenamedImported()') #=> calls the default export
vm.eval_code('!!globalThis.Imported') #=> false β the original name was never assigned
# Side-effect-only import: run the module body but don't expose anything
vm.import('initSomething', from: File.read('setup.esm.js'), code_to_expose: '')code_to_expose is just a JavaScript fragment that runs after the import statement, with the imported binding(s) in scope under the name(s) you requested. It works with both from: and filename:.
By default, import specifiers that aren't already loaded fall through to QuickJS's filesystem loader. Set a module_loader Proc to resolve specifiers in-memory instead β useful when the source code lives in a database, an importmap, or a virtual filesystem.
vm = Quickjs::VM.new
modules = {
'a' => "import { b } from 'b'; export const a = () => `a-${b()}`;",
'b' => "export const b = () => 'b-result';"
}
vm.module_loader = ->(name) { modules[name] }
vm.import(['a'], filename: 'a')
vm.eval_code('a()') #=> 'a-b-result'The Proc receives the (already normalized) module specifier and returns the module source as a String, or nil to signal "not found" (which raises Quickjs::ReferenceError on the JS side). Pass nil to clear a previously set loader.
When module_loader= is set, pass filename: to import instead of from: to resolve a named specifier directly through the loader β no inline bridge source needed. Passing both from: and filename: raises ArgumentError.
import awaits the module's top-level evaluation β top-level await, synchronous body execution, and any chained dynamic import(). The call blocks until the module's settle promise resolves. A top-level throw, a failed dynamic import(), or a rejected top-level await propagates back to Ruby as the matching Quickjs::*Error instead of being silently dropped.
Register a block to be notified when a JS Promise rejects with no .catch / then(_, onRejected) attached at the time of rejection β fire-and-forget chains, failed dynamic imports without try, etc.
vm = Quickjs::VM.new
vm.on_unhandled_rejection do |err|
warn "[JS] unhandled rejection: #{err.class} #{err.message}"
end
vm.eval_code("void Promise.reject(new TypeError('drift'));")
#=> warns: [JS] unhandled rejection: Quickjs::TypeError driftCalling on_unhandled_rejection again with a new block replaces the previously registered one (matching on_log).
The block receives a Quickjs::*Error matching the rejection reason (Quickjs::TypeError for new TypeError, etc.); non-Error rejections (Promise.reject('str'), Promise.reject({})) are wrapped in Quickjs::RuntimeError. The exception's #backtrace carries the JS-side stack frames (at func (file:line:col)) for Error rejections, so the rejection site shows up directly when you log or re-raise. Exceptions raised inside the block are swallowed β propagating them out would corrupt the QuickJS runtime.
The tracker fires synchronously when QuickJS first observes the rejection. A .catch attached later in the same tick does not suppress the notification, and a chain like Promise.reject(x).then(y).then(z) without a terminating .catch may emit a notification per intermediate promise. If that noise is a problem, attach handlers synchronously or dedupe by reason identity in your block. The block runs on the QuickJS stack β heavy work blocks JS execution.
vm = Quickjs::VM.new
vm.define_function("greetingTo") do |arg1|
['Hello!', arg1].join(' ')
end
vm.eval_code("greetingTo('Rick')") #=> 'Hello! Rick'Pass an Array as the name to register the function on an existing JS object (the last element is the method name; preceding elements are the object path):
vm = Quickjs::VM.new
vm.eval_code("const myLib = {}")
vm.define_function(["myLib", "greetingTo"]) { |name| "Hello, #{name}!" }
vm.eval_code("myLib.greetingTo('Rick')") #=> 'Hello! Rick'
# Deeply nested
vm.eval_code("const a = { b: { c: {} } }")
vm.define_function(["a", "b", "c", "double"]) { |x| x * 2 }
vm.eval_code("a.b.c.double(21)") #=> 42define_function returns the registered name as a Symbol (or an Array of Symbols for array paths).
A Ruby exception raised inside the block is catchable in JS as an Error, and propagates back to Ruby as the original exception type if uncaught in JS.
vm.define_function("fail") { raise IOError, "something went wrong" }
vm.eval_code('try { fail() } catch (e) { e.message }') #=> "something went wrong"
vm.eval_code("fail()") #=> raise IOError transparentlyWith POLYFILL_FILE enabled, a Ruby ::File returned from the block becomes a JS File-compatible proxy. Passing it back to Ruby from JS returns the original ::File object.
vm = Quickjs::VM.new(features: [::Quickjs::POLYFILL_FILE])
vm.define_function(:get_file) { File.open('report.pdf') }
vm.eval_code("get_file().name") #=> "report.pdf"
vm.eval_code("get_file().size") #=> Integer (byte size)
vm.eval_code("await get_file().text()") #=> file content as StringRegister a block to be called for each console.(log|info|debug|warn|error) call.
vm = Quickjs::VM.new
vm.on_log { |log| puts "#{log.severity}: #{log.to_s}" }
vm.eval_code('console.log("hello", 42)')
# => prints: info: hello 42
# log.severity #=> :info / :verbose / :warning / :error
# log.to_s #=> space-joined string of all arguments
# log.raw #=> Array of raw Ruby valuesvm = Quickjs::VM.new
vm.memory_usage
# => { malloc_size: Integer, malloc_limit: Integer, memory_used_size: Integer,
# atom_count: Integer, str_count: Integer, obj_count: Integer,
# prop_count: Integer, shape_count: Integer,
# js_func_count: Integer, js_func_code_size: Integer,
# c_func_count: Integer, array_count: Integer }
vm.gc! # trigger a QuickJS GC cycle; returns nil
vm.memory_poisoned? #=> false (true once the VM has hit out-of-memory)When the JS heap exhausts its memory limit, QuickJS enters a fragile state where further evaluation can segfault the process. memory_poisoned? flips to true after such an event, and subsequent eval_code / call calls raise Quickjs::RuntimeError immediately instead of risking a crash. Rescue it and recreate the VM.
vm = Quickjs::VM.new(memory_limit: 256 * 1024 * 1024)
begin
vm.eval_code(js)
rescue Quickjs::RuntimeError => e
raise unless vm.memory_poisoned?
vm = Quickjs::VM.new(memory_limit: 256 * 1024 * 1024)
retry
endBy default, the JSRuntime / JSContext behind a Quickjs::VM lives until Ruby's GC reclaims the wrapping object. Ruby's GC sizes its trigger by the Ruby-side object footprint (a few pointers) and doesn't see the C-side JS heap, so a workload that rebuilds VMs frequently β per-request, per-page-visit, throwaway pool β can let several megabytes per dead VM accumulate before a major GC fires.
dispose! frees the runtime immediately and marks the VM unusable:
vm = Quickjs::VM.new(features: [::Quickjs::POLYFILL_INTL])
vm.eval_code('β¦')
vm.dispose! # frees JSContext + JSRuntime now
vm.disposed? #=> true
vm.eval_code('1 + 1') # raises Quickjs::RuntimeError "VM has been disposed"dispose! is idempotent and safe to call before letting Ruby drop the reference β the dfree handler is a no-op on an already-disposed VM. The teardown itself can take tens of milliseconds on a VM with polyfills loaded; the GVL is released during the free so other Ruby threads (e.g. a background pool builder) keep running. For fire-and-forget teardown that doesn't block the caller, wrap it in a thread:
Thread.new { vm.dispose! }QuickJS does not automatically drain the job queue at the end of a synchronous eval_code / call. Continuations scheduled via Promise.resolve().then(...) or JS_EnqueueJob stay pending until something explicitly runs them β await inside JS does, but a sync return path does not.
vm = Quickjs::VM.new
vm.eval_code('globalThis.x = 0; Promise.resolve().then(() => { x = 1 }); void 0')
vm.eval_code('x') #=> 0 (the .then() callback hasn't run yet)
vm.drain_jobs! #=> 1 (number of jobs executed)
vm.eval_code('x') #=> 1drain_jobs! keeps running until the queue empties, so jobs that schedule further jobs all run in a single call. The drain is bounded by the VM's timeout_msec; exceeding it raises Quickjs::InterruptedError.
Useful when porting JS that assumed V8's implicit-drain semantics β V8 (and therefore mini_racer) flushes pending jobs at every eval boundary, so eval_code already sees .then() continuations run by the time it returns. QuickJS doesn't. Patterns like Promise.resolve().then(() => { ... }) and Stimulus/Hotwire callbacks that assume "the next microtask tick" silently fall through unless you call drain_jobs! explicitly.
| JavaScript | Ruby | Note | |
|---|---|---|---|
number (integer / float) |
β | Integer / Float |
|
string |
β | String |
|
true / false |
β | true / false |
|
null |
β | nil |
|
Array |
β | Array |
recursively converted |
Object |
β | Hash |
recursively converted; keys are always String |
function |
β | Quickjs::Function β .source, .call(*args, on:) |
|
undefined |
β | Quickjs::Value::UNDEFINED |
|
NaN |
β | Quickjs::Value::NAN |
|
Blob |
β | Quickjs::Blob β .size, .type, .content |
requires POLYFILL_FILE |
File |
β | Quickjs::File β .name, .last_modified + Blob attrs |
requires POLYFILL_FILE |
File proxy |
β | ::File |
requires POLYFILL_FILE; applies to define_function return values |
- @ursm β for continuous contributions improving performance and developer experience
- @persona-id β for providing real-world use cases that shape the direction of this project
ext/quickjsrb/quickjsext/quickjsrb/vendor/polyfill-intl-en.min.js(bundled and minified frompolyfills/)- MIT License Copyright (c) 2022 FormatJS
- MIT License Copyright (c) 2023 FormatJS
- MIT License Copyright (c) 2026 FormatJS
Otherwise, the MIT License, Copyright 2024 by Kengo Hamasaki.