UNPKG

@foxglove/ws-protocol

Version:

Foxglove WebSocket protocol

492 lines 19.5 kB
import ConsumerQueue from "consumer-queue"; import { WebSocket, WebSocketServer } from "ws"; import { BinaryOpcode, ClientBinaryOpcode, FetchAssetStatus, ServerCapability, } from "."; import FoxgloveServer from "./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 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 ConsumerQueue(); const ws = new 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 ConsumerQueue(); 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({ 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({ 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({ 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({ 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([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({ name: "foo", capabilities: [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({ name: "foo", capabilities: [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([BinaryOpcode.TIME, ...uint64LE(42n)])); } finally { close(); } }); it("receives parameter set & get request from client", async () => { const server = new FoxgloveServer({ name: "foo", capabilities: [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({ name: "foo", capabilities: [ServerCapability.parameters, 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({ name: "foo", capabilities: [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([ 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: 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([ 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: BinaryOpcode.FETCH_ASSET_RESPONSE, requestId: 123, status: FetchAssetStatus.SUCCESS, data: new Uint8Array([4, 5, 6]), }, ], [ "non existing asset", "package://non-existing/bar.urdf", { op: BinaryOpcode.FETCH_ASSET_RESPONSE, requestId: 456, status: 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({ name: "foo", capabilities: [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 === FetchAssetStatus.ERROR ? response.error : ""; const data = response.status === FetchAssetStatus.SUCCESS ? response.data : new Uint8Array(); await expect(nextBinaryMessage()).resolves.toEqual(new Uint8Array([ 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