blob: b90f9982918fac3c16c5c0fce5fb3134cc5e4ae2 [file]
// Copyright 2021-2023 Buf Technologies, Inc.
//
// 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 * as http2 from "http2";
import * as http from "http";
import * as https from "https";
import type * as net from "net";
import { Code, DubboError } from "@apachedubbo/dubbo";
import {
nodeHeaderToWebHeader,
webHeaderToNodeHeaders,
} from "./node-universal-header.js";
import {
dubboErrorFromH2ResetCode,
dubboErrorFromNodeReason,
getNodeErrorProps,
H2Code,
unwrapNodeErrorChain,
} from "./node-error.js";
import type {
UniversalClientFn,
UniversalClientRequest,
UniversalClientResponse,
} from "@apachedubbo/dubbo/protocol";
import { getAbortSignalReason } from "@apachedubbo/dubbo/protocol";
import { Http2SessionManager } from "./http2-session-manager.js";
/**
* Options for creating an UniversalClientFn using the Node.js `http`, `https`,
* or `http2` module.
*/
type NodeHttpClientOptions =
| {
/**
* Use the Node.js `http` or `https` module.
*/
httpVersion: "1.1";
/**
* Options passed to the request() call of the Node.js built-in
* http or https module.
*/
nodeOptions?:
| Omit<http.RequestOptions, "signal">
| Omit<https.RequestOptions, "signal">;
}
| {
/**
* Use the Node.js `http2` module.
*/
httpVersion: "2";
/**
* A function that must return a session manager for the given authority.
* The session manager may be taken from a pool.
* By default, a new Http2SessionManager is created for every request.
*/
sessionProvider?: (authority: string) => NodeHttp2ClientSessionManager;
};
/**
* Create a universal client function, a minimal abstraction of an HTTP client,
* using the Node.js `http`, `https`, or `http2` module.
*
* @private Internal code, does not follow semantic versioning.
*/
export function createNodeHttpClient(options: NodeHttpClientOptions) {
if (options.httpVersion == "1.1") {
return createNodeHttp1Client(options.nodeOptions);
}
const sessionProvider =
options.sessionProvider ??
((authority: string) => new Http2SessionManager(authority));
return createNodeHttp2Client(sessionProvider);
}
/**
* Manager for a HTTP/2 session.
*/
export interface NodeHttp2ClientSessionManager {
/**
* The host this session manager connect to.
*/
authority: string;
/**
* Issue a request.
*/
request(
method: string,
path: string,
headers: http2.OutgoingHttpHeaders,
options: Omit<http2.ClientSessionRequestOptions, "signal">
): Promise<http2.ClientHttp2Stream>;
/**
* Notify the manager of a successful read from a http2.ClientHttp2Stream.
*/
notifyResponseByteRead(stream: http2.ClientHttp2Stream): void;
}
/**
* Create an HTTP client using the Node.js `http` or `https` package.
*
* The HTTP client is a simple function conforming to the type UniversalClientFn.
* It takes an UniversalClientRequest as an argument, and returns a promise for
* an UniversalClientResponse.
*/
function createNodeHttp1Client(
httpOptions:
| Omit<http.RequestOptions, "signal">
| Omit<https.RequestOptions, "signal">
| undefined
): UniversalClientFn {
return async function request(
req: UniversalClientRequest
): Promise<UniversalClientResponse> {
const sentinel = createSentinel(req.signal);
return new Promise<UniversalClientResponse>((resolve, reject) => {
sentinel.catch((e) => {
reject(e);
});
h1Request(
sentinel,
req.url,
{
...httpOptions,
headers: webHeaderToNodeHeaders(req.header),
method: req.method,
},
(request) => {
void sinkRequest(req, request, sentinel);
request.on("response", (response) => {
response.on("error", sentinel.reject);
sentinel.catch((reason) =>
response.destroy(dubboErrorFromNodeReason(reason))
);
const trailer = new Headers();
resolve({
status: response.statusCode ?? 0,
header: nodeHeaderToWebHeader(response.headers),
body: h1ResponseIterable(sentinel, response, trailer),
trailer,
});
});
}
);
});
};
}
/**
* Create an HTTP client using the Node.js `http2` package.
*
* The HTTP client is a simple function conforming to the type UniversalClientFn.
* It takes an UniversalClientRequest as an argument, and returns a promise for
* an UniversalClientResponse.
*/
function createNodeHttp2Client(
sessionProvider: (authority: string) => NodeHttp2ClientSessionManager
): UniversalClientFn {
return function request(
req: UniversalClientRequest
): Promise<UniversalClientResponse> {
const sentinel = createSentinel(req.signal);
const sessionManager = sessionProvider(req.url);
return new Promise<UniversalClientResponse>((resolve, reject) => {
sentinel.catch((e) => {
reject(e);
});
h2Request(
sentinel,
sessionManager,
req.url,
req.method,
webHeaderToNodeHeaders(req.header),
{},
(stream) => {
void sinkRequest(req, stream, sentinel);
stream.on("response", (headers) => {
const response: UniversalClientResponse = {
status: headers[":status"] ?? 0,
header: nodeHeaderToWebHeader(headers),
body: h2ResponseIterable(sentinel, stream, sessionManager),
trailer: h2ResponseTrailer(stream),
};
resolve(response);
});
}
);
});
};
}
function h1Request(
sentinel: Sentinel,
url: string,
options:
| Omit<http.RequestOptions, "signal">
| Omit<https.RequestOptions, "signal">,
onRequest: (request: http.ClientRequest) => void
): void {
let request: http.ClientRequest;
if (new URL(url).protocol.startsWith("https")) {
request = https.request(url, options);
} else {
request = http.request(url, options);
}
sentinel.catch((reason) =>
request.destroy(dubboErrorFromNodeReason(reason))
);
// Node.js will only send headers with the first request body byte by default.
// We force it to send headers right away for consistent behavior between
// HTTP/1.1 and HTTP/2.2 clients.
request.flushHeaders();
request.on("error", sentinel.reject);
request.on("socket", function onRequestSocket(socket: net.Socket) {
function onSocketConnect() {
socket.off("connect", onSocketConnect);
onRequest(request);
}
// If readyState is open, then socket is already open due to keepAlive, so
// the 'connect' event will never fire so call onRequest explicitly
if (socket.readyState === "open") {
onRequest(request);
} else {
socket.on("connect", onSocketConnect);
}
});
}
function h1ResponseIterable(
sentinel: Sentinel,
response: http.IncomingMessage,
trailer: Headers
): AsyncIterable<Uint8Array> {
const inner: AsyncIterator<Uint8Array> = response[Symbol.asyncIterator]();
return {
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<Uint8Array>> {
const r = await sentinel.race(inner.next());
if (r.done === true) {
nodeHeaderToWebHeader(response.trailers).forEach((value, key) => {
trailer.set(key, value);
});
sentinel.resolve();
await sentinel;
}
return r;
},
throw(e?: unknown): Promise<IteratorResult<Uint8Array>> {
sentinel.reject(e);
throw e;
},
};
},
};
}
function h2Request(
sentinel: Sentinel,
sm: NodeHttp2ClientSessionManager,
url: string,
method: string,
headers: http2.OutgoingHttpHeaders,
options: Omit<http2.ClientSessionRequestOptions, "signal">,
onStream: (stream: http2.ClientHttp2Stream) => void
): void {
const requestUrl = new URL(url, sm.authority);
if (requestUrl.origin !== sm.authority) {
const message = `cannot make a request to ${requestUrl.origin}: the http2 session is connected to ${sm.authority}`;
sentinel.reject(new DubboError(message, Code.Internal));
return;
}
sm.request(method, requestUrl.pathname + requestUrl.search, headers, {}).then(
(stream) => {
stream.session?.on("error", sentinel.reject);
sentinel
.catch((reason) => {
if (stream.closed) {
return;
}
// Node.js http2 streams that are aborted via an AbortSignal close with
// an RST_STREAM with code INTERNAL_ERROR.
// To comply with the mapping between gRPC and HTTP/2 codes, we need to
// close with code CANCEL.
// See https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#errors
// See https://www.rfc-editor.org/rfc/rfc7540#section-7
const rstCode =
reason instanceof DubboError && reason.code == Code.Canceled
? H2Code.CANCEL
: H2Code.INTERNAL_ERROR;
return new Promise<void>((resolve) => stream.close(rstCode, resolve));
})
.finally(() => {
stream.session?.off("error", sentinel.reject);
})
.catch(() => {
// We intentionally swallow sentinel rejection - errors must
// propagate through the request or response iterables.
});
stream.on("error", function h2StreamError(e: unknown) {
if (
stream.writableEnded &&
unwrapNodeErrorChain(e)
.map(getNodeErrorProps)
.some((p) => p.code == "ERR_STREAM_WRITE_AFTER_END")
) {
return;
}
sentinel.reject(e);
});
stream.on("close", function h2StreamClose() {
const err = dubboErrorFromH2ResetCode(stream.rstCode);
if (err) {
sentinel.reject(err);
}
});
onStream(stream);
},
(reason) => {
sentinel.reject(reason);
}
);
}
function h2ResponseTrailer(response: http2.ClientHttp2Stream): Headers {
const trailer = new Headers();
response.on(
"trailers",
(args: http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader) => {
nodeHeaderToWebHeader(args).forEach((value, key) => {
trailer.set(key, value);
});
}
);
return trailer;
}
function h2ResponseIterable(
sentinel: Sentinel,
response: http2.ClientHttp2Stream,
sm?: NodeHttp2ClientSessionManager
): AsyncIterable<Uint8Array> {
const inner: AsyncIterator<Uint8Array> = response[Symbol.asyncIterator]();
return {
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<Uint8Array>> {
const r = await sentinel.race(inner.next());
if (r.done === true) {
sentinel.resolve();
await sentinel;
}
sm?.notifyResponseByteRead(response);
return r;
},
throw(e?: unknown): Promise<IteratorResult<Uint8Array>> {
sentinel.reject(e);
throw e;
},
};
},
};
}
async function sinkRequest(
request: UniversalClientRequest,
nodeRequest: http.ClientRequest | http2.ClientHttp2Stream,
sentinel: Sentinel
) {
if (request.body === undefined) {
await new Promise<void>((resolve) => nodeRequest.end(resolve));
return;
}
const it = request.body[Symbol.asyncIterator]();
return new Promise<void>((resolve) => {
writeNext();
function writeNext() {
if (sentinel.isRejected()) {
return;
}
it.next().then(
(r) => {
if (r.done === true) {
nodeRequest.end(resolve);
return;
}
nodeRequest.write(r.value, "binary", function (e) {
if (e) {
if (
nodeRequest.writableEnded &&
unwrapNodeErrorChain(e)
.map(getNodeErrorProps)
.some((p) => p.code == "ERR_STREAM_WRITE_AFTER_END") &&
it.throw !== undefined
) {
// If the server responds and closes the connection before the client has written the entire response
// body, we get an ERR_STREAM_WRITE_AFTER_END error code from Node.js here.
// We do want to notify the iterable of the error condition, but we do not want to reject our sentinel,
// because that would also affect the reading side.
it.throw(new DubboError("stream closed", Code.Aborted)).catch(
() => {
//
}
);
return;
}
sentinel.reject(e);
} else {
writeNext();
}
});
},
(e) => {
sentinel.reject(e);
}
);
}
});
}
type Sentinel = Promise<void> & {
/**
* Resolve the sentinel.
*/
resolve(): void;
isResolved(): boolean;
/**
* Reject the sentinel. All errors are converted to DubboError via
* DubboError.from().
*/
reject: (reason: DubboError | unknown) => void;
isRejected(): boolean;
/**
* Race a promise against the sentinel.
*
* Returns the outcome of the promise if it resolves or rejects faster than
* the sentinel.
*
* Rejects if the sentinel is faster, even if the sentinel resolved
* successfully.
*/
race<T>(promise: PromiseLike<T>): Promise<Awaited<T>>;
};
function createSentinel(signal?: AbortSignal): Sentinel {
let res: (() => void) | undefined;
let rej: ((reason: DubboError | unknown) => void) | undefined;
let resolved = false;
let rejected = false;
const p = new Promise<void>((resolve, reject) => {
res = resolve;
rej = reject;
});
const c: Pick<
Sentinel,
"resolve" | "isResolved" | "reject" | "isRejected" | "race"
> = {
resolve(): void {
if (!resolved && !rejected) {
resolved = true;
res?.();
}
},
isResolved() {
return resolved;
},
reject(reason): void {
if (!resolved && !rejected) {
rejected = true;
rej?.(dubboErrorFromNodeReason(reason));
}
},
isRejected() {
return rejected;
},
async race<T>(promise: PromiseLike<T>): Promise<Awaited<T>> {
const r = await Promise.race([promise, p]);
if (r === undefined && resolved) {
throw new DubboError("sentinel completed early", Code.Internal);
}
return r as Awaited<T>;
},
};
const s = Object.assign(p, c);
function onSignalAbort(this: AbortSignal) {
c.reject(getAbortSignalReason(this));
}
if (signal) {
if (signal.aborted) {
c.reject(getAbortSignalReason(signal));
} else {
signal.addEventListener("abort", onSignalAbort);
}
p.finally(() => signal.removeEventListener("abort", onSignalAbort)).catch(
() => {
// We intentionally swallow sentinel rejection - errors must
// propagate through the request or response iterables.
}
);
}
return s;
}