diff --git a/README.md b/README.md index f37143171..d6d13ad00 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/rpc-server/src/lib.rs b/examples/rpc-server/src/lib.rs index 64f08c8a8..57663c0ba 100644 --- a/examples/rpc-server/src/lib.rs +++ b/examples/rpc-server/src/lib.rs @@ -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::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 + } } diff --git a/worker-build/src/js/shim.js b/worker-build/src/js/shim.js index ed4c9fd1e..2e75e4dd3 100644 --- a/worker-build/src/js/shim.js +++ b/worker-build/src/js/shim.js @@ -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; diff --git a/worker-macros/src/lib.rs b/worker-macros/src/lib.rs index 892c53094..c9e615b94 100644 --- a/worker-macros/src/lib.rs +++ b/worker-macros/src/lib.rs @@ -1,6 +1,7 @@ mod durable_object; mod event; mod send; +mod rpc; use proc_macro::TokenStream; @@ -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) +} diff --git a/worker-macros/src/rpc.rs b/worker-macros/src/rpc.rs new file mode 100644 index 000000000..36fdae896 --- /dev/null +++ b/worker-macros/src/rpc.rs @@ -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)* + } + }) +} + diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e9a4b767e..6da936f02 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -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 @@ -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}; @@ -235,6 +286,7 @@ mod streams; mod version; mod websocket; + pub type Result = StdResult; #[cfg(feature = "http")]