diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 44e0cd0f9..65475e24c 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -15,6 +15,26 @@ The package exposes tree-shakeable subpath exports — import only what your env | `@parity/truapi-host/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | | `@parity/truapi-host/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | +## Optional capabilities + +`HostCallbacks` groups are required except those listed on the Rust +`OptionalPlatform` super-trait, which are emitted as optional members. Omit one +and the core answers its product calls with `Unsupported`; supply it and the +whole group must be implemented: + +```ts +const callbacks: HostCallbacks = { + navigation, + notifications, + // ...required groups... + chat, // optional: leave it out and chat products get `Unsupported` +}; +``` + +Under `createWebWorkerPairingHostRuntime` the presence of each optional group is +reported to the worker in its `init` message, so the core sees the same +capability set on both sides of the boundary. + ## Generated WASM artefacts The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index b6674188b..f14300755 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { err, ok } from "neverthrow"; import { + HostChatCreateRoomRequest, + HostChatCreateRoomResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, @@ -371,6 +373,40 @@ describe("createWasmRawCallbacks", () => { disposePreimages?.(); }); + it("omits the chat callbacks when the host does not serve chat", () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + + expect(raw.createChatRoom).toBeUndefined(); + expect(raw.postChatMessage).toBeUndefined(); + expect(raw.subscribeChatRooms).toBeUndefined(); + }); + + it("adapts the chat callbacks when the host serves chat", async () => { + const seen: string[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + chat: { + createChatRoom: async (product, request) => { + seen.push(`${product.productId}:${request.roomId}`); + return { status: "Exists" }; + }, + }, + }), + ); + + const product = ProductContext.enc({ + productId: "chat.dot", + executionKind: "Chat", + }); + const request = HostChatCreateRoomRequest.enc({ roomId: "room" }); + const response = await raw.createChatRoom!(product, request); + + expect(seen).toEqual(["chat.dot:room"]); + expect(HostChatCreateRoomResponse.dec(response)).toEqual({ + status: "Exists", + }); + }); + it("adapts typed result subscriptions", async () => { async function* themes() { yield ok("Dark"); diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index c479cdc25..81d16da02 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -77,6 +77,18 @@ export function makeHostCallbacks( preimage: { ...defaults.preimage, ...overrides.preimage }, theme: { ...defaults.theme, ...overrides.theme }, chain: { ...defaults.chain, ...overrides.chain }, + // Chat is an optional capability: only fixtures that ask for it get the + // group, so the default fixture is a host that does not serve chat. + ...(overrides.chat + ? { + chat: { + createChatRoom: async () => ({ status: "New" as const }), + postChatMessage: async () => ({ messageId: "message" }), + async *subscribeChatRooms() {}, + ...overrides.chat, + }, + } + : {}), }; } diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 4aa1dd046..f2606e7dd 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -608,6 +608,7 @@ export function createWebWorkerPairingHostRuntime( kind: "init", logLevel: devLogLevelOverride ?? options.logLevel ?? "off", hostConfig: options.hostConfig, + capabilities: { chat: host.chat !== undefined }, } satisfies MainToWorker); } else if (msg.kind === "ready") { cleanupInit(); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 9ac00565e..3fe0c5452 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -204,6 +204,7 @@ describe("createWebWorkerPairingHostRuntime", () => { kind: "init", logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), + capabilities: { chat: false }, }); worker.emit({ kind: "ready" }); @@ -221,6 +222,23 @@ describe("createWebWorkerPairingHostRuntime", () => { provider.dispose(); }); + it("reports the chat capability to the worker when the host serves it", async () => { + const worker = new FakeWorker(); + void createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + chat: { createChatRoom: async () => ({ status: "New" }) }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + + worker.emit({ kind: "loaded" }); + + expect(lastMessageOfKind(worker, "init").capabilities).toEqual({ + chat: true, + }); + }); + it("creates multiple product cores on one worker runtime", async () => { const worker = new FakeWorker(); const config = runtimeConfig(); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index cdb3ea586..84d9a2d5c 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -29,6 +29,7 @@ // views into WASM memory) and frames are small, so the copy is the simpler // safe choice. +import type { OptionalCapabilities } from "./generated/worker-callbacks.js"; import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; import type { CallbackName, @@ -55,7 +56,17 @@ export type CallbackArgs = readonly unknown[]; * host callback/subscription/chain responses requested by the worker. */ export type MainToWorker = - | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { + kind: "init"; + logLevel: LogLevel; + hostConfig: unknown; + /** + * Optional capabilities the main-thread host serves. The worker proxies + * only these, so the core sees the same capability set on both sides of + * the boundary. + */ + capabilities: OptionalCapabilities; + } | { kind: "createCore"; coreId: number; product: unknown } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index c986b9a5c..3bee24f0d 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -13,6 +13,7 @@ import type { GenericError } from "@parity/truapi"; import { createWorkerRawCallbacks, type CallbackName, + type OptionalCapabilities, } from "./generated/worker-callbacks.js"; import { handleGetPermissionAuthorizationStatus, @@ -179,12 +180,15 @@ function chainConnect( } /** Build the host-level callback object passed to the WASM runtime. */ -function buildRawCallbacks() { - return createWorkerRawCallbacks({ - callbackRequest, - startSubscription, - chainConnect, - }); +function buildRawCallbacks(capabilities: OptionalCapabilities) { + return createWorkerRawCallbacks( + { + callbackRequest, + startSubscription, + chainConnect, + }, + capabilities, + ); } function buildCoreCallbacks(coreId: number) { @@ -233,7 +237,7 @@ ctx.addEventListener("message", (ev: MessageEvent) => { wasm.setLogLevel?.(msg.logLevel); try { runtime = new wasm.WasmPairingHostRuntime( - buildRawCallbacks(), + buildRawCallbacks(msg.capabilities), msg.hostConfig, ); postToMain({ kind: "ready" }); diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index 0f6a0a3a6..96233f78b 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -27,6 +27,9 @@ pub struct PlatformDefinition { pub types: Vec, /// Composite super-trait (`Platform: Storage + Navigation + ...`), if any. pub super_trait: Option, + /// Composite super-trait of capabilities a host may omit + /// (`OptionalPlatform: ChatPlatform + ...`), if any. + pub optional_super_trait: Option, } /// Single capability trait extracted from the platform crate. @@ -61,8 +64,12 @@ pub struct PlatformMethod { pub struct PlatformParam { /// Parameter name as written in the trait method signature. pub name: String, - /// Parameter type expressed as a [`TypeRef`]. + /// Parameter type expressed as a [`TypeRef`]. References are resolved to + /// the referent; [`Self::borrowed`] records that the signature borrowed it. pub type_ref: TypeRef, + /// Whether the trait method takes this parameter by reference. Rust + /// emitters must reproduce the `&`; TS has no equivalent and ignores it. + pub borrowed: bool, } /// Return shape after stripping async-trait `Pin>>` @@ -108,6 +115,7 @@ pub fn extract(krate: &Crate) -> Result { let mut traits = Vec::new(); let mut super_trait = None; + let mut optional_super_trait = None; for item_id in &trait_ids { let item = krate .index @@ -124,10 +132,15 @@ pub fn extract(krate: &Crate) -> Result { .with_context(|| format!("Trait `{name}` missing rustdoc trait body"))?; if is_super_trait(trait_inner) { - if super_trait.is_some() { - bail!("Multiple super-traits with method-less bodies found; only one is supported"); + let slot = if name == OPTIONAL_SUPER_TRAIT { + &mut optional_super_trait + } else { + &mut super_trait + }; + if slot.is_some() { + bail!("Multiple `{name}` super-traits found; only one is supported"); } - super_trait = Some(extract_super_trait(&name, item, trait_inner)?); + *slot = Some(extract_super_trait(&name, item, trait_inner)?); continue; } @@ -147,6 +160,7 @@ pub fn extract(krate: &Crate) -> Result { traits, types, super_trait, + optional_super_trait, }) } @@ -312,6 +326,10 @@ fn collect_local_trait_ids(krate: &Crate) -> BTreeSet { out } +/// Name of the method-less super-trait listing capabilities a host may omit. +/// Every other method-less super-trait composes the required surface. +pub(crate) const OPTIONAL_SUPER_TRAIT: &str = "OptionalPlatform"; + fn is_super_trait(trait_inner: &serde_json::Value) -> bool { let no_methods = trait_inner .get("items") @@ -439,6 +457,7 @@ fn extract_method(item: &Item, names: &NameContext) -> Result Vec<&PlatformTrait> { - let composed: BTreeSet = match &definition.super_trait { + let mut composed: BTreeSet = match &definition.super_trait { Some(s) => s.composes.iter().cloned().collect(), None => definition.traits.iter().map(|t| t.name.clone()).collect(), }; + composed.extend(optional_trait_names(definition)); definition .traits .iter() @@ -19,6 +20,17 @@ pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformT .collect() } +/// Capability trait names a host may omit, taken from the `OptionalPlatform` +/// super-trait. A host that supplies none of a trait's callbacks is not +/// broken: the core answers the matching product calls with `Unsupported`. +pub(crate) fn optional_trait_names(definition: &PlatformDefinition) -> BTreeSet { + definition + .optional_super_trait + .as_ref() + .map(|s| s.composes.iter().cloned().collect()) + .unwrap_or_default() +} + /// JS-side callback name for a platform method (camelCase of the Rust name). pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String { to_camel_case(&method.name) @@ -103,7 +115,7 @@ pub(crate) fn raw_callback_adapter_name( /// Callback-object namespace for a trait: its name with the role suffix /// (`Provider`, `Presenter`, `Host`) stripped, lower-cased first letter. pub(crate) fn callback_namespace(trait_name: &str) -> String { - let stem = ["Provider", "Presenter", "Host"] + let stem = ["Provider", "Presenter", "Host", "Platform"] .into_iter() .find_map(|suffix| trait_name.strip_suffix(suffix)) .unwrap_or(trait_name); diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index d4c18d72d..696a49528 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -6,8 +6,9 @@ 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, + callback_namespace, composed_traits, optional_trait_names, 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}; @@ -18,6 +19,7 @@ pub fn generate_wasm_bridge( let ctx = BridgeCtx::new(definition, api); let traits = composed_traits(definition); let trait_names = platform_trait_names(definition); + let optional_traits = optional_trait_names(definition); validate_errors(&traits, &ctx)?; let mut out = String::new(); writedoc!( @@ -37,22 +39,30 @@ pub fn generate_wasm_bridge( 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, + get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, + invoke_optional_bytes_return, invoke_unit, missing_callback, 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. + /// + /// Callbacks of an optional capability trait are replaced by a throwing + /// stub when the host omits the group. The core never reaches them: it + /// only holds an adapter for a capability whose `has_*` accessor is + /// true, and answers the rest with `Unsupported`. pub(super) struct JsBridge {{ "#, ) .unwrap(); - for field in bridge_fields(&traits, &trait_names) { + for field in bridge_fields(&traits, &trait_names, &optional_traits) { writeln!(out, " pub(super) {}: Function,", field.field_name).unwrap(); } + for namespace in optional_namespaces(&traits, &optional_traits) { + writeln!(out, " pub(super) {namespace}_present: bool,").unwrap(); + } out.push_str("}\n\n"); writedoc!( @@ -64,15 +74,52 @@ pub fn generate_wasm_bridge( "#, ) .unwrap(); - for field in bridge_fields(&traits, &trait_names) { - writeln!( + for field in bridge_fields(&traits, &trait_names, &optional_traits) { + if field.optional { + writeln!( + out, + " {}: get_optional_function(callbacks, \"{}\")?\n .unwrap_or_else(|| missing_callback(\"{}\")),", + field.field_name, field.raw_name, field.raw_name + ) + .unwrap(); + } else { + writeln!( + out, + " {}: get_function(callbacks, \"{}\")?,", + field.field_name, field.raw_name + ) + .unwrap(); + } + } + for namespace in optional_namespaces(&traits, &optional_traits) { + let probes = bridge_fields(&traits, &trait_names, &optional_traits) + .into_iter() + .filter(|field| field.namespace.as_deref() == Some(namespace.as_str())) + .map(|field| { + format!( + "get_optional_function(callbacks, \"{}\")?.is_some()", + field.raw_name + ) + }) + .collect::>() + .join("\n && "); + writeln!(out, " {namespace}_present: {probes},").unwrap(); + } + out.push_str(" })\n }\n"); + for namespace in optional_namespaces(&traits, &optional_traits) { + writedoc!( out, - " {}: get_function(callbacks, \"{}\")?,", - field.field_name, field.raw_name + r#" + + /// Whether the host supplied every `{namespace}` callback. + pub(super) fn has_{namespace}(&self) -> bool {{ + self.{namespace}_present + }} + "#, ) .unwrap(); } - out.push_str(" })\n }\n}\n\n"); + out.push_str("}\n\n"); let mut parse_fns = BTreeMap::new(); for trait_def in traits { @@ -160,18 +207,37 @@ impl<'a> BridgeCtx<'a> { struct BridgeField { field_name: String, raw_name: String, + optional: bool, + namespace: Option, +} + +/// Callback namespaces of the optional capability traits, in emission order. +fn optional_namespaces( + traits: &[&PlatformTrait], + optional_traits: &BTreeSet, +) -> Vec { + traits + .iter() + .filter(|trait_def| optional_traits.contains(&trait_def.name)) + .map(|trait_def| snake_case(&callback_namespace(&trait_def.name))) + .collect() } fn bridge_fields( traits: &[&PlatformTrait], platform_trait_names: &BTreeSet, + optional_traits: &BTreeSet, ) -> Vec { let mut fields = Vec::new(); for trait_def in traits { + let optional = optional_traits.contains(&trait_def.name); + let namespace = optional.then(|| snake_case(&callback_namespace(&trait_def.name))); 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), + optional, + namespace: namespace.clone(), }); } } @@ -397,8 +463,9 @@ fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { .params .iter() .map(|param| { + let reference = if param.borrowed { "&" } else { "" }; Ok(format!( - "{}: {}", + "{}: {reference}{}", param.name, rust_type(¶m.type_ref, ctx)? )) diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 4f449a366..50b89d7bb 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -19,9 +19,9 @@ 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, + callback_namespace, composed_traits, optional_trait_names, 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; @@ -149,14 +149,24 @@ fn emit_host_callbacks( // The Rust super-trait `Platform` becomes `HostCallbacks` on the TS // surface: that is the name every host implementer reaches for, and // it stays stable even if the Rust trait is renamed. - let (composes, docs): (Vec, Option<&str>) = match &definition.super_trait { + let optional_traits = optional_trait_names(definition); + let (mut composes, docs): (Vec, Option<&str>) = match &definition.super_trait { Some(s) => (s.composes.clone(), s.docs.as_deref()), None => ( definition.traits.iter().map(|t| t.name.clone()).collect(), None, ), }; - out.push_str(&emit_host_callback_composites(&composes, docs)); + for name in &optional_traits { + if !composes.contains(name) { + composes.push(name.clone()); + } + } + out.push_str(&emit_host_callback_composites( + &composes, + &optional_traits, + docs, + )); Ok(out) } @@ -267,11 +277,13 @@ fn emit_wasm_adapter( if !runtime_types.is_empty() || !support_imports.is_empty() { out.push('\n'); } + let optional_traits = optional_trait_names(definition); out.push_str(&emit_raw_callbacks( &traits, codec_types, local_codec_types, &trait_names, + &optional_traits, )); writedoc!( out, @@ -281,11 +293,23 @@ fn emit_wasm_adapter( export function createWasmRawCallbacks( callbacks: RequiredHostCallbacks, ): RawCallbacks {{ - return {{ "#, ) .unwrap(); + // Bind optional capabilities once so the adapter closures capture a + // narrowed reference rather than re-reading a possibly-absent member. + for name in &optional_traits { + let namespace = callback_namespace(name); + writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(); + } + out.push_str(" return {\n"); for trait_def in &traits { + let optional = optional_traits.contains(&trait_def.name); + let namespace = callback_namespace(&trait_def.name); + if optional { + writeln!(out, " ...({namespace}").unwrap(); + out.push_str(" ? {\n"); + } for method in &trait_def.methods { let entry = emit_adapter_entry( trait_def, @@ -293,8 +317,13 @@ fn emit_wasm_adapter( codec_types, local_codec_types, &trait_names, + optional, )?; - writeln!(out, " {entry}").unwrap(); + let indent = if optional { " " } else { " " }; + writeln!(out, "{indent}{entry}").unwrap(); + } + if optional { + out.push_str(" }\n : {}),\n"); } } out.push_str(" };\n}\n"); @@ -318,21 +347,41 @@ fn emit_worker_callbacks( let mut subscriptions = Vec::new(); let mut trait_object_callbacks = Vec::new(); let mut runtime_types = BTreeSet::new(); - + let mut stream_names: BTreeSet = BTreeSet::new(); + let mut all_names: Vec<&PlatformMethod> = Vec::new(); + + let optional_traits = optional_trait_names(definition); + // Optional capabilities are proxied only when the main thread reports the + // host serves them, so the core sees the same presence either side of the + // worker boundary. + let mut optional_groups: Vec<(String, Vec<&PlatformMethod>)> = Vec::new(); for trait_def in traits { + let optional = optional_traits.contains(&trait_def.name); + let mut group = Vec::new(); 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), + if matches!(method.return_shape.inner, PlatformInner::TraitObject(_)) { + unreachable!("trait-object callbacks are handled above") + } + let is_stream = matches!(method.return_shape.inner, PlatformInner::Stream(_)); + if optional { + group.push(method); + } else if is_stream { + subscriptions.push(method); + } else { + callbacks.push(method); } + if is_stream { + stream_names.insert(raw_callback_name(method)); + } + all_names.push(method); + } + if optional && !group.is_empty() { + optional_groups.push((callback_namespace(&trait_def.name), group)); } } @@ -358,9 +407,19 @@ fn emit_worker_callbacks( out.push('\n'); } - out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); + let all_callbacks: Vec<&PlatformMethod> = all_names + .iter() + .copied() + .filter(|method| !stream_names.contains(&raw_callback_name(method))) + .collect(); + let all_subscriptions: Vec<&PlatformMethod> = all_names + .iter() + .copied() + .filter(|method| stream_names.contains(&raw_callback_name(method))) + .collect(); + out.push_str(&const_name_array("CALLBACK_NAMES", &all_callbacks)); out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); - out.push_str(&const_name_array("SUBSCRIPTION_NAMES", &subscriptions)); + out.push_str(&const_name_array("SUBSCRIPTION_NAMES", &all_subscriptions)); out.push_str("export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number];\n\n"); out.push_str("export interface WorkerCallbackBridge {\n"); @@ -384,35 +443,96 @@ fn emit_worker_callbacks( } out.push_str("}\n\n"); + let required_names = callbacks + .iter() + .map(|method| ts_string_literal(&raw_callback_name(method))) + .collect::>() + .join(" | "); out.push_str(&emit_worker_callback_factory( "rawCallbacks", - "CallbackName", + &required_names, &callbacks, )?); out.push('\n'); - out.push_str(&emit_worker_subscription_factory(&subscriptions)?); + let required_subscription_names = subscriptions + .iter() + .map(|method| ts_string_literal(&raw_callback_name(method))) + .collect::>() + .join(" | "); + out.push_str(&emit_worker_subscription_factory( + &required_subscription_names, + &subscriptions, + )?); out.push('\n'); + for (namespace, methods) in &optional_groups { + let names = methods + .iter() + .map(|method| ts_string_literal(&raw_callback_name(method))) + .collect::>() + .join(" | "); + out.push_str(&emit_worker_optional_factory(namespace, &names, methods)?); + out.push('\n'); + } - writedoc!( - out, - r#" - export function createWorkerRawCallbacks( - bridge: WorkerCallbackBridge, - ): Record {{ - const callbacks: Record = {{ - ...rawCallbacks(bridge), - ...subscriptionRawCallbacks(bridge), - "# - ) - .unwrap(); + if optional_groups.is_empty() { + writedoc!( + out, + r#" + export function createWorkerRawCallbacks( + bridge: WorkerCallbackBridge, + ): Record {{ + const callbacks: Record = {{ + ...rawCallbacks(bridge), + ...subscriptionRawCallbacks(bridge), + "# + ) + .unwrap(); + } else { + let members = optional_groups + .iter() + .map(|(namespace, _)| { + format!( + " /** Whether the host serves this capability. */\n {namespace}?: boolean;" + ) + }) + .collect::>() + .join("\n"); + writedoc!( + out, + r#" + /// Optional capabilities the main-thread host actually serves. A + /// capability left out here is not proxied into the worker, so the + /// core answers its product calls with `Unsupported`. + export interface OptionalCapabilities {{ + {members} + }} + + export function createWorkerRawCallbacks( + bridge: WorkerCallbackBridge, + capabilities: OptionalCapabilities = {{}}, + ): Record {{ + const callbacks: Record = {{ + ...rawCallbacks(bridge), + ...subscriptionRawCallbacks(bridge), + "# + ) + .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(); } + out.push_str(" };\n"); + for (namespace, _) in &optional_groups { + writeln!( + out, + " if (capabilities.{namespace}) Object.assign(callbacks, {namespace}RawCallbacks(bridge));" + ) + .unwrap(); + } writedoc!( out, r#" - }}; return callbacks; }} @@ -426,7 +546,13 @@ fn emit_worker_callbacks( "# ) .unwrap(); - out.push_str(&emit_start_raw_subscription_switch(&subscriptions)?); + out.push_str(&emit_start_raw_subscription_switch( + &all_subscriptions, + &optional_groups + .iter() + .flat_map(|(_, methods)| methods.iter().map(|m| raw_callback_name(m))) + .collect(), + )?); writedoc!( out, r#" @@ -458,6 +584,33 @@ fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { format!("export const {const_name} = [\n{entries}\n] as const;\n") } +/// Emit the proxy factory for one optional capability. It covers both the +/// capability's one-shot callbacks and its subscriptions, so the whole group is +/// installed or omitted together. +fn emit_worker_optional_factory( + namespace: &str, + name_type: &str, + methods: &[&PlatformMethod], +) -> Result { + let mut out = String::new(); + writeln!( + out, + "function {namespace}RawCallbacks(bridge: WorkerCallbackBridge): Required> {{" + ) + .unwrap(); + out.push_str(" return {\n"); + for method in methods { + if matches!(method.return_shape.inner, PlatformInner::Stream(_)) { + out.push_str(&emit_worker_subscription_entry(method)?); + } else { + out.push_str(&emit_worker_callback_entry(method)?); + } + } + out.push_str(" };\n"); + out.push_str("}\n"); + Ok(out) +} + fn emit_worker_callback_factory( function_name: &str, name_type: &str, @@ -493,7 +646,7 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { }; if method.return_shape.is_async { Ok(format!( - " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType,\n" + " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType[\"{raw}\"]>,\n" )) } else { match &method.return_shape.inner { @@ -501,7 +654,7 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { " {raw}: ({args}) =>\n void bridge.callbackRequest(\"{raw}\", {arg_array}).catch(() => {{}}),\n" )), PlatformInner::Plain(_) => Ok(format!( - " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType,\n" + " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType[\"{raw}\"]>,\n" )), PlatformInner::Result { .. } | PlatformInner::Stream(_) @@ -512,42 +665,59 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { } } -fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result { +fn emit_worker_subscription_entry(method: &PlatformMethod) -> Result { + let raw = raw_callback_name(method); + Ok(match worker_subscription_payload_param(method)? { + Some(param) => format!( + " {raw}: ({param}, sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", {param}, sendItem, sendError),\n" + ), + None => format!( + " {raw}: (sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", null, sendItem, sendError),\n" + ), + }) +} + +fn emit_worker_subscription_factory( + name_type: &str, + methods: &[&PlatformMethod], +) -> Result { let mut out = String::new(); - out.push_str( - "function subscriptionRawCallbacks(bridge: WorkerCallbackBridge): Required> {\n", - ); + writeln!( + out, + "function subscriptionRawCallbacks(bridge: WorkerCallbackBridge): Required> {{" + ) + .unwrap(); out.push_str(" return {\n"); for method in methods { - let raw = raw_callback_name(method); - let payload_param = worker_subscription_payload_param(method)?; - if let Some(param) = payload_param { - out.push_str(&format!( - " {raw}: ({param}, sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", {param}, sendItem, sendError),\n" - )); - } else { - out.push_str(&format!( - " {raw}: (sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", null, sendItem, sendError),\n" - )); - } + out.push_str(&emit_worker_subscription_entry(method)?); } out.push_str(" };\n"); out.push_str("}\n"); Ok(out) } -fn emit_start_raw_subscription_switch(methods: &[&PlatformMethod]) -> Result { +fn emit_start_raw_subscription_switch( + methods: &[&PlatformMethod], + optional_names: &BTreeSet, +) -> Result { let mut out = String::new(); out.push_str(" switch (name) {\n"); for method in methods { let raw = raw_callback_name(method); + // An optional capability's subscription is absent when the host does + // not serve it, and the core never asks for it. + let call = if optional_names.contains(&raw) { + format!("callbacks.{raw}?.") + } else { + format!("callbacks.{raw}") + }; if worker_subscription_payload_param(method)?.is_some() { out.push_str(&format!( - " case \"{raw}\":\n if (payload === null) {{\n console.warn(`[truapi worker] ${{name}} requires payload`);\n return undefined;\n }}\n return callbacks.{raw}(payload, sendItem, sendError);\n" + " case \"{raw}\":\n if (payload === null) {{\n console.warn(`[truapi worker] ${{name}} requires payload`);\n return undefined;\n }}\n return {call}(payload, sendItem, sendError);\n" )); } else { out.push_str(&format!( - " case \"{raw}\":\n return callbacks.{raw}(sendItem, sendError);\n" + " case \"{raw}\":\n return {call}(sendItem, sendError);\n" )); } } @@ -601,19 +771,35 @@ fn emit_raw_callbacks( codec_types: &BTreeSet, local_codec_types: &BTreeSet, platform_trait_names: &BTreeSet, + optional_traits: &BTreeSet, ) -> String { let mut out = String::new(); + writedoc!( + out, + r#" + /// Byte-oriented callback surface the WASM core invokes. Members of an + /// optional capability are absent when the host omits the capability; + /// the core then answers the matching product calls with `Unsupported`. + "#, + ) + .unwrap(); out.push_str("export interface RawCallbacks {\n"); for trait_def in traits { + let optional = optional_traits.contains(&trait_def.name); for method in &trait_def.methods { out.push_str(" "); - out.push_str(&raw_member( + let member = raw_member( trait_def, method, codec_types, local_codec_types, platform_trait_names, - )); + ); + out.push_str(&if optional { + mark_member_optional(&member) + } else { + member + }); out.push('\n'); } } @@ -621,6 +807,15 @@ fn emit_raw_callbacks( out } +/// Turn a `RawCallbacks` member signature into an optional one, for both the +/// method (`name(...)`) and property (`name: T`) forms. +fn mark_member_optional(member: &str) -> String { + match member.find(['(', ':']) { + Some(idx) => format!("{}?{}", &member[..idx], &member[idx..]), + None => member.to_string(), + } +} + /// One `RawCallbacks` member signature for `method`. fn raw_member( trait_def: &PlatformTrait, @@ -827,11 +1022,18 @@ fn emit_adapter_entry( codec_types: &BTreeSet, local_codec_types: &BTreeSet, platform_trait_names: &BTreeSet, + optional: bool, ) -> Result { validate_adapter_codec_boundary(method, codec_types, local_codec_types)?; let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); - let host_method = format!("callbacks.{}.{}", callback_namespace(&trait_def.name), raw); + let namespace = callback_namespace(&trait_def.name); + // Optional capabilities are hoisted into a local binding by the caller. + let host_method = if optional { + format!("{namespace}.{raw}") + } else { + format!("callbacks.{namespace}.{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!( @@ -1449,24 +1651,45 @@ fn inline_object_type(fields: &[FieldDef]) -> Result { Ok(format!("{{ {body} }}")) } -fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> String { +fn emit_host_callback_composites( + composes: &[String], + optional_traits: &BTreeSet, + docs: Option<&str>, +) -> String { let jsdoc = render_jsdoc("", docs); if composes.is_empty() { return format!( "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" ); } + // A host may leave out an optional capability entirely; the core then + // answers the matching product calls with `Unsupported`. + let mark = |trait_name: &String| { + if optional_traits.contains(trait_name) { + "?" + } else { + "" + } + }; let host_members = composes .iter() - .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) + .map(|trait_name| { + format!( + " {}{}: {};", + callback_namespace(trait_name), + mark(trait_name), + trait_name + ) + }) .collect::>() .join("\n"); let required_members = composes .iter() .map(|trait_name| { format!( - " {}: Required<{}>;", + " {}{}: Required<{}>;", callback_namespace(trait_name), + mark(trait_name), trait_name ) }) @@ -1667,6 +1890,7 @@ mod tests { docs: None, }], super_trait: None, + optional_super_trait: None, }; let local_codec_types = ["SessionUiInfo".to_string()].into_iter().collect(); @@ -1690,6 +1914,7 @@ mod tests { }], types: Vec::new(), super_trait: None, + optional_super_trait: None, } } @@ -1700,6 +1925,7 @@ mod tests { params: vec![PlatformParam { name: "request".to_string(), type_ref: named("HostFeatureSupportedRequest"), + borrowed: false, }], return_shape: PlatformReturn { is_async: true, @@ -1719,6 +1945,7 @@ mod tests { params: vec![PlatformParam { name: "request".to_string(), type_ref: param, + borrowed: false, }], return_shape: PlatformReturn { is_async: true, 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 54b57927f..d35e81753 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -6,6 +6,11 @@ // primitives and byte blobs pass through unchanged. import { + HostChatCreateRoomRequest, + HostChatCreateRoomResponse, + HostChatListSubscribeItem, + HostChatPostMessageRequest, + HostChatPostMessageResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, @@ -21,6 +26,7 @@ import { AuthState, CoreStorageKey, HostChainSet, + ProductContext, UserConfirmationReview, } from "./host-callbacks.js"; import type { RequiredHostCallbacks } from "./host-callbacks.js"; @@ -28,9 +34,25 @@ import type { RequiredHostCallbacks } from "./host-callbacks.js"; import type { ChainConnect } from "../runtime.js"; import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; +/// Byte-oriented callback surface the WASM core invokes. Members of an +/// optional capability are absent when the host omits the capability; +/// the core then answers the matching product calls with `Unsupported`. export interface RawCallbacks { authStateChanged(state: Uint8Array): void; chainConnect: ChainConnect; + createChatRoom?( + product: Uint8Array, + request: Uint8Array, + ): Promise; + postChatMessage?( + product: Uint8Array, + request: Uint8Array, + ): Promise; + subscribeChatRooms?( + product: Uint8Array, + sendItem: (item?: Uint8Array) => void, + sendError: (error: GenericError) => void, + ): (() => void) | void; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -60,10 +82,35 @@ export interface RawCallbacks { export function createWasmRawCallbacks( callbacks: RequiredHostCallbacks, ): RawCallbacks { + const chat = callbacks.chat; return { authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), chainConnect: chainConnectAdapter(callbacks.chain), + ...(chat + ? { + createChatRoom: async (product, request) => + HostChatCreateRoomResponse.enc( + await chat.createChatRoom( + ProductContext.dec(product), + HostChatCreateRoomRequest.dec(request), + ), + ), + postChatMessage: async (product, request) => + HostChatPostMessageResponse.enc( + await chat.postChatMessage( + ProductContext.dec(product), + HostChatPostMessageRequest.dec(request), + ), + ), + subscribeChatRooms: (product, sendItem, sendError) => + driveResultStream( + chat.subscribeChatRooms(ProductContext.dec(product)), + (item) => sendItem(HostChatListSubscribeItem.enc(item)), + sendError, + ), + } + : {}), readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), writeCoreStorage: async (key, value) => diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index c52bfd9e2..7eddcb41b 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -777,7 +777,7 @@ export interface ChatPlatform { /** * Create or resolve a product-scoped native chat room. */ - createRoom( + createChatRoom( product: ProductContext, request: HostChatCreateRoomRequest, ): Promise; @@ -785,7 +785,7 @@ export interface ChatPlatform { /** * Persist a product-authored message in a native chat room. */ - postMessage( + postChatMessage( product: ProductContext, request: HostChatPostMessageRequest, ): Promise; @@ -793,9 +793,9 @@ export interface ChatPlatform { /** * Emit the current product-scoped room list and later replacements. */ - subscribeRooms( + subscribeChatRooms( product: ProductContext, - ): AsyncIterable; + ): AsyncIterable>; } /** @@ -1043,6 +1043,7 @@ export interface HostCallbacks { userConfirmation: UserConfirmation; theme: ThemeHost; preimage: PreimageHost; + chat?: ChatPlatform; } export interface RequiredHostCallbacks { @@ -1057,4 +1058,5 @@ export interface RequiredHostCallbacks { userConfirmation: Required; theme: Required; preimage: Required; + chat?: Required; } diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 322f3068e..0fb1164b8 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -12,17 +12,25 @@ 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, + get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, + invoke_optional_bytes_return, invoke_unit, missing_callback, 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. +/// +/// Callbacks of an optional capability trait are replaced by a throwing +/// stub when the host omits the group. The core never reaches them: it +/// only holds an adapter for a capability whose `has_*` accessor is +/// true, and answers the rest with `Unsupported`. pub(super) struct JsBridge { pub(super) auth_state_changed: Function, pub(super) chain_connect: Function, + pub(super) create_chat_room: Function, + pub(super) post_chat_message: Function, + pub(super) subscribe_chat_rooms: Function, pub(super) read_core_storage: Function, pub(super) write_core_storage: Function, pub(super) clear_core_storage: Function, @@ -39,6 +47,7 @@ pub(super) struct JsBridge { pub(super) clear: Function, pub(super) subscribe_theme: Function, pub(super) confirm_user_action: Function, + pub(super) chat_present: bool, } impl JsBridge { @@ -46,6 +55,12 @@ impl JsBridge { Ok(Self { auth_state_changed: get_function(callbacks, "authStateChanged")?, chain_connect: get_function(callbacks, "chainConnect")?, + create_chat_room: get_optional_function(callbacks, "createChatRoom")? + .unwrap_or_else(|| missing_callback("createChatRoom")), + post_chat_message: get_optional_function(callbacks, "postChatMessage")? + .unwrap_or_else(|| missing_callback("postChatMessage")), + subscribe_chat_rooms: get_optional_function(callbacks, "subscribeChatRooms")? + .unwrap_or_else(|| missing_callback("subscribeChatRooms")), read_core_storage: get_function(callbacks, "readCoreStorage")?, write_core_storage: get_function(callbacks, "writeCoreStorage")?, clear_core_storage: get_function(callbacks, "clearCoreStorage")?, @@ -62,8 +77,16 @@ impl JsBridge { clear: get_function(callbacks, "clear")?, subscribe_theme: get_function(callbacks, "subscribeTheme")?, confirm_user_action: get_function(callbacks, "confirmUserAction")?, + chat_present: get_optional_function(callbacks, "createChatRoom")?.is_some() + && get_optional_function(callbacks, "postChatMessage")?.is_some() + && get_optional_function(callbacks, "subscribeChatRooms")?.is_some(), }) } + + /// Whether the host supplied every `chat` callback. + pub(super) fn has_chat(&self) -> bool { + self.chat_present + } } impl truapi_platform::AuthPresenter for WasmPlatform { @@ -77,6 +100,62 @@ impl truapi_platform::AuthPresenter for WasmPlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::ChatPlatform for WasmPlatform { + async fn create_chat_room( + &self, + product: &truapi_platform::ProductContext, + request: v01::HostChatCreateRoomRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.create_chat_room, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason })?; + decode_bytes::( + bytes, + "createChatRoom response did not decode", + ) + .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason }) + } + + async fn post_chat_message( + &self, + product: &truapi_platform::ProductContext, + request: v01::HostChatPostMessageRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.post_chat_message, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason })?; + decode_bytes::( + bytes, + "postChatMessage response did not decode", + ) + .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason }) + } + + fn subscribe_chat_rooms( + &self, + product: &truapi_platform::ProductContext, + ) -> BoxStream<'static, Result> { + invoke_js_subscription( + &self.bridge.subscribe_chat_rooms, + Some(product.encode()), + parse_host_chat_list_subscribe_item_item, + ) + } +} + #[truapi_platform::async_trait] impl truapi_platform::CoreStorage for WasmPlatform { async fn read_core_storage( @@ -296,6 +375,12 @@ impl truapi_platform::UserConfirmation for WasmPlatform { } } +fn parse_host_chat_list_subscribe_item_item( + value: JsValue, +) -> Result { + decode_js_item::(value, "HostChatListSubscribeItem") +} + 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 baa79b3b4..ab285e073 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -12,6 +12,8 @@ import type { ChainConnect } from "../runtime.js"; export const CALLBACK_NAMES = [ "authStateChanged", + "createChatRoom", + "postChatMessage", "readCoreStorage", "writeCoreStorage", "clearCoreStorage", @@ -29,7 +31,11 @@ export const CALLBACK_NAMES = [ ] as const; export type CallbackName = (typeof CALLBACK_NAMES)[number]; -export const SUBSCRIPTION_NAMES = ["lookupPreimage", "subscribeTheme"] as const; +export const SUBSCRIPTION_NAMES = [ + "subscribeChatRooms", + "lookupPreimage", + "subscribeTheme", +] as const; export type SubscriptionName = (typeof SUBSCRIPTION_NAMES)[number]; export interface WorkerCallbackBridge { @@ -48,70 +54,91 @@ export interface WorkerCallbackBridge { function rawCallbacks( bridge: WorkerCallbackBridge, -): Required> { +): Required< + Pick< + RawCallbacks, + | "authStateChanged" + | "readCoreStorage" + | "writeCoreStorage" + | "clearCoreStorage" + | "featureSupported" + | "supportedChains" + | "navigateTo" + | "pushNotification" + | "cancelNotification" + | "devicePermission" + | "remotePermission" + | "read" + | "write" + | "clear" + | "confirmUserAction" + > +> { return { authStateChanged: (state) => void bridge.callbackRequest("authStateChanged", [state]).catch(() => {}), readCoreStorage: (key) => bridge.callbackRequest("readCoreStorage", [key]) as ReturnType< - RawCallbacks["readCoreStorage"] + Required["readCoreStorage"] >, writeCoreStorage: (key, value) => bridge.callbackRequest("writeCoreStorage", [key, value]) as ReturnType< - RawCallbacks["writeCoreStorage"] + Required["writeCoreStorage"] >, clearCoreStorage: (key) => bridge.callbackRequest("clearCoreStorage", [key]) as ReturnType< - RawCallbacks["clearCoreStorage"] + Required["clearCoreStorage"] >, featureSupported: (request) => bridge.callbackRequest("featureSupported", [request]) as ReturnType< - RawCallbacks["featureSupported"] + Required["featureSupported"] >, supportedChains: () => bridge.callbackRequest("supportedChains", []) as ReturnType< - RawCallbacks["supportedChains"] + Required["supportedChains"] >, navigateTo: (url) => bridge.callbackRequest("navigateTo", [url]) as ReturnType< - RawCallbacks["navigateTo"] + Required["navigateTo"] >, pushNotification: (notification) => bridge.callbackRequest("pushNotification", [notification]) as ReturnType< - RawCallbacks["pushNotification"] + Required["pushNotification"] >, cancelNotification: (id) => bridge.callbackRequest("cancelNotification", [id]) as ReturnType< - RawCallbacks["cancelNotification"] + Required["cancelNotification"] >, devicePermission: (request) => bridge.callbackRequest("devicePermission", [request]) as ReturnType< - RawCallbacks["devicePermission"] + Required["devicePermission"] >, remotePermission: (request) => bridge.callbackRequest("remotePermission", [request]) as ReturnType< - RawCallbacks["remotePermission"] + Required["remotePermission"] >, read: (key) => - bridge.callbackRequest("read", [key]) as ReturnType, + bridge.callbackRequest("read", [key]) as ReturnType< + Required["read"] + >, write: (key, value) => bridge.callbackRequest("write", [key, value]) as ReturnType< - RawCallbacks["write"] + Required["write"] >, clear: (key) => bridge.callbackRequest("clear", [key]) as ReturnType< - RawCallbacks["clear"] + Required["clear"] >, confirmUserAction: (review) => bridge.callbackRequest("confirmUserAction", [review]) as ReturnType< - RawCallbacks["confirmUserAction"] + Required["confirmUserAction"] >, }; } function subscriptionRawCallbacks( bridge: WorkerCallbackBridge, -): Required> { +): Required> { return { lookupPreimage: (key, sendItem, sendError) => bridge.startSubscription("lookupPreimage", key, sendItem, sendError), @@ -120,14 +147,53 @@ function subscriptionRawCallbacks( }; } +function chatRawCallbacks( + bridge: WorkerCallbackBridge, +): Required< + Pick< + RawCallbacks, + "createChatRoom" | "postChatMessage" | "subscribeChatRooms" + > +> { + return { + createChatRoom: (product, request) => + bridge.callbackRequest("createChatRoom", [ + product, + request, + ]) as ReturnType["createChatRoom"]>, + postChatMessage: (product, request) => + bridge.callbackRequest("postChatMessage", [ + product, + request, + ]) as ReturnType["postChatMessage"]>, + subscribeChatRooms: (product, sendItem, sendError) => + bridge.startSubscription( + "subscribeChatRooms", + product, + sendItem, + sendError, + ), + }; +} + +/// Optional capabilities the main-thread host actually serves. A +/// capability left out here is not proxied into the worker, so the +/// core answers its product calls with `Unsupported`. +export interface OptionalCapabilities { + /** Whether the host serves this capability. */ + chat?: boolean; +} + export function createWorkerRawCallbacks( bridge: WorkerCallbackBridge, + capabilities: OptionalCapabilities = {}, ): Record { const callbacks: Record = { ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), chainConnect: bridge.chainConnect, }; + if (capabilities.chat) Object.assign(callbacks, chatRawCallbacks(bridge)); return callbacks; } @@ -139,6 +205,12 @@ export function startRawSubscription( sendError: (error: GenericError) => void, ): (() => void) | void { switch (name) { + case "subscribeChatRooms": + if (payload === null) { + console.warn(`[truapi worker] ${name} requires payload`); + return undefined; + } + return callbacks.subscribeChatRooms?.(payload, sendItem, sendError); case "lookupPreimage": if (payload === null) { console.warn(`[truapi worker] ${name} requires payload`); diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index b4b0b0df1..429d30346 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -371,8 +371,12 @@ fn golden_host_callbacks_ts() { "SUBSCRIPTION_NAMES", )); for name in generated_names { + // Callbacks of an optional capability bind through the optional getter; + // either way the bridge must name every callback the worker proxies. assert!( - wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")), + wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")) + || wasm_bridge_actual + .contains(&format!("get_optional_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 168b74632..35e676c6d 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1250,24 +1250,24 @@ pub trait PreimageHost: Send + Sync { #[async_trait] pub trait ChatPlatform: Send + Sync { /// Create or resolve a product-scoped native chat room. - async fn create_room( + async fn create_chat_room( &self, product: &ProductContext, request: HostChatCreateRoomRequest, ) -> Result; /// Persist a product-authored message in a native chat room. - async fn post_message( + async fn post_chat_message( &self, product: &ProductContext, request: HostChatPostMessageRequest, ) -> Result; /// Emit the current product-scoped room list and later replacements. - fn subscribe_rooms( + fn subscribe_chat_rooms( &self, product: &ProductContext, - ) -> BoxStream<'static, HostChatListSubscribeItem>; + ) -> BoxStream<'static, Result>; } /// Combined platform interface. A host must provide all capability traits. @@ -1300,3 +1300,11 @@ impl Platform for T where + PreimageHost { } + +/// Capability traits a host may serve but is not required to. A host that +/// omits one is not broken: the core answers the corresponding product calls +/// with `Unsupported`. Codegen reads this list to emit each capability as an +/// optional group on the host-callback surface. +pub trait OptionalPlatform: ChatPlatform {} + +impl OptionalPlatform for T where T: ChatPlatform {} diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 94f2bc7ea..ace910f35 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -91,15 +91,32 @@ impl PairingHostRuntime { /// Build a long-lived pairing-host runtime around a platform implementation. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))] pub fn new

