diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index ece41dcec6..b2f5bb7970 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -624,6 +624,11 @@ public: return std::move(*this); } + TemplatedApp &&setRequireHostHeader(bool value) { + httpContext->getSocketContextData()->requireHostHeader = value; + return std::move(*this); + } + }; typedef TemplatedApp App; diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 67cd550a3e..07086ae630 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -174,7 +174,7 @@ private: #endif /* The return value is entirely up to us to interpret. The HttpParser cares only for whether the returned value is DIFFERENT from passed user */ - auto [err, returnedSocket] = httpResponseData->consumePostPadded(data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto [err, returnedSocket] = httpResponseData->consumePostPadded(httpContextData->requireHostHeader,data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { /* For every request we reset the timeout and hang until user makes action */ /* Warning: if we are in shutdown state, resetting the timer is a security issue! */ us_socket_timeout(SSL, (us_socket_t *) s, 0); diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index c71f3098d2..e2ddfee497 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -52,6 +52,7 @@ private: bool isParsingHttp = false; bool rejectUnauthorized = false; bool usingCustomExpectHandler = false; + bool requireHostHeader = true; /* Used to simulate Node.js socket events. */ OnSocketClosedCallback onSocketClosed = nullptr; diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index dbe20ff0ed..1ec2569a09 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -483,6 +483,17 @@ namespace uWS } return 0; } + /* No request headers found */ + size_t buffer_size = end - postPaddedBuffer; + if(buffer_size < 2) { + /* Fragmented request */ + err = HTTP_ERROR_400_BAD_REQUEST; + return 0; + } + if(buffer_size >= 2 && postPaddedBuffer[0] == '\r' && postPaddedBuffer[1] == '\n') { + /* No headers found */ + return (unsigned int) ((postPaddedBuffer + 2) - start); + } headers++; for (unsigned int i = 1; i < UWS_HTTP_MAX_HEADERS_COUNT - 1; i++) { @@ -568,7 +579,7 @@ namespace uWS * or [consumed, nullptr] for "break; I am closed or upgraded to websocket" * or [whatever, fullptr] for "break and close me, I am a parser error!" */ template - std::pair fenceAndConsumePostPadded(char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + std::pair fenceAndConsumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { /* How much data we CONSUMED (to throw away) */ unsigned int consumedTotal = 0; @@ -579,7 +590,6 @@ namespace uWS data[length] = '\r'; data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ bool isAncientHTTP = false; - for (unsigned int consumed; length && (consumed = getHeaders(data, data + length, req->headers, reserved, err, isAncientHTTP)); ) { data += consumed; length -= consumed; @@ -595,12 +605,12 @@ namespace uWS /* Add all headers to bloom filter */ req->bf.reset(); + for (HttpRequest::Header *h = req->headers; (++h)->key.length(); ) { req->bf.add(h->key); } - /* Break if no host header (but we can have empty string which is different from nullptr) */ - if (!req->getHeader("host").data()) { + if (!isAncientHTTP && requireHostHeader && !req->getHeader("host").data()) { return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; } @@ -719,7 +729,7 @@ namespace uWS } public: - std::pair consumePostPadded(char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + std::pair consumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { /* This resets BloomFilter by construction, but later we also reset it again. * Optimize this to skip resetting twice (req could be made global) */ @@ -768,7 +778,7 @@ public: fallback.append(data, maxCopyDistance); // break here on break - std::pair consumed = fenceAndConsumePostPadded(fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); + std::pair consumed = fenceAndConsumePostPadded(requireHostHeader,fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); if (consumed.second != user) { return consumed; } @@ -823,7 +833,7 @@ public: } } - std::pair consumed = fenceAndConsumePostPadded(data, length, user, reserved, &req, requestHandler, dataHandler); + std::pair consumed = fenceAndConsumePostPadded(requireHostHeader,data, length, user, reserved, &req, requestHandler, dataHandler); if (consumed.second != user) { return consumed; } diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index b0a6651fe8..f852d72e5a 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -462,6 +462,28 @@ public: return internalEnd({nullptr, 0}, 0, false, false, closeConnection); } + void flushHeaders() { + + writeStatus(HTTP_200_OK); + + HttpResponseData *httpResponseData = getHttpResponseData(); + + if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER) && !httpResponseData->fromAncientRequest) { + if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { + /* Write mark on first call to write */ + writeMark(); + + writeHeader("Transfer-Encoding", "chunked"); + Super::write("\r\n", 2); + httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; + } + + } else if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { + writeMark(); + Super::write("\r\n", 2); + httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; + } + } /* Write parts of the response in chunking fashion. Starts timeout if failed. */ bool write(std::string_view data, size_t *writtenPtr = nullptr) { writeStatus(HTTP_200_OK); diff --git a/src/bun.js/api/bun/socket.zig b/src/bun.js/api/bun/socket.zig index 82b5602534..07b5325ae8 100644 --- a/src/bun.js/api/bun/socket.zig +++ b/src/bun.js/api/bun/socket.zig @@ -4482,11 +4482,28 @@ pub fn jsIsNamedPipeSocket(global: *JSC.JSGlobalObject, callframe: *JSC.CallFram } return JSC.JSValue.jsBoolean(false); } + +pub fn jsGetBufferedAmount(global: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + JSC.markBinding(@src()); + + const arguments = callframe.arguments_old(3); + if (arguments.len < 1) { + return global.throwNotEnoughArguments("getBufferedAmount", 1, arguments.len); + } + const socket = arguments.ptr[0]; + if (socket.as(TCPSocket)) |this| { + return JSC.JSValue.jsNumber(this.buffered_data_for_node_net.len); + } else if (socket.as(TLSSocket)) |this| { + return JSC.JSValue.jsNumber(this.buffered_data_for_node_net.len); + } + return JSC.JSValue.jsNumber(0); +} pub fn createNodeTLSBinding(global: *JSC.JSGlobalObject) JSC.JSValue { return JSC.JSArray.create(global, &.{ JSC.JSFunction.create(global, "addServerName", jsAddServerName, 3, .{}), JSC.JSFunction.create(global, "upgradeDuplexToTLS", jsUpgradeDuplexToTLS, 2, .{}), JSC.JSFunction.create(global, "isNamedPipeSocket", jsIsNamedPipeSocket, 1, .{}), + JSC.JSFunction.create(global, "getBufferedAmount", jsGetBufferedAmount, 1, .{}), }); } diff --git a/src/bun.js/api/server.classes.ts b/src/bun.js/api/server.classes.ts index 0584b7a23f..fb867be70a 100644 --- a/src/bun.js/api/server.classes.ts +++ b/src/bun.js/api/server.classes.ts @@ -111,6 +111,10 @@ export default [ fn: "end", length: 2, }, + flushHeaders: { + fn: "flushHeaders", + length: 0, + }, cork: { fn: "cork", length: 1, diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 3f3891009e..9e8d2addb2 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -5322,6 +5322,12 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d this.config.idleTimeout = @truncate(@min(seconds, 255)); } + pub fn setRequireHostHeader(this: *ThisServer, require_host_header: bool) void { + if (this.app) |app| { + app.setRequireHostHeader(require_host_header); + } + } + pub fn appendStaticRoute(this: *ThisServer, path: []const u8, route: AnyRoute) !void { try this.config.appendStaticRoute(path, route); } @@ -7607,8 +7613,14 @@ extern fn Bun__addInspector(bool, *anyopaque, *JSC.JSGlobalObject) void; const assert = bun.assert; pub export fn Server__setIdleTimeout(server: JSC.JSValue, seconds: JSC.JSValue, globalThis: *JSC.JSGlobalObject) void { - Server__setIdleTimeout_(server, seconds, globalThis) catch return; + Server__setIdleTimeout_(server, seconds, globalThis) catch |err| switch (err) { + error.JSError => {}, + error.OutOfMemory => { + _ = globalThis.throwOutOfMemoryValue(); + }, + }; } + pub fn Server__setIdleTimeout_(server: JSC.JSValue, seconds: JSC.JSValue, globalThis: *JSC.JSGlobalObject) bun.JSError!void { if (!server.isObject()) { return globalThis.throw("Failed to set timeout: The 'this' value is not a Server.", .{}); @@ -7630,9 +7642,35 @@ pub fn Server__setIdleTimeout_(server: JSC.JSValue, seconds: JSC.JSValue, global return globalThis.throw("Failed to set timeout: The 'this' value is not a Server.", .{}); } } +pub export fn Server__setRequireHostHeader(server: JSC.JSValue, require_host_header: bool, globalThis: *JSC.JSGlobalObject) void { + Server__setRequireHostHeader_(server, require_host_header, globalThis) catch |err| switch (err) { + error.JSError => {}, + error.OutOfMemory => { + _ = globalThis.throwOutOfMemoryValue(); + }, + }; +} +pub fn Server__setRequireHostHeader_(server: JSC.JSValue, require_host_header: bool, globalThis: *JSC.JSGlobalObject) bun.JSError!void { + if (!server.isObject()) { + return globalThis.throw("Failed to set requireHostHeader: The 'this' value is not a Server.", .{}); + } + + if (server.as(HTTPServer)) |this| { + this.setRequireHostHeader(require_host_header); + } else if (server.as(HTTPSServer)) |this| { + this.setRequireHostHeader(require_host_header); + } else if (server.as(DebugHTTPServer)) |this| { + this.setRequireHostHeader(require_host_header); + } else if (server.as(DebugHTTPSServer)) |this| { + this.setRequireHostHeader(require_host_header); + } else { + return globalThis.throw("Failed to set timeout: The 'this' value is not a Server.", .{}); + } +} comptime { _ = Server__setIdleTimeout; + _ = Server__setRequireHostHeader; _ = NodeHTTPResponse.create; } diff --git a/src/bun.js/api/server/NodeHTTPResponse.zig b/src/bun.js/api/server/NodeHTTPResponse.zig index a12a50de1f..0dfd98a7e9 100644 --- a/src/bun.js/api/server/NodeHTTPResponse.zig +++ b/src/bun.js/api/server/NodeHTTPResponse.zig @@ -382,7 +382,7 @@ pub fn getHasBody(this: *const NodeHTTPResponse, _: *JSC.JSGlobalObject) JSC.JSV pub fn getBufferedAmount(this: *const NodeHTTPResponse, _: *JSC.JSGlobalObject) JSC.JSValue { if (this.flags.request_has_completed or this.flags.socket_closed) { - return JSC.JSValue.jsNull(); + return JSC.JSValue.jsNumber(0); } return JSC.JSValue.jsNumber(this.raw_response.getBufferedAmount()); @@ -999,6 +999,11 @@ pub fn write(this: *NodeHTTPResponse, globalObject: *JSC.JSGlobalObject, callfra return writeOrEnd(this, globalObject, arguments, .zero, false); } +pub fn flushHeaders(this: *NodeHTTPResponse, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue { + this.raw_response.flushHeaders(); + return .undefined; +} + pub fn end(this: *NodeHTTPResponse, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { const arguments = callframe.arguments_old(3).slice(); //We dont wanna a paused socket when we call end, so is important to resume the socket diff --git a/src/bun.js/bindings/NodeHTTP.cpp b/src/bun.js/bindings/NodeHTTP.cpp index e6642c1470..fbdabd12a8 100644 --- a/src/bun.js/bindings/NodeHTTP.cpp +++ b/src/bun.js/bindings/NodeHTTP.cpp @@ -492,6 +492,7 @@ extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JS extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); +extern "C" void Server__setRequireHostHeader(EncodedJSValue, bool, JSC::JSGlobalObject*); static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject* prototype, JSObject* objectValue, JSC::InternalFieldTuple* tuple, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -911,6 +912,7 @@ static EncodedJSValue NodeHTTPServer__onRequest( args.append(jsBoolean(true)); args.append(jsUndefined()); } + args.append(jsBoolean(request->isAncient())); WTF::NakedPtr exception; JSValue returnValue = AsyncContextFrame::call(globalObject, callbackObject, jsUndefined(), args, exception); @@ -1268,6 +1270,22 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetServerIdleTimeout, (JSGlobalObject * globalObj return JSValue::encode(jsUndefined()); } +JSC_DEFINE_HOST_FUNCTION(jsHTTPSetRequireHostHeader, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + // This is an internal binding. + JSValue serverValue = callFrame->uncheckedArgument(0); + JSValue requireHostHeader = callFrame->uncheckedArgument(1); + + ASSERT(callFrame->argumentCount() == 2); + + Server__setRequireHostHeader(JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), globalObject); + + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsHTTPGetHeader, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -1386,6 +1404,10 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerIdleTimeout"_s)), JSC::JSFunction::create(vm, globalObject, 2, "setServerIdleTimeout"_s, jsHTTPSetServerIdleTimeout, ImplementationVisibility::Public), 0); + + obj->putDirect( + vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setRequireHostHeader"_s)), + JSC::JSFunction::create(vm, globalObject, 2, "setRequireHostHeader"_s, jsHTTPSetRequireHostHeader, ImplementationVisibility::Public), 0); obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "Response"_s)), globalObject->JSResponseConstructor(), 0); diff --git a/src/deps/libuwsockets.cpp b/src/deps/libuwsockets.cpp index 0cae069908..2324a573e0 100644 --- a/src/deps/libuwsockets.cpp +++ b/src/deps/libuwsockets.cpp @@ -470,6 +470,15 @@ extern "C" uwsApp->domain(server_name); } } + void uws_app_set_require_host_header(int ssl, uws_app_t *app, bool require_host_header) { + if (ssl) { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->setRequireHostHeader(require_host_header); + } else { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->setRequireHostHeader(require_host_header); + } + } void uws_app_destroy(int ssl, uws_app_t *app) { @@ -1711,6 +1720,16 @@ __attribute__((callback (corker, ctx))) } } + void uws_res_flush_headers(int ssl, uws_res_r res) { + if (ssl) { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->flushHeaders(); + } else { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->flushHeaders(); + } + } + void *uws_res_get_native_handle(int ssl, uws_res_r res) { if (ssl) diff --git a/src/deps/uws.zig b/src/deps/uws.zig index a8a2d0f2f7..c2bc4fe8c1 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -3053,7 +3053,11 @@ pub const AnyResponse = union(enum) { inline else => |resp| resp.getRemoteSocketInfo(), }; } - + pub fn flushHeaders(this: AnyResponse) void { + return switch (this) { + inline else => |resp| resp.flushHeaders(), + }; + } pub fn getWriteOffset(this: AnyResponse) u64 { return switch (this) { inline else => |resp| resp.getWriteOffset(), @@ -3282,6 +3286,10 @@ pub fn NewApp(comptime ssl: bool) type { return uws_app_destroy(ssl_flag, @as(*uws_app_s, @ptrCast(app))); } + pub fn setRequireHostHeader(this: *ThisApp, require_host_header: bool) void { + return uws_app_set_require_host_header(ssl_flag, @as(*uws_app_t, @ptrCast(this)), require_host_header); + } + pub fn clearRoutes(app: *ThisApp) void { return uws_app_clear_routes(ssl_flag, @as(*uws_app_t, @ptrCast(app))); } @@ -3572,6 +3580,10 @@ pub fn NewApp(comptime ssl: bool) type { return uws_res_try_end(ssl_flag, res.downcast(), data.ptr, data.len, total, close_); } + pub fn flushHeaders(res: *Response) void { + uws_res_flush_headers(ssl_flag, res.downcast()); + } + pub fn state(res: *const Response) State { return uws_res_state(ssl_flag, @as(*const uws_res, @ptrCast(@alignCast(res)))); } @@ -3917,6 +3929,7 @@ extern fn uws_res_get_native_handle(ssl: i32, res: *uws_res) *Socket; extern fn uws_res_get_remote_address_as_text(ssl: i32, res: *uws_res, dest: *[*]const u8) usize; extern fn uws_create_app(ssl: i32, options: us_bun_socket_context_options_t) ?*uws_app_t; extern fn uws_app_destroy(ssl: i32, app: *uws_app_t) void; +extern fn uws_app_set_require_host_header(ssl: i32, app: *uws_app_t, require_host_header: bool) void; extern fn uws_app_get(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; extern fn uws_app_post(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; extern fn uws_app_options(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; @@ -3982,6 +3995,7 @@ extern fn uws_res_try_end( total: usize, close: bool, ) bool; +extern fn uws_res_flush_headers(ssl: i32, res: *uws_res) void; extern fn uws_res_pause(ssl: i32, res: *uws_res) void; extern fn uws_res_resume(ssl: i32, res: *uws_res) void; extern fn uws_res_write_continue(ssl: i32, res: *uws_res) void; diff --git a/src/js/internal/net.ts b/src/js/internal/net.ts index f4ccad0870..f995f2c7bf 100644 --- a/src/js/internal/net.ts +++ b/src/js/internal/net.ts @@ -1,10 +1,14 @@ -const [addServerName, upgradeDuplexToTLS, isNamedPipeSocket] = $zig("socket.zig", "createNodeTLSBinding"); +const [addServerName, upgradeDuplexToTLS, isNamedPipeSocket, getBufferedAmount] = $zig( + "socket.zig", + "createNodeTLSBinding", +); const { SocketAddress } = $zig("node_net_binding.zig", "createBinding"); export default { addServerName, upgradeDuplexToTLS, isNamedPipeSocket, + getBufferedAmount, SocketAddress, normalizedArgsSymbol: Symbol("normalizedArgs"), }; diff --git a/src/js/node/http.ts b/src/js/node/http.ts index c1fd46368c..269b96a130 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -111,6 +111,7 @@ const { webRequestOrResponseHasBodyValue, getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, drainMicrotasks, + setRequireHostHeader, } = $cpp("NodeHTTP.cpp", "createNodeHTTPInternalBinding") as { getHeader: (headers: Headers, name: string) => string | undefined; setHeader: (headers: Headers, name: string, value: string) => void; @@ -124,6 +125,7 @@ const { Blob: (typeof globalThis)["Blob"]; headersTuple: any; webRequestOrResponseHasBodyValue: (arg: any) => boolean; + setRequireHostHeader: (server: any, requireHostHeader: boolean) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; }; @@ -194,7 +196,8 @@ const kEmptyBuffer = Buffer.alloc(0); function isValidTLSArray(obj) { if (typeof obj === "string" || isTypedArray(obj) || isArrayBuffer(obj) || $inheritsBlob(obj)) return true; if (Array.isArray(obj)) { - for (var i = 0; i < obj.length; i++) { + const length = obj.length; + for (var i = 0; i < length; i++) { const item = obj[i]; if (typeof item !== "string" && !isTypedArray(item) && !isArrayBuffer(item) && !$inheritsBlob(item)) return false; // prettier-ignore } @@ -235,6 +238,9 @@ var FakeSocket = class Socket extends Duplex { connect(port, host, connectListener) { return this; } + _onTimeout = function () { + this.emit("timeout"); + }; _destroy(err, callback) { const socketData = this[kInternalSocketData]; @@ -380,6 +386,20 @@ const NodeHTTPServerSocket = class Socket extends Duplex { $isCallable(closeCallback) && closeCallback(); } + _onTimeout() { + const handle = this[kHandle]; + const response = handle?.response; + // if there is a response, and it has pending data, + // we suppress the timeout because a write is in progress + if (response && response.bufferedAmount > 0) { + return; + } + this.emit("timeout"); + } + _unrefTimer() { + // for compatibility + } + address() { return this[kHandle]?.remoteAddress || null; } @@ -1036,6 +1056,7 @@ const ServerPrototype = { socketHandle, isSocketNew, socket, + isAncientHTTP: boolean, ) { const prevIsNextIncomingMessageHTTPS = isNextIncomingMessageHTTPS; isNextIncomingMessageHTTPS = isHTTPS; @@ -1044,6 +1065,9 @@ const ServerPrototype = { } const http_req = new RequestClass(kHandle, url, method, headersObject, headersArray, handle, hasBody, socket); + if (isAncientHTTP) { + http_req.httpVersion = "1.0"; + } const http_res = new ResponseClass(http_req, { [kHandle]: handle, [kRejectNonStandardBodyWrites]: server.rejectNonStandardBodyWrites, @@ -1206,6 +1230,7 @@ const ServerPrototype = { }); getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; + setRequireHostHeader(this[serverSymbol], this.requireHostHeader); if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1462,6 +1487,7 @@ function onDataIncomingMessage( const IncomingMessagePrototype = { constructor: IncomingMessage, __proto__: Readable.prototype, + httpVersion: "1.1", _construct(callback) { // TODO: streaming const type = this[typeSymbol]; @@ -1632,20 +1658,22 @@ const IncomingMessagePrototype = { set statusMessage(value) { this[statusMessageSymbol] = value; }, - get httpVersion() { - return "1.1"; - }, - set httpVersion(value) { - // noop - }, get httpVersionMajor() { - return 1; + const version = this.httpVersion; + if (version.startsWith("1.")) { + return 1; + } + return 0; }, set httpVersionMajor(value) { // noop }, get httpVersionMinor() { - return 1; + const version = this.httpVersion; + if (version.endsWith(".1")) { + return 1; + } + return 0; }, set httpVersionMinor(value) { // noop @@ -2458,9 +2486,12 @@ const ServerResponsePrototype = { this._implicitHeader(); const handle = this[kHandle]; - if (handle && !this.headersSent) { - this[headerStateSymbol] = NodeHTTPHeaderState.sent; - handle.writeHead(this.statusCode, this.statusMessage, this[headersSymbol]); + if (handle) { + if (this[headerStateSymbol] === NodeHTTPHeaderState.assigned) { + this[headerStateSymbol] = NodeHTTPHeaderState.sent; + handle.writeHead(this.statusCode, this.statusMessage, this[headersSymbol]); + } + handle.flushHeaders(); } }, } satisfies typeof import("node:http").ServerResponse.prototype; @@ -2663,7 +2694,6 @@ const kOptions = Symbol("options"); const kSocketPath = Symbol("socketPath"); const kSignal = Symbol("signal"); const kMaxHeaderSize = Symbol("maxHeaderSize"); -const kJoinDuplicateHeaders = Symbol("joinDuplicateHeaders"); function ClientRequest(input, options, cb) { if (!(this instanceof ClientRequest)) { @@ -3249,27 +3279,25 @@ function ClientRequest(input, options, cb) { } const _maxHeaderSize = options.maxHeaderSize; - // TODO: Validators - // if (maxHeaderSize !== undefined) - // validateInteger(maxHeaderSize, "maxHeaderSize", 0); + const maxHeaderSize = options.maxHeaderSize; + if (maxHeaderSize !== undefined) validateInteger(maxHeaderSize, "maxHeaderSize", 0); + this.maxHeaderSize = maxHeaderSize; + this[kMaxHeaderSize] = _maxHeaderSize; - // const insecureHTTPParser = options.insecureHTTPParser; - // if (insecureHTTPParser !== undefined) { - // validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser'); - // } - - // this.insecureHTTPParser = insecureHTTPParser; - var _joinDuplicateHeaders = options.joinDuplicateHeaders; - if (_joinDuplicateHeaders !== undefined) { - // TODO: Validators - // validateBoolean( - // options.joinDuplicateHeaders, - // "options.joinDuplicateHeaders", - // ); + const insecureHTTPParser = options.insecureHTTPParser; + if (insecureHTTPParser !== undefined) { + validateBoolean(insecureHTTPParser, "options.insecureHTTPParser"); } - this[kJoinDuplicateHeaders] = _joinDuplicateHeaders; + this.insecureHTTPParser = insecureHTTPParser; + const joinDuplicateHeaders = options.joinDuplicateHeaders; + + if (joinDuplicateHeaders !== undefined) { + validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); + } + this.joinDuplicateHeaders = joinDuplicateHeaders; + if (options.pfx) { throw new Error("pfx is not supported"); } @@ -3358,7 +3386,15 @@ function ClientRequest(input, options, cb) { const { headers } = options; const headersArray = $isJSArray(headers); - if (!headersArray) { + if (headersArray) { + const length = headers.length; + if (length % 2 !== 0) { + throw $ERR_INVALID_ARG_VALUE("options.headers", headers); + } + for (let i = 0; i < length; ) { + this.appendHeader(headers[i++], headers[i++]); + } + } else { if (headers) { for (let key in headers) { this.setHeader(key, headers[key]); @@ -3705,27 +3741,29 @@ function _writeHead(statusCode, reason, obj, response) { let k; if ($isArray(obj)) { + const length = obj.length; // Append all the headers provided in the array: - if (obj.length && $isArray(obj[0])) { - for (let i = 0; i < obj.length; i++) { + if (length && $isArray(obj[0])) { + for (let i = 0; i < length; i++) { const k = obj[i]; if (k) response.appendHeader(k[0], k[1]); } } else { - if (obj.length % 2 !== 0) { + if (length % 2 !== 0) { throw new Error("raw headers must have an even number of elements"); } - for (let n = 0; n < obj.length; n += 2) { - k = obj[n + 0]; - if (k) response.setHeader(k, obj[n + 1]); + for (let n = 0; n < length; n += 2) { + k = obj[n]; + if (k) response.appendHeader(k, obj[n + 1]); } } } else if (obj) { const keys = Object.keys(obj); + const length = keys.length; // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 - for (let i = 0; i < keys.length; i++) { + for (let i = 0; i < length; i++) { k = keys[i]; if (k) response.setHeader(k, obj[k]); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index eee2eebcaf..280f0d1981 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -28,6 +28,7 @@ const { upgradeDuplexToTLS, isNamedPipeSocket, normalizedArgsSymbol, + getBufferedAmount, } = require("internal/net"); const { ExceptionWithHostPort } = require("internal/shared"); import type { SocketListener, SocketHandler } from "bun"; @@ -587,6 +588,22 @@ Socket.prototype.address = function address() { }; }; +Socket.prototype._onTimeout = function () { + // if there is pending data, write is in progress + // so we suppress the timeout + if (this._pendingData) { + return; + } + + const handle = this._handle; + // if there is a handle, and it has pending data, + // we suppress the timeout because a write is in progress + if (handle && getBufferedAmount(handle) > 0) { + return; + } + this.emit("timeout"); +}; + Object.defineProperty(Socket.prototype, "bufferSize", { get: function () { return this.writableLength; diff --git a/test/js/node/test/parallel/test-http-1.0-keep-alive.js b/test/js/node/test/parallel/test-http-1.0-keep-alive.js new file mode 100644 index 0000000000..6d117035a5 --- /dev/null +++ b/test/js/node/test/parallel/test-http-1.0-keep-alive.js @@ -0,0 +1,156 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const http = require('http'); +const net = require('net'); + +// Check that our HTTP server correctly handles HTTP/1.0 keep-alive requests. +check([{ + name: 'keep-alive, no TE header', + requests: [{ + expectClose: true, + data: 'POST / HTTP/1.0\r\n' + + 'Connection: keep-alive\r\n' + + '\r\n' + }, { + expectClose: true, + data: 'POST / HTTP/1.0\r\n' + + 'Connection: keep-alive\r\n' + + '\r\n' + }], + responses: [{ + headers: { 'Connection': 'keep-alive' }, + chunks: ['OK'] + }, { + chunks: [] + }] +}, { + name: 'keep-alive, with TE: chunked', + requests: [{ + expectClose: false, + data: 'POST / HTTP/1.0\r\n' + + 'Connection: keep-alive\r\n' + + 'TE: chunked\r\n' + + '\r\n' + }, { + expectClose: true, + data: 'POST / HTTP/1.0\r\n' + + '\r\n' + }], + responses: [{ + headers: { 'Connection': 'keep-alive' }, + chunks: ['OK'] + }, { + chunks: [] + }] +}, { + name: 'keep-alive, with Transfer-Encoding: chunked', + requests: [{ + expectClose: false, + data: 'POST / HTTP/1.0\r\n' + + 'Connection: keep-alive\r\n' + + '\r\n' + }, { + expectClose: true, + data: 'POST / HTTP/1.0\r\n' + + '\r\n' + }], + responses: [{ + headers: { 'Connection': 'keep-alive', + 'Transfer-Encoding': 'chunked' }, + chunks: ['OK'] + }, { + chunks: [] + }] +}, { + name: 'keep-alive, with Content-Length', + requests: [{ + expectClose: false, + data: 'POST / HTTP/1.0\r\n' + + 'Connection: keep-alive\r\n' + + '\r\n' + }, { + expectClose: true, + data: 'POST / HTTP/1.0\r\n' + + '\r\n' + }], + responses: [{ + headers: { 'Connection': 'keep-alive', + 'Content-Length': '2' }, + chunks: ['OK'] + }, { + chunks: [] + }] +}]); + +function check(tests) { + const test = tests[0]; + let server; + if (test) { + server = http.createServer(serverHandler).listen(0, '127.0.0.1', client); + } + let current = 0; + + function next() { + check(tests.slice(1)); + } + + function serverHandler(req, res) { + if (current + 1 === test.responses.length) this.close(); + const ctx = test.responses[current]; + console.error('< SERVER SENDING RESPONSE', ctx); + res.writeHead(200, ctx.headers); + ctx.chunks.slice(0, -1).forEach(function(chunk) { res.write(chunk); }); + res.end(ctx.chunks[ctx.chunks.length - 1]); + } + + function client() { + if (current === test.requests.length) return next(); + const port = server.address().port; + const conn = net.createConnection(port, '127.0.0.1', connected); + + function connected() { + const ctx = test.requests[current]; + console.error(' > CLIENT SENDING REQUEST', ctx); + conn.setEncoding('utf8'); + conn.write(ctx.data); + + function onclose() { + console.error(' > CLIENT CLOSE'); + if (!ctx.expectClose) throw new Error('unexpected close'); + client(); + } + conn.on('close', onclose); + + function ondata(s) { + console.error(' > CLIENT ONDATA %j %j', s.length, s.toString()); + current++; + if (ctx.expectClose) return; + conn.removeListener('close', onclose); + conn.removeListener('data', ondata); + connected(); + } + conn.on('data', ondata); + } + } +} diff --git a/test/js/node/test/parallel/test-http-client-insecure-http-parser-error.js b/test/js/node/test/parallel/test-http-client-insecure-http-parser-error.js new file mode 100644 index 0000000000..d483d817f0 --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-insecure-http-parser-error.js @@ -0,0 +1,14 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const ClientRequest = require('http').ClientRequest; + +{ + assert.throws(() => { + new ClientRequest({ insecureHTTPParser: 'wrongValue' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /insecureHTTPParser/ + }, 'http request should throw when passing invalid insecureHTTPParser'); +} diff --git a/test/js/node/test/parallel/test-http-flush-response-headers.js b/test/js/node/test/parallel/test-http-flush-response-headers.js new file mode 100644 index 0000000000..1745d42285 --- /dev/null +++ b/test/js/node/test/parallel/test-http-flush-response-headers.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(); + +server.on('request', function(req, res) { + res.writeHead(200, { 'foo': 'bar' }); + res.flushHeaders(); + res.flushHeaders(); // Should be idempotent. +}); +server.listen(0, common.localhostIPv4, function() { + const req = http.request({ + method: 'GET', + host: common.localhostIPv4, + port: this.address().port, + }, onResponse); + + req.end(); + + function onResponse(res) { + assert.strictEqual(res.headers.foo, 'bar'); + res.destroy(); + server.closeAllConnections(); + } +}); diff --git a/test/js/node/test/parallel/test-http-request-methods.js b/test/js/node/test/parallel/test-http-request-methods.js new file mode 100644 index 0000000000..2f0da7432a --- /dev/null +++ b/test/js/node/test/parallel/test-http-request-methods.js @@ -0,0 +1,64 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const http = require('http'); + +// Test that the DELETE, PATCH and PURGE verbs get passed through correctly + +['DELETE', 'PATCH', 'PURGE'].forEach(function(method, index) { + const server = http.createServer(common.mustCall(function(req, res) { + assert.strictEqual(req.method, method); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write('hello '); + res.write('world\n'); + res.end(); + })); + server.listen(0); + + server.on('listening', common.mustCall(function() { + const c = net.createConnection(this.address().port); + let server_response = ''; + + c.setEncoding('utf8'); + + c.on('connect', function() { + c.write(`${method} / HTTP/1.0\r\n\r\n`); + }); + + c.on('data', function(chunk) { + server_response += chunk; + }); + + c.on('end', common.mustCall(function() { + const m = server_response.split('\r\n\r\n'); + assert.strictEqual(m[1], 'hello world\n'); + c.end(); + })); + + c.on('close', function() { + server.close(); + }); + })); +}); diff --git a/test/js/node/test/parallel/test-http-server-method.query.js b/test/js/node/test/parallel/test-http-server-method.query.js new file mode 100644 index 0000000000..8159fb7505 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-method.query.js @@ -0,0 +1,27 @@ +'use strict'; + +const common = require('../common'); +const { strictEqual } = require('assert'); +const { createServer, request } = require('http'); + +const server = createServer(common.mustCall((req, res) => { + strictEqual(req.method, 'QUERY'); + res.end('OK'); +})); + +server.listen(0, common.mustCall(() => { + const req = request({ port: server.address().port, method: 'QUERY' }, common.mustCall((res) => { + strictEqual(res.statusCode, 200); + + let buffer = ''; + res.setEncoding('utf-8'); + + res.on('data', (c) => buffer += c); + res.on('end', common.mustCall(() => { + strictEqual(buffer, 'OK'); + server.close(); + })); + })); + + req.end(); +})); diff --git a/test/js/node/test/parallel/test-http-server-timeouts-validation.js b/test/js/node/test/parallel/test-http-server-timeouts-validation.js new file mode 100644 index 0000000000..681a8bc321 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-timeouts-validation.js @@ -0,0 +1,50 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { createServer } = require('http'); + +// This test validates that the HTTP server timeouts are properly validated and set. + +{ + const server = createServer(); + assert.strictEqual(server.headersTimeout, 60000); + assert.strictEqual(server.requestTimeout, 300000); +} + +{ + const server = createServer({ headersTimeout: 10000, requestTimeout: 20000 }); + assert.strictEqual(server.headersTimeout, 10000); + assert.strictEqual(server.requestTimeout, 20000); +} + +{ + const server = createServer({ headersTimeout: 10000, requestTimeout: 10000 }); + assert.strictEqual(server.headersTimeout, 10000); + assert.strictEqual(server.requestTimeout, 10000); +} + +{ + const server = createServer({ headersTimeout: 10000 }); + assert.strictEqual(server.headersTimeout, 10000); + assert.strictEqual(server.requestTimeout, 300000); +} + +{ + const server = createServer({ requestTimeout: 20000 }); + assert.strictEqual(server.headersTimeout, 20000); + assert.strictEqual(server.requestTimeout, 20000); +} + +{ + const server = createServer({ requestTimeout: 100000 }); + assert.strictEqual(server.headersTimeout, 60000); + assert.strictEqual(server.requestTimeout, 100000); +} + +{ + assert.throws( + () => createServer({ headersTimeout: 10000, requestTimeout: 1000 }), + { code: 'ERR_OUT_OF_RANGE' } + ); +} diff --git a/test/js/node/test/parallel/test-https-server-headers-timeout.js b/test/js/node/test/parallel/test-https-server-headers-timeout.js new file mode 100644 index 0000000000..755336053e --- /dev/null +++ b/test/js/node/test/parallel/test-https-server-headers-timeout.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const { createServer } = require('https'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +const server = createServer(options); + +// 60000 seconds is the default +assert.strictEqual(server.headersTimeout, 60000); +const headersTimeout = common.platformTimeout(1000); +server.headersTimeout = headersTimeout; +assert.strictEqual(server.headersTimeout, headersTimeout); \ No newline at end of file diff --git a/test/js/node/test/parallel/test-https-server-request-timeout.js b/test/js/node/test/parallel/test-https-server-request-timeout.js new file mode 100644 index 0000000000..e9224973a7 --- /dev/null +++ b/test/js/node/test/parallel/test-https-server-request-timeout.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const { createServer } = require('https'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const server = createServer(options); + +// 300 seconds is the default +assert.strictEqual(server.requestTimeout, 300000); +const requestTimeout = common.platformTimeout(1000); +server.requestTimeout = requestTimeout; +assert.strictEqual(server.requestTimeout, requestTimeout); \ No newline at end of file diff --git a/test/js/node/test/sequential/test-http-keep-alive-large-write.js b/test/js/node/test/sequential/test-http-keep-alive-large-write.js new file mode 100644 index 0000000000..622872e008 --- /dev/null +++ b/test/js/node/test/sequential/test-http-keep-alive-large-write.js @@ -0,0 +1,47 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// This test assesses whether long-running writes can complete +// or timeout because the socket is not aware that the backing +// stream is still writing. + +const writeSize = 3000000; +let socket; + +const server = http.createServer(common.mustCall((req, res) => { + server.close(); + const content = Buffer.alloc(writeSize, 0x44); + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': content.length.toString(), + 'Vary': 'Accept-Encoding' + }); + + socket = res.socket; + const onTimeout = socket._onTimeout; + socket._onTimeout = common.mustCallAtLeast(() => onTimeout.call(socket), 1); + res.write(content); + res.end(); +})); +server.on('timeout', () => { + // TODO(apapirovski): This test is faulty on certain Windows systems + // as no queue is ever created + assert(!socket._handle || socket._handle.writeQueueSize === 0, + 'Should not timeout'); +}); + +server.listen(0, common.mustCall(() => { + http.get({ + path: '/', + port: server.address().port + }, (res) => { + res.once('data', () => { + socket._onTimeout(); + res.on('data', () => {}); + }); + res.on('end', () => server.close()); + }); +})); \ No newline at end of file