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.