A dozen or so Rust crates — mostly libraries, plus the gen-compile-cache binary — linked into workerd via CXX FFI. No Cargo workspace — entirely Bazel-driven (wd_rust_crate.bzl / wd_rust_binary.bzl). Clippy pedantic+nursery enabled; allow-unwrap-in-tests; clippy.toml and rustfmt.toml live at the repository root and apply to every crate.
Rust does not have to live here. A crate that belongs to a component lives beside that component's C++, in a subdirectory named for the crate: e.g. workerd's entry point (workerd-cli, src/workerd/server/cli/, declared in src/workerd/server/BUILD.bazel with its srcs globbed from that subdirectory and its cxx_bridge_hdrs listed explicitly) and its server (workerd-server, src/workerd/server/server/, a package of its own over the C++ worker factory in src/workerd/server/factory/; see src/workerd/server/AGENTS.md). Such crates use the component's C++ namespace for their bridge (workerd::server, workerd::server::cli), and their generated bridge header sits at the crate's C++ include path (workerd/server/cli/bridge.rs.h, workerd/server/server/bridge.rs.h). This directory holds the crates that have no C++ home of their own, and the in-tree cxx fork.
The "CXX FFI" above is the in-tree
src/rust/cxxfork of cxx-rs — not stock cxx-rs. It adds deep KJ interoperability upstream lacks:asyncfns becomekj::Promise<T>, you can return/holdkj::Own<T>,Result<T>throwskj::Exception, and other KJ types cross the boundary (see CXX BRIDGE below). cxx-rs is well represented in LLM training data, so it is easy to "recall" an API that is wrong here — prefer the prior art in these crates and the in-tree CXX sources (especially itskj-rscrate) over upstream cxx-rs docs or memory.
Snapshot — the set drifts as crates come and go; bazel query //src/rust/... is authoritative.
| Crate | Purpose |
|---|---|
jsg/ |
Rust JSG bindings: Lock, Rc<T>, Resource, Struct, Type, Realm, FeatureFlags, module registration; V8 handle types in jsg::v8 (Local, Global, TracedGlobal over marker types that mirror V8's classes, from Value and Number to the typed arrays, and BackingStore); JS functions from Rust closures (v8::Function::new); structured serialization in jsg::Serializer's format (ser) |
jsg-macros/ |
Proc macros: #[jsg_struct], #[jsg_method], #[jsg_resource], #[jsg_oneof], #[jsg_static_constant], #[jsg_constructor], #[jsg_property], #[jsg_inspect_property], #[jsg_wildcard_property] |
jsg-test/ |
Test harness (Harness) for JSG Rust bindings |
api/ |
Rust-implemented Node.js APIs; registers modules via register_nodejs_modules() |
encoding/ |
WHATWG legacy text decoders via encoding_rs; an opaque streaming Decoder emits UTF-16 over the CXX bridge |
kj/ |
Rust bindings for KJ library (http, io, own submodules); Result<T> = Result<T, cxx::KjError> |
cxx/kj-hyper/ |
HTTP/1.1, WebSocket and TLS for the Rust server (hyper, rustls; WebSockets are kj's over the upgraded transport) with kj-typed seams: server::serve_connection dispatches to a Handler with kj arguments, client::Client implements kj::http::Service; see src/rust/cxx/AGENTS.md |
worker/ |
Rust counterpart of workerd::WorkerInterface: the worker::Interface trait (into_kj hands an implementation to C++ as KjOwn<WorkerInterface>; not_supported answers a CustomEvent as C++'s event->notSupported() does), PromisedInterface (an Interface whose target is still starting, as C++'s PromisedWorkerInterface) plus FFI bindings; multi-bridge crate |
async-trace/ |
Async activity tracking core (async_hooks-style): per-isolate IsolateState, per-IoContext Tracker (turns, scopes, binding adoption, stats), Sink trait, NDJSON and recording sinks; opaque Isolate/Tracker/Writer over CXX |
cxx-integration/ |
One bridge function, trigger_panic: the hook C++ tests use to exercise the panic-to-kj::Exception conversion at the bridge |
cxx-integration-test/ |
Non-production crate exercising Rust/C++ integration: callbacks, shared structs, Result error mapping |
transpiler/ |
TS type stripping via SWC (ts_strip(), StripOnly mode) |
python-parser/ |
Python import extraction via ruff_python_parser; namespace: edgeworker::rust:: |
gen-compile-cache/ |
Binary crate — V8 bytecode cache generator; calls C++ compile() via CXX |
- CXX bridge:
#[cxx::bridge(namespace = "workerd::rust::<crate>")]with companionffi.c++/ffi.hfiles - Namespace:
workerd::rust::*for crates in this directory, exceptpython-parser→edgeworker::rust::python_parser; a crate living beside C++ uses that C++'s namespace (e.g.workerd::server::cli) - Errors:
thiserrorfor library crates;jsg::ErrorwithExceptionTypefor JSG-facing crates - JSG resources:
#[jsg_resource]on struct + impl block;#[jsg_method]auto-convertssnake_case→camelCase; methods with&selfbecome instance methods, methods without a receiver become static methods, and any other receiver is a compile error (JavaScript can re-enter a resource, so state that methods change goes inCell/RefCellfields; never hold aRefCellborrow across a call that can run JavaScript, nor aborrow_mut()of one holding GC references across a call that can allocate on the V8 heap, wrap the resource or create or drop ajsg::Rcto it, since tracing skips a mutably borrowedRefCell); a method or accessor can takethis: jsg::Rc<Self>as its first parameter instead of&selfto get ajsg::Rcof its resource (likeJSG_THIS) to hand to a callback; don't share the resource's state with a callback through anstd::rc::Rc(seejsg/README.md§Reaching the resource from a callback); an eligible#[jsg_method]also gets a V8 Fast API callback (types injsg/fast_api.rs) and must not call into V8; a#[jsg_constructor]returnsSelforResult<Self, E>(anErris thrown) and throwsTypeErrorwhen called withoutnew;#[jsg_static_constant]onconstitems exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase);#[jsg_resource(nested_types(Foo, Name = Bar))]on the impl block puts other resources' constructors on the prototype (JSG_NESTED_TYPE); resources integrate with GC viaTraced+GarbageCollected: every named field is traced viaTraced::trace(&self.field, visitor)and all non-traceable types use no-opTracedimpls - Resources created by C++: C++ creates a Rust resource through an
extern "Rust"function of the crate that defines it, which returns the wrapper throughjsg::v8::run_ffi_callback; C++ holds the wrapper (ajsg::JsRef), since it has no typed reference to a Rust resource, and checks a value's type through one that returnsjsg::resource::ffi_is::<R>, akj::Maybe<bool>that is none when an exception is pending. Seejsg/README.md§Creating resources from C++. - Custom JSG registration:
#[jsg_resource(custom_members)]on an impl generates member factories but leavesimpl Resourceto the author.Resource::members(flags)uses ordinary Rust to select members, order, and property placement. Factories are<rust_name>_member()(constant names lowercased); ordinary property factories are<getter_name>_member(PropertyPlacement)(Prototype,InstanceorLazyInstance) and include the paired setter. Seejsg-macros/README.md§Custom member registration. - JSG properties: three property macros on
#[jsg_resource]impl blocks —#[jsg_property(prototype|instance|lazy_instance [, name = "..."] [, readonly])](prototyperegisters an accessor on the prototype, equivalent toJSG_PROTOTYPE_PROPERTY;instanceregisters an own data property via V8SetNativeDataProperty, equivalent toJSG_INSTANCE_PROPERTY;lazy_instanceregisters an own data property via V8SetLazyDataPropertywhose getter runs once, on first access, equivalent toJSG_LAZY_INSTANCE_PROPERTY: it has no setter, is writable unlessreadonly(JSG_LAZY_READONLY_INSTANCE_PROPERTY); declaration order setsObject.keys()order for automatic registration;readonlyis a compile-time check preventing a paired setter;name = "..."overrides the JS name; preferprototypein almost all cases),#[jsg_inspect_property](registered under a unique symbol, invisible to normal enumeration and string-key lookup, surfaced bynode:utilinspect(), equivalent toJSG_INSPECT_PROPERTY), and#[jsg_wildcard_property](answers string-named properties the object and its prototypes lack, at most one per type, equivalent toJSG_WILDCARD_PROPERTY; seejsg/README.md); setter auto-detected fromset_prefix; read-only when no setter present; getter/setter.lengthand.nameare set correctly whenspec_compliant_property_attributescompat flag is enabled jsg::v8::Global/TracedGlobal: persistent handles. AGlobalis for handles Rust owns outside resources: GC never visits it, so it is always strong, holds its handle inline (no box), has noTracedimpl (it cannot be a resource field), and isSendandSync. ATracedGlobalis for resource fields, which GC traces to collect cycles; it isSend(notSync). Either may be dropped on any thread. Under the isolate lock its strong handle is reset at once; without the lock it is queued and reset at the nextIsolateBase::Lock, throughIsolateBase::applyDeferredActions(); once theRealmis dropped (under the lock, beforeDispose) the handles are left toDispose. Seejsg/isolate_state.rs. Hand-written FFI that resets a V8 handle directly must hold the lock. Rust never resets aTracedGlobal's traced reference: every GC visit lets go of it and makes the strong handle strong again (ClearWeak) before C++ installs a new one if needed, and a drop just lets go of it; V8 frees untraced nodes at the next full GC. SeeGcVisitor::visit_traced_global. ATracedGlobalkeeps its handles in aBox<GlobalSlot>, since V8 holds the address of a weak handle's slot; the slot of aTracedGlobaldropped after theRealmis kept until the isolate is disposedTraced: core tracing trait injsg::wrappable; built-ins include no-op impls for primitives/value types and delegating impls for wrappers/collections (Option,Nullable,Vec, maps/sets,Cell,jsg::Rc,jsg::Weak,jsg::v8::TracedGlobal);jsg::v8::Globaldeliberately has none- Sequences to JavaScript:
Vec<T>converts to anArray, numbers included (vec![1u8, 2]makes[1, 2]). A typed array or anArrayBufferis an explicit conversion of a slice:to_js_typed_array(lock)makes the typed array of the element type, andto_js_array_buffer(lock)makes anArrayBufferof bytes ([u8]only), as C++ JSG returns for akj::Array<kj::byte>. Seejsg/README.md§Arrays, typed arrays andArrayBuffers #[jsg_resource(custom_trace)]: suppresses the auto-generatedTracedimpl so the user can write their own;GarbageCollected(memory_name),jsg::Type,jsg::ToJS, andjsg::FromJSare still generated- Rust JSG safety rules (full text and examples in
src/rust/jsg/README.md§Safety rules):- Never throw while execution is terminating, and never discard a
jsg::Errorfrom a call that ran JavaScript or fromToJS::to_js, which can fail; pass it on with?. - Never put traced state (
TracedGlobal,jsg::Rc, or a type holding them) behindstd::rc::Rc. To give a callback or another party access, give it ajsg::Rcorjsg::Weakto the resource that owns the state (a method gets one asthis: jsg::Rc<Self>). Clone ajsg::Rcright after moving it out of a traced field (take,replace,pop), since the moved one is neither strong nor traced (#7976). Don't put aGlobalin anything GC traces. - The C++ shims call V8 directly, not C++ JSG (
JSG_TRY,jsg::check,jsg::Lock::from,createTunneledException,makeInternalError,jsg::Data, ...).//src/rust/jsg:cpp-jsg-allowlist-testenforces this for the files inCPP_SHIMSinjsg/BUILD.bazel; add shim files outsidesrc/rust/jsgthere. Local<'s>andLock<'s>stay in their scope, and futures passed to async I/O are'static.
- Never throw while execution is terminating, and never discard a
- Formatting:
rustfmt.toml—group_imports = "StdExternalCrate",imports_granularity = "Item"(oneuseper import) - Linting:
just clippy <crate>for a crate here,just clippy <label>for one elsewhere — pedantic+nursery;allow-unwrap-in-tests - Lint defaults: the
wd_rust_*macros give every crate the lint levels in//build/rust:lints:unsafe_code,unsafe_op_in_unsafe_fn,clippy::undocumented_unsafe_blocksandclippy::{unwrap_used, expect_used, panic, todo, unimplemented}are denied. Do not repeat them as crate-root attributes, and do not relax them for a whole crate. The file or item that needs an exception says so, and says why:clippy::allow_attributes_without_reasonis denied too, so everyallowandexpect, of any lint, carries areason.- A module with
unsafein it, which includes every#[cxx::bridge](the macro expands tounsafe), puts#![allow(unsafe_code, reason = "...")]at the top of its file. The layout to aim for is one such module per crate,ffi.rs, holding the bridge and every hand-writtenunsafe(kj-rs-io,kj-rs-tokio); put a new crate's bridge there. - In a crate root that attribute would cover the whole crate, so there the item takes
#[expect(unsafe_code, reason = "...")]instead: the bridge module (after its#[cxx::bridge]line), or the function orimpl. - What can fail returns a
Result; a panic asserts what cannot happen.unreachable!says exactly that and is allowed everywhere. Any other deliberate panic takes#[expect(clippy::<lint>, reason = "...")]on its function. - Test code is not held to any of this. A module's
<module>-test.rsis compiled only into the crate's<name>_testtarget, and test targets get no lint levels from the macros; neither dotestonlycrates (wd_rust_crate(testonly = True): test harnesses, the Rust half of a C++ test). So a test file needs no opt-ins, and the production files are still checked where the library is built. #![forbid(unsafe_code)]in a crate root (memory-cache,jsg-macros) is stronger than the default and stays.- The crates derived from upstream cxx (
src/rust/cxx/{src,syntax,gen,macro,tests}) are outside these defaults; thekj-rs*crates beside them are inside.
- A module with
- Tests: a module's unit tests live beside it in
<module>-test.rs(dns.rs→dns-test.rs,lib.rs→lib-test.rs), never in an inlinemod tests { ... }, so a diff shows production changes and test changes as separate files. See UNIT TEST FILES below. JSG tests usejsg_test::Harness::run_in_context();Harness::with_flags(|mut f| f.set_<flag>(true))sets raw compat flags and uses workers' sharedconfigureJsgIsolate()helper for isolate-level JSG settings. It starts with all flags false and applies no compatibility date or flag implications; seejsg/README.md§Testing with flags. Always run the fullsrc/rust/...test suite (bazel test //src/rust/...) rather than targeting a single crate — changes in shared crates likejsgorjsg-macroscan break downstream consumers - FFI pointers: functions receiving raw pointers must be
unsafe fn(seejsg/README.md) KjOwn<T>: dropping one runskj::Own<T>::~Own()through a C++ function generated per type, soT: kj_rs::OwnTargetis implemented by the bridge that declaresT(type T;) and holds it in aKjOwn. A bridge that only aliasesTgets none; addimpl KjOwn<T> {}to the declaring bridge. Details incxx/kj-rs/README.mdandcxx/AGENTS.md- Scopes and handles: a
Lock<'s>stands for the innermost openHandleScope. It is only handed out as&'s mut Lock<'s>, and every handle is aLocal<'s, T>that cannot outlive it. ALocalis a non-null pointer; a handle that may be empty is anOption<Local>. Seejsg/README.md.- Generated callbacks get their lock from
jsg::function_callback/getter_callback/setter_callback, which take a closure that captures nothing and need nounsafe. Other Rust code that C++ calls with a scope open uses theunsafejsg::callback_scope!. A panic that unwinds into C++ aborts, so such an entry point pins ajsg::CallbackScopeand opens it withjsg::catch_panic, or, when it returns a handle to C++, goes throughjsg::v8::run_ffi_callback. - Code that only needs the isolate locked, with no context entered or scope open, takes
&mut jsg::Isolate.Lockdereferences to it, and C++ hands one over through theunsafejsg::Isolate::from_raw. src/rust/jsg-compile-testsholds snippets that must not compile (or, prefixedok-, must). Add one for every new lifetime orSend/Syncrule; the//~ ERRORannotations must not depend on how many errors rustc reports.
- Generated callbacks get their lock from
- Parameter ordering:
&Lock/&mut Lockmust always be the first parameter in any function that takes a lock (matching the C++ convention wherejsg::Lock&is always first). This applies to free functions, trait methods, and associated functions (excluding&self/&mut selfreceivers and a JSG method'sthis: jsg::Rc<Self>, which come beforelock). - Method naming: do not use
get_prefixes on methods — e.g.buf.backing_store()notbuf.get_backing_store(). Static constructors belong on the marker struct (impl ArrayBuffer { fn new(...) }) not onimpl Local<'_, ArrayBuffer>. - FFI naming: instance methods on an existing handle use a
local_<type>_<method>prefix (e.g.local_array_buffer_byte_length). Static constructors that create a new value do not use thelocal_prefix — name them<type>_<method>(e.g.array_buffer_new_with_mode,array_buffer_maybe_new,backing_store_new_resizable). - FFI groups:
v8.rsmod ffi,ffi.h, andffi.c++all use matching comment groups (e.g.// Local<T>,// Local<Array>,// Local<TypedArray>,// Local<ArrayBuffer>,// Local<ArrayBufferView>,// Local<SharedArrayBuffer>,// BackingStore,// Unwrappers,// TracedGlobal<T>,// FunctionCallbackInfo). When adding new FFI functions, place them in the correct group in all three files. Do not scatter related functions across groups. - Feature flags:
Lock::feature_flags()returns a capnpcompatibility_flags::Readerfor the current worker. Uselock.feature_flags().get_node_js_compat(). Flags are parsed once and stored in theRealmat construction; C++ passes canonical capnp bytes torealm_create(). Schema:src/workerd/io/compatibility-date.capnp, generated Rust bindings:compatibility_date_capnpcrate.
This applies to every Rust crate in the repository, wherever it lives. The module under test declares its tests as the last item in the file:
#[cfg(test)]
#[path = "dns-test.rs"]
mod tests;dns-test.rs sits in the same directory and holds what would otherwise be the body of mod tests { ... }. It is an ordinary child module: use super::*; reaches the private items of the module under test, and a module-wide lint attribute is an inner attribute (#![expect(...)]) at the top of the file. The #[path] is required because a hyphen cannot appear in a module name.
- One test file per source file, and the module is always named
tests. - Production files contain no
#[test]functions and no inline#[cfg(test)]modules. A helper that only tests use belongs in the test file. wd_rust_crate,wd_rust_binaryandwd_rust_proc_macrotake the*-test.rsfiles out of the crate'ssrcsand compile them only into its<name>_testtarget (split_rust_test_srcsinbuild/wd_rust_test.bzl). A crate defined withrust_librarydirectly does the same by hand:exclude = ["*-test.rs"]in the library'ssrcsglob, andcompile_data = glob(["*-test.rs"])on itswd_rust_test(arust_testbuilt from acraterejectssrcs). An unconditionalmoddeclaration of a test file therefore fails the production build.- Crates that are test code throughout are exempt: a test-support crate in a
tests/directory (e.g.cxx/kj-rs/tests/), and awd_rust_testbuilt from its ownsrcs(e.g.tsan/).
Mark an extern "Rust" function async to generate a C++ function returning kj::Promise<T>. If the function borrows any references (including &self), it needs an explicit lifetime annotation, which in turn requires unsafe:
extern "Rust" {
// Borrows &self — needs explicit lifetime + unsafe.
async unsafe fn do_work<'a>(self: &'a MyType, arg: i32) -> Result<u64>;
// Only owned parameters — no lifetime or unsafe needed.
async fn do_work_owned(arg: i32) -> Result<u64>;
}Without the explicit lifetime, the CXX macro requires the future to be 'static, which fails if the async body references borrowed parameters.
Mark an extern "C++" function async to wrap a C++ function returning kj::Promise<T> as a Rust Future. The Rust caller can .await it:
unsafe extern "C++" {
async fn request(
this_: Pin<&mut HttpService>,
method: HttpMethod,
url: &[u8],
) -> Result<()>;
}Note: this_ is used instead of self because CXX's self receiver syntax doesn't support Pin<&mut T>. This is needed for C++ types with virtual methods where the Rust side calls into a pinned C++ object.
Functions returning Result<T> in extern "Rust" blocks translate to C++ functions that throw kj::Exception on error. The error type must implement std::error::Error (which provides IntoKjException). Recommended patterns:
thiserrorenums: Define a crate-levelErrorenum for structured errors. This is the preferred pattern for crates with multiple error cases.std::io::Error: Acceptable for purely I/O-related errors.cxx::KjError: Use thekjcrate's error macros (kj::failed!,kj::overloaded!,kj::disconnected!,kj::not_implemented!to construct a value;kj::fail_require!,kj::overloaded_require!,kj::disconnected_require!,kj::not_implemented_require!to early-returnErr(...)) when you need direct control over the KJ exception type. These mirror C++'sKJ_EXCEPTION/KJ_FAIL_REQUIRE/KJ_UNIMPLEMENTEDmacros fromkj/debug.h; seesrc/rust/kj/macros.rs.
The reverse direction has a stricter, non-optional rule: any extern "C++" shim that can throw (jsg::check(), KJ_REQUIRE/KJ_ASSERT, constructing a rust::String/rust::Vec that can panic, etc.) MUST return Result<T>. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a Result<T> signature turns them into an Err instead (//src/rust/cxx/gen/src/write.rs, write_cxx_function_shim). kj::CanceledException is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an Err (//src/rust/cxx/src/result.rs, into_result). See //src/rust/jsg/v8.rs "Unwrappers" section for worked examples: unwrap_string is fallible (it constructs a rust::String), while unwrap_boolean and unwrap_number are the deliberate infallible counterexamples (no jsg::check(), no fallible construction). Fallible shims typically funnel their C++-side TryCatch/KJ_REQUIRE handling into a cxx::KjException, which impl From<cxx::KjException> for jsg::Error (//src/rust/jsg/lib.rs) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that can run JavaScript (a call, a coercion, a property read, array iteration), whose exception must reach JavaScript unchanged as in C++ JSG, instead leaves the exception pending and returns an empty result or false (local_function_call, unwrap_string, local_object_get_property), which Rust turns into an Error for which is_pending() is true and Lock::throw_exception throws nothing (all through call_into_js() in jsg/v8.rs, which also refuses to call while execution is terminating); Lock::try_catch catches such exceptions explicitly, and a shim that catches one in C++ decides with catch_exception() (ffi.h), which applies jsg::Lock::tryCatch()'s termination rule.
wd_rust_crate (and wd_rust_binary) generate, for each cxx_bridge_src / cxx_bridge_srcs entry, a companion :<bridge>@cxx cc_library — the C++ side of the bridge: the cxx-generated <bridge>.rs.{h,cc} plus the headers the bridge include!()s (cxx_bridge_hdrs, by default every **/*.h in the package). C++ consumers #include the generated header at the package's C++ include path — <workerd/rust/<pkg>/<bridge>.rs.h> for src/rust/<pkg>, <workerd/server/<bridge>.rs.h> for src/workerd/server — and depend on the crate; depend on :<bridge>@cxx alone if you only need the header. Any crate with a bridge auto-gets //src/rust/cxx:cxx and //src/rust/cxx/kj-rs.
Template: gen-compile-cache, whose main.rs bridge calls C++ compile(). The shim header is conventionally named cxx-bridge.h (or bridge.h):
unsafe extern "C++" {
include!("workerd/rust/gen-compile-cache/cxx-bridge.h");
fn compile(path: &str, source_code: &str) -> Vec<u8>;
}- Put the shim header in the package — the macro globs
**/*.hinto the generated@cxxlibrary'shdrs, so theinclude!resolves. Keep it light (ideally just#include <rust/cxx.h>forrust::Str/rust::Vec). - Implement the function in a
.c++exposed as a separatewd_cc_library(e.g.gen-compile-cache's:cxx-bridge); the heavy C++ deps (JSG, etc.) live there, not in the shim header. - Add that
wd_cc_libraryto the crate'slink_depsso the symbol resolves at the final link.
If a C++ library already depends on your crate (C++ → Rust) and you also add a Rust → C++ shim, the shim must be a separate cc_library from the one that depends on the crate, or Bazel reports a cycle. To pass a cxx shared struct to the shim without a cycle (see the worker crate's :bridge):
- The shim's
.c++includes<workerd/rust/<pkg>/<bridge>.rs.h>and the shimcc_librarydepends on the generated:<bridge>@cxxfor that header. This is acyclic::<bridge>@cxxdepends only on cxx/kj-rs, never on the shim, and the shim's symbol is resolved at the final link. - Forward-declare the struct in the shim header — do not
#includethe generated<bridge>.rs.hthere, because that generated header#includes your shim header, so including it back is circular. A forward declaration is enough for aconst T¶meter; the full definition is only needed in the.c++.
cxx also supports reusing a binding type across bridges (docs): the worker crate's error.rs / ok.rs / kill_switch.rs bridges reuse ffi.rs's types by depending on :ffi.rs@cxx. Still, keep a struct that only crosses FFI within one crate in that crate's bridge.
V8 handles must always cross the FFI as the shared jsg::v8::ffi types, never as a bare usize. When another crate's bridge passes a V8 Local/Global, reuse the jsg shared struct via a type alias (type Local = jsg::v8::ffi::Local;) plus include!("workerd/rust/jsg/v8.rs.h"), and depend on //src/rust/jsg (which supplies the generated header transitively). Do not smuggle the handle word through a usize — the shared type keeps both sides in one canonical, cxx-verified definition. See node-exceptions/lib.rs. A handle that may be empty crosses as ffi::MaybeLocal, because cxx cannot name Option<Local>; convert it with Local::from_ffi_maybe / Local::into_ffi_maybe at the boundary and use Option<Local> everywhere else. The one exception is the extern "C" instance-property callbacks that jsg hands V8 as AccessorNameGetterCallback/AccessorNameSetterCallbackV2: V8 calls them directly with v8::Local arguments, so they take each handle as a usize, and ffi.c++ static-asserts that v8::Local is one trivially copyable pointer. Keep that confined to jsg.
A rust_test exercising code that calls into heavy C++ (V8, etc.) must link those impl symbols itself via test_deps — the production binary already links them, so only the standalone test binary needs them. Symptom if missing: undefined C++ symbols at test link.