Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A JS host that serves chat can create rooms, post messages and list rooms, but can never deliver an incoming message. Nothing in a wasm build can publish a chat action: ChatConnection::publish_action is compiled out on wasm, and its only caller lives in the native module, which is also compiled out. So Chat/action_subscribe returns a subscription that yields nothing and never ends, which a product cannot tell apart from a quiet room. Could you say here that JS-host chat is outbound-only for now, and open a follow-up issue for the inbound path. Custom-message rendering is native-only too.

};
```

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.
Expand Down
36 changes: 36 additions & 0 deletions js/packages/truapi-host/src/host-callbacks-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test";
import { err, ok } from "neverthrow";

import {
HostChatCreateRoomRequest,
HostChatCreateRoomResponse,
HostDevicePermissionRequest,
HostDevicePermissionResponse,
HostFeatureSupportedRequest,
Expand Down Expand Up @@ -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<ThemeVariant>("Dark");
Expand Down
12 changes: 12 additions & 0 deletions js/packages/truapi-host/src/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
: {}),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions js/packages/truapi-host/src/web/worker-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ describe("createWebWorkerPairingHostRuntime", () => {
kind: "init",
logLevel: "debug",
hostConfig: hostConfigFromRuntimeConfig(config),
capabilities: { chat: false },
});

worker.emit({ kind: "ready" });
Expand All @@ -221,6 +222,23 @@ describe("createWebWorkerPairingHostRuntime", () => {
provider.dispose();
});

it("reports the chat capability to the worker when the host serves it", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the init message carrying chat: true, but nothing covers the worker acting on it. No test in the package references createWorkerRawCallbacks or startRawSubscription, so the gate that installs or omits chatRawCallbacks, and the new callbacks.subscribeChatRooms?.(...) path, are both untested. Could you add three cases: chat omitted when no capability is reported, chat proxied when it is, and startRawSubscription("subscribeChatRooms", ...) returning undefined when chat is absent.

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();
Expand Down
13 changes: 12 additions & 1 deletion js/packages/truapi-host/src/worker-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
Expand Down
18 changes: 11 additions & 7 deletions js/packages/truapi-host/src/worker-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { GenericError } from "@parity/truapi";
import {
createWorkerRawCallbacks,
type CallbackName,
type OptionalCapabilities,
} from "./generated/worker-callbacks.js";
import {
handleGetPermissionAuthorizationStatus,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -233,7 +237,7 @@ ctx.addEventListener("message", (ev: MessageEvent<MainToWorker>) => {
wasm.setLogLevel?.(msg.logLevel);
try {
runtime = new wasm.WasmPairingHostRuntime(
buildRawCallbacks(),
buildRawCallbacks(msg.capabilities),
msg.hostConfig,
);
postToMain({ kind: "ready" });
Expand Down
28 changes: 24 additions & 4 deletions rust/crates/truapi-codegen/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct PlatformDefinition {
pub types: Vec<TypeDef>,
/// Composite super-trait (`Platform: Storage + Navigation + ...`), if any.
pub super_trait: Option<PlatformSuperTrait>,
/// Composite super-trait of capabilities a host may omit
/// (`OptionalPlatform: ChatPlatform + ...`), if any.
pub optional_super_trait: Option<PlatformSuperTrait>,
}

/// Single capability trait extracted from the platform crate.
Expand Down Expand Up @@ -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<Box<dyn Future<Output = T>>>`
Expand Down Expand Up @@ -108,6 +115,7 @@ pub fn extract(krate: &Crate) -> Result<PlatformDefinition> {

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
Expand All @@ -124,10 +132,15 @@ pub fn extract(krate: &Crate) -> Result<PlatformDefinition> {
.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;
}

Expand All @@ -147,6 +160,7 @@ pub fn extract(krate: &Crate) -> Result<PlatformDefinition> {
traits,
types,
super_trait,
optional_super_trait,
})
}

Expand Down Expand Up @@ -312,6 +326,10 @@ fn collect_local_trait_ids(krate: &Crate) -> BTreeSet<String> {
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")
Expand Down Expand Up @@ -439,6 +457,7 @@ fn extract_method(item: &Item, names: &NameContext) -> Result<Option<PlatformMet
params.push(PlatformParam {
name: param_name,
type_ref,
borrowed: ty.get("borrowed_ref").is_some(),
});
}
}
Expand Down Expand Up @@ -766,6 +785,7 @@ mod tests {
name: "Shared".to_string(),
args: Vec::new(),
},
borrowed: false,
}],
return_shape: PlatformReturn {
is_async: false,
Expand Down
16 changes: 14 additions & 2 deletions rust/crates/truapi-codegen/src/platform_callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,29 @@ use crate::rustdoc::TypeRef;
/// Traits the platform surface actually composes: the super trait's
/// constituents when one exists, otherwise every collected trait.
pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> {
let composed: BTreeSet<String> = match &definition.super_trait {
let mut composed: BTreeSet<String> = 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()
.filter(|t| composed.contains(&t.name))
.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<String> {
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)
Expand Down Expand Up @@ -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);
Expand Down
Loading