summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ext/node/polyfills/http2.ts1061
-rw-r--r--ext/node/polyfills/internal/errors.ts10
-rw-r--r--tests/unit/serve_test.ts28
-rw-r--r--tests/unit/test_util.ts33
-rw-r--r--tests/unit_node/http2_test.ts57
5 files changed, 991 insertions, 198 deletions
diff --git a/ext/node/polyfills/http2.ts b/ext/node/polyfills/http2.ts
index c81ae4b39..2856d3938 100644
--- a/ext/node/polyfills/http2.ts
+++ b/ext/node/polyfills/http2.ts
@@ -4,7 +4,7 @@
// TODO(petamoriken): enable prefer-primordials for node polyfills
// deno-lint-ignore-file prefer-primordials
-import { core } from "ext:core/mod.js";
+import { core, primordials } from "ext:core/mod.js";
import {
op_http2_client_get_response,
op_http2_client_get_response_body_chunk,
@@ -15,11 +15,16 @@ import {
op_http2_client_send_trailers,
op_http2_connect,
op_http2_poll_client_connection,
+ op_http_set_response_trailers,
} from "ext:core/ops";
import { notImplemented, warnNotImplemented } from "ext:deno_node/_utils.ts";
+import { toInnerRequest } from "ext:deno_fetch/23_request.js";
+import { Readable } from "node:stream";
import { EventEmitter } from "node:events";
import { Buffer } from "node:buffer";
+import { emitWarning } from "node:process";
+import Stream from "node:stream";
import { connect as netConnect, Server, Socket, TCP } from "node:net";
import { connect as tlsConnect } from "node:tls";
import { TypedArray } from "ext:deno_node/internal/util/types.ts";
@@ -31,7 +36,7 @@ import {
} from "ext:deno_node/internal/stream_base_commons.ts";
import { FileHandle } from "node:fs/promises";
import { kStreamBaseField } from "ext:deno_node/internal_binding/stream_wrap.ts";
-import { addTrailers, serveHttpOnConnection } from "ext:deno_http/00_serve.js";
+import { serveHttpOnConnection } from "ext:deno_http/00_serve.js";
import { nextTick } from "ext:deno_node/_next_tick.ts";
import { TextEncoder } from "ext:deno_web/08_text_encoding.js";
import { Duplex } from "node:stream";
@@ -41,21 +46,40 @@ import {
ERR_HTTP2_CONNECT_PATH,
ERR_HTTP2_CONNECT_SCHEME,
ERR_HTTP2_GOAWAY_SESSION,
+ ERR_HTTP2_HEADERS_SENT,
+ ERR_HTTP2_INFO_STATUS_NOT_ALLOWED,
ERR_HTTP2_INVALID_PSEUDOHEADER,
ERR_HTTP2_INVALID_SESSION,
ERR_HTTP2_INVALID_STREAM,
+ ERR_HTTP2_NO_SOCKET_MANIPULATION,
ERR_HTTP2_SESSION_ERROR,
+ ERR_HTTP2_STATUS_INVALID,
ERR_HTTP2_STREAM_CANCEL,
ERR_HTTP2_STREAM_ERROR,
+ ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS,
ERR_HTTP2_TRAILERS_ALREADY_SENT,
ERR_HTTP2_TRAILERS_NOT_READY,
ERR_HTTP2_UNSUPPORTED_PROTOCOL,
+ ERR_INVALID_ARG_VALUE,
ERR_INVALID_HTTP_TOKEN,
ERR_SOCKET_CLOSED,
+ ERR_STREAM_WRITE_AFTER_END,
} from "ext:deno_node/internal/errors.ts";
import { _checkIsHttpToken } from "ext:deno_node/_http_common.ts";
+const {
+ StringPrototypeTrim,
+ FunctionPrototypeBind,
+ ObjectKeys,
+ ReflectGetPrototypeOf,
+ ObjectAssign,
+ StringPrototypeToLowerCase,
+ ReflectApply,
+ ArrayIsArray,
+ ObjectPrototypeHasOwnProperty,
+} = primordials;
const kSession = Symbol("session");
+const kOptions = Symbol("options");
const kAlpnProtocol = Symbol("alpnProtocol");
const kAuthority = Symbol("authority");
const kEncrypted = Symbol("encrypted");
@@ -85,6 +109,9 @@ const STREAM_FLAGS_HEAD_REQUEST = 0x8;
const STREAM_FLAGS_ABORTED = 0x10;
const STREAM_FLAGS_HAS_TRAILERS = 0x20;
+// Maximum number of allowed additional settings
+const MAX_ADDITIONAL_SETTINGS = 10;
+
const SESSION_FLAGS_PENDING = 0x0;
const SESSION_FLAGS_READY = 0x1;
const SESSION_FLAGS_CLOSED = 0x2;
@@ -563,6 +590,8 @@ export class Http2Stream extends EventEmitter {
#readerPromise: Promise<ReadableStream<Uint8Array>>;
#closed: boolean;
_response: Response;
+ // This is required to set the trailers on the response.
+ _request: Request;
constructor(
session: Http2Session,
@@ -696,8 +725,9 @@ export class Http2Stream extends EventEmitter {
return {};
}
- sendTrailers(_headers: Record<string, unknown>) {
- addTrailers(this._response, [["grpc-status", "0"], ["grpc-message", "OK"]]);
+ sendTrailers(headers: Record<string, unknown>) {
+ const request = toInnerRequest(this._request);
+ op_http_set_response_trailers(request.external, Object.entries(headers));
}
}
@@ -1269,10 +1299,13 @@ export class ServerHttp2Stream extends Http2Stream {
controllerPromise: Promise<ReadableStreamDefaultController<Uint8Array>>,
reader: ReadableStream<Uint8Array>,
body: ReadableStream<Uint8Array>,
+ // This is required to set the trailers on the response.
+ req: Request,
) {
super(session, headers, controllerPromise, Promise.resolve(reader));
this._deferred = Promise.withResolvers<Response>();
this.#body = body;
+ this._request = req;
}
additionalHeaders(_headers: Record<string, unknown>) {
@@ -1343,6 +1376,107 @@ export class ServerHttp2Stream extends Http2Stream {
}
}
+function setupCompat(ev) {
+ if (ev === "request") {
+ this.removeListener("newListener", setupCompat);
+ this.on(
+ "stream",
+ FunctionPrototypeBind(
+ onServerStream,
+ this,
+ this[kOptions].Http2ServerRequest,
+ this[kOptions].Http2ServerResponse,
+ ),
+ );
+ }
+}
+
+function onServerStream(
+ ServerRequest,
+ ServerResponse,
+ stream,
+ headers,
+ _flags,
+ rawHeaders,
+) {
+ const request = new ServerRequest(stream, headers, undefined, rawHeaders);
+ const response = new ServerResponse(stream);
+
+ // Check for the CONNECT method
+ const method = headers[constants.HTTP2_HEADER_METHOD];
+ if (method === "CONNECT") {
+ if (!this.emit("connect", request, response)) {
+ response.statusCode = constants.HTTP_STATUS_METHOD_NOT_ALLOWED;
+ response.end();
+ }
+ return;
+ }
+
+ // Check for Expectations
+ if (headers.expect !== undefined) {
+ if (headers.expect === "100-continue") {
+ if (this.listenerCount("checkContinue")) {
+ this.emit("checkContinue", request, response);
+ } else {
+ response.writeContinue();
+ this.emit("request", request, response);
+ }
+ } else if (this.listenerCount("checkExpectation")) {
+ this.emit("checkExpectation", request, response);
+ } else {
+ response.statusCode = constants.HTTP_STATUS_EXPECTATION_FAILED;
+ response.end();
+ }
+ return;
+ }
+
+ this.emit("request", request, response);
+}
+
+function initializeOptions(options) {
+ // assertIsObject(options, 'options');
+ options = { ...options };
+ // assertIsObject(options.settings, 'options.settings');
+ options.settings = { ...options.settings };
+
+ // assertIsArray(options.remoteCustomSettings, 'options.remoteCustomSettings');
+ if (options.remoteCustomSettings) {
+ options.remoteCustomSettings = [...options.remoteCustomSettings];
+ if (options.remoteCustomSettings.length > MAX_ADDITIONAL_SETTINGS) {
+ throw new ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS();
+ }
+ }
+
+ // if (options.maxSessionInvalidFrames !== undefined)
+ // validateUint32(options.maxSessionInvalidFrames, 'maxSessionInvalidFrames');
+
+ // if (options.maxSessionRejectedStreams !== undefined) {
+ // validateUint32(
+ // options.maxSessionRejectedStreams,
+ // 'maxSessionRejectedStreams',
+ // );
+ // }
+
+ if (options.unknownProtocolTimeout !== undefined) {
+ // validateUint32(options.unknownProtocolTimeout, 'unknownProtocolTimeout');
+ } else {
+ // TODO(danbev): is this a good default value?
+ options.unknownProtocolTimeout = 10000;
+ }
+
+ // Used only with allowHTTP1
+ // options.Http1IncomingMessage = options.Http1IncomingMessage ||
+ // http.IncomingMessage;
+ // options.Http1ServerResponse = options.Http1ServerResponse ||
+ // http.ServerResponse;
+
+ options.Http2ServerRequest = options.Http2ServerRequest ||
+ Http2ServerRequest;
+ options.Http2ServerResponse = options.Http2ServerResponse ||
+ Http2ServerResponse;
+ return options;
+}
+
export class Http2Server extends Server {
#options: Record<string, unknown> = {};
#abortController;
@@ -1353,8 +1487,12 @@ export class Http2Server extends Server {
options: Record<string, unknown>,
requestListener: () => unknown,
) {
+ options = initializeOptions(options);
super(options);
+ this[kOptions] = options;
this.#abortController = new AbortController();
+ this.on("newListener", setupCompat);
+
this.on(
"connection",
(conn: Deno.Conn) => {
@@ -1386,8 +1524,8 @@ export class Http2Server extends Server {
controllerDeferred.promise,
req.body,
body,
+ req,
);
- session.emit("stream", stream, headers);
this.emit("stream", stream, headers);
return await stream._deferred.promise;
} catch (e) {
@@ -1405,10 +1543,6 @@ export class Http2Server extends Server {
}
},
);
- this.on(
- "newListener",
- (event) => console.log(`Event in newListener: ${event}`),
- );
this.#options = options;
if (typeof requestListener === "function") {
this.on("request", requestListener);
@@ -1420,14 +1554,6 @@ export class Http2Server extends Server {
return clientHandle[kStreamBaseField];
}
- close(callback?: () => unknown) {
- if (callback) {
- this.on("close", callback);
- }
- this.#abortController.abort();
- super.close();
- }
-
setTimeout(msecs: number, callback?: () => unknown) {
this.timeout = msecs;
if (callback !== undefined) {
@@ -1906,216 +2032,865 @@ export function getUnpackedSettings(
export const sensitiveHeaders = Symbol("nodejs.http2.sensitiveHeaders");
-export class Http2ServerRequest {
- constructor() {
+const kBeginSend = Symbol("begin-send");
+const kStream = Symbol("stream");
+const kResponse = Symbol("response");
+const kHeaders = Symbol("headers");
+const kRawHeaders = Symbol("rawHeaders");
+const kSocket = Symbol("socket");
+const kTrailers = Symbol("trailers");
+const kRawTrailers = Symbol("rawTrailers");
+const kSetHeader = Symbol("setHeader");
+const kAppendHeader = Symbol("appendHeader");
+const kAborted = Symbol("aborted");
+const kProxySocket = Symbol("proxySocket");
+const kRequest = Symbol("request");
+
+const proxySocketHandler = {
+ has(stream, prop) {
+ const ref = stream.session !== undefined ? stream.session[kSocket] : stream;
+ return (prop in stream) || (prop in ref);
+ },
+
+ get(stream, prop) {
+ switch (prop) {
+ case "on":
+ case "once":
+ case "end":
+ case "emit":
+ case "destroy":
+ return FunctionPrototypeBind(stream[prop], stream);
+ case "writable":
+ case "destroyed":
+ return stream[prop];
+ case "readable": {
+ if (stream.destroyed) {
+ return false;
+ }
+ const request = stream[kRequest];
+ return request ? request.readable : stream.readable;
+ }
+ case "setTimeout": {
+ const session = stream.session;
+ if (session !== undefined) {
+ return FunctionPrototypeBind(session.setTimeout, session);
+ }
+ return FunctionPrototypeBind(stream.setTimeout, stream);
+ }
+ case "write":
+ case "read":
+ case "pause":
+ case "resume":
+ throw new ERR_HTTP2_NO_SOCKET_MANIPULATION();
+ default: {
+ const ref = stream.session !== undefined
+ ? stream.session[kSocket]
+ : stream;
+ const value = ref[prop];
+ return typeof value === "function"
+ ? FunctionPrototypeBind(value, ref)
+ : value;
+ }
+ }
+ },
+ getPrototypeOf(stream) {
+ if (stream.session !== undefined) {
+ return ReflectGetPrototypeOf(stream.session[kSocket]);
+ }
+ return ReflectGetPrototypeOf(stream);
+ },
+ set(stream, prop, value) {
+ switch (prop) {
+ case "writable":
+ case "readable":
+ case "destroyed":
+ case "on":
+ case "once":
+ case "end":
+ case "emit":
+ case "destroy":
+ stream[prop] = value;
+ return true;
+ case "setTimeout": {
+ const session = stream.session;
+ if (session !== undefined) {
+ session.setTimeout = value;
+ } else {
+ stream.setTimeout = value;
+ }
+ return true;
+ }
+ case "write":
+ case "read":
+ case "pause":
+ case "resume":
+ throw new ERR_HTTP2_NO_SOCKET_MANIPULATION();
+ default: {
+ const ref = stream.session !== undefined
+ ? stream.session[kSocket]
+ : stream;
+ ref[prop] = value;
+ return true;
+ }
+ }
+ },
+};
+
+function onStreamCloseRequest() {
+ const req = this[kRequest];
+
+ if (req === undefined) {
+ return;
}
- get aborted(): boolean {
- notImplemented("Http2ServerRequest.aborted");
- return false;
+ const state = req[kState];
+ state.closed = true;
+
+ req.push(null);
+ // If the user didn't interact with incoming data and didn't pipe it,
+ // dump it for compatibility with http1
+ if (!state.didRead && !req._readableState.resumeScheduled) {
+ req.resume();
}
- get authority(): string {
- notImplemented("Http2ServerRequest.authority");
- return "";
+ this[kProxySocket] = null;
+ this[kRequest] = undefined;
+
+ req.emit("close");
+}
+
+function onStreamTimeout(kind) {
+ return function onStreamTimeout() {
+ const obj = this[kind];
+ obj.emit("timeout");
+ };
+}
+
+class Http2ServerRequest extends Readable {
+ readableEnded = false;
+
+ constructor(stream, headers, options, rawHeaders) {
+ super({ autoDestroy: false, ...options });
+ this[kState] = {
+ closed: false,
+ didRead: false,
+ };
+ // Headers in HTTP/1 are not initialized using Object.create(null) which,
+ // although preferable, would simply break too much code. Ergo header
+ // initialization using Object.create(null) in HTTP/2 is intentional.
+ this[kHeaders] = headers;
+ this[kRawHeaders] = rawHeaders;
+ this[kTrailers] = {};
+ this[kRawTrailers] = [];
+ this[kStream] = stream;
+ this[kAborted] = false;
+ stream[kProxySocket] = null;
+ stream[kRequest] = this;
+
+ // Pause the stream..
+ stream.on("trailers", onStreamTrailers);
+ stream.on("end", onStreamEnd);
+ stream.on("error", onStreamError);
+ stream.on("aborted", onStreamAbortedRequest);
+ stream.on("close", onStreamCloseRequest);
+ stream.on("timeout", onStreamTimeout(kRequest));
+ this.on("pause", onRequestPause);
+ this.on("resume", onRequestResume);
}
- get complete(): boolean {
- notImplemented("Http2ServerRequest.complete");
- return false;
+ get aborted() {
+ return this[kAborted];
}
- get connection(): Socket /*| TlsSocket*/ {
- notImplemented("Http2ServerRequest.connection");
- return {};
+ get complete() {
+ return this[kAborted] ||
+ this.readableEnded ||
+ this[kState].closed ||
+ this[kStream].destroyed;
}
- destroy(_error: Error) {
- notImplemented("Http2ServerRequest.destroy");
+ get stream() {
+ return this[kStream];
}
- get headers(): Record<string, unknown> {
- notImplemented("Http2ServerRequest.headers");
- return {};
+ get headers() {
+ return this[kHeaders];
}
- get httpVersion(): string {
- notImplemented("Http2ServerRequest.httpVersion");
- return "";
+ get rawHeaders() {
+ return this[kRawHeaders];
}
- get method(): string {
- notImplemented("Http2ServerRequest.method");
- return "";
+ get trailers() {
+ return this[kTrailers];
}
- get rawHeaders(): string[] {
- notImplemented("Http2ServerRequest.rawHeaders");
- return [];
+ get rawTrailers() {
+ return this[kRawTrailers];
}
- get rawTrailers(): string[] {
- notImplemented("Http2ServerRequest.rawTrailers");
- return [];
+ get httpVersionMajor() {
+ return 2;
}
- get scheme(): string {
- notImplemented("Http2ServerRequest.scheme");
- return "";
+ get httpVersionMinor() {
+ return 0;
}
- setTimeout(msecs: number, callback?: () => unknown) {
- this.stream.setTimeout(callback, msecs);
+ get httpVersion() {
+ return "2.0";
}
- get socket(): Socket /*| TlsSocket*/ {
- notImplemented("Http2ServerRequest.socket");
- return {};
+ get socket() {
+ const stream = this[kStream];
+ const proxySocket = stream[kProxySocket];
+ if (proxySocket === null) {
+ return stream[kProxySocket] = new Proxy(stream, proxySocketHandler);
+ }
+ return proxySocket;
}
- get stream(): Http2Stream {
- notImplemented("Http2ServerRequest.stream");
- return new Http2Stream();
+ get connection() {
+ return this.socket;
}
- get trailers(): Record<string, unknown> {
- notImplemented("Http2ServerRequest.trailers");
- return {};
+ // _read(nread) {
+ // const state = this[kState];
+ // assert(!state.closed);
+ // if (!state.didRead) {
+ // state.didRead = true;
+ // this[kStream].on("data", onStreamData);
+ // } else {
+ // nextTick(resumeStream, this[kStream]);
+ // }
+ // }
+
+ get method() {
+ return this[kHeaders][constants.HTTP2_HEADER_METHOD];
}
- get url(): string {
- notImplemented("Http2ServerRequest.url");
- return "";
+ set method(method) {
+ // validateString(method, "method");
+ if (StringPrototypeTrim(method) === "") {
+ throw new ERR_INVALID_ARG_VALUE("method", method);
+ }
+
+ this[kHeaders][constants.HTTP2_HEADER_METHOD] = method;
+ }
+
+ get authority() {
+ return getAuthority(this[kHeaders]);
+ }
+
+ get scheme() {
+ return this[kHeaders][constants.HTTP2_HEADER_SCHEME];
+ }
+
+ get url() {
+ return this[kHeaders][constants.HTTP2_HEADER_PATH];
+ }
+
+ set url(url) {
+ this[kHeaders][constants.HTTP2_HEADER_PATH] = url;
+ }
+
+ setTimeout(msecs, callback) {
+ if (!this[kState].closed) {
+ this[kStream].setTimeout(msecs, callback);
+ }
+ return this;
}
}
-export class Http2ServerResponse {
- constructor() {
+function onStreamEnd() {
+ // Cause the request stream to end as well.
+ const request = this[kRequest];
+ if (request !== undefined) {
+ this[kRequest].push(null);
}
+}
+
+function onStreamError(_error) {
+ // This is purposefully left blank
+ //
+ // errors in compatibility mode are
+ // not forwarded to the request
+ // and response objects.
+}
- addTrailers(_headers: Record<string, unknown>) {
- notImplemented("Http2ServerResponse.addTrailers");
+function onRequestPause() {
+ this[kStream].pause();
+}
+
+function onRequestResume() {
+ this[kStream].resume();
+}
+
+function onStreamDrain() {
+ const response = this[kResponse];
+ if (response !== undefined) {
+ response.emit("drain");
}
+}
- get connection(): Socket /*| TlsSocket*/ {
- notImplemented("Http2ServerResponse.connection");
- return {};
+function onStreamAbortedRequest() {
+ const request = this[kRequest];
+ if (request !== undefined && request[kState].closed === false) {
+ request[kAborted] = true;
+ request.emit("aborted");
}
+}
- createPushResponse(
- _headers: Record<string, unknown>,
- _callback: () => unknown,
- ) {
- notImplemented("Http2ServerResponse.createPushResponse");
+function onStreamTrailersReady() {
+ this.sendTrailers(this[kResponse][kTrailers]);
+}
+
+function onStreamCloseResponse() {
+ const res = this[kResponse];
+
+ if (res === undefined) {
+ return;
}
- end(
- _data: string | Buffer | Uint8Array,
- _encoding: string,
- _callback: () => unknown,
- ) {
- notImplemented("Http2ServerResponse.end");
+ const state = res[kState];
+
+ if (this.headRequest !== state.headRequest) {
+ return;
}
- get finished(): boolean {
- notImplemented("Http2ServerResponse.finished");
- return false;
+ state.closed = true;
+
+ this[kProxySocket] = null;
+
+ this.removeListener("wantTrailers", onStreamTrailersReady);
+ this[kResponse] = undefined;
+
+ res.emit("finish");
+ res.emit("close");
+}
+
+function onStreamAbortedResponse() {
+ // non-op for now
+}
+
+let statusMessageWarned = false;
+
+// Defines and implements an API compatibility layer on top of the core
+// HTTP/2 implementation, intended to provide an interface that is as
+// close as possible to the current require('http') API
+
+function statusMessageWarn() {
+ if (statusMessageWarned === false) {
+ emitWarning(
+ "Status message is not supported by HTTP/2 (RFC7540 8.1.2.4)",
+ "UnsupportedWarning",
+ );
+ statusMessageWarned = true;
}
+}
- getHeader(_name: string): string {
- notImplemented("Http2ServerResponse.getHeader");
- return "";
+function isConnectionHeaderAllowed(name, value) {
+ return name !== constants.HTTP2_HEADER_CONNECTION ||
+ value === "trailers";
+}
+
+class Http2ServerResponse extends Stream {
+ writable = false;
+ req = null;
+
+ constructor(stream, options) {
+ super(options);
+ this[kState] = {
+ closed: false,
+ ending: false,
+ destroyed: false,
+ headRequest: false,
+ sendDate: true,
+ statusCode: constants.HTTP_STATUS_OK,
+ };
+ this[kHeaders] = { __proto__: null };
+ this[kTrailers] = { __proto__: null };
+ this[kStream] = stream;
+ stream[kProxySocket] = null;
+ stream[kResponse] = this;
+ this.writable = true;
+ this.req = stream[kRequest];
+ stream.on("drain", onStreamDrain);
+ stream.on("aborted", onStreamAbortedResponse);
+ stream.on("close", onStreamCloseResponse);
+ stream.on("wantTrailers", onStreamTrailersReady);
+ stream.on("timeout", onStreamTimeout(kResponse));
+ }
+
+ // User land modules such as finalhandler just check truthiness of this
+ // but if someone is actually trying to use this for more than that
+ // then we simply can't support such use cases
+ get _header() {
+ return this.headersSent;
+ }
+
+ get writableEnded() {
+ const state = this[kState];
+ return state.ending;
}
- getHeaderNames(): string[] {
- notImplemented("Http2ServerResponse.getHeaderNames");
- return [];
+ get finished() {
+ const state = this[kState];
+ return state.ending;
}
- getHeaders(): Record<string, unknown> {
- notImplemented("Http2ServerResponse.getHeaders");
- return {};
+ get socket() {
+ // This is compatible with http1 which removes socket reference
+ // only from ServerResponse but not IncomingMessage
+ if (this[kState].closed) {
+ return undefined;
+ }
+
+ const stream = this[kStream];
+ const proxySocket = stream[kProxySocket];
+ if (proxySocket === null) {
+ return stream[kProxySocket] = new Proxy(stream, proxySocketHandler);
+ }
+ return proxySocket;
}
- hasHeader(_name: string) {
- notImplemented("Http2ServerResponse.hasHeader");
+ get connection() {
+ return this.socket;
}
- get headersSent(): boolean {
- notImplemented("Http2ServerResponse.headersSent");
- return false;
+ get stream() {
+ return this[kStream];
}
- removeHeader(_name: string) {
- notImplemented("Http2ServerResponse.removeHeader");
+ get headersSent() {
+ return this[kStream].headersSent;
}
- get req(): Http2ServerRequest {
- notImplemented("Http2ServerResponse.req");
- return new Http2ServerRequest();
+ get sendDate() {
+ return this[kState].sendDate;
}
- get sendDate(): boolean {
- notImplemented("Http2ServerResponse.sendDate");
- return false;
+ set sendDate(bool) {
+ this[kState].sendDate = Boolean(bool);
}
- setHeader(_name: string, _value: string | string[]) {
- notImplemented("Http2ServerResponse.setHeader");
+ get writableCorked() {
+ return this[kStream].writableCorked;
}
- setTimeout(msecs: number, callback?: () => unknown) {
- this.stream.setTimeout(msecs, callback);
+ get writableHighWaterMark() {
+ return this[kStream].writableHighWaterMark;
}
- get socket(): Socket /*| TlsSocket*/ {
- notImplemented("Http2ServerResponse.socket");
- return {};
+ get writableFinished() {
+ return this[kStream].writableFinished;
}
- get statusCode(): number {
- notImplemented("Http2ServerResponse.statusCode");
- return 0;
+ get writableLength() {
+ return this[kStream].writableLength;
+ }
+
+ get statusCode() {
+ return this[kState].statusCode;
+ }
+
+ set statusCode(code) {
+ code |= 0;
+ if (code >= 100 && code < 200) {
+ throw new ERR_HTTP2_INFO_STATUS_NOT_ALLOWED();
+ }
+ if (code < 100 || code > 599) {
+ throw new ERR_HTTP2_STATUS_INVALID(code);
+ }
+ this[kState].statusCode = code;
+ }
+
+ setTrailer(name, value) {
+ // validateString(name, "name");
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+ // assertValidHeader(name, value);
+ this[kTrailers][name] = value;
+ }
+
+ addTrailers(headers) {
+ const keys = ObjectKeys(headers);
+ let key = "";
+ for (let i = 0; i < keys.length; i++) {
+ key = keys[i];
+ this.setTrailer(key, headers[key]);
+ }
+ }
+
+ getHeader(name) {
+ // validateString(name, "name");
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+ return this[kHeaders][name];
+ }
+
+ getHeaderNames() {
+ return ObjectKeys(this[kHeaders]);
+ }
+
+ getHeaders() {
+ const headers = { __proto__: null };
+ return ObjectAssign(headers, this[kHeaders]);
+ }
+
+ hasHeader(name) {
+ // validateString(name, "name");
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+ return ObjectPrototypeHasOwnProperty(this[kHeaders], name);
+ }
+
+ removeHeader(name) {
+ // validateString(name, "name");
+ if (this[kStream].headersSent) {
+ throw new ERR_HTTP2_HEADERS_SENT();
+ }
+
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+
+ if (name === "date") {
+ this[kState].sendDate = false;
+
+ return;
+ }
+
+ delete this[kHeaders][name];
}
- get statusMessage(): string {
- notImplemented("Http2ServerResponse.statusMessage");
+ setHeader(name, value) {
+ // validateString(name, "name");
+ if (this[kStream].headersSent) {
+ throw new ERR_HTTP2_HEADERS_SENT();
+ }
+
+ this[kSetHeader](name, value);
+ }
+
+ [kSetHeader](name, value) {
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+ // assertValidHeader(name, value);
+
+ if (!isConnectionHeaderAllowed(name, value)) {
+ return;
+ }
+
+ if (name[0] === ":") {
+ assertValidPseudoHeader(name);
+ } else if (!_checkIsHttpToken(name)) {
+ this.destroy(new ERR_INVALID_HTTP_TOKEN("Header name", name));
+ }
+
+ this[kHeaders][name] = value;
+ }
+
+ appendHeader(name, value) {
+ // validateString(name, "name");
+ if (this[kStream].headersSent) {
+ throw new ERR_HTTP2_HEADERS_SENT();
+ }
+
+ this[kAppendHeader](name, value);
+ }
+
+ [kAppendHeader](name, value) {
+ name = StringPrototypeToLowerCase(StringPrototypeTrim(name));
+ // assertValidHeader(name, value);
+
+ if (!isConnectionHeaderAllowed(name, value)) {
+ return;
+ }
+
+ if (name[0] === ":") {
+ assertValidPseudoHeader(name);
+ } else if (!_checkIsHttpToken(name)) {
+ this.destroy(new ERR_INVALID_HTTP_TOKEN("Header name", name));
+ }
+
+ // Handle various possible cases the same as OutgoingMessage.appendHeader:
+ const headers = this[kHeaders];
+ if (headers === null || !headers[name]) {
+ return this.setHeader(name, value);
+ }
+
+ if (!ArrayIsArray(headers[name])) {
+ headers[name] = [headers[name]];
+ }
+
+ const existingValues = headers[name];
+ if (ArrayIsArray(value)) {
+ for (let i = 0, length = value.length; i < length; i++) {
+ existingValues.push(value[i]);
+ }
+ } else {
+ existingValues.push(value);
+ }
+ }
+
+ get statusMessage() {
+ statusMessageWarn();
+
return "";
}
- get stream(): Http2Stream {
- notImplemented("Http2ServerResponse.stream");
- return new Http2Stream();
+ set statusMessage(msg) {
+ statusMessageWarn();
}
- get writableEnded(): boolean {
- notImplemented("Http2ServerResponse.writableEnded");
- return false;
+ flushHeaders() {
+ const state = this[kState];
+ if (!state.closed && !this[kStream].headersSent) {
+ this.writeHead(state.statusCode);
+ }
}
- write(
- _chunk: string | Buffer | Uint8Array,
- _encoding: string,
- _callback: () => unknown,
- ) {
- notImplemented("Http2ServerResponse.write");
- return this.write;
+ writeHead(statusCode, statusMessage, headers) {
+ const state = this[kState];
+
+ if (state.closed || this.stream.destroyed) {
+ return this;
+ }
+ if (this[kStream].headersSent) {
+ throw new ERR_HTTP2_HEADERS_SENT();
+ }
+
+ if (typeof statusMessage === "string") {
+ statusMessageWarn();
+ }
+
+ if (headers === undefined && typeof statusMessage === "object") {
+ headers = statusMessage;
+ }
+
+ let i;
+ if (ArrayIsArray(headers)) {
+ if (this[kHeaders]) {
+ // Headers in obj should override previous headers but still
+ // allow explicit duplicates. To do so, we first remove any
+ // existing conflicts, then use appendHeader. This is the
+ // slow path, which only applies when you use setHeader and
+ // then pass headers in writeHead too.
+
+ // We need to handle both the tuple and flat array formats, just
+ // like the logic further below.
+ if (headers.length && ArrayIsArray(headers[0])) {
+ for (let n = 0; n < headers.length; n += 1) {
+ const key = headers[n + 0][0];
+ this.removeHeader(key);
+ }
+ } else {
+ for (let n = 0; n < headers.length; n += 2) {
+ const key = headers[n + 0];
+ this.removeHeader(key);
+ }
+ }
+ }
+
+ // Append all the headers provided in the array:
+ if (headers.length && ArrayIsArray(headers[0])) {
+ for (i = 0; i < headers.length; i++) {
+ const header = headers[i];
+ this[kAppendHeader](header[0], header[1]);
+ }
+ } else {
+ if (headers.length % 2 !== 0) {
+ throw new ERR_INVALID_ARG_VALUE("headers", headers);
+ }
+
+ for (i = 0; i < headers.length; i += 2) {
+ this[kAppendHeader](headers[i], headers[i + 1]);
+ }
+ }
+ } else if (typeof headers === "object") {
+ const keys = ObjectKeys(headers);
+ let key = "";
+ for (i = 0; i < keys.length; i++) {
+ key = keys[i];
+ this[kSetHeader](key, headers[key]);
+ }
+ }
+
+ state.statusCode = statusCode;
+ this[kBeginSend]();
+
+ return this;
}
- writeContinue() {
- notImplemented("Http2ServerResponse.writeContinue");
+ cork() {
+ this[kStream].cork();
}
- writeEarlyHints(_hints: Record<string, unknown>) {
- notImplemented("Http2ServerResponse.writeEarlyHints");
+ uncork() {
+ this[kStream].uncork();
}
- writeHead(
- _statusCode: number,
- _statusMessage: string,
- _headers: Record<string, unknown>,
- ) {
- notImplemented("Http2ServerResponse.writeHead");
+ write(chunk, encoding, cb) {
+ const state = this[kState];
+
+ if (typeof encoding === "function") {
+ cb = encoding;
+ encoding = "utf8";
+ }
+
+ let err;
+ if (state.ending) {
+ err = new ERR_STREAM_WRITE_AFTER_END();
+ } else if (state.closed) {
+ err = new ERR_HTTP2_INVALID_STREAM();
+ } else if (state.destroyed) {
+ return false;
+ }
+
+ if (err) {
+ if (typeof cb === "function") {
+ nextTick(cb, err);
+ }
+ this.destroy(err);
+ return false;
+ }
+
+ const stream = this[kStream];
+ if (!stream.headersSent) {
+ this.writeHead(state.statusCode);
+ }
+ return stream.write(chunk, encoding, cb);
+ }
+
+ end(chunk, encoding, cb) {
+ const stream = this[kStream];
+ const state = this[kState];
+
+ if (typeof chunk === "function") {
+ cb = chunk;
+ chunk = null;
+ } else if (typeof encoding === "function") {
+ cb = encoding;
+ encoding = "utf8";
+ }
+
+ if (
+ (state.closed || state.ending) &&
+ state.headRequest === stream.headRequest
+ ) {
+ if (typeof cb === "function") {
+ nextTick(cb);
+ }
+ return this;
+ }
+
+ if (chunk !== null && chunk !== undefined) {
+ this.write(chunk, encoding);
+ }
+
+ state.headRequest = stream.headRequest;
+ state.ending = true;
+
+ if (typeof cb === "function") {
+ if (stream.writableEnded) {
+ this.once("finish", cb);
+ } else {
+ stream.once("finish", cb);
+ }
+ }
+
+ if (!stream.headersSent) {
+ this.writeHead(this[kState].statusCode);
+ }
+
+ if (this[kState].closed || stream.destroyed) {
+ ReflectApply(onStreamCloseResponse, stream, []);
+ } else {
+ stream.end();
+ }
+
+ return this;
+ }
+
+ destroy(err) {
+ if (this[kState].destroyed) {
+ return;
+ }
+
+ this[kState].destroyed = true;
+ this[kStream].destroy(err);
+ }
+
+ setTimeout(msecs, callback) {
+ if (this[kState].closed) {
+ return;
+ }
+ this[kStream].setTimeout(msecs, callback);
+ }
+
+ createPushResponse(headers, callback) {
+ // validateFunction(callback, "callback");
+ if (this[kState].closed) {
+ nextTick(callback, new ERR_HTTP2_INVALID_STREAM());
+ return;
+ }
+ this[kStream].pushStream(headers, {}, (err, stream, _headers, options) => {
+ if (err) {
+ callback(err);
+ return;
+ }
+ callback(null, new Http2ServerResponse(stream, options));
+ });
+ }
+
+ [kBeginSend]() {
+ const state = this[kState];
+ const headers = this[kHeaders];
+ headers[constants.HTTP2_HEADER_STATUS] = state.statusCode;
+ const options = {
+ endStream: state.ending,
+ waitForTrailers: true,
+ sendDate: state.sendDate,
+ };
+ this[kStream].respond(headers, options);
+ }
+
+ writeContinue() {
+ const stream = this[kStream];
+ if (stream.headersSent || this[kState].closed) {
+ return false;
+ }
+ stream.additionalHeaders({
+ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_CONTINUE,
+ });
+ return true;
+ }
+
+ writeEarlyHints(hints) {
+ // validateObject(hints, "hints");
+
+ const headers = { __proto__: null };
+
+ // const linkHeaderValue = validateLinkHeaderValue(hints.link);
+
+ for (const key of ObjectKeys(hints)) {
+ if (key !== "link") {
+ headers[key] = hints[key];
+ }
+ }
+
+ // if (linkHeaderValue.length === 0) {
+ // return false;
+ // }
+
+ const stream = this[kStream];
+
+ if (stream.headersSent || this[kState].closed) {
+ return false;
+ }
+
+ stream.additionalHeaders({
+ ...headers,
+ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_EARLY_HINTS,
+ // "Link": linkHeaderValue,
+ });
+
+ return true;
}
}
diff --git a/ext/node/polyfills/internal/errors.ts b/ext/node/polyfills/internal/errors.ts
index 5d5946d46..5a6446ae8 100644
--- a/ext/node/polyfills/internal/errors.ts
+++ b/ext/node/polyfills/internal/errors.ts
@@ -2248,6 +2248,16 @@ export class ERR_FALSY_VALUE_REJECTION extends NodeError {
this.reason = reason;
}
}
+
+export class ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS extends NodeError {
+ constructor() {
+ super(
+ "ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS",
+ "Number of custom settings exceeds MAX_ADDITIONAL_SETTINGS",
+ );
+ }
+}
+
export class ERR_HTTP2_INVALID_SETTING_VALUE extends NodeRangeError {
actual: unknown;
min?: number;
diff --git a/tests/unit/serve_test.ts b/tests/unit/serve_test.ts
index 5d83aa5fc..9b2870ebd 100644
--- a/tests/unit/serve_test.ts
+++ b/tests/unit/serve_test.ts
@@ -8,6 +8,8 @@ import {
assertEquals,
assertStringIncludes,
assertThrows,
+ curlRequest,
+ curlRequestWithStdErr,
execCode,
fail,
tmpUnixSocketPath,
@@ -3793,32 +3795,6 @@ Deno.test(
},
);
-async function curlRequest(args: string[]) {
- const { success, stdout, stderr } = await new Deno.Command("curl", {
- args,
- stdout: "piped",
- stderr: "piped",
- }).output();
- assert(
- success,
- `Failed to cURL ${args}: stdout\n\n${stdout}\n\nstderr:\n\n${stderr}`,
- );
- return new TextDecoder().decode(stdout);
-}
-
-async function curlRequestWithStdErr(args: string[]) {
- const { success, stdout, stderr } = await new Deno.Command("curl", {
- args,
- stdout: "piped",
- stderr: "piped",
- }).output();
- assert(
- success,
- `Failed to cURL ${args}: stdout\n\n${stdout}\n\nstderr:\n\n${stderr}`,
- );
- return [new TextDecoder().decode(stdout), new TextDecoder().decode(stderr)];
-}
-
Deno.test("Deno.HttpServer is not thenable", async () => {
// deno-lint-ignore require-await
async function serveTest() {
diff --git a/tests/unit/test_util.ts b/tests/unit/test_util.ts
index c73f52b15..ba9bf1839 100644
--- a/tests/unit/test_util.ts
+++ b/tests/unit/test_util.ts
@@ -1,6 +1,7 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import * as colors from "@std/fmt/colors.ts";
+import { assert } from "@std/assert/mod.ts";
export { colors };
import { join, resolve } from "@std/path/mod.ts";
export {
@@ -85,3 +86,35 @@ export function tmpUnixSocketPath(): string {
const folder = Deno.makeTempDirSync();
return join(folder, "socket");
}
+
+export async function curlRequest(args: string[]) {
+ const { success, stdout, stderr } = await new Deno.Command("curl", {
+ args,
+ stdout: "piped",
+ stderr: "piped",
+ }).output();
+ const decoder = new TextDecoder();
+ assert(
+ success,
+ `Failed to cURL ${args}: stdout\n\n${
+ decoder.decode(stdout)
+ }\n\nstderr:\n\n${decoder.decode(stderr)}`,
+ );
+ return decoder.decode(stdout);
+}
+
+export async function curlRequestWithStdErr(args: string[]) {
+ const { success, stdout, stderr } = await new Deno.Command("curl", {
+ args,
+ stdout: "piped",
+ stderr: "piped",
+ }).output();
+ const decoder = new TextDecoder();
+ assert(
+ success,
+ `Failed to cURL ${args}: stdout\n\n${
+ decoder.decode(stdout)
+ }\n\nstderr:\n\n${decoder.decode(stderr)}`,
+ );
+ return [decoder.decode(stdout), decoder.decode(stderr)];
+}
diff --git a/tests/unit_node/http2_test.ts b/tests/unit_node/http2_test.ts
index fd9cdd0ec..872e6641e 100644
--- a/tests/unit_node/http2_test.ts
+++ b/tests/unit_node/http2_test.ts
@@ -3,6 +3,7 @@
import * as http2 from "node:http2";
import * as net from "node:net";
import { assert, assertEquals } from "@std/assert/mod.ts";
+import { curlRequest } from "../unit/test_util.ts";
for (const url of ["http://127.0.0.1:4246", "https://127.0.0.1:4247"]) {
Deno.test(`[node/http2 client] ${url}`, {
@@ -108,35 +109,6 @@ Deno.test(`[node/http2 client createConnection]`, {
assertEquals(receivedData, "hello world\n");
});
-// TODO(bartlomieju): reenable sanitizers
-Deno.test("[node/http2 server]", { sanitizeOps: false }, async () => {
- const server = http2.createServer();
- server.listen(0);
- const port = (<net.AddressInfo> server.address()).port;
- const sessionPromise = new Promise<http2.Http2Session>((resolve) =>
- server.on("session", resolve)
- );
-
- const responsePromise = fetch(`http://localhost:${port}/path`, {
- method: "POST",
- body: "body",
- });
-
- const session = await sessionPromise;
- const stream = await new Promise<http2.ServerHttp2Stream>((resolve) =>
- session.on("stream", resolve)
- );
- await new Promise((resolve) => stream.on("headers", resolve));
- await new Promise((resolve) => stream.on("data", resolve));
- await new Promise((resolve) => stream.on("end", resolve));
- stream.respond();
- stream.end();
- const resp = await responsePromise;
- await resp.text();
-
- await new Promise((resolve) => server.close(resolve));
-});
-
Deno.test("[node/http2 client GET https://www.example.com]", async () => {
const clientSession = http2.connect("https://www.example.com");
const req = clientSession.request({
@@ -165,3 +137,30 @@ Deno.test("[node/http2 client GET https://www.example.com]", async () => {
assertEquals(status, 200);
assert(chunk.length > 0);
});
+
+Deno.test("[node/http2.createServer()]", {
+ // TODO(satyarohith): enable the test on windows.
+ ignore: Deno.build.os === "windows",
+}, async () => {
+ const server = http2.createServer((_req, res) => {
+ res.setHeader("Content-Type", "text/html");
+ res.setHeader("X-Foo", "bar");
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
+ res.write("Hello, World!");
+ res.end();
+ });
+ server.listen(0);
+ const port = (<net.AddressInfo> server.address()).port;
+ const endpoint = `http://localhost:${port}`;
+
+ const response = await curlRequest([
+ endpoint,
+ "--http2-prior-knowledge",
+ ]);
+ assertEquals(response, "Hello, World!");
+ server.close();
+ // Wait to avoid leaking the timer from here
+ // https://github.com/denoland/deno/blob/749b6e45e58ac87188027f79fe403d130f86bd73/ext/node/polyfills/net.ts#L2396-L2402
+ // Issue: https://github.com/denoland/deno/issues/22764
+ await new Promise<void>((resolve) => server.on("close", resolve));
+});