(platform: Arc

, config: PairingHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + Self::with_chat_platform(platform, config, spawner, None) + } + + /// Same as [`Self::new`], with the host's chat adapter installed. Passing + /// `None` leaves the host without the Chat capability, so its products' + /// chat calls resolve as `Unsupported`. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.with_chat_platform"))] + pub fn with_chat_platform

( + platform: Arc

, + config: PairingHostConfig, + spawner: Spawner, + chat_platform: Option>, + ) -> Self where P: Platform + 'static, { let platform: Arc = platform; - let services = RuntimeServices::new( + let services = RuntimeServices::with_chat_platform( platform, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner.clone(), + chat_platform, ); let pairing_host = PairingHostRole::new(services.clone(), config); pairing_host.clone().start_session_store_sync(spawner); @@ -576,7 +593,7 @@ impl ConnectionAdapters { pub(crate) fn from_services(services: &RuntimeServices) -> Self { Self { platform: services.platform.clone(), - chat_platform: None, + chat_platform: services.chat_platform.clone(), chat: Arc::new(ChatConnection::new()), } } @@ -770,7 +787,24 @@ impl ProductRuntime { where P: Platform + 'static, { - let pairing = PairingHostRuntime::new(platform, host_config, spawner); + Self::from_platform_with_chat_platform(platform, host_config, product, spawner, sink, None) + } + + /// Same as [`Self::from_platform_with_config`], with the host's chat + /// adapter installed. + pub fn from_platform_with_chat_platform

