Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions build/wd_rust_crate.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,11 @@ def wd_rust_crate(
deps = [":" + name],
tags = ["manual", "off-by-default"],
)

if len(test_proc_macro_deps) > 0:
rust_unpretty(
name = name + "_test@expand",
deps = [":" + name + "_test"],
tags = ["manual", "off-by-default"],
testonly = True,
)
8 changes: 6 additions & 2 deletions docs/reference/rust-review-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,12 @@ Rust types exposed to JavaScript via the JSG bindings follow these patterns:
- **`#[jsg_struct]`** is for value types (passed by value across the JS boundary).
- **`#[jsg_oneof]`** is for union/variant types (mapped from JS values by trying each variant).
- **Type mappings**: `jsg::Number` wraps JS numbers (distinct from `f64`). `Vec<u8>` maps to
`Uint8Array`, not a regular JS `Array`. `Option<T>` maps to nullable. `String`/`&str` map to
JS strings.
`Uint8Array`, not a regular JS `Array`. `Option<T>` maps to `T | undefined` (rejects `null`).
`Nullable<T>` maps to `T | null | undefined`. `String`/`&str` map to JS strings.
- **GC tracing**: `Ref<T>`, `Option<Ref<T>>`, and `Nullable<Ref<T>>` fields on `#[jsg_resource]`
structs are automatically traced during GC. `WeakRef<T>` fields are not traced (no-op). Verify
that any resource holding a `Ref<T>` or `Nullable<Ref<T>>` to another resource is properly
traced — missing traces cause use-after-free when the child is collected prematurely.

### Linting & Style

Expand Down
5 changes: 3 additions & 2 deletions src/rust/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

| Crate | Purpose |
| -------------------- | ------------------------------------------------------------------------------------------------------ |
| `jsg/` | Rust JSG bindings: `Lock`, `Ref<T>`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration |
| `jsg/` | Rust JSG bindings: `Lock`, `Rc<T>`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration |
| `jsg-macros/` | Proc macros: `#[jsg_struct]`, `#[jsg_method]`, `#[jsg_resource]`, `#[jsg_oneof]`, `#[jsg_static_constant]` |
| `jsg-test/` | Test harness (`Harness`) for JSG Rust bindings |
| `api/` | Rust-implemented Node.js APIs; registers modules via `register_nodejs_modules()` |
Expand All @@ -25,9 +25,10 @@
- **CXX bridge**: `#[cxx::bridge(namespace = "workerd::rust::<crate>")]` with companion `ffi.c++`/`ffi.h` files
- **Namespace**: always `workerd::rust::*` except `python-parser` → `edgeworker::rust::python_parser`
- **Errors**: `thiserror` for library crates; `jsg::Error` with `ExceptionType` for JSG-facing crates
- **JSG resources**: must include `_state: jsg::ResourceState` field; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self`/`&mut self` become instance methods, methods without a receiver become static methods; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase)
- **JSG resources**: `#[jsg_resource]` on struct + impl block; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self`/`&mut self` become instance methods, methods without a receiver become static methods; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase); resources integrate with GC via the `GarbageCollected` trait (auto-derived for `Rc<T>`, `WeakRc<T>`, `Option<Rc<T>>`, and `Nullable<Rc<T>>` fields)
- **Formatting**: `rustfmt.toml` — `group_imports = "StdExternalCrate"`, `imports_granularity = "Item"` (one `use` per import)
- **Linting**: `just clippy <crate>` — pedantic+nursery; `allow-unwrap-in-tests`
- **Tests**: inline `#[cfg(test)]` modules; JSG tests use `jsg_test::Harness::run_in_context()`
- **FFI pointers**: functions receiving raw pointers must be `unsafe fn` (see `jsg/README.md`)
- **Parameter ordering**: `&Lock` / `&mut Lock` must always be the first parameter in any function that takes a lock (matching the C++ convention where `jsg::Lock&` is always first). This applies to free functions, trait methods, and associated functions (excluding `&self`/`&mut self` receivers which come before `lock`).
- **Feature flags**: `Lock::feature_flags()` returns a capnp `compatibility_flags::Reader` for the current worker. Use `lock.feature_flags().get_node_js_compat()`. Flags are parsed once and stored in the `Realm` at construction; C++ passes canonical capnp bytes to `realm_create()`. Schema: `src/workerd/io/compatibility-date.capnp`, generated Rust bindings: `compatibility_date_capnp` crate.
63 changes: 21 additions & 42 deletions src/rust/api/dns.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use jsg::ResourceState;
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

