blob: a9fe71822539160df092bd6d5187bf0da8d9b1de [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 type * as http from "http";
import type * as http2 from "http2";
import type * as stream from "stream";
import type { JsonValue } from "@bufbuild/protobuf";
import { Code, DubboError } from "@apachedubbo/dubbo";
import type {
UniversalServerRequest,
UniversalServerResponse,
} from "@apachedubbo/dubbo/protocol";
import {
nodeHeaderToWebHeader,
webHeaderToNodeHeaders,
} from "./node-universal-header.js";
import {
dubboErrorFromH2ResetCode,
dubboErrorFromNodeReason,
} from "./node-error.js";
/**
* NodeHandlerFn is compatible with http.RequestListener and its equivalent
* for http2.
*/
export type NodeHandlerFn = (
request: NodeServerRequest,
response: NodeServerResponse
) => void;
/**
* A Node.js server request from the http, https, or the http2 module.
*/
export type NodeServerRequest = http.IncomingMessage | http2.Http2ServerRequest;
/**
* A Node.js server response from the http, https, or the http2 module.
* Note that we are taking the liberty to patch the type of write() so
* that they are compatible with each other.
*/
export type NodeServerResponse = (
| Omit<http.ServerResponse, "write">
| Omit<http2.Http2ServerResponse, "write">
) & {
write(
chunk: string | Uint8Array,
callback?: (err: Error | null | undefined) => void
): boolean;
write(
chunk: string | Uint8Array,
encoding: BufferEncoding,
callback?: (err: Error | null | undefined) => void
): boolean;
};
/**
* Converts a UniversalServerRequest to a Node.js server request.
* This function helps to implement adapters to server frameworks running
* on Node.js. Please be careful using this function in your own code, as we
* may have to make changes to it in the future.
*/
export function universalRequestFromNodeRequest(
nodeRequest: NodeServerRequest,
parsedJsonBody: JsonValue | undefined
): UniversalServerRequest {
const encrypted =
"encrypted" in nodeRequest.socket && nodeRequest.socket.encrypted;
const protocol = encrypted ? "https" : "http";
const authority =
"authority" in nodeRequest
? nodeRequest.authority
: nodeRequest.headers.host;
const pathname = nodeRequest.url ?? "";
if (authority === undefined) {
throw new DubboError(
"unable to determine request authority from Node.js server request",
Code.Internal
);
}
const body =
parsedJsonBody !== undefined
? parsedJsonBody
: asyncIterableFromNodeServerRequest(nodeRequest);
const abortController = new AbortController();
if ("stream" in nodeRequest) {
// HTTP/2 has error codes we want to honor
nodeRequest.once("close", () => {
const err = dubboErrorFromH2ResetCode(nodeRequest.stream.rstCode);
if (err !== undefined) {
abortController.abort(err);
} else {
abortController.abort();
}
});
} else {
// HTTP/1.1 does not have error codes, but Node.js has ECONNRESET
const onH1Error = (e: Error) => {
nodeRequest.off("error", onH1Error);
nodeRequest.off("close", onH1Close);
abortController.abort(dubboErrorFromNodeReason(e));
};
const onH1Close = () => {
nodeRequest.off("error", onH1Error);
nodeRequest.off("close", onH1Close);
abortController.abort();
};
nodeRequest.once("error", onH1Error);
nodeRequest.once("close", onH1Close);
}
return {
httpVersion: nodeRequest.httpVersion,
method: nodeRequest.method ?? "",
url: new URL(pathname, `${protocol}://${authority}`).toString(),
header: nodeHeaderToWebHeader(nodeRequest.headers),
body,
signal: abortController.signal,
};
}
/**
* Writes a UniversalServerResponse to a Node.js server response.
* This function helps to implement adapters to server frameworks running
* on Node.js. Please be careful using this function in your own code, as we
* may have to make changes to it in the future.
*/
export async function universalResponseToNodeResponse(
universalResponse: UniversalServerResponse,
nodeResponse: NodeServerResponse
): Promise<void> {
try {
if (universalResponse.body !== undefined) {
for await (const chunk of universalResponse.body) {
// we deliberately send headers *in* this loop, not before,
// because we have to give the implementation a chance to
// set response headers
if (!nodeResponse.headersSent) {
nodeResponse.writeHead(
universalResponse.status,
webHeaderToNodeHeaders(universalResponse.header)
);
}
await write(nodeResponse, chunk);
if (
"flush" in nodeResponse &&
typeof nodeResponse.flush == "function"
) {
// The npm package "compression" is an express middleware that is widely used,
// for example in next.js. It uses the npm package "compressible" to determine
// whether to apply compression to a response. Unfortunately, "compressible"
// matches every mime type that ends with "+json", causing our server-streaming
// RPCs to be buffered.
// The package modifies the response object, and adds a flush() method, which
// flushes the underlying gzip or deflate stream from the Node.js zlib module.
// The method is added here:
// https://github.com/expressjs/compression/blob/ad5113b98cafe1382a0ece30bb4673707ac59ce7/index.js#L70
nodeResponse.flush();
}
}
}
if (!nodeResponse.headersSent) {
nodeResponse.writeHead(
universalResponse.status,
webHeaderToNodeHeaders(universalResponse.header)
);
}
if (universalResponse.trailer) {
nodeResponse.addTrailers(
webHeaderToNodeHeaders(universalResponse.trailer)
);
}
await new Promise<void>((resolve) => {
// The npm package "compression" crashes when a callback is passed to end()
// https://github.com/expressjs/compression/blob/ad5113b98cafe1382a0ece30bb4673707ac59ce7/index.js#L115
nodeResponse.once("end", resolve);
nodeResponse.end();
});
} catch (e) {
throw dubboErrorFromNodeReason(e);
}
}
async function* asyncIterableFromNodeServerRequest(
request: NodeServerRequest
): AsyncIterable<Uint8Array> {
for await (const chunk of request) {
yield chunk;
}
}
function write(stream: stream.Writable, data: Uint8Array): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (stream.errored) {
return error(stream.errored);
}
stream.once("error", error);
stream.once("drain", drain);
// flushed == false: the stream wishes for the calling code to wait for
// the 'drain' event to be emitted before continuing to write additional
// data.
const flushed = stream.write(data, "binary", function (err) {
if (err && !flushed) {
// We are never getting a "drain" nor an "error" event if the stream
// has already ended (ERR_STREAM_WRITE_AFTER_END), so we have to
// resolve our promise in this callback.
error(err);
// However, once we do that (and remove our event listeners), we _do_
// get an "error" event, which ends up as an uncaught exception.
// We silence this error specifically with the following listener.
// All of this seems very fragile.
stream.once("error", () => {
//
});
}
});
if (flushed) {
drain();
}
function error(err: Error) {
stream.off("error", error);
stream.off("drain", drain);
reject(err);
}
function drain() {
stream.off("error", error);
stream.off("drain", drain);
resolve();
}
});
}