diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 394f2a08a924..a3b1e580dd3c 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -398,7 +398,7 @@ function hasServerResponseFinished(self, chunk, callback) { if (finished) { err = $ERR_STREAM_WRITE_AFTER_END(); } else if (destroyed) { - err = $ERR_STREAM_DESTROYED("Stream is destroyed"); + err = $ERR_STREAM_DESTROYED("write"); } if (!destroyed) { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 282248b5675b..8a20016c47ba 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2114,7 +2114,7 @@ function _writeHead(statusCode, reason, obj, response) { // header fields, regardless of the header fields present in the // message, and thus cannot contain a message body or 'trailers'. if (hasInvalidTrailer(response)) { - throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding"); + throw $ERR_HTTP_TRAILER_INVALID(); } // Headers in obj should override previous headers but still // allow explicit duplicates. To do so, we first remove any @@ -2144,7 +2144,7 @@ function _writeHead(statusCode, reason, obj, response) { // The message is not chunk-framed, so `Trailer` is the offending header; drop it // so a caller that swallows the throw cannot put it on the wire. response.removeHeader("trailer"); - throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding"); + throw $ERR_HTTP_TRAILER_INVALID(); } } } diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 95cbbcc1dbee..a0237fcca6a2 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -1148,7 +1148,7 @@ function asyncWrap(fn: any, name: string) { if (bytesWritten === 0) { if (++retries > 5) { - throw $ERR_OPERATION_FAILED("Operation failed: write failed after retries"); + throw $ERR_OPERATION_FAILED("write failed after retries"); } } else { retries = 0; @@ -1192,7 +1192,7 @@ function asyncWrap(fn: any, name: string) { // Retry the writev as-is on a zero-byte write (up to 5 times) // instead of degrading to the concat fallback below. if (++retries > 5) { - throw $ERR_OPERATION_FAILED("Operation failed: writev failed after retries"); + throw $ERR_OPERATION_FAILED("writev failed after retries"); } continue; } @@ -1229,7 +1229,7 @@ function asyncWrap(fn: any, name: string) { const bytesWritten = fsSync.writeSync(fd, buf, offset, length, position >= 0 ? position : null) || 0; if (bytesWritten === 0) { if (++retries > 5) { - throw $ERR_OPERATION_FAILED("Operation failed: write failed after retries"); + throw $ERR_OPERATION_FAILED("write failed after retries"); } } else { retries = 0; diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index b9fc1144b82d..6e4e0fc9b600 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2391,6 +2391,25 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_HEADERS_SENT, message)); } + case Bun::ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH: { + auto arg0 = callFrame->argument(1); + auto str0 = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto arg1 = callFrame->argument(2); + auto str1 = arg1.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto message = makeString("Response body's content-length of "_s, str0, " byte(s) does not match the content-length of "_s, str1, " byte(s) set in header"_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, message)); + } + + case Bun::ErrorCode::ERR_HTTP2_UNSUPPORTED_PROTOCOL: { + auto arg0 = callFrame->argument(1); + auto str0 = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto message = makeString("protocol \""_s, str0, "\" is unsupported."_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_UNSUPPORTED_PROTOCOL, message)); + } + case Bun::ErrorCode::ERR_UNESCAPED_CHARACTERS: { auto arg0 = callFrame->argument(1); auto str0 = arg0.toWTFString(globalObject); @@ -2670,6 +2689,10 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_GOAWAY_SESSION, "New streams cannot be created after receiving a GOAWAY"_s)); case ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED, "Adding content for this request method or response status is not allowed."_s)); + case ErrorCode::ERR_HTTP_TRAILER_INVALID: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_TRAILER_INVALID, "Trailers are invalid with this transfer encoding"_s)); + case ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_SOCKET_ASSIGNED, "Socket already assigned"_s)); case ErrorCode::ERR_STREAM_RELEASE_LOCK: @@ -2708,7 +2731,14 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject } } - auto&& message = callFrame->argument(1).toWTFString(globalObject); + auto messageValue = callFrame->argument(1); +#if ASSERT_ENABLED + if (!messageValue.isString()) { + JSC::throwTypeError(globalObject, scope, makeString("$ERR_ code "_s, static_cast(error), " has no message template; first argument must be the full message string"_s)); + RELEASE_AND_RETURN(scope, {}); + } +#endif + auto&& message = messageValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); return JSC::JSValue::encode(createError(globalObject, error, message)); diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 3945a7331475..2e6853499374 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -289,6 +289,24 @@ fn err_throw(global: &JSGlobalObject, code: ErrorCode, msg: &'static str) -> Err(err_throw_cold(global, code, msg)) } +#[cold] +#[inline(never)] +fn err_throw_content_length_mismatch( + global: &JSGlobalObject, + actual: u64, + expected: u64, +) -> JsResult { + Err(global + .err( + ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, + format_args!( + "Response body's content-length of {} byte(s) does not match the content-length of {} byte(s) set in header", + actual, expected + ), + ) + .throw()) +} + /// AnyResponse `is_ssl()` shim (upstream lacks this accessor). #[inline] fn any_response_is_ssl(r: &uws::AnyResponse) -> bool { @@ -1989,17 +2007,17 @@ impl NodeHTTPResponse { if IS_END { if bytes_written as u64 != content_length { - return err_throw( + return err_throw_content_length_mismatch( global_object, - ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, - "Content-Length mismatch", + bytes_written as u64, + content_length, ); } } else if bytes_written as u64 > content_length { - return err_throw( + return err_throw_content_length_mismatch( global_object, - ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, - "Content-Length mismatch", + bytes_written as u64, + content_length, ); } self.bytes_written.set(bytes_written); diff --git a/test/js/node/node-error-messages.test.ts b/test/js/node/node-error-messages.test.ts new file mode 100644 index 000000000000..14706d97bc4f --- /dev/null +++ b/test/js/node/node-error-messages.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { once } from "node:events"; +import http from "node:http"; +import http2 from "node:http2"; +import net from "node:net"; + +// Node.js error codes are produced in C++ (ErrorCode.cpp jsFunctionMakeErrorWithCode). +// Codes with no message template fall through to "use the first JS argument verbatim +// as .message", so a call site that passes template args instead of the full sentence +// yields a broken message, and a call site that passes the full sentence into a code +// that *is* templated gets the template wrapper applied twice. These tests pin the +// messages to Node.js's format for the codes that were observably wrong. + +test("ERR_HTTP2_UNSUPPORTED_PROTOCOL message matches Node.js", () => { + let err: any; + try { + http2.connect("gopher://127.0.0.1:1"); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_HTTP2_UNSUPPORTED_PROTOCOL"); + expect(err?.message).toBe('protocol "gopher:" is unsupported.'); +}); + +test("ERR_HTTP_TRAILER_INVALID message matches Node.js", () => { + const req = http.request({ port: 1, method: "POST", createConnection: () => new net.Socket() }); + req.on("error", () => {}); + req.setHeader("Content-Length", "5"); + req.setHeader("Trailer", "X-Foo"); + let err: any; + try { + req.flushHeaders(); + } catch (e) { + err = e; + } + req.destroy(); + expect({ code: err?.code, message: err?.message }).toEqual({ + code: "ERR_HTTP_TRAILER_INVALID", + message: "Trailers are invalid with this transfer encoding", + }); +}); + +test("ERR_HTTP_CONTENT_LENGTH_MISMATCH message matches Node.js", () => { + const req = http.request({ port: 1, method: "POST", createConnection: () => new net.Socket() }); + req.on("error", () => {}); + req.strictContentLength = true; + req.setHeader("Content-Length", "5"); + req.flushHeaders(); + let err: any; + try { + req.write("hello world"); + } catch (e) { + err = e; + } + req.destroy(); + expect({ code: err?.code, message: err?.message }).toEqual({ + code: "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + message: + "Response body's content-length of 11 byte(s) does not match the content-length of 5 byte(s) set in header", + }); +}); + +test("ERR_HTTP_CONTENT_LENGTH_MISMATCH message from http ServerResponse matches Node.js", async () => { + const { promise, resolve } = Promise.withResolvers(); + const server = http.createServer((req, res) => { + res.strictContentLength = true; + res.setHeader("Content-Length", "5"); + try { + res.end("hello world"); + resolve(undefined); + } catch (e) { + resolve(e); + } + try { + res.destroy(); + } catch {} + }); + server.listen(0); + await once(server, "listening"); + const sock = net.connect((server.address() as net.AddressInfo).port); + sock.on("error", () => {}); + sock.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const err = await promise; + sock.destroy(); + await new Promise(r => server.close(() => r())); + expect({ code: err?.code, message: err?.message }).toEqual({ + code: "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + message: + "Response body's content-length of 11 byte(s) does not match the content-length of 5 byte(s) set in header", + }); +}); + +test("ERR_STREAM_DESTROYED message from http ServerResponse matches Node.js", async () => { + const { promise, resolve } = Promise.withResolvers(); + const server = http.createServer((req, res) => { + res.destroy(); + res.write("x", resolve); + }); + server.listen(0); + await once(server, "listening"); + const sock = net.connect((server.address() as net.AddressInfo).port); + sock.on("error", () => {}); + sock.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const err = await promise; + sock.destroy(); + await new Promise(r => server.close(() => r())); + expect({ code: err?.code, message: err?.message }).toEqual({ + code: "ERR_STREAM_DESTROYED", + message: "Cannot call write after a stream was destroyed", + }); +});