diff --git a/bench/snippets/require-builtins.mjs b/bench/snippets/require-builtins.mjs index 4d59f13532..1c1af9e79f 100644 --- a/bench/snippets/require-builtins.mjs +++ b/bench/snippets/require-builtins.mjs @@ -11,10 +11,10 @@ const builtin = ${JSON.stringify(builtin)}; const now = performance.now(); require(builtin); const end = performance.now(); -process.stdout.write(JSON.stringify({builtin, time: end - now}) + "\\n"); +process.stdout.write(JSON.stringify({ builtin, time: end - now }) + "\\n"); `, ); - const result = spawnSync(typeof Bun !== "undefined" ? "bun" : "node", [path], { + spawnSync(process.execPath, [path], { stdio: ["inherit", "inherit", "inherit"], env: { ...process.env, diff --git a/scripts/runner.node.mjs b/scripts/runner.node.mjs index cdf824ddfb..5029e34823 100755 --- a/scripts/runner.node.mjs +++ b/scripts/runner.node.mjs @@ -202,12 +202,12 @@ if (isBuildkite) { const doc = await res.json(); console.log(`-> page ${i}, found ${doc.length} items`); if (doc.length === 0) break; - if (doc.length < per_page) break; for (const { filename, status } of doc) { prFileCount += 1; if (status !== "added") continue; newFiles.push(filename); } + if (doc.length < per_page) break; } console.log(`- PR ${process.env.BUILDKITE_PULL_REQUEST}, ${prFileCount} files, ${newFiles.length} new files`); } catch (e) { diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index e87e171e6e..047aa1eab1 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -208,6 +208,7 @@ const errors: ErrorCodeMapping = [ ["ERR_POSTGRES_UNSUPPORTED_BYTEA_FORMAT", TypeError, "PostgresError"], ["ERR_POSTGRES_UNSUPPORTED_INTEGER_SIZE", TypeError, "PostgresError"], ["ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT", TypeError, "PostgresError"], + ["ERR_PROXY_INVALID_CONFIG", Error], ["ERR_MYSQL_CONNECTION_CLOSED", Error, "MySQLError"], ["ERR_MYSQL_CONNECTION_TIMEOUT", Error, "MySQLError"], ["ERR_MYSQL_IDLE_TIMEOUT", Error, "MySQLError"], diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 1ad9622989..6ff8e9e4de 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -1,3 +1,5 @@ +const { isIPv4 } = require("internal/net/isIP"); + const { getHeader, setHeader, @@ -360,6 +362,130 @@ const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTT const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); const kOutHeaders = Symbol("kOutHeaders"); +function ipToInt(ip) { + const octets = ip.split("."); + let result = 0; + for (let i = 0; i < octets.length; i++) result = (result << 8) + Number.parseInt(octets[i]); + return result >>> 0; +} + +class ProxyConfig { + href; + protocol; + auth; + bypassList; + proxyConnectionOptions; + + constructor(proxyUrl, keepAlive, noProxyList) { + let parsedURL; + try { + parsedURL = new URL(proxyUrl); + } catch { + throw $ERR_PROXY_INVALID_CONFIG(`Invalid proxy URL: ${proxyUrl}`); + } + const { hostname, port, protocol, username, password } = parsedURL; + + this.href = proxyUrl; + this.protocol = protocol; + + if (username || password) { + // If username or password is provided, prepare the proxy-authorization header. + const auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; + this.auth = `Basic ${Buffer.from(auth).toString("base64")}`; + } + if (noProxyList) { + this.bypassList = noProxyList.split(",").map(entry => entry.trim().toLowerCase()); + } else { + this.bypassList = []; + } + + this.proxyConnectionOptions = { + // The host name comes from parsed URL so if it starts with '[' it must be an IPv6 address ending with ']'. Remove the brackets for net.connect(). + host: hostname[0] === "[" ? hostname.slice(1, -1) : hostname, + // The port comes from parsed URL so it is either '' or a valid number string. + port: port ? Number(port) : protocol === "https:" ? 443 : 80, + }; + } + + // See: https://about.gitlab.com/blog/we-need-to-talk-no-proxy + shouldUseProxy(hostname, port) { + const bypassList = this.bypassList; + if (this.bypassList.length === 0) return true; // No bypass list, always use the proxy. + const host = hostname.toLowerCase(); + const hostWithPort = port ? `${host}:${port}` : host; + + for (let i = 0; i < bypassList.length; i++) { + const entry = bypassList[i]; + + if (entry === "*") return false; // * bypasses all hosts. + if (entry === host || entry === hostWithPort) return false; // Matching host and host:port + + // Follow curl's behavior: strip leading dot before matching suffixes. + if (entry.startsWith(".")) { + const suffix = entry.substring(1); + if (host.endsWith(suffix)) return false; + } + + // Handle wildcards like *.example.com + if (entry.startsWith("*.") && host.endsWith(entry.substring(1))) return false; + + // Handle IP ranges (simple format like 192.168.1.0-192.168.1.255) + // TODO: support IPv6. + if (entry.includes("-") && isIPv4(host)) { + let { 0: startIP, 1: endIP } = entry.split("-"); + startIP = startIP.trim(); + endIP = endIP.trim(); + if (startIP && endIP && isIPv4(startIP) && isIPv4(endIP)) { + const hostInt = ipToInt(host); + const startInt = ipToInt(startIP); + const endInt = ipToInt(endIP); + if (hostInt >= startInt && hostInt <= endInt) return false; + } + } + + // It might be useful to support CIDR notation, but it's not so widely supported + // in other tools as a de-facto standard to follow, so we don't implement it for now. + } + + return true; + } +} + +function parseProxyConfigFromEnv(env, protocol, keepAlive) { + // We only support proxying for HTTP and HTTPS requests. + if (protocol !== "http:" && protocol !== "https:") return null; + // Get the proxy url - following the most popular convention, lower case takes precedence. + // See https://about.gitlab.com/blog/we-need-to-talk-no-proxy/#http_proxy-and-https_proxy + const proxyUrl = protocol === "https:" ? env.https_proxy || env.HTTPS_PROXY : env.http_proxy || env.HTTP_PROXY; + // No proxy settings from the environment, ignore. + if (!proxyUrl) return null; + + if (proxyUrl.includes("\r") || proxyUrl.includes("\n")) { + throw $ERR_PROXY_INVALID_CONFIG(`Invalid proxy URL: ${proxyUrl}`); + } + + // Only http:// and https:// proxies are supported. Ignore instead of throw, in case other protocols are supposed to be handled by the user land. + if (!proxyUrl.startsWith("http://") && !proxyUrl.startsWith("https://")) return null; + return new ProxyConfig(proxyUrl, keepAlive, env.no_proxy || env.NO_PROXY); +} + +function checkShouldUseProxy(proxyConfig: ProxyConfig, reqOptions: any) { + if (!proxyConfig) return false; + if (reqOptions.socketPath) return false; // If socketPath is set, the endpoint is a Unix domain socket, which can't be proxied. + return proxyConfig.shouldUseProxy(reqOptions.host || "localhost", reqOptions.port); +} + +function filterEnvForProxies(env) { + return { + http_proxy: env.http_proxy, + HTTP_PROXY: env.HTTP_PROXY, + https_proxy: env.https_proxy, + HTTPS_PROXY: env.HTTPS_PROXY, + no_proxy: env.no_proxy, + NO_PROXY: env.NO_PROXY, + }; +} + export { ConnResetException, Headers, @@ -369,6 +495,7 @@ export { assignHeadersFast, bodyStreamSymbol, callCloseCallback, + checkShouldUseProxy, controllerSymbol, deferredSymbol, drainMicrotasks, @@ -378,6 +505,7 @@ export { emitErrorNextTickIfErrorListenerNT, eofInProgress, fakeSocketSymbol, + filterEnvForProxies, firstWriteSymbol, getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, getHeader, @@ -425,6 +553,7 @@ export { kUseDefaultPort, noBodySymbol, optionsSymbol, + parseProxyConfigFromEnv, reqSymbol, runSymbol, serverSymbol, diff --git a/src/js/internal/net/isIP.ts b/src/js/internal/net/isIP.ts new file mode 100644 index 0000000000..93d3845640 --- /dev/null +++ b/src/js/internal/net/isIP.ts @@ -0,0 +1,37 @@ +const v4Seg = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"; +const v4Str = `(?:${v4Seg}\\.){3}${v4Seg}`; +var IPv4Reg: RegExp | undefined; + +const v6Seg = "(?:[0-9a-fA-F]{1,4})"; +var IPv6Reg: RegExp | undefined; + +function isIPv4(s) { + return (IPv4Reg ??= new RegExp(`^${v4Str}$`)).test(s); +} + +function isIPv6(s) { + return (IPv6Reg ??= new RegExp( + "^(?:" + + `(?:${v6Seg}:){7}(?:${v6Seg}|:)|` + + `(?:${v6Seg}:){6}(?:${v4Str}|:${v6Seg}|:)|` + + `(?:${v6Seg}:){5}(?::${v4Str}|(?::${v6Seg}){1,2}|:)|` + + `(?:${v6Seg}:){4}(?:(?::${v6Seg}){0,1}:${v4Str}|(?::${v6Seg}){1,3}|:)|` + + `(?:${v6Seg}:){3}(?:(?::${v6Seg}){0,2}:${v4Str}|(?::${v6Seg}){1,4}|:)|` + + `(?:${v6Seg}:){2}(?:(?::${v6Seg}){0,3}:${v4Str}|(?::${v6Seg}){1,5}|:)|` + + `(?:${v6Seg}:){1}(?:(?::${v6Seg}){0,4}:${v4Str}|(?::${v6Seg}){1,6}|:)|` + + `(?::(?:(?::${v6Seg}){0,5}:${v4Str}|(?::${v6Seg}){1,7}|:))` + + ")(?:%[0-9a-zA-Z-.:]{1,})?$", + )).test(s); +} + +function isIP(s) { + if (isIPv4(s)) return 4; + if (isIPv6(s)) return 6; + return 0; +} + +export default { + isIPv4, + isIPv6, + isIP, +}; diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 089a40a68e..afcf9c8930 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -126,6 +126,17 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); +function getLazy(initializer: () => T) { + let value: T; + let initialized = false; + return function () { + if (initialized) return value; + value = initializer(); + initialized = true; + return value; + }; +} + // export default { @@ -137,6 +148,7 @@ export default { NodeAggregateError, ErrnoException, once, + getLazy, kHandle: Symbol("kHandle"), kAutoDestroyed: Symbol("kAutoDestroyed"), diff --git a/src/js/node/_http_agent.ts b/src/js/node/_http_agent.ts index c7e3c441ec..c9f62b96c6 100644 --- a/src/js/node/_http_agent.ts +++ b/src/js/node/_http_agent.ts @@ -1,153 +1,488 @@ -const EventEmitter: typeof import("node:events").EventEmitter = require("node:events"); +const EventEmitter = require("node:events"); +const { parseProxyConfigFromEnv, kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http"); +const { getLazy, kEmptyObject, once } = require("internal/shared"); +const { validateNumber, validateOneOf, validateString } = require("internal/validators"); +const { isIP } = require("internal/net/isIP"); -const { kEmptyObject } = require("internal/http"); +const kOnKeylog = Symbol("onkeylog"); +const kRequestOptions = Symbol("requestOptions"); -const { FakeSocket } = require("internal/http/FakeSocket"); - -const ObjectDefineProperty = Object.defineProperty; - -const kfakeSocket = Symbol("kfakeSocket"); - -const NODE_HTTP_WARNING = - "WARN: Agent is mostly unused in Bun's implementation of http. If you see strange behavior, this is probably the cause."; - -// Define Agent interface -interface Agent extends InstanceType { - defaultPort: number; - protocol: string; - options: any; - requests: Record; - sockets: Record; - freeSockets: Record; - keepAliveMsecs: number; - keepAlive: boolean; - maxSockets: number; - maxFreeSockets: number; - scheduling: string; - maxTotalSockets: any; - totalSocketCount: number; - [kfakeSocket]?: any; - - createConnection(): any; - getName(options?: any): string; - addRequest(): void; - createSocket(req: any, options: any, cb: (err: any, socket: any) => void): void; - removeSocket(): void; - keepSocketAlive(): boolean; - reuseSocket(): void; - destroy(): void; +function freeSocketErrorListener(err) { + const socket = this; + $debug("SOCKET ERROR on FREE socket:", err.message, err.stack); + socket.destroy(); + socket.emit("agentRemove"); } -// Define the constructor interface -interface AgentConstructor { - new (options?: any): Agent; - (options?: any): Agent; - defaultMaxSockets: number; - globalAgent: Agent; - prototype: Agent; -} - -function Agent(options = kEmptyObject) { +type Agent = import("node:http").Agent; +function Agent(options): void { if (!(this instanceof Agent)) return new Agent(options); - EventEmitter.$apply(this, []); + EventEmitter.$call(this); - this.defaultPort = 80; - this.protocol = "http:"; + this.options = { __proto__: null, ...options }; - this.options = options = { ...options, path: null }; - if (options.noDelay === undefined) options.noDelay = true; + this.defaultPort = this.options.defaultPort || 80; + this.protocol = this.options.protocol || "http:"; - // Don't confuse net and make it think that we're connecting to a pipe - this.requests = Object.create(null); - this.sockets = Object.create(null); - this.freeSockets = Object.create(null); + if (this.options.noDelay === undefined) this.options.noDelay = true; - this.keepAliveMsecs = options.keepAliveMsecs || 1000; - this.keepAlive = options.keepAlive || false; - this.maxSockets = options.maxSockets || Agent.defaultMaxSockets; - this.maxFreeSockets = options.maxFreeSockets || 256; - this.scheduling = options.scheduling || "lifo"; - this.maxTotalSockets = options.maxTotalSockets; + // Don't confuse node:net and make it think that we're connecting to a pipe + this.options.path = null; + this.requests = { __proto__: null }; + this.sockets = { __proto__: null }; + this.freeSockets = { __proto__: null }; + this.keepAliveMsecs = this.options.keepAliveMsecs || 1000; + this.keepAlive = this.options.keepAlive || false; + this.maxSockets = this.options.maxSockets || Agent.defaultMaxSockets; + this.maxFreeSockets = this.options.maxFreeSockets || 256; + this.scheduling = this.options.scheduling || "lifo"; + this.maxTotalSockets = this.options.maxTotalSockets; this.totalSocketCount = 0; - this.defaultPort = options.defaultPort || 80; - this.protocol = options.protocol || "http:"; + + this.agentKeepAliveTimeoutBuffer = + typeof this.options.agentKeepAliveTimeoutBuffer === "number" && + this.options.agentKeepAliveTimeoutBuffer >= 0 && + Number.isFinite(this.options.agentKeepAliveTimeoutBuffer) + ? this.options.agentKeepAliveTimeoutBuffer + : 1000; + + const proxyEnv = this.options.proxyEnv; + if (typeof proxyEnv === "object" && proxyEnv !== null) { + this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive); + $debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]); + } + + validateOneOf(this.scheduling, "scheduling", ["fifo", "lifo"]); + + if (this.maxTotalSockets !== undefined) { + validateNumber(this.maxTotalSockets, "maxTotalSockets", 1); + } else { + this.maxTotalSockets = Infinity; + } + + this.on("free", (socket, options) => { + const name = this.getName(options); + $debug("agent.on(free)", name); + + // TODO: socket.destroy(err) might have been called before coming here and have an 'error' scheduled. + // In the case of socket.destroy() below this 'error' has no handler and could cause unhandled exception. + if (!socket.writable) { + socket.destroy(); + return; + } + + const requests = this.requests[name]; + if (requests?.length) { + const req = requests.shift(); + setRequestSocket(this, req, socket); + if (requests.length === 0) { + delete this.requests[name]; + } + return; + } + + // If there are no pending requests, then put it in the freeSockets pool, but only if we're allowed to do so. + const req = socket._httpMessage; + if (!req || !req.shouldKeepAlive || !this.keepAlive) { + socket.destroy(); + return; + } + + const freeSockets = this.freeSockets[name] || []; + const freeLen = freeSockets.length; + let count = freeLen; + if (this.sockets[name]) count += this.sockets[name].length; + + if ( + this.totalSocketCount > this.maxTotalSockets || + count > this.maxSockets || + freeLen >= this.maxFreeSockets || + !this.keepSocketAlive(socket) + ) { + socket.destroy(); + return; + } + + this.freeSockets[name] = freeSockets; + socket._httpMessage = null; + this.removeSocket(socket, options); + + socket.once("error", freeSocketErrorListener); + freeSockets.push(socket); + }); + + // Don't emit keylog events unless there is a listener for them. + this.on("newListener", maybeEnableKeylog); } $toClass(Agent, "Agent", EventEmitter); -// Type assertion to help TypeScript understand Agent has static properties -const AgentClass = Agent as unknown as AgentConstructor; +function maybeEnableKeylog(this: Agent, eventName) { + if (eventName === "keylog") { + this.removeListener("newListener", maybeEnableKeylog); + // Future sockets will listen on keylog at creation. + const agent = this; + this[kOnKeylog] = function onkeylog(keylog) { + agent.emit("keylog", keylog, this); + }; + // Existing sockets will start listening on keylog now. + const sockets = Object.values(this.sockets); + for (let i = 0; i < sockets.length; i++) { + sockets[i]!.on("keylog", this[kOnKeylog]); + } + } +} -ObjectDefineProperty(AgentClass, "globalAgent", { - get: function () { - return globalAgent; - }, -}); +const tls = getLazy(() => require("node:tls")); +const net = getLazy(() => require("node:net")); -ObjectDefineProperty(AgentClass, "defaultMaxSockets", { - get: function () { - return Infinity; - }, -}); +Agent.defaultMaxSockets = Infinity; -Agent.prototype.createConnection = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.createConnection is a no-op, returns fake socket"); - return (this[kfakeSocket] ??= new FakeSocket()); +Agent.prototype.createConnection = function createConnection(...args) { + const normalized = net()._normalizeArgs(args); + const options = normalized[0]; + const cb = normalized[1]; + + const shouldUseProxy = checkShouldUseProxy(this[kProxyConfig], options); + $debug(`http createConnection should use proxy for ${options.host}:${options.port}:`, shouldUseProxy); + if (!shouldUseProxy) { + // @ts-ignore + return net().createConnection(...args); + } + + const connectOptions = { + ...this[kProxyConfig].proxyConnectionOptions, + }; + const proxyProtocol = this[kProxyConfig].protocol; + if (proxyProtocol === "http:") { + // @ts-ignore + return net().connect(connectOptions, cb); + } else if (proxyProtocol === "https:") { + // @ts-ignore + return tls().connect(connectOptions, cb); + } + // This should be unreachable because proxy config should be null for other protocols. + $assert(false, `Unexpected proxy protocol ${proxyProtocol}`); }; -Agent.prototype.getName = function (options = kEmptyObject) { +Agent.prototype.getName = function getName(options = kEmptyObject) { let name = options.host || "localhost"; + name += ":"; - if (options.port) { - name += options.port; - } + if (options.port) name += options.port; + name += ":"; - if (options.localAddress) { - name += options.localAddress; - } - // Pacify parallel/test-http-agent-getname by only appending - // the ':' when options.family is set. - if (options.family === 4 || options.family === 6) { - name += `:${options.family}`; - } - if (options.socketPath) { - name += `:${options.socketPath}`; - } + if (options.localAddress) name += options.localAddress; + + // Pacify parallel/test-http-agent-getname by only appending the ':' when options.family is set. + if (options.family === 4 || options.family === 6) name += `:${options.family}`; + + if (options.socketPath) name += `:${options.socketPath}`; + return name; }; -Agent.prototype.addRequest = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.addRequest is a no-op"); +function handleSocketAfterProxy(err, req) { + if (err.code === "ERR_PROXY_TUNNEL") { + if (err.proxyTunnelTimeout) { + req.emit("timeout"); // Propagate the timeout from the tunnel to the request. + } else { + req.emit("error", err); + } + } +} + +Agent.prototype.addRequest = function addRequest(req, options, port /* legacy */, localAddress /* legacy */) { + $debug("WARN: Agent.addRequest is a no-op"); + return; // TODO: + + // Legacy API: addRequest(req, host, port, localAddress) + if (typeof options === "string") { + options = { + __proto__: null, + host: options, + port, + localAddress, + }; + } + + // Here the agent options will override per-request options. + options = { __proto__: null, ...options, ...this.options }; + if (options.socketPath) options.path = options.socketPath; + + normalizeServerName(options, req); + + const name = this.getName(options); + this.sockets[name] ||= []; + + const freeSockets = this.freeSockets[name]; + let socket; + if (freeSockets) { + while (freeSockets.length && freeSockets[0].destroyed) { + freeSockets.shift(); + } + socket = this.scheduling === "fifo" ? freeSockets.shift() : freeSockets.pop(); + if (!freeSockets.length) delete this.freeSockets[name]; + } + + const freeLen = freeSockets ? freeSockets.length : 0; + const sockLen = freeLen + this.sockets[name].length; + + if (socket) { + this.reuseSocket(socket, req); + setRequestSocket(this, req, socket); + this.sockets[name].push(socket); + } else if (sockLen < this.maxSockets && this.totalSocketCount < this.maxTotalSockets) { + this.createSocket(req, options, (err, socket) => { + if (err) { + handleSocketAfterProxy(err, req); + $debug("call onSocket", sockLen, freeLen); + req.onSocket(socket, err); + return; + } + setRequestSocket(this, req, socket); + }); + } else { + $debug("wait for socket"); + this.requests[name] ||= []; + req[kRequestOptions] = options; + this.requests[name].push(req); + } }; -Agent.prototype.createSocket = function (req, options, cb) { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.createSocket returns fake socket"); - cb(null, (this[kfakeSocket] ??= new FakeSocket())); +Agent.prototype.createSocket = function createSocket(req, options, cb) { + options = { __proto__: null, ...options, ...this.options }; + if (options.socketPath) options.path = options.socketPath; + + normalizeServerName(options, req); + + // Make sure per-request timeout is respected. + const timeout = req.timeout || this.options.timeout || undefined; + if (timeout) { + options.timeout = timeout; + } + + const name = this.getName(options); + options._agentKey = name; + + $debug("createConnection", name); + options.encoding = null; + + const oncreate = once((err, s) => { + if (err) return cb(err); + this.sockets[name] ||= []; + this.sockets[name].push(s); + this.totalSocketCount++; + $debug("sockets", name, this.sockets[name].length, this.totalSocketCount); + installListeners(this, s, options); + cb(null, s); + }); + if (this.keepAlive) { + options.keepAlive = this.keepAlive; + options.keepAliveInitialDelay = this.keepAliveMsecs; + } + + const newSocket = this.createConnection(options, oncreate); + if (newSocket && !newSocket[kWaitForProxyTunnel]) oncreate(null, newSocket); }; -Agent.prototype.removeSocket = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.removeSocket is a no-op"); +function normalizeServerName(options, req) { + if (!options.servername && options.servername !== "") options.servername = calculateServerName(options, req); +} + +function calculateServerName(options, req) { + let servername = options.host; + const hostHeader = req.getHeader("host"); + if (hostHeader) { + validateString(hostHeader, "options.headers.host"); + + // abc => abc + // abc:123 => abc + // [::1] => ::1 + // [::1]:123 => ::1 + if (hostHeader[0] === "[") { + const index = hostHeader.indexOf("]"); + if (index === -1) { + // Leading '[', but no ']'. Need to do something... + servername = hostHeader; + } else { + servername = hostHeader.substring(1, index); + } + } else { + servername = hostHeader.split(":", 1)[0]; + } + } + // Don't implicitly set invalid (IP) servernames. + if (isIP(servername)) servername = ""; + return servername; +} + +function installListeners(agent, s, options) { + function onFree() { + $debug("CLIENT socket onFree"); + agent.emit("free", s, options); + } + s.on("free", onFree); + + function onClose() { + $debug("CLIENT socket onClose"); + // This is the only place where sockets get removed from the Agent. + // If you want to remove a socket from the pool, just close it. + // All socket errors end in a close event anyway. + agent.totalSocketCount--; + agent.removeSocket(s, options); + } + s.on("close", onClose); + + function onTimeout() { + $debug("CLIENT socket onTimeout"); + + const sockets = agent.freeSockets; + if (Object.keys(sockets).some(name => sockets[name].includes(s))) { + return s.destroy(); + } + } + s.on("timeout", onTimeout); + + function onRemove() { + $debug("CLIENT socket onRemove"); + agent.totalSocketCount--; + agent.removeSocket(s, options); + s.removeListener("close", onClose); + s.removeListener("free", onFree); + s.removeListener("timeout", onTimeout); + s.removeListener("agentRemove", onRemove); + } + s.on("agentRemove", onRemove); + + if (agent[kOnKeylog]) { + s.on("keylog", agent[kOnKeylog]); + } +} + +Agent.prototype.removeSocket = function removeSocket(s, options) { + const name = this.getName(options); + $debug("removeSocket", name, "writable:", s.writable); + const sets = [this.sockets]; + + if (!s.writable) sets.push(this.freeSockets); + + for (let sk = 0; sk < sets.length; sk++) { + const sockets = sets[sk]; + const socket = sockets[name]; + + if (socket) { + const index = socket.indexOf(s); + if (index !== -1) { + socket.splice(index, 1); + if (socket.length === 0) delete sockets[name]; + } + } + } + + let req; + if (this.requests[name]?.length) { + $debug("removeSocket, have a request, make a socket"); + req = this.requests[name][0]; + } else { + const keys = Object.keys(this.requests); + for (let i = 0; i < keys.length; i++) { + const prop = keys[i]; + if (this.sockets[prop]?.length) break; + $debug("removeSocket, have a request with different origin, make a socket"); + req = this.requests[prop][0]; + options = req[kRequestOptions]; + break; + } + } + + if (req && options) { + req[kRequestOptions] = undefined; + this.createSocket(req, options, (err, socket) => { + if (err) { + handleSocketAfterProxy(err, req); + req.onSocket(null, err); + return; + } + + socket.emit("free"); + }); + } }; -Agent.prototype.keepSocketAlive = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.keepSocketAlive is a no-op"); - return true; +Agent.prototype.keepSocketAlive = function keepSocketAlive(socket) { + socket.setKeepAlive(true, this.keepAliveMsecs); + socket.unref(); + + let agentTimeout = this.options.timeout || 0; + let canKeepSocketAlive = true; + const res = socket._httpMessage?.res; + + if (res) { + const keepAliveHint = res.headers["keep-alive"]; + + if (keepAliveHint) { + const hint = /^timeout=(\d+)/.exec(keepAliveHint)?.[1]; + + if (hint) { + // Let the timer expire before the announced timeout to reduce the likelihood of ECONNRESET errors + let serverHintTimeout = Number.parseInt(hint) * 1000 - this.agentKeepAliveTimeoutBuffer; + serverHintTimeout = serverHintTimeout > 0 ? serverHintTimeout : 0; + if (serverHintTimeout === 0) { + // Cannot safely reuse the socket because the server timeout is too short + canKeepSocketAlive = false; + } else if (serverHintTimeout < agentTimeout) { + agentTimeout = serverHintTimeout; + } + } + } + } + + if (socket.timeout !== agentTimeout) { + socket.setTimeout(agentTimeout); + } + + return canKeepSocketAlive; }; -Agent.prototype.reuseSocket = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.reuseSocket is a no-op"); +Agent.prototype.reuseSocket = function reuseSocket(socket, req) { + $debug("have free socket"); + socket.removeListener("error", freeSocketErrorListener); + req.reusedSocket = true; + socket.ref(); }; -Agent.prototype.destroy = function () { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.destroy is a no-op"); +Agent.prototype.destroy = function destroy() { + const sets = [this.freeSockets, this.sockets]; + for (let s = 0; s < sets.length; s++) { + const set = sets[s]; + const keys = Object.keys(set); + for (let v = 0; v < keys.length; v++) { + const setName = set[keys[v]]; + for (let n = 0; n < setName.length; n++) { + setName[n].destroy(); + } + } + } }; -var globalAgent = new Agent(); +function setRequestSocket(agent, req, socket) { + req.onSocket(socket); + const agentTimeout = agent.options.timeout || 0; + if (req.timeout === undefined || req.timeout === agentTimeout) { + return; + } + socket.setTimeout(req.timeout); +} -const http_agent_exports = { - Agent: AgentClass, - globalAgent, - NODE_HTTP_WARNING, +export default { + Agent, + globalAgent: new Agent({ + keepAlive: true, + scheduling: "lifo", + timeout: 5000, + // This normalized from both --use-env-proxy and NODE_USE_ENV_PROXY settings. + // proxyEnv: getOptionValue("--use-env-proxy") ? filterEnvForProxies(process.env) : undefined, + proxyEnv: undefined, // TODO: + }), }; - -export default http_agent_exports; diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index bd2c7c687d..f3adf767a8 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -1,4 +1,4 @@ -const { isIP, isIPv6 } = require("node:net"); +const { isIP, isIPv6 } = require("internal/net/isIP"); const { checkIsHttpToken, validateFunction, validateInteger, validateBoolean } = require("internal/validators"); const { urlToHttpOptions } = require("internal/url"); @@ -44,7 +44,7 @@ const { ConnResetException, } = require("internal/http"); -const { Agent, NODE_HTTP_WARNING } = require("node:_http_agent"); +const { globalAgent } = require("node:_http_agent"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); @@ -639,7 +639,7 @@ function ClientRequest(input, options, cb) { this[kAbortController] = null; let agent = options.agent; - const defaultAgent = options._defaultAgent || Agent.globalAgent; + const defaultAgent = options._defaultAgent || globalAgent; if (agent === false) { agent = new defaultAgent.constructor(); } else if (agent == null) { diff --git a/src/js/node/_http_incoming.ts b/src/js/node/_http_incoming.ts index 80e0dfa719..83de7a47c7 100644 --- a/src/js/node/_http_incoming.ts +++ b/src/js/node/_http_incoming.ts @@ -1,4 +1,4 @@ -const { Readable } = require("internal/streams/readable"); +const Readable = require("internal/streams/readable"); const { abortedSymbol, diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index ae40dfb472..025ab8770e 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -53,7 +53,7 @@ const { validateAbortSignal, } = require("internal/validators"); -const { isIP } = require("node:net"); +const { isIP } = require("internal/net/isIP"); const EventEmitter = require("node:events"); diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index c6640b6cba..03ea35aa8b 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -1,7 +1,7 @@ // Hardcoded module "node:dns" const dns = Bun.dns; const utilPromisifyCustomSymbol = Symbol.for("nodejs.util.promisify.custom"); -const { isIP } = require("node:net"); +const { isIP } = require("internal/net/isIP"); const { validateFunction, validateArray, diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 16b65d6cee..1c50331347 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -33,6 +33,7 @@ import type { TLSSocket } from "node:tls"; const { kTimeout, getTimerDuration } = require("internal/timers"); const { validateFunction, validateNumber, validateAbortSignal, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { NodeAggregateError, ErrnoException } = require("internal/shared"); +const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); const ArrayPrototypeIncludes = Array.prototype.includes; const ArrayPrototypePush = Array.prototype.push; @@ -55,40 +56,6 @@ const upgradeDuplexToTLS = $newZigFunction("socket.zig", "jsUpgradeDuplexToTLS", const isNamedPipeSocket = $newZigFunction("socket.zig", "jsIsNamedPipeSocket", 1); const getBufferedAmount = $newZigFunction("socket.zig", "jsGetBufferedAmount", 1); -// IPv4 Segment -const v4Seg = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"; -const v4Str = `(?:${v4Seg}\\.){3}${v4Seg}`; -var IPv4Reg; - -// IPv6 Segment -const v6Seg = "(?:[0-9a-fA-F]{1,4})"; -var IPv6Reg; - -function isIPv4(s): boolean { - return (IPv4Reg ??= new RegExp(`^${v4Str}$`)).test(s); -} - -function isIPv6(s): boolean { - return (IPv6Reg ??= new RegExp( - "^(?:" + - `(?:${v6Seg}:){7}(?:${v6Seg}|:)|` + - `(?:${v6Seg}:){6}(?:${v4Str}|:${v6Seg}|:)|` + - `(?:${v6Seg}:){5}(?::${v4Str}|(?::${v6Seg}){1,2}|:)|` + - `(?:${v6Seg}:){4}(?:(?::${v6Seg}){0,1}:${v4Str}|(?::${v6Seg}){1,3}|:)|` + - `(?:${v6Seg}:){3}(?:(?::${v6Seg}){0,2}:${v4Str}|(?::${v6Seg}){1,4}|:)|` + - `(?:${v6Seg}:){2}(?:(?::${v6Seg}){0,3}:${v4Str}|(?::${v6Seg}){1,5}|:)|` + - `(?:${v6Seg}:){1}(?:(?::${v6Seg}){0,4}:${v4Str}|(?::${v6Seg}){1,6}|:)|` + - `(?::(?:(?::${v6Seg}){0,5}:${v4Str}|(?::${v6Seg}){1,7}|:))` + - ")(?:%[0-9a-zA-Z-.:]{1,})?$", - )).test(s); -} - -function isIP(s): 0 | 4 | 6 { - if (isIPv4(s)) return 4; - if (isIPv6(s)) return 6; - return 0; -} - const bunTlsSymbol = Symbol.for("::buntls::"); const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); const owner_symbol = Symbol("owner_symbol"); diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 7209a66f3a..7092f3f1d4 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -253,7 +253,7 @@ declare function $bindgenFn any>(filename: string, symbol: // NOTE: $debug, $assert, and $isPromiseFulfilled omitted declare module "node:net" { - export function _normalizeArgs(args: any[]): unknown[]; + function _normalizeArgs(options: any[]): [Record, Function | null]; interface Socket { _handle: Bun.Socket<{ self: Socket; req?: object }> | null; diff --git a/test/js/bun/test/parallel/test-require-builtins.ts b/test/js/bun/test/parallel/test-require-builtins.ts new file mode 100644 index 0000000000..9fde68b91f --- /dev/null +++ b/test/js/bun/test/parallel/test-require-builtins.ts @@ -0,0 +1,30 @@ +import { spawnSync } from "child_process"; +import { builtinModules } from "node:module"; +import { tempDirWithFiles } from "./../../../../harness"; +import { join } from "node:path"; +import { expect } from "bun:test"; + +for (let builtin of builtinModules) { + const safe = builtin.replaceAll("/", "_").replaceAll(":", "_"); + const base = safe + ".cjs"; + const dir = tempDirWithFiles("", { + [`${base}`]: ` +const builtin = ${JSON.stringify(builtin)}; +console.log(builtin); +const now = performance.now(); +require(builtin); +const end = performance.now(); +console.log(JSON.stringify({ builtin, time: end - now })); + `, + }); + const path = join(dir, base); + const proc = spawnSync(process.execPath, [path], { + stdio: ["inherit", "inherit", "inherit"], + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, + }); + expect(proc.signal).toBeNull(); + expect(proc.status).toBe(0); +} diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 80cc212fd2..0106c03453 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -130,7 +130,7 @@ if (process.argv.length === 2 && // If the binary is build without `intl` the inspect option is // invalid. The test itself should handle this case. (process.features.inspector || !flag.startsWith('--inspect'))) { - if (flag === "--expose-gc" && process.versions.bun) { + if ((flag === "--expose-gc" || flag === "--expose_gc") && process.versions.bun) { globalThis.gc ??= () => Bun.gc(true); break; } diff --git a/test/js/node/test/parallel/test-http-agent-maxtotalsockets.js b/test/js/node/test/parallel/test-http-agent-maxtotalsockets.js new file mode 100644 index 0000000000..fce1bf8de8 --- /dev/null +++ b/test/js/node/test/parallel/test-http-agent-maxtotalsockets.js @@ -0,0 +1,111 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const Countdown = require('../common/countdown'); + +assert.throws(() => new http.Agent({ + maxTotalSockets: 'test', +}), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "maxTotalSockets" argument must be of type number. ' + + "Received type string ('test')", +}); + +[-1, 0, NaN].forEach((item) => { + assert.throws(() => new http.Agent({ + maxTotalSockets: item, + }), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); +}); + +assert.ok(new http.Agent({ + maxTotalSockets: Infinity, +})); + +function start(param = {}) { + const { maxTotalSockets, maxSockets } = param; + + const agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxTotalSockets, + maxSockets, + maxFreeSockets: 3 + }); + + const server = http.createServer(common.mustCall((req, res) => { + res.end('hello world'); + }, 6)); + const server2 = http.createServer(common.mustCall((req, res) => { + res.end('hello world'); + }, 6)); + + server.keepAliveTimeout = 0; + server2.keepAliveTimeout = 0; + + const countdown = new Countdown(12, () => { + assert.strictEqual(getRequestCount(), 0); + agent.destroy(); + server.close(); + server2.close(); + }); + + function handler(s) { + for (let i = 0; i < 6; i++) { + http.get({ + host: 'localhost', + port: s.address().port, + agent, + path: `/${i}`, + }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + res.resume(); + res.on('end', common.mustCall(() => { + for (const key of Object.keys(agent.sockets)) { + assert(agent.sockets[key].length <= maxSockets); + } + assert(getTotalSocketsCount() <= maxTotalSockets); + countdown.dec(); + })); + })); + } + } + + function getTotalSocketsCount() { + let num = 0; + for (const key of Object.keys(agent.sockets)) { + num += agent.sockets[key].length; + } + return num; + } + + function getRequestCount() { + let num = 0; + for (const key of Object.keys(agent.requests)) { + num += agent.requests[key].length; + } + return num; + } + + server.listen(0, common.mustCall(() => handler(server))); + server2.listen(0, common.mustCall(() => handler(server2))); +} + +// If maxTotalSockets is larger than maxSockets, +// then the origin check will be skipped +// when the socket is removed. +[{ + maxTotalSockets: 2, + maxSockets: 3, +}, { + maxTotalSockets: 3, + maxSockets: 2, +}, { + maxTotalSockets: 2, + maxSockets: 2, +}].forEach(start); diff --git a/test/js/node/test/parallel/test-http-client-finished.js b/test/js/node/test/parallel/test-http-client-finished.js new file mode 100644 index 0000000000..2d7e5b95b3 --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-finished.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const http = require('http'); +const { finished } = require('stream'); + +{ + // Test abort before finished. + + const server = http.createServer(function(req, res) { + res.write('asd'); + }); + + server.listen(0, common.mustCall(function() { + http.request({ + port: this.address().port + }) + .on('response', (res) => { + res.on('readable', () => { + res.destroy(); + }); + finished(res, common.mustCall(() => { + server.close(); + })); + }) + .end(); + })); +} diff --git a/test/js/node/test/parallel/test-http-client-response-timeout.js b/test/js/node/test/parallel/test-http-client-response-timeout.js new file mode 100644 index 0000000000..dc2c42934f --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-response-timeout.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); +if (common.isWindows) return; // TODO: BUN +const http = require('http'); + +const server = http.createServer((req, res) => res.flushHeaders()); + +server.listen(common.mustCall(() => { + const req = + http.get({ port: server.address().port }, common.mustCall((res) => { + res.on('timeout', common.mustCall(() => req.destroy())); + res.setTimeout(1); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-http-hostname-typechecking.js b/test/js/node/test/parallel/test-http-hostname-typechecking.js index f45d58b204..0c1ea3a106 100644 --- a/test/js/node/test/parallel/test-http-hostname-typechecking.js +++ b/test/js/node/test/parallel/test-http-hostname-typechecking.js @@ -15,6 +15,7 @@ vals.forEach((v) => { { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', + message: 'The "options.hostname" property must be of type string, undefined, or null.' + received } ); @@ -23,6 +24,7 @@ vals.forEach((v) => { { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', + message: 'The "options.host" property must be of type string, undefined, or null.' + received } ); }); diff --git a/test/js/node/test/parallel/test-http-outgoing-buffer.js b/test/js/node/test/parallel/test-http-outgoing-buffer.js index 599449139d..ba102b3825 100644 --- a/test/js/node/test/parallel/test-http-outgoing-buffer.js +++ b/test/js/node/test/parallel/test-http-outgoing-buffer.js @@ -1,7 +1,7 @@ -// Flags: --expose-internals 'use strict'; require('../common'); const assert = require('assert'); +const { getDefaultHighWaterMark } = require('stream'); const http = require('http'); const OutgoingMessage = http.OutgoingMessage; @@ -11,7 +11,8 @@ msg._implicitHeader = function() {}; // Writes should be buffered until highwatermark // even when no socket is assigned. + assert.strictEqual(msg.write('asd'), true); while (msg.write('asd')); -const highwatermark = msg.writableHighWaterMark; +const highwatermark = msg.writableHighWaterMark || getDefaultHighWaterMark(); assert(msg.outputSize >= highwatermark); diff --git a/test/js/node/test/parallel/test-http-outgoing-proto.js b/test/js/node/test/parallel/test-http-outgoing-proto.js index 4ed677b61d..2122204779 100644 --- a/test/js/node/test/parallel/test-http-outgoing-proto.js +++ b/test/js/node/test/parallel/test-http-outgoing-proto.js @@ -89,8 +89,7 @@ assert.throws(() => { }, { code: "ERR_INVALID_ARG_TYPE", name: "TypeError", - message: - 'The "chunk" argument must be of type string, Buffer, or Uint8Array. Received type number (1)', + message: 'The "chunk" argument must be of type string, Buffer, or Uint8Array. Received type number (1)', }); assert.throws(() => { diff --git a/test/js/node/test/parallel/test-http-parser-bad-ref.js b/test/js/node/test/parallel/test-http-parser-bad-ref.js index 174de13406..e34054eca6 100644 --- a/test/js/node/test/parallel/test-http-parser-bad-ref.js +++ b/test/js/node/test/parallel/test-http-parser-bad-ref.js @@ -18,7 +18,7 @@ let messagesComplete = 0; function flushPool() { Buffer.allocUnsafe(Buffer.poolSize - 1); - Bun.gc(true) + globalThis.gc(); } function demoBug(part1, part2) { diff --git a/test/js/node/test/parallel/test-http-parser-free.js b/test/js/node/test/parallel/test-http-parser-free.js new file mode 100644 index 0000000000..a6fdd19008 --- /dev/null +++ b/test/js/node/test/parallel/test-http-parser-free.js @@ -0,0 +1,54 @@ +// 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.isMacOS && require('os').release().split(".")[0] === "22") return; // TODO: BUN macOS 13 +if (common.isMacOS && process.arch === "arm64" && process.env.CI === "true") return; // TODO: BUN CI +const assert = require('assert'); +const http = require('http'); +const Countdown = require('../common/countdown'); +const N = 100; + +const server = http.createServer(function(req, res) { + res.end('Hello'); +}); + +const countdown = new Countdown(N, () => server.close()); + +server.listen(0, function() { + http.globalAgent.maxSockets = 1; + let parser; + for (let i = 0; i < N; ++i) { + (function makeRequest(i) { + const req = http.get({ port: server.address().port }, function(res) { + if (!parser) { + parser = req.parser; + } else { + assert.strictEqual(req.parser, parser); + } + + countdown.dec(); + res.resume(); + }); + })(i); + } +}); diff --git a/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js b/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js index 0c4a3879c8..fd96ff264f 100644 --- a/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js +++ b/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js @@ -12,7 +12,6 @@ const { createConnection } = require('net'); const server = createServer(); server.on('connection', mustCall((socket) => { - socket.on('error', expectsError({ name: 'Error', message: 'Parse Error: Invalid method encountered', @@ -38,11 +37,11 @@ server.listen(0, () => { }); socket.on('end', mustCall(() => { - const expected = Buffer.from( 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' ); assert(Buffer.concat(chunks).equals(expected)); + server.close(); })); -}); \ No newline at end of file +}); diff --git a/test/js/node/test/parallel/test-http-transfer-encoding-smuggling.js b/test/js/node/test/parallel/test-http-transfer-encoding-smuggling.js new file mode 100644 index 0000000000..900c50eb2a --- /dev/null +++ b/test/js/node/test/parallel/test-http-transfer-encoding-smuggling.js @@ -0,0 +1,86 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const http = require('http'); +const net = require('net'); + +{ + const msg = [ + 'POST / HTTP/1.1', + 'Host: 127.0.0.1', + 'Transfer-Encoding: chunked', + 'Transfer-Encoding: chunked-false', + 'Connection: upgrade', + '', + '1', + 'A', + '0', + '', + 'GET /flag HTTP/1.1', + 'Host: 127.0.0.1', + '', + '', + ].join('\r\n'); + + const server = http.createServer(common.mustNotCall((req, res) => { + res.end(); + }, 1)); + + server.listen(0, common.mustSucceed(() => { + const client = net.connect(server.address().port, 'localhost'); + + let response = ''; + + // Verify that the server listener is never called + + client.on('data', common.mustCall((chunk) => { + response += chunk; + })); + + client.setEncoding('utf8'); + client.on('error', common.mustNotCall()); + client.on('end', common.mustCall(() => { + assert.strictEqual( + response, + 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' + ); + server.close(); + })); + client.write(msg); + client.resume(); + })); +} + +{ + const msg = [ + 'POST / HTTP/1.1', + 'Host: 127.0.0.1', + 'Transfer-Encoding: chunked', + ' , chunked-false', + 'Connection: upgrade', + '', + '1', + 'A', + '0', + '', + 'GET /flag HTTP/1.1', + 'Host: 127.0.0.1', + '', + '', + ].join('\r\n'); + + const server = http.createServer(common.mustNotCall()); + + server.listen(0, common.mustSucceed(() => { + const client = net.connect(server.address().port, 'localhost'); + + client.on('end', common.mustCall(function() { + server.close(); + })); + + client.write(msg); + client.resume(); + })); +} diff --git a/test/js/node/test/parallel/test-http-unix-socket.js b/test/js/node/test/parallel/test-http-unix-socket.js new file mode 100644 index 0000000000..bf820e6adf --- /dev/null +++ b/test/js/node/test/parallel/test-http-unix-socket.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.isWindows) return; // TODO: BUN +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(function(req, res) { + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Connection': 'close' + }); + res.write('hello '); + res.write('world\n'); + res.end(); +}); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +server.listen(common.PIPE, common.mustCall(function() { + + const options = { + socketPath: common.PIPE, + path: '/' + }; + + const req = http.get(options, common.mustCall(function(res) { + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers['content-type'], 'text/plain'); + + res.body = ''; + res.setEncoding('utf8'); + + res.on('data', function(chunk) { + res.body += chunk; + }); + + res.on('end', common.mustCall(function() { + assert.strictEqual(res.body, 'hello world\n'); + server.close(common.mustCall(function(error) { + assert.strictEqual(error, undefined); + server.close(common.expectsError({ + code: 'ERR_SERVER_NOT_RUNNING', + message: 'Server is not running.', + name: 'Error' + })); + })); + })); + })); + + req.on('error', function(e) { + assert.fail(e); + }); + + req.end(); + +})); diff --git a/test/js/node/test/parallel/test-http-url.parse-https.request.js b/test/js/node/test/parallel/test-http-url.parse-https.request.js new file mode 100644 index 0000000000..4299505cd0 --- /dev/null +++ b/test/js/node/test/parallel/test-http-url.parse-https.request.js @@ -0,0 +1,63 @@ +// 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.isMacOS && require('os').release().split(".")[0] === "22") return; // TODO: BUN macOS 13 +if (common.isMacOS && process.arch === "arm64" && process.env.CI === "true") return; // TODO: BUN CI +if (!common.hasCrypto) + common.skip('missing crypto'); +const { readKey } = require('../common/fixtures'); + +const assert = require('assert'); +const https = require('https'); +const url = require('url'); + +// https options +const httpsOptions = { + key: readKey('agent1-key.pem'), + cert: readKey('agent1-cert.pem') +}; + +function check(request) { + // Assert that I'm https + assert.ok(request.socket._secureEstablished); +} + +const server = https.createServer(httpsOptions, function(request, response) { + // Run the check function + check(request); + response.writeHead(200, {}); + response.end('ok'); + server.close(); +}); + +server.listen(0, function() { + const testURL = url.parse(`https://localhost:${this.address().port}`); + testURL.rejectUnauthorized = false; + + // make the request + const clientRequest = https.request(testURL); + // Since there is a little magic with the agent + // make sure that the request uses the https.Agent + assert.ok(clientRequest.agent instanceof https.Agent); + clientRequest.end(); +}); diff --git a/test/js/node/test/sequential/test-http-econnrefused.js b/test/js/node/test/sequential/test-http-econnrefused.js index 8b0c50e5fd..4d0f7a174e 100644 --- a/test/js/node/test/sequential/test-http-econnrefused.js +++ b/test/js/node/test/sequential/test-http-econnrefused.js @@ -154,4 +154,4 @@ process.on('exit', function() { console.error(responses); assert.strictEqual(responses.length, 8); -}); \ 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 index 622872e008..4119c2353d 100644 --- 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 @@ -44,4 +44,4 @@ server.listen(0, common.mustCall(() => { }); res.on('end', () => server.close()); }); -})); \ No newline at end of file +})); diff --git a/test/no-validate-exceptions.txt b/test/no-validate-exceptions.txt index 62134a7a00..6ba7a6246a 100644 --- a/test/no-validate-exceptions.txt +++ b/test/no-validate-exceptions.txt @@ -52,6 +52,7 @@ test/js/third_party/astro/astro-post.test.js test/regression/issue/ctrl-c.test.ts test/bundler/bundler_comments.test.ts test/js/node/test/parallel/test-fs-promises-file-handle-readLines.mjs +test/js/bun/test/parallel/test-require-builtins.ts # trips asan on my macos test machine test/js/node/test/parallel/test-fs-watch.js