diff options
-rw-r--r-- | src/bun.js/streams.exports.js | 4 | ||||
-rw-r--r-- | src/bun.js/undici.exports.js | 223 | ||||
-rw-r--r-- | test/bun.js/undici.test.ts | 140 |
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"); + // }); + }); +}); |