Skip to content
Open
27 changes: 27 additions & 0 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ this ships the full 1.4 MB `.wasm` where about 600 kB (gzip) or 470 kB (brotli)
would do — and a server configured with `gzip_static` but no dynamic `gzip on`
has no fallback.

## 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`
Comment thread
filvecchiato marked this conversation as resolved.
};
```

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.

`chat` is **outbound-only** from a JS host today: a host can create rooms, post
messages and serve the room list, but cannot deliver an incoming message.
Publishing a chat action is native-only, so `Chat/action_subscribe` yields a
subscription that never emits, which a product cannot tell apart from a quiet
room. Custom-message rendering is native-only for the same reason. The inbound
path is tracked in [#422](https://github.com/paritytech/host-rust-core/issues/422).

## 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 @@ -389,6 +391,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<HostThemeSubscribeItemValue>(namedTheme("midnight", "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 @@ -672,6 +672,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 @@ -215,6 +215,7 @@ describe("createWebWorkerPairingHostRuntime", () => {
kind: "init",
logLevel: "debug",
hostConfig: hostConfigFromRuntimeConfig(config),
capabilities: { chat: false },
});

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

it("reports the chat capability to the worker when the host serves it", async () => {
Comment thread
filvecchiato marked this conversation as resolved.
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
98 changes: 98 additions & 0 deletions js/packages/truapi-host/src/worker-callbacks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "bun:test";

import {
createWorkerRawCallbacks,
startRawSubscription,
} from "./generated/worker-callbacks.js";
import type { RawCallbacks } from "./generated/host-callbacks-adapter.js";

// The worker proxies an optional capability only when the main thread reports
// the host serves it, so the core sees the same capability set on both sides of
// the boundary. Without that gate a worker host would always look chat-capable
// and the core would route chat calls at a host that cannot answer them.

function stubBridge() {
const requests: { name: string; args: readonly unknown[] }[] = [];
const subscriptions: { name: string; payload: Uint8Array | null }[] = [];
return {
requests,
subscriptions,
bridge: {
callbackRequest: async (name: string, args: readonly unknown[]) => {
requests.push({ name, args });
return new Uint8Array();
},
startSubscription: (name: string, payload: Uint8Array | null) => {
subscriptions.push({ name, payload });
return () => {};
},
chainConnect: async () => null,
},
};
}

describe("worker raw callbacks", () => {
it("omits the chat proxies when no chat capability is reported", () => {
const { bridge } = stubBridge();

const callbacks = createWorkerRawCallbacks(
bridge as unknown as Parameters<typeof createWorkerRawCallbacks>[0],
);

expect(callbacks.createChatRoom).toBeUndefined();
expect(callbacks.postChatMessage).toBeUndefined();
expect(callbacks.subscribeChatRooms).toBeUndefined();
expect(callbacks.subscribeTheme).toBeDefined();
});

it("proxies chat through the bridge when the capability is reported", async () => {
const { bridge, requests, subscriptions } = stubBridge();

const callbacks = createWorkerRawCallbacks(
bridge as unknown as Parameters<typeof createWorkerRawCallbacks>[0],
{ chat: true },
);

const product = new Uint8Array([1]);
await (
callbacks.createChatRoom as (
product: Uint8Array,
request: Uint8Array,
) => Promise<unknown>
)(product, new Uint8Array([2]));
(
callbacks.subscribeChatRooms as (
product: Uint8Array,
sendItem: () => void,
sendError: () => void,
) => void
)(
product,
() => {},
() => {},
);

expect(requests.map((r) => r.name)).toContain("createChatRoom");
expect(subscriptions).toEqual([
{ name: "subscribeChatRooms", payload: product },
]);
});

it("starts no chat room subscription when chat is absent", () => {
const { bridge, subscriptions } = stubBridge();
const callbacks = createWorkerRawCallbacks(
bridge as unknown as Parameters<typeof createWorkerRawCallbacks>[0],
) as RawCallbacks;

const stop = startRawSubscription(
callbacks,
"subscribeChatRooms",
new Uint8Array([1]),
() => {},
() => {},
);

expect(stop).toBeUndefined();
expect(subscriptions).toEqual([]);
});
});
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 @@ -151,12 +152,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 @@ -205,7 +209,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
Loading