diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 30f58d79ec54..64caeba57b8d 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -767,9 +767,8 @@ struct TemplatedApp { /* lenientHttpFlags: bit 0 = lenient header values (llhttp LENIENT_HEADERS), * bit 1 = lenient transfer-encoding (llhttp LENIENT_TRANSFER_ENCODING). */ - TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation, uint8_t lenientHttpFlags, bool httpAllowHalfOpen) { + TemplatedApp &&setFlags(bool requireHostHeader, uint8_t lenientHttpFlags, bool httpAllowHalfOpen) { httpContext->getSocketContextData()->flags.requireHostHeader = requireHostHeader; - httpContext->getSocketContextData()->flags.useStrictMethodValidation = useStrictMethodValidation; httpContext->getSocketContextData()->flags.useInsecureHTTPParser = (lenientHttpFlags & 1) != 0; httpContext->getSocketContextData()->flags.useLenientTransferEncoding = (lenientHttpFlags & 2) != 0; httpContext->getSocketContextData()->flags.httpAllowHalfOpen = httpAllowHalfOpen; diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 253b8c704ea8..912c82928f8c 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -383,7 +383,7 @@ struct HttpContext { nodeHttpRequestTrailers = &nodeHttpResponseData->nodeHttpRequestTrailers; } - auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpContextData->maxHeadersCount, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, httpContextData->flags.useInsecureHTTPParser, httpContextData->flags.useLenientTransferEncoding, nodeHttpRequestTrailers, &httpResponseData->chunkedExtensionsByteCount, data, (unsigned int) length, s, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpContextData->maxHeadersCount, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader, httpContextData->flags.useInsecureHTTPParser, httpContextData->flags.useLenientTransferEncoding, nodeHttpRequestTrailers, &httpResponseData->chunkedExtensionsByteCount, data, (unsigned int) length, s, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext((us_socket_t *) s); @@ -527,13 +527,16 @@ struct HttpContext { ((AsyncSocket *) s)->cork(); } - /* Route the method and URL */ - selectedRouter->getUserData() = {(HttpResponse *) s, httpRequest}; - if (!selectedRouter->route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrlForRouting())) { - /* We have to force close this socket as we have no handler for it. - * close() first sends the responses to earlier requests of this read. */ - ((AsyncSocket *) s)->close(); - return nullptr; + /* Bun.serve routes only the methods of HTTP_METHOD_NAMES. Any other method gets 501 (RFC 9110 15.6.2), and a + * request that no route takes gets 404, as over HTTP/2 and HTTP/3. No handler reads the content of such a request, + * so the connection closes after the answer. node:http's parser is strict: it answers 400 to an unknown method. */ + if (!IsNodeHttp && methodIdFromWire(httpRequest->getCaseSensitiveMethod()) == HTTP_METHOD_NONE) [[unlikely]] { + ((HttpResponse *) s)->writeStatus("501 Not Implemented")->end({}, true); + } else { + selectedRouter->getUserData() = {(HttpResponse *) s, httpRequest}; + if (!selectedRouter->route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrlForRouting())) { + ((HttpResponse *) s)->writeStatus("404 Not Found")->end({}, true); + } } /* First of all we need to check if this socket was deleted due to upgrade */ @@ -1142,8 +1145,7 @@ struct HttpContext { std::span methods; std::string method_buffer; std::string_view method_sv_buffer; - // When it's NOT node:http, allow the uWS default precedence ordering. - if (method == "*" && !httpContextData->flags.useStrictMethodValidation) { + if (method == "*") { methods = HTTP_METHOD_NAMES; } else { method_buffer = std::string(method); diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 4c28067a9c8c..76b97595a014 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -33,7 +33,6 @@ struct HttpFlags { bool rejectUnauthorized: 1 = false; bool usingCustomExpectHandler: 1 = false; bool requireHostHeader: 1 = true; - bool useStrictMethodValidation: 1 = false; /* node:http parser leniency. Two llhttp lenient bits: useInsecureHTTPParser = LENIENT_HEADERS * ("relaxed"+"insecure"); useLenientTransferEncoding = LENIENT_TRANSFER_ENCODING ("insecure" * only). TE+CL conflict, chunked-size/CRLF, version, header-token checks stay enforced. */ diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 62998eedbca7..ae77763d948a 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -1192,7 +1192,7 @@ struct HttpResponseData; /* This is the only caller of getHeaders and is thus the deepest part of the parser. */ template - HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { /* How much data we CONSUMED (to throw away) */ unsigned int consumedTotal = 0; @@ -1245,7 +1245,7 @@ struct HttpResponseData; return HttpParserResult::success(consumedTotal + length, user); } bool isConnectRequestLine = false; - auto result = getHeaders(data, data + length, req->headers, req->ancientHttp, isConnectRequestLine, useStrictMethodValidation, useInsecureHTTPParser, maxHeaderSize, maxHeaderFields); + auto result = getHeaders(data, data + length, req->headers, req->ancientHttp, isConnectRequestLine, /* useStrictMethodValidation: */ IsNodeHttp, useInsecureHTTPParser, maxHeaderSize, maxHeaderFields); if(result.isError()) { return result; } @@ -1575,7 +1575,7 @@ struct HttpResponseData; * head, or WHOLE_READ when the request declared a body: the body is not parsed * and is not the caller's. The handler may have destroyed this parser. */ template - HttpParserResult consumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, uint32_t maxHeadersCount, bool& isConnectRequest, bool requireHostHeader, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { char *const readStart = data; /* The fallback buffer may not exceed the configured per-request header * limit (per-server maxHeaderSize can raise it above the default). */ @@ -1669,7 +1669,7 @@ struct HttpResponseData; } // break here on break - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, reassembled.data(), (unsigned int) reassembled.length(), user, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, reassembled.data(), (unsigned int) reassembled.length(), user, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error. * The parser can be gone by then: no member is touched before this return. */ if (consumed.returnedData != user) { @@ -1771,7 +1771,7 @@ struct HttpResponseData; } } - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, maxHeadersCount, isConnectRequest, requireHostHeader, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { /* A body or a fallback head ahead of this request moved data forward. */ diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 35d1ca3c8e18..83a0a7c0c6ee 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -7,7 +7,6 @@ const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, dra setServerCustomOptions: ( server: any, requireHostHeader: boolean, - useStrictMethodValidation: boolean, lenientHttpFlags: number, maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, @@ -16,7 +15,6 @@ const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, dra setServerAppFlags: ( server: any, requireHostHeader: boolean, - useStrictMethodValidation: boolean, lenientHttpFlags: number, httpAllowHalfOpen: boolean, ) => void; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 6c66f7904a12..300c63617cb5 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1176,14 +1176,12 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort }; // Pushes the per-server parser/handler options down to the native listener. -// Always sets strict method validation, for node.js compatibility. function applyServerCustomOptions(server: Server) { const handle = server[serverSymbol]; if (!handle) return; setServerCustomOptions( handle, server.requireHostHeader, - true, serverLenientFlags(server), typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(server, server[kListenerGeneration]), @@ -1213,7 +1211,7 @@ function httpAllowHalfOpenSet(this: Server, value) { const next = !!value; if (previous === next) return; const handle = this[serverSymbol]; - if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverLenientFlags(this), next); + if (handle) setServerAppFlags(handle, this.requireHostHeader, serverLenientFlags(this), next); } // Node.js keeps httpAllowHalfOpen as an own enumerable property of the server. diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 32b937ecd9dc..7841e337e318 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -29,7 +29,7 @@ using namespace JSC; using namespace WebCore; BUN_DECLARE_HOST_FUNCTION(Bun__drainMicrotasksFromJS); -extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open); +extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, uint8_t lenient_http_flags, bool http_allow_half_open); extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setOnConnection(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); @@ -868,23 +868,22 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 8); + ASSERT(callFrame->argumentCount() == 7); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); - JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); - JSValue maxHeaderSize = callFrame->uncheckedArgument(4); - JSValue callback = callFrame->uncheckedArgument(5); - JSValue onConnectionCallback = callFrame->argument(6); - JSValue httpAllowHalfOpen = callFrame->argument(7); + JSValue lenientHttpFlags = callFrame->uncheckedArgument(2); + JSValue maxHeaderSize = callFrame->uncheckedArgument(3); + JSValue callback = callFrame->uncheckedArgument(4); + JSValue onConnectionCallback = callFrame->argument(5); + JSValue httpAllowHalfOpen = callFrame->argument(6); double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); RETURN_IF_EXCEPTION(scope, {}); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHTTPHeaderSizeFromNumber(maxHeaderSizeNumber)); @@ -908,18 +907,17 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetAppFlags, (JSGlobalObject * globalObject, Call { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 5); + ASSERT(callFrame->argumentCount() == 4); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); - JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); - JSValue httpAllowHalfOpen = callFrame->argument(4); + JSValue lenientHttpFlags = callFrame->uncheckedArgument(2); + JSValue httpAllowHalfOpen = callFrame->argument(3); int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); RETURN_IF_EXCEPTION(scope, {}); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); @@ -951,7 +949,7 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) JSC::JSFunction::create(vm, globalObject, 2, "setServerCustomOptions"_s, jsHTTPSetCustomOptions, ImplementationVisibility::Public), 0); obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerAppFlags"_s)), - JSC::JSFunction::create(vm, globalObject, 5, "setServerAppFlags"_s, jsHTTPSetAppFlags, ImplementationVisibility::Public), 0); + JSC::JSFunction::create(vm, globalObject, 4, "setServerAppFlags"_s, jsHTTPSetAppFlags, ImplementationVisibility::Public), 0); obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerMaxHeadersCount"_s)), JSC::JSFunction::create(vm, globalObject, 2, "setServerMaxHeadersCount"_s, jsHTTPSetMaxHeadersCount, ImplementationVisibility::Public), 0); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 5cb83b6cf091..d007e691c4f0 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1671,7 +1671,6 @@ impl NewServer { pub(crate) fn set_flags( &mut self, require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ) { @@ -1679,7 +1678,6 @@ impl NewServer { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. bun_opaque::opaque_deref_mut(app).set_flags( require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ); diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 102a1c6ca94e..1c1de8652e55 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3682,7 +3682,6 @@ fn server_set_app_flags( global: &JSGlobalObject, server: JSValue, require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ) -> JsResult { @@ -3696,7 +3695,6 @@ fn server_set_app_flags( // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. unsafe { &mut *this }.set_flags( require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ); @@ -3704,7 +3702,6 @@ fn server_set_app_flags( // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. unsafe { &mut *this }.set_flags( require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ); @@ -3712,7 +3709,6 @@ fn server_set_app_flags( // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. unsafe { &mut *this }.set_flags( require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ); @@ -3720,7 +3716,6 @@ fn server_set_app_flags( // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. unsafe { &mut *this }.set_flags( require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ); @@ -3803,7 +3798,6 @@ extern "C" fn server_set_app_flags_shim( global: &JSGlobalObject, server: JSValue, require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ) -> JSValue { @@ -3813,7 +3807,6 @@ extern "C" fn server_set_app_flags_shim( global, server, require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ), diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 9ec9afb62143..489d66d0601d 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -128,7 +128,6 @@ impl App { pub fn set_flags( &mut self, require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ) { @@ -136,7 +135,6 @@ impl App { Self::SSL_FLAG, self.as_raw(), require_host_header, - use_strict_method_validation, lenient_http_flags, http_allow_half_open, ) @@ -519,7 +517,6 @@ pub mod c { ssl: i32, app: &mut uws_app_t, require_host_header: bool, - use_strict_method_validation: bool, lenient_http_flags: u8, http_allow_half_open: bool, ); diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index d9dc129a943d..0c2374c3e7b9 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -502,13 +502,13 @@ extern "C" uwsApp->setMaxHeadersCount(max_headers_count); } } - void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open) { + void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, uint8_t lenient_http_flags, bool http_allow_half_open) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); + uwsApp->setFlags(require_host_header, lenient_http_flags, http_allow_half_open); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); + uwsApp->setFlags(require_host_header, lenient_http_flags, http_allow_half_open); } } diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index f9f1c17e3b64..39c698646a3c 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -1109,3 +1109,290 @@ describe.concurrent("false route with no fetch handler", () => { await proc.exited; }); }); + +describe.concurrent("a request method that is not one of Bun's 36 methods", () => { + type Exchange = { status: number; body: string }[]; + + // Sends the bytes on one connection. Resolves with what the server sent on + // it: everything until the server closes the connection, or the bytes of the + // first `stopAfter` responses. + async function rawExchange(port: number, bytes: string, stopAfter = Infinity, methods: string[] = []) { + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + const socket = net.connect(port, "127.0.0.1"); + socket.on("connect", () => socket.write(bytes)); + socket.on("data", chunk => { + received += chunk.toString("latin1"); + if (parse(received, methods).length >= stopAfter) socket.destroy(); + }); + socket.on("error", reject); + socket.on("close", () => resolve(received)); + return promise; + } + + // The complete responses in `received`, in order. `methods` names the + // request methods when one of them is HEAD, whose response has no body. + function parse(received: string, methods: string[] = []): Exchange { + const responses: Exchange = []; + let rest = received; + while (rest.length > 0) { + const headEnd = rest.indexOf("\r\n\r\n"); + if (headEnd === -1) break; + const head = rest.slice(0, headEnd); + const length = + methods[responses.length] === "HEAD" ? 0 : Number(/^content-length: (\d+)$/im.exec(head)?.[1] ?? 0); + if (rest.length < headEnd + 4 + length) break; + responses.push({ status: Number(head.slice(9, 12)), body: rest.slice(headEnd + 4, headEnd + 4 + length) }); + rest = rest.slice(headEnd + 4 + length); + } + return responses; + } + + const exchange = async (port: number, bytes: string, stopAfter = Infinity, methods: string[] = []) => + parse(await rawExchange(port, bytes, stopAfter, methods), methods); + + const request = (method: string, path: string, fields = "") => + `${method} ${path} HTTP/1.1\r\nHost: localhost\r\n${fields}\r\n`; + + // Every handler reports the method it was given. + const handler = (calls: string[], name: string) => (req: Request) => { + calls.push(`${name} ${req.method}`); + return new Response(`${name} ${req.method}`); + }; + + const missingFile = "/bun-serve-routes-this-file-does-not-exist"; + + // Route tables. With some of them the router used to hand such a request to + // the routes of another method, and the handler saw it as a GET. With the + // others the socket closed with no response. `get` is the answer to + // `GET /nope`, and `getCalls` the handler calls that answer makes. + type Table = { routes: (calls: string[]) => any; websocket?: true; get: string; getCalls: string[] }; + const tables: Record = { + "no routes": { routes: () => ({}), get: "fetch GET", getCalls: ["fetch GET"] }, + "a static route": { routes: () => ({ "/x": new Response("x") }), get: "fetch GET", getCalls: ["fetch GET"] }, + "a static route for HEAD": { + routes: () => ({ "/x": { HEAD: new Response("h") } }), + get: "fetch GET", + getCalls: ["fetch GET"], + }, + "static routes for GET and HEAD": { + routes: () => ({ "/x": { GET: new Response("g"), HEAD: new Response("h") } }), + get: "fetch GET", + getCalls: ["fetch GET"], + }, + "two parameter routes of one shape": { + routes: calls => ({ "/u/:id": handler(calls, "id"), "/u/:name": handler(calls, "name") }), + get: "fetch GET", + getCalls: ["fetch GET"], + }, + "a function route and a static route for HEAD": { + routes: calls => ({ "/x": handler(calls, "x"), "/y": { HEAD: new Response("h") } }), + get: "fetch GET", + getCalls: ["fetch GET"], + }, + "static routes for GET and POST on /*": { + routes: () => ({ "/*": { GET: new Response("star"), POST: new Response("post") } }), + get: "star", + getCalls: [], + }, + "a GET file route on /* whose file is missing": { + routes: () => ({ "/*": { GET: new Response(Bun.file(missingFile)) } }), + get: "fetch GET", + getCalls: ["fetch GET"], + }, + "a false route on /*": { routes: () => ({ "/*": false }), get: "fetch GET", getCalls: ["fetch GET"] }, + "a websocket handler and a GET function route on /*": { + routes: calls => ({ "/*": { GET: handler(calls, "star") } }), + websocket: true, + get: "star GET", + getCalls: ["star GET"], + }, + }; + + const serve = (table: Table, calls: string[]) => + Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: table.routes(calls), + fetch: handler(calls, "fetch"), + ...(table.websocket ? { websocket: { message() {} } } : {}), + }); + + const unknown = [ + ["BREW", "/nope"], + ["BREW", "/x"], + ["FROB", "/nope"], + ["get", "/nope"], + ["Get", "/x"], + ["patch", "/x"], + ["GETS", "/u/1"], + ]; + + // Each unknown method gets 501 on a connection that the server then closes, + // and runs no handler. PROPFIND is a method Bun knows and no route names, so + // `fetch` takes it. + async function expectNotImplemented(port: number, calls: string[], table: Table, requests = unknown) { + calls.length = 0; + const [refused, served] = await Promise.all([ + Promise.all(requests.map(([method, path]) => exchange(port, request(method, path)))), + exchange(port, request("PROPFIND", "/nope") + request("GET", "/nope", "Connection: close\r\n")), + ]); + expect({ refused, served, calls }).toEqual({ + refused: requests.map(() => [{ status: 501, body: "" }]), + served: [ + { status: 200, body: "fetch PROPFIND" }, + { status: 200, body: table.get }, + ], + calls: ["fetch PROPFIND", ...table.getCalls], + }); + } + + test.each(Object.keys(tables))("gets 501 and runs no handler with %s", async name => { + const calls: string[] = []; + await using server = serve(tables[name], calls); + await expectNotImplemented(server.port!, calls, tables[name]); + }); + + // The first and the third table used to give such a request to the routes of + // another method, and the second one closed the socket. + test("gets 501 whatever routes an earlier reload() registered", async () => { + const calls: string[] = []; + const fetch = handler(calls, "fetch"); + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", routes: {}, fetch }); + for (const name of ["a static route for HEAD", "no routes", "two parameter routes of one shape"]) { + server.reload({ routes: tables[name].routes(calls), fetch }); + await expectNotImplemented(server.port!, calls, tables[name], [unknown[0], unknown[3]]); + } + }); + + // No handler reads the content of the request, so the server closes the + // connection. It does not answer the GET behind the BREW in the same read. + test("gets a 501 with no body, and the server closes the connection", async () => { + const calls: string[] = []; + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler(calls, "fetch") }); + const received = await rawExchange(server.port!, request("BREW", "/") + request("GET", "/")); + expect({ received: received.replace(/^Date: .*\r\n/gm, ""), calls }).toEqual({ + received: "HTTP/1.1 501 Not Implemented\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + calls: [], + }); + }); + + test("gets 501 on a server with routes and no fetch", async () => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/test": { GET: handler(calls, "test") } }, + }); + const responses = [ + ...(await exchange(server.port!, request("BUN", "/test"))), + ...(await exchange(server.port!, request("get", "/test"))), + ...(await exchange(server.port!, request("GET", "/test", "Connection: close\r\n"))), + ]; + expect({ responses, calls }).toEqual({ + responses: [ + { status: 501, body: "" }, + { status: 501, body: "" }, + { status: 200, body: "test GET" }, + ], + calls: ["test GET"], + }); + }); + + // An any-method route on "/*" leaves no place for the default handler, and + // this one yields: its file is missing. HTTP/2 and HTTP/3 answer 404 too. + test("a request that no route takes gets 404, and the server closes the connection", async () => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/*": new Response(Bun.file(missingFile)) }, + fetch: handler(calls, "fetch"), + }); + const received = await rawExchange(server.port!, request("GET", "/x") + request("GET", "/y")); + expect({ received: received.replace(/^Date: .*\r\n/gm, ""), calls }).toEqual({ + received: "HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + calls: [], + }); + }); + + test("gets 501 before a websocket upgrade, a 100 Continue, or its content is read", async () => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req, server) { + calls.push(`fetch ${req.method}`); + if (server.upgrade(req)) return; + return new Response(`fetch ${req.method}`); + }, + websocket: { + open() { + calls.push("websocket open"); + }, + message() {}, + }, + }); + const upgrade = + "Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"; + const requests = [ + request("BREW", "/ws", upgrade), + request("get", "/ws", upgrade), + // This client waits for 100 Continue and sends no content. + request("BREW", "/x", "Expect: 100-continue\r\nContent-Length: 5\r\n"), + request("BREW", "/x", "Content-Length: 5\r\n") + "hello", + request("BREW", "/x", "Transfer-Encoding: chunked\r\n") + "5\r\nhello\r\n0\r\n\r\n", + ]; + const responses = await Promise.all(requests.map(bytes => exchange(server.port!, bytes))); + expect({ responses, calls }).toEqual({ responses: requests.map(() => [{ status: 501, body: "" }]), calls: [] }); + }); + + const known = [ + ...["ACL", "BIND", "CHECKOUT", "COPY", "DELETE", "GET", "HEAD", "LINK", "LOCK", "M-SEARCH", "MERGE", "MKACTIVITY"], + ...["MKADDRESSBOOK", "MKCALENDAR", "MKCOL", "MOVE", "NOTIFY", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH"], + ...["PURGE", "PUT", "QUERY", "REBIND", "REPORT", "SEARCH", "SOURCE", "SUBSCRIBE", "TRACE", "UNBIND", "UNLINK"], + ...["UNLOCK", "UNSUBSCRIBE"], + ]; + const routed = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"]; + + // The handler must see the method the client sent, for each of them. + // CONNECT has a test of its own below: after it the connection is a tunnel. + test("each of Bun's methods reaches the route of that method, or fetch", async () => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/m": Object.fromEntries(routed.map(method => [method, handler(calls, `route ${method}`)])) }, + fetch: handler(calls, "fetch"), + }); + const responses = await exchange( + server.port!, + known.map((method, i) => request(method, "/m", i === known.length - 1 ? "Connection: close\r\n" : "")).join(""), + Infinity, + known, + ); + const expected = known.map(method => (routed.includes(method) ? `route ${method} ${method}` : `fetch ${method}`)); + expect({ statuses: responses.map(response => response.status), calls }).toEqual({ + statuses: known.map(() => 200), + calls: expected, + }); + }); + + test("CONNECT reaches a CONNECT route, or fetch", async () => { + const calls: string[] = []; + // The types of a route object do not have CONNECT. + const routes: Record = { "/m": { CONNECT: handler(calls, "route") } }; + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", routes, fetch: handler(calls, "fetch") }); + const responses = [ + ...(await exchange(server.port!, request("CONNECT", "/m"), 1)), + ...(await exchange(server.port!, request("CONNECT", "/nope"), 1)), + ]; + expect({ responses, calls }).toEqual({ + responses: [ + { status: 200, body: "route CONNECT" }, + { status: 200, body: "fetch CONNECT" }, + ], + calls: ["route CONNECT", "fetch CONNECT"], + }); + }); +});