UNPKG

@oada/client

Version:

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

238 lines 9.57 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 { fromString } from "media-type"; import PQueue from "p-queue"; import { generate as ksuid } from "xksuid"; import { assert as assertOADASocketRequest } from "@oada/types/oada/websockets/request.js"; import { AbortController, Agent, fetch } from "#fetch"; import { TimeoutError, fixError } from "./utils.js"; import { handleErrors } from "./errors.js"; import { WebSocketClient } from "./websocket.js"; const trace = debug("@oada/client:http:trace"); const error = debug("@oada/client:http:error"); var ConnectionStatus; (function (ConnectionStatus) { ConnectionStatus[ConnectionStatus["Disconnected"] = 0] = "Disconnected"; ConnectionStatus[ConnectionStatus["Connecting"] = 1] = "Connecting"; ConnectionStatus[ConnectionStatus["Connected"] = 2] = "Connected"; })(ConnectionStatus || (ConnectionStatus = {})); function isJson(contentType) { const media = fromString(contentType); return [media.subtype, media.suffix].includes("json"); } async function getBody(result) { return isJson(result.headers.get("content-type") ?? "") ? (await result.json()) : new Uint8Array(await result.arrayBuffer()); } export class HttpClient extends EventEmitter { #domain; #token; #status; #q; #initialConnection; // Await on the initial HEAD #concurrency; #userAgent; #agent; #ws; // Fall-back socket for watches /** * Constructor * @param domain Domain. E.g., www.example.com * @param concurrency Number of allowed in-flight requests. Default 10. */ constructor(domain, token, { concurrency = 10, userAgent, timeouts, }) { super(); // Ensure leading https:// this.#domain = domain.startsWith("http") ? domain : `https://${domain}`; // Ensure no trailing slash this.#domain = this.#domain.replace(/\/$/, ""); this.#token = token; this.#status = ConnectionStatus.Connecting; // "Open" the http connection: just make sure a HEAD succeeds trace("Opening HTTP connection to HEAD %s/bookmarks w/authorization: Bearer %s", this.#domain, this.#token); this.#agent = Agent && new Agent({ keepAliveTimeout: timeouts.keepAlive, bodyTimeout: timeouts.body, headersTimeout: timeouts.headers, connect: { timeout: timeouts.connect, rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0", }, }); this.#initialConnection = fetch(`${this.#domain}/bookmarks`, { dispatcher: this.#agent, method: "HEAD", headers: { "user-agent": userAgent, authorization: `Bearer ${this.#token}`, }, }) // eslint-disable-next-line github/no-then .then((result) => { trace("Initial HEAD returned status: ", result.status); // eslint-disable-next-line promise/always-return if (result.status < 400) { trace('Initial HEAD succeeded, emitting "open"'); this.#status = ConnectionStatus.Connected; this.emit("open"); } else { trace('Initial HEAD failed, emitting "close"'); this.#status = ConnectionStatus.Disconnected; this.emit("close"); } }); this.#concurrency = concurrency; this.#userAgent = userAgent; this.#q = new PQueue({ concurrency }); this.#q.on("active", () => { trace("HTTP Queue. Size: %d pending: %d", this.#q.size, this.#q.pending); }); } /** Disconnect the connection */ async disconnect() { this.#status = ConnectionStatus.Disconnected; await Promise.all([ // Close fetch agent this.#agent?.close(), // Close our ws connection this.#ws?.disconnect(), ]); this.emit("close"); } /** Return true if connected, otherwise false */ isConnected() { return this.#status === ConnectionStatus.Connected; } /** Wait for the connection to open */ async awaitConnection() { // Wait for the initial HEAD request to return await this.#initialConnection; } async request(request, { timeout, signal } = {}) { trace(request, "Starting http request"); try { // Check for WATCH/UNWATCH // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (request.watch || request.method === "unwatch") { trace("WATCH/UNWATCH not currently supported for http(2), falling-back to ws"); if (!this.#ws) { // Open a WebSocket connection this.#ws = new WebSocketClient(this.#domain, { concurrency: this.#concurrency, userAgent: this.#userAgent, }); await this.#ws.awaitConnection(); } return await this.#ws.request(request, { timeout, signal }); } request.requestId ||= ksuid(); trace("Adding http request w/ id %s to the queue", request.requestId); return await this.#q.add(async () => handleErrors(this.#doRequest.bind(this), request, timeout), { throwOnTimeout: true }); } catch (cError) { // @ts-expect-error stupid errors const code = `${cError?.code}`; throw Object.assign(new Error(cError?.message ?? "HTTP request failed", { cause: cError, }), cError, { request, code }); } } /** * Send a request to server */ async #doRequest(request, timeout) { // Send object to the server. trace("Pulled request %s from queue, starting on it", request.requestId); assertOADASocketRequest(request); trace("Req looks like socket request, awaiting race of timeout and fetch to %s%s", this.#domain, request.path); let done = false; let timedout = false; let controller; if (timeout) { controller = new AbortController(); setTimeout(() => { if (!done) { timedout = true; controller.abort(); } }, timeout); } // Assume anything that is not a Uint8Array should be JSON? const body = request.data instanceof Uint8Array ? request.data : JSON.stringify(request.data); try { const result = await fetch(new URL(request.path, this.#domain), { dispatcher: this.#agent, method: request.method.toUpperCase(), signal: controller?.signal, body, // We are not explicitly sending token in each request // because parent library sends it headers: { "user-agent": this.#userAgent, ...request.headers, }, }); done = true; trace("Fetch did not throw, checking status of %s", result.status); // This is the same test as in ./websocket.ts if (!result.ok) { trace("result.status %s is not 2xx, throwing", result.status); throw await fixError(result); } trace("result.status ok, pulling headers"); // Have to construct the headers as a regular object const headers = Object.fromEntries(result.headers.entries()); const data = request.method.toUpperCase() === "HEAD" ? undefined : await getBody(result); // Trace("length = %d, result.headers = %O", length, headers); return [ { requestId: request.requestId, status: result.status, statusText: result.statusText, headers, data, }, ]; } catch (cError) { if (timedout) { throw new TimeoutError(request); } // @ts-expect-error stupid error handling switch (cError?.code) { // Happens when the HTTP/2 session is killed case "ERR_HTTP2_INVALID_SESSION": { error(cError, "HTTP/2 session was killed, reconnecting"); return this.#doRequest(request, timeout); } default: { throw cError; } } } } } //# sourceMappingURL=http.js.map