@foxglove/ws-protocol
Version:
Foxglove WebSocket protocol
495 lines • 19.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const consumer_queue_1 = tslib_1.__importDefault(require("consumer-queue"));
const ws_1 = require("ws");
const _1 = require(".");
const FoxgloveServer_1 = tslib_1.__importDefault(require("./FoxgloveServer"));
function uint32LE(n) {
const result = new Uint8Array(4);
new DataView(result.buffer).setUint32(0, n, true);
return result;
}
function uint64LE(n) {
const result = new Uint8Array(8);
new DataView(result.buffer).setBigUint64(0, n, true);
return result;
}
async function setupServerAndClient(server) {
const wss = new ws_1.WebSocketServer({
port: 0,
handleProtocols: server.handleProtocols.bind(server),
});
wss.on("connection", (conn, req) => {
server.handleConnection(conn, `${req.socket.remoteAddress}:${req.socket.remotePort}`);
});
await new Promise((resolve) => wss.on("listening", resolve));
const msgQueue = new consumer_queue_1.default();
const ws = new ws_1.WebSocket(`ws://localhost:${wss.address().port}`);
ws.binaryType = "arraybuffer";
ws.onmessage = (event) => {
msgQueue.push(event.data);
};
const nextJsonMessage = async () => {
const msg = await msgQueue.pop();
if (typeof msg === "string") {
return JSON.parse(msg);
}
throw new Error("Expected string message");
};
const nextBinaryMessage = async () => {
const msg = await msgQueue.pop();
if (msg instanceof ArrayBuffer) {
return new Uint8Array(msg);
}
throw new Error(`Expected binary message, got: ${typeof msg}`);
};
const eventQueue = new consumer_queue_1.default();
server.on("subscribe", (chanId) => {
eventQueue.push(["subscribe", chanId]);
});
server.on("unsubscribe", (chanId) => {
eventQueue.push(["unsubscribe", chanId]);
});
server.on("error", (err) => {
eventQueue.push(["error", err]);
});
server.on("advertise", (event) => {
eventQueue.push(["advertise", event]);
});
server.on("unadvertise", (event) => {
eventQueue.push(["unadvertise", event]);
});
server.on("message", (event) => {
eventQueue.push(["message", event]);
});
server.on("getParameters", (event) => {
eventQueue.push(["getParameters", event]);
});
server.on("setParameters", (event) => {
eventQueue.push(["setParameters", event]);
});
server.on("subscribeParameterUpdates", (event) => {
eventQueue.push(["subscribeParameterUpdates", event]);
});
server.on("unsubscribeParameterUpdates", (event) => {
eventQueue.push(["unsubscribeParameterUpdates", event]);
});
server.on("serviceCallRequest", (event, clientConnection) => {
eventQueue.push(["serviceCallRequest", event, clientConnection]);
});
server.on("fetchAsset", (event, clientConnection) => {
eventQueue.push(["fetchAsset", event, clientConnection]);
});
const nextEvent = async () => await eventQueue.pop();
const send = (data) => {
ws.send(data);
};
const close = () => {
msgQueue.cancelWait(new Error("Server was closed"));
void msgQueue.pop().then((_msg) => {
throw new Error("Unexpected message on close");
});
eventQueue.cancelWait(new Error("Server was closed"));
void eventQueue.pop().then((event) => {
throw new Error(`Unexpected event on close: ${event[0]}`);
});
ws.close();
wss.close();
};
return { server, send, nextJsonMessage, nextBinaryMessage, nextEvent, close };
}
describe("FoxgloveServer", () => {
it("sends server info upon connection", async () => {
const server = new FoxgloveServer_1.default({ name: "foo" });
const { nextJsonMessage, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: [],
});
}
finally {
close();
}
});
it("sends server info and existing channels upon connection", async () => {
const server = new FoxgloveServer_1.default({ name: "foo" });
const chan = {
topic: "foo",
encoding: "bar",
schemaName: "Foo",
schema: "some data",
};
const id = server.addChannel(chan);
const { nextJsonMessage, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: [],
});
await expect(nextJsonMessage()).resolves.toEqual({
op: "advertise",
channels: [{ ...chan, id }],
});
}
finally {
close();
}
});
it("sends newly added channels to connected clients", async () => {
const server = new FoxgloveServer_1.default({ name: "foo" });
const { nextJsonMessage, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: [],
});
const chan = {
topic: "foo",
encoding: "bar",
schemaName: "Foo",
schema: "some data",
};
const id = server.addChannel(chan);
await expect(nextJsonMessage()).resolves.toEqual({
op: "advertise",
channels: [{ ...chan, id }],
});
}
finally {
close();
}
});
it("handles client subscribe/unsubscribe and forwards messages", async () => {
const server = new FoxgloveServer_1.default({ name: "foo" });
const chan = {
topic: "foo",
encoding: "bar",
schemaName: "Foo",
schema: "some data",
};
const chanId = server.addChannel(chan);
const { send, nextJsonMessage, nextBinaryMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: [],
});
await expect(nextJsonMessage()).resolves.toEqual({
op: "advertise",
channels: [{ ...chan, id: chanId }],
});
// message before subscribe is ignored
server.sendMessage(chanId, 42n, new Uint8Array([1, 2, 3]));
const subId = 1;
send(JSON.stringify({ op: "subscribe", subscriptions: [{ id: subId, channelId: chanId }] }));
await expect(nextEvent()).resolves.toEqual(["subscribe", chanId]);
server.sendMessage(chanId, 42n, new Uint8Array([1, 2, 3]));
await expect(nextBinaryMessage()).resolves.toEqual(new Uint8Array([_1.BinaryOpcode.MESSAGE_DATA, ...uint32LE(subId), ...uint64LE(42n), 1, 2, 3]));
send(JSON.stringify({ op: "unsubscribe", subscriptionIds: [subId] }));
await expect(nextEvent()).resolves.toEqual(["unsubscribe", chanId]);
// message after unsubscribe is ignored
server.sendMessage(chanId, 42n, new Uint8Array([1, 2, 3]));
}
finally {
close();
}
});
it("receives advertisements and messages from clients", async () => {
const server = new FoxgloveServer_1.default({
name: "foo",
capabilities: [_1.ServerCapability.clientPublish],
supportedEncodings: ["json"],
});
const { send, nextJsonMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["clientPublish"],
supportedEncodings: ["json"],
});
// client message, this will be ignored since it is not preceded by an "advertise"
const msg1 = new Uint8Array([1, 42, 0, 0, 0, 1, 2, 3]);
send(msg1);
// client advertisement
send(JSON.stringify({
op: "advertise",
channels: [{ id: 42, topic: "foo", encoding: "json", schemaName: "baz" }],
}));
// client message
const msg2 = new Uint8Array([1, 42, 0, 0, 0, 2, 3, 4]);
send(msg2);
// client unadvertisement
send(JSON.stringify({ op: "unadvertise", channelIds: [1, 42] }));
await expect(nextEvent()).resolves.toEqual([
"error",
new Error("Client sent message data for unknown channel 42"),
]);
await expect(nextEvent()).resolves.toMatchObject([
"advertise",
{ id: 42, topic: "foo", encoding: "json", schemaName: "baz" },
]);
const expectedPayload = new Uint8Array([2, 3, 4]);
const msgEvent = await nextEvent();
expect(msgEvent).toMatchObject([
"message",
{
channel: { id: 42, topic: "foo", encoding: "json", schemaName: "baz" },
data: expectedPayload,
},
]);
await expect(nextEvent()).resolves.toMatchObject(["unadvertise", { channelId: 42 }]);
}
catch (ex) {
close();
throw ex;
}
close();
});
it("sends time messages to clients", async () => {
const server = new FoxgloveServer_1.default({ name: "foo", capabilities: [_1.ServerCapability.time] });
const { nextJsonMessage, nextBinaryMessage, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["time"],
});
server.broadcastTime(42n);
await expect(nextBinaryMessage()).resolves.toEqual(new Uint8Array([_1.BinaryOpcode.TIME, ...uint64LE(42n)]));
}
finally {
close();
}
});
it("receives parameter set & get request from client", async () => {
const server = new FoxgloveServer_1.default({
name: "foo",
capabilities: [_1.ServerCapability.parameters],
});
const { send, nextJsonMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["parameters"],
});
let paramStore = [
{ name: "/foo/bool_param", value: true },
{ name: "/foo/int_param", value: 123 },
];
// client set parameter request
send(JSON.stringify({
op: "setParameters",
parameters: [
{ name: "/foo/bool_param", value: false },
{ name: "/foo/int_param", value: undefined },
],
}));
const setParameters = await nextEvent();
expect(setParameters).toMatchObject([
"setParameters",
{
parameters: [{ name: "/foo/bool_param", value: false }, { name: "/foo/int_param" }],
},
]);
const request = setParameters[1];
paramStore = paramStore
.map((p) => request.parameters.find((p2) => p2.name === p.name) ?? p)
.filter((p) => p.value != undefined);
// client get parameter request
send(JSON.stringify({
op: "getParameters",
parameterNames: [],
id: "req-456",
}));
const getParameters = await nextEvent();
expect(getParameters).toMatchObject(["getParameters", { parameterNames: [], id: "req-456" }]);
const clientConnection = getParameters[2];
server.publishParameterValues(paramStore, "req-456", clientConnection);
await expect(nextJsonMessage()).resolves.toEqual({
op: "parameterValues",
parameters: [{ name: "/foo/bool_param", value: false }],
id: "req-456",
});
}
catch (ex) {
close();
throw ex;
}
close();
});
it("subscribes to parameter updates", async () => {
const server = new FoxgloveServer_1.default({
name: "foo",
capabilities: [_1.ServerCapability.parameters, _1.ServerCapability.parametersSubscribe],
});
const { send, nextJsonMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["parameters", "parametersSubscribe"],
});
// client subscribe parameter request
send(JSON.stringify({
op: "subscribeParameterUpdates",
parameterNames: ["/foo/bool_param"],
}));
await expect(nextEvent()).resolves.toMatchObject([
"subscribeParameterUpdates",
["/foo/bool_param"],
]);
// trigger parameter updates to be sent to clients
server.updateParameterValues([
{ name: "/foo/bool_param", value: false },
{ name: "/foo/int_param", value: 123 },
]);
// only expect the subscribed parameter to be communicated to the client
await expect(nextJsonMessage()).resolves.toEqual({
op: "parameterValues",
parameters: [{ name: "/foo/bool_param", value: false }],
});
}
catch (ex) {
close();
throw ex;
}
close();
});
it("receives service request from client and sends response back", async () => {
const server = new FoxgloveServer_1.default({
name: "foo",
capabilities: [_1.ServerCapability.services],
supportedEncodings: ["json"],
});
const { send, nextJsonMessage, nextBinaryMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["services"],
supportedEncodings: ["json"],
});
const service = {
name: "foo",
type: "bar",
requestSchema: "schema1",
responseSchema: "FooShcame",
};
const serviceId = server.addService(service);
await expect(nextJsonMessage()).resolves.toEqual({
op: "advertiseServices",
services: [{ ...service, id: serviceId }],
});
const request = {
serviceId,
callId: 123,
encoding: "json",
data: new Uint8Array([1, 2, 3]),
};
const serializedRequest = new Uint8Array([
_1.ClientBinaryOpcode.SERVICE_CALL_REQUEST,
...uint32LE(serviceId),
...uint32LE(request.callId),
...uint32LE(request.encoding.length),
...new TextEncoder().encode(request.encoding),
...new Uint8Array(request.data.buffer, request.data.byteOffset, request.data.byteLength),
]);
send(serializedRequest);
const [eventId, receivedRequest, connection] = await nextEvent();
expect(eventId).toEqual("serviceCallRequest");
expect(receivedRequest).toEqual({ op: _1.ClientBinaryOpcode.SERVICE_CALL_REQUEST, ...request });
const response = {
...request,
data: new Uint8Array([4, 5, 6]),
};
server.sendServiceCallResponse(response, connection);
await expect(nextBinaryMessage()).resolves.toEqual(new Uint8Array([
_1.BinaryOpcode.SERVICE_CALL_RESPONSE,
...uint32LE(response.serviceId),
...uint32LE(response.callId),
...uint32LE("json".length),
...new TextEncoder().encode("json"),
4,
5,
6,
]));
}
catch (ex) {
close();
throw ex;
}
close();
});
it.each([
[
"existing asset",
"package://foo/bar.urdf",
{
op: _1.BinaryOpcode.FETCH_ASSET_RESPONSE,
requestId: 123,
status: _1.FetchAssetStatus.SUCCESS,
data: new Uint8Array([4, 5, 6]),
},
],
[
"non existing asset",
"package://non-existing/bar.urdf",
{
op: _1.BinaryOpcode.FETCH_ASSET_RESPONSE,
requestId: 456,
status: _1.FetchAssetStatus.ERROR,
error: "asset not found",
},
],
])("should send fetch asset request and receive appropriate response for %s", async (timePrimitive, uri, response) => {
expect(timePrimitive).toEqual(timePrimitive);
expect(uri).toEqual(uri);
expect(response).toEqual(response);
const server = new FoxgloveServer_1.default({
name: "foo",
capabilities: [_1.ServerCapability.assets],
});
const { send, nextJsonMessage, nextBinaryMessage, nextEvent, close } = await setupServerAndClient(server);
try {
await expect(nextJsonMessage()).resolves.toMatchObject({
op: "serverInfo",
name: "foo",
capabilities: ["assets"],
});
const request = {
op: "fetchAsset",
uri,
requestId: response.requestId,
};
send(JSON.stringify(request));
const [eventId, receivedRequest, connection] = await nextEvent();
expect(eventId).toEqual("fetchAsset");
expect(receivedRequest).toEqual(request);
server.sendFetchAssetResponse(response, connection);
const errorMsg = response.status === _1.FetchAssetStatus.ERROR ? response.error : "";
const data = response.status === _1.FetchAssetStatus.SUCCESS ? response.data : new Uint8Array();
await expect(nextBinaryMessage()).resolves.toEqual(new Uint8Array([
_1.BinaryOpcode.FETCH_ASSET_RESPONSE,
...uint32LE(response.requestId),
response.status,
...uint32LE(errorMsg.length),
...new TextEncoder().encode(errorMsg),
...Buffer.from(data.buffer, data.byteOffset, data.byteLength),
]));
}
catch (ex) {
close();
throw ex;
}
close();
});
});
//# sourceMappingURL=FoxgloveServer.test.js.map