UNPKG

@oada/client

Version:

A lightweight client tool to interact with an OADA-compliant server

205 lines 8.34 kB
/** * @license * Copyright 2021 Open Ag Data Alliance * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import debug from "debug"; import { EventEmitter } from "eventemitter3"; import { setTimeout } from "isomorphic-timers-promises"; import WebSocket from "isomorphic-ws"; import PQueue from "p-queue"; import _ReconnectingWebSocket from "reconnecting-websocket"; import { generate as ksuid } from "xksuid"; import { assert as assertOADAChangeV2 } from "@oada/types/oada/change/v2.js"; import { is as isOADASocketChange } from "@oada/types/oada/websockets/change.js"; import { assert as assertOADASocketRequest } from "@oada/types/oada/websockets/request.js"; import { is as isOADASocketResponse } from "@oada/types/oada/websockets/response.js"; import { on, once } from "#event-iterator"; import { handleErrors } from "./errors.js"; import { TimeoutError, fixError } from "./utils.js"; // HACK: Fix for default export types in esm // eslint-disable-next-line @typescript-eslint/naming-convention const ReconnectingWebSocket = _ReconnectingWebSocket; const trace = debug("@oada/client:ws:trace"); const error = debug("@oada/client:ws:error"); var ConnectionStatus; (function (ConnectionStatus) { ConnectionStatus[ConnectionStatus["Disconnected"] = 0] = "Disconnected"; ConnectionStatus[ConnectionStatus["Connecting"] = 1] = "Connecting"; ConnectionStatus[ConnectionStatus["Connected"] = 2] = "Connected"; })(ConnectionStatus || (ConnectionStatus = {})); /** * Override defaults for ws in node * * @todo make sure this does not break in browser */ class BetterWebSocket extends WebSocket { constructor(url, protocols = [], { maxPayload = 0, ...rest } = {}) { super(url, protocols, { maxPayload, ...rest }); } } export class WebSocketClient extends EventEmitter { #ws; #domain; #status; #requests = new EventEmitter(); #q; #userAgent; /** * Constructor * @param domain Domain. E.g., www.example.com * @param concurrency Number of allowed in-flight requests. Default 10. */ constructor(domain, { concurrency = 10, userAgent }) { super(); this.#userAgent = userAgent; this.#domain = domain.replace(/^http/, "ws"); this.#status = ConnectionStatus.Connecting; // Create websocket connection const ws = new ReconnectingWebSocket(this.#domain, [], { // Not sure why it needs so long, but 30s is the ws timeout connectionTimeout: 30 * 1000, WebSocket: BetterWebSocket, }); // eslint-disable-next-line github/no-then const openP = once(ws, "open").then(() => ws); // eslint-disable-next-line github/no-then const errorP = once(ws, "error").then(([wsError]) => { throw wsError; }); this.#ws = Promise.race([openP, errorP]); // Register handlers ws.addEventListener("open", () => { trace("Connection opened"); this.#status = ConnectionStatus.Connected; this.emit("open"); }); ws.addEventListener("close", () => { trace("Connection closed"); this.#status = ConnectionStatus.Disconnected; this.emit("close"); }); ws.addEventListener("error", (wsError) => { trace(wsError, "Connection error"); // This.#status = ConnectionStatus.Disconnected; // this.emit("error"); }); ws.addEventListener("message", (message) => { trace({ message: { ...message, data: message.data, origin: message.origin } }, "Websocket message received"); this.#receive(message); }); this.#q = new PQueue({ concurrency }); this.#q.on("active", () => { trace("WS Queue. Size: %d pending: %d", this.#q.size, this.#q.pending); }); } /** Disconnect the WebSocket connection */ async disconnect() { if (this.#status === ConnectionStatus.Disconnected) { return; } // eslint-disable-next-line unicorn/no-await-expression-member (await this.#ws).close(); } /** Return true if connected, otherwise false */ isConnected() { return this.#status === ConnectionStatus.Connected; } /** Wait for the connection to open */ async awaitConnection() { // Wait for _ws to resolve and return await this.#ws; } async request(request, { timeout, signal } = {}) { return this.#q.add(async () => handleErrors(this.#doRequest.bind(this), request, { timeout, signal }), { throwOnTimeout: true }); } /** Send a request to server */ async #doRequest(request, { timeout, signal } = {}) { const ws = await this.#ws; // Send object to the server. const requestId = request.requestId ?? ksuid(); request.requestId = requestId; assertOADASocketRequest(request); const { headers, watch, method } = request; // Start listening for response before sending the request so we don't miss it const responsePs = [once(this.#requests, `response:${requestId}`)]; const socketRequest = { ...request, headers: { "user-agent": this.#userAgent, ...headers, }, method: watch ? method === "head" ? "watch" : `${method}-watch` : method, }; ws.send(JSON.stringify(socketRequest)); if (timeout) { responsePs.push( // eslint-disable-next-line github/no-then setTimeout(timeout).then(() => { throw new TimeoutError(request); })); } const [response] = await Promise.race(responsePs); if (response.status >= 200 && response.status < 300) { if (watch) { const changes = on(this.#requests, `change:${requestId}`, { signal }); return [response, changes]; } return [response]; } throw await fixError(response); } #receive(m) { try { const message = JSON.parse(String(m.data)); trace({ message }, "Websocket message parsed"); const requestIds = Array.isArray(message.requestId) ? message.requestId : [message.requestId]; if (isOADASocketResponse(message)) { for (const requestId of requestIds) { this.#requests.emit(`response:${requestId}`, message); } } else if (isOADASocketChange(message)) { assertOADAChangeV2(message.change); const change = message.change.map( // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ({ body, ...rest }) => ({ ...rest, body })); for (const requestId of requestIds) { const rChange = { requestId: [requestId], resourceId: message.resourceId, change, }; this.#requests.emit(`change:${requestId}`, rChange); } } else { throw new Error("Invalid websocket payload received"); } } catch (cError) { error("[Websocket %s] Received invalid response. Ignoring.", this.#domain); trace(cError, "[Websocket %s] Received invalid response", this.#domain); // No point in throwing here; the promise cannot be resolved because the // requestId cannot be retrieved; throwing will just blow up client } } } //# sourceMappingURL=websocket.js.map