use jsg_macros::jsg_method;
use jsg_macros::jsg_resource;
use jsg_macros::jsg_struct;
Expand Down Expand Up @@ -114,14 +117,14 @@ pub fn parse_replacement(input: &[&str]) -> jsg::Result<String, DnsParserError>
}

#[jsg_resource]
pub struct DnsUtil {
// TODO(soon): Generated code. Move this to jsg-macros.
#[expect(clippy::pub_underscore_fields)]
pub _state: ResourceState,
}
pub struct DnsUtil;

#[jsg_resource]
impl DnsUtil {
pub fn new() -> jsg::Rc<Self> {
jsg::Rc::new(Self {})
}

/// Parses an unknown RR format returned from Cloudflare DNS.
/// Specification is available at
/// `<https://datatracker.ietf.org/doc/html/rfc3597>`
Expand Down Expand Up @@ -313,9 +316,7 @@ mod tests {

#[test]
fn test_parse_caa_record_issue() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
let record = dns_util
.parse_caa_record("\\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67".to_owned())
.unwrap();
Expand All @@ -327,9 +328,7 @@ mod tests {

#[test]
fn test_parse_caa_record_issuewild() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
let record = dns_util
.parse_caa_record(
"\\# 21 00 09 69 73 73 75 65 77 69 6c 64 6c 65 74 73 65 6e 63 72 79 70 74"
Expand All @@ -344,9 +343,7 @@ mod tests {

#[test]
fn test_parse_caa_record_invalid_field() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
let result = dns_util.parse_caa_record(
"\\# 15 00 05 69 6e 76 61 6c 69 64 70 6b 69 2e 67 6f 6f 67".to_owned(),
);
Expand All @@ -356,9 +353,7 @@ mod tests {

#[test]
fn test_parse_naptr_record() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
let record = dns_util
.parse_naptr_record("\\# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00".to_owned())
.unwrap();
Expand All @@ -378,33 +373,25 @@ mod tests {

#[test]
fn test_parse_caa_record_empty_string() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(dns_util.parse_caa_record(String::new()).is_err());
}

#[test]
fn test_parse_caa_record_single_token() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(dns_util.parse_caa_record("\\#".to_owned()).is_err());
}

#[test]
fn test_parse_caa_record_two_tokens() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(dns_util.parse_caa_record("\\# 15".to_owned()).is_err());
}

#[test]
fn test_parse_caa_record_data_too_short_for_prefix() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
// critical=00, prefix_length=FF (255) but no data follows
assert!(
dns_util
Expand All @@ -415,25 +402,19 @@ mod tests {

#[test]
fn test_parse_naptr_record_empty_string() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(dns_util.parse_naptr_record(String::new()).is_err());
}

#[test]
fn test_parse_naptr_record_single_token() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(dns_util.parse_naptr_record("\\#".to_owned()).is_err());
}

#[test]
fn test_parse_naptr_record_too_few_fields() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
assert!(
dns_util
.parse_naptr_record("\\# 37 15 b3".to_owned())
Expand All @@ -450,9 +431,7 @@ mod tests {

#[test]
fn test_parse_naptr_record_truncated_at_flags() {
let dns_util = DnsUtil {
_state: ResourceState::default(),
};
let dns_util = DnsUtil {};
// Has order+preference+flag_length but no flag data
assert!(
dns_util
Expand Down
33 changes: 13 additions & 20 deletions src/rust/api/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

use std::pin::Pin;

use jsg::ResourceState;
use jsg::ResourceTemplate;
use jsg::ToJS;

use crate::dns::DnsUtil;
use crate::dns::DnsUtilTemplate;

pub mod dns;

Expand All @@ -25,39 +27,30 @@ pub fn register_nodejs_modules(registry: Pin<&mut ffi::ModuleRegistry>) {
jsg::modules::add_builtin(
registry,
"node-internal:dns",
// SAFETY: isolate is a valid pointer provided by V8 during module resolution.
// SAFETY: isolate is valid and locked — called from C++ module registration.
|isolate| unsafe {
let mut lock = jsg::Lock::from_isolate_ptr(isolate);
Comment thread
anonrig marked this conversation as resolved.
let dns_util = jsg::Ref::new(DnsUtil {
_state: ResourceState::default(),
});
let mut dns_util_template = DnsUtilTemplate::new(&mut lock);

jsg::wrap_resource(&mut lock, dns_util, &mut dns_util_template).into_ffi()
let dns_util = DnsUtil::new();
dns_util.to_js(&mut lock).into_ffi()
},
jsg::modules::ModuleType::INTERNAL,
jsg::modules::ModuleType::Internal,
);
}

#[cfg(test)]
mod tests {
use jsg::ResourceTemplate;
use jsg_test::Harness;

use super::*;

#[test]
fn test_wrap_resource_equality() {
let harness = Harness::new();
// SAFETY: Harness guarantees lock and context are valid within run_in_context.
harness.run_in_context(|lock, _ctx| unsafe {
let dns_util = jsg::Ref::new(DnsUtil {
_state: ResourceState::default(),
});
let mut dns_util_template = DnsUtilTemplate::new(lock);
harness.run_in_context(|lock, _ctx| {
let dns_util = DnsUtil::new();

let lhs = jsg::wrap_resource(lock, dns_util.clone(), &mut dns_util_template);
let rhs = jsg::wrap_resource(lock, dns_util, &mut dns_util_template);
let lhs = dns_util.clone().to_js(lock);
let rhs = dns_util.to_js(lock);

assert_eq!(lhs, rhs);
Ok(())
Expand Down
90 changes: 82 additions & 8 deletions src/rust/jsg-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The macro automatically detects whether a method is an instance method or a stat
- **Instance methods** (with `&self`/`&mut self`) are placed on the prototype, called on instances (e.g., `obj.getName()`).
- **Static methods** (without a receiver) are placed on the constructor, called on the class itself (e.g., `MyClass.create()`).

Parameters and return values are handled via the `jsg::Wrappable` trait. Any type implementing `Wrappable` can be used as a parameter or return value:
Parameters and return values are handled via the `jsg::FromJS` and `jsg::ToJS` traits. Any type implementing these traits can be used as a parameter or return value:

- `Option<T>` - accepts `T` or `undefined`, rejects `null`
- `Nullable<T>` - accepts `T`, `null`, or `undefined`
Expand Down Expand Up @@ -66,17 +66,13 @@ impl DnsUtil {

Generates boilerplate for JSG resources. Applied to both struct definitions and impl blocks. Automatically implements `jsg::Type::class_name()` using the struct name, or a custom name if provided via the `name` parameter.

**Important:** Resource structs must include a `_state: jsg::ResourceState` field for internal JSG state management.

```rust
#[jsg_resource]
pub struct DnsUtil {
pub _state: jsg::ResourceState,
}
pub struct DnsUtil {}

#[jsg_resource(name = "CustomUtil")]
pub struct MyUtil {
pub _state: jsg::ResourceState,
pub value: u32,
}

#[jsg_resource]
Expand All @@ -93,7 +89,12 @@ impl DnsUtil {
}
```

On struct definitions, generates `jsg::Type`, wrapper struct, and `ResourceTemplate` implementations. On impl blocks, scans for `#[jsg_method]` and `#[jsg_static_constant]` attributes and generates the `Resource` trait implementation. Methods with a receiver (`&self`/`&mut self`) are registered as instance methods; methods without a receiver are registered as static methods.
On struct definitions, generates:
- `jsg::Type` implementation
- `jsg::GarbageCollected` implementation with automatic field tracing (see below)
- Wrapper struct and `ResourceTemplate` implementations

On impl blocks, scans for `#[jsg_method]` and `#[jsg_static_constant]` attributes and generates the `Resource` trait implementation. Methods with a receiver (`&self`/`&mut self`) are registered as instance methods; methods without a receiver are registered as static methods.

## `#[jsg_static_constant]`

Expand Down Expand Up @@ -124,6 +125,25 @@ impl WebSocket {

Per Web IDL, constants are `{writable: false, enumerable: true, configurable: false}`.

## `#[jsg_constructor]`

Marks a static method as the JavaScript constructor for a `#[jsg_resource]`. When JavaScript calls `new MyClass(args)`, V8 invokes this method, creates a `jsg::Rc<Self>`, and attaches it to the `this` object.

```rust
#[jsg_resource]
impl MyResource {
#[jsg_constructor]
fn constructor(name: String) -> Self {
Self { name }
}
}
// JS: let r = new MyResource("hello");
```

The method must be static (no `self` receiver) and must return `Self`. Only one `#[jsg_constructor]` is allowed per impl block. The first parameter may be `&mut Lock` if the constructor needs isolate access — it is not exposed as a JS argument.

If no `#[jsg_constructor]` is present, `new MyClass()` throws an `Illegal constructor` error.

## `#[jsg_oneof]`

Generates `jsg::Type` and `jsg::FromJS` implementations for union types. Use this to accept parameters that can be one of several JavaScript types.
Expand Down Expand Up @@ -152,3 +172,57 @@ impl MyResource {
```

The macro generates type-checking code that matches JavaScript values to enum variants without coercion. If no variant matches, a `TypeError` is thrown listing all expected types.

### Garbage Collection

Resources are automatically integrated with V8's garbage collector through the C++ `Wrappable` base class. The macro generates a `GarbageCollected` implementation that traces fields based on their type:

| Field type | Behaviour |
|---|---|
| `jsg::Rc<T>` | Strong GC edge — keeps target alive |
| `jsg::Weak<T>` | Not traced — does not keep target alive |
| `jsg::v8::Global<T>` | Dual strong/traced — enables back-reference cycle collection |
| Anything else | Not traced — plain data, ignored by tracer |

`Option<F>` and `jsg::Nullable<F>` wrappers are supported for all traced field types and are traced only when `Some`. Any traced field type may also be wrapped in `Cell<F>` (or `std::cell::Cell<F>`) for interior mutability — required when the field is set after construction, since `GarbageCollected::trace` receives `&self`.

#### `jsg::v8::Global<T>` and cycle collection

`jsg::v8::Global<T>` fields use the same strong↔traced dual-mode as C++ `jsg::V8Ref<T>`. While the parent resource has strong Rust `Rc` refs the handle stays strong; once all `Rc`s are dropped, `visit_global` downgrades the handle to a `v8::TracedReference` that cppgc can follow — allowing back-reference cycles (e.g. a resource holding a callback that captures its own JS wrapper) to be collected by the next full GC.

```rust
use std::cell::Cell;

#[jsg_resource]
pub struct MyResource {
// Strong GC edge — keeps child alive
child: jsg::Rc<ChildResource>,

// Conditionally traced
maybe_child: Option<jsg::Rc<ChildResource>>,

// Weak — does not keep target alive
observer: jsg::Weak<ChildResource>,

// JS value — traced with dual-mode switching; Cell needed because
// the callback is set after construction (trace takes &self)
callback: Cell<Option<jsg::v8::Global<jsg::v8::Value>>>,

// Plain data — not traced
name: String,
}
```

For complex cases or custom tracing logic, you can manually implement `GarbageCollected` without using the `jsg_resource` macro:

```rust
pub struct CustomResource {
data: String,
}

impl jsg::GarbageCollected for CustomResource {
fn trace(&self, visitor: &mut jsg::GcVisitor) {
// Custom tracing logic
}
}
```
Loading
Loading