Skip to content
Open
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
62 changes: 57 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,18 +347,70 @@ binding = "my_queue"
## RPC Support

`workers-rs` has experimental support for [Workers RPC](https://developers.cloudflare.com/workers/runtime-apis/rpc/).
For now, this relies on JavaScript bindings and may require some manual usage of `wasm-bindgen`.

Not all features of RPC are supported yet (or have not been tested), including:
- Function arguments and return values
- Class instances
- Stub forwarding

### RPC Server
### RPC Server

RPC methods can be exported using a custom `#[rpc]` attribute macro.
RPC methods must be defined inside an `impl` block annotated with `#[rpc]`, and individual methods must also be marked with `#[rpc]`.

When the macro is expanded, it generates:
- A `#[wasm_bindgen]`-annotated struct with `env: worker::Env`
- A constructor function: `#[wasm_bindgen(constructor)] pub fn new(env: Env)`
- A method `#[wasm_bindgen(js_name = "__is_rpc__")] fn is_rpc(&self) -> bool` for RPC auto-discovery
- All methods marked with `#[rpc]` converted into `#[wasm_bindgen]`-annotated exports


**RPC method names must be unique across all types.**

Due to how the JavaScript shim dynamically attaches RPC methods to the `Entrypoint` prototype, each `#[rpc]` method must have a unique name,
even if it is defined on a different struct. If two methods share the same name, only one will be registered and the others will be silently skipped or overwritten.


### Example

```rust
#[rpc]
impl Rpc {
#[rpc]
pub async fn add(&self, a: u32, b: u32) -> u32 {
a + b
}
}
```

Expands to:

```rust
#[wasm_bindgen]
pub struct Rpc {
env: worker::Env,
}

#[wasm_bindgen]
impl Rpc {
#[wasm_bindgen(js_name = "__is_rpc__")]
pub fn is_rpc(&self) -> bool {
true
}
#[wasm_bindgen(constructor)]
pub fn new(env: worker::Env) -> Self {
Self { env }
}

#[wasm_bindgen]
pub async fn add(&self, a: u32, b: u32) -> u32 {
a + b
}
}
```

See [example](./examples/rpc-server).

Writing an RPC server with `workers-rs` is relatively simple. Simply export methods using `wasm-bindgen`. These
will be automatically detected by `worker-build` and made available to other Workers. See
[example](./examples/rpc-server).

### RPC Client

Expand Down
15 changes: 10 additions & 5 deletions examples/rpc-server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
use wasm_bindgen::prelude::wasm_bindgen;
use worker::*;
use wasm_bindgen::prelude::wasm_bindgen;


#[event(fetch)]
async fn main(_req: Request, _env: Env, _ctx: Context) -> Result<Response> {
Response::ok("Hello World")
}

#[wasm_bindgen]
pub async fn add(a: u32, b: u32) -> u32 {
console_error_panic_hook::set_once();
a + b
#[rpc]
impl Rpc {

#[rpc]
pub async fn add(&self, a: u32, b: u32) -> u32 {
console_error_panic_hook::set_once();
a + b
}
}
41 changes: 38 additions & 3 deletions worker-build/src/js/shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,47 @@ const EXCLUDE_EXPORT = [
"queue",
"scheduled",
"getMemory",
"Rpc"
];

Object.keys(imports).map((k) => {
if (!(EXCLUDE_EXPORT.includes(k) | k.startsWith("__"))) {
Entrypoint.prototype[k] = imports[k];
Object.keys(imports).forEach((key) => {
const fn = imports[key];
if (typeof fn === "function" && !EXCLUDE_EXPORT.includes(key) && !key.startsWith("__")) {
// Otherwise, assign the function as-is.
Entrypoint.prototype[key] = fn;
}
});


// Helper to lazily create the RPC instance
Entrypoint.prototype._getRpc = function (Ctor) {
if (!this._rpcInstanceMap) this._rpcInstanceMap = new Map();
if (!this._rpcInstanceMap.has(Ctor)) {
this._rpcInstanceMap.set(Ctor, new Ctor(this.env));
}
return this._rpcInstanceMap.get(Ctor);
};

const EXCLUDE_RPC_EXPORT = ["constructor", "new", "free"];

//Register RPC entrypoint methods into Endpoint
Object.entries(imports).forEach(([exportName, exportValue]) => {
if (typeof exportValue === "function" && exportValue.prototype?.__is_rpc__) {
const Ctor = exportValue;

const methodNames = Object.getOwnPropertyNames(Ctor.prototype)
.filter(name => !EXCLUDE_RPC_EXPORT.includes(name) && typeof exportValue.prototype[name] === "function");

for (const methodName of methodNames) {
if (!Entrypoint.prototype.hasOwnProperty(methodName)) {
Entrypoint.prototype[methodName] = function (...args) {
const rpc = this._getRpc(Ctor);
return rpc[methodName](...args);
};
}
}
}
});


export default Entrypoint;
43 changes: 43 additions & 0 deletions worker-macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod durable_object;
mod event;
mod send;
mod rpc;

use proc_macro::TokenStream;

Expand Down Expand Up @@ -86,3 +87,45 @@ pub fn event(attr: TokenStream, item: TokenStream) -> TokenStream {
pub fn send(attr: TokenStream, stream: TokenStream) -> TokenStream {
send::expand_macro(attr, stream)
}




#[proc_macro_attribute]
/// Marks an `impl` block and its methods for RPC export to JavaScript (Workers RPC).
///
/// This macro generates a `#[wasm_bindgen]`-annotated struct with a constructor that stores the `Env`,
/// and creates JavaScript-accessible exports for all methods marked with `#[rpc]` inside the block.
///
/// The following are added automatically:
/// - `#[wasm_bindgen] pub struct Rpc { env: worker::Env }`
/// - `#[wasm_bindgen(constructor)] pub fn new(env: Env) -> Self`
/// - `#[wasm_bindgen(js_name = "__is_rpc__")] pub fn is_rpc(&self) -> bool`
///
/// ## Usage
///
/// Apply `#[rpc]` to an `impl` block, and to any individual methods you want exported.
///
/// ```rust
/// #[worker::rpc]
/// impl Rpc {
/// #[worker::rpc]
/// pub async fn add(&self, a: u32, b: u32) -> u32 {
/// a + b
/// }
/// }
/// ```
///
/// This will generate a WASM-exported `Rpc` class usable from JavaScript,
/// with `add` available as an RPC endpoint.
///
/// ## Constraints
///
/// - All exported method names **must be unique across the entire Worker**.
/// The underlying JavaScript shim attaches methods to a single `Entrypoint` prototype.
/// If two methods share the same name (even from different `impl` blocks), only one will be used.
/// - Only methods explicitly marked with `#[rpc]` are exported.
/// - Method bodies are not modified. You can use `self.env` as needed inside methods.
pub fn rpc(attr: TokenStream, stream: TokenStream) -> TokenStream {
rpc::expand_macro(attr, stream)
}
52 changes: 52 additions & 0 deletions worker-macros/src/rpc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ImplItem, ItemImpl, Type, Error};
use syn::spanned::Spanned;

pub fn expand_macro(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(item as ItemImpl);

let struct_ident = match &*input.self_ty {
Type::Path(p) => &p.path.segments.last().unwrap().ident,
_ => return Error::new(input.self_ty.span(), "Expected a named type").to_compile_error().into(),
};

let mut exported_methods = Vec::new();

for item in &mut input.items {
if let ImplItem::Fn(ref mut func) = item {
if let Some(rpc_pos) = func.attrs.iter().position(|attr| attr.path().is_ident("rpc")) {
func.attrs.remove(rpc_pos);
func.attrs.insert(0, syn::parse_quote!(#[wasm_bindgen]));
exported_methods.push(func.clone());
}
}
}

if exported_methods.is_empty() {
return Error::new(input.span(), "No methods marked with #[rpc] found.").to_compile_error().into();
}

TokenStream::from(quote! {
#[wasm_bindgen]
pub struct #struct_ident {
env: worker::Env,
}

#[wasm_bindgen]
impl #struct_ident {
#[wasm_bindgen(js_name = "__is_rpc__")]
pub fn is_rpc(&self) -> bool {
true
}

#[wasm_bindgen(constructor)]
pub fn new(env: worker::Env) -> Self {
Self { env }
}

#(#exported_methods)*
}
})
}

76 changes: 64 additions & 12 deletions worker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,73 @@
//! let router = axum::Router::new()
//! .route("/", get(handler))
//! ```
//!
//! # RPC Support
//!
//! ## RPC Support
//!
//! `workers-rs` has experimental support for [Workers RPC](https://developers.cloudflare.com/workers/runtime-apis/rpc/).
//! For now, this relies on JavaScript bindings and may require some manual usage of `wasm-bindgen`.
//!
//!
//! Not all features of RPC are supported yet (or have not been tested), including:
//! - Function arguments and return values
//! - Class instances
//! - Stub forwarding
//!
//! ## RPC Server
//!
//! Writing an RPC server with `workers-rs` is relatively simple. Simply export methods using `wasm-bindgen`. These
//! will be automatically detected by `worker-build` and made available to other Workers. See
//! [example](https://github.com/cloudflare/workers-rs/tree/main/examples/rpc-server).
//!
//!
//! ### RPC Server
//!
//! RPC methods can be exported using a custom `#[rpc]` attribute macro.
//! RPC methods must be defined inside an `impl` block annotated with `#[rpc]`, and individual methods must also be marked with `#[rpc]`.
//!
//! When the macro is expanded, it generates:
//! - A `#[wasm_bindgen]`-annotated struct with `env: worker::Env`
//! - A constructor function: `#[wasm_bindgen(constructor)] pub fn new(env: Env)`
//! - A method `#[wasm_bindgen(js_name = "__is_rpc__")] fn is_rpc(&self) -> bool` for RPC auto-discovery
//! - All methods marked with `#[rpc]` converted into `#[wasm_bindgen]`-annotated exports
//!
//! **RPC method names must be unique across all types.**
//!
//! Due to how the JavaScript shim dynamically attaches RPC methods to the `Entrypoint` prototype, each `#[rpc]` method must have a unique name,
//! even if it is defined on a different struct. If two methods share the same name, only one will be registered and the others will be silently skipped or overwritten.
//!
//!
//! #### Example
//!
//! ```rust
//! #[rpc]
//! impl Rpc {
//! #[rpc]
//! pub async fn add(&self, a: u32, b: u32) -> u32 {
//! a + b
//! }
//! }
//! ```
//!
//! Expands to:
//!
//! ```rust
//! #[wasm_bindgen]
//! pub struct Rpc {
//! env: worker::Env,
//! }
//!
//! #[wasm_bindgen]
//! impl Rpc {
//! #[wasm_bindgen(js_name = "__is_rpc__")]
//! pub fn is_rpc(&self) -> bool {
//! true
//! }
//! #[wasm_bindgen(constructor)]
//! pub fn new(env: worker::Env) -> Self {
//! Self { env }
//! }
//!
//! #[wasm_bindgen]
//! pub async fn add(&self, a: u32, b: u32) -> u32 {
//! a + b
//! }
//! }
//! ```
//!
//! See [example](./examples/rpc-server).
//!
//! ## RPC Client
//!
//! Creating types and bindings for invoking another Worker's RPC methods is a bit more involved. You will need to
Expand Down Expand Up @@ -157,7 +208,7 @@ pub use wasm_bindgen_futures;
pub use worker_kv as kv;

pub use cf::{Cf, CfResponseProperties, TlsClientAuth};
pub use worker_macros::{durable_object, event, send};
pub use worker_macros::{durable_object, event, send, rpc};
#[doc(hidden)]
pub use worker_sys;
pub use worker_sys::{console_debug, console_error, console_log, console_warn};
Expand Down Expand Up @@ -235,6 +286,7 @@ mod streams;
mod version;
mod websocket;


pub type Result<T> = StdResult<T, error::Error>;

#[cfg(feature = "http")]
Expand Down