( + platform: Arc

, + host_config: PairingHostConfig, + product: ProductContext, + spawner: Spawner, + sink: Arc, + chat_platform: Option>, + ) -> Self + where + P: Platform + 'static, + { + let pairing = + PairingHostRuntime::with_chat_platform(platform, host_config, spawner, chat_platform); pairing.product_runtime(product, sink) } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 73955de00..cd9e2d7e9 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -1538,7 +1538,7 @@ struct ChatCallbackPlatform { #[async_trait] impl truapi_platform::ChatPlatform for ChatCallbackPlatform { - async fn create_room( + async fn create_chat_room( &self, _product: &ProductContext, request: v01::HostChatCreateRoomRequest, @@ -1559,7 +1559,7 @@ impl truapi_platform::ChatPlatform for ChatCallbackPlatform { Ok(v01::HostChatCreateRoomResponse { status }) } - async fn post_message( + async fn post_chat_message( &self, _product: &ProductContext, request: v01::HostChatPostMessageRequest, @@ -1584,14 +1584,14 @@ impl truapi_platform::ChatPlatform for ChatCallbackPlatform { Ok(v01::HostChatPostMessageResponse { message_id }) } - fn subscribe_rooms( + fn subscribe_chat_rooms( &self, _product: &ProductContext, - ) -> BoxStream<'static, v01::HostChatListSubscribeItem> { + ) -> BoxStream<'static, Result> { let current = v01::HostChatListSubscribeItem { rooms: self.chat.list_rooms().unwrap_or_default(), }; - self.events.subscribe_chat_rooms(current) + Box::pin(self.events.subscribe_chat_rooms(current).map(Ok)) } } @@ -2054,14 +2054,18 @@ mod tests { let product = ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) .unwrap(); - let mut stream = truapi_platform::ChatPlatform::subscribe_rooms(&platform, &product); + let mut stream = truapi_platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); - let first = futures::executor::block_on(stream.next()).unwrap(); + let first = futures::executor::block_on(stream.next()) + .unwrap() + .expect("initial room list"); events.notify_chat_rooms_changed(vec![v01::ChatRoom { room_id: "support".to_string(), participating_as: v01::ChatRoomParticipation::Bot, }]); - let second = futures::executor::block_on(stream.next()).unwrap(); + let second = futures::executor::block_on(stream.next()) + .unwrap() + .expect("replacement room list"); assert!(first.rooms.is_empty()); assert_eq!(second.rooms.len(), 1); @@ -2087,31 +2091,33 @@ mod tests { name: "Support".to_string(), icon: String::new(), }; - let mut rooms = truapi_platform::ChatPlatform::subscribe_rooms(&platform, &product); + let mut rooms = truapi_platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); assert!( futures::executor::block_on(rooms.next()) .expect("initial room list") + .expect("room list is not an error") .rooms .is_empty() ); - let created = futures::executor::block_on(truapi_platform::ChatPlatform::create_room( + let created = futures::executor::block_on(truapi_platform::ChatPlatform::create_chat_room( &platform, &product, request.clone(), )) .unwrap(); - let updated_rooms = - futures::executor::block_on(rooms.next()).expect("created room replacement"); + let updated_rooms = futures::executor::block_on(rooms.next()) + .expect("created room replacement") + .expect("room list is not an error"); *callbacks .chat_room_status .lock() .expect("room status mutex poisoned") = v01::ChatRoomRegistrationStatus::Exists; - let existing = futures::executor::block_on(truapi_platform::ChatPlatform::create_room( - &platform, &product, request, - )) + let existing = futures::executor::block_on( + truapi_platform::ChatPlatform::create_chat_room(&platform, &product, request), + ) .unwrap(); - let posted = futures::executor::block_on(truapi_platform::ChatPlatform::post_message( + let posted = futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( &platform, &product, v01::HostChatPostMessageRequest { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 8b983d619..eadb79cc5 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -2146,7 +2146,7 @@ impl Chat for ProductRuntimeHost { let platform = self.chat_platform()?; let HostChatCreateRoomRequest::V1(request) = request; platform - .create_room(&self.product, request) + .create_chat_room(&self.product, request) .await .map(HostChatCreateRoomResponse::V1) .map_err(|error| CallError::Domain(HostChatCreateRoomError::V1(error))) @@ -2159,8 +2159,8 @@ impl Chat for ProductRuntimeHost { }; Subscription::new(Box::pin( platform - .subscribe_rooms(&self.product) - .map(HostChatListSubscribeItem::V1), + .subscribe_chat_rooms(&self.product) + .filter_map(|item| async { item.ok().map(HostChatListSubscribeItem::V1) }), )) } @@ -2173,7 +2173,7 @@ impl Chat for ProductRuntimeHost { let platform = self.chat_platform()?; let HostChatPostMessageRequest::V1(request) = request; platform - .post_message(&self.product, request) + .post_chat_message(&self.product, request) .await .map(HostChatPostMessageResponse::V1) .map_err(|error| CallError::Domain(HostChatPostMessageError::V1(error))) diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7288c2582..c4c6db762 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -28,6 +28,9 @@ const STATEMENT_CACHE_MAX_ENTRIES: usize = 64; pub(crate) struct RuntimeServices { /// Host platform backing all syscalls. pub(crate) platform: Arc, + /// Host chat adapter, when the host serves the Chat capability. `None` + /// makes every product chat call resolve as `Unsupported`. + pub(crate) chat_platform: Option>, /// Shared chainHead-v1 runtime behind the Chain surface. pub(crate) chain: ChainRuntime, /// People-chain statement store RPC client. @@ -68,6 +71,7 @@ impl RuntimeServices { let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { platform, + chat_platform: None, chain, statement_store, bulletin, @@ -80,6 +84,29 @@ impl RuntimeServices { }) } + /// Same as [`Self::new`], with the host's chat adapter installed. + pub(crate) fn with_chat_platform( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], + spawner: Spawner, + chat_platform: Option>, + ) -> Arc { + let services = Self::new( + platform, + people_chain_genesis_hash, + bulletin_chain_genesis_hash, + spawner, + ); + let Some(chat_platform) = chat_platform else { + return services; + }; + let mut services = Arc::try_unwrap(services) + .unwrap_or_else(|_| unreachable!("services are not shared before this point")); + services.chat_platform = Some(chat_platform); + Arc::new(services) + } + /// Allocate the next per-product-runtime id, used to scope chain follow /// operation ids within the shared host runtime. pub(crate) fn next_core_instance(&self) -> u64 { diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 95da42029..90c128ee5 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -25,8 +25,8 @@ use truapi::v01; #[cfg(feature = "wasm-signing-host")] use truapi_platform::SigningHostConfig; use truapi_platform::{ - ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, - ProductExecutionKind, RuntimeConfigValidationError, + ChainProvider, ChatPlatform, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, + ProductContext, ProductExecutionKind, RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -430,6 +430,16 @@ fn noop_function() -> Function { Function::new_no_args("") } +/// Stand-in for a callback of an optional capability the host left out. The +/// core only holds an adapter for a capability the bridge reports as present, +/// so this is never invoked; it throws rather than returning a value the +/// decoder would misread. +fn missing_callback(name: &str) -> Function { + Function::new_no_args(&format!( + "throw new Error('host callback {name} is not implemented')" + )) +} + fn runtime_config_from_js(value: &JsValue) -> Result<(PairingHostConfig, ProductContext), JsValue> { let host_config = pairing_host_config_from_js(value)?; let product = product_context_from_js(value)?; @@ -738,13 +748,20 @@ impl WasmPairingHostRuntime { console_error_panic_hook::set_once(); crate::logging::init(); let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let has_chat = bridge.has_chat(); let platform = Arc::new(WasmPlatform::new(bridge)); + let chat_platform = has_chat.then(|| platform.clone() as Arc); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); }); let host_config = pairing_host_config_from_js(&host_config)?; Ok(Self { - runtime: Rc::new(PairingHostRuntime::new(platform, host_config, spawner)), + runtime: Rc::new(PairingHostRuntime::with_chat_platform( + platform, + host_config, + spawner, + chat_platform, + )), }) } @@ -1019,17 +1036,20 @@ impl WasmProductRuntime { let frame_sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); + let has_chat = bridge.has_chat(); let platform = Arc::new(WasmPlatform::new(bridge)); + let chat_platform = has_chat.then(|| platform.clone() as Arc); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); }); let (host_config, product) = runtime_config_from_js(&runtime_config)?; - let core = ProductRuntime::from_platform_with_config( + let core = ProductRuntime::from_platform_with_chat_platform( platform, host_config, product, spawner, frame_sink, + chat_platform, ); Ok(Self::from_parts(core, channel.dispose)) } diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs index 322f3068e..0fb1164b8 100644 --- a/rust/crates/truapi-server/src/wasm/generated_bridge.rs +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -12,17 +12,25 @@ 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, + get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, + invoke_optional_bytes_return, invoke_unit, missing_callback, 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. +/// +/// Callbacks of an optional capability trait are replaced by a throwing +/// stub when the host omits the group. The core never reaches them: it +/// only holds an adapter for a capability whose `has_*` accessor is +/// true, and answers the rest with `Unsupported`. pub(super) struct JsBridge { pub(super) auth_state_changed: Function, pub(super) chain_connect: Function, + pub(super) create_chat_room: Function, + pub(super) post_chat_message: Function, + pub(super) subscribe_chat_rooms: Function, pub(super) read_core_storage: Function, pub(super) write_core_storage: Function, pub(super) clear_core_storage: Function, @@ -39,6 +47,7 @@ pub(super) struct JsBridge { pub(super) clear: Function, pub(super) subscribe_theme: Function, pub(super) confirm_user_action: Function, + pub(super) chat_present: bool, } impl JsBridge { @@ -46,6 +55,12 @@ impl JsBridge { Ok(Self { auth_state_changed: get_function(callbacks, "authStateChanged")?, chain_connect: get_function(callbacks, "chainConnect")?, + create_chat_room: get_optional_function(callbacks, "createChatRoom")? + .unwrap_or_else(|| missing_callback("createChatRoom")), + post_chat_message: get_optional_function(callbacks, "postChatMessage")? + .unwrap_or_else(|| missing_callback("postChatMessage")), + subscribe_chat_rooms: get_optional_function(callbacks, "subscribeChatRooms")? + .unwrap_or_else(|| missing_callback("subscribeChatRooms")), read_core_storage: get_function(callbacks, "readCoreStorage")?, write_core_storage: get_function(callbacks, "writeCoreStorage")?, clear_core_storage: get_function(callbacks, "clearCoreStorage")?, @@ -62,8 +77,16 @@ impl JsBridge { clear: get_function(callbacks, "clear")?, subscribe_theme: get_function(callbacks, "subscribeTheme")?, confirm_user_action: get_function(callbacks, "confirmUserAction")?, + chat_present: get_optional_function(callbacks, "createChatRoom")?.is_some() + && get_optional_function(callbacks, "postChatMessage")?.is_some() + && get_optional_function(callbacks, "subscribeChatRooms")?.is_some(), }) } + + /// Whether the host supplied every `chat` callback. + pub(super) fn has_chat(&self) -> bool { + self.chat_present + } } impl truapi_platform::AuthPresenter for WasmPlatform { @@ -77,6 +100,62 @@ impl truapi_platform::AuthPresenter for WasmPlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::ChatPlatform for WasmPlatform { + async fn create_chat_room( + &self, + product: &truapi_platform::ProductContext, + request: v01::HostChatCreateRoomRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.create_chat_room, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason })?; + decode_bytes::( + bytes, + "createChatRoom response did not decode", + ) + .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason }) + } + + async fn post_chat_message( + &self, + product: &truapi_platform::ProductContext, + request: v01::HostChatPostMessageRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.post_chat_message, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason })?; + decode_bytes::( + bytes, + "postChatMessage response did not decode", + ) + .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason }) + } + + fn subscribe_chat_rooms( + &self, + product: &truapi_platform::ProductContext, + ) -> BoxStream<'static, Result> { + invoke_js_subscription( + &self.bridge.subscribe_chat_rooms, + Some(product.encode()), + parse_host_chat_list_subscribe_item_item, + ) + } +} + #[truapi_platform::async_trait] impl truapi_platform::CoreStorage for WasmPlatform { async fn read_core_storage( @@ -296,6 +375,12 @@ impl truapi_platform::UserConfirmation for WasmPlatform { } } +fn parse_host_chat_list_subscribe_item_item( + value: JsValue, +) -> Result { + decode_js_item::(value, "HostChatListSubscribeItem") +} + fn parse_theme_variant_item(value: JsValue) -> Result { decode_js_item::(value, "ThemeVariant") }