fauna
Version:
A driver to query Fauna databases in browsers, Node.js, and other Javascript runtimes
344 lines (301 loc) • 9.88 kB
text/typescript
let http2: any;
try {
http2 = require("node:http2");
} catch (_) {
http2 = undefined;
}
import {
HTTPClient,
HTTPClientOptions,
HTTPRequest,
HTTPResponse,
HTTPStreamClient,
HTTPStreamRequest,
StreamAdapter,
} from "./http-client";
import { NetworkError, getServiceError } from "../errors";
import { QueryFailure, QueryRequest } from "../wire-protocol";
import { FaunaAPIPaths } from "./paths";
// alias http2 types
type ClientHttp2Session = any;
type ClientHttp2Stream = any;
type IncomingHttpHeaders = any;
type IncomingHttpStatusHeader = any;
type OutgoingHttpHeaders = any;
/**
* An implementation for {@link HTTPClient} that uses the node http package
*/
export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient {
static #clients: Map<string, NodeHTTP2Client> = new Map();
#http2_session_idle_ms: number;
#http2_max_streams: number;
#url: string;
#numberOfUsers = 0;
#session: ClientHttp2Session | null;
#defaultRequestPath = FaunaAPIPaths.QUERY;
#defaultStreamPath = FaunaAPIPaths.STREAM;
private constructor({
http2_session_idle_ms,
url,
http2_max_streams,
}: HTTPClientOptions) {
if (http2 === undefined) {
throw new Error("Your platform does not support Node's http2 library");
}
this.#http2_session_idle_ms = http2_session_idle_ms;
this.#http2_max_streams = http2_max_streams;
this.#url = url;
this.#session = null;
}
/**
* Gets a {@link NodeHTTP2Client} matching the {@link HTTPClientOptions}
* @param httpClientOptions - the {@link HTTPClientOptions}
* @returns a {@link NodeHTTP2Client} matching the {@link HTTPClientOptions}
*/
static getClient(httpClientOptions: HTTPClientOptions): NodeHTTP2Client {
const clientKey = NodeHTTP2Client.#getClientKey(httpClientOptions);
if (!NodeHTTP2Client.#clients.has(clientKey)) {
NodeHTTP2Client.#clients.set(
clientKey,
new NodeHTTP2Client(httpClientOptions),
);
}
// we know that we have a client here
const client = NodeHTTP2Client.#clients.get(clientKey) as NodeHTTP2Client;
client.#numberOfUsers++;
return client;
}
static #getClientKey({ http2_session_idle_ms, url }: HTTPClientOptions) {
return `${url}|${http2_session_idle_ms}`;
}
/** {@inheritDoc HTTPClient.request} */
async request<T = QueryRequest>(req: HTTPRequest<T>): Promise<HTTPResponse> {
let retryCount = 0;
let memoizedError: any;
do {
try {
return await this.#doRequest(req);
} catch (error: any) {
// see https://github.com/nodejs/node/pull/42190/files
// and https://github.com/nodejs/help/issues/2105
//
// TLDR; In Node, there is a race condition between handling
// GOAWAY and submitting requests - that can cause
// clients that safely handle go away to submit
// requests after a GOAWAY was received anyway.
//
// technical explanation: node HTTP2 request gets put
// on event queue before it is actually executed. In the iterim,
// a GOAWAY can come and cause the request to fail
// with a GOAWAY.
if (error?.code !== "ERR_HTTP2_GOAWAY_SESSION") {
throw new NetworkError(
"The network connection encountered a problem.",
{
cause: error,
},
);
}
memoizedError = error;
retryCount++;
}
} while (retryCount < 3);
throw new NetworkError("The network connection encountered a problem.", {
cause: memoizedError,
});
}
/** {@inheritDoc HTTPStreamClient.stream} */
stream(req: HTTPStreamRequest): StreamAdapter {
return this.#doStream(req);
}
/** {@inheritDoc HTTPClient.close} */
close() {
// defend against redundant close calls
if (this.isClosed()) {
return;
}
this.#numberOfUsers--;
if (this.#numberOfUsers === 0 && this.#session && !this.#session.closed) {
this.#session.close();
}
}
/**
* @returns true if this client has been closed, false otherwise.
*/
isClosed(): boolean {
return this.#numberOfUsers === 0;
}
#closeForAll() {
this.#numberOfUsers = 0;
if (this.#session && !this.#session.closed) {
this.#session.close();
}
}
#connect() {
// create the session if it does not exist or is closed
if (!this.#session || this.#session.closed || this.#session.destroyed) {
const newSession: ClientHttp2Session = http2
.connect(this.#url, {
peerMaxConcurrentStreams: this.#http2_max_streams,
})
.once("error", () => this.#closeForAll())
.once("goaway", () => this.#closeForAll());
newSession.setTimeout(this.#http2_session_idle_ms, () => {
this.#closeForAll();
});
this.#session = newSession;
}
return this.#session;
}
#doRequest<T = QueryRequest>({
client_timeout_ms,
data: requestData,
headers: requestHeaders,
method,
path = this.#defaultRequestPath,
}: HTTPRequest<T>): Promise<HTTPResponse> {
return new Promise<HTTPResponse>((resolvePromise, rejectPromise) => {
let req: ClientHttp2Stream;
const onResponse = (
http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader,
) => {
const status = Number(
http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS],
);
let responseData = "";
// append response data to the data string every time we receive new
// data chunks in the response
req.on("data", (chunk: string) => {
responseData += chunk;
});
// Once the response is finished, resolve the promise
req.on("end", () => {
resolvePromise({
status,
body: responseData,
headers: http2ResponseHeaders,
});
});
};
try {
const httpRequestHeaders: OutgoingHttpHeaders = {
...requestHeaders,
[http2.constants.HTTP2_HEADER_PATH]: path,
[http2.constants.HTTP2_HEADER_METHOD]: method,
};
const session = this.#connect();
req = session
.request(httpRequestHeaders)
.setEncoding("utf8")
.on("error", (error: any) => {
rejectPromise(error);
})
.on("response", onResponse);
req.write(JSON.stringify(requestData), "utf8");
// req.setTimeout must be called before req.end()
req.setTimeout(client_timeout_ms, () => {
req.destroy(new Error(`Client timeout`));
});
req.end();
} catch (error) {
rejectPromise(error);
}
});
}
/** {@inheritDoc HTTPStreamClient.stream} */
#doStream({
data: requestData,
headers: requestHeaders,
method,
path = this.#defaultStreamPath,
}: HTTPStreamRequest): StreamAdapter {
let resolveChunk: (chunk: string[]) => void;
let rejectChunk: (reason: any) => void;
const setChunkPromise = () =>
new Promise<string[]>((res, rej) => {
resolveChunk = res;
rejectChunk = rej;
});
let chunkPromise = setChunkPromise();
let req: ClientHttp2Stream;
const onResponse = (
http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader,
) => {
const status = Number(
http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS],
);
if (!(status >= 200 && status < 400)) {
// Get the error body and then throw an error
let responseData = "";
// append response data to the data string every time we receive new
// data chunks in the response
req.on("data", (chunk: string) => {
responseData += chunk;
});
// Once the response is finished, resolve the promise
req.on("end", () => {
try {
const failure: QueryFailure = JSON.parse(responseData);
rejectChunk(getServiceError(failure, status));
} catch (error) {
rejectChunk(
new NetworkError("Could not process query failure.", {
cause: error,
}),
);
}
});
} else {
let partOfLine = "";
// append response data to the data string every time we receive new
// data chunks in the response
req.on("data", (chunk: string) => {
const chunkLines = (partOfLine + chunk).split("\n");
// Yield all complete lines
resolveChunk(chunkLines.map((s) => s.trim()).slice(0, -1));
chunkPromise = setChunkPromise();
// Store the partial line
partOfLine = chunkLines[chunkLines.length - 1];
});
// Once the response is finished, resolve the promise
req.on("end", () => {
resolveChunk([partOfLine]);
});
}
};
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
async function* reader(): AsyncGenerator<string> {
const httpRequestHeaders: OutgoingHttpHeaders = {
...requestHeaders,
[http2.constants.HTTP2_HEADER_PATH]: path,
[http2.constants.HTTP2_HEADER_METHOD]: method,
};
const session = self.#connect();
req = session
.request(httpRequestHeaders)
.setEncoding("utf8")
.on("error", (error: any) => {
rejectChunk(error);
})
.on("response", onResponse);
const body = JSON.stringify(requestData);
req.write(body, "utf8");
req.end();
while (true) {
const chunks = await chunkPromise;
for (const chunk of chunks) {
yield chunk;
}
}
}
return {
read: reader(),
close: () => {
if (req) {
req.close();
}
},
};
}
}