Clone in a terminal:
lib.rs
src/ - Add return type to callback closure
// Copyright (c) 2026 Johan Mattsson // License: MIT #![doc = include_str!("../README.md")] use proc_macro::TokenStream; use quote::quote; use syn::{FnArg, Ident, ItemFn, LitStr, parse_macro_input}; /// This macro makes sure a function is registerd in JS DOM Window for a given callback /// in the Kaja Web Framework (WebAssembly, Rust). /// /// Example usage: /// ```rust,ignore /// kaja_web::prelude::* /// /// #[derive(serde::Deserialize)] /// struct SomeClickEvent { /// índex: usize, /// } /// /// #[callback("someClickCallback")] /// fn some_on_click_function(event: SomeClickEvent) { /// log!("event.índex: {:?}", event.índex); /// } /// /// // This example allows you to call the Rust function from JavaScript like this: /// let html = kaja_html_macro::html! {{ /// <button onclick="someClickCallback({ /// index: 1 /// })">Run WASM Callback</button> /// }}; /// ``` /// /// The program using this macro need to call `init_callback()` at startup /// in order to have make the callbacks available in JavaScript. #[proc_macro_attribute] pub fn callback(attr: TokenStream, item: TokenStream) -> TokenStream { let callback_arg = parse_macro_input!(attr as CallbackArg); let js_name_lit = LitStr::new(&callback_arg.name, callback_arg.span); let input_fn = parse_macro_input!(item as ItemFn); let fn_name = &input_fn.sig.ident; let vis = &input_fn.vis; let sig = &input_fn.sig; let fn_block = &input_fn.block; let register_fn_name = syn::Ident::new(&format!("{}_register", fn_name), fn_name.span()); let register_fn_name_lit = LitStr::new(&register_fn_name.to_string(), register_fn_name.span()); let is_async = input_fn.sig.asyncness.is_some(); let callback_closure = generate_callback_closure( &fn_name, input_fn.sig.inputs.clone(), is_async, &input_fn.sig.output, ); let expanded = { quote! { #vis #sig #fn_block #[wasm_bindgen] pub fn #register_fn_name() { use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use js_sys::{Array, Object, Reflect}; use wasm_bindgen::JsValue; if let Some(window) = web_sys::window() { #callback_closure; let previous = match Reflect::get(window.as_ref(), &JsValue::from_str(#register_fn_name_lit)) { Ok(v) => v, Err(e) => { ::gloo::console::error!("Callback registration check failed:", e); JsValue::UNDEFINED } }; if !previous.is_undefined() { ::gloo::console::error!("Callback already registered: {}. Callbacks are global on the entire document", #register_fn_name_lit); return; } let _ = Reflect::set( window.as_ref(), &JsValue::from_str(#register_fn_name_lit), callback_closure.as_ref().unchecked_ref(), ); let key = JsValue::from_str(#js_name_lit); let _ = Reflect::set(window.as_ref(), &key, callback_closure.as_ref().unchecked_ref()); callback_closure.forget(); } } ::inventory::submit! { InitFn(#register_fn_name) } } }; TokenStream::from(expanded) } // The closure will look like this: /* let callback_js_closure = Closure::<dyn FnMut(wasm_bindgen::JsValue, wasm_bindgen::JsValue)>::new(|val, val2| { let event: SomeStruct = serde_wasm_bindgen::from_value(val); if event.is_err() { gloo::console::error!("Callback error: {}. Wrong argument type: {}", fn_name, event.err().unwrap()); return; } let event2: u32 = serde_wasm_bindgen::from_value(val2); if event2.is_err() { gloo::console::error!("Callback error: {}. Wrong argument type: {}", fn_name, event2.err().unwrap()); return; } let event = event.unwrap(); let event2 = event.unwrap(); the_rust_callback_function(event, event2); }); */ fn generate_callback_closure( fn_name: &Ident, inputs: syn::punctuated::Punctuated<FnArg, syn::token::Comma>, is_async: bool, return_type: &syn::ReturnType, ) -> proc_macro2::TokenStream { use proc_macro2::Span; use quote::quote; let mut js_value_types: Vec<proc_macro2::TokenStream> = Vec::new(); let mut js_arg_idents: Vec<syn::Ident> = Vec::new(); let mut rust_arg_types: Vec<syn::Type> = Vec::new(); let mut rs_arg_idents: Vec<syn::Ident> = Vec::new(); let mut temp_result_idents: Vec<syn::Ident> = Vec::new(); for (i, arg) in inputs.iter().enumerate() { let span = Span::call_site(); let js_value_type = quote! { wasm_bindgen::JsValue }; js_value_types.push(js_value_type); let val_ident = syn::Ident::new(&format!("val{}", i), span); js_arg_idents.push(val_ident); let rs_ident = syn::Ident::new(&format!("arg{}", i), span); rs_arg_idents.push(rs_ident); let res_ident = syn::Ident::new(&format!("res{}", i), span); temp_result_idents.push(res_ident); match arg { FnArg::Typed(pat_type) => { rust_arg_types.push((*pat_type.ty).clone()); } _ => panic!("expected typed argument"), } } let mut conversions = Vec::new(); for ((res_ident, rs_ident), (val_ident, ty)) in temp_result_idents .iter() .zip(rs_arg_idents.iter()) .zip(js_arg_idents.iter().zip(rust_arg_types.iter())) { // If the expected Rust type is JsValue, skip serde conversion and // pass the JsValue through directly. let ty_tokens = quote! { #ty }.to_string(); let conv = if ty_tokens.contains("JsValue") { quote! { let #rs_ident = #val_ident; } } else if ty_tokens.contains("web_sys") || ty_tokens.contains("js_sys") || ty_tokens.contains("wasm_bindgen") || ty_tokens.contains("HtmlElement") { // Convert JsValue -> web_sys/js_sys type using JsCast::dyn_into // Treat bare `HtmlElement` identifiers as web_sys elements as well quote! { let #res_ident = #val_ident.clone().dyn_into::<#ty>(); if #res_ident.is_err() { gloo::console::log!( concat!("Callback error: ", stringify!(#fn_name), ". Wrong argument"), #res_ident.err().unwrap() ); return JsValue::UNDEFINED; } let #rs_ident = #res_ident.unwrap(); } } else { quote! { let #res_ident = serde_wasm_bindgen::from_value::<#ty>(#val_ident.clone()); if #res_ident.is_err() { gloo::console::log!( concat!("Callback error: ", stringify!(#fn_name), ". Wrong argument"), #res_ident.err().unwrap() ); return JsValue::UNDEFINED; } let #rs_ident = #res_ident.unwrap(); } }; conversions.push(conv); } let js_types: Vec<proc_macro2::TokenStream> = (0..js_arg_idents.len()) .map(|_| quote! { wasm_bindgen::JsValue }) .collect(); // Build the call + conversion to JsValue for the function's return value. let ret_handling = match return_type { syn::ReturnType::Default => { // no return type -> return undefined quote! { #fn_name( #(#rs_arg_idents),* ); JsValue::UNDEFINED } } syn::ReturnType::Type(_, ty) => { let ret_ty_tokens = quote! { #ty }.to_string(); if ret_ty_tokens.contains("JsValue") { // function returns a JsValue directly quote! { let res = #fn_name( #(#rs_arg_idents),* ); res } } else if ret_ty_tokens.contains("web_sys") || ret_ty_tokens.contains("js_sys") || ret_ty_tokens.contains("wasm_bindgen") || ret_ty_tokens.contains("HtmlElement") { // web_sys/js_sys types -> convert to JsValue via Into quote! { let res = #fn_name( #(#rs_arg_idents),* ); wasm_bindgen::JsValue::from(res) } } else { // fallback: serialize with serde_wasm_bindgen quote! { let res = #fn_name( #(#rs_arg_idents),* ); match serde_wasm_bindgen::to_value(&res) { Ok(v) => v, Err(e) => { ::gloo::console::error!( concat!("Callback error: ", stringify!(#fn_name), ". Serialize error"), e ); JsValue::UNDEFINED } } } } } }; // Build async conversion logic (returns Result<JsValue, JsValue> inside the future) let async_conv = match return_type { syn::ReturnType::Default => quote! { Ok(JsValue::UNDEFINED) }, syn::ReturnType::Type(_, ty) => { let ret_ty_tokens = quote! { #ty }.to_string(); if ret_ty_tokens.contains("JsValue") { quote! { Ok(ret) } } else if ret_ty_tokens.contains("web_sys") || ret_ty_tokens.contains("js_sys") || ret_ty_tokens.contains("wasm_bindgen") || ret_ty_tokens.contains("HtmlElement") { quote! { Ok(wasm_bindgen::JsValue::from(ret)) } } else { quote! { match serde_wasm_bindgen::to_value(&ret) { Ok(v) => Ok(v), Err(e) => { ::gloo::console::error!( concat!("Callback error: ", stringify!(#fn_name), ". Serialize error"), e ); Ok(JsValue::UNDEFINED) } } } } } }; let expanded = if is_async { // For async functions return a Promise (JsValue) by using future_to_promise quote! { let callback_closure = Closure::wrap(Box::new(move | #( #js_arg_idents : #js_types ),* | -> wasm_bindgen::JsValue { #(#conversions)* let promise = ::wasm_bindgen_futures::future_to_promise(async move { let ret = #fn_name( #(#rs_arg_idents),* ).await; #async_conv }); promise.into() }) as Box<dyn FnMut( #(#js_types),* ) -> wasm_bindgen::JsValue + 'static>); } } else { quote! { let callback_closure = Closure::wrap(Box::new(move | #( #js_arg_idents : #js_types ),* | -> wasm_bindgen::JsValue { #(#conversions)* #ret_handling }) as Box<dyn FnMut( #(#js_types),* ) -> wasm_bindgen::JsValue + 'static>); } }; return expanded; } struct CallbackArg { name: String, span: proc_macro2::Span, } // remove quote if present impl syn::parse::Parse for CallbackArg { fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { if input.peek(LitStr) { let s: LitStr = input.parse()?; Ok(CallbackArg { name: s.value(), span: s.span(), }) } else if input.peek(syn::Ident) { let id: syn::Ident = input.parse()?; Ok(CallbackArg { name: id.to_string(), span: id.span(), }) } else { Err(input.error("expected string literal or identifier, e.g. #[callback(\"name\")] or #[callback(name)]")) } } }

