@viamrobotics/post-message-sdk
v0.2.2
Published
Call Viam machine APIs from a sandboxed iframe through a RobotClient held by the parent window, over window.postMessage.
Readme
@viamrobotics/post-message-sdk
Call Viam machine APIs from a sandboxed iframe.
A custom control UI often runs in an iframe with sandbox="allow-scripts". That iframe has an opaque origin and cannot open its own connection to a machine. This SDK lets the page that holds a RobotClient serve the iframe. The iframe sends typed requests, the parent runs them on the machine, and the only transport is window.postMessage.
Install
pnpm add @viamrobotics/post-message-sdk @viamrobotics/sdk@viamrobotics/sdk is a peer dependency (>=0.76.0). The host needs it at runtime. The guest imports it for types only, so a guest bundle carries no SDK code.
Guest usage
The guest runs inside the iframe. Import from the package root.
import { connect, createClient, createRequest } from '@viamrobotics/post-message-sdk';
import { SensorClient } from '@viamrobotics/sdk';
await connect();
const sensor = createClient(SensorClient, 'my-sensor');
const readings = await createRequest(sensor, 'getReadings');connect() resolves once the host hands over its channel. It rejects if the host does not answer within timeoutMs (default 10 000 ms). ConnectOptions also has parent (default window.parent) and requestTimeoutMs (default 30 000 ms).
connect() resolves with a ConnectInfo. Its resourceName is the resource the host rendered this control for, when the host passed HostOptions.resourceName, and is also readable later through getConnectInfo().
const { resourceName } = await connect();
const sensor = createClient<SensorClient>(resourceName ?? 'my-sensor');Reporting size
Pass reportSize: true to connect and the guest posts its document height to the host. It posts once after the handshake and again whenever the document element resizes.
await connect({ reportSize: true });Call reportSize() to post the current height yourself, for example after a change that a resize observer misses. It does nothing until the host has connected.
import { reportSize } from '@viamrobotics/post-message-sdk';
reportSize();Creating a client handle
createClient returns a handle that names one resource. It has two forms. Both infer method and argument types from the SDK client.
import { createClient } from '@viamrobotics/post-message-sdk';
import { SensorClient } from '@viamrobotics/sdk';
const a = createClient(SensorClient, 'my-sensor');The second form takes the type only. Use import type so the SDK stays out of your bundle.
import { createClient } from '@viamrobotics/post-message-sdk';
import type { SensorClient } from '@viamrobotics/sdk';
const b = createClient<SensorClient>('my-sensor');Queries
createQuery polls a method into a store. The arguments are the method's argument tuple, then options. When the method takes no arguments, the options object goes second.
import { createQuery } from '@viamrobotics/post-message-sdk';
const query = createQuery(sensor, 'getReadings', { refetchInterval: 1000 });
const unsubscribe = query.subscribe(({ data, error, isLoading }) => {
console.log(data, error, isLoading);
});
await query.refetch();
query.destroy();
unsubscribe();QueryOptions has three fields.
| Field | Default | Meaning |
| ----------------------------- | ------- | ------------------------------------------------- |
| enabled | true | false pauses fetching and polling. |
| refetchInterval | none | Poll every N ms. false or omitted fetches once. |
| refetchIntervalInBackground | false | Keep polling while the document is hidden. |
The state is { data, error, isLoading, isFetching }. query.current reads it now. query.setOptions(options) replaces the options.
Mutations
createMutation wraps a method you call on demand.
import { createMutation } from '@viamrobotics/post-message-sdk';
import type { MotorClient } from '@viamrobotics/sdk';
const motor = createClient<MotorClient>('my-motor');
const stop = createMutation(motor, 'stop');
await stop.mutate();
stop.subscribe(({ isPending, error }) => {
console.log(isPending, error);
});mutate resolves with the result and rejects with the BridgeError. reset() clears data and error.
Streaming
createStream opens a server-streaming method, such as VideoClient.getVideo, and returns an async iterable. Items arrive as plain data, like any other result.
import { createClient, createStream } from '@viamrobotics/post-message-sdk';
import type { VideoClient } from '@viamrobotics/sdk';
const video = createClient<VideoClient>('my-video');
for await (const chunk of createStream(video, 'getVideo')) {
console.log(chunk.videoData.length);
}Leaving the loop early cancels the stream on the host. break, return, and a thrown error all do it. To stop from outside the loop, keep the stream and call cancel().
const stream = createStream(video, 'getVideo');
setTimeout(() => {
stream.cancel();
}, 5000);
for await (const chunk of stream) {
console.log(chunk.videoData.length);
}A failure on the machine rejects the loop with a BridgeError. If the host is destroyed mid-stream, the loop rejects with kind: 'disconnected'.
The guest buffers items the loop has not read yet. When the buffer reaches streamHighWaterMark, the guest tells the host to pause, and the host stops pulling from the machine. When the loop drains the buffer below half the mark, the guest tells the host to resume. A slow consumer therefore holds back the machine instead of growing memory. Set the mark in connect. The default is 64 items.
await connect({ streamHighWaterMark: 16 });createStreamQuery collects a stream into a store. The state is { data, error, isStreaming }, where data is every item so far.
import { createStreamQuery } from '@viamrobotics/post-message-sdk';
const query = createStreamQuery(video, 'getVideo', { refetchMode: 'reset' });
const unsubscribe = query.subscribe(({ data, error, isStreaming }) => {
console.log(data.length, error, isStreaming);
});
query.destroy();
unsubscribe();StreamQueryOptions has two fields.
| Field | Default | Meaning |
| ------------- | ---------- | ------------------------------------------------------------------------------------------------------- |
| enabled | true | false closes the stream. true opens it again. |
| refetchMode | 'append' | On reopen, append keeps old items, reset clears them first, replace clears at the first new item. |
When the method takes arguments, pass them as an array before the options: createStreamQuery(client, 'method', [arg], options).
Two types pick out the streaming methods of a client. StreamMethodKeys<TClient> is the union of method names that return an async iterable. StreamItemType<TMethod> is the item type of one method, such as StreamItemType<VideoClient['getVideo']>. createStream accepts only names in StreamMethodKeys, and createRequest accepts only unary method names.
Errors
Every failure is a BridgeError. Branch on its kind.
import { BridgeError, createRequest } from '@viamrobotics/post-message-sdk';
try {
await createRequest(sensor, 'getReadings');
} catch (error) {
if (error instanceof BridgeError && error.kind === 'timeout') {
console.warn('The machine did not answer in time.');
} else {
throw error;
}
}| kind | Meaning |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| remote | The machine call threw. code and remoteName carry details. |
| resource-not-found | The host has no resource with that name. |
| not-allowed | The host's allow callback rejected the request. |
| not-a-method | The named method does not exist on the client. |
| timeout | No reply within requestTimeoutMs. |
| disconnected | The host closed the channel. Pending requests reject with this. Queries fetch again once the host reconnects. |
| not-connected | A request ran before connect() finished. |
Svelte
The /svelte subpath wraps the guest primitives in Svelte 5 runes, with the same call shapes as the viam-svelte-sdk. It needs the optional svelte peer (>=5). The root entry stays framework-free.
Call useBridge once near the top of your app. It starts the handshake and exposes status, resourceName, and error reactively. Pass reportSize: true when the host should size the iframe to your content. When the host disconnects, status returns to 'connecting' until it hands over a new port, and every createResourceClient handle is rebuilt on reconnect so queries and streams start over.
<script lang="ts">
import { useBridge } from '@viamrobotics/post-message-sdk/svelte';
const bridge = useBridge({ reportSize: true });
</script>
{#if bridge.status === 'error'}
<p>{bridge.error?.message}</p>
{/if}createResourceClient returns a handle that is undefined until the bridge is connected and the resource has a name. Without a name thunk it uses the resourceName the host sent in the handshake (HostOptions.resourceName), so the control needs no configuration. Pass () => 'my-sensor' to name a resource yourself.
Every hook accepts args and options as a value or a thunk. A single trailing value is the args tuple when it is an array and the options otherwise. Queries run only while the client is defined and enabled is not false. A thunk that returns an args tuple needs a tuple type, for example (): [number] => [id].
<script lang="ts">
import type { SensorClient, ArmClient, VideoClient } from '@viamrobotics/sdk';
import {
createResourceClient,
createResourceQuery,
createResourceMutation,
createResourceStream,
} from '@viamrobotics/post-message-sdk/svelte';
const sensor = createResourceClient<SensorClient>();
const readings = createResourceQuery(sensor, 'getReadings', () => ({ refetchInterval: 1000 }));
const arm = createResourceClient<ArmClient>(() => 'arm-1');
const move = createResourceMutation(arm, 'moveToPosition');
const camera = createResourceClient<VideoClient>(() => 'cam-1');
const video = createResourceStream(camera, 'getVideo');
</script>
{#if readings.isSuccess}
<pre>{JSON.stringify(readings.data)}</pre>
{/if}
<button onclick={() => move.mutate([pose])} disabled={move.isPending}>Move</button>
<p>{video.data.length} chunks, streaming: {video.isStreaming}</p>createResourceQueryreturnsdata,error,isLoading,isFetching,isError,isSuccess, andrefetch.createResourceMutationreturnsdata,error,isPending,isError,isSuccess,mutate,mutateAsync, andreset.mutatetakes the method's arguments as one tuple. Before the bridge connects, a call rejects withkind: 'not-connected'.createResourceStreamreturnsdata(every item received),error,isStreaming, andisError. It closes whileenabledisfalseand reopens whenargschange.
Type-only client
createResourceClient<SensorClient>() takes the client type as a type parameter and needs no class value. Prefer it in a guest. The class form createResourceClient(SensorClient, () => name) matches the svelte-sdk, but it imports @viamrobotics/sdk at runtime and pulls the SDK into your artifact. With import type, the bundle carries none of it.
To ship a control inside a module, see docs/deploy.md.
Host usage
The host runs in the parent window, next to the RobotClient. Import from the /host subpath.
<iframe id="control-ui" sandbox="allow-scripts" src="https://example.com/control-ui/"></iframe>import { createHost } from '@viamrobotics/post-message-sdk/host';
const iframe = document.querySelector<HTMLIFrameElement>('#control-ui')!;
const host = createHost(robot, iframe);
// Later, when the page is done with the iframe:
host.destroy();allow-scripts is the only sandbox token required. The channel is a MessagePort that the host transfers to the iframe, so the guest needs no same-origin access, no network permission, and no shared storage. createHost throws if the iframe has no contentWindow. Call it after the iframe is in the document.
HostOptions
createHost(robot, iframe, options) takes an optional HostOptions.
| Field | Type | Meaning |
| -------------- | ------------------------------------ | -------------------------------------------------------------- |
| allow | (request: AllowRequest) => boolean | Return false to reject a request before its method runs. |
| clients | Record<string, ClientConstructor> | Extra client classes, merged over the built-in table. |
| onSize | (height: number) => void | Called with the guest's content height in CSS pixels. |
| resourceName | string | Sent to the guest in the handshake as the resource it renders. |
The default for allow is to allow everything. Use it to limit what a registry module can do.
const host = createHost(robot, iframe, {
allow: ({ resource, method }) => resource === 'my-sensor' && method === 'getReadings',
});AllowRequest is a type you can import from @viamrobotics/post-message-sdk/host. It is { resource, type, subtype, method }. resource is the name the guest passed to createClient. type and subtype come from the machine's resource list, such as component and sensor. A rejected request fails in the guest with kind: 'not-allowed'.
onSize fires only when the guest connected with reportSize: true. The host decides how to size the iframe. The SDK never changes the iframe's style.
const host = createHost(robot, iframe, {
onSize: (height) => {
iframe.style.height = `${height}px`;
},
});Serving a client type the table does not know
The built-in table covers the @viamrobotics/sdk clients built as new Client(robot, name). For anything else, pass a class under a key built by clientKey(type, subtype). The key is <type>/<subtype>, such as component/arm. Both halves matter because generic exists as a component and as a service.
import { clientKey, createHost } from '@viamrobotics/post-message-sdk/host';
const host = createHost(robot, iframe, {
clients: { [clientKey('component', 'my_custom')]: MyCustomClient },
});MyCustomClient must be constructible as new MyCustomClient(robot, name).
How it works
The guest and host agree on a short handshake, then talk over a private channel.
- The guest posts
readytowindow.parent. - The host creates a
MessageChanneland postsconnectto the iframe, transferring oneMessagePort. - The guest keeps the port.
connect()resolves. - Each call is a
requestwith a fresh ID. The host answers with aresponseor anerrorcarrying the same ID. - When the host is destroyed, it sends
disconnecton every channel it served and closes them. The guest rejects pending requests, notifiesonConnectionChangelisteners, and listens for a newconnect. On the nextconnectit notifies again and everycreateQueryfetches once more.
The host also posts connect once on its own, before any ready, so a guest that loaded first is served without waiting. A ready always gets a fresh channel, and the earlier channel keeps answering until the host is destroyed, so a guest that already attached one is never cut off by a later handshake. If the iframe reloads, it posts ready again. Handshake messages carry protocol: 'viam-post-message' and version: 1.
| Message | Direction | Transport | Purpose |
| --------------- | ------------- | ------------- | --------------------------------------------- |
| ready | guest to host | postMessage | The guest is loaded and wants a channel. |
| connect | host to guest | postMessage | Carries the transferred MessagePort. |
| request | guest to host | port | id, resource, method, args. |
| response | host to guest | port | id and result. |
| error | host to guest | port | id and a classified error. |
| disconnect | host to guest | port | The host is closing the port. |
| stream-start | guest to host | port | id, resource, method, args. |
| stream-item | host to guest | port | id and one item. |
| stream-end | host to guest | port | id. The stream finished. |
| stream-cancel | guest to host | port | id. Stop and run the iterator's return(). |
| stream-pause | guest to host | port | id. Stop pulling until resumed. |
| stream-resume | guest to host | port | id. Pull again. |
| size | guest to host | port | height. The guest's content height in px. |
Serialization
Results cross the channel by structured clone, so they arrive as plain data. A protobuf message keeps its fields and loses its methods. A Pose has x, y, z and the rest of its fields, but no toJson(). bytes fields stay Uint8Array. 64-bit integers stay bigint.
createRequest returns Jsonify<Awaited<ReturnType<...>>>, which models this. Arrays map element-wise, objects keep their data fields, and function members are dropped. Prefer the returned type over the SDK's class type when you handle a result.
Method arguments cross the same way, so they must be structured-clone-safe. Pass plain objects, arrays, strings, numbers, bigint, and Uint8Array. Do not pass functions or class instances that rely on methods.
Limitations
- One host per
RobotClient. The bridge is 1:1 by design. - Server-streaming methods work through
createStream. Client-streaming methods do not, such asAudioOutClient.playStream, because the guest cannot send a stream of arguments. - SDK clients without a
(robot, name)constructor are not served by default. Add them throughclients.
Development
pnpm install
pnpm run verifyverify runs format, build, lint, typecheck, tests, and the exports check. The build comes before lint because the typed lint of the test/sandboxed-sensor fixture resolves the SDK through the workspace link, whose types live in dist.
Releasing
Versions are managed with changesets. Every pull
request that changes @viamrobotics/post-message-sdk or @viamrobotics/create-control adds a changeset:
pnpm changesetOn merge to main, the release workflow opens or updates a "Version Packages" pull request that
applies the pending changesets to package.json and CHANGELOG.md. Merging that pull request
publishes the bumped packages to npm through trusted publishing. The private workspace packages
(examples and templates) are never versioned or published.
