aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bun.js/http.exports.js861
-rw-r--r--test/bun.js/node-http.test.ts377
-rw-r--r--test/bun.js/node-test-helpers.ts21
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.