diff --git a/preload/src/api/fetch.js b/preload/src/api/fetch.js new file mode 100644 index 00000000..f423b1df --- /dev/null +++ b/preload/src/api/fetch.js @@ -0,0 +1,88 @@ +import * as https from "https"; +import * as http from "http"; + +const redirectCodes = new Set([301, 302, 307, 308]); + +export function nativeFetch(url, options) { + let state = "PENDING"; + const data = {content: [], headers: null, statusCode: null, url: url, statusText: "", redirected: false}; + const listeners = new Set(); + const errors = new Set(); + + /** * @param {URL} url */ + const execute = (url, options, redirect = false) => { + const Module = url.protocol === "http" ? http : https; + + const req = Module.request(url.href, { + headers: options.headers ?? {}, + method: options.method ?? "GET" + }, res => { + if (redirectCodes.has(res.statusCode) && res.headers.location && options.redirect !== "manual") { + const final = new URL(res.headers.location); + + for (const [key, value] of new URL(url).searchParams.entries()) { + final.searchParams.set(key, value); + } + + return execute(final, options, true); + } + + res.on("data", chunk => data.content.push(chunk)); + res.on("end", () => { + data.content = Buffer.concat(data.content); + data.headers = res.headers; + data.statusCode = res.statusCode; + data.url = url.toString(); + data.statusText = res.statusMessage; + data.redirected = redirect; + state = "DONE"; + + listeners.forEach(listener => listener()); + }); + res.on("error", error => { + state = "ABORTED"; + errors.forEach(e => e(error)); + }); + }); + + if (options.body) { + try {req.write(options.body)} + catch (error) { + state = "ABORTED"; + errors.forEach(e => e(error)); + } finally { + req.end(); + } + } else { + req.end(); + } + + if (options.signal) { + options.signal.addEventListener("abort", () => { + req.end(); + state = "ABORTED"; + }); + } + }; + + execute(new URL(url), options); + + return { + onComplete(listener) { + listeners.add(listener); + }, + onError(listener) { + errors.add(listener); + }, + readData() { + switch (state) { + case "PENDING": + throw new Error("Cannot read data before request is done!"); + case "ABORTED": + throw new Error("Request was aborted."); + case "DONE": + return data; + } + } + }; +} diff --git a/preload/src/api/https.js b/preload/src/api/https.js index dae2ac0f..a65ede15 100644 --- a/preload/src/api/https.js +++ b/preload/src/api/https.js @@ -34,7 +34,15 @@ const makeRequest = (url, options, callback, setReq) => { req.end(); }); }); - req.end(); + + if (options.formData) { + // Make sure to close the socket. + try {req.write(options.formData);} + finally {req.end();} + } else { + req.end(); + } + }; const request = function (url, options, callback) { diff --git a/preload/src/api/index.js b/preload/src/api/index.js index 2d097c76..7907775f 100644 --- a/preload/src/api/index.js +++ b/preload/src/api/index.js @@ -3,6 +3,7 @@ export {default as https} from "./https"; export * as electron from "./electron"; export * as crypto from "./crypto"; export * as vm from "./vm"; +export * from "./fetch"; // We can expose that without any issues. export * as path from "path"; diff --git a/renderer/src/modules/api/fetch.js b/renderer/src/modules/api/fetch.js new file mode 100644 index 00000000..e9a3efb5 --- /dev/null +++ b/renderer/src/modules/api/fetch.js @@ -0,0 +1,63 @@ +import Remote from "../../polyfill/remote"; + +class FetchResponse extends Response { + constructor(options) { + super(options.content, { + headers: new Headers(options.headers), + method: options.method ?? "GET", + body: options.content, + ...options + }); + + this._options = options; + } + + get url() {return this._options.url;} + get redirected() {return this._options.redirected;} +} + +const convertSignal = signal => { + const listeners = new Set(); + + signal.addEventListener("abort", () => { + listeners.forEach(l => l()); + }); + + return { + addEventListener(_, listener) { + listeners.add(listener); + } + }; +}; + +export function fetch(url, options = {}) { + return new Promise((resolve, reject) => { + const ctx = Remote.nativeFetch(url, { + ...(options.headers && {headers: options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers}), + ...(options.body && {body: options.body}), + ...(options.method && {method: options.method}), + ...(options.signal && {signal: convertSignal(options.signal)}) + }); + + ctx.onError(error => { + reject(error); + }); + + ctx.onComplete(() => { + try { + const data = ctx.readData(); + + const req = new FetchResponse({ + method: options.method ?? "GET", + status: data.statusCode, + ...options, + ...data + }); + + resolve(req); + } catch (error) { + reject(error); + } + }); + }); +} diff --git a/renderer/src/modules/api/index.js b/renderer/src/modules/api/index.js index 7f56b7c6..cab1f18f 100644 --- a/renderer/src/modules/api/index.js +++ b/renderer/src/modules/api/index.js @@ -12,6 +12,7 @@ import Utils from "./utils"; import Webpack from "./webpack"; import * as Legacy from "./legacy"; import ContextMenu from "./contextmenu"; +import {fetch} from "./fetch"; import {DiscordModules} from "modules"; const bounded = new Map(); @@ -57,6 +58,7 @@ export default class BdApi { Components = { get Tooltip() {return DiscordModules.Tooltip;} } + fetch = fetch; } // Add legacy functions @@ -126,6 +128,8 @@ BdApi.Components = { get Tooltip() {return DiscordModules.Tooltip;} }; +BdApi.fetch = fetch; + Object.freeze(BdApi); Object.freeze(BdApi.prototype); Object.freeze(BdApi.Components);