Skip to main content

Parent-owned live transport

Parent-owned live transport is available to organizations on plans that support embedding, including Pro, Enterprise, Enterprise trial, Enterprise complimentary, Academic, and legacy Team plans. Layout authoring and other developer features remain subject to the user's seat permissions.

Secure context required

Foxglove must run in a secure context. Serve your application over HTTPS or from localhost.

This guide describes how a host application can keep the robot connection in the parent page while running Foxglove in an embedded iframe.

Host application API

Define a ParentTransportFactory, then pass it as the live source's transport:

import { FoxgloveViewer } from "@foxglove/embed";
import type { ParentTransportFactory } from "@foxglove/embed";

const parentTransportFactory: ParentTransportFactory = (args) => {
// Create one connection attempt here. See the examples below.
throw new Error(`Implement the transport for ${args.url}`);
};

const viewer = new FoxgloveViewer({
parent: document.getElementById("foxglove")!,
orgSlug: undefined,
});

viewer.setDataSource({
type: "live",
protocol: "foxglove-websocket",
url: "virtual://my-robot",
transport: parentTransportFactory,
});

The React wrapper accepts the factory on its data prop:

import { FoxgloveViewer } from "@foxglove/embed-react";

<FoxgloveViewer
data={{
type: "live",
protocol: "foxglove-websocket",
url: "virtual://my-robot",
transport: parentTransportFactory,
}}
/>;

Memoize the data source and factory or define them at module scope so React doesn't reconnect to the source on every render.

url is passed to the factory. It can be a real ws:// URL or merely a stable display label for a virtual source. Foxglove does not open this URL when transport is a factory.

The SDK extends its existing iframe handshake and set-data-source command with a transferred MessagePort. The host should not send its own window.postMessage messages. Existing embed commands, sources without a factory transport, origin validation, and iframe behavior are unchanged.

The factory is called once per connection attempt. It receives:

  • url and Foxglove's supported protocols.
  • onOpen(protocol?) when the connection is ready.
  • onMessage(frame) for server-to-Foxglove text or binary protocol frames.
  • onError(error) for an error.
  • onClose(details?) when the attempt ends.

It returns:

  • send(frame) for Foxglove-to-server frames such as subscribe, publish, and service requests.
  • close() to stop the connection attempt and release its resources.

All frames at this boundary use the Foxglove WebSocket wire protocol. An upstream robot connection may use any protocol as long as the parent translates it at this boundary.

Iframe compatibility

viewer.getCapabilities().parentOwnedLiveTransport reports whether the current iframe can use this feature:

  • pending while the iframe is loading or waiting for authentication.
  • available after the iframe advertises support and the authenticated organization is eligible.
  • unavailable when the iframe does not advertise the feature or the organization is ineligible.

The SDK emits a capabilities event when the status resolves. A host can wait for that event and choose between parent-owned and iframe-owned connections:

const parentOwnedSource = {
type: "live" as const,
protocol: "foxglove-websocket" as const,
url: "wss://robot.example.com",
transport: parentTransportFactory,
};
const iframeOwnedSource = {
type: "live" as const,
protocol: "foxglove-websocket" as const,
url: "wss://robot.example.com",
};

function getParentOwnedLiveTransportStatus() {
try {
return viewer.getCapabilities().parentOwnedLiveTransport;
} catch {
// A host that loads an older SDK runtime has no capability API.
return "unavailable" as const;
}
}

function selectSupportedTransport() {
const status = getParentOwnedLiveTransportStatus();
if (status === "pending") {
return;
}
viewer.setDataSource(status === "available" ? parentOwnedSource : iframeOwnedSource);
}

viewer.addEventListener("capabilities", selectSupportedTransport, { once: true });
selectSupportedTransport();

Capability checks are backward compatible. A viewer that doesn't advertise parent-owned live transport reports the capability as unavailable, allowing the host to select its fallback data source. The try/catch above additionally supports a page that loads an SDK version where getCapabilities() itself doesn't exist.

If a parent-owned source is supplied while the capability is not available, the SDK emits an error event and does not transfer its channel.

