diff options
-rw-r--r-- | src/bun.js/http.exports.js | 861 | ||||
-rw-r--r-- | test/bun.js/node-http.test.ts | 377 | ||||
-rw-r--r-- | test/bun.js/node-test-helpers.ts | 21 |
3 files changed, 1197 insertions, 62 deletions
diff --git a/src/bun.js/http.exports.js b/src/bun.js/http.exports.js index d7f6e7053..00e2c2dc9 100644 --- a/src/bun.js/http.exports.js +++ b/src/bun.js/http.exports.js @@ -1,12 +1,195 @@ const { EventEmitter } = import.meta.require("node:events"); const { Readable, Writable } = import.meta.require("node:stream"); - -const { newArrayWithSize, isPromise } = import.meta.primordials; +const { newArrayWithSize, String, Object, Array } = import.meta.primordials; + +const globalReportError = globalThis.reportError; +const setTimeout = globalThis.setTimeout; +const fetch = Bun.fetch; +const nop = () => {}; +const debug = process.env.BUN_JS_DEBUG ? (...args) => console.log("node:http", ...args) : nop; + +const kEmptyObject = Object.freeze(Object.create(null)); +const kOutHeaders = Symbol.for("kOutHeaders"); +const kEndCalled = Symbol.for("kEndCalled"); +const kCorked = Symbol.for("kCorked"); +const searchParamsSymbol = Symbol.for("query"); // This is the symbol used in Node + +// Primordials +const StringPrototypeSlice = String.prototype.slice; +const StringPrototypeStartsWith = String.prototype.startsWith; +const StringPrototypeToUpperCase = String.prototype.toUpperCase; +const StringPrototypeIncludes = String.prototype.includes; +const StringPrototypeCharCodeAt = String.prototype.charCodeAt; +const StringPrototypeIndexOf = String.prototype.indexOf; +const ArrayIsArray = Array.isArray; +const RegExpPrototypeExec = RegExp.prototype.exec; +const ObjectAssign = Object.assign; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; + +const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; +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."; + +var _globalAgent; + +var FakeSocket = class Socket { + on() { + return this; + } + off() {} + addListener() { + return this; + } + removeListener() {} + removeAllListeners() {} +}; export function createServer(options, callback) { return new Server(options, callback); } +export class Agent extends EventEmitter { + #defaultPort = 80; + #protocol = "http:"; + #options; + #requests; + #sockets; + #freeSockets; + + #keepAliveMsecs; + #keepAlive; + #maxSockets; + #maxFreeSockets; + #scheduling; + #maxTotalSockets; + #totalSocketCount; + + #fakeSocket; + + static get globalAgent() { + return (_globalAgent ??= new Agent()); + } + + static get defaultMaxSockets() { + return Infinity; + } + + constructor(options = kEmptyObject) { + super(); + this.#options = options = { ...options, path: null }; + if (options.noDelay === undefined) options.noDelay = true; + + // Don't confuse net and make it think that we're connecting to a pipe + this.#requests = kEmptyObject; + this.#sockets = kEmptyObject; + this.#freeSockets = kEmptyObject; + + 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; + this.#totalSocketCount = 0; + } + + get defaultPort() { + return this.#defaultPort; + } + + get protocol() { + return this.#protocol; + } + + get requests() { + return this.#requests; + } + + get sockets() { + return this.#sockets; + } + + get freeSockets() { + return this.#freeSockets; + } + + get options() { + return this.#options; + } + + get keepAliveMsecs() { + return this.#keepAliveMsecs; + } + + get keepAlive() { + return this.#keepAlive; + } + + get maxSockets() { + return this.#maxSockets; + } + + get maxFreeSockets() { + return this.#maxFreeSockets; + } + + get scheduling() { + return this.#scheduling; + } + + get maxTotalSockets() { + return this.#maxTotalSockets; + } + + get totalSocketCount() { + return this.#totalSocketCount; + } + + createConnection() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.createConnection is a no-op, returns fake socket"); + return (this.#fakeSocket ??= new FakeSocket()); + } + + getName(options = kEmptyObject) { + let name = `http:${options.host || "localhost"}:`; + 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}`; + return name; + } + + addRequest() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.addRequest is a no-op"); + } + + createSocket(req, options, cb) { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.createSocket returns fake socket"); + cb(null, (this.#fakeSocket ??= new FakeSocket())); + } + + removeSocket() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.removeSocket is a no-op"); + } + + keepSocketAlive() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.keepSocketAlive is a no-op"); + + return true; + } + + reuseSocket() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.reuseSocket is a no-op"); + } + + destroy() { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: Agent.destroy is a no-op"); + } +} + export class Server extends EventEmitter { #server; #options; @@ -123,8 +306,10 @@ function assignHeaders(object, req) { function destroyBodyStreamNT(bodyStream) { bodyStream.destroy(); } + +var defaultIncomingOpts = { type: "request" }; export class IncomingMessage extends Readable { - constructor(req) { + constructor(req, { type = "request" } = defaultIncomingOpts) { const method = req.method; super(); @@ -132,19 +317,22 @@ export class IncomingMessage extends Readable { const url = new URL(req.url); this.#noBody = - "GET" === method || - "HEAD" === method || - "TRACE" === method || - "CONNECT" === method || - "OPTIONS" === method || - (parseInt(req.headers.get("Content-Length") || "") || 0) === 0; + type === "request" // TODO: Add logic for checking for body on response + ? "GET" === method || + "HEAD" === method || + "TRACE" === method || + "CONNECT" === method || + "OPTIONS" === method || + (parseInt(req.headers.get("Content-Length") || "") || 0) === 0 + : false; this.#req = req; this.method = method; + this.#type = type; this.complete = !!this.#noBody; this.#bodyStream = null; - this.#socket = undefined; + this.#fakeSocket = undefined; this.url = url.pathname; assignHeaders(this, req); @@ -155,15 +343,16 @@ export class IncomingMessage extends Readable { _consuming = false; _dumped = false; #bodyStream = null; - #socket = undefined; + #fakeSocket = undefined; #noBody = false; #aborted = false; #req; url; + #type; _construct(callback) { // TODO: streaming - if (this.#noBody) { + if (this.#type === "response" || this.#noBody) { callback(); return; } @@ -181,6 +370,7 @@ export class IncomingMessage extends Readable { } #closeBodyStream() { + debug("closeBodyStream()"); var bodyStream = this.#bodyStream; if (bodyStream == null) return; this.complete = true; @@ -204,6 +394,7 @@ export class IncomingMessage extends Readable { if (isBodySizeKnown) { this.#bodyStream.on("data", chunk => { + debug("body size known", remaining); this.push(chunk); // when we are streaming a known body size, automatically close the stream when we have read enough remaining -= chunk?.byteLength ?? 0; @@ -239,15 +430,15 @@ export class IncomingMessage extends Readable { } get connection() { - throw new Error("not implemented"); + return this.#fakeSocket; } get statusCode() { - throw new Error("not implemented"); + return this.#req.status; } get statusMessage() { - throw new Error("not implemented"); + return STATUS_CODES[this.#req.status]; } get httpVersion() { @@ -267,23 +458,206 @@ export class IncomingMessage extends Readable { } get trailers() { - return Object.create(null); + return kEmptyObject; } get socket() { - var _socket = this.#socket; - if (_socket) return _socket; + return (this.#fakeSocket ??= new FakeSocket()); + } - this.#socket = _socket = new EventEmitter(); - this.on("end", () => _socket.emit("end")); - this.on("close", () => _socket.emit("close")); + setTimeout(msecs, callback) { + throw new Error("not implemented"); + } +} - return _socket; +function emitErrorNt(msg, err, callback) { + callback(err); + if (typeof msg.emit === "function" && !msg._closed) { + msg.emit("error", err); } +} - setTimeout(msecs, callback) { +function onError(self, err, cb) { + process.nextTick(() => emitErrorNt(self, err, cb)); +} + +function write_(msg, chunk, encoding, callback, fromEnd) { + if (typeof callback !== "function") callback = nop; + + let len; + if (chunk === null) { + // throw new ERR_STREAM_NULL_VALUES(); + throw new Error("ERR_STREAM_NULL_VALUES"); + } else if (typeof chunk === "string") { + len = Buffer.byteLength(chunk, encoding); + } else { + throw new Error("Invalid arg type for chunk"); + // throw new ERR_INVALID_ARG_TYPE( + // "chunk", + // ["string", "Buffer", "Uint8Array"], + // chunk, + // ); + } + + let err; + if (msg.finished) { + // err = new ERR_STREAM_WRITE_AFTER_END(); + err = new Error("ERR_STREAM_WRITE_AFTER_END"); + } else if (msg.destroyed) { + // err = new ERR_STREAM_DESTROYED("write"); + err = new Error("ERR_STREAM_DESTROYED"); + } + + if (err) { + if (!msg.destroyed) { + onError(msg, err, callback); + } else { + process.nextTick(callback, err); + } + return false; + } + + if (!msg._header) { + if (fromEnd) { + msg._contentLength = len; + } + // msg._implicitHeader(); + } + + if (!msg._hasBody) { + debug("This type of response MUST NOT have a body. " + "Ignoring write() calls."); + process.nextTick(callback); + return true; + } + + // if (!fromEnd && msg.socket && !msg.socket.writableCorked) { + // msg.socket.cork(); + // process.nextTick(connectionCorkNT, msg.socket); + // } + + return true; +} + +const kClearTimeout = Symbol("kClearTimeout"); + +export class OutgoingMessage extends Writable { + #headers; + headersSent = false; + sendDate = true; + req; + + #finished = false; + [kEndCalled] = false; + + #fakeSocket; + #timeoutTimer = null; + + // For compat with IncomingRequest + get headers() { + if (!this.#headers) return kEmptyObject; + return this.#headers.toJSON(); + } + + get shouldKeepAlive() { + return true; + } + + get chunkedEncoding() { + return false; + } + + set chunkedEncoding(value) { + // throw new Error('not implemented'); + } + + set shouldKeepAlive(value) { + // throw new Error('not implemented'); + } + + get useChunkedEncodingByDefault() { + return true; + } + + set useChunkedEncodingByDefault(value) { + // throw new Error('not implemented'); + } + + get socket() { + return (this.#fakeSocket ??= new FakeSocket()); + } + + get connection() { + return this.socket; + } + + get finished() { + return this.#finished; + } + + appendHeader(name, value) { + var headers = (this.#headers ??= new Headers()); + headers.append(name, value); + } + + flushHeaders() {} + + getHeader(name) { + if (!this.#headers) return; + return this.#headers.get(name); + } + + getHeaders() { + if (!this.#headers) return kEmptyObject; + return this.#headers.toJSON(); + } + + getHeaderNames() { + var headers = this.#headers; + if (!headers) return []; + return Array.from(headers.keys()); + } + + removeHeader(name) { + if (!this.#headers) return; + this.#headers.delete(name); + } + + setHeader(name, value) { + var headers = (this.#headers ??= new Headers()); + headers.set(name, value); + return this; + } + + hasHeader(name) { + if (!this.#headers) return false; + return this.#headers.has(name); + } + + addTrailers(headers) { throw new Error("not implemented"); } + + [kClearTimeout]() { + if (this.#timeoutTimer) { + clearTimeout(this.#timeoutTimer); + this.#timeoutTimer = null; + } + } + + // TODO: Use fetch AbortSignal when implemented + setTimeout(msecs, callback) { + if (this.#timeoutTimer) return this; + if (callback) { + this.on("timeout", callback); + } + + this.#timeoutTimer = setTimeout(async () => { + this.emit("timeout"); + this.#timeoutTimer = null; + }, msecs); + + return this; + } } export class ServerResponse extends Writable { @@ -293,7 +667,6 @@ export class ServerResponse extends Writable { this._reply = reply; this.sendDate = true; this.statusCode = 200; - this.#headers = new Headers(); this.headersSent = false; this.statusMessage = undefined; this.#controller = undefined; @@ -318,8 +691,6 @@ export class ServerResponse extends Writable { #deferred = undefined; #finished = false; - #fakeSocket; - _write(chunk, encoding, callback) { if (!this.#firstWrite && !this.headersSent) { this.#firstWrite = chunk; @@ -409,18 +780,6 @@ export class ServerResponse extends Writable { }); } - get socket() { - if (!this.#fakeSocket) { - this.#fakeSocket = Object.create(this); - } - - return this.#fakeSocket; - } - - get connection() { - throw new Error("not implemented"); - } - writeProcessing() { throw new Error("not implemented"); } @@ -469,48 +828,404 @@ export class ServerResponse extends Writable { // throw new Error('not implemented'); } - flushHeaders() {} - - removeHeader(name) { - var headers = this.#headers; - headers.delete(name); + appendHeader(name, value) { + var headers = (this.#headers ??= new Headers()); + headers.append(name, value); } + flushHeaders() {} + getHeader(name) { - var headers = this.#headers; - return headers.get(name); + if (!this.#headers) return; + return this.#headers.get(name); } - hasHeader(name) { - var headers = this.#headers; - return headers.has(name); + getHeaders() { + if (!this.#headers) return kEmptyObject; + return this.#headers.toJSON(); } getHeaderNames() { var headers = this.#headers; + if (!headers) return []; return Array.from(headers.keys()); } - setHeader(name, value) { - var headers = this.#headers; + removeHeader(name) { + if (!this.#headers) return; + this.#headers.delete(name); + } + setHeader(name, value) { + var headers = (this.#headers ??= new Headers()); headers.set(name, value); - return this; } + hasHeader(name) { + if (!this.#headers) return false; + return this.#headers.has(name); + } + writeHead(statusCode, statusMessage, headers) { _writeHead(statusCode, statusMessage, headers, this); return this; } +} - getHeaders() { - if (!this.#headers) return {}; - return this.#headers.toJSON(); +export class ClientRequest extends OutgoingMessage { + #timeout; + #res = null; + #aborted = false; + #timeoutCb = null; + #upgradeOrConnect = false; + #parser = null; + #maxHeadersCount = null; + #reusedSocket = false; + #host; + #protocol; + #method; + #port; + #joinDuplicateHeaders; + #maxHeaderSize; + #agent = _globalAgent; + #path; + #socketPath; + + #body = null; + #fetchRequest; + + #options; + #finished; + + get path() { + return this.#path; + } + + get port() { + return this.#port; + } + + get host() { + return this.#host; + } + + get protocol() { + return this.#protocol; + } + + _write(chunk, encoding, callback) { + var body = this.#body; + if (!body) { + this.#body = chunk; + callback(); + return; + } + this.#body = body + chunk; + callback(); + } + + _writev(chunks, callback) { + var body = this.#body; + if (!body) { + this.#body = chunks.join(); + callback(); + return; + } + this.#body = body + chunks.join(); + callback(); + } + + _final(callback) { + this.#finished = true; + this.#fetchRequest = fetch(`${this.#protocol}//${this.#host}:${this.#port}${this.#path}`, { + method: this.#method, + headers: this.getHeaders(), + body: this.#body, + redirect: "manual", + verbose: Boolean(process.env.BUN_JS_DEBUG), + }) + .then(response => { + var res = (this.#res = new IncomingMessage(response, { + type: "response", + })); + this.emit("response", res); + }) + .catch(err => { + if (process.env.BUN_JS_DEBUG) globalReportError(err); + this.emit("error", err); + }) + .finally(() => { + this.#fetchRequest = null; + this[kClearTimeout](); + }); + + callback(); + + // TODO: Clear timeout here + } + + get aborted() { + return this.#aborted; + } + + // TODO: Use fetch AbortSignal when implemented + abort() { + if (this.#aborted) return; + this.#aborted = true; + this[kClearTimeout](); + // TODO: Close stream if body streaming + } + + constructor(input, options, cb) { + super(); + + if (typeof input === "string") { + const urlStr = input; + input = urlToHttpOptions(new URL(urlStr)); + } else if (input && input[searchParamsSymbol] && input[searchParamsSymbol][searchParamsSymbol]) { + // url.URL instance + input = urlToHttpOptions(input); + } else { + cb = options; + options = input; + input = null; + } + + if (typeof options === "function") { + cb = options; + options = input || kEmptyObject; + } else { + options = ObjectAssign(input || {}, options); + } + + const defaultAgent = options._defaultAgent || Agent.globalAgent; + const protocol = (this.#protocol = options.protocol || defaultAgent.protocol); + const expectedProtocol = defaultAgent.protocol; + + if (options.path) { + const path = String(options.path); + if (RegExpPrototypeExec.call(INVALID_PATH_REGEX, path) !== null) { + debug('Path contains unescaped characters: "%s"', path); + throw new Error("Path contains unescaped characters"); + // throw new ERR_UNESCAPED_CHARACTERS("Request path"); + } + } + + if (protocol !== expectedProtocol) { + throw new Error(`Protocol mismatch. Expected: ${expectedProtocol}. Got: ${protocol}`); + // throw new ERR_INVALID_PROTOCOL(protocol, expectedProtocol); + } + + const defaultPort = options.defaultPort || (this.#agent && this.#agent.defaultPort); + + this.#port = options.port = options.port || defaultPort || 80; + const host = + (this.#host = + options.host = + validateHost(options.hostname, "hostname") || validateHost(options.host, "host") || "localhost"); + + // const setHost = options.setHost === undefined || Boolean(options.setHost); + + this.#socketPath = options.socketPath; + + // if (options.timeout !== undefined) + // this.timeout = getTimerDuration(options.timeout, "timeout"); + + const signal = options.signal; + if (signal) { + // TODO: Implement this when AbortSignal binding is available from Zig (required for fetch) + // addAbortSignal(signal, this); + } + let method = options.method; + const methodIsString = typeof method === "string"; + if (method !== null && method !== undefined && !methodIsString) { + // throw new ERR_INVALID_ARG_TYPE("options.method", "string", method); + throw new Error("ERR_INVALID_ARG_TYPE: options.method"); + } + + if (methodIsString && method) { + if (!checkIsHttpToken(method)) { + // throw new ERR_INVALID_HTTP_TOKEN("Method", method); + throw new Error("ERR_INVALID_HTTP_TOKEN: Method"); + } + method = this.#method = StringPrototypeToUpperCase.call(method); + } else { + method = this.#method = "GET"; + } + + const _maxHeaderSize = options.maxHeaderSize; + // TODO: Validators + // if (maxHeaderSize !== undefined) + // validateInteger(maxHeaderSize, "maxHeaderSize", 0); + this.#maxHeaderSize = _maxHeaderSize; + + // const insecureHTTPParser = options.insecureHTTPParser; + // if (insecureHTTPParser !== undefined) { + // validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser'); + // } + + // this.insecureHTTPParser = insecureHTTPParser; + var _joinDuplicateHeaders = options.joinDuplicateHeaders; + if (_joinDuplicateHeaders !== undefined) { + // TODO: Validators + // validateBoolean( + // options.joinDuplicateHeaders, + // "options.joinDuplicateHeaders", + // ); + } + + this.#joinDuplicateHeaders = _joinDuplicateHeaders; + + this.#path = options.path || "/"; + if (cb) { + this.once("response", cb); + } + + debug(`new ClientRequest: ${this.#method} ${this.#protocol}//${this.#host}:${this.#port}${this.#path}`); + + // if ( + // method === "GET" || + // method === "HEAD" || + // method === "DELETE" || + // method === "OPTIONS" || + // method === "TRACE" || + // method === "CONNECT" + // ) { + // this.useChunkedEncodingByDefault = false; + // } else { + // this.useChunkedEncodingByDefault = true; + // } + + this.#finished = false; + this.#res = null; + this.#aborted = false; + this.#timeoutCb = null; + this.#upgradeOrConnect = false; + this.#parser = null; + this.#maxHeadersCount = null; + this.#reusedSocket = false; + this.#host = host; + this.#protocol = protocol; + + const headersArray = ArrayIsArray(headers); + if (!headersArray) { + var headers = options.headers; + if (headers) { + for (let key in headers) { + this.setHeader(key, headers[key]); + } + } + + // if (host && !this.getHeader("host") && setHost) { + // let hostHeader = host; + + // // For the Host header, ensure that IPv6 addresses are enclosed + // // in square brackets, as defined by URI formatting + // // https://tools.ietf.org/html/rfc3986#section-3.2.2 + // const posColon = StringPrototypeIndexOf.call(hostHeader, ":"); + // if ( + // posColon !== -1 && + // StringPrototypeIncludes(hostHeader, ":", posColon + 1) && + // StringPrototypeCharCodeAt(hostHeader, 0) !== 91 /* '[' */ + // ) { + // hostHeader = `[${hostHeader}]`; + // } + + // if (port && +port !== defaultPort) { + // hostHeader += ":" + port; + // } + // this.setHeader("Host", hostHeader); + // } + + var auth = options.auth; + if (auth && !this.getHeader("Authorization")) { + this.setHeader("Authorization", "Basic " + Buffer.from(auth).toString("base64")); + } + + // if (this.getHeader("expect")) { + // if (this._header) { + // throw new ERR_HTTP_HEADERS_SENT("render"); + // } + + // this._storeHeader( + // this.method + " " + this.path + " HTTP/1.1\r\n", + // this[kOutHeaders], + // ); + // } + // } else { + // this._storeHeader( + // this.method + " " + this.path + " HTTP/1.1\r\n", + // options.headers, + // ); + } + + // this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); + + var optsWithoutSignal = options; + if (optsWithoutSignal.signal) { + optsWithoutSignal = ObjectAssign({}, options); + delete optsWithoutSignal.signal; + } + this.#options = optsWithoutSignal; + + var timeout = options.timeout; + if (timeout) { + this.setTimeout(timeout); + } + } + + setSocketKeepAlive(enable = true, initialDelay = 0) { + debug(`${NODE_HTTP_WARNING}\n`, "WARN: ClientRequest.setSocketKeepAlive is a no-op"); } } +function urlToHttpOptions(url) { + var { protocol, hostname, hash, search, pathname, href, port, username, password } = url; + const options = { + protocol, + hostname: + typeof hostname === "string" && StringPrototypeStartsWith.call(hostname, "[") + ? StringPrototypeSlice.call(hostname, 1, -1) + : hostname, + hash, + search, + pathname, + path: `${pathname || ""}${search || ""}`, + href, + }; + if (port !== "") { + options.port = Number(port); + } + if (username || password) { + options.auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; + } + return options; +} + +function validateHost(host, name) { + if (host !== null && host !== undefined && typeof host !== "string") { + // throw new ERR_INVALID_ARG_TYPE( + // `options.${name}`, + // ["string", "undefined", "null"], + // host, + // ); + throw new Error("Invalid arg type in options"); + } + return host; +} + +const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; +/** + * Verifies that the given val is a valid HTTP token + * per the rules defined in RFC 7230 + * See https://tools.ietf.org/html/rfc7230#section-3.2.6 + */ +function checkIsHttpToken(val) { + return RegExpPrototypeExec.call(tokenRegExp, val) !== null; +} + // Copyright Joyent, Inc. and other Node contributors. // // Permission is hereby granted, free of charge, to any person obtaining a @@ -707,13 +1422,51 @@ function _writeHead(statusCode, reason, obj, response) { } } } + +/** + * Makes an HTTP request. + * @param {string | URL} url + * @param {HTTPRequestOptions} [options] + * @param {Function} [cb] + * @returns {ClientRequest} + */ +export function request(url, options, cb) { + return new ClientRequest(url, options, cb); +} + +/** + * Makes a `GET` HTTP request. + * @param {string | URL} url + * @param {HTTPRequestOptions} [options] + * @param {Function} [cb] + * @returns {ClientRequest} + */ +export function get(url, options, cb) { + const req = request(url, options, cb); + req.end(); + return req; +} + var defaultObject = { + Agent, Server, METHODS, STATUS_CODES, createServer, ServerResponse, IncomingMessage, + request, + get, + maxHeaderSize: 16384, + // validateHeaderName, + // validateHeaderValue, + setMaxIdleHTTPParsers(max) { + debug(`${NODE_HTTP_WARNING}\n`, "setMaxIdleHTTPParsers() is a no-op"); + }, + get globalAgent() { + return (_globalAgent ??= new Agent()); + }, + set globalAgent(agent) {}, }; var wrapper = diff --git a/test/bun.js/node-http.test.ts b/test/bun.js/node-http.test.ts index cdb0d4758..e3d20f1c0 100644 --- a/test/bun.js/node-http.test.ts +++ b/test/bun.js/node-http.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test"; -import { createServer } from "node:http"; +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { createServer, request, get, Agent, globalAgent } from "node:http"; +import { createDoneDotAll } from "node-test-helpers"; describe("node:http", () => { describe("createServer", async () => { @@ -69,4 +70,376 @@ describe("node:http", () => { server.close(); }); }); + + describe("request", () => { + let server; + let timer = null; + beforeAll(() => { + server = createServer((req, res) => { + const reqUrl = new URL(req.url, `http://${req.headers.host}`); + if (reqUrl.pathname) { + if (reqUrl.pathname === "/redirect") { + // Temporary redirect + res.writeHead(301, { + Location: "http://localhost:8126/redirected", + }); + res.end("Got redirect!\n"); + return; + } + if (reqUrl.pathname === "/redirected") { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + if (reqUrl.pathname.includes("timeout")) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + res.end("Hello World"); + timer = null; + }, 3000); + return; + } + if (reqUrl.pathname === "/pathTest") { + res.end("Path correct!\n"); + return; + } + } + + res.writeHead(200, { "Content-Type": "text/plain" }); + + if (req.headers["x-test"]) { + res.write(`x-test: ${req.headers["x-test"]}\n`); + } + + // Check for body + if (req.method === "POST") { + req.on("data", (chunk) => { + res.write(chunk); + }); + + req.on("end", () => { + res.write("POST\n"); + res.end("Hello World"); + }); + } else { + if (req.headers["X-Test"] !== undefined) { + res.write(`X-Test: test\n`); + } + res.write("Maybe GET maybe not\n"); + res.end("Hello World"); + } + }); + server.listen(8126); + }); + afterAll(() => { + server.close(); + if (timer) clearTimeout(timer); + }); + + it("should make a standard GET request when passed string as first arg", (done) => { + const req = request("http://localhost:8126", (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Maybe GET maybe not\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }); + req.end(); + }); + + it("should make a POST request when provided POST method, even without a body", (done) => { + const req = request( + { host: "localhost", port: 8126, method: "POST" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("POST\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }, + ); + req.end(); + }); + + it("should correctly handle a POST request with a body", (done) => { + const req = request( + { host: "localhost", port: 8126, method: "POST" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Posting\nPOST\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }, + ); + req.write("Posting\n"); + req.end(); + }); + + it("should noop request.setSocketKeepAlive without error", () => { + const req = request("http://localhost:8126"); + req.setSocketKeepAlive(true, 1000); + req.end(); + expect(true).toBe(true); + }); + + it("should allow us to set timeout with request.setTimeout or `timeout` in options", (done) => { + const createDone = createDoneDotAll(done); + const req1Done = createDone(); + const req2Done = createDone(); + + // const start = Date.now(); + const req1 = request( + { + host: "localhost", + port: 8126, + path: "/timeout", + timeout: 500, + }, + (res) => { + req1Done(new Error("Should not have received response")); + }, + ); + req1.on("timeout", () => req1Done()); + + const req2 = request( + { + host: "localhost", + port: 8126, + path: "/timeout", + }, + (res) => { + req2Done(new Error("Should not have received response")); + }, + ); + + req2.setTimeout(500, () => { + req2Done(); + }); + req1.end(); + req2.end(); + }); + + it("should correctly set path when path provided", (done) => { + const createDone = createDoneDotAll(done); + const req1Done = createDone(); + const req2Done = createDone(); + + const req1 = request("http://localhost:8126/pathTest", (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Path correct!\n"); + req1Done(); + }); + res.on("error", (err) => req1Done(err)); + }); + + const req2 = request( + "http://localhost:8126", + { path: "/pathTest" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Path correct!\n"); + req2Done(); + }); + res.on("error", (err) => req2Done(err)); + }, + ); + + req1.end(); + req2.end(); + + expect(req1.path).toBe("/pathTest"); + expect(req2.path).toBe("/pathTest"); + }); + + it("should emit response when response received", (done) => { + const req = request("http://localhost:8126"); + + req.on("response", (res) => { + expect(res.statusCode).toBe(200); + done(); + }); + req.end(); + }); + + // NOTE: Node http.request doesn't follow redirects by default + it("should handle redirects properly", (done) => { + const req = request("http://localhost:8126/redirect", (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Got redirect!\n"); + done(); + }); + res.on("error", (err) => done(err)); + }); + req.end(); + }); + + it("should correctly attach headers to request", (done) => { + const req = request( + { host: "localhost", port: 8126, headers: { "X-Test": "test" } }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("x-test: test\nMaybe GET maybe not\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }, + ); + req.end(); + expect(req.getHeader("X-Test")).toBe("test"); + }); + + it("should correct casing of method param", (done) => { + const req = request( + { host: "localhost", port: 8126, method: "get" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Maybe GET maybe not\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }, + ); + req.end(); + }); + + it("should allow for port as a string", (done) => { + const req = request( + { host: "localhost", port: "8126", method: "GET" }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Maybe GET maybe not\nHello World"); + done(); + }); + res.on("error", (err) => done(err)); + }, + ); + req.end(); + }); + }); + + describe("get", () => { + let server; + beforeAll(() => { + server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Hello World"); + }); + server.listen(8127); + }); + afterAll(() => { + server.close(); + }); + it("should make a standard GET request, like request", (done) => { + get("http://127.0.0.1:8127", (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + expect(data).toBe("Hello World"); + done(); + }); + res.on("error", (err) => done(err)); + }); + }); + }); + + describe("Agent", () => { + let server; + let dummyReq; + let dummyAgent; + beforeAll(() => { + dummyAgent = new Agent(); + server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Hello World"); + }); + server.listen(8128, () => { + // Setup request after server is listening + dummyReq = request( + { + host: "localhost", + port: 8128, + agent: dummyAgent, + }, + (res) => {}, + ); + dummyReq.on("error", () => {}); + }); + }); + + afterAll(() => { + dummyReq.end(); + server.close(); + }); + + it("should be a class", () => { + expect(Agent instanceof Function).toBe(true); + }); + + it("should have a default maxSockets of Infinity", () => { + expect(dummyAgent.maxSockets).toBe(Infinity); + }); + + it("should have a keepAlive value", () => { + expect(dummyAgent.keepAlive).toBe(false); + }); + + it("should noop keepSocketAlive", () => { + const agent = new Agent({ keepAlive: true }); + expect(agent.keepAlive).toBe(true); + agent.keepSocketAlive(dummyReq.socket); + }); + + it("should provide globalAgent", () => { + expect(globalAgent instanceof Agent).toBe(true); + }); + }); }); diff --git a/test/bun.js/node-test-helpers.ts b/test/bun.js/node-test-helpers.ts index 34147c194..0eaa7d07b 100644 --- a/test/bun.js/node-test-helpers.ts +++ b/test/bun.js/node-test-helpers.ts @@ -123,13 +123,22 @@ export const createCallCheckCtx = (done: DoneCb) => { // mustCallChecks.push(context); const done = createDone(); const _return = (...args) => { - // @ts-ignore - const result = fn.apply(this, args); - actual++; - if (actual >= expected) { - done(); + try { + // @ts-ignore + const result = fn.apply(this, args); + actual++; + if (actual >= expected) { + done(); + } + return result; + } catch (err) { + if (err instanceof Error) done(err); + else if (err?.toString) done(new Error(err?.toString())); + else { + console.error("Unknown error", err); + done(new Error("Unknown error")); + } } - return result; }; // Function instances have own properties that may be relevant. // Let's replicate those properties to the returned function. |