aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bun.js/streams.exports.js4
-rw-r--r--src/bun.js/undici.exports.js223
-rw-r--r--test/bun.js/undici.test.ts140
3 files changed, 359 insertions, 8 deletions
diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js
index 19e68ca58..7fdf0fb6a 100644
--- a/src/bun.js/streams.exports.js
+++ b/src/bun.js/streams.exports.js
@@ -2330,6 +2330,8 @@ var require_from = __commonJS({
},
});
+var _ReadableFromWeb;
+
// node_modules/readable-stream/lib/internal/streams/readable.js
var require_readable = __commonJS({
"node_modules/readable-stream/lib/internal/streams/readable.js"(exports, module) {
@@ -2593,6 +2595,7 @@ var require_readable = __commonJS({
}
module.exports = Readable;
+ _ReadableFromWeb = ReadableFromWeb;
var { addAbortSignal } = require_add_abort_signal();
var eos = require_end_of_stream();
@@ -5628,6 +5631,7 @@ var NativeWritable = class NativeWritable extends Writable {
const stream_exports = require_ours();
stream_exports[Symbol.for("CommonJS")] = 0;
+stream_exports[Symbol.for("::bunternal::")] = { _ReadableFromWeb };
export default stream_exports;
export var _uint8ArrayToBuffer = stream_exports._uint8ArrayToBuffer;
export var _isUint8Array = stream_exports._isUint8Array;
diff --git a/src/bun.js/undici.exports.js b/src/bun.js/undici.exports.js
index 2021d71d2..58621ff7d 100644
--- a/src/bun.js/undici.exports.js
+++ b/src/bun.js/undici.exports.js
@@ -1,4 +1,12 @@
+// const { Object } = import.meta.primordials;
const { EventEmitter } = import.meta.require("events");
+const {
+ Readable,
+ [Symbol.for("::bunternal::")]: { _ReadableFromWeb },
+} = import.meta.require("node:stream");
+
+const ObjectCreate = Object.create;
+const kEmptyObject = ObjectCreate(null);
export var fetch = Bun.fetch;
export var Response = globalThis.Response;
@@ -13,17 +21,212 @@ export class FileReader extends EventTarget {
}
}
-export class FormData {
- constructor() {
- throw new Error("Not implemented yet!");
- }
-}
+export var FormData = globalThis.FormData;
function notImplemented() {
throw new Error("Not implemented in bun");
}
-export function request() {
- throw new Error("TODO: Not implemented in bun yet!");
+
+/**
+ * An object representing a URL.
+ * @typedef {Object} UrlObject
+ * @property {string | number} [port]
+ * @property {string} [path]
+ * @property {string} [pathname]
+ * @property {string} [hostname]
+ * @property {string} [origin]
+ * @property {string} [protocol]
+ * @property {string} [search]
+ */
+
+/**
+ * @typedef {import('http').IncomingHttpHeaders} IncomingHttpHeaders
+ * @typedef {'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'} HttpMethod
+ * @typedef {import('stream').Readable} Readable
+ * @typedef {import('events').EventEmitter} EventEmitter
+ */
+
+class BodyReadable extends _ReadableFromWeb {
+ #response;
+ #bodyUsed;
+
+ constructor(response, options = {}) {
+ var { body } = response;
+ if (!body) throw new Error("Response body is null");
+ super(options, body);
+
+ this.#response = response;
+ this.#bodyUsed = response.bodyUsed;
+ }
+
+ get bodyUsed() {
+ // return this.#response.bodyUsed;
+ return this.#bodyUsed;
+ }
+
+ #consume() {
+ if (this.#bodyUsed) throw new TypeError("unusable");
+ this.#bodyUsed = true;
+ }
+
+ async arrayBuffer() {
+ this.#consume();
+ return await this.#response.arrayBuffer();
+ }
+
+ async blob() {
+ this.#consume();
+ return await this.#response.blob();
+ }
+
+ async formData() {
+ this.#consume();
+ return await this.#response.formData();
+ }
+
+ async json() {
+ this.#consume();
+ return await this.#response.json();
+ }
+
+ async text() {
+ this.#consume();
+ return await this.#response.text();
+ }
}
+
+// NOT IMPLEMENTED
+// * idempotent?: boolean;
+// * onInfo?: (info: { statusCode: number, headers: Object<string, string | string[]> }) => void;
+// * opaque?: *;
+// * responseHeader: 'raw' | null;
+// * headersTimeout?: number | null;
+// * bodyTimeout?: number | null;
+// * upgrade?: boolean | string | null;
+// * blocking?: boolean;
+
+/**
+ * Performs an HTTP request.
+ * @param {string | URL | UrlObject} url
+ * @param {{
+ * dispatcher: Dispatcher;
+ * method: HttpMethod;
+ * signal?: AbortSignal | EventEmitter | null;
+ * maxRedirections?: number;
+ * body?: string | Buffer | Uint8Array | Readable | null | FormData;
+ * headers?: IncomingHttpHeaders | string[] | null;
+ * query?: Record<string, any>;
+ * reset?: boolean;
+ * throwOnError?: boolean;
+ * }} [options]
+ * @returns {{
+ * statusCode: number;
+ * headers: IncomingHttpHeaders;
+ * body: ResponseBody;
+ * trailers: Object<string, string>;
+ * opaque: *;
+ * context: Object<string, *>;
+ * }}
+ */
+export async function request(
+ url,
+ options = {
+ method: "GET",
+ signal: null,
+ headers: null,
+ query: null,
+ // idempotent: false, // GET and HEAD requests are idempotent by default
+ // blocking = false,
+ // upgrade = false,
+ // headersTimeout: 30000,
+ // bodyTimeout: 30000,
+ reset: false,
+ throwOnError: false,
+ body: null,
+ // dispatcher,
+ },
+) {
+ let {
+ method = "GET",
+ headers: inputHeaders,
+ query,
+ signal,
+ // idempotent, // GET and HEAD requests are idempotent by default
+ // blocking = false,
+ // upgrade = false,
+ // headersTimeout = 30000,
+ // bodyTimeout = 30000,
+ reset = false,
+ throwOnError = false,
+ body: inputBody,
+ maxRedirections,
+ // dispatcher,
+ } = options;
+
+ // TODO: More validations
+
+ if (typeof url === "string") {
+ if (query) url = new URL(url);
+ } else if (typeof url === "object" && url !== null) {
+ if (!url instanceof URL) {
+ // TODO: Parse undici UrlObject
+ throw new Error("not implemented");
+ }
+ } else throw new TypeError("url must be a string, URL, or UrlObject");
+
+ if (typeof url === "string" && query) url = new URL(url);
+ if (typeof url === "object" && url !== null && query) if (query) url.search = new URLSearchParams(query).toString();
+
+ method = method && typeof method === "string" ? method.toUpperCase() : null;
+ // idempotent = idempotent === undefined ? method === "GET" || method === "HEAD" : idempotent;
+
+ if (inputBody && (method === "GET" || method === "HEAD")) {
+ throw new Error("Body not allowed for GET or HEAD requests");
+ }
+
+ if (inputBody && inputBody.read && inputBody instanceof Readable) {
+ // TODO: Streaming via ReadableStream?
+ let data = "";
+ inputBody.setEncoding("utf8");
+ for await (const chunk of stream) {
+ data += chunk;
+ }
+ inputBody = new TextEncoder().encode(data);
+ }
+
+ if (maxRedirections !== undefined && Number.isNaN(maxRedirections)) {
+ throw new Error("maxRedirections must be a number if defined");
+ }
+
+ if (signal && !(signal instanceof AbortSignal)) {
+ // TODO: Add support for event emitter signal
+ throw new Error("signal must be an instance of AbortSignal");
+ }
+
+ let resp;
+ /** @type {Response} */
+ const {
+ status: statusCode,
+ headers,
+ trailers,
+ } = (resp = await fetch(url, {
+ signal,
+ mode: "cors",
+ method,
+ headers: inputHeaders || kEmptyObject,
+ body: inputBody,
+ redirect: maxRedirections === "undefined" || maxRedirections > 0 ? "follow" : "manual",
+ keepalive: !reset,
+ }));
+
+ // Throw if received 4xx or 5xx response indicating HTTP error
+ if (throwOnError && statusCode >= 400 && statusCode < 600) {
+ throw new Error(`Request failed with status code ${statusCode}`);
+ }
+
+ const body = resp.body ? new BodyReadable(resp) : null;
+ return { statusCode, headers, body, trailers, opaque: kEmptyObject, context: kEmptyObject };
+}
+
export function stream() {
throw new Error("Not implemented in bun");
}
@@ -63,7 +266,11 @@ export function Undici() {
class Dispatcher extends EventEmitter {}
class Agent extends Dispatcher {}
-class Pool extends Dispatcher {}
+class Pool extends Dispatcher {
+ request() {
+ throw new Error("Not implemented in bun");
+ }
+}
class BalancedPool extends Dispatcher {}
class Client extends Dispatcher {
request() {
diff --git a/test/bun.js/undici.test.ts b/test/bun.js/undici.test.ts
new file mode 100644
index 000000000..603b7a03d
--- /dev/null
+++ b/test/bun.js/undici.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect } from "bun:test";
+import { request } from "undici";
+
+describe("undici", () => {
+ describe("request", () => {
+ it("should make a GET request when passed a URL string", async () => {
+ const { body } = await request("https://httpbin.org/get");
+ expect(body).toBeDefined();
+ const json = (await body.json()) as { url: string };
+ expect(json.url).toBe("https://httpbin.org/get");
+ });
+
+ it("should error when body has already been consumed", async () => {
+ const { body } = await request("https://httpbin.org/get");
+ await body.json();
+ expect(body.bodyUsed).toBe(true);
+ try {
+ await body.json();
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("unusable");
+ }
+ });
+
+ it("should make a POST request when provided a body and POST method", async () => {
+ const { body } = await request("https://httpbin.org/post", {
+ method: "POST",
+ body: "Hello world",
+ });
+ expect(body).toBeDefined();
+ const json = (await body.json()) as { data: string };
+ expect(json.data).toBe("Hello world");
+ });
+
+ it("should accept a URL class object", async () => {
+ const { body } = await request(new URL("https://httpbin.org/get"));
+ expect(body).toBeDefined();
+ const json = (await body.json()) as { url: string };
+ expect(json.url).toBe("https://httpbin.org/get");
+ });
+
+ // it("should accept an undici UrlObject", async () => {
+ // // @ts-ignore
+ // const { body } = await request({ protocol: "https:", hostname: "httpbin.org", path: "/get" });
+ // expect(body).toBeDefined();
+ // const json = (await body.json()) as { url: string };
+ // expect(json.url).toBe("https://httpbin.org/get");
+ // });
+
+ it("should prevent body from being attached to GET or HEAD requests", async () => {
+ try {
+ await request("https://httpbin.org/get", {
+ method: "GET",
+ body: "Hello world",
+ });
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("Body not allowed for GET or HEAD requests");
+ }
+
+ try {
+ await request("https://httpbin.org/head", {
+ method: "HEAD",
+ body: "Hello world",
+ });
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("Body not allowed for GET or HEAD requests");
+ }
+ });
+
+ it("should allow a query string to be passed", async () => {
+ const { body } = await request("https://httpbin.org/get?foo=bar");
+ expect(body).toBeDefined();
+ const json = (await body.json()) as { args: { foo: string } };
+ expect(json.args.foo).toBe("bar");
+
+ const { body: body2 } = await request("https://httpbin.org/get", {
+ query: { foo: "bar" },
+ });
+ expect(body2).toBeDefined();
+ const json2 = (await body2.json()) as { args: { foo: string } };
+ expect(json2.args.foo).toBe("bar");
+ });
+
+ it("should throw on HTTP 4xx or 5xx error when throwOnError is true", async () => {
+ try {
+ await request("https://httpbin.org/status/404", { throwOnError: true });
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("Request failed with status code 404");
+ }
+
+ try {
+ await request("https://httpbin.org/status/500", { throwOnError: true });
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("Request failed with status code 500");
+ }
+ });
+
+ it("should allow us to abort the request with a signal", async () => {
+ const controller = new AbortController();
+ try {
+ setTimeout(() => controller.abort(), 1000);
+ const req = await request("https://httpbin.org/delay/5", {
+ signal: controller.signal,
+ });
+ await req.body.json();
+ throw new Error("Should have errored");
+ } catch (e) {
+ expect((e as Error).message).toBe("The operation was aborted.");
+ }
+ });
+
+ it("should properly append headers to the request", async () => {
+ const { body } = await request("https://httpbin.org/headers", {
+ headers: {
+ "x-foo": "bar",
+ },
+ });
+ expect(body).toBeDefined();
+ const json = (await body.json()) as { headers: { "X-Foo": string } };
+ expect(json.headers["X-Foo"]).toBe("bar");
+ });
+
+ // it("should allow the use of FormData", async () => {
+ // const form = new FormData();
+ // form.append("foo", "bar");
+ // const { body } = await request("https://httpbin.org/post", {
+ // method: "POST",
+ // body: form,
+ // });
+
+ // expect(body).toBeDefined();
+ // const json = (await body.json()) as { form: { foo: string } };
+ // expect(json.form.foo).toBe("bar");
+ // });
+ });
+});