Building a JavaScript Runtime with V8 and Rust
Welcome to this guide on understanding V8 and building custom JavaScript runtimes using Rust. These notes focus on core V8 concepts and runtime architecture patterns.
What is V8?
V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It’s the same engine that powers Chrome, Node.js, and Deno. V8 implements the ECMAScript specification and compiles JavaScript directly to native machine code using just-in-time (JIT) compilation.
Why Build a Runtime?
Building a runtime helps you:
- Understand Runtime Internals - Learn how Node.js and Deno work under the hood
- Embed Scripting - Add JavaScript capabilities to your Rust applications
- Control the Environment - Define exactly what JavaScript can access (security, resource limits)
- Expose Native Functionality - Bridge your Rust code to JavaScript
Prerequisites
- Rust programming language
- Basic understanding of JavaScript event loops and async programming
rusty_v8crate (safe Rust bindings to V8)
What You’ll Learn
This book covers the fundamental concepts for building a JavaScript runtime:
- Core V8 concepts (Isolates, Contexts, Handles, Scopes)
- Runtime architecture and initialization
- Event loop design with message passing
- Bridging Rust and JavaScript with native bindings
- ES Module loading and resolution
- Async operations (timers, fetch, promises)
- Data exchange between Rust and JS
- Security considerations and resource limits
Throughout this book, we’ll reference toyjs - a minimal teaching runtime that demonstrates these concepts in practice. The code examples show how these V8 concepts translate into working Rust code.
Core V8 Concepts
Before building a runtime, you need to understand V8’s fundamental building blocks. These concepts apply to any V8-based runtime, whether Node.js, Deno, or your own.
Isolate
An Isolate is an isolated instance of the V8 engine with its own heap and garbage collector. Think of it as a completely independent JavaScript VM.
Key characteristics:
- Each isolate has its own memory heap - objects from one isolate cannot be used in another
- An isolate can only be accessed by one thread at a time (V8 is single-threaded per isolate)
- You can have multiple isolates in one process (each on different threads)
When to use multiple isolates:
- Running untrusted code in isolation (sandbox per tenant)
- Worker threads (each worker gets its own isolate)
- Microservices architecture where each service has independent JS execution
#![allow(unused)]
fn main() {
// Creating an Isolate with heap limits
let params = v8::CreateParams::default()
.heap_limits(0, 128 * 1024 * 1024); // 128MB max heap
let mut isolate = v8::Isolate::new(params);
}
In toyjs, we create a single isolate for the entire runtime (see runtime.rs:61).
Context
A Context is an execution environment within an isolate. It provides the global object and built-in JavaScript objects (Object, Array, etc.).
Why contexts:
- Multiple contexts can share one isolate
- Each context has isolated globals - variables defined in one context don’t leak to another
- Useful for running untrusted code without polluting your main environment
The global object:
- In browsers:
window - In Node.js:
global - In your runtime: whatever you define
#![allow(unused)]
fn main() {
// Creating a Context
v8::scope!(let handle_scope, &mut isolate);
let context = v8::Context::new(handle_scope, Default::default());
}
Most simple runtimes use a single context. Advanced runtimes might use multiple contexts for sandboxing.
Handles and Scopes
V8 uses garbage collection, so you can’t hold raw pointers to JavaScript objects. Instead, V8 provides handles - smart pointers that the GC knows about.
Types of Scopes
There are two main types of scopes in rusty_v8, with others derived from them:
HandleScope- a scope to create and accessLocalhandles.TryCatch- a scope to catch exceptions thrown from JavaScript.
The Challenge of Scopes in Rust
V8 scopes have properties that are challenging to model in Rust:
- Nesting:
HandleScopes can be nested, but handles are bound to the innermost scope. This means handle lifetimes are determined by the innermostHandleScope. - Immovability:
HandleScopeandTryCatchcannot be moved because V8 holds direct pointers to them. - Inheritance: The C++ API relies on inheritance, which is modeled using
Derefin Rust.
Creating and Initializing Scopes
Because scopes must be pinned to the stack, creating them involves allocation, pinning, and initialization.
The verbose way:
#![allow(unused)]
fn main() {
use v8::{HandleScope, Local, Object, Isolate, Context, ContextScope};
// 1. Allocate storage
let scope = HandleScope::new(&mut isolate);
// 2. Pin to stack
let scope = std::pin::pin!(scope);
// 3. Initialize
let mut scope = scope.init();
}
The idiomatic way using the v8::scope! macro:
#![allow(unused)]
fn main() {
// This expands into statements introducing `scope`
v8::scope!(let scope, &mut isolate);
}
Scopes as Function Arguments
When passing a scope to a function, use v8::PinScope. This is a shorthand for PinnedRef<'s, HandleScope<'i>>.
#![allow(unused)]
fn main() {
fn create_number<'s>(scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Number> {
v8::Number::new(scope, 42.0)
}
}
Inheritance via Deref
Scopes implement Deref and DerefMut to simulate inheritance. This allows you to pass a ContextScope to a function expecting a PinScope.
ContextScopederefs toHandleScopeCallbackScopederefs toHandleScope
ContextScope
A ContextScope enters a context, making it the “current” context for V8 operations. Unlike HandleScope, it is not address-sensitive and can be moved.
#![allow(unused)]
fn main() {
let context_scope = v8::ContextScope::new(scope, context);
// Now we're "inside" the context
}
Global Handles
If you need a handle that outlives a HandleScope (e.g., storing a callback for later), use v8::Global:
#![allow(unused)]
fn main() {
let global_context = v8::Global::new(scope, context);
// global_context can be stored in a struct and used later
}
The Scope Stack Pattern
Every V8 operation follows this pattern:
#![allow(unused)]
fn main() {
pub fn execute_script(&mut self, code: &str) {
// 1. Create HandleScope (borrows isolate mutably)
// We create the scope, pin it to the stack, and initialize it
let handle_scope = std::pin::pin!(v8::HandleScope::new(&mut self.isolate));
let mut handle_scope = handle_scope.init();
// 2. Get the context (convert Global → Local)
let context = v8::Local::new(&mut handle_scope, &self.context);
// 3. Enter the context
// ContextScope wraps the HandleScope and doesn't need its own pinning
let mut scope = v8::ContextScope::new(&mut handle_scope, context);
// 4. Now you can work with V8
let source = v8::String::new(&mut scope, code).unwrap();
let script = v8::Script::compile(&mut scope, source, None).unwrap();
script.run(&mut scope);
}
}
Why the scope dance?
- Rust’s borrowing rules enforce V8’s threading rules
- Scopes ensure handles are properly managed
- The type system prevents you from using handles after they’re invalidated
This pattern appears everywhere in V8 code - you’ll write it hundreds of times.
Runtime Architecture
A JavaScript runtime is more than just the V8 engine - it’s the orchestration layer that connects V8 to the outside world. Let’s understand the key architectural decisions.
The Runtime Struct
At the core, a runtime needs to hold:
- The V8 Isolate - The JavaScript engine instance
- A Global Context - The execution environment
- Communication Channels - For async operations (timers, I/O, etc.)
#![allow(unused)]
fn main() {
pub struct JsRuntime {
isolate: v8::OwnedIsolate,
context: v8::Global<v8::Context>,
// Channels for event loop communication
scheduler_tx: mpsc::UnboundedSender<SchedulerMessage>,
callback_rx: mpsc::UnboundedReceiver<CallbackMessage>,
}
}
Why v8::Global for context?
- The context needs to outlive individual function scopes
v8::Globalis a handle that can be stored in structs and moved between functions- It’s converted to
v8::Localwhen you need to use it (with a HandleScope)
Initialization Sequence
Setting up a runtime involves several steps in a specific order:
1. Initialize the V8 Platform (Once Per Process)
V8 requires platform initialization before creating isolates. Use std::sync::Once to ensure it happens exactly once:
#![allow(unused)]
fn main() {
use std::sync::Once;
static INIT: Once = Once::new();
pub fn init_v8() {
INIT.call_once(|| {
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform);
v8::V8::initialize();
});
}
}
Why once? V8 maintains global state. Multiple initializations will crash your program.
2. Create the Isolate
#![allow(unused)]
fn main() {
init_v8(); // Ensure V8 is initialized first
let params = v8::CreateParams::default();
let mut isolate = v8::Isolate::new(params);
}
Optional: Set heap limits
#![allow(unused)]
fn main() {
let params = v8::CreateParams::default()
.heap_limits(0, 128 * 1024 * 1024); // 128MB
}
3. Create the Context
The context must be created inside a HandleScope and immediately converted to a Global:
#![allow(unused)]
fn main() {
let context = {
let scope = &mut v8::HandleScope::new(&mut isolate);
let context = v8::Context::new(scope, Default::default());
v8::Global::new(scope, context) // Convert to Global before scope ends
};
}
Why the extra scope block? We need the HandleScope to create the context, but we can’t store the HandleScope in our struct. We convert to a Global handle which can outlive the scope.
4. Setup Native Bindings
With the context created, you can now attach native functions to the global object:
#![allow(unused)]
fn main() {
let scope = &mut v8::HandleScope::new(&mut isolate);
let context = v8::Local::new(scope, &context);
let scope = &mut v8::ContextScope::new(scope, context);
// Get the global object
let global = context.global(scope);
// Create a native function
let print_fn = v8::FunctionTemplate::new(scope, print_callback);
let print_fn = print_fn.get_function(scope).unwrap();
// Attach to global
let name = v8::String::new(scope, "print").unwrap();
global.set(scope, name.into(), print_fn.into());
}
Now JavaScript can call print("hello")!
Pinned Scopes
Modern rusty_v8 uses a pinned scope pattern for better safety:
#![allow(unused)]
fn main() {
let handle_scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let mut scope = handle_scope.init();
// use scope...
}
This prevents accidentally moving scopes, which can cause memory safety issues. You’ll see this pattern in toyjs (see runtime.rs:65).
Startup Optimization: Snapshots
V8 supports heap snapshots - serialized state of an isolate that can be quickly loaded instead of initializing from scratch.
How it works:
- At build time: Create an isolate, run initialization code, serialize the heap
- At runtime: Load the blob directly
#![allow(unused)]
fn main() {
let snapshot_data: &'static [u8] = include_bytes!("snapshot.bin");
let params = v8::CreateParams::default()
.snapshot_blob(snapshot_data);
let isolate = v8::Isolate::new(params);
}
Benefits:
- Faster startup (10-100x for large standard libraries)
- Smaller runtime binary (move JS code into snapshot)
- Used by Node.js, Deno for built-in modules
Tradeoffs:
- Build-time complexity
- Snapshot must be regenerated when code changes
- Only helps if you have significant initialization code
For a simple runtime like toyjs, snapshots aren’t necessary. But production runtimes (Node, Deno) use them extensively.
The Event Loop
JavaScript’s power comes from its non-blocking async model. But V8 itself is synchronous - it can’t wait for network requests or timers. We must build an event loop in Rust to provide async capabilities.
Why V8 Needs an Event Loop
V8 executes JavaScript synchronously:
console.log("A");
// V8 blocks here if this were synchronous
doExpensiveWork();
console.log("B");
But JavaScript expects async operations:
console.log("A");
setTimeout(() => console.log("C"), 1000); // Don't block!
console.log("B");
// Output: A, B, C (after 1 second)
The runtime (not V8) must provide:
- Timers (setTimeout, setInterval)
- I/O (fetch, file operations)
- Promises (microtask queue)
The Two-Channel Architecture
The standard pattern uses message passing between V8 (single-threaded) and an async event loop:
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ JavaScript │ │ Rust Runtime │ │ Event Loop │
│ (V8) │ │ (Main Thread) │ │ (Tokio) │
└─────────────┘ └──────────────────┘ └──────────────┘
│ │ │
│ fetch("url") │ │
│------------------------------>│ │
│ │ SchedulerMessage::Fetch │
│ │------------------------------->│
│ │ │
│ │ (spawn async task)
│ │ (perform HTTP)
│ │ │
│ │ CallbackMessage::Success │
│ │<-------------------------------│
│ resolve(response) │ │
│<------------------------------│ │
│ │ │
Two channels:
- Scheduler Channel (JS → Event Loop): Sends async work requests
- Callback Channel (Event Loop → JS): Sends completion notifications
Message Types
Define enums for communication:
#![allow(unused)]
fn main() {
pub type CallbackId = u64;
// JavaScript → Event Loop
pub enum SchedulerMessage {
ScheduleTimeout(CallbackId, u64), // id, delay_ms
Fetch(CallbackId, String), // id, url
Shutdown,
}
// Event Loop → JavaScript
pub enum CallbackMessage {
ExecuteTimeout(CallbackId),
FetchSuccess(CallbackId, String), // id, body
FetchError(CallbackId, String), // id, error
}
}
The Event Loop (Rust Async Side)
The event loop runs in a Tokio task and spawns async operations:
#![allow(unused)]
fn main() {
pub async fn run_event_loop(
mut scheduler_rx: mpsc::UnboundedReceiver<SchedulerMessage>,
callback_tx: mpsc::UnboundedSender<CallbackMessage>,
) {
while let Some(msg) = scheduler_rx.recv().await {
match msg {
SchedulerMessage::ScheduleTimeout(id, delay_ms) => {
let tx = callback_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
let _ = tx.send(CallbackMessage::ExecuteTimeout(id));
});
}
SchedulerMessage::Fetch(id, url) => {
let tx = callback_tx.clone();
tokio::spawn(async move {
match reqwest::get(&url).await {
Ok(resp) => match resp.text().await {
Ok(body) => {
let _ = tx.send(CallbackMessage::FetchSuccess(id, body));
}
Err(e) => {
let _ = tx.send(CallbackMessage::FetchError(id, e.to_string()));
}
},
Err(e) => {
let _ = tx.send(CallbackMessage::FetchError(id, e.to_string()));
}
}
});
}
SchedulerMessage::Shutdown => break,
}
}
}
}
See runtime/event_loop.rs in toyjs for the full implementation.
The V8 Pump (Sync Side)
The main thread periodically checks for completed callbacks and executes them in V8:
#![allow(unused)]
fn main() {
pub fn process_callbacks(&mut self) {
let scope = std::pin::pin!(v8::HandleScope::new(&mut self.isolate));
let mut scope = scope.init();
let context = v8::Local::new(&scope, &self.context);
let scope = &mut v8::ContextScope::new(&mut scope, context);
// Process all pending callbacks
while let Ok(msg) = self.callback_rx.try_recv() {
match msg {
CallbackMessage::ExecuteTimeout(id) => {
// Call JS: __executeTimer(id)
let global = context.global(scope);
let key = v8::String::new(scope, "__executeTimer").unwrap();
if let Some(func) = global.get(scope, key.into()) {
if func.is_function() {
let func: v8::Local<v8::Function> = func.try_into().unwrap();
let id_val = v8::Number::new(scope, id as f64);
func.call(scope, global.into(), &[id_val.into()]);
}
}
}
// ... handle other callbacks
}
}
// CRITICAL: Process microtasks (Promises!)
let tc_scope = std::pin::pin!(v8::TryCatch::new(scope));
let mut tc_scope = tc_scope.init();
tc_scope.perform_microtask_checkpoint();
}
}
Microtasks and Promises
V8 has an internal microtask queue for Promises. After processing callbacks, you must call perform_microtask_checkpoint():
#![allow(unused)]
fn main() {
tc_scope.perform_microtask_checkpoint();
}
Why? When JavaScript uses Promises:
fetch("url").then(data => console.log(data));
The .then() handler is queued as a microtask. Without the checkpoint, Promise handlers never execute!
The Main Loop
In your main function, continuously pump callbacks:
#[tokio::main]
async fn main() {
let mut runtime = JsRuntime::new();
let event_loop = runtime.run_event_loop();
runtime.execute_script_module("fetch('https://httpbin.org/get')");
// Keep processing callbacks
for _ in 0..100 {
runtime.process_callbacks();
tokio::time::sleep(Duration::from_millis(100)).await;
}
runtime.shutdown();
event_loop.await.unwrap();
}
Points to note
- V8 is synchronous - The event loop is your responsibility
- Message passing - Tokio and V8 communicate via channels
- Callback IDs - Track which JS callback to invoke when async work completes
- Microtask checkpoint - Essential for Promise resolution
- try_recv() - Non-blocking, V8 doesn’t wait for callbacks
This architecture is used by Node.js (libuv), Deno (Tokio), and most V8-based runtimes.
Limitations of the Message-Passing Design
While our two-channel architecture is clear and educational, it has several limitations in production:
1. Performance Overhead
Every async operation requires:
- Creating and sending a message through a channel
- Channel synchronization costs
- Polling the callback channel (
try_recv()) - Looking up callbacks in JavaScript Maps
// JavaScript side maintains callback maps
const timeoutCallbacks = new Map();
const fetchResolvers = new Map();
// Every async op needs manual bookkeeping
function fetch(url) {
const id = nextId++;
return new Promise((resolve, reject) => {
fetchResolvers.set(id, { resolve, reject });
__scheduleFetch(id, url);
});
}
2. Polling vs Event-Driven
Our main loop polls for callbacks every 100ms:
#![allow(unused)]
fn main() {
for _ in 0..100 {
runtime.process_callbacks();
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
This means:
- 100ms latency for fast operations
- Wasted CPU cycles when idle
- No backpressure control
3. Manual Type Conversions
Each binding requires manual argument handling:
#![allow(unused)]
fn main() {
fn fetch_binding(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut retval: v8::ReturnValue,
) {
// Manual extraction and validation
let id = args.get(0).number_value(scope).unwrap() as u64;
let url = args.get(1).to_rust_string_lossy(scope);
// ... send message
}
}
4. Limited Error Handling
Errors become simple strings:
#![allow(unused)]
fn main() {
CallbackMessage::FetchError(id, e.to_string())
}
No stack traces, error types, or proper error propagation.
5. No Resource Management
No way to:
- Track open connections
- Cancel in-flight operations
- Limit concurrent operations
- Clean up resources on shutdown
How Deno Solves These Problems
Deno uses a sophisticated op (operation) system that addresses all these limitations:
1. Direct Op Integration
Instead of message passing, Deno ops are directly callable:
#![allow(unused)]
fn main() {
#[op2(async)]
pub async fn op_fetch(
state: Rc<RefCell<OpState>>,
#[string] url: String,
) -> Result<Response, AnyError> {
// Direct async Rust code
let client = state.borrow().borrow::<HttpClient>();
let resp = client.get(url).await?;
Ok(Response::from(resp))
}
}
The #[op2] macro generates:
- V8 function bindings
- Type conversions (automatic)
- Fast call paths (when possible)
- Error handling
2. Promise-Based Architecture
Deno tracks promises internally with a clever system:
#![allow(unused)]
fn main() {
// In Rust
pub struct JsRuntime {
pending_ops: FuturesUnorderedDriver<OpResult>,
promise_ring: Vec<Option<(PromiseId, OpResult)>>,
// ...
}
// In JavaScript (core/01_core.js)
const promiseRing = new Array(RING_SIZE);
const promiseMap = new SafeMap();
// Promises get tagged with IDs internally
function opAsync(name, ...args) {
const promise = new Promise((resolve, reject) => {
// ...
});
promise[promiseIdSymbol] = promiseId;
return promise;
}
}
3. Integrated Event Loop
Instead of polling, Deno integrates async ops into the main event loop:
#![allow(unused)]
fn main() {
pub fn poll_event_loop(&mut self, cx: &mut Context) -> Poll<Result<(), Error>> {
// Poll pending ops
loop {
match self.pending_ops.poll_next_unpin(cx) {
Poll::Ready(Some((promise_id, result))) => {
// Collect results
results.push((promise_id, result));
}
Poll::Ready(None) | Poll::Pending => break,
}
}
// Single JavaScript call with all results
if !results.is_empty() {
self.resolve_promises_in_js(results);
}
// Run microtasks
self.perform_microtask_checkpoint();
// Determine if more work pending
if self.has_pending_work() {
Poll::Pending
} else {
Poll::Ready(Ok(()))
}
}
}
4. OpState for Resource Management
Deno provides OpState - a type-safe container for runtime state:
#![allow(unused)]
fn main() {
pub struct OpState {
resource_table: ResourceTable,
extensions: HashMap<TypeId, Box<dyn Any>>,
// ...
}
// Ops can access shared state
#[op2]
fn op_read(
state: &mut OpState,
#[smi] rid: ResourceId,
#[buffer] buf: &mut [u8],
) -> Result<usize, AnyError> {
let resource = state.resource_table.get::<FileResource>(rid)?;
resource.read(buf)
}
}
5. Performance Optimizations
Deno uses several techniques for performance:
- Fast ops: Direct V8 fast API calls for simple ops
- Zero-copy buffers: Pass ArrayBuffers without copying
- Lazy/Eager scheduling: Control when ops are polled
- Ring buffer: Recent promises in array, older in Map
#![allow(unused)]
fn main() {
// Fast op - no promise overhead for sync operations
#[op2(fast)]
fn op_add(#[smi] a: i32, #[smi] b: i32) -> i32 {
a + b
}
}
Bridging Rust and JavaScript
The power of a custom runtime comes from exposing Rust capabilities to JavaScript. This bridge is where Rust functions become JavaScript APIs.
Native Function Callbacks
V8 function callbacks have a specific signature using rusty_v8’s pinned scope pattern:
#![allow(unused)]
fn main() {
fn print_callback(
scope: &mut v8::PinScope,
args: v8::FunctionCallbackArguments,
mut _retval: v8::ReturnValue,
) {
// Get first argument
if args.length() > 0 {
let arg = args.get(0);
let s = arg.to_string(scope).unwrap();
let rust_string = s.to_rust_string_lossy(scope);
println!("JS: {}", rust_string);
}
}
}
Key components:
- scope: The HandleScope for creating V8 values
- args: Access function arguments with
.get(index) - retval: Set the return value with
.set(value)
Registering Native Functions
To make a Rust function callable from JavaScript, attach it to the global object:
#![allow(unused)]
fn main() {
pub fn setup_bindings(scope: &mut v8::PinScope) {
let global = scope.get_current_context().global(scope);
// Create function from callback
let func = v8::FunctionTemplate::new(scope, print_callback);
let func = func.get_function(scope).unwrap();
// Attach to global with name "print"
let name = v8::String::new(scope, "print").unwrap();
global.set(scope, name.into(), func.into());
}
}
Now JavaScript can call print("Hello from JS!")
Type Conversion
Converting between Rust and JavaScript types:
Rust → JavaScript:
#![allow(unused)]
fn main() {
// Strings
let js_str = v8::String::new(scope, "hello").unwrap();
// Numbers
let js_num = v8::Number::new(scope, 42.5);
// Booleans
let js_bool = v8::Boolean::new(scope, true);
// Objects
let obj = v8::Object::new(scope);
let key = v8::String::new(scope, "foo").unwrap();
let val = v8::String::new(scope, "bar").unwrap();
obj.set(scope, key.into(), val.into());
}
JavaScript → Rust:
#![allow(unused)]
fn main() {
// Check type
if value.is_string() {
let s = value.to_string(scope).unwrap();
let rust_str = s.to_rust_string_lossy(scope);
}
if value.is_number() {
let num = value.number_value(scope).unwrap(); // f64
}
if value.is_boolean() {
let b = value.boolean_value(scope); // bool
}
}
The Closure Problem
Consider this async function:
#![allow(unused)]
fn main() {
fn setup_fetch(scope: &mut v8::PinScope, scheduler_tx: mpsc::UnboundedSender<SchedulerMessage>) {
// ❌ This doesn't work - can't capture non-Copy types in callback
let func = v8::Function::new(scope, |scope, args, _retval| {
let url = args.get(0).to_rust_string_lossy(scope);
scheduler_tx.send(SchedulerMessage::Fetch(1, url)); // ❌ Can't capture scheduler_tx
});
}
}
Problem: Function callbacks can’t capture non-Copy types like channels.
Solution: External State Pattern
Store state in V8’s heap using v8::External:
#![allow(unused)]
fn main() {
// 1. Define state struct
struct FetchState {
scheduler_tx: mpsc::UnboundedSender<SchedulerMessage>,
}
// 2. Store state in V8 heap
pub fn setup_fetch(scope: &mut v8::PinScope, scheduler_tx: mpsc::UnboundedSender<SchedulerMessage>) {
let global = scope.get_current_context().global(scope);
// Box the state and convert to raw pointer
let state = FetchState { scheduler_tx };
let state_ptr = Box::into_raw(Box::new(state)) as *mut std::ffi::c_void;
// Wrap in v8::External
let external = v8::External::new(scope, state_ptr);
let key = v8::String::new(scope, "__fetchState").unwrap();
global.set(scope, key.into(), external.into());
// Create native function
let native_fetch = v8::Function::new(scope, native_fetch_callback).unwrap();
let name = v8::String::new(scope, "__nativeFetch").unwrap();
global.set(scope, name.into(), native_fetch.into());
}
// 3. Retrieve state in callback
fn native_fetch_callback(
scope: &mut v8::PinScope,
args: v8::FunctionCallbackArguments,
mut _retval: v8::ReturnValue,
) {
// Get the state from global
let global = scope.get_current_context().global(scope);
let key = v8::String::new(scope, "__fetchState").unwrap();
let external_val = global.get(scope, key.into()).unwrap();
if let Ok(external) = v8::Local::<v8::External>::try_from(external_val) {
let state_ptr = external.value() as *const FetchState;
let state = unsafe { &*state_ptr };
// Now we can use the channel!
let id = args.get(0).number_value(scope).unwrap_or(0.0) as u64;
let url = args.get(1).to_rust_string_lossy(scope);
let _ = state.scheduler_tx.send(SchedulerMessage::Fetch(id, url));
}
}
}
This pattern is used in toyjs for timers and fetch (see runtime/timers.rs and runtime/fetch.rs).
JavaScript Wrapper Pattern
Native functions are low-level. Wrap them with JavaScript for a clean API:
#![allow(unused)]
fn main() {
// Compile JavaScript wrapper
let js_code = r#"
globalThis.fetchCallbacks = new Map();
globalThis.nextFetchId = 1;
globalThis.fetch = function(url) {
return new Promise((resolve, reject) => {
const id = globalThis.nextFetchId++;
globalThis.fetchCallbacks.set(id, { resolve, reject });
__nativeFetch(id, url); // Call native function
});
};
globalThis.__executeFetchSuccess = function(id, body) {
const callbacks = globalThis.fetchCallbacks.get(id);
if (callbacks) {
globalThis.fetchCallbacks.delete(id);
callbacks.resolve({ text: () => Promise.resolve(body) });
}
};
"#;
let code_str = v8::String::new(scope, js_code).unwrap();
let script = v8::Script::compile(scope, code_str, None).unwrap();
script.run(scope).unwrap();
}
Benefits:
- Clean JavaScript API (
fetch(url)instead of__nativeFetch(id, url)) - Promise support
- Callback management in JavaScript (easier than Rust
HashMap<u64, v8::Global<v8::Function>>)
The Complete Flow
- JavaScript calls
fetch("url") - JS wrapper generates ID, stores callbacks, calls
__nativeFetch(id, url) - Rust callback extracts state, sends
SchedulerMessage::Fetchto event loop - Event loop performs async HTTP request
- Event loop sends
CallbackMessage::FetchSuccess(id, body)back - Rust calls
__executeFetchSuccess(id, body)in V8 - JS wrapper retrieves stored callbacks, resolves Promise
This pattern powers all async operations in JavaScript runtimes.
ES Module Loading
Modern JavaScript uses ES modules (import/export). Unlike scripts, modules require a loader to resolve and fetch dependencies. V8 provides the infrastructure, but you must implement the loading logic.
Scripts vs Modules
Scripts are simple - compile and run:
#![allow(unused)]
fn main() {
let code = "console.log('hello')";
let source = v8::String::new(scope, code).unwrap();
let script = v8::Script::compile(scope, source, None).unwrap();
script.run(scope);
}
Modules have dependencies:
// main.js
import { add } from './math.js';
console.log(add(2, 3));
// math.js
export function add(a, b) {
return a + b;
}
V8 needs your help to:
- Resolve
'./math.js'to an absolute path - Load the file from disk
- Compile it as a module
- Cache it for reuse
- Link modules together
Module Compilation
Compile a module with v8::script_compiler::compile_module:
#![allow(unused)]
fn main() {
let code = "export function add(a, b) { return a + b; }";
let source_str = v8::String::new(scope, code).unwrap();
// Create ScriptOrigin with is_module=true
let origin = v8::ScriptOrigin::new(
scope,
v8::String::new(scope, "math.js").unwrap().into(), // filename
0, // line_offset
0, // column_offset
false, // is_shared_cross_origin
123, // script_id
None, // source_map_url
false, // is_opaque
false, // is_wasm
true, // is_module ← important!
None, // host_defined_options
);
let mut source = v8::script_compiler::Source::new(source_str, Some(&origin));
let module = v8::script_compiler::compile_module(scope, &mut source).unwrap();
}
Module Resolution Callback
When a module imports another module, V8 calls your module resolver callback:
#![allow(unused)]
fn main() {
fn module_resolver<'a>(
context: v8::Local<'a, v8::Context>,
specifier: v8::Local<'a, v8::String>, // './math.js'
_import_attributes: v8::Local<'a, v8::FixedArray>,
referrer: v8::Local<'a, v8::Module>, // main.js module
) -> Option<v8::Local<'a, v8::Module>> {
// Your loading logic here
}
}
Your responsibilities:
- Convert specifier (
'./math.js') to absolute path - Check cache for already-loaded module
- If not cached, read file and compile module
- Store in cache
- Return the module
The Module Loader Pattern
Create a global module cache:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
static LOADER: OnceLock<Mutex<FsModuleLoader>> = OnceLock::new();
pub struct FsModuleLoader {
// Map: absolute_path → Global<Module>
modules: HashMap<String, v8::Global<v8::Module>>,
// Map: module_hash → absolute_path (for reverse lookup)
paths: HashMap<i32, String>,
}
impl FsModuleLoader {
// Global singleton
pub fn global() -> Arc<Mutex<Self>> {
LOADER.get_or_init(|| {
Arc::new(Mutex::new(FsModuleLoader {
modules: HashMap::new(),
paths: HashMap::new(),
}))
}).clone()
}
pub fn store_module(&mut self, path: String, module: v8::Global<v8::Module>, hash: i32) {
self.modules.insert(path.clone(), module);
self.paths.insert(hash, path);
}
pub fn get_module(&self, path: &str) -> Option<&v8::Global<v8::Module>> {
self.modules.get(path)
}
pub fn get_path_by_hash(&self, hash: i32) -> Option<&String> {
self.paths.get(&hash)
}
}
}
Path Resolution
Resolve relative specifiers to absolute paths:
#![allow(unused)]
fn main() {
impl FsModuleLoader {
pub fn resolve_path(referrer_path: &str, specifier: &str) -> Option<String> {
let referrer = std::path::Path::new(referrer_path);
let referrer_dir = referrer.parent()?;
// Resolve relative path
let resolved = referrer_dir.join(specifier);
let canonical = resolved.canonicalize().ok()?;
Some(canonical.to_string_lossy().into_owned())
}
}
}
This handles:
'./math.js'- same directory'../utils/helper.js'- parent directory'./sub/module.js'- subdirectory
The Complete Resolver
#![allow(unused)]
fn main() {
fn module_resolver<'a>(
context: v8::Local<'a, v8::Context>,
specifier: v8::Local<'a, v8::String>,
_import_attributes: v8::Local<'a, v8::FixedArray>,
referrer: v8::Local<'a, v8::Module>,
) -> Option<v8::Local<'a, v8::Module>> {
let scope_storage = std::pin::pin!(unsafe { v8::CallbackScope::new(context) });
let scope = &mut scope_storage.init();
let specifier_str = specifier.to_rust_string_lossy(scope);
let referrer_hash = referrer.get_identity_hash();
let loader = FsModuleLoader::global();
// 1. Find referrer's path using hash
let referrer_path = {
let loader_guard = loader.lock().unwrap();
loader_guard.get_path_by_hash(referrer_hash).cloned()?
};
// 2. Resolve specifier relative to referrer
let mut resolved_path = FsModuleLoader::resolve_path(&referrer_path, &specifier_str)?;
// 3. Auto-add .js extension if missing
if !std::path::Path::new(&resolved_path).exists() {
resolved_path = format!("{}.js", resolved_path);
}
// 4. Check cache
{
let loader_guard = loader.lock().unwrap();
if let Some(cached_module) = loader_guard.get_module(&resolved_path) {
return Some(v8::Local::new(scope, cached_module));
}
}
// 5. Load from filesystem
let code = std::fs::read_to_string(&resolved_path).ok()?;
// 6. Compile as module
let source_str = v8::String::new(scope, &code)?;
let origin = v8::ScriptOrigin::new(
scope,
v8::String::new(scope, &resolved_path)?.into(),
0, 0, false, 123, None, false, false,
true, // is_module
None,
);
let mut source = v8::script_compiler::Source::new(source_str, Some(&origin));
let module = v8::script_compiler::compile_module(scope, &mut source)?;
// 7. Store in cache
let module_hash = module.get_identity_hash();
let global_module = v8::Global::new(scope, module);
{
let mut loader_guard = loader.lock().unwrap();
loader_guard.store_module(resolved_path, global_module, module_hash);
}
Some(module)
}
}
Module Instantiation and Evaluation
After compiling, modules must be instantiated (link imports) and evaluated (run code):
#![allow(unused)]
fn main() {
pub fn execute_module(&mut self, code: &str) -> String {
let scope = &mut v8::HandleScope::new(&mut self.isolate);
let context = v8::Local::new(scope, &self.context);
let scope = &mut v8::ContextScope::new(scope, context);
let tc_scope = &mut v8::TryCatch::new(scope);
// 1. Compile
let source_str = v8::String::new(tc_scope, code).unwrap();
let origin = v8::ScriptOrigin::new(
tc_scope,
v8::String::new(tc_scope, "main.js").unwrap().into(),
0, 0, false, 123, None, false, false,
true, // is_module
None,
);
let mut source = v8::script_compiler::Source::new(source_str, Some(&origin));
let module = v8::script_compiler::compile_module(tc_scope, &mut source).unwrap();
// Store main module in cache
let module_hash = module.get_identity_hash();
let global_module = v8::Global::new(tc_scope, module);
let loader = FsModuleLoader::global();
let cwd = std::env::current_dir().unwrap().to_string_lossy().to_string();
let main_path = format!("{}/main.js", cwd);
{
let mut loader_guard = loader.lock().unwrap();
loader_guard.store_module(main_path, global_module, module_hash);
}
// 2. Instantiate (resolve imports)
module.instantiate_module(tc_scope, module_resolver).unwrap();
// 3. Evaluate (run code)
let _result = module.evaluate(tc_scope).unwrap();
"Module executed".to_string()
}
}
Registering the Resolver
Tell V8 to use your resolver with set_host_import_module_dynamically_callback:
#![allow(unused)]
fn main() {
let mut isolate = v8::Isolate::new(params);
isolate.set_host_import_module_dynamically_callback(
import_module_dynamically_callback
);
}
This enables dynamic import() at runtime (beyond static imports).
Key Insights
- V8 provides infrastructure, not implementation - You load and cache modules
- Module identity by hash - Use
get_identity_hash()to track modules - Global cache essential - Avoid loading the same module twice
- Path resolution is tricky - Handle relative paths, missing extensions
- Instantiate before evaluate - V8 must link imports first
This is the same pattern used by Node.js and Deno, though they add features like node_modules resolution, package.json, and HTTP imports.
For the complete implementation, see modules/mod.rs in toyjs.
Wrapper Scripts
Raw native bindings are often low-level. We use JavaScript wrapper scripts to provide a friendly Web API (like fetch, Request, Response).
Polyfilling fetch
Instead of exposing fetch directly from Rust, we can expose a low-level __nativeFetch and wrap it.
globalThis.fetch = function(url, options) {
return new Promise((resolve, reject) => {
__nativeFetch(url, options, (responseMeta) => {
// Create Response object
const response = new Response(null, responseMeta);
resolve(response);
}, (error) => {
reject(new Error(error));
});
});
};
This allows us to implement the standard Promise API in JS, while the heavy lifting happens in Rust.
Data Exchange
Passing data between Rust and V8 requires converting between Rust types and V8 handles.
Primitives
Strings
V8 strings are UTF-16, but rusty_v8 handles UTF-8 conversion.
#![allow(unused)]
fn main() {
// Rust -> JS
let v8_str = v8::String::new(scope, "Hello").unwrap();
// JS -> Rust
let v8_str = args.get(0).to_string(scope).unwrap();
let rust_str = v8_str.to_rust_string_lossy(scope);
}
Numbers
#![allow(unused)]
fn main() {
// Rust -> JS
let v8_num = v8::Number::new(scope, 42.0);
// JS -> Rust
let val = args.get(0).number_value(scope).unwrap();
}
Objects
#![allow(unused)]
fn main() {
// Create object
let obj = v8::Object::new(scope);
// Set property
let key = v8::String::new(scope, "foo").unwrap();
let val = v8::String::new(scope, "bar").unwrap();
obj.set(scope, key.into(), val.into());
}
Typed Arrays (Binary Data)
For high-performance binary data (like request bodies), we use Uint8Array backed by an ArrayBuffer.
Zero-Copy (ish) Transfer
To move data from Rust to JS without full copy logic in JS:
- Create an
ArrayBufferwith aBackingStorefrom a RustVec. - Create a
Uint8Arrayview over it.
#![allow(unused)]
fn main() {
let data: Vec<u8> = vec![1, 2, 3];
let len = data.len();
// Create backing store (takes ownership of Vec)
let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(data);
// Create ArrayBuffer
let buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_store.make_shared());
// Create Uint8Array
let uint8_array = v8::Uint8Array::new(scope, buffer, 0, len).unwrap();
}
Note: new_backing_store_from_vec might involve a copy depending on alignment, but it’s efficient. The Vec memory is now managed by V8 GC.
JSON
For complex structures, serialization via JSON is often easiest, though slower than direct object manipulation.
#![allow(unused)]
fn main() {
// Rust -> JS via JSON
let json = serde_json::to_string(&my_struct).unwrap();
let json_str = v8::String::new(scope, &json).unwrap();
let parsed = v8::json::parse(scope, json_str.into()).unwrap();
}