From f22b4b5cf2a84028de54535032dc6a4052807152 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 08:07:32 +0200 Subject: [PATCH 1/3] feat(codegen): generate wasm bridge callbacks Derive namespaced host callback adapters and the Rust wasm bridge from platform trait metadata instead of hardcoding the existing traits. --- rust/crates/truapi-codegen/src/main.rs | 53 +- .../truapi-codegen/src/platform_callbacks.rs | 181 ++++ rust/crates/truapi-codegen/src/rust.rs | 17 + .../truapi-codegen/src/rust/wasm_bridge.rs | 842 ++++++++++++++++++ .../truapi-codegen/src/ts/host_callbacks.rs | 352 +++++--- .../tests/golden/host-callbacks-adapter.ts | 81 +- .../tests/golden/host-callbacks.ts | 44 +- .../tests/golden/wasm_bridge.rs | 300 +++++++ .../tests/golden/worker-callbacks.ts | 5 +- .../truapi-codegen/tests/golden_rust_emit.rs | 44 +- rust/crates/truapi-platform/src/lib.rs | 4 +- rust/crates/truapi-server/src/wasm.rs | 473 +--------- .../src/wasm/generated_bridge.rs | 300 +++++++ scripts/codegen.sh | 3 + 14 files changed, 2092 insertions(+), 607 deletions(-) create mode 100644 rust/crates/truapi-codegen/src/platform_callbacks.rs create mode 100644 rust/crates/truapi-codegen/src/rust/wasm_bridge.rs create mode 100644 rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs create mode 100644 rust/crates/truapi-server/src/wasm/generated_bridge.rs diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 42a5aa1e..d4927a61 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::str::FromStr; mod platform; +mod platform_callbacks; mod rust; mod rustdoc; mod ts; @@ -72,6 +73,11 @@ struct Cli { #[arg(long)] platform_wasm_adapter_output: Option, + /// Output directory for the generated Rust WASM platform bridge. + /// Only honored when `--platform-input` is also set. + #[arg(long)] + platform_rust_output: Option, + /// Output directory for generated explorer metadata (optional). When set, /// writes `codegen/types.ts` with the DataType list consumed by the /// explorer site. @@ -145,33 +151,44 @@ fn main() -> Result<()> { .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } - if let (Some(input), Some(output)) = (&cli.platform_input, &cli.platform_ts_output) { + if let Some(input) = &cli.platform_input { + if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() { + anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output"); + } let json = std::fs::read_to_string(input) .with_context(|| format!("reading platform rustdoc JSON from {input}"))?; let krate = rustdoc::parse(&json).with_context(|| format!("parsing platform rustdoc {input}"))?; let definition = platform::extract(&krate) .with_context(|| format!("extracting platform definition from {input}"))?; - let codec_types = api - .types - .iter() - .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) - .map(|t| t.name.clone()) - .collect(); - let adapter_output = cli - .platform_wasm_adapter_output - .as_deref() - .unwrap_or(output.as_str()); - ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) - .with_context(|| format!("writing host callbacks TS to {output}"))?; - println!("Generated typed HostCallbacks TS surface in {output}"); - println!("Generated WASM HostCallbacks adapter in {adapter_output}"); - } else if cli.platform_input.is_some() != cli.platform_ts_output.is_some() + if let Some(output) = &cli.platform_ts_output { + let codec_types = api + .types + .iter() + .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) + .map(|t| t.name.clone()) + .collect(); + let adapter_output = cli + .platform_wasm_adapter_output + .as_deref() + .unwrap_or(output.as_str()); + ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) + .with_context(|| format!("writing host callbacks TS to {output}"))?; + println!("Generated typed HostCallbacks TS surface in {output}"); + println!("Generated WASM HostCallbacks adapter in {adapter_output}"); + } + if let Some(output) = &cli.platform_rust_output { + rust::generate_wasm_bridge_file(&definition, &api, output) + .with_context(|| format!("writing Rust WASM bridge to {}", output.display()))?; + println!("Generated Rust WASM bridge in {}", output.display()); + } + } else if cli.platform_ts_output.is_some() || cli.platform_wasm_adapter_output.is_some() + || cli.platform_rust_output.is_some() { anyhow::bail!( - "--platform-input and --platform-ts-output must be provided together; \ - --platform-wasm-adapter-output additionally requires both" + "--platform-input is required for platform output; \ + --platform-wasm-adapter-output additionally requires --platform-ts-output" ); } if let Some(path) = &cli.explorer_output { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs new file mode 100644 index 00000000..98a33f65 --- /dev/null +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -0,0 +1,181 @@ +//! Shared callback naming and selection rules for generated platform bridges. + +use std::collections::BTreeSet; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::rustdoc::TypeRef; + +pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { + let composed: BTreeSet = match &definition.super_trait { + Some(s) => s.composes.iter().cloned().collect(), + None => definition.traits.iter().map(|t| t.name.clone()).collect(), + }; + definition + .traits + .iter() + .filter(|t| composed.contains(&t.name)) + .collect() +} + +pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String { + to_camel_case(&method.name) +} + +pub(crate) fn platform_trait_names(definition: &PlatformDefinition) -> BTreeSet { + definition.traits.iter().map(|t| t.name.clone()).collect() +} + +pub(crate) fn trait_object_return_name<'a>( + method: &'a PlatformMethod, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + match &method.return_shape.inner { + PlatformInner::TraitObject(name) => Some(name.as_str()), + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + named_platform_trait(ok, platform_trait_names) + } + PlatformInner::Unit | PlatformInner::Stream(_) => None, + } +} + +pub(crate) fn raw_callback_wire_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + let raw = raw_callback_name(method); + if trait_object_return_name(method, platform_trait_names).is_some() { + return format!( + "{}{}", + callback_namespace(&trait_def.name), + upper_first(&raw) + ); + } + raw +} + +pub(crate) fn raw_callback_field_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + snake_case(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_type_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + upper_first(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_adapter_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + format!( + "{}Adapter", + raw_callback_wire_name(trait_def, method, platform_trait_names) + ) +} + +pub(crate) fn callback_namespace(trait_name: &str) -> String { + let stem = ["Provider", "Presenter", "Host"] + .into_iter() + .find_map(|suffix| trait_name.strip_suffix(suffix)) + .unwrap_or(trait_name); + lower_pascal_case(stem) +} + +fn named_platform_trait<'a>( + ty: &'a TypeRef, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + let TypeRef::Named { name, args } = ty else { + return None; + }; + if args.is_empty() && platform_trait_names.contains(name) { + return Some(name.as_str()); + } + None +} + +/// Unwrap a `Result` stream item to its `T`; other item types pass +/// through. Streams carry `Result`s on the Rust side but the JS raw bridge +/// already unwraps them before handing each item to the WASM callback sink. +pub(crate) fn stream_item(item: &TypeRef) -> &TypeRef { + if let TypeRef::Named { name, args } = item + && name == "Result" + && let Some(ok) = args.first() + { + return ok; + } + item +} + +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper_next = false; + for (idx, ch) in name.chars().enumerate() { + if ch == '_' { + upper_next = idx != 0; + continue; + } + if upper_next { + out.extend(ch.to_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out +} + +fn lower_pascal_case(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_lowercase(), + chars.collect::() + ) +} + +fn upper_first(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_uppercase(), + chars.collect::() + ) +} + +pub(crate) fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (idx, ch) in name.chars().enumerate() { + if ch.is_ascii_uppercase() { + if idx != 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 1ca6f77c..c7878829 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -11,12 +11,15 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use crate::platform::PlatformDefinition; use crate::rustdoc::*; mod dispatcher; +mod wasm_bridge; mod wire_table; pub use dispatcher::generate_dispatcher; +pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. @@ -29,6 +32,20 @@ pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { Ok(()) } +/// Generates the Rust wasm-bindgen platform bridge source into `output_dir`. +pub fn generate_wasm_bridge_file( + definition: &PlatformDefinition, + api: &ApiDefinition, + output_dir: &Path, +) -> Result<()> { + fs::create_dir_all(output_dir)?; + fs::write( + output_dir.join("generated_bridge.rs"), + generate_wasm_bridge(definition, api)?, + )?; + Ok(()) +} + /// Trait -> versioned-module mapping. Trait names are PascalCase /// (`JsonRpc`, `LocalStorage`); module names are snake_case /// (`jsonrpc`, `local_storage`). The mapping is irregular enough diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs new file mode 100644 index 00000000..88c05435 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -0,0 +1,842 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::platform_callbacks::{ + composed_traits, platform_trait_names, raw_callback_field_name, raw_callback_name, + raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, +}; +use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; + +pub fn generate_wasm_bridge( + definition: &PlatformDefinition, + api: &ApiDefinition, +) -> Result { + let ctx = BridgeCtx::new(definition, api); + let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); + validate_errors(&traits, &ctx)?; + let mut out = String::new(); + writedoc!( + out, + r#" + //! Auto-generated by truapi-codegen. Do not edit. + //! + //! Mechanical wasm-bindgen callback bridge derived from + //! `truapi-platform`. Raw callback names and payload shapes match the + //! generated TypeScript host-callback adapter. + + use futures::stream::BoxStream; + use js_sys::{{Function, Uint8Array}}; + use parity_scale_codec::Encode; + use truapi::v01; + use wasm_bindgen::JsValue; + + use super::{{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, + }}; + + /// JS-side callbacks invoked by the wasm platform bridge. Methods with + /// Rust default bodies are still required here because the generated TS + /// adapter resolves optional host callbacks before constructing this + /// raw callback object. + pub(super) struct JsBridge {{ + "#, + ) + .unwrap(); + + for field in bridge_fields(&traits, &trait_names) { + writeln!(out, " pub(super) {}: Function,", field.field_name).unwrap(); + } + out.push_str("}\n\n"); + + writedoc!( + out, + r#" + impl JsBridge {{ + pub(super) fn from_js(callbacks: &JsValue) -> Result {{ + Ok(Self {{ + "#, + ) + .unwrap(); + for field in bridge_fields(&traits, &trait_names) { + writeln!( + out, + " {}: get_function(callbacks, \"{}\")?,", + field.field_name, field.raw_name + ) + .unwrap(); + } + out.push_str(" })\n }\n}\n\n"); + + let mut parse_fns = BTreeMap::new(); + for trait_def in traits { + if requires_manual_trait_impl(trait_def, &trait_names) { + continue; + } + out.push_str(&emit_trait_impl(trait_def, &ctx, &mut parse_fns)?); + out.push('\n'); + } + for (idx, (_name, body)) in parse_fns.into_iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(body.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +struct BridgeCtx<'a> { + api_types: BTreeMap<&'a str, &'a TypeDef>, + codec_types: BTreeSet<&'a str>, + local_types: BTreeSet<&'a str>, + local_codec_types: BTreeSet<&'a str>, +} + +impl<'a> BridgeCtx<'a> { + fn new(definition: &'a PlatformDefinition, api: &'a ApiDefinition) -> Self { + let api_types = api + .types + .iter() + .map(|ty| (ty.name.as_str(), ty)) + .collect::>(); + let codec_types = api + .types + .iter() + .filter(|ty| !matches!(ty.kind, TypeDefKind::Alias(_))) + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_types = definition + .types + .iter() + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_codec_types = collect_local_bridge_payload_types(definition); + Self { + api_types, + codec_types, + local_types, + local_codec_types, + } + } + + fn is_api_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.codec_types.contains(name.as_str())) + } + + fn is_local_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) + } + + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { + let TypeRef::Named { name, .. } = ty else { + return None; + }; + let mut seen = BTreeSet::new(); + let mut current = name.as_str(); + loop { + if !seen.insert(current) { + return None; + } + let TypeDefKind::Alias(target) = &self.api_types.get(current)?.kind else { + return None; + }; + match target { + TypeRef::Primitive(primitive) => return Some(primitive.as_str()), + TypeRef::Named { name, .. } => current = name, + _ => return None, + } + } + } +} + +struct BridgeField { + field_name: String, + raw_name: String, +} + +fn bridge_fields( + traits: &[&PlatformTrait], + platform_trait_names: &BTreeSet, +) -> Vec { + let mut fields = Vec::new(); + for trait_def in traits { + for method in &trait_def.methods { + fields.push(BridgeField { + field_name: raw_callback_field_name(trait_def, method, platform_trait_names), + raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), + }); + } + } + fields +} + +fn requires_manual_trait_impl( + trait_def: &PlatformTrait, + platform_trait_names: &BTreeSet, +) -> bool { + trait_def + .methods + .iter() + .any(|method| trait_object_return_name(method, platform_trait_names).is_some()) +} + +fn emit_trait_impl( + trait_def: &PlatformTrait, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let mut out = String::new(); + if trait_def + .methods + .iter() + .any(|method| method.return_shape.is_async) + { + out.push_str("#[truapi_platform::async_trait]\n"); + } + writeln!( + out, + "impl truapi_platform::{} for WasmPlatform {{", + trait_def.name + ) + .unwrap(); + for (idx, method) in trait_def.methods.iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(&emit_method(method, ctx, parse_fns)?); + } + out.push_str("}\n"); + Ok(out) +} + +fn emit_method( + method: &PlatformMethod, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + match &method.return_shape.inner { + PlatformInner::Result { ok, err } => emit_result_method(method, ok, err, ctx), + PlatformInner::Stream(item) => emit_stream_method(method, item, ctx, parse_fns), + PlatformInner::Unit => emit_unit_method(method, ctx), + PlatformInner::Plain(ok) => emit_plain_method(method, ok, ctx), + PlatformInner::TraitObject(_) => bail!( + "trait-object platform method `{}` must be handled manually", + method.name + ), + } +} + +fn emit_result_method( + method: &PlatformMethod, + ok: &TypeRef, + err: &TypeRef, + ctx: &BridgeCtx<'_>, +) -> Result { + let raw = raw_callback_name(method); + let args = js_arg_vec(method, ctx)?; + let ret = format!("Result<{}, {}>", rust_type(ok, ctx)?, rust_type(err, ctx)?); + let params = rust_params(method, ctx)?; + let map_err = error_mapper(err, ctx)?; + + let body = if is_unit(ok) { + await_chain( + &bridge_call("invoke_unit", &method.name, &args, &[]), + &map_err, + ) + } else if is_bool(ok) { + await_chain( + &bridge_call("invoke_bool", &method.name, &args, &[]), + &map_err, + ) + } else if is_bytes(ok) { + await_chain( + &bridge_call("invoke_bytes_return", &method.name, &args, &[]), + &map_err, + ) + } else if is_optional_bytes(ok) { + await_chain( + &bridge_call( + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], + ), + &map_err, + ) + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? + } else { + bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); + }; + + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("async ", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn emit_plain_method(method: &PlatformMethod, ok: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if !is_unit(ok) { + bail!( + "unsupported non-async plain return for wasm bridge method `{}`", + method.name + ); + } + emit_unit_method(method, ctx) +} + +fn emit_unit_method(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = js_arg_vec(method, ctx)?; + let params = rust_params(method, ctx)?; + let body = if method.return_shape.is_async { + format!( + "if let Err(reason) = {}\n .await\n{{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("invoke_unit", &method.name, &args, &[]) + ) + } else { + let args = if args == "Vec::new()" { + "&[]".to_string() + } else { + format!("&{args}") + }; + format!( + "if let Err(reason) = {} {{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("call_js_function", &method.name, &args, &[]) + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header( + if method.return_shape.is_async { + "async " + } else { + "" + }, + &method.name, + ¶ms, + None + ), + body = indent_body(&body, 8), + )) +} + +fn emit_stream_method( + method: &PlatformMethod, + item: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let raw = raw_callback_name(method); + let TypeRef::Named { name, args } = item else { + bail!("stream `{raw}` must yield Result"); + }; + if name != "Result" || args.len() != 2 { + bail!("stream `{raw}` must yield Result"); + } + let ok = stream_item(item); + let err = &args[1]; + let ret = format!( + "BoxStream<'static, Result<{}, {}>>", + rust_type(ok, ctx)?, + rust_type(err, ctx)? + ); + let params = rust_params(method, ctx)?; + let payload = subscription_payload(method, ctx)?; + let parser = stream_parser(ok, ctx, parse_fns)?; + let body = if payload == "None" { + format!( + "invoke_js_subscription(&self.bridge.{}, None, {parser})", + method.name + ) + } else { + bridge_call( + "invoke_js_subscription", + &method.name, + &payload, + &[parser.to_string()], + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn formatdoc_decode_result( + method: &PlatformMethod, + ok: &TypeRef, + raw: &str, + args: &str, + map_err: &str, + ctx: &BridgeCtx<'_>, +) -> Result { + let ty = rust_type(ok, ctx)?; + let call = bridge_call("invoke_bytes_return", &method.name, args, &[]); + let await_bytes = await_chain(&call, &format!("{map_err}?")); + Ok(format!( + "let bytes = {await_bytes};\ndecode_bytes::<{ty}>(\n bytes,\n {:?},\n)\n{map_err}", + format!("{raw} response did not decode"), + )) +} + +fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + method + .params + .iter() + .map(|param| { + Ok(format!( + "{}: {}", + param.name, + rust_type(¶m.type_ref, ctx)? + )) + }) + .collect::>>() + .map(|params| params.join(", ")) +} + +fn method_header(keyword: &str, name: &str, params: &str, ret: Option<&str>) -> String { + let ret = ret.map(|ret| format!(" -> {ret}")).unwrap_or_default(); + if params.is_empty() { + return format!(" {keyword}fn {name}(&self){ret} {{"); + } + + let one_line = format!(" {keyword}fn {name}(&self, {params}){ret} {{"); + if one_line.len() <= 100 { + return one_line; + } + + let mut out = format!(" {keyword}fn {name}(\n &self,\n"); + for param in params.split(", ") { + writeln!(out, " {param},").unwrap(); + } + write!(out, " ){ret} {{").unwrap(); + out +} + +fn bridge_call(name: &str, field: &str, second_arg: &str, extra_args: &[String]) -> String { + let mut args = vec![format!("&self.bridge.{field}"), second_arg.to_string()]; + args.extend(extra_args.iter().cloned()); + let one_line = format!("{name}({})", args.join(", ")); + if !one_line.contains('\n') && one_line.len() <= 72 { + return one_line; + } + + let mut out = format!("{name}(\n &self.bridge.{field},\n"); + out.push_str(&indent_body(second_arg, 4)); + out.push_str(",\n"); + for arg in extra_args { + writeln!(out, " {arg},").unwrap(); + } + out.push(')'); + out +} + +fn await_chain(call: &str, map_err: &str) -> String { + if call.contains('\n') { + format!("{call}\n.await\n{map_err}") + } else { + format!("{call}\n .await\n {map_err}") + } +} + +fn rust_type(ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + match ty { + TypeRef::Primitive(name) if name == "String" || name == "str" => Ok("String".to_string()), + TypeRef::Primitive(name) => Ok(name.clone()), + TypeRef::Named { name, args } if name == "String" && args.is_empty() => { + Ok("String".to_string()) + } + TypeRef::Named { name, args } if name == "Result" && args.len() == 2 => Ok(format!( + "Result<{}, {}>", + rust_type(&args[0], ctx)?, + rust_type(&args[1], ctx)? + )), + TypeRef::Named { name, args } if ctx.api_types.contains_key(name.as_str()) => { + if args.is_empty() { + Ok(format!("v01::{name}")) + } else { + bail!("generic API type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, args } if ctx.local_types.contains(name.as_str()) => { + if args.is_empty() { + Ok(format!("truapi_platform::{name}")) + } else { + bail!("generic platform type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, .. } => Ok(name.clone()), + TypeRef::Vec(inner) if is_u8(inner) => Ok("Vec".to_string()), + TypeRef::Vec(inner) => Ok(format!("Vec<{}>", rust_type(inner, ctx)?)), + TypeRef::Option(inner) => Ok(format!("Option<{}>", rust_type(inner, ctx)?)), + TypeRef::Array(inner, len) => Ok(format!("[{}; {len}]", rust_type(inner, ctx)?)), + TypeRef::Tuple(items) if items.is_empty() => Ok("()".to_string()), + TypeRef::Tuple(items) => Ok(format!( + "({})", + items + .iter() + .map(|item| rust_type(item, ctx)) + .collect::>>()? + .join(", ") + )), + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("()".to_string()), + } +} + +fn js_arg_vec(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = method + .params + .iter() + .map(|param| js_arg_expr(¶m.name, ¶m.type_ref, ctx)) + .collect::>>()?; + if args.is_empty() { + Ok("Vec::new()".to_string()) + } else if args.len() == 1 { + Ok(format!("vec![{}]", args[0])) + } else { + let mut out = "vec![\n".to_string(); + for arg in args { + writeln!(out, " {arg},").unwrap(); + } + out.push(']'); + Ok(out) + } +} + +fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if is_string(ty) { + return Ok(format!("JsValue::from_str(&{name})")); + } + if is_bytes(ty) { + return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); + } + if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + return Ok(format!( + "Uint8Array::from({name}.encode().as_slice()).into()" + )); + } + if let Some(primitive) = ctx.alias_primitive(ty) { + return numeric_js_arg(name, primitive); + } + if let TypeRef::Primitive(primitive) = ty { + return numeric_js_arg(name, primitive); + } + bail!("unsupported wasm bridge callback parameter `{name}`: {ty:?}") +} + +fn numeric_js_arg(name: &str, primitive: &str) -> Result { + match primitive { + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { + Ok(format!("JsValue::from_f64(f64::from({name}))")) + } + "bool" => Ok(format!("JsValue::from_bool({name})")), + other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), + } +} + +fn subscription_payload(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + match method.params.as_slice() { + [] => Ok("None".to_string()), + [param] if is_bytes(¶m.type_ref) => Ok(format!("Some({})", param.name)), + [param] if ctx.is_api_codec(¶m.type_ref) || ctx.is_local_codec(¶m.type_ref) => { + Ok(format!("Some({}.encode())", param.name)) + } + [param] => bail!( + "unsupported subscription payload for `{}`: {:?}", + method.name, + param.type_ref + ), + _ => bail!( + "subscription `{}` has more than one payload parameter", + method.name + ), + } +} + +fn stream_parser( + ok: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + if is_optional_bytes(ok) { + return Ok("parse_optional_bytes_item".to_string()); + } + if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + let ty = rust_type(ok, ctx)?; + let label = named_type_name(ok)?; + let fn_name = format!("parse_{}_item", snake_case(label)); + parse_fns.entry(fn_name.clone()).or_insert_with(|| { + formatdoc! { + r#" + fn {fn_name}(value: JsValue) -> Result<{ty}, String> {{ + decode_js_item::<{ty}>(value, "{label}") + }} + "#, + fn_name = fn_name, + ty = ty, + label = label, + } + }); + return Ok(fn_name); + } + bail!("unsupported stream item type: {ok:?}") +} + +fn error_mapper(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(".map_err(generic)".to_string()); + } + let err_ty = rust_type(err, ctx)?; + Ok(format!(".map_err(|reason| {err_ty}::Unknown {{ reason }})")) +} + +fn validate_errors(traits: &[&PlatformTrait], ctx: &BridgeCtx<'_>) -> Result<()> { + for trait_def in traits { + for method in &trait_def.methods { + match &method.return_shape.inner { + PlatformInner::Result { err, .. } => validate_error_type(err, ctx)?, + PlatformInner::Stream(item) => { + let TypeRef::Named { name, args } = item else { + bail!("stream `{}` must yield Result", method.name); + }; + if name == "Result" && args.len() == 2 { + validate_error_type(&args[1], ctx)?; + } + } + PlatformInner::Unit | PlatformInner::Plain(_) | PlatformInner::TraitObject(_) => {} + } + } + } + Ok(()) +} + +fn validate_error_type(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result<()> { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(()); + } + let mut seen = BTreeSet::new(); + validate_error_name(name, ctx, &mut seen) +} + +fn validate_error_name<'a>( + name: &'a str, + ctx: &BridgeCtx<'a>, + seen: &mut BTreeSet<&'a str>, +) -> Result<()> { + if name == "GenericError" { + return Ok(()); + } + if !seen.insert(name) { + bail!("platform error type `{name}` contains a recursive alias/envelope"); + } + let Some(type_def) = resolve_alias_type(name, ctx) else { + bail!("platform error type `{name}` is not present in the API definition"); + }; + let TypeDefKind::Enum(variants) = &type_def.kind else { + bail!("platform error type `{name}` must resolve to an enum"); + }; + let has_unknown_reason = variants.iter().any(|variant| { + variant.name == "Unknown" + && matches!( + &variant.fields, + VariantFields::Named(fields) + if fields.iter().any(|field| { + field.name == "reason" && is_string(&field.type_ref) + }) + ) + }); + if !has_unknown_reason { + let versioned_payloads = variants + .iter() + .filter_map(|variant| match &variant.fields { + VariantFields::Unnamed(types) if types.len() == 1 => Some(&types[0]), + _ => None, + }) + .collect::>(); + if !versioned_payloads.is_empty() && versioned_payloads.len() == variants.len() { + for payload in versioned_payloads { + validate_error_name(named_type_name(payload)?, ctx, seen)?; + } + return Ok(()); + } + bail!("platform error type `{name}` must define `Unknown {{ reason: String }}`"); + } + Ok(()) +} + +fn resolve_alias_type<'a>(name: &'a str, ctx: &BridgeCtx<'a>) -> Option<&'a TypeDef> { + let mut seen = BTreeSet::new(); + let mut current = name; + loop { + if !seen.insert(current) { + return None; + } + let type_def = *ctx.api_types.get(current)?; + match &type_def.kind { + TypeDefKind::Alias(TypeRef::Named { name, .. }) => current = name, + _ => return Some(type_def), + } + } +} + +fn named_type_name(ty: &TypeRef) -> Result<&str> { + let TypeRef::Named { name, .. } = ty else { + bail!("expected named type, got {ty:?}"); + }; + Ok(name) +} + +fn is_unit(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Unit) || matches!(ty, TypeRef::Tuple(items) if items.is_empty()) +} + +fn is_bool(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "bool") +} + +fn is_string(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "String" || name == "str") + || matches!(ty, TypeRef::Named { name, args } if name == "String" && args.is_empty()) +} + +fn is_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Vec(inner) if is_u8(inner)) + || matches!(ty, TypeRef::Array(inner, _) if is_u8(inner)) +} + +fn is_optional_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Option(inner) if is_bytes(inner)) +} + +fn is_u8(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "u8") +} + +fn indent_body(body: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + body.lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { + let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); + let mut out = BTreeSet::new(); + for trait_def in &definition.traits { + for method in &trait_def.methods { + for param in &method.params { + collect_local_from_type(¶m.type_ref, &local, &mut out); + } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + } + } + let mut changed = true; + while changed { + changed = false; + let referenced = definition + .types + .iter() + .filter(|ty| out.contains(ty.name.as_str())) + .collect::>(); + for type_def in referenced { + let before = out.len(); + collect_local_from_type_def(type_def, &local, &mut out); + changed |= out.len() != before; + } + } + out +} + +fn collect_local_from_type_def<'a>( + type_def: &'a TypeDef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => collect_local_from_type(type_ref, local, out), + TypeDefKind::Struct(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_local_from_type(field, local, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_local_from_type(ty, local, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + } + } + } + } +} + +fn collect_local_from_type<'a>( + ty: &'a TypeRef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match ty { + TypeRef::Named { name, args } => { + if local.contains(name.as_str()) { + out.insert(name); + } + for arg in args { + collect_local_from_type(arg, local, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_local_from_type(inner, local, out); + } + TypeRef::Tuple(items) => { + for item in items { + collect_local_from_type(item, local, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 4d3f1882..ac0f52b6 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -18,6 +18,11 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; +use crate::platform_callbacks::{ + callback_namespace, composed_traits, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, +}; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -34,7 +39,7 @@ pub fn generate( ) -> Result<()> { fs::create_dir_all(callbacks_output_dir)?; fs::create_dir_all(adapter_output_dir)?; - let local_codec_types = collect_local_async_payload_types(definition); + let local_codec_types = collect_local_bridge_payload_types(definition); let body = emit_host_callbacks(definition, codec_types, &local_codec_types)?; fs::write( Path::new(callbacks_output_dir).join("host-callbacks.ts"), @@ -152,25 +157,26 @@ fn emit_host_callbacks( None, ), }; - out.push_str(&emit_super_interface("HostCallbacks", &composes, docs)); + out.push_str(&emit_host_callback_composites(&composes, docs)); Ok(out) } /// Emit the `createWasmRawCallbacks` adapter that maps the typed /// `HostCallbacks` surface onto the byte-oriented surface the WASM core -/// invokes. Named wire types cross as SCALE bytes (`.enc`/`.dec`); strings, -/// primitives, byte blobs and platform-local types (`AuthState`) pass through -/// unchanged. `ChainProvider` is delegated to the hand-written -/// `chainConnectAdapter` since its connection handle is bespoke. +/// invokes. Codec-backed wire and platform-local types cross as SCALE bytes +/// (`.enc`/`.dec`); strings, primitives and byte blobs pass through unchanged. +/// Trait-object returns are delegated to hand-written adapters whose names are +/// derived from the raw callback name. fn emit_wasm_adapter( definition: &PlatformDefinition, codec_types: &BTreeSet, local_codec_types: &BTreeSet, ) -> Result { // Only the capability traits the `Platform` super-trait composes are host - // callbacks; returned handles like `JsonRpcConnection` are not. + // callbacks; returned handle traits are adapted separately. let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); // Local types are emitted in `host-callbacks.ts` (e.g. `AuthState`); codec // types carry SCALE codecs and are imported as values for `.enc`/`.dec`. @@ -181,6 +187,8 @@ fn emit_wasm_adapter( let mut imports: BTreeSet = BTreeSet::new(); let mut extra_types: BTreeSet = BTreeSet::new(); let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); + let mut runtime_types: BTreeSet = BTreeSet::new(); + let mut support_imports: BTreeSet = BTreeSet::new(); for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -192,15 +200,32 @@ fn emit_wasm_adapter( ); collect_extra_named(¶m.type_ref, codec_types, &local, &mut extra_types); } + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + support_imports.insert(raw_callback_adapter_name(trait_def, method, &trait_names)); + continue; + } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { collect_codec_imports(ok, codec_types, &mut imports); + collect_local_codec_names( + ok, + local_codec_types, + &mut adapter_local_codec_types, + ); collect_extra_named(ok, codec_types, &local, &mut extra_types); } PlatformInner::Stream(item) => { - collect_codec_imports(stream_item(item), codec_types, &mut imports) + support_imports.insert("driveResultStream".to_string()); + collect_codec_imports(stream_item(item), codec_types, &mut imports); + collect_local_codec_names( + stream_item(item), + local_codec_types, + &mut adapter_local_codec_types, + ); } - PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + PlatformInner::TraitObject(_) => {} + PlatformInner::Unit => {} } } } @@ -212,9 +237,9 @@ fn emit_wasm_adapter( // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented - // callback surface the WASM core invokes. Named wire types cross as - // SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and - // platform-local types pass through unchanged. + // callback surface the WASM core invokes. Codec-backed wire and + // platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, + // primitives and byte blobs pass through unchanged. "#, ) @@ -230,40 +255,53 @@ fn emit_wasm_adapter( writedoc!( out, r#" - import type {{ AuthState, HostCallbacks }} from "./host-callbacks.js"; - import type {{ ChainConnect }} from "../runtime.js"; - import {{ - chainConnectAdapter, - driveResultStream, - }} from "../adapter-support.js"; + import type {{ + FlatHostCallbacks, + RequiredHostCallbacks, + }} from "./host-callbacks.js"; "#, ) .unwrap(); - out.push_str(&emit_raw_callbacks(&traits, codec_types, local_codec_types)); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + emit_import_block(&mut out, false, "../adapter-support.js", &support_imports); + if !runtime_types.is_empty() || !support_imports.is_empty() { + out.push('\n'); + } + out.push_str(&emit_raw_callbacks( + &traits, + codec_types, + local_codec_types, + &trait_names, + )); writedoc!( out, r#" /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks {{ + const callbacks = normalizeHostCallbacks(host); return {{ "#, ) .unwrap(); for trait_def in &traits { - if trait_def.name == "ChainProvider" { - writeln!(out, " chainConnect: chainConnectAdapter(host),").unwrap(); - continue; - } for method in &trait_def.methods { - let entry = emit_adapter_entry(method, codec_types, local_codec_types)?; + let entry = emit_adapter_entry( + trait_def, + method, + codec_types, + local_codec_types, + &trait_names, + )?; writeln!(out, " {entry}").unwrap(); } } out.push_str(" };\n}\n"); + out.push('\n'); + out.push_str(&emit_normalize_host_callbacks(&traits)); Ok(out) } @@ -279,16 +317,24 @@ fn emit_worker_callbacks( _local_codec_types: &BTreeSet, ) -> Result { let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); let mut callbacks = Vec::new(); let mut subscriptions = Vec::new(); + let mut trait_object_callbacks = Vec::new(); + let mut runtime_types = BTreeSet::new(); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + trait_object_callbacks.push((trait_def, method)); + continue; + } match &method.return_shape.inner { PlatformInner::Stream(_) => subscriptions.push(method), + PlatformInner::TraitObject(_) => { + unreachable!("trait-object callbacks are handled above") + } _ => callbacks.push(method), } } @@ -305,12 +351,15 @@ fn emit_worker_callbacks( // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. - import type {{ ChainConnect }} from "../runtime.js"; import type {{ RawCallbacks }} from "./host-callbacks-adapter.js"; "# ) .unwrap(); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + if !runtime_types.is_empty() { + out.push('\n'); + } out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); @@ -327,7 +376,21 @@ fn emit_worker_callbacks( payload: Uint8Array | null, sendItem: (value: T) => void, ): () => void; - chainConnect: ChainConnect; + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + writeln!( + out, + " {}: {};", + raw_callback_wire_name(trait_def, method, &trait_names), + raw_callback_type_name(trait_def, method, &trait_names) + ) + .unwrap(); + } + writedoc!( + out, + r#" }} "# @@ -352,7 +415,16 @@ fn emit_worker_callbacks( const callbacks: Record = {{ ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), - chainConnect: bridge.chainConnect, + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + let raw = raw_callback_wire_name(trait_def, method, &trait_names); + writeln!(out, " {raw}: bridge.{raw},").unwrap(); + } + writedoc!( + out, + r#" }}; return callbacks; }} @@ -378,18 +450,6 @@ fn emit_worker_callbacks( Ok(out) } -fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { - let composed: BTreeSet = match &definition.super_trait { - Some(s) => s.composes.iter().cloned().collect(), - None => definition.traits.iter().map(|t| t.name.clone()).collect(), - }; - definition - .traits - .iter() - .filter(|t| composed.contains(&t.name)) - .collect() -} - /// All names defined locally in the platform crate: capability trait names plus /// the struct/enum type names emitted into `host-callbacks.ts`. fn local_names(definition: &PlatformDefinition) -> BTreeSet { @@ -404,7 +464,7 @@ fn local_names(definition: &PlatformDefinition) -> BTreeSet { fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { let entries = methods .iter() - .map(|method| format!(" \"{}\",", to_camel_case(&method.name))) + .map(|method| format!(" \"{}\",", raw_callback_name(method))) .collect::>() .join("\n"); format!("export const {const_name} = [\n{entries}\n] as const;\n") @@ -431,7 +491,7 @@ fn emit_worker_callback_factory( } fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_name(method); let args = method .params .iter() @@ -471,7 +531,7 @@ fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result Result, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { let mut out = String::new(); out.push_str("export interface RawCallbacks {\n"); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { out.push_str(" "); - out.push_str(&raw_member(method, codec_types, local_codec_types)); + out.push_str(&raw_member( + trait_def, + method, + codec_types, + local_codec_types, + platform_trait_names, + )); out.push('\n'); } } - out.push_str(" chainConnect: ChainConnect;\n"); out.push_str("}\n"); out } /// One `RawCallbacks` member signature for `method`. fn raw_member( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { - let name = to_camel_case(&method.name); + let name = raw_callback_wire_name(trait_def, method, platform_trait_names); match &method.return_shape.inner { PlatformInner::Stream(_) => { let mut params: Vec = method @@ -593,7 +658,13 @@ fn raw_member( params.push("sendItem: (item?: Uint8Array) => void".to_string()); format!("{name}({}): (() => void) | void;", params.join(", ")) } - PlatformInner::TraitObject(_) => String::new(), + _ if trait_object_return_name(method, platform_trait_names).is_some() => { + format!( + "{}: {};", + name, + raw_callback_type_name(trait_def, method, platform_trait_names) + ) + } inner => { let params = method .params @@ -609,7 +680,7 @@ fn raw_member( .join(", "); let ok = match inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { - raw_ok_ts(ok, codec_types) + raw_ok_ts(ok, codec_types, local_codec_types) } _ => "void".to_string(), }; @@ -649,14 +720,22 @@ fn raw_param_ts( } /// TS type for a `RawCallbacks` `Result` ok value under the byte boundary. -fn raw_ok_ts(ty: &TypeRef, codec_types: &BTreeSet) -> String { +fn raw_ok_ts( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { match ty { TypeRef::Named { name, .. } if codec_types.contains(name) => "Uint8Array".to_string(), + TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), TypeRef::Vec(inner) | TypeRef::Array(inner, _) if matches!(inner.as_ref(), TypeRef::Primitive(p) if p == "u8") => { "Uint8Array".to_string() } - TypeRef::Option(inner) => format!("{} | null | undefined", raw_ok_ts(inner, codec_types)), + TypeRef::Option(inner) => format!( + "{} | null | undefined", + raw_ok_ts(inner, codec_types, local_codec_types) + ), TypeRef::Primitive(p) => raw_primitive_ts(p), TypeRef::Unit => "void".to_string(), TypeRef::Tuple(items) if items.is_empty() => "void".to_string(), @@ -716,19 +795,6 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } } -/// Unwrap a `Result` stream item to its `T`; other item types pass -/// through. Streams carry `Result`s on the Rust side but `driveResultStream` -/// already unwraps them, so the adapter encodes the inner item type. -fn stream_item(item: &TypeRef) -> &TypeRef { - if let TypeRef::Named { name, args } = item - && name == "Result" - && let Some(ok) = args.first() - { - return ok; - } - item -} - /// The call argument expression for one Rust param. Codec types arrive as /// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and /// are widened to `bigint`; everything else passes through. Arrow parameter @@ -767,25 +833,39 @@ fn param_names(method: &PlatformMethod) -> String { /// host provides a complete callback implementation, so missing capability /// behavior is expressed inside the host callback rather than by omitting it. fn emit_adapter_entry( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); + let host_method = format!("callbacks.{}.{}", callback_namespace(&trait_def.name), raw); + if trait_object_return_name(method, platform_trait_names).is_some() { + let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); + return Ok(format!( + "{raw}: {adapter}(callbacks.{}),", + callback_namespace(&trait_def.name) + )); + } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { - adapter_stream_impl(&raw, method, item, codec_types, local_codec_types)? + adapter_stream_impl(&host_method, method, item, codec_types, local_codec_types)? } PlatformInner::Result { ok, .. } => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } PlatformInner::Plain(ok) => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? - } - PlatformInner::Unit => { - adapter_unary_impl(&raw, method, &TypeRef::Unit, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } - PlatformInner::TraitObject(_) => bail!("unexpected trait-object return on `{raw}`"), + PlatformInner::Unit => adapter_unary_impl( + &host_method, + method, + &TypeRef::Unit, + codec_types, + local_codec_types, + )?, + PlatformInner::TraitObject(_) => unreachable!("trait-object callbacks are handled above"), }; Ok(format!("{raw}: {impl_expr},")) } @@ -793,7 +873,7 @@ fn emit_adapter_entry( /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, ok: &TypeRef, codec_types: &BTreeSet, @@ -806,9 +886,11 @@ fn adapter_unary_impl( .map(|p| adapter_arg(p, codec_types, local_codec_types)) .collect::>() .join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); let body = match ok { - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("{ty}.enc(await {call})") } _ => format!("await {call}"), @@ -819,7 +901,7 @@ fn adapter_unary_impl( /// The adapter implementation expression for a subscription callback: drive /// the host's stream into `sendItem`, SCALE-encoding each codec item. fn adapter_stream_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, item: &TypeRef, codec_types: &BTreeSet, @@ -838,7 +920,9 @@ fn adapter_stream_impl( // Tick subscription: the item carries no value, so ignore it and emit // a bare tick to the core's sink. _ if is_unit => "() => sendItem()".to_string(), - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("(item) => sendItem({ty}.enc(item))") } _ => "sendItem".to_string(), @@ -850,23 +934,29 @@ fn adapter_stream_impl( .collect(); names.push("sendItem".to_string()); let params = names.join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); Ok(format!( "({params}) => driveResultStream({call}, {item_expr})" )) } -fn collect_local_async_payload_types(definition: &PlatformDefinition) -> BTreeSet { +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet { let local: BTreeSet = definition.types.iter().map(|ty| ty.name.clone()).collect(); let mut out = BTreeSet::new(); for trait_def in &definition.traits { for method in &trait_def.methods { - if !method.return_shape.is_async { - continue; - } for param in &method.params { collect_local_from_type(¶m.type_ref, &local, &mut out); } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } } } let mut changed = true; @@ -1307,17 +1397,77 @@ fn inline_object_type(fields: &[FieldDef]) -> Result { Ok(format!("{{ {body} }}")) } -fn emit_super_interface(name: &str, composes: &[String], docs: Option<&str>) -> String { +fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> String { let jsdoc = render_jsdoc("", docs); if composes.is_empty() { - return format!("{jsdoc}export interface {name} {{}}\n"); + return format!( + "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" + ); } let extends = composes .iter() .map(|name| name.to_string()) .collect::>() .join(", "); - format!("{jsdoc}export interface {name} extends {extends} {{}}\n") + let host_members = composes + .iter() + .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) + .collect::>() + .join("\n"); + let required_members = composes + .iter() + .map(|trait_name| { + format!( + " {}: Required<{}>;", + callback_namespace(trait_name), + trait_name + ) + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + /** @deprecated Use the namespaced `HostCallbacks` shape instead. */ + export interface FlatHostCallbacks extends {extends} {{}} + + {jsdoc}export interface HostCallbacks {{ + {host_members} + }} + + export interface RequiredHostCallbacks {{ + {required_members} + }} + "#, + } +} + +fn emit_normalize_host_callbacks(traits: &[&PlatformTrait]) -> String { + let namespace_guard = traits + .first() + .map(|trait_def| callback_namespace(&trait_def.name)) + .unwrap_or_default(); + let members = traits + .iter() + .map(|trait_def| { + let namespace = callback_namespace(&trait_def.name); + format!(" {namespace}: host,") + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, + ): RequiredHostCallbacks {{ + if ("{namespace_guard}" in host) {{ + return host; + }} + return {{ + {members} + }}; + }} + "#, + } } fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { @@ -1463,21 +1613,3 @@ fn render_ts_doc_line(line: &str) -> String { .replace("Ok(())", "success") .replace("None", "`undefined`") } - -fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut upper_next = false; - for (idx, ch) in name.chars().enumerate() { - if ch == '_' { - upper_next = idx != 0; - continue; - } - if upper_next { - out.extend(ch.to_uppercase()); - upper_next = false; - } else { - out.push(ch); - } - } - out -} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 3edef0ec..f56af427 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -1,9 +1,9 @@ // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented -// callback surface the WASM core invokes. Named wire types cross as -// SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and -// platform-local types pass through unchanged. +// callback surface the WASM core invokes. Codec-backed wire and +// platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, +// primitives and byte blobs pass through unchanged. import { HostDevicePermissionRequest, @@ -20,18 +20,26 @@ import type { NotificationId, } from "@parity/truapi"; import { + AuthState, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; -import type { AuthState, HostCallbacks } from "./host-callbacks.js"; -import type { ChainConnect } from "../runtime.js"; +import type { + FlatHostCallbacks, + RequiredHostCallbacks, +} from "./host-callbacks.js"; + +import type { + ChainConnect, +} from "../runtime.js"; import { chainConnectAdapter, driveResultStream, } from "../adapter-support.js"; export interface RawCallbacks { - authStateChanged(state: AuthState): void; + authStateChanged(state: Uint8Array): void; + chainConnect: ChainConnect; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -48,31 +56,52 @@ export interface RawCallbacks { clear(key: string): Promise; subscribeTheme(sendItem: (item?: Uint8Array) => void): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; - chainConnect: ChainConnect; } /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks { + const callbacks = normalizeHostCallbacks(host); + return { + authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + chainConnect: chainConnectAdapter(callbacks.chain), + readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), + writeCoreStorage: async (key, value) => await callbacks.coreStorage.writeCoreStorage(CoreStorageKey.dec(key), value), + clearCoreStorage: async (key) => await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), + featureSupported: async (request) => HostFeatureSupportedResponse.enc(await callbacks.features.featureSupported(HostFeatureSupportedRequest.dec(request))), + navigateTo: async (url) => await callbacks.navigation.navigateTo(url), + pushNotification: async (notification) => HostPushNotificationResponse.enc(await callbacks.notifications.pushNotification(HostPushNotificationRequest.dec(notification))), + cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), + devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), + remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), + submitPreimage: async (value) => await callbacks.preimage.submitPreimage(value), + lookupPreimage: (key, sendItem) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem), + read: async (key) => await callbacks.productStorage.read(key), + write: async (key, value) => await callbacks.productStorage.write(key, value), + clear: async (key) => await callbacks.productStorage.clear(key), + subscribeTheme: (sendItem) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), + confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), + }; +} + +function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, +): RequiredHostCallbacks { + if ("auth" in host) { + return host; + } return { - authStateChanged: async (state) => await host.authStateChanged(state), - chainConnect: chainConnectAdapter(host), - readCoreStorage: async (key) => await host.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => await host.writeCoreStorage(CoreStorageKey.dec(key), value), - clearCoreStorage: async (key) => await host.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => HostFeatureSupportedResponse.enc(await host.featureSupported(HostFeatureSupportedRequest.dec(request))), - navigateTo: async (url) => await host.navigateTo(url), - pushNotification: async (notification) => HostPushNotificationResponse.enc(await host.pushNotification(HostPushNotificationRequest.dec(notification))), - cancelNotification: async (id) => await host.cancelNotification(id), - devicePermission: async (request) => HostDevicePermissionResponse.enc(await host.devicePermission(HostDevicePermissionRequest.dec(request))), - remotePermission: async (request) => RemotePermissionResponse.enc(await host.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value) => await host.submitPreimage(value), - lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), - read: async (key) => await host.read(key), - write: async (key, value) => await host.write(key, value), - clear: async (key) => await host.clear(key), - subscribeTheme: (sendItem) => driveResultStream(host.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), - confirmUserAction: async (review) => await host.confirmUserAction(UserConfirmationReview.dec(review)), + auth: host, + chain: host, + coreStorage: host, + features: host, + navigation: host, + notifications: host, + permissions: host, + preimage: host, + productStorage: host, + theme: host, + userConfirmation: host, }; } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4321e226..4c060456 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -241,6 +241,13 @@ export type UserConfirmationReview = */ export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +/** + * Auth/session lifecycle state the core projects for host UI. The core owns + * every transition and emits states in order; hosts render the current state + * and never derive auth UI from any other signal. + */ +export const AuthState: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Disconnected: S._void, Pairing: S.Struct({deeplink: S.str}) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, LoginFailed: S.Struct({reason: S.str}) as S.Codec<{ reason: string }>})); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -279,6 +286,12 @@ export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); +/** + * Decoded session fields a host shell needs to render account UI without + * parsing the opaque session blob the core persists through `CoreStorage`. + */ +export const SessionUiInfo: S.Codec = S.lazy((): S.Codec => S.Struct({publicKey: S.Bytes(32), identityAccountId: S.Option(S.Bytes(32)), liteUsername: S.Option(S.str), fullUsername: S.Option(S.str)}) as S.Codec); + /** * Review shown before a sign-payload request is sent to the paired wallet. */ @@ -532,7 +545,36 @@ export interface UserConfirmation { confirmUserAction(review: UserConfirmationReview): Promise; } +/** @deprecated Use the namespaced `HostCallbacks` shape instead. */ +export interface FlatHostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} + /** * Combined platform interface. A host must provide all capability traits. */ -export interface HostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} +export interface HostCallbacks { + navigation: Navigation; + notifications: Notifications; + permissions: Permissions; + features: Features; + productStorage: ProductStorage; + coreStorage: CoreStorage; + chain: ChainProvider; + auth: AuthPresenter; + userConfirmation: UserConfirmation; + theme: ThemeHost; + preimage: PreimageHost; +} + +export interface RequiredHostCallbacks { + navigation: Required; + notifications: Required; + permissions: Required; + features: Required; + productStorage: Required; + coreStorage: Required; + chain: Required; + auth: Required; + userConfirmation: Required; + theme: Required; + preimage: Required; +} diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 34275dbf..451b7bcc 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -5,9 +5,12 @@ // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. -import type { ChainConnect } from "../runtime.js"; import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { + ChainConnect, +} from "../runtime.js"; + export const CALLBACK_NAMES = [ "authStateChanged", "readCoreStorage", diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index b26d9e6e..bd650d13 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -31,27 +31,6 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { .collect() } -fn wasm_optional_callback_names(workspace: &Path) -> Vec { - let src = fs::read_to_string(workspace.join("rust/crates/truapi-server/src/wasm.rs")) - .expect("read wasm.rs"); - let mut names = src - .lines() - .filter_map(|line| { - let line = line.trim(); - let start = line.find("get_optional_function(callbacks, \"")?; - let quoted = &line[start + "get_optional_function(callbacks, \"".len()..]; - let end = quoted.find('"')?; - let name = "ed[..end]; - match name { - "chainConnect" | "dispose" => None, - _ => Some(name.to_string()), - } - }) - .collect::>(); - names.sort(); - names -} - /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot @@ -214,6 +193,8 @@ fn golden_host_callbacks_ts() { tempdir.path().join("host").to_str().unwrap(), "--platform-wasm-adapter-output", tempdir.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tempdir.path().join("rust-wasm").to_str().unwrap(), ]) .output() .expect("run truapi-codegen"); @@ -264,6 +245,20 @@ fn golden_host_callbacks_ts() { ); } + let wasm_bridge_golden_path = manifest_dir.join("tests/golden/wasm_bridge.rs"); + let wasm_bridge_actual = + fs::read_to_string(tempdir.path().join("rust-wasm/generated_bridge.rs")) + .expect("read generated wasm bridge"); + let wasm_bridge_golden = fs::read_to_string(&wasm_bridge_golden_path).unwrap_or_default(); + if wasm_bridge_golden != wasm_bridge_actual { + let dump = manifest_dir.join("tests/golden/wasm_bridge.rs.actual"); + let _ = fs::write(&dump, &wasm_bridge_actual); + panic!( + "golden mismatch for wasm_bridge.rs; wrote actual to {}", + dump.display() + ); + } + assert!( !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" @@ -273,11 +268,10 @@ fn golden_host_callbacks_ts() { &worker_actual, "SUBSCRIPTION_NAMES", )); - let wasm_optional = wasm_optional_callback_names(&workspace); - for name in wasm_optional { + for name in generated_names { assert!( - generated_names.contains(&name), - "generated worker names must include JsBridge optional callback `{name}`" + wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")), + "generated wasm bridge must bind worker callback `{name}`" ); } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 940d2ee2..b133af52 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -462,7 +462,7 @@ pub trait CoreStorage: Send + Sync { /// Decoded session fields a host shell needs to render account UI without /// parsing the opaque session blob the core persists through [`CoreStorage`]. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub struct SessionUiInfo { /// 32-byte sr25519 root public key of the active session. pub public_key: [u8; 32], @@ -477,7 +477,7 @@ pub struct SessionUiInfo { /// Auth/session lifecycle state the core projects for host UI. The core owns /// every transition and emits states in order; hosts render the current state /// and never derive auth UI from any other signal. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub enum AuthState { /// No active session and no login in progress. #[default] diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index ff5386b1..79dfd3a4 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -19,14 +19,12 @@ use std::sync::atomic::{AtomicBool, Ordering}; use futures::channel::mpsc; use futures::stream::{self, BoxStream, Stream, StreamExt}; use js_sys::{Array, Function, Reflect, Uint8Array}; -use parity_scale_codec::{Decode, Encode}; +use parity_scale_codec::Decode; use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ - AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, - PreimageHost, ProductContext, ProductStorage, RuntimeConfigValidationError, SessionUiInfo, - ThemeHost, UserConfirmation, UserConfirmationReview, + ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, + RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -37,28 +35,9 @@ use crate::{ ProductRuntime, }; -/// Bundle of JS-side callbacks the bridge invokes. Names map to camelCase -/// keys on the JS object passed to the constructor. -struct JsBridge { - navigate_to: Function, - push_notification: Function, - cancel_notification: Function, - device_permission: Function, - remote_permission: Function, - feature_supported: Function, - local_storage_read: Function, - local_storage_write: Function, - local_storage_clear: Function, - core_storage_read: Function, - core_storage_write: Function, - core_storage_clear: Function, - confirm_user_action: Function, - submit_preimage: Function, - lookup_preimage: Function, - subscribe_theme: Function, - auth_state_changed: Function, - chain_connect: Function, -} +mod generated_bridge; + +use generated_bridge::JsBridge; /// Per-core JS channel: outgoing frames and teardown for one product core. struct CoreChannel { @@ -66,31 +45,6 @@ struct CoreChannel { dispose: Function, } -impl JsBridge { - fn from_js(callbacks: &JsValue) -> Result { - Ok(Self { - navigate_to: get_function(callbacks, "navigateTo")?, - push_notification: get_function(callbacks, "pushNotification")?, - cancel_notification: get_function(callbacks, "cancelNotification")?, - device_permission: get_function(callbacks, "devicePermission")?, - remote_permission: get_function(callbacks, "remotePermission")?, - feature_supported: get_function(callbacks, "featureSupported")?, - local_storage_read: get_function(callbacks, "read")?, - local_storage_write: get_function(callbacks, "write")?, - local_storage_clear: get_function(callbacks, "clear")?, - core_storage_read: get_function(callbacks, "readCoreStorage")?, - core_storage_write: get_function(callbacks, "writeCoreStorage")?, - core_storage_clear: get_function(callbacks, "clearCoreStorage")?, - confirm_user_action: get_function(callbacks, "confirmUserAction")?, - submit_preimage: get_function(callbacks, "submitPreimage")?, - lookup_preimage: get_function(callbacks, "lookupPreimage")?, - subscribe_theme: get_function(callbacks, "subscribeTheme")?, - auth_state_changed: get_function(callbacks, "authStateChanged")?, - chain_connect: get_function(callbacks, "chainConnect")?, - }) - } -} - impl CoreChannel { fn from_js(callbacks: &JsValue) -> Result { Ok(Self { @@ -125,99 +79,6 @@ impl WasmPlatform { } } -#[truapi_platform::async_trait] -impl Navigation for WasmPlatform { - async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { - invoke_navigate_to(&self.bridge, &url) - .await - .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) - } -} - -#[truapi_platform::async_trait] -impl Notifications for WasmPlatform { - async fn push_notification( - &self, - notification: v01::HostPushNotificationRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.push_notification, notification.encode()) - .await - .map_err(generic)?; - v01::HostPushNotificationResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("pushNotification response did not decode".to_string())) - } - - async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { - invoke_u32_unit(&self.bridge.cancel_notification, id) - .await - .map_err(generic) - } -} - -#[truapi_platform::async_trait] -impl Permissions for WasmPlatform { - async fn device_permission( - &self, - request: v01::HostDevicePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.device_permission, request.encode()) - .await - .map_err(generic)?; - v01::HostDevicePermissionResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("devicePermission response did not decode".to_string())) - } - - async fn remote_permission( - &self, - request: v01::RemotePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.remote_permission, request.encode()) - .await - .map_err(generic)?; - v01::RemotePermissionResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("remotePermission response did not decode".to_string())) - } -} - -#[truapi_platform::async_trait] -impl Features for WasmPlatform { - async fn feature_supported( - &self, - request: v01::HostFeatureSupportedRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.feature_supported, request.encode()) - .await - .map_err(generic)?; - v01::HostFeatureSupportedResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("featureSupported response did not decode".to_string())) - } -} - -#[truapi_platform::async_trait] -impl ProductStorage for WasmPlatform { - async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { - invoke_local_storage_read(&self.bridge, &key) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn write( - &self, - key: String, - value: Vec, - ) -> Result<(), v01::HostLocalStorageReadError> { - invoke_local_storage_write(&self.bridge, &key, &value) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { - invoke_local_storage_clear(&self.bridge, &key) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } -} - #[truapi_platform::async_trait] impl ChainProvider for WasmPlatform { async fn connect( @@ -275,85 +136,6 @@ impl ChainProvider for WasmPlatform { } } -impl AuthPresenter for WasmPlatform { - fn auth_state_changed(&self, state: AuthState) { - if let Err(err) = self - .bridge - .auth_state_changed - .call1(&JsValue::NULL, &auth_state_to_js(&state)) - { - web_sys::console::error_1(&err); - } - } -} - -#[truapi_platform::async_trait] -impl CoreStorage for WasmPlatform { - async fn read_core_storage( - &self, - key: CoreStorageKey, - ) -> Result>, v01::GenericError> { - invoke_core_storage_read(&self.bridge, key) - .await - .map_err(generic) - } - - async fn write_core_storage( - &self, - key: CoreStorageKey, - value: Vec, - ) -> Result<(), v01::GenericError> { - invoke_core_storage_write(&self.bridge, key, value) - .await - .map_err(generic) - } - - async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { - invoke_core_storage_clear(&self.bridge, key) - .await - .map_err(generic) - } -} - -#[truapi_platform::async_trait] -impl UserConfirmation for WasmPlatform { - async fn confirm_user_action( - &self, - review: UserConfirmationReview, - ) -> Result { - invoke_bool(&self.bridge.confirm_user_action, review.encode()) - .await - .map_err(generic) - } -} - -impl ThemeHost for WasmPlatform { - fn subscribe_theme(&self) -> BoxStream<'static, Result> { - invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_item).boxed() - } -} - -#[truapi_platform::async_trait] -impl PreimageHost for WasmPlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { - invoke_bytes_return(&self.bridge.submit_preimage, value) - .await - .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) - } - - fn lookup_preimage( - &self, - key: Vec, - ) -> BoxStream<'static, Result>, v01::GenericError>> { - invoke_js_subscription( - &self.bridge.lookup_preimage, - Some(key), - parse_preimage_lookup_item, - ) - .boxed() - } -} - // Account, signing, and statement-store flows live in the Rust core itself. // The JS bridge only carries callbacks for platform capabilities the core // cannot satisfy alone; account authority is selected by the runtime. @@ -492,27 +274,32 @@ async fn await_optional_promise(returned: JsValue) -> Result { } } -fn invoke_navigate_to( - bridge: &JsBridge, - url: &str, +fn call_js_function(fn_: &Function, args: &[JsValue]) -> Result { + let js_args = Array::new(); + for arg in args { + js_args.push(arg); + } + fn_.apply(&JsValue::NULL, &js_args).map_err(js_to_string) +} + +fn invoke_unit( + fn_: &Function, + args: Vec, ) -> impl Future> + Send { - let fn_ = bridge.navigate_to.clone(); - let url = url.to_string(); + let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = JsValue::from_str(&url); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; await_optional_promise(returned).await.map(|_| ()) }) } fn invoke_bool( fn_: &Function, - payload: Vec, + args: Vec, ) -> impl Future> + Send { let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = Uint8Array::from(payload.as_slice()); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; // A non-boolean resolved value is a host contract violation; surface it // rather than silently masking it as `false` (which would read as a @@ -523,23 +310,13 @@ fn invoke_bool( }) } -fn invoke_u32_unit(fn_: &Function, value: u32) -> impl Future> + Send { - let fn_ = fn_.clone(); - SendWrapper::new(async move { - let arg = JsValue::from_f64(f64::from(value)); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) -} - fn invoke_bytes_return( fn_: &Function, - value: Vec, + args: Vec, ) -> impl Future, String>> + Send { let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = Uint8Array::from(value.as_slice()); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; resolved .dyn_into::() @@ -548,197 +325,45 @@ fn invoke_bytes_return( }) } -fn parse_preimage_lookup_item(value: JsValue) -> Result>, String> { - if value.is_null() || value.is_undefined() { - return Ok(None); - } - value - .dyn_into::() - .map(|array| Some(array.to_vec())) - .map_err(|_| "preimage lookup item must be Uint8Array, null, or undefined".to_string()) -} - -fn parse_theme_item(value: JsValue) -> Result { - if let Some(theme) = value.as_string() { - return match theme.as_str() { - "Light" | "light" => Ok(v01::ThemeVariant::Light), - "Dark" | "dark" => Ok(v01::ThemeVariant::Dark), - _ => Err("theme item string must be Light or Dark".to_string()), - }; - } - if let Some(theme) = value.as_f64() { - return match theme as u8 { - 0 if theme == 0.0 => Ok(v01::ThemeVariant::Light), - 1 if theme == 1.0 => Ok(v01::ThemeVariant::Dark), - _ => Err("theme item number must be 0 or 1".to_string()), - }; - } - value - .dyn_into::() - .map_err(|_| "theme item must be Light, Dark, 0, 1, or encoded ThemeVariant".to_string()) - .and_then(|array| { - v01::ThemeVariant::decode(&mut array.to_vec().as_slice()) - .map_err(|_| "encoded ThemeVariant item did not decode".to_string()) - }) -} - -/// Plain JS object mirroring the generated `AuthState` TS tagged union: -/// `{ tag, value }` with `value` omitted for unit variants. -fn auth_state_to_js(state: &AuthState) -> JsValue { - let object = js_sys::Object::new(); - let set = |key: &str, value: &JsValue| { - let _ = Reflect::set(&object, &JsValue::from_str(key), value); - }; - match state { - AuthState::Disconnected => { - set("tag", &JsValue::from_str("Disconnected")); - } - AuthState::Pairing { deeplink } => { - set("tag", &JsValue::from_str("Pairing")); - let value = js_sys::Object::new(); - let _ = Reflect::set( - &value, - &JsValue::from_str("deeplink"), - &JsValue::from_str(deeplink), - ); - set("value", &value.into()); - } - AuthState::Connected(info) => { - set("tag", &JsValue::from_str("Connected")); - set("value", &session_ui_info_to_js(info)); - } - AuthState::LoginFailed { reason } => { - set("tag", &JsValue::from_str("LoginFailed")); - let value = js_sys::Object::new(); - let _ = Reflect::set( - &value, - &JsValue::from_str("reason"), - &JsValue::from_str(reason), - ); - set("value", &value.into()); - } - } - object.into() -} - -/// Plain JS object mirroring the generated `SessionUiInfo` TS interface. -fn session_ui_info_to_js(info: &SessionUiInfo) -> JsValue { - let object = js_sys::Object::new(); - let set = |key: &str, value: &JsValue| { - let _ = Reflect::set(&object, &JsValue::from_str(key), value); - }; - set("publicKey", &Uint8Array::from(info.public_key.as_slice())); - if let Some(identity_account_id) = &info.identity_account_id { - set( - "identityAccountId", - &Uint8Array::from(identity_account_id.as_slice()), - ); - } - if let Some(lite_username) = &info.lite_username { - set("liteUsername", &JsValue::from_str(lite_username)); - } - if let Some(full_username) = &info.full_username { - set("fullUsername", &JsValue::from_str(full_username)); - } - object.into() -} - -fn invoke_core_storage_read( - bridge: &JsBridge, - key: CoreStorageKey, +fn invoke_optional_bytes_return( + fn_: &Function, + args: Vec, + expected: &'static str, ) -> impl Future>, String>> + Send { - let fn_ = bridge.core_storage_read.clone(); + let fn_ = fn_.clone(); SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; if resolved.is_null() || resolved.is_undefined() { return Ok(None); } - let array = resolved.dyn_into::().map_err(|_| { - "readCoreStorage must resolve to Uint8Array, null or undefined".to_string() - })?; - Ok(Some(array.to_vec())) - }) -} - -fn invoke_core_storage_write( - bridge: &JsBridge, - key: CoreStorageKey, - value: Vec, -) -> impl Future> + Send { - let fn_ = bridge.core_storage_write.clone(); - SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let value_arg = Uint8Array::from(value.as_slice()); - let returned = fn_ - .call2(&JsValue::NULL, &key_arg, &value_arg) - .map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) + resolved + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| expected.to_string()) }) } -fn invoke_core_storage_clear( - bridge: &JsBridge, - key: CoreStorageKey, -) -> impl Future> + Send { - let fn_ = bridge.core_storage_clear.clone(); - SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn decode_bytes(bytes: Vec, message: &str) -> Result { + T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } -fn invoke_local_storage_read( - bridge: &JsBridge, - key: &str, -) -> impl Future>, String>> + Send { - let fn_ = bridge.local_storage_read.clone(); - let key = key.to_string(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - let resolved = await_optional_promise(returned).await?; - if resolved.is_null() || resolved.is_undefined() { - return Ok(None); - } - let array = resolved - .dyn_into::() - .map_err(|_| "read must resolve to Uint8Array, null or undefined".to_string())?; - Ok(Some(array.to_vec())) - }) -} - -fn invoke_local_storage_write( - bridge: &JsBridge, - key: &str, - value: &[u8], -) -> impl Future> + Send { - let fn_ = bridge.local_storage_write.clone(); - let key = key.to_string(); - let value = value.to_vec(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let value_arg = Uint8Array::from(value.as_slice()); - let returned = fn_ - .call2(&JsValue::NULL, &key_arg, &value_arg) - .map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn decode_js_item(value: JsValue, label: &str) -> Result { + let bytes = value + .dyn_into::() + .map_err(|_| format!("{label} item must be Uint8Array"))? + .to_vec(); + decode_bytes(bytes, &format!("encoded {label} item did not decode")) } -fn invoke_local_storage_clear( - bridge: &JsBridge, - key: &str, -) -> impl Future> + Send { - let fn_ = bridge.local_storage_clear.clone(); - let key = key.to_string(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn parse_optional_bytes_item(value: JsValue) -> Result>, String> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| "optional bytes item must be Uint8Array, null, or undefined".to_string()) } fn js_to_string(value: JsValue) -> String { diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 86c17f78..da696fd6 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -11,6 +11,7 @@ # --platform-input target/doc/truapi_platform.json # --platform-ts-output js/packages/truapi-host-wasm/src/generated # --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated +# --platform-rust-output rust/crates/truapi-server/src/wasm # --codec-version 1 # # The client surface defaults to the latest wire version any versioned @@ -34,6 +35,7 @@ cargo run -p truapi-codegen -- \ --platform-input target/doc/truapi_platform.json \ --platform-ts-output js/packages/truapi-host-wasm/src/generated \ --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated \ + --platform-rust-output rust/crates/truapi-server/src/wasm \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 @@ -70,3 +72,4 @@ echo "Generated playground metadata at js/packages/truapi/src/playground/codegen echo "Generated client examples at playground/test/generated/examples/" echo "Generated Rust dispatcher at rust/crates/truapi-server/src/generated/" echo "Generated host-callbacks WASM adapter at js/packages/truapi-host-wasm/src/generated/" +echo "Generated Rust WASM bridge at rust/crates/truapi-server/src/wasm/generated_bridge.rs" From a2538742709b9db562033f0fbc0d0281a18a24a6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 09:06:25 +0200 Subject: [PATCH 2/3] fix(codegen): satisfy wasm bridge clippy --- rust/crates/truapi-codegen/src/rust/wasm_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 88c05435..d4c18d72 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -365,7 +365,7 @@ fn emit_stream_method( "invoke_js_subscription", &method.name, &payload, - &[parser.to_string()], + std::slice::from_ref(&parser), ) }; Ok(format!( From 95ce2e9bded890595ab72f70a647a325ec7b1a97 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 7 Jul 2026 10:09:12 +0200 Subject: [PATCH 3/3] fixup! feat(codegen): generate wasm bridge callbacks --- .../tests/golden/host-callbacks.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4c060456..f86b2a36 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,21 @@ import type { ThemeVariant, } from "@parity/truapi"; +/** + * Review shown before a product asks to access another product account. + */ +export interface AccountAccessReview { + /** + * Product currently handling the request. + */ + requestingProductId: string; + + /** + * Product whose account is being requested. + */ + targetProductId: string; +} + /** * Review shown before a product asks to alias another product account. */ @@ -234,7 +249,16 @@ export type UserConfirmationReview = /** * Submit a preimage to the host-selected backend. */ - | { tag: "PreimageSubmit"; value: PreimageSubmitReview }; + | { tag: "PreimageSubmit"; value: PreimageSubmitReview } + /** + * Allow a product to access another product account. + */ + | { tag: "AccountAccess"; value: AccountAccessReview }; + +/** + * Review shown before a product asks to access another product account. + */ +export const AccountAccessReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** * Review shown before a product asks to alias another product account. @@ -305,7 +329,7 @@ export const SignRawReview: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview})); /** * Host auth UI driven by core-owned `AuthState` transitions.