diff --git a/DiscordClient.js b/DiscordClient.js new file mode 100644 index 0000000..4645376 --- /dev/null +++ b/DiscordClient.js @@ -0,0 +1,238 @@ +import EventEmitter from "events"; +import zlib from "zlib"; +import { WebSocket } from "ws"; +import fetch from "node-fetch"; + +const opcodes = { + EVENT: 0, + CLIENT_HEARTBEAT: 1, + IDENTIFY: 2, + HELLO: 10, + HEARTBEAT_ACK: 11, +}; + +class DiscordClient extends EventEmitter { + constructor(token, { intents, baseDomain="discord.com", gatewayUrl="wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream", apiBase="https://discord.com/api/v9" } = {}) { + super(); + + this.token = token; + this.gatewayUrl = gatewayUrl; + this.apiBase = apiBase; + this.inflate = zlib.createInflate({ + chunkSize: 128 * 1024 + }); + this.ws = null; + this.intents = intents; + + this.user = null; + this.guilds = []; + this.sessionId = null; + this.seq = null; + this.gotServerHeartbeatACK = true; + } + + _setHeartbeat(interval) { + this._heartbeatIntervalTime = interval; + if (interval < 0 && this._heartbeatInterval) { + clearInterval(this._heartbeatInterval); + return; + } + + this._heartbeatInterval = setInterval(() => { + if (!this.gotServerHeartbeatACK) { + this.emit("error", "NO_HEARTBEAT_ACK"); + return; + } + this.gotServerHeartbeatACK = false; + + this.ws.send(JSON.stringify({ + op: opcodes.CLIENT_HEARTBEAT, + d: this.seq + })); + }, this._heartbeatIntervalTime); + } + + _handleGatewayMessage(ws, message) { + try { + message = JSON.parse(message); + } catch(e) { + console.error("error: DiscordClient: on `message`: failed to parse incoming message as JSON", e); + return; + } + + if (message.s) { + this.seq = message.s; + } + + const payload = message.d; + + switch (message.op) { + case opcodes.HELLO: { + this._setHeartbeat(payload.heartbeat_interval); + + ws.send(JSON.stringify({ + op: opcodes.IDENTIFY, + d: { + token: this.token, + intents: this.intents, + properties: { + "$os": "linux", + "$browser": "generic", + "$device": "generic" + }, + presence: { + since: Date.now(), + activities: [ + { + type: 2, // LISTENING + name: "the voices" + } + ], + status: "online", + afk: false + } + } + })); + break; + } + + case opcodes.EVENT: { + switch (message.t) { + case "READY": { + console.log("DiscordClient: ready"); + this.user = payload.user; + this.sessionId = payload.session_id; + this.guilds = payload.guilds; + break; + } + + case "GUILD_CREATE": { + const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); + if (targetGuildIndex < 0) { + this.guilds.push(payload); + break; + } + // The guild already exists in our array. This means that + // this GUILD_CREATE event is completing our `Unavailable Guild` + // objects that we got from the initial READY. + this.guilds[targetGuildIndex] = payload; + break; + } + + case "GUILD_UPDATE": { + const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); + if (targetGuildIndex < 0) { + // tried to update a guild that doesn't exist??? + this.emit("warn", "got GUILD_UPDATE for a guild that doesn't exist"); + break; + } + this.guilds[targetGuildIndex] = payload; + break; + } + + case "GUILD_DELETE": { + const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); + if (targetGuildIndex < 0) { + // tried to delete a guild that doesn't exist??? + this.emit("warn", "got GUILD_DELETE for a guild that doesn't exist"); + break; + } + this.guilds.splice(targetGuildIndex, 1); + break; + } + + case "CHANNEL_CREATE": { + const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); + if (parentGuildIndex < 0) { + this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); + break; + } + this.guilds[parentGuildIndex].channels.push(payload); + break; + } + + case "CHANNEL_UPDATE": { + const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); + if (parentGuildIndex < 0) { + this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); + break; + } + const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id); + this.guilds[parentGuildIndex].channels[relevantChannelIndex] = payload; + break; + } + + case "CHANNEL_DELETE": { + const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); + if (parentGuildIndex < 0) { + this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); + break; + } + const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id); + this.guilds[parentGuildIndex].channels.splice(relevantChannelIndex, 1); + break; + } + } + + + this.emit(message.t, payload); + + break; + } + + case opcodes.HEARTBEAT_ACK: { + this.gotServerHeartbeatACK = true; + break; + } + + default: { + console.warn(`warn: DiscordClient: got unhandled opcode "${message.op}"`); + break; + } + } + } + + connect() { + const ws = new WebSocket(this.gatewayUrl); + + this.ws = ws; + + // we decompressed the data, send it to the handler now + this.inflate.on("data", (message) => this._handleGatewayMessage(ws, message)); + + ws.on("message", (data, isBinary) => { + // pass the data to the decompressor + this.inflate.write(data); + }); + + ws.on("close", (code, reason) => { + reason = reason.toString(); + console.error(`DiscordClient: on \`close\`: disconnected from gateway: code \`${code}\`, reason \`${reason}\``); + }) + } + + async api([method, path], body=undefined, throwOnError=true) { + const options = { + method, + headers: { + "authorization": `Bot ${this.token}` + } + }; + + if (method !== "GET" && method !== "HEAD" && typeof body === "object") { + options.headers["content-type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(`${this.apiBase}${path}`, options); + const json = await response.json(); + + if (!response.ok && throwOnError) { + throw new Error(`API request returned non-success status ${response.status}, with JSON body ${JSON.stringify(json)}`); + } + + return json; + } +} + +export default DiscordClient; \ No newline at end of file diff --git a/WatchedGuild.js b/WatchedGuild.js index 1796711..4f0960c 100644 --- a/WatchedGuild.js +++ b/WatchedGuild.js @@ -1,80 +1,83 @@ -const { EventEmitter } = require("events"); +import { EventEmitter } from "events"; class WatchedGuild extends EventEmitter { constructor() { super(); this.knownWebhooks = new Map(); - this.eventStack = []; this.upstreamGuildId = null; } pushEvent(e) { - this.eventStack.push(e); this.emit("pushed", e); } - consumeEvent() { - return this.eventStack.pop(); - } - - consumeAll() { - const events = [...this.eventStack]; - this.eventStack = []; - return events; - } - - hasEvents() { - return this.eventStack.length > 0; - } - - _pushMessageEvent(message) { - this.pushEvent({ - eventType: "messageCreate", - message: message.toJSON() + holdForEvent() { + return new Promise((resolve, reject) => { + // potential memory leak here when too many promises are created and waiting + this.once("pushed", (event) => { + resolve(event); + }); }); } discordConnect(bot) { this.bot = bot; - this.bot.on("messageCreate", (message) => { - if (message.guildId !== this.upstreamGuildId) + this.guildObject = this.bot.guilds.find(e => e.id === this.upstreamGuildId); + + if (!this.guildObject) { + throw new Error("Could not find guild object from bot cache by id (is the upstreamGuildId valid and does the bot have access to it?)"); + } + + this.bot.on("GUILD_CREATE", (guild) => { + if (guild.id === this.upstreamGuildId) + this.guildObject = guild; + }); + + this.bot.on("GUILD_UPDATE", (guild) => { + if (guild.id === this.upstreamGuildId) + this.guildObject = guild; + }); + + this.bot.on("MESSAGE_CREATE", (message) => { + if (message.guild_id !== this.upstreamGuildId) return; - this._pushMessageEvent(message); + this.pushEvent({ + eventType: "MESSAGE_CREATE", + message: message + }); }); } - async discordSendMessage(messageContent, channelId, username, avatarURL=undefined) { - if (!this.bot) - throw new Error("Bot not connected"); + userFacingChannelList() { + return this.guildObject.channels.map(channel => ({ id: channel.id, name: channel.name, position: channel.position, type: channel.type, nsfw: channel.nsfw })); + } + async discordSendMessage(content, channelId, username, avatarURL=undefined) { let webhook = this.knownWebhooks.get(channelId); if (!webhook) { - webhook = (await this.bot.getChannelWebhooks(channelId)) - .filter(w => w.name == "well_known__bridge")[0]; - + webhook = (await this.bot.api(["GET", `/channels/${channelId}/webhooks`])) + .find(e => e.name === "well_known__bridge"); + if (!webhook) - webhook = await this.bot.createChannelWebhook(channelId, { + webhook = await this.bot.api(["POST", `/channels/${channelId}/webhooks`], { name: "well_known__bridge" - }, "This webhook was created by the bridge API bot."); + }); this.knownWebhooks.set(channelId, webhook); } - await this.bot.executeWebhook(webhook.id, webhook.token, { - allowedMentions: { - everyone: false, - roles: false, - users: true - }, - content: messageContent, + await this.bot.api(["POST", `/webhooks/${webhook.id}/${webhook.token}?wait=true`], { + content, + username, + avatar_url: avatarURL, tts: false, - wait: true, - avatarURL, - username + allowed_mentions: { + parse: ["users"] + } }); } } -module.exports = WatchedGuild; +export default WatchedGuild; diff --git a/common.js b/common.js index cf0b699..348a4c8 100644 --- a/common.js +++ b/common.js @@ -1,17 +1,19 @@ -const Eris = require("eris"); -const { discordToken, watchedGuildIds } = require("./config"); -const WatchedGuild = require("./WatchedGuild"); +import { discordToken, watchedGuildIds } from "./config.js"; +import DiscordClient from "./DiscordClient.js"; +import WatchedGuild from "./WatchedGuild.js"; -const bot = new Eris(discordToken, { - intents: [ - "guildMessages" - ] +export const guildMap = new Map(); +export const bot = new DiscordClient(discordToken, { + intents: 0 | (1 << 0) | (1 << 9) // GUILDS & GUILD_MESSAGES }); -const guildMap = new Map(); +export function wait(time) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), time); + }); +} -bot.on("ready", () => { - console.log("discord bot: ready"); +bot.on("READY", () => { watchedGuildIds.forEach(id => { const watchedGuild = new WatchedGuild(); watchedGuild.upstreamGuildId = id; @@ -19,10 +21,3 @@ bot.on("ready", () => { guildMap.set(id, watchedGuild); }); }); - -bot.connect(); - -module.exports = { - bot, - guildMap -}; diff --git a/config.js b/config.js index 1fcc5fe..91a5f98 100644 --- a/config.js +++ b/config.js @@ -1,7 +1,5 @@ -module.exports = { - mainHttpListenPort: 4050, - watchedGuildIds: ["822089558886842418"], - jwtSecret: process.env.JWT_SECRET, - discordToken: process.env.DISCORD_TOKEN, - dangerousAdminMode: true -}; +export const mainHttpListenPort = 4050; +export const watchedGuildIds = ["822089558886842418"]; +export const jwtSecret = process.env.JWT_SECRET; +export const discordToken = process.env.DISCORD_TOKEN; +export const dangerousAdminMode = true; diff --git a/index.js b/index.js index 9368aef..c754457 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ -const express = require("express"); -const { mainHttpListenPort } = require("./config"); +import express from "express"; +import apiRoute from "./routes/api.js"; +import { mainHttpListenPort } from "./config.js"; +import { bot } from "./common.js"; + const app = express(); -const apiRoute = require("./routes/api"); app.use(express.json()); app.use("/", express.static("public/")); @@ -13,4 +15,5 @@ app.get("/", (req, res) => { app.listen(mainHttpListenPort, () => { console.log(`server main: listen on ${mainHttpListenPort}`); + bot.connect(); }); \ No newline at end of file diff --git a/package.json b/package.json index 2ebc709..305b76d 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "version": "1.0.0", "main": "index.js", "license": "MIT", + "type": "module", "dependencies": { - "eris": "^0.16.1", "express": "^4.17.2", - "jsonwebtoken": "^8.5.1" + "jsonwebtoken": "^8.5.1", + "node-fetch": "^3.2.0", + "ws": "^8.4.2" } } diff --git a/public/index.html b/public/index.html index 89b0668..24eed1d 100644 --- a/public/index.html +++ b/public/index.html @@ -7,8 +7,11 @@ Document - - + + + + + diff --git a/routes/api.js b/routes/api.js index 91c7155..f1c112c 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1,7 +1,8 @@ -const express = require("express"); -const { guildMap } = require("../common"); -const { dangerousAdminMode } = require("../config"); -const { checkAuth, createToken } = require("../tokens"); +import express from "express"; +import { guildMap, wait } from "../common.js"; +import { dangerousAdminMode } from "../config.js"; +import { checkAuth, createToken } from "../tokens.js"; + const router = express(); router.get("/", (req, res) => { @@ -46,11 +47,57 @@ router.post("/guilds/:guildId/channels/:channelId/messages/create", checkAuth(as const guild = guildMap.get(guildId); try { await guild.discordSendMessage(messageContent, channelId, username, avatarURL); - res.status(201).send({ error: false, message: "SUCCESS_MESSAGE_CREATED" }); + res.status(201).send(""); } catch(e) { console.error("server main: api: message create: error: ", e); res.status(500).send({ error: true, message: "ERROR_MESSAGE_SEND_FAILURE" }); } })); -module.exports = router; \ No newline at end of file +router.get("/guilds/:guildId/channels", checkAuth(async (req, res) => { + const guildId = req.params.guildId; + if (!guildId) + return res.status(400).send({ error: true, message: "ERROR_NO_GUILD_ID" }); + + const { guildAccess } = req.user; + + if (guildAccess.indexOf(guildId) === -1) + return res.status(403).send({ error: true, message: "ERROR_NO_GUILD_ACCESS" }); + + const guild = guildMap.get(guildId); + try { + res.status(200).send({ error: false, channels: guild.userFacingChannelList() }); + } catch(e) { + console.error("server main: api: guild get channels: error: ", e); + res.status(500).send({ error: true, message: "ERROR_CHANNELS_FETCH_FAILURE" }); + } +})); + +router.get("/guilds/:guildId/events/poll", checkAuth(async (req, res) => { + const guildId = req.params.guildId; + if (!guildId) + return res.status(400).send({ error: true, message: "ERROR_NO_GUILD_ID" }); + + const { guildAccess } = req.user; + + if (guildAccess.indexOf(guildId) === -1) + return res.status(403).send({ error: true, message: "ERROR_NO_GUILD_ACCESS" }); + + const guild = guildMap.get(guildId); + try { + Promise.race([ + guild.holdForEvent(), + wait(10000) + ]).then(event => { + res.status(200).send({ error: false, event }); + }) + .catch(() => { + res.status(200).send({ error: false, event: null }); + }); + } catch(e) { + console.error("server main: api: guild poll events: error: ", e); + res.status(500).send({ error: true, message: "ERROR_POLL_FAILURE" }); + } +})); + +export default router; diff --git a/tokens.js b/tokens.js index 55ebfd6..3475fc0 100644 --- a/tokens.js +++ b/tokens.js @@ -1,7 +1,7 @@ -const jsonwebtoken = require("jsonwebtoken"); -const { jwtSecret } = require("./config"); +import jsonwebtoken from "jsonwebtoken"; +import { jwtSecret } from "./config.js"; -function createToken({ username, avatarURL, discordID, guildAccess }) { +export function createToken({ username, avatarURL, discordID, guildAccess }) { return new Promise((resolve, reject) => { jsonwebtoken.sign({ username, avatarURL, discordID, guildAccess }, jwtSecret, (err, token) => { if (err) @@ -12,7 +12,7 @@ function createToken({ username, avatarURL, discordID, guildAccess }) { }); } -function decodeToken(token) { +export function decodeToken(token) { return new Promise((resolve, reject) => { jsonwebtoken.verify(token, jwtSecret, (err, token) => { if (err) @@ -23,7 +23,7 @@ function decodeToken(token) { }); } -function checkAuth(callback) { +export function checkAuth(callback) { return async (req, res) => { const token = req.get("authorization"); if (token) { @@ -48,9 +48,3 @@ function checkAuth(callback) { } }; } - -module.exports = { - createToken, - decodeToken, - checkAuth -}; diff --git a/yarn.lock b/yarn.lock index f3e1991..8b18752 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,6 +63,11 @@ cookie@0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +data-uri-to-buffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b" + integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -97,16 +102,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -eris@^0.16.1: - version "0.16.1" - resolved "https://registry.yarnpkg.com/eris/-/eris-0.16.1.tgz#44b0a9220944fc73dd74538cd614826bfbfcde61" - integrity sha512-fqjgaddSvUlUjA7s85OvZimLrgCwX58Z6FXOIxdNFJdT6XReJ/LOWZKdew2CaalM8BvN2JKzn98HmKYb3zMhKg== - dependencies: - ws "^8.2.3" - optionalDependencies: - opusscript "^0.0.8" - tweetnacl "^1.0.3" - escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -153,6 +148,14 @@ express@^4.17.2: utils-merge "1.0.1" vary "~1.1.2" +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.4.tgz#e8c6567f80ad7fc22fd302e7dcb72bafde9c1717" + integrity sha512-Eq5Xv5+VlSrYWEqKrusxY1C3Hm/hjeAsCGVG3ft7pZahlUAChpGZT/Ms1WmSLnEAisEXszjzu/s+ce6HZB2VHA== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" @@ -166,6 +169,13 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -319,6 +329,20 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.0.tgz#59390db4e489184fa35d4b74caf5510e8dfbaf3b" + integrity sha512-8xeimMwMItMw8hRrOl3C9/xzU49HV/yE6ORew/l+dxWimO5A4Ra8ld2rerlJvc/O7et5Z1zrWsPX43v1QBjCxw== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -326,11 +350,6 @@ on-finished@~2.3.0: dependencies: ee-first "1.1.1" -opusscript@^0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/opusscript/-/opusscript-0.0.8.tgz#00b49e81281b4d99092d013b1812af8654bd0a87" - integrity sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ== - parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -428,11 +447,6 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -tweetnacl@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" - integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== - type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -456,7 +470,12 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -ws@^8.2.3: +web-streams-polyfill@^3.0.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965" + integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA== + +ws@^8.4.2: version "8.4.2" resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.2.tgz#18e749868d8439f2268368829042894b6907aa0b" integrity sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==