From 7fe05d7c8ac246dfd42534ab196e244c76830db0 Mon Sep 17 00:00:00 2001 From: beckerinj Date: Tue, 10 May 2022 13:24:27 -0400 Subject: [PATCH] slack notifier --- core/package.json | 1 + core/src/config.ts | 7 ++ core/src/main.ts | 2 + core/src/plugins/slackNotifier.ts | 78 +++++++++++++++++ core/src/util/periphery/server.ts | 4 +- core/src/util/slack.ts | 18 ++++ util/helpers.ts | 12 +++ yarn.lock | 137 ++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 core/src/plugins/slackNotifier.ts create mode 100644 core/src/util/slack.ts diff --git a/core/package.json b/core/package.json index 715dda7fe..5d1f1ca93 100644 --- a/core/package.json +++ b/core/package.json @@ -23,6 +23,7 @@ "dependencies": { "@monitor/util": "1.0.0", "@monitor/util-node": "1.0.0", + "@slack/web-api": "^6.7.1", "axios": "^0.26.1", "bcrypt": "^5.0.1", "dockerode": "^3.3.1", diff --git a/core/src/config.ts b/core/src/config.ts index 19ff57576..1bf235b73 100644 --- a/core/src/config.ts +++ b/core/src/config.ts @@ -45,3 +45,10 @@ export const SERVER_CHECK_TIMEOUT = getNumberFromEnv( "SERVER_CHECK_TIMEOUT", 1000 ); + +export const SERVER_STATS_INTERVAL = getNumberFromEnv("SERVER_STATS_INTERVAL", 5 * 60 * 1000); // 5 minute check default +export const SLACK_CHANNEL = getStringFromEnv("SLACK_CHANNEL", ""); +export const SLACK_TOKEN = getStringFromEnv("SLACK_TOKEN", ""); +export const CPU_USAGE_NOTIFY_LIMIT = getNumberFromEnv("CPU_USAGE_NOTIFY_LIMIT", 50); +export const MEM_USAGE_NOTIFY_LIMIT = getNumberFromEnv("MEM_USAGE_NOTIFY_LIMIT", 75); +export const DISK_USAGE_NOTIFY_LIMIT = getNumberFromEnv("DISK_USAGE_NOTIFY_LIMIT", 75); \ No newline at end of file diff --git a/core/src/main.ts b/core/src/main.ts index 9d933f2ba..4814011d0 100644 --- a/core/src/main.ts +++ b/core/src/main.ts @@ -9,6 +9,7 @@ import docker from "./plugins/docker"; import frontend from "./plugins/frontend"; import actionStates from "./plugins/actionStates"; import routes from "./routes"; +import slackNotifier from "./plugins/slackNotifier"; async function main() { const app = fastify({ logger: LOGGER }) @@ -31,6 +32,7 @@ async function main() { .register(ws) .register(frontend) .register(actionStates) + .register(slackNotifier) .register(routes); app.listen(PORT, "0.0.0.0", async (err, address) => { diff --git a/core/src/plugins/slackNotifier.ts b/core/src/plugins/slackNotifier.ts new file mode 100644 index 000000000..467c5c141 --- /dev/null +++ b/core/src/plugins/slackNotifier.ts @@ -0,0 +1,78 @@ +import { getSystemStats } from "@monitor/util-node"; +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import { + CPU_USAGE_NOTIFY_LIMIT, + DISK_USAGE_NOTIFY_LIMIT, + MEM_USAGE_NOTIFY_LIMIT, + SERVER_STATS_INTERVAL, + SLACK_TOKEN, +} from "../config"; +import { getPeripherySystemStats } from "../util/periphery/server"; +import { serverStatusPeriphery } from "../util/periphery/status"; +import { notifySlack } from "../util/slack"; + +const slackNotifier = fp((app: FastifyInstance, _: {}, done: () => void) => { + const getAllServerStats = async () => { + const servers = await app.servers.find({}); + const serversWithStatus = ( + await Promise.all( + servers + .filter((server) => server.enabled) + .map(async (server) => { + const status = await serverStatusPeriphery(server); + return { + ...server, + stats: server.isCore + ? await getSystemStats() + : status + ? await getPeripherySystemStats(server) + : undefined, + }; + }) + ) + ).filter((server) => server.stats); + return serversWithStatus; + }; + + const interval = async () => { + const servers = await getAllServerStats(); + servers.forEach((server) => { + // check for out of bounds stats + const stats = server.stats!; + if (stats.cpu > CPU_USAGE_NOTIFY_LIMIT) { + // high cpu usage + notifySlack(` + WARNING | server ${server.name} has high CPU usage. + + usage: ${stats.cpu}% + `); + } + if (stats.mem.usedMemPercentage > MEM_USAGE_NOTIFY_LIMIT) { + // high memory usage + notifySlack(` + WARNING | server ${server.name} has high memory usage. + + using ${stats.mem.usedMemMb} MB of ${stats.mem.totalMemMb} MB (${stats.mem.usedMemPercentage}%) + `); + } + if (stats.disk.usedPercentage > DISK_USAGE_NOTIFY_LIMIT) { + // high disk usage + notifySlack(` + WARNING | server ${server.name} has high disk usage. + + using ${stats.disk.usedGb} GB of ${stats.disk.totalGb} (${stats.disk.usedPercentage}% full) + `); + } + }); + }; + + if (SLACK_TOKEN) { + // only do this if slack token is provided + setInterval(interval, SERVER_STATS_INTERVAL); + } + + done(); +}); + +export default slackNotifier; diff --git a/core/src/util/periphery/server.ts b/core/src/util/periphery/server.ts index 4e904dc82..19c49a882 100644 --- a/core/src/util/periphery/server.ts +++ b/core/src/util/periphery/server.ts @@ -1,4 +1,4 @@ -import { CommandLogError, Server } from "@monitor/types"; +import { CommandLogError, Server, SystemStats } from "@monitor/types"; import axios from "axios"; import { SECRETS } from "../../config"; @@ -24,7 +24,7 @@ export async function getPeripheryDockerStats({ address }: Server) { export async function getPeripherySystemStats({ address }: Server) { return await axios - .get(`${address}/sys-stats`, { + .get(`${address}/sys-stats`, { headers: { Authorization: SECRETS.PASSKEY, }, diff --git a/core/src/util/slack.ts b/core/src/util/slack.ts new file mode 100644 index 000000000..c3bb5c28b --- /dev/null +++ b/core/src/util/slack.ts @@ -0,0 +1,18 @@ +import { readableTimestamp, timestamp } from "@monitor/util"; +import { WebClient, LogLevel } from "@slack/web-api"; +import { SLACK_CHANNEL, SLACK_TOKEN } from "../config"; + +const slack = new WebClient(SLACK_TOKEN, { logLevel: LogLevel.INFO }); + +export async function notifySlack(text: string) { + try { + await slack.chat.postMessage({ + token: SLACK_TOKEN, + channel: SLACK_CHANNEL, + text, + }); + } catch (error) { + console.log("POST TO SLACK FAILED @", readableTimestamp(timestamp())); + console.log(error); + } +} \ No newline at end of file diff --git a/util/helpers.ts b/util/helpers.ts index 4be959dcc..28aa2ac94 100644 --- a/util/helpers.ts +++ b/util/helpers.ts @@ -120,4 +120,16 @@ export function objMap(obj: { [key: string]: T }, map: (entry: T) => any) { return Object.fromEntries( Object.entries(obj).map((entry) => [entry[0], map(entry[1])]) ); +} + +export function readableTimestamp(unixTimeInSecs: number) { + const date = new Date(unixTimeInSecs * 1000); + const hours24 = date.getHours(); + let hours = hours24 % 12; + if (hours === 0) hours = 12; + const pm = hours24 > 11; + const minutes = date.getMinutes(); + return `${date.getMonth() + 1}/${date.getDate()} ${hours}:${ + minutes > 9 ? minutes : "0" + minutes + } ${pm ? "PM" : "AM"}`; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0a04c98af..f8c625fd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -385,6 +385,35 @@ semver "^7.3.5" tar "^6.1.11" +"@slack/logger@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-3.0.0.tgz#b736d4e1c112c22a10ffab0c2d364620aedcb714" + integrity sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA== + dependencies: + "@types/node" ">=12.0.0" + +"@slack/types@^2.0.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.4.0.tgz#b6f6d50e9181f723080b841302e089739cef512d" + integrity sha512-0k8UlVEH9gUVwTbwcanS1JT2vCROkr1WESgdXW7d2maWYTuwbVEx87YvXPjsemAJfdu+RYqxGhO2oGTigprepA== + +"@slack/web-api@^6.7.1": + version "6.7.1" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-6.7.1.tgz#a0e983ec7925dccaf8fe15047f1a53eb82c24888" + integrity sha512-Aa2E/7NtGagd7mVsFCrc69iZMoviR2032SBOic06sYVvptdzJlvNsSQVqLCb1Aqz7r/jodb2fnXO1gl016OcWQ== + dependencies: + "@slack/logger" "^3.0.0" + "@slack/types" "^2.0.0" + "@types/is-stream" "^1.1.0" + "@types/node" ">=12.0.0" + axios "^0.26.1" + eventemitter3 "^3.1.0" + form-data "^2.5.0" + is-electron "2.2.0" + is-stream "^1.1.0" + p-queue "^6.6.1" + p-retry "^4.0.0" + "@swc/core-android-arm-eabi@1.2.156": version "1.2.156" resolved "https://registry.yarnpkg.com/@swc/core-android-arm-eabi/-/core-android-arm-eabi-1.2.156.tgz#4d4cc38d5071b74c7f55ddc5e0931a2cd3bcc530" @@ -499,6 +528,13 @@ dependencies: "@types/node" "*" +"@types/is-stream@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" + integrity sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg== + dependencies: + "@types/node" "*" + "@types/minimist@^1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -514,6 +550,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== +"@types/node@>=12.0.0": + version "17.0.31" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.31.tgz#a5bb84ecfa27eec5e1c802c6bbf8139bdb163a5d" + integrity sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -533,6 +574,11 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/scheduler@*": version "0.16.2" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" @@ -694,6 +740,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -951,6 +1002,13 @@ color-support@^1.1.2: resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +combined-stream@^1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1039,6 +1097,11 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -1284,6 +1347,16 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +eventemitter3@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" + integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== + +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + fast-decode-uri-component@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" @@ -1480,6 +1553,15 @@ follow-redirects@^1.14.8: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== +form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -1770,6 +1852,11 @@ is-core-module@^2.5.0, is-core-module@^2.8.1: dependencies: has "^1.0.3" +is-electron@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0" + integrity sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1797,6 +1884,11 @@ is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + is-what@^4.1.6: version "4.1.7" resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.7.tgz#c41dc1d2d2d6a9285c624c2505f61849c8b1f9cc" @@ -1952,6 +2044,18 @@ merge-anything@^5.0.2: is-what "^4.1.6" ts-toolbelt "^9.6.0" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -2193,6 +2297,11 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -2207,6 +2316,29 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-queue@^6.6.1: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-retry@^4.0.0: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -2434,6 +2566,11 @@ ret@~0.2.0: resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c" integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.0, reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"