- Published on
How Deno’s Rust Functions Actually Work — Wrapping V8 via rusty_v8
- Authors

- Name
- Mehdi Akiki
There’s a clean idea at the heart of Deno: JavaScript and Web APIs on top of V8, with Rust doing the heavy lifting. You write JS/TS; the runtime routes privileged work into Rust; rusty_v8 exposes V8’s C++ API to Rust; and a thin macro layer (#[op2]) keeps the cross-language boundary boring and safe.
If you understand three concepts—ops, resources, and V8 handles/scopes—you can read the Deno source and feel at home. This piece walks you through those concepts using real, representative snippets (crypto, console preview, and filesystem abstractions), then ties them back to where they plug into the Deno tree.
0) The mental model (one screen)
JS/TS ──(call)──► opSync/opAsync ──► Rust fn (#[op2])
▲ │
│ serde_v8 marshaling │
└───────────────◄────────┘ return value / Promise resolution
Rust fn may:
• manipulate V8 directly (via rusty_v8 handles)
• do IO/CPU work, create/operate on Resources
• schedule async work on a pool / event loop
V8 isolate & scopes are managed by deno_core; rusty_v8 is the FFI layer.
- Ops: Named, exported Rust functions callable from JS (
op_*). They are declared with#[op2]. Sync ops return a value; async ops return aFutureand map to Promises on the JS side. - Resources: Long-lived, OS-backed objects (files, sockets, subprocess pipes) kept in a
ResourceTableand referenced by numericrids from JS. - V8 handles & scopes: When you want to touch actual V8 objects (not just serialize values), you receive a
v8::HandleScopeand operate with typed handles.
Everything else is glue.
1) The #[op2] macro: the boring bridge you want
Ops are declared in Rust with #[op2] and optionally async. The macro generates a trampoline that:
- Reads V8 arguments and converts them to Rust types (via
serde_v8or special buffer converters). - Calls your Rust function.
- Converts the result/error back into a V8 value (or a rejected Promise for
async).
Example: WebCrypto encrypt
A real Deno-style op:
use deno_core::{op2, JsBuffer, ToJsBuffer};
use deno_core::unsync::spawn_blocking;
#[op2(async)]
#[serde]
pub async fn op_crypto_encrypt(
#[serde] opts: EncryptOptions,
#[buffer] data: JsBuffer,
) -> Result<ToJsBuffer, EncryptError> {
let key = opts.key;
let fun = move || match opts.algorithm {
EncryptAlgorithm::RsaOaep { hash, label } => {
encrypt_rsa_oaep(key, hash, label, &data)
}
EncryptAlgorithm::AesCbc { iv, length } => {
encrypt_aes_cbc(key, length, iv, &data)
}
EncryptAlgorithm::AesGcm {
iv, additional_data, length, tag_length,
} => encrypt_aes_gcm(key, length, tag_length, iv, additional_data, &data),
EncryptAlgorithm::AesCtr { counter, ctr_length, key_length } => {
encrypt_aes_ctr(key, key_length, &counter, ctr_length, &data)
}
};
let buf = spawn_blocking(fun).await.unwrap()?;
Ok(buf.into())
}
Key takeaways:
#[op2(async)]tells Deno this returns aPromisein JS.#[serde]on parameters enables automatic deserialization from JS objects into Rust structs/enums viaserde_v8.#[buffer] data: JsBuffergives you a borrowed view into the JS ArrayBuffer/TypedArray payload (without eager copying). The return typeToJsBuffermoves bytes back to JS efficiently.- The op uses
deno_core::unsync::spawn_blockingto offload CPU-heavy encryption so you don’t block the V8 thread. That’s crucial: a long sync call would freeze JS;spawn_blockingruns it on a worker and resumes the Promise when done.
Why this matters: JS gets a clean await crypto.encrypt(...), while the runtime does permissions checks, argument parsing, and CPU work safely on the Rust side.
Error mapping that feels native
#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum EncryptError {
#[class(inherit)]
#[error(transparent)]
General(#[from] #[inherit] SharedError),
#[class(type)]
#[error("invalid length")]
InvalidLength,
#[class("DOMExceptionOperationError")]
#[error("invalid key or iv")]
InvalidKeyOrIv,
// ...
}
deno_error::JsError + the #[class(...)] annotations tell the bridge how to project Rust errors into domain-specific JS errors (often DOMExceptions with correct names). That’s how you get familiar WebCrypto exceptions instead of “Rust error XYZ”.
The serde angle that saves you code
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EncryptOptions {
key: V8RawKeyData,
#[serde(flatten)]
algorithm: EncryptAlgorithm,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", tag = "algorithm")]
pub enum EncryptAlgorithm {
#[serde(rename = "RSA-OAEP")] RsaOaep { /* … */ },
#[serde(rename = "AES-CBC")] AesCbc { /* … */ },
#[serde(rename = "AES-GCM")] AesGcm { /* … */ },
#[serde(rename = "AES-CTR")] AesCtr { /* … */ },
}
JS can pass a single options object. The tagged enum picks the right variant. You don’t write manual argument decoders; the macro does. In practice, 80% of ops are simple because of this.
2) When you do want to touch V8 directly
Most ops just need “data in/data out”. Sometimes you need the engine—e.g., to inspect objects, construct exotic values, or use embedder APIs.
The console example shows the pattern:
use deno_core::op2;
use deno_core::v8;
deno_core::extension!(
deno_console,
ops = [op_preview_entries],
esm = ["01_console.js"],
);
#[op2]
pub fn op_preview_entries<'s>(
scope: &mut v8::HandleScope<'s>,
object: &v8::Object,
slow_path: bool,
) -> v8::Local<'s, v8::Value> {
let (entries, is_key_value) = object.preview_entries(scope);
match entries {
None => v8::undefined(scope).into(),
Some(entries) => {
if !slow_path {
return entries.into();
}
let ret: [v8::Local<v8::Value>; 2] =
[entries.into(), v8::Boolean::new(scope, is_key_value).into()];
v8::Array::new_with_elements(scope, &ret).into()
}
}
}
Notes:
- You can opt into raw V8 by taking
&mut v8::HandleScope<'_>and typed handles as parameters. The#[op2]trampoline knows how to pass them. - You must return handles created within that scope. Lifetimes (
's) enforce it, which prevents classic embedder mistakes (dangling handles across awaits). - The
deno_core::extension!macro registers your op and wires the companion JS module (01_console.js) that calls it—this is how “console.*” integrates as a first-class Web API.
Use this path when you need fine control inside the isolate. For pure data shuttling, let #[serde] do the heavy lifting.
3) Long-lived things: resources, rids, and OS handles
Deno models OS things (files, sockets, subprocess stdio) as resources. A resource is a Rust type implementing deno_core::Resource, stored in a ResourceTable. JS only sees an integer resource id (rid) and calls ops that operate on it.
Filesystem idioms:
#[derive(Debug, deno_error::JsError)]
pub enum FsError {
#[class(inherit)] Io(std::io::Error),
#[class("Busy")] FileBusy,
// ...
}
pub struct FileResource {
name: String,
file: std::rc::Rc<dyn File>,
}
impl deno_core::Resource for FileResource {
fn name(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.name)
}
fn read(self: std::rc::Rc<Self>, limit: usize)
-> deno_core::AsyncResult<deno_core::BufView> {
Box::pin(async move {
self.file.clone().read(limit).await.map_err(deno_error::JsErrorBox::from_err)
})
}
}
What happens in practice:
- A create op (e.g.,
op_fs_open) does permission checks, opens an OS file, wraps it in aFileResource, inserts it intoOpState.resource_table, and returns anridto JS. - Subsequent operate ops (
op_fs_read,op_fs_write, …) receiverid, fetch theFileResourcefrom the table, and forward to the trait methods. - When JS drops the last reference (or calls
Deno.close(rid)), the resource is removed; RAII cleans up the OS handle.
Permissions integrate naturally here—errors map to user-facing classes via deno_error annotations.
4) Zero-copy where it matters
Deno has evolved its buffer story for performance and safety:
JsBuffer(param) /ToJsBuffer(return): ergonomic wrappers for passing byte slices into/out of V8 with minimal copying.ZeroCopyBuf(older idiom): similar idea—operate on bytes without marshaling through JSON.BufView/BufMutView: used by resource methods for BYOB patterns and to avoid intermediate allocations.
Crypto uses JsBuffer for plaintext in, ToJsBuffer for ciphertext out. FS uses BufView/BufMutView. The goal is the same: don’t move bytes unless you have to.
5) Async without footguns: spawn_blocking and the isolate
Rust async is cooperative. V8 is single-threaded per isolate. The rule of thumb:
- Small, non-blocking ops: just
async fnthat awaits IO; they yield back quickly. - CPU-bound work (crypto, compression, hashing):
spawn_blocking(Deno’s unsync pool) to avoid hogging the isolate thread.
let buf = deno_core::unsync::spawn_blocking(fun).await.unwrap()?;
Don’t hold V8 handles across an await. If you accept a HandleScope, finish V8 work before the first suspension point.
6) Putting ops into the runtime: the extension! macro
Deno organizes subsystems as extensions. Each extension declares:
- A set of ops (Rust functions with
#[op2]). - Optional embedded JS/ESM modules that expose ergonomic APIs.
- Optional state initialization.
deno_core::extension!(
deno_console,
ops = [op_preview_entries],
esm = ["01_console.js"],
);
At runtime creation, Deno collects extensions and passes them to:
use deno_core::{JsRuntime, RuntimeOptions};
let mut rt = JsRuntime::new(RuntimeOptions {
extensions: vec![deno_console::init()],
..Default::default()
});
The loader injects the ESM modules and makes ops available to that JS code. That’s how Web APIs become “built-ins” without shipping a giant monolith.
7) A quick end-to-end trace (WebCrypto)
Path for await crypto.subtle.encrypt(...):
- Userland JS calls the WebCrypto API (shipped as an ESM inside the
cryptoextension). - That ESM validates arguments, then calls
op_crypto_encryptviaopAsync("op_crypto_encrypt", opts, data). - Trampoline converts
optstoEncryptOptions(serde) anddatatoJsBuffer. - Rust op body chooses an algorithm, runs the CPU work on a blocking pool, maps any algorithmic errors to annotated
EncryptError. - Return:
ToJsBufferbecomes a JSUint8Array; the Promise resolves.
Replace “crypto” with “fs read” or “console preview”; only the middle differs.
8) The parts that matter most
- AES/RSA helpers: use typed aliases (
cbc::Encryptor<aes::Aes128>), nonce typenum (U12/U16), CTR families, and tight error mapping. These don’t need V8 at all—ops isolate the engine from your domain logic. - Console preview: engine-facing op with strict handle lifetimes—this is when you go to
rusty_v8. - FS resources: capability objects surfaced as
rids; clean permission and IO error mapping.
9) Where this lives in Deno’s tree (the “map”)
deno_core/crate: V8 isolate management,JsRuntime,extension!,#[op2], buffers, resource table, event loop, inspector,serde_v8.ext/<name>/: one extension per subsystem (ext/crypto/,ext/console/,ext/fs/, …).cli/or runtime crate: assembles extensions, configures permissions, bootstraps isolate, loader, caching, flags.
As a rule: ops live with their extension, and core holds the reusable embedding substrate.
10) Practical guidance for writing your own ops
- Start with ESM glue. Design a minimal, Web-API-shaped surface and call an op by name.
- Use serde-friendly structs/enums. Prefer tagged enums for “algorithm” switches.
- Use
#[buffer]/JsBufferfor bytes. - Return
Result<T, E>whereE: deno_error::JsError. Annotate#[class]for web parity. - Reserve
v8::HandleScopefor cases that need engine access. - Avoid blocking the isolate;
spawn_blockingCPU work. - Don’t cross
awaitwith V8 handles. - Resource-ize OS things.
- Test with a tiny
JsRuntime. Drive it with a short JS snippet.
11) A compact skeleton
Rust (op + extension):
use deno_core::{op2, Extension, JsRuntime, RuntimeOptions, JsBuffer, ToJsBuffer};
#[op2(async)]
pub async fn op_hash_echo(#[buffer] data: JsBuffer) -> Result<ToJsBuffer, MyErr> {
let out = deno_core::unsync::spawn_blocking(move || {
let mut v = data.to_vec();
v.reverse();
Ok::<_, MyErr>(v)
}).await??;
Ok(out.into())
}
pub fn init() -> Extension {
Extension::builder("my_ext")
.ops(vec![op_hash_echo::decl()])
.esm(vec![("ext:my_ext/init.js", include_str!("init.js"))])
.build()
}
JS (glue):
// ext:my_ext/init.js
import { opAsync } from "ext:core/ops";
export async function reverseBytes(u8) {
return new Uint8Array(await opAsync("op_hash_echo", u8));
}
Harness:
let mut rt = JsRuntime::new(RuntimeOptions { extensions: vec![init()], ..Default::default() });
rt.execute_script("<main>", r#"
import { reverseBytes } from "ext:my_ext/init.js";
const result = await reverseBytes(new Uint8Array([1,2,3]));
globalThis.out = Array.from(result);
"#)?;
12) Why this architecture feels good
- Safety by default: isolate threading + lifetimes prevent classic embedder bugs.
- Performance where it counts: zero-copy buffers; explicit blocking pools for CPU work.
- Testability: boot a
JsRuntimein a unit test; exercise everything like a user would. - Web parity: error classes and API shapes match platform expectations.
TL;DR
- Ops are Rust fns with
#[op2]—serialize args/results, sync or async. - Resources wrap OS state and live in a
ResourceTable(rids to JS). rusty_v8gives raw engine access when needed; otherwise letserde_v8move data.- Never block the isolate; use
spawn_blocking. - Ship as an extension with ESM glue; test in a tiny
JsRuntime.
I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.
Got something in mind?
Book a Discovery Call