The React wrapper exposes the same status through its onCapabilities prop and the getCapabilities() method on its imperative ref.

Example: proxy a real WebSocket from the parent

import type { ParentTransportFactory } from "@foxglove/embed";

export const parentTransportFactory: ParentTransportFactory = ({
url,
protocols,
onOpen,
onMessage,
onError,
onClose,
}) => {
const socket = new WebSocket(url, [...protocols]);
socket.binaryType = "arraybuffer";

socket.onopen = () => onOpen(socket.protocol);
socket.onmessage = (event: MessageEvent<string | ArrayBuffer>) => onMessage(event.data);
socket.onerror = () => onError(new Error(`WebSocket connection failed: ${url}`));
socket.onclose = (event) => {
onClose({ code: event.code, reason: event.reason, wasClean: event.wasClean });
};

return {
send: (frame) => socket.send(frame),
close: () => socket.close(),
};
};

Because the WebSocket constructor executes in the host page, its origin, networking permissions, authentication environment, and browser context belong to the host rather than the Foxglove iframe.

Example: fully virtual JSON topic

This example uses no WebSocket. It advertises /virtual/temperature, watches Foxglove's subscribe commands, and generates one JSON message per second.

import type { ParentTransportFactory } from "@foxglove/embed";

const textEncoder = new TextEncoder();

function messageFrame(subscriptionId: number, value: unknown): ArrayBuffer {
const payload = textEncoder.encode(JSON.stringify(value));
const frame = new ArrayBuffer(13 + payload.byteLength);
const view = new DataView(frame);
view.setUint8(0, 1); // Foxglove server MESSAGE_DATA opcode
view.setUint32(1, subscriptionId, true);
view.setBigUint64(5, BigInt(Date.now()) * 1_000_000n, true);
new Uint8Array(frame, 13).set(payload);
return frame;
}

export const parentTransportFactory: ParentTransportFactory = ({
onOpen,
onMessage,
onError,
onClose,
}) => {
let subscriptionId: number | undefined;
let closed = false;

onOpen("foxglove.websocket.v1");
onMessage(
JSON.stringify({
op: "serverInfo",
name: "Virtual robot",
capabilities: [],
supportedEncodings: ["json"],
}),
);
onMessage(
JSON.stringify({
op: "advertise",
channels: [
{
id: 1,
topic: "/virtual/temperature",
encoding: "json",
schemaName: "virtual.Temperature",
schemaEncoding: "jsonschema",
schema: JSON.stringify({
type: "object",
properties: { celsius: { type: "number" } },
}),
},
],
}),
);

const timer = setInterval(() => {
if (subscriptionId != undefined) {
onMessage(messageFrame(subscriptionId, { celsius: 20 + Math.random() * 5 }));
}
}, 1_000);

return {
send(frame) {
if (typeof frame !== "string") {
return;
}
try {
const command = JSON.parse(frame) as {
op?: string;
subscriptions?: { id: number; channelId: number }[];
subscriptionIds?: number[];
};
if (command.op === "subscribe") {
subscriptionId = command.subscriptions?.find((item) => item.channelId === 1)?.id;
} else if (
command.op === "unsubscribe" &&
subscriptionId != undefined &&
command.subscriptionIds?.includes(subscriptionId) === true
) {
subscriptionId = undefined;
}
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)));
}
},
close() {
if (closed) {
return;
}
closed = true;
clearInterval(timer);
onClose({ code: 1000, reason: "Virtual source closed", wasClean: true });
},
};
};

The parent can replace the timer with messages from WebRTC, WebTransport, Electron IPC, a native bridge, a worker, generated simulation data, or any other source. It must still produce valid Foxglove protocol server frames and process Foxglove's client frames.

Lifecycle requirements

  • Return a new transport object on every factory invocation.
  • Do not reuse a connection object after calling onClose.
  • Release sockets, timers, listeners, and native resources in close().
  • Set a real WebSocket's binaryType to "arraybuffer"; Blob frames are not accepted.
  • Treat an ArrayBuffer passed to onMessage as transferred ownership. Do not reuse it afterward.
  • Call viewer.destroy() when removing the viewer. This closes the active parent transport.