diff --git a/src/bun.js/ModuleLoader.zig b/src/bun.js/ModuleLoader.zig index 0473e7711a..76f13be4d9 100644 --- a/src/bun.js/ModuleLoader.zig +++ b/src/bun.js/ModuleLoader.zig @@ -2700,6 +2700,12 @@ pub const HardcodedModule = enum { @"node:_stream_wrap", @"node:_stream_writable", @"node:_tls_common", + @"node:_http_agent", + @"node:_http_client", + @"node:_http_common", + @"node:_http_incoming", + @"node:_http_outgoing", + @"node:_http_server", /// This is gated behind '--expose-internals' @"bun:internal-for-testing", @@ -2778,6 +2784,12 @@ pub const HardcodedModule = enum { .{ "node:_stream_wrap", .@"node:_stream_wrap" }, .{ "node:_stream_writable", .@"node:_stream_writable" }, .{ "node:_tls_common", .@"node:_tls_common" }, + .{ "node:_http_agent", .@"node:_http_agent" }, + .{ "node:_http_client", .@"node:_http_client" }, + .{ "node:_http_common", .@"node:_http_common" }, + .{ "node:_http_incoming", .@"node:_http_incoming" }, + .{ "node:_http_outgoing", .@"node:_http_outgoing" }, + .{ "node:_http_server", .@"node:_http_server" }, .{ "node-fetch", HardcodedModule.@"node-fetch" }, .{ "isomorphic-fetch", HardcodedModule.@"isomorphic-fetch" }, @@ -2929,18 +2941,26 @@ pub const HardcodedModule = enum { nodeEntry("worker_threads"), nodeEntry("zlib"), + nodeEntry("node:_http_agent"), + nodeEntry("node:_http_client"), + nodeEntry("node:_http_common"), + nodeEntry("node:_http_incoming"), + nodeEntry("node:_http_outgoing"), + nodeEntry("node:_http_server"), + + nodeEntry("_http_agent"), + nodeEntry("_http_client"), + nodeEntry("_http_common"), + nodeEntry("_http_incoming"), + nodeEntry("_http_outgoing"), + nodeEntry("_http_server"), + // sys is a deprecated alias for util .{ "sys", .{ .path = "node:util", .node_builtin = true } }, .{ "node:sys", .{ .path = "node:util", .node_builtin = true } }, // These are returned in builtinModules, but probably not many // packages use them so we will just alias them. - .{ "node:_http_agent", .{ .path = "node:http", .node_builtin = true } }, - .{ "node:_http_client", .{ .path = "node:http", .node_builtin = true } }, - .{ "node:_http_common", .{ .path = "node:http", .node_builtin = true } }, - .{ "node:_http_incoming", .{ .path = "node:http", .node_builtin = true } }, - .{ "node:_http_outgoing", .{ .path = "node:http", .node_builtin = true } }, - .{ "node:_http_server", .{ .path = "node:http", .node_builtin = true } }, .{ "node:_stream_duplex", .{ .path = "node:_stream_duplex", .node_builtin = true } }, .{ "node:_stream_passthrough", .{ .path = "node:_stream_passthrough", .node_builtin = true } }, .{ "node:_stream_readable", .{ .path = "node:_stream_readable", .node_builtin = true } }, @@ -2949,12 +2969,6 @@ pub const HardcodedModule = enum { .{ "node:_stream_writable", .{ .path = "node:_stream_writable", .node_builtin = true } }, .{ "node:_tls_wrap", .{ .path = "node:tls", .node_builtin = true } }, .{ "node:_tls_common", .{ .path = "node:_tls_common", .node_builtin = true } }, - .{ "_http_agent", .{ .path = "node:http", .node_builtin = true } }, - .{ "_http_client", .{ .path = "node:http", .node_builtin = true } }, - .{ "_http_common", .{ .path = "node:http", .node_builtin = true } }, - .{ "_http_incoming", .{ .path = "node:http", .node_builtin = true } }, - .{ "_http_outgoing", .{ .path = "node:http", .node_builtin = true } }, - .{ "_http_server", .{ .path = "node:http", .node_builtin = true } }, .{ "_stream_duplex", .{ .path = "node:_stream_duplex", .node_builtin = true } }, .{ "_stream_passthrough", .{ .path = "node:_stream_passthrough", .node_builtin = true } }, .{ "_stream_readable", .{ .path = "node:_stream_readable", .node_builtin = true } }, diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 40184c4d38..a910b7ecd5 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -1,4 +1,3 @@ -const { checkIsHttpToken } = require("internal/validators"); const { isTypedArray, isArrayBuffer } = require("node:util/types"); const { @@ -85,8 +84,6 @@ const kRequest = Symbol("request"); const kCloseCallback = Symbol("closeCallback"); const kDeferredTimeouts = Symbol("deferredTimeouts"); -const RegExpPrototypeExec = RegExp.prototype.exec; - const kEmptyObject = Object.freeze(Object.create(null)); export const enum ClientRequestEmitState { @@ -142,31 +139,6 @@ function emitErrorNextTickIfErrorListener(self, err, cb) { } } } -const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; -/** - * True if val contains an invalid field-vchar - * field-value = *( field-content / obs-fold ) - * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] - * field-vchar = VCHAR / obs-text - */ -function checkInvalidHeaderChar(val: string) { - return RegExpPrototypeExec.$call(headerCharRegex, val) !== null; -} - -const validateHeaderName = (name, label?) => { - if (typeof name !== "string" || !name || !checkIsHttpToken(name)) { - throw $ERR_INVALID_HTTP_TOKEN(label || "Header name", name); - } -}; - -const validateHeaderValue = (name, value) => { - if (value === undefined) { - throw $ERR_HTTP_INVALID_HEADER_VALUE(value, name); - } - if (checkInvalidHeaderChar(value)) { - throw $ERR_INVALID_CHAR("header content", name); - } -}; // TODO: make this more robust. function isAbortError(err) { @@ -448,8 +420,6 @@ export { kRequest, kCloseCallback, kDeferredTimeouts, - validateHeaderName, - validateHeaderValue, isAbortError, kEmptyObject, getIsNextIncomingMessageHTTPS, diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index cac51f04b1..3b49c3ceab 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -376,7 +376,7 @@ function ClientRequest(input, options, cb) { setIsNextIncomingMessageHTTPS(prevIsHTTPS); res.req = this; let timer; - response.setTimeout = (msecs, callback) => { + res.setTimeout = (msecs, callback) => { if (timer) { clearTimeout(timer); } @@ -870,7 +870,7 @@ function ClientRequest(input, options, cb) { this.setTimeout = (msecs, callback) => { if (this.destroyed) return this; - this.timeout = msecs = validateMsecs(msecs, "msecs"); + this.timeout = msecs = validateMsecs(msecs, "timeout"); // Attempt to clear an existing timer in both cases - // even if it will be rescheduled we don't want to leak an existing timer. diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts new file mode 100644 index 0000000000..6dc97c97ef --- /dev/null +++ b/src/js/node/_http_common.ts @@ -0,0 +1,85 @@ +const { checkIsHttpToken } = require("internal/validators"); + +const kIncomingMessage = Symbol("IncomingMessage"); + +const RegExpPrototypeExec = RegExp.prototype.exec; + +let headerCharRegex; + +/** + * True if val contains an invalid field-vchar + * field-value = *( field-content / obs-fold ) + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + */ +function checkInvalidHeaderChar(val: string) { + if (!headerCharRegex) { + headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; + } + return RegExpPrototypeExec.$call(headerCharRegex, val) !== null; +} + +const validateHeaderName = (name, label?) => { + if (typeof name !== "string" || !name || !checkIsHttpToken(name)) { + throw $ERR_INVALID_HTTP_TOKEN(label || "Header name", name); + } +}; + +const validateHeaderValue = (name, value) => { + if (value === undefined) { + throw $ERR_HTTP_INVALID_HEADER_VALUE(value, name); + } + if (checkInvalidHeaderChar(value)) { + throw $ERR_INVALID_CHAR("header content", name); + } +}; + +const methods = [ + "DELETE", + "GET", + "HEAD", + "POST", + "PUT", + "CONNECT", + "OPTIONS", + "TRACE", + "COPY", + "LOCK", + "MKCOL", + "MOVE", + "PROPFIND", + "PROPPATCH", + "SEARCH", + "UNLOCK", + "BIND", + "REBIND", + "UNBIND", + "ACL", + "REPORT", + "MKACTIVITY", + "CHECKOUT", + "MERGE", + "M-SEARCH", + "NOTIFY", + "SUBSCRIBE", + "UNSUBSCRIBE", + "PATCH", + "PURGE", + "MKCALENDAR", + "LINK", + "UNLINK", + "SOURCE", + "QUERY", +]; + +export default { + _checkIsHttpToken: checkIsHttpToken, + _checkInvalidHeaderChar: checkInvalidHeaderChar, + chunkExpression: /(?:^|\W)chunked(?:$|\W)/i, + continueExpression: /(?:^|\W)100-continue(?:$|\W)/i, + CRLF: "\r\n", + methods, + kIncomingMessage, + validateHeaderName, + validateHeaderValue, +}; diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 3e72cdd63b..9e869c8fec 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -6,8 +6,6 @@ const { NodeHTTPHeaderState, kAbortController, fakeSocketSymbol, - validateHeaderName, - validateHeaderValue, headersSymbol, kBodyChunks, kEmitState, @@ -23,6 +21,8 @@ const { getRawKeys, } = require("internal/http"); +const { validateHeaderName, validateHeaderValue } = require("node:_http_common"); + const { FakeSocket } = require("internal/http/FakeSocket"); function OutgoingMessage(options) { @@ -161,7 +161,7 @@ const OutgoingMessagePrototype = { setTimeout(msecs, callback) { if (this.destroyed) return this; - this.timeout = msecs = validateMsecs(msecs, "msecs"); + this.timeout = msecs = validateMsecs(msecs, "timeout"); // Attempt to clear an existing timer in both cases - // even if it will be rescheduled we don't want to leak an existing timer. diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index ac70c8da4d..0cbc6842bd 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -47,11 +47,11 @@ const { format } = require("internal/util/inspect"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); +const { kIncomingMessage } = require("node:_http_common"); const getBunServerAllClosedPromise = $newZigFunction("node_http_binding.zig", "getBunServerAllClosedPromise", 1); const sendHelper = $newZigFunction("node_cluster_binding.zig", "sendHelperChild", 3); -const kIncomingMessage = Symbol("IncomingMessage"); const kServerResponse = Symbol("ServerResponse"); const kRejectNonStandardBodyWrites = Symbol("kRejectNonStandardBodyWrites"); const GlobalPromise = globalThis.Promise; diff --git a/src/js/node/http.ts b/src/js/node/http.ts index 962a05dbd0..106ee29d53 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -1,11 +1,12 @@ const { validateInteger } = require("internal/validators"); const { Agent, globalAgent, NODE_HTTP_WARNING } = require("node:_http_agent"); const { ClientRequest } = require("node:_http_client"); +const { validateHeaderName, validateHeaderValue } = require("node:_http_common"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); const { Server, ServerResponse } = require("node:_http_server"); -const { validateHeaderName, validateHeaderValue, METHODS, STATUS_CODES } = require("internal/http"); +const { METHODS, STATUS_CODES } = require("internal/http"); const { WebSocket, CloseEvent, MessageEvent } = globalThis; diff --git a/test/js/node/http/client-timeout-error.test.ts b/test/js/node/http/client-timeout-error.test.ts new file mode 100644 index 0000000000..efa430386c --- /dev/null +++ b/test/js/node/http/client-timeout-error.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { createServer } from "node:http"; +import { request } from "node:http"; +import { once } from "node:events"; + +describe("node:http client timeout", () => { + it("should emit timeout event when timeout is reached", async () => { + const server = createServer((req, res) => { + // Intentionally not sending response to trigger timeout + }).listen(0); + + try { + await once(server, "listening"); + const port = (server.address() as any).port; + + const req = request({ + port, + host: "localhost", + path: "/", + timeout: 50, // Set a short timeout + }); + + let timeoutEventEmitted = false; + let destroyCalled = false; + + req.on("timeout", () => { + timeoutEventEmitted = true; + }); + + req.on("close", () => { + destroyCalled = true; + }); + + req.end(); + + // Wait for events to be emitted + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(timeoutEventEmitted).toBe(true); + expect(destroyCalled).toBe(true); + expect(req.destroyed).toBe(true); + } finally { + server.close(); + } + }); + + it("should clear timeout when explicitly set to 0", async () => { + const server = createServer((req, res) => { + res.end("OK"); + }).listen(0); + + try { + await once(server, "listening"); + const port = (server.address() as any).port; + + const req = request({ + port, + host: "localhost", + path: "/", + }); + + let timeoutEventEmitted = false; + req.on("timeout", () => { + timeoutEventEmitted = true; + }); + + // Set and then clear timeout + req.setTimeout(50); + req.setTimeout(0); + + req.end(); + + // Wait longer than the original timeout + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(timeoutEventEmitted).toBe(false); + expect(req.destroyed).toBe(false); + } finally { + server.close(); + } + }); +}); diff --git a/test/js/node/test/parallel/test-http-client-req-error-dont-double-fire.js b/test/js/node/test/parallel/test-http-client-req-error-dont-double-fire.js new file mode 100644 index 0000000000..b162df03d6 --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-req-error-dont-double-fire.js @@ -0,0 +1,27 @@ +'use strict'; + +// This tests that the error emitted on the socket does +// not get fired again when the 'error' event handler throws +// an error. + +const common = require('../common'); +const { addresses } = require('../common/internet'); +const { errorLookupMock } = require('../common/dns'); + +const assert = require('assert'); +const http = require('http'); + +const host = addresses.INVALID_HOST; + +const req = http.get({ + host, + lookup: common.mustCall(errorLookupMock()) +}); +const err = new Error('mock unexpected code error'); +req.on('error', common.mustCall(() => { + throw err; +})); + +process.on('uncaughtException', common.mustCall((e) => { + assert.strictEqual(e, err); +})); diff --git a/test/js/node/test/parallel/test-http-client-timeout-option.js b/test/js/node/test/parallel/test-http-client-timeout-option.js new file mode 100644 index 0000000000..1003c28b5d --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-timeout-option.js @@ -0,0 +1,40 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +assert.throws(() => { + http.request({ timeout: null }); +}, /The "timeout" argument must be of type number/); + +const options = { + method: 'GET', + port: undefined, + host: '127.0.0.1', + path: '/', + timeout: 1 +}; + +const server = http.createServer(); + +server.listen(0, options.host, function() { + options.port = this.address().port; + const req = http.request(options); + req.on('error', function() { + // This space is intentionally left blank + }); + req.on('close', common.mustCall(() => { + assert.strictEqual(req.destroyed, true); + server.close(); + })); + + let timeout_events = 0; + req.on('timeout', common.mustCall(() => timeout_events += 1)); + setTimeout(function() { + req.destroy(); + assert.strictEqual(timeout_events, 1); + }, common.platformTimeout(100)); + setTimeout(function() { + req.end(); + }, common.platformTimeout(10)); +}); diff --git a/test/js/node/test/parallel/test-http-common.js b/test/js/node/test/parallel/test-http-common.js new file mode 100644 index 0000000000..1629856ce5 --- /dev/null +++ b/test/js/node/test/parallel/test-http-common.js @@ -0,0 +1,33 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const httpCommon = require('_http_common'); +const checkIsHttpToken = httpCommon._checkIsHttpToken; +const checkInvalidHeaderChar = httpCommon._checkInvalidHeaderChar; + +// checkIsHttpToken +assert(checkIsHttpToken('t')); +assert(checkIsHttpToken('tt')); +assert(checkIsHttpToken('ttt')); +assert(checkIsHttpToken('tttt')); +assert(checkIsHttpToken('ttttt')); + +assert.strictEqual(checkIsHttpToken(''), false); +assert.strictEqual(checkIsHttpToken(' '), false); +assert.strictEqual(checkIsHttpToken('あ'), false); +assert.strictEqual(checkIsHttpToken('あa'), false); +assert.strictEqual(checkIsHttpToken('aaaaあaaaa'), false); + +// checkInvalidHeaderChar +assert(checkInvalidHeaderChar('あ')); +assert(checkInvalidHeaderChar('aaaaあaaaa')); + +assert.strictEqual(checkInvalidHeaderChar(''), false); +assert.strictEqual(checkInvalidHeaderChar(1), false); +assert.strictEqual(checkInvalidHeaderChar(' '), false); +assert.strictEqual(checkInvalidHeaderChar(false), false); +assert.strictEqual(checkInvalidHeaderChar('t'), false); +assert.strictEqual(checkInvalidHeaderChar('tt'), false); +assert.strictEqual(checkInvalidHeaderChar('ttt'), false); +assert.strictEqual(checkInvalidHeaderChar('tttt'), false); +assert.strictEqual(checkInvalidHeaderChar('ttttt'), false); diff --git a/test/js/node/test/parallel/test-http-dns-error.js b/test/js/node/test/parallel/test-http-dns-error.js new file mode 100644 index 0000000000..20e12f24fb --- /dev/null +++ b/test/js/node/test/parallel/test-http-dns-error.js @@ -0,0 +1,78 @@ +// 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'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const http = require('http'); +const https = require('https'); + +const host = '*'.repeat(64); +const MAX_TRIES = 5; + +const errCodes = ['ENOTFOUND', 'EAI_FAIL']; + +function tryGet(mod, tries) { + // Bad host name should not throw an uncatchable exception. + // Ensure that there is time to attach an error listener. + const req = mod.get({ host: host, port: 42 }, common.mustNotCall()); + req.on('error', common.mustCall(function(err) { + if (err.code === 'EAGAIN' && tries < MAX_TRIES) { + tryGet(mod, ++tries); + return; + } + assert(errCodes.includes(err.code), err); + })); + // http.get() called req1.end() for us +} + +function tryRequest(mod, tries) { + const req = mod.request({ + method: 'GET', + host: host, + port: 42 + }, common.mustNotCall()); + req.on('error', common.mustCall(function(err) { + if (err.code === 'EAGAIN' && tries < MAX_TRIES) { + tryRequest(mod, ++tries); + return; + } + assert(errCodes.includes(err.code), err); + })); + req.end(); +} + +function test(mod) { + tryGet(mod, 0); + tryRequest(mod, 0); +} + +if (common.hasCrypto) { + test(https); +} else { + common.printSkipMessage('missing crypto'); +} + +test(http); diff --git a/test/js/node/test/parallel/test-http-invalidheaderfield2.js b/test/js/node/test/parallel/test-http-invalidheaderfield2.js new file mode 100644 index 0000000000..1b4e9e6edb --- /dev/null +++ b/test/js/node/test/parallel/test-http-invalidheaderfield2.js @@ -0,0 +1,88 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const inspect = require('util').inspect; +const { _checkIsHttpToken, _checkInvalidHeaderChar } = require('_http_common'); + +// Good header field names +[ + 'TCN', + 'ETag', + 'date', + 'alt-svc', + 'Content-Type', + '0', + 'Set-Cookie2', + 'Set_Cookie', + 'foo`bar^', + 'foo|bar', + '~foobar', + 'FooBar!', + '#Foo', + '$et-Cookie', + '%%Test%%', + 'Test&123', + 'It\'s_fun', + '2*3', + '4+2', + '3.14159265359', +].forEach(function(str) { + assert.strictEqual( + _checkIsHttpToken(str), true, + `_checkIsHttpToken(${inspect(str)}) unexpectedly failed`); +}); +// Bad header field names +[ + ':', + '@@', + '中文呢', // unicode + '((((())))', + ':alternate-protocol', + 'alternate-protocol:', + 'foo\nbar', + 'foo\rbar', + 'foo\r\nbar', + 'foo\x00bar', + '\x7FMe!', + '{Start', + '(Start', + '[Start', + 'End}', + 'End)', + 'End]', + '"Quote"', + 'This,That', +].forEach(function(str) { + assert.strictEqual( + _checkIsHttpToken(str), false, + `_checkIsHttpToken(${inspect(str)}) unexpectedly succeeded`); +}); + + +// Good header field values +[ + 'foo bar', + 'foo\tbar', + '0123456789ABCdef', + '!@#$%^&*()-_=+\\;\':"[]{}<>,./?|~`', +].forEach(function(str) { + assert.strictEqual( + _checkInvalidHeaderChar(str), false, + `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly failed`); +}); + +// Bad header field values +[ + 'foo\rbar', + 'foo\nbar', + 'foo\r\nbar', + '中文呢', // unicode + '\x7FMe!', + 'Testing 123\x00', + 'foo\vbar', + 'Ding!\x07', +].forEach(function(str) { + assert.strictEqual( + _checkInvalidHeaderChar(str), true, + `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly succeeded`); +}); diff --git a/test/js/node/test/parallel/test-http-outgoing-finish-writable.js b/test/js/node/test/parallel/test-http-outgoing-finish-writable.js new file mode 100644 index 0000000000..e3c870164b --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-finish-writable.js @@ -0,0 +1,40 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// Verify that after calling end() on an `OutgoingMessage` (or a type that +// inherits from `OutgoingMessage`), its `writable` property is not set to false + +const server = http.createServer(common.mustCall(function(req, res) { + assert.strictEqual(res.writable, true); + assert.strictEqual(res.finished, false); + assert.strictEqual(res.writableEnded, false); + res.end(); + + // res.writable is set to false after it has finished sending + // Ref: https://github.com/nodejs/node/issues/15029 + assert.strictEqual(res.writable, true); + assert.strictEqual(res.finished, true); + assert.strictEqual(res.writableEnded, true); + + server.close(); +})); + +server.listen(0); + +server.on('listening', common.mustCall(function() { + const clientRequest = http.request({ + port: server.address().port, + method: 'GET', + path: '/' + }); + + assert.strictEqual(clientRequest.writable, true); + clientRequest.end(); + + // Writable is still true when close + // THIS IS LEGACY, we cannot change it + // unless we break error detection + assert.strictEqual(clientRequest.writable, true); +})); diff --git a/test/js/node/test/parallel/test-http-response-add-header-after-sent.js b/test/js/node/test/parallel/test-http-response-add-header-after-sent.js new file mode 100644 index 0000000000..27dc47529f --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-add-header-after-sent.js @@ -0,0 +1,24 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer((req, res) => { + res.setHeader('header1', 1); + res.write('abc'); + assert.throws( + () => res.setHeader('header2', 2), + { + code: 'ERR_HTTP_HEADERS_SENT', + name: 'Error', + message: 'Cannot set headers after they are sent to the client' + } + ); + res.end(); +}); + +server.listen(0, () => { + http.get({ port: server.address().port }, () => { + server.close(); + }); +}); diff --git a/test/js/node/test/parallel/test-http-response-splitting.js b/test/js/node/test/parallel/test-http-response-splitting.js new file mode 100644 index 0000000000..78e7a94b25 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-splitting.js @@ -0,0 +1,63 @@ +'use strict'; + +require('../common'); +const http = require('http'); +const net = require('net'); +const url = require('url'); +const assert = require('assert'); +const Countdown = require('../common/countdown'); + +// Response splitting example, credit: Amit Klein, Safebreach +const str = '/welcome?lang=bar%c4%8d%c4%8aContent-Length:%200%c4%8d%c4%8a%c' + + '4%8d%c4%8aHTTP/1.1%20200%20OK%c4%8d%c4%8aContent-Length:%202' + + '0%c4%8d%c4%8aLast-Modified:%20Mon,%2027%20Oct%202003%2014:50:18' + + '%20GMT%c4%8d%c4%8aContent-Type:%20text/html%c4%8d%c4%8a%c4%8' + + 'd%c4%8a%3chtml%3eGotcha!%3c/html%3e'; + +// Response splitting example, credit: Сковорода Никита Андреевич (@ChALkeR) +const x = 'fooഊSet-Cookie: foo=barഊഊ'; +const y = 'foo⠊Set-Cookie: foo=bar'; + +let count = 0; +const countdown = new Countdown(3, () => server.close()); + +function test(res, code, key, value) { + const header = { [key]: value }; + assert.throws( + () => res.writeHead(code, header), + { + code: 'ERR_INVALID_CHAR', + name: 'TypeError', + message: `Invalid character in header content ["${key}"]` + } + ); +} + +const server = http.createServer((req, res) => { + switch (count++) { + case 0: { + const loc = url.parse(req.url, true).query.lang; + test(res, 302, 'Location', `/foo?lang=${loc}`); + break; + } + case 1: + test(res, 200, 'foo', x); + break; + case 2: + test(res, 200, 'foo', y); + break; + default: + assert.fail('should not get to here.'); + } + countdown.dec(); + res.end('ok'); +}); +server.listen(0, () => { + const end = 'HTTP/1.1\r\nHost: example.com\r\n\r\n'; + const client = net.connect({ port: server.address().port }, () => { + client.write(`GET ${str} ${end}`); + client.write(`GET / ${end}`); + client.write(`GET / ${end}`); + client.end(); + }); +}); diff --git a/test/js/node/test/parallel/test-http-response-status-message.js b/test/js/node/test/parallel/test-http-response-status-message.js new file mode 100644 index 0000000000..3c22e40b43 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-status-message.js @@ -0,0 +1,86 @@ +// 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 http = require('http'); +const net = require('net'); +const Countdown = require('../common/countdown'); + +const testCases = [ + { path: '/200', statusMessage: 'OK', + response: 'HTTP/1.1 200 OK\r\n\r\n' }, + { path: '/500', statusMessage: 'Internal Server Error', + response: 'HTTP/1.1 500 Internal Server Error\r\n\r\n' }, + { path: '/302', statusMessage: 'Moved Temporarily', + response: 'HTTP/1.1 302 Moved Temporarily\r\n\r\n' }, + { path: '/missing', statusMessage: '', + response: 'HTTP/1.1 200 \r\n\r\n' }, + { path: '/missing-no-space', statusMessage: '', + response: 'HTTP/1.1 200\r\n\r\n' }, +]; +testCases.findByPath = function(path) { + const matching = this.filter(function(testCase) { + return testCase.path === path; + }); + if (matching.length === 0) { + assert.fail(`failed to find test case with path ${path}`); + } + return matching[0]; +}; + +const server = net.createServer(function(connection) { + connection.on('data', function(data) { + const path = data.toString().match(/GET (.*) HTTP\/1\.1/)[1]; + const testCase = testCases.findByPath(path); + + connection.write(testCase.response); + connection.end(); + }); +}); + +const countdown = new Countdown(testCases.length, () => server.close()); + +function runTest(testCaseIndex) { + const testCase = testCases[testCaseIndex]; + + http.get({ + port: server.address().port, + path: testCase.path + }, function(response) { + console.log(`client: expected status message: ${testCase.statusMessage}`); + console.log(`client: actual status message: ${response.statusMessage}`); + assert.strictEqual(testCase.statusMessage, response.statusMessage); + + response.on('aborted', common.mustNotCall()); + response.on('end', function() { + countdown.dec(); + if (testCaseIndex + 1 < testCases.length) { + runTest(testCaseIndex + 1); + } + }); + + response.resume(); + }); +} + +server.listen(0, function() { runTest(0); }); diff --git a/test/js/node/test/parallel/test-http-response-statuscode.js b/test/js/node/test/parallel/test-http-response-statuscode.js new file mode 100644 index 0000000000..f552270407 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-statuscode.js @@ -0,0 +1,90 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const Countdown = require('../common/countdown'); + +const MAX_REQUESTS = 13; +let reqNum = 0; + +function test(res, header, code) { + assert.throws(() => { + res.writeHead(header); + }, { + code: 'ERR_HTTP_INVALID_STATUS_CODE', + name: 'RangeError', + message: `Invalid status code: ${code}` + }); +} + +const server = http.Server(common.mustCall(function(req, res) { + switch (reqNum) { + case 0: + test(res, -1, '-1'); + break; + case 1: + test(res, Infinity, 'Infinity'); + break; + case 2: + test(res, NaN, 'NaN'); + break; + case 3: + test(res, {}, '{}'); + break; + case 4: + test(res, 99, '99'); + break; + case 5: + test(res, 1000, '1000'); + break; + case 6: + test(res, '1000', '1000'); + break; + case 7: + test(res, null, 'null'); + break; + case 8: + test(res, true, 'true'); + break; + case 9: + test(res, [], '[]'); + break; + case 10: + test(res, 'this is not valid', 'this is not valid'); + break; + case 11: + test(res, '404 this is not valid either', '404 this is not valid either'); + break; + case 12: + assert.throws(() => { res.writeHead(); }, + { + code: 'ERR_HTTP_INVALID_STATUS_CODE', + name: 'RangeError', + message: 'Invalid status code: undefined' + }); + this.close(); + break; + default: + throw new Error('Unexpected request'); + } + res.statusCode = 200; + res.end(); +}, MAX_REQUESTS)); +server.listen(); + +const countdown = new Countdown(MAX_REQUESTS, () => server.close()); + +server.on('listening', function makeRequest() { + http.get({ + port: this.address().port + }, (res) => { + assert.strictEqual(res.statusCode, 200); + res.on('end', () => { + countdown.dec(); + reqNum = MAX_REQUESTS - countdown.remaining; + if (countdown.remaining > 0) + makeRequest.call(this); + }); + res.resume(); + }); +});