File history (21 commits)

11c9583 Johan Mattsson - johan.mattsson.m@gmail.com
Add return type to callback closure
Add return type to callback closure
04dee4d Johan Mattsson - johan.mattsson.m@gmail.com
Pass HTML element to callback
Pass HTML element to callback
443c628 Johan Mattsson - johan.mattsson.m@gmail.com
License
License
595fe6b Johan Mattsson - johan.mattsson.m@gmail.com
Support JsValue as argument with into conversion
Support JsValue as argument with into conversion
fc8a34e Johan Mattsson - johan.mattsson.m@gmail.com
Pass along JsValue in closure without conversion
Pass along JsValue in closure without conversion
83d9905 Johan Mattsson - johan.mattsson.m@gmail.com
Support async functions
Support async functions
2bdcbb8 Johan Mattsson - johan.mattsson.m@gmail.com
Readme
Readme
8cb1084 Johan Mattsson - johan.mattsson.m@gmail.com
Use error instead of log
Use error instead of log
954a515 Johan Mattsson - johan.mattsson.m@gmail.com
Check is callback already is registerd
Check is callback already is registerd
e8e5dd2 Johan Mattsson - johan.mattsson.m@gmail.com
Adding a comment for clarity
Adding a comment for clarity

Macros can be hard to read.
09795e8 Johan Mattsson - johan.mattsson.m@gmail.com
Cleanup
Cleanup
0bc8a2b Johan Mattsson - johan.mattsson.m@gmail.com
Use serde for all types
Use serde for all types
0f73065 Johan Mattsson - johan.mattsson.m@gmail.com
Fix type issue
Fix type issue
412770c Johan Mattsson - johan.mattsson.m@gmail.com
Test closures
Test closures
355b246 Johan Mattsson - johan.mattsson.m@gmail.com
Two arguments in callback
Two arguments in callback
eb7f0a2 Johan Mattsson - johan.mattsson.m@gmail.com
Refactor
Refactor
bf9aad2 Johan Mattsson - johan.mattsson.m@gmail.com
Fix compiler warning
Fix compiler warning
59d9c33 Johan Mattsson - johan.mattsson.m@gmail.com
Drop quotes
Drop quotes
150ef0f Johan Mattsson - johan.mattsson.m@gmail.com
Fix inventory
Fix inventory
61ad1d4 Johan Mattsson - johan.mattsson.m@gmail.com
Macro update
Macro update
cf38348 Johan Mattsson - johan.mattsson.m@gmail.com
Create JS callback function for kaja-web framework. Rust.
Create JS callback function for kaja-web framework. Rust.

Contact

Email: johan.mattsson.m@gmail.com
Mastodon: typo.social/@birdfont
GitHub: johanmattssonm
LinkedIn: johan-mattsson-2ab324112