diff --git a/ext/node/polyfills/http2.ts b/ext/node/polyfills/http2.ts index d86148e4e2d064..8b5c16b2afcae6 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; @@ -564,6 +591,8 @@ export class Http2Stream extends EventEmitter { #readerPromise: Promise>; #closed: boolean; _response: Response; + // This is required to set the trailers on the response. + _request: Request; constructor( session: Http2Session, @@ -697,8 +726,9 @@ export class Http2Stream extends EventEmitter { return {}; } - sendTrailers(_headers: Record) { - addTrailers(this._response, [["grpc-status", "0"], ["grpc-message", "OK"]]); + sendTrailers(headers: Record) { + const request = toInnerRequest(this._request); + op_http_set_response_trailers(request.external, Object.entries(headers)); } } @@ -1270,10 +1300,13 @@ export class ServerHttp2Stream extends Http2Stream { controllerPromise: Promise>, reader: ReadableStream, body: ReadableStream, + // This is required to set the trailers on the response. + req: Request, ) { super(session, headers, controllerPromise, Promise.resolve(reader)); this._deferred = Promise.withResolvers(); this.#body = body; + this._request = req; } additionalHeaders(_headers: Record) { @@ -1344,6 +1377,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 = {}; #abortController; @@ -1354,8 +1488,12 @@ export class Http2Server extends Server { options: Record, requestListener: () => unknown, ) { + options = initializeOptions(options); super(options); + this[kOptions] = options; this.#abortController = new AbortController(); + this.on("newListener", setupCompat); + this.on( "connection", (conn: Deno.Conn) => { @@ -1387,8 +1525,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) { @@ -1406,10 +1544,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); @@ -1421,14 +1555,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) { @@ -1907,216 +2033,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 { - 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 { - 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) { - 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, - _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 { - 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) { - notImplemented("Http2ServerResponse.writeEarlyHints"); + uncork() { + this[kStream].uncork(); } - writeHead( - _statusCode: number, - _statusMessage: string, - _headers: Record, - ) { - 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 5d5946d464c740..5a6446ae8f7bd7 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 5d83aa5fc66590..9b2870ebd112c9 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 c73f52b1589eaa..ba9bf1839a6fdc 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 fd9cdd0ec8a091..872e6641e29ca4 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 = ( server.address()).port; - const sessionPromise = new Promise((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((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 = ( 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((resolve) => server.on("close", resolve)); +});