@oada/client
Version:
A lightweight client tool to interact with an OADA-compliant server
205 lines • 8.34 kB
JavaScript
/**
* @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