UNPKG

@foxglove/ws-protocol-examples

Version:

Foxglove WebSocket protocol examples

512 lines (511 loc) 22.4 kB
import createDebug from "debug"; import EventEmitter from "eventemitter3"; import { StatusLevel } from "."; import { parseClientMessage } from "./parse"; import { BinaryOpcode, ClientBinaryOpcode, FetchAssetStatus, ServerCapability, } from "./types"; const log = createDebug("foxglove:server"); const textEncoder = new TextEncoder(); const REQUIRED_CAPABILITY_BY_OPERATION = { subscribe: undefined, unsubscribe: undefined, advertise: ServerCapability.clientPublish, unadvertise: ServerCapability.clientPublish, [ClientBinaryOpcode.MESSAGE_DATA]: ServerCapability.clientPublish, getParameters: ServerCapability.parameters, setParameters: ServerCapability.parameters, subscribeParameterUpdates: ServerCapability.parametersSubscribe, unsubscribeParameterUpdates: ServerCapability.parametersSubscribe, [ClientBinaryOpcode.SERVICE_CALL_REQUEST]: ServerCapability.services, subscribeConnectionGraph: ServerCapability.connectionGraph, unsubscribeConnectionGraph: ServerCapability.connectionGraph, fetchAsset: ServerCapability.assets, }; export default class FoxgloveServer { static SUPPORTED_SUBPROTOCOL = "foxglove.websocket.v1"; name; capabilities; supportedEncodings; metadata; sessionId; #emitter = new EventEmitter(); #clients = new Map(); #nextChannelId = 0; #channels = new Map(); #nextServiceId = 0; #services = new Map(); constructor({ name, capabilities, supportedEncodings, metadata, sessionId, }) { this.name = name; this.capabilities = capabilities ?? []; this.supportedEncodings = supportedEncodings; this.metadata = metadata; this.sessionId = sessionId ?? new Date().toUTCString(); } on(name, listener) { this.#emitter.on(name, listener); } off(name, listener) { this.#emitter.off(name, listener); } /** * Select a sub-protocol to communicate with a new client. * @param protocols sub-protocols offered by the client in the connection header */ handleProtocols(protocols) { for (const protocol of protocols) { if (protocol === FoxgloveServer.SUPPORTED_SUBPROTOCOL) { return protocol; } } return false; } /** * Advertise a new channel and inform any connected clients. * @returns The id of the new channel */ addChannel(channel) { const newId = ++this.#nextChannelId; const newChannel = { ...channel, id: newId }; this.#channels.set(newId, newChannel); for (const client of this.#clients.values()) { this.#send(client.connection, { op: "advertise", channels: [newChannel] }); } return newId; } /** * Remove a previously advertised channel and inform any connected clients. */ removeChannel(channelId) { if (!this.#channels.delete(channelId)) { throw new Error(`Channel ${channelId} does not exist`); } for (const client of this.#clients.values()) { const subId = client.subscriptionsByChannel.get(channelId); if (subId != undefined) { client.subscriptions.delete(subId); client.subscriptionsByChannel.delete(channelId); } this.#send(client.connection, { op: "unadvertise", channelIds: [channelId] }); } } /** * Advertise a new service and inform any connected clients. * @returns The id of the new service */ addService(service) { // eslint-disable-next-line @typescript-eslint/no-deprecated if (service.request == undefined && service.requestSchema == undefined) { throw new Error("Either 'request' or 'requestSchema' has to be given."); } // eslint-disable-next-line @typescript-eslint/no-deprecated if (service.response == undefined && service.responseSchema == undefined) { throw new Error("Either 'response' or 'responseSchema' has to be given."); } const newId = ++this.#nextServiceId; const newService = { ...service, id: newId }; this.#services.set(newId, newService); for (const client of this.#clients.values()) { this.#send(client.connection, { op: "advertiseServices", services: [newService] }); } return newId; } /** * Remove a previously advertised service and inform any connected clients. */ removeService(serviceId) { if (!this.#services.delete(serviceId)) { throw new Error(`Service ${serviceId} does not exist`); } for (const client of this.#clients.values()) { this.#send(client.connection, { op: "unadvertiseServices", serviceIds: [serviceId] }); } } /** * Emit a message payload to any clients subscribed to `chanId`. */ sendMessage(chanId, timestamp, payload) { for (const client of this.#clients.values()) { const subId = client.subscriptionsByChannel.get(chanId); if (subId == undefined) { continue; } this.#sendMessageData(client.connection, subId, timestamp, payload); } } /** * Emit a time update to clients. */ broadcastTime(timestamp) { if (!this.capabilities.includes(ServerCapability.time)) { log("Sending time data is only supported if the server has declared the '%s' capability.", ServerCapability.time); return; } for (const client of this.#clients.values()) { this.#sendTimeData(client.connection, timestamp); } } /** * Send a service call response to the client * @param response Response to send to the client * @param connection Connection of the client that called the service */ sendServiceCallResponse(response, connection) { const encoding = textEncoder.encode(response.encoding); const payload = new Uint8Array(1 + 4 + 4 + 4 + encoding.length + response.data.byteLength); const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); let offset = 0; view.setUint8(offset, BinaryOpcode.SERVICE_CALL_RESPONSE); offset += 1; view.setUint32(offset, response.serviceId, true); offset += 4; view.setUint32(offset, response.callId, true); offset += 4; view.setUint32(offset, response.encoding.length, true); offset += 4; payload.set(encoding, offset); offset += encoding.length; payload.set(new Uint8Array(response.data.buffer, response.data.byteOffset, response.data.byteLength), offset); connection.send(payload); } /** * Send a service call failure response to the client * @param response Response to send to the client * @param connection Connection of the client that called the service */ sendServiceCallFailure(response, connection) { this.#send(connection, response); } /** * Publish parameter values. * @param parameters Parameter values * @param id Optional request ID coming from a "getParameters" request * @param connection Optional connection when parameter values are to be sent to a single client */ publishParameterValues(parameters, id, connection) { if (!this.capabilities.includes(ServerCapability.parameters)) { log("Publishing parameter values is only supported if the server has declared the '%s' capability.", ServerCapability.parameters); return; } if (connection) { this.#send(connection, { op: "parameterValues", parameters, id }); } else { for (const client of this.#clients.values()) { this.#send(client.connection, { op: "parameterValues", parameters, id }); } } } /** * Inform clients about parameter value changes. * @param parameters Parameter values */ updateParameterValues(parameters) { if (!this.capabilities.includes(ServerCapability.parametersSubscribe)) { log("Publishing parameter value updates is only supported if the server has declared the '%s' capability.", ServerCapability.parametersSubscribe); return; } for (const client of this.#clients.values()) { const parametersOfInterest = parameters.filter((p) => client.parameterSubscriptions.has(p.name)); this.#send(client.connection, { op: "parameterValues", parameters: parametersOfInterest }); } } /** * Track a new client connection. * @param connection WebSocket used to communicate with the client * @param name Human-readable name for the client in log messages */ handleConnection(connection, name) { log("client %s connected", name); connection.binaryType = "arraybuffer"; const client = { name, connection, subscriptions: new Map(), subscriptionsByChannel: new Map(), advertisements: new Map(), parameterSubscriptions: new Set(), }; this.#clients.set(connection, client); this.#send(connection, { op: "serverInfo", name: this.name, capabilities: this.capabilities, supportedEncodings: this.supportedEncodings, metadata: this.metadata, sessionId: this.sessionId, }); if (this.#channels.size > 0) { this.#send(connection, { op: "advertise", channels: Array.from(this.#channels.values()) }); } if (this.#services.size > 0) { this.#send(connection, { op: "advertiseServices", services: Array.from(this.#services.values()), }); } connection.onclose = (event) => { log("client %s disconnected, code=%s reason=%s wasClean=%s", name, event.code, event.reason, event.wasClean); const potentialUnsubscribes = client.subscriptionsByChannel.keys(); this.#clients.delete(connection); for (const channelId of potentialUnsubscribes) { if (!this.#anySubscribed(channelId)) { this.#emitter.emit("unsubscribe", channelId); } } }; connection.onmessage = (event) => { let message; try { if (event.data instanceof ArrayBuffer || ArrayBuffer.isView(event.data)) { message = parseClientMessage(event.data); } else { message = JSON.parse(event.data); } this.#handleClientMessage(client, message); } catch (error) { this.#emitter.emit("error", error); return; } }; } #send(client, message) { client.send(JSON.stringify(message)); } #anySubscribed(chanId) { for (const client of this.#clients.values()) { if (client.subscriptionsByChannel.has(chanId)) { return true; } } return false; } #handleClientMessage(client, message) { const requiredCapability = REQUIRED_CAPABILITY_BY_OPERATION[message.op]; if (requiredCapability && !this.capabilities.includes(requiredCapability)) { log("Operation '%s' is not supported, as the server has not declared the capability '%s'.", message.op, requiredCapability); return; } switch (message.op) { case "subscribe": for (const { channelId, id: subId } of message.subscriptions) { if (client.subscriptions.has(subId)) { this.#send(client.connection, { op: "status", level: StatusLevel.ERROR, message: `Client subscription id ${subId} was already used; ignoring subscription`, }); continue; } if (client.subscriptionsByChannel.has(channelId)) { this.#send(client.connection, { op: "status", level: StatusLevel.WARNING, message: `Client already subscribed to channel ${channelId}; ignoring subscription`, }); continue; } const channel = this.#channels.get(channelId); if (!channel) { this.#send(client.connection, { op: "status", level: StatusLevel.WARNING, message: `Channel ${channelId} is not available; ignoring subscription",`, }); continue; } log("client %s subscribed to channel %d", client.name, channelId); const firstSubscription = !this.#anySubscribed(channelId); client.subscriptions.set(subId, channelId); client.subscriptionsByChannel.set(channelId, subId); if (firstSubscription) { this.#emitter.emit("subscribe", channelId); } } break; case "unsubscribe": for (const subId of message.subscriptionIds) { const chanId = client.subscriptions.get(subId); if (chanId == undefined) { this.#send(client.connection, { op: "status", level: StatusLevel.WARNING, message: `Client subscription id ${subId} did not exist; ignoring unsubscription`, }); continue; } log("client %s unsubscribed from channel %d", client.name, chanId); client.subscriptions.delete(subId); if (client.subscriptionsByChannel.has(chanId)) { client.subscriptionsByChannel.delete(chanId); } if (!this.#anySubscribed(chanId)) { this.#emitter.emit("unsubscribe", chanId); } } break; case "advertise": for (const channel of message.channels) { if (client.advertisements.has(channel.id)) { log("client %s tried to advertise channel %d, but it was already advertised", client.name, channel.id); this.#send(client.connection, { op: "status", level: StatusLevel.ERROR, message: `Channel id ${channel.id} was already advertised; ignoring advertisement`, }); continue; } client.advertisements.set(channel.id, channel); this.#emitter.emit("advertise", { client, ...channel }); } break; case "unadvertise": for (const channelId of message.channelIds) { if (client.advertisements.has(channelId)) { client.advertisements.delete(channelId); this.#emitter.emit("unadvertise", { client, channelId }); } else { log("client %s unadvertised unknown channel %d", client.name, channelId); } } break; case "getParameters": this.#emitter.emit("getParameters", { ...message }, client.connection); break; case "setParameters": this.#emitter.emit("setParameters", { ...message }, client.connection); break; case "subscribeParameterUpdates": { const alreadySubscribedParameters = Array.from(this.#clients.values()).reduce((acc, c) => new Set([...acc, ...c.parameterSubscriptions]), new Set()); const parametersToSubscribe = message.parameterNames.filter((p) => !alreadySubscribedParameters.has(p)); message.parameterNames.forEach((p) => client.parameterSubscriptions.add(p)); if (parametersToSubscribe.length > 0) { this.#emitter.emit("subscribeParameterUpdates", parametersToSubscribe); } } break; case "unsubscribeParameterUpdates": { message.parameterNames.forEach((p) => client.parameterSubscriptions.delete(p)); const subscribedParameters = Array.from(this.#clients.values()).reduce((acc, c) => new Set([...acc, ...c.parameterSubscriptions]), new Set()); const parametersToUnsubscribe = message.parameterNames.filter((p) => !subscribedParameters.has(p)); if (parametersToUnsubscribe.length > 0) { this.#emitter.emit("unsubscribeParameterUpdates", parametersToUnsubscribe); } } break; case "fetchAsset": this.#emitter.emit("fetchAsset", { ...message }, client.connection); break; case ClientBinaryOpcode.MESSAGE_DATA: { const channel = client.advertisements.get(message.channelId); if (!channel) { throw new Error(`Client sent message data for unknown channel ${message.channelId}`); } const data = message.data; this.#emitter.emit("message", { client, channel, data }); break; } case ClientBinaryOpcode.SERVICE_CALL_REQUEST: { const service = this.#services.get(message.serviceId); if (!service) { throw new Error(`Client sent service call request for unknown service ${message.serviceId}`); } this.#emitter.emit("serviceCallRequest", message, client.connection); break; } case "subscribeConnectionGraph": case "unsubscribeConnectionGraph": default: throw new Error(`Unrecognized client opcode: ${message.op}`); } } #sendMessageData(connection, subId, timestamp, payload) { const header = new DataView(new ArrayBuffer(1 + 4 + 8)); header.setUint8(0, BinaryOpcode.MESSAGE_DATA); header.setUint32(1, subId, true); header.setBigUint64(5, timestamp, true); // attempt to detect support for {fin: false} if (connection.send.length > 1) { connection.send(header.buffer, { fin: false }); connection.send(payload, { fin: true }); } else { const buffer = new Uint8Array(header.buffer.byteLength + payload.byteLength); buffer.set(new Uint8Array(header.buffer), 0); buffer.set(ArrayBuffer.isView(payload) ? new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) : new Uint8Array(payload), header.buffer.byteLength); connection.send(buffer); } } #sendTimeData(connection, timestamp) { const msg = new DataView(new ArrayBuffer(1 + 8)); msg.setUint8(0, BinaryOpcode.TIME); msg.setBigUint64(1, timestamp, true); connection.send(msg); } /** * Send a response to a fetchAsset request * @param response The response to send * @param connection Connection of the client that called the service */ sendFetchAssetResponse(response, connection) { const isSuccess = response.status === FetchAssetStatus.SUCCESS; const errorMsg = textEncoder.encode(isSuccess ? "" : response.error); const dataLength = isSuccess ? response.data.byteLength : 0; const msg = new Uint8Array(1 + 4 + 1 + 4 + errorMsg.length + dataLength); const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); let offset = 0; view.setUint8(offset, BinaryOpcode.FETCH_ASSET_RESPONSE); offset += 1; view.setUint32(offset, response.requestId, true); offset += 4; view.setUint8(offset, response.status); offset += 1; view.setUint32(offset, errorMsg.length, true); offset += 4; msg.set(errorMsg, offset); offset += errorMsg.length; if (isSuccess) { msg.set(new Uint8Array(response.data.buffer, response.data.byteOffset, response.data.byteLength), offset); } connection.send(msg); } /** * Send a status message to one or all clients. * * @param status Status message * @param connection Optional connection. If undefined, the status message will be sent to all clients. */ sendStatus(status, connection) { if (connection) { // Send the status to a single client. this.#send(connection, { op: "status", ...status }); return; } // Send status message to all clients. for (const client of this.#clients.values()) { this.sendStatus(status, client.connection); } } /** * Remove status message(s) for one or for all clients. * @param statusIds Status ids to be removed. * @param connection Optional connection. If undefined, the status will be removed for all clients. */ removeStatus(statusIds, connection) { if (connection) { // Remove status for a single client. this.#send(connection, { op: "removeStatus", statusIds }); return; } // Remove status for all clients. for (const client of this.#clients.values()) { this.#send(client.connection, { op: "removeStatus", statusIds }); } } } //# sourceMappingURL=FoxgloveServer.js.map