Skip to content
Merged
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
34 changes: 27 additions & 7 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@ WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compil
behind a Web Worker provider, plus per-environment integration entry points. It is the
counterpart to the native Android/iOS host shells.

`executionKind: "Chat"` is accepted by the runtime config but not served: the
Chat modality reaches products through a host-supplied `ChatPlatform` adapter,
which only the native (UniFFI) entrypoints can install. A JS host that opens a
`Chat` execution gets a provider whose Chat requests answer unsupported; its
subscriptions end empty, which is indistinguishable from a healthy close.
Tracked in paritytech/host-rust-core#383.

## Entry points

The package exposes tree-shakeable subpath exports — import only what your environment needs:
Expand Down Expand Up @@ -86,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
13 changes: 13 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,19 @@ 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 }),
registerChatBot: 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 @@ -724,6 +724,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
Loading