diff --git a/example/browser/README.md b/example/browser/README.md new file mode 100644 index 000000000..8ebdb860a --- /dev/null +++ b/example/browser/README.md @@ -0,0 +1,15 @@ +At first, set environment vairables + +``` +$ export MASTODON_URL=wss://mastodon.social +$ export MASTODON_ACCESS_TOKEN=foobar +``` +And execute + +``` +$ yarn workspace megalodon build +$ yarn workspace browser build +$ yarn workspace browser start +``` + +Let's open `http://127.0.0.1:8000`. diff --git a/example/browser/src/index.ts b/example/browser/src/index.ts index a7eda26b8..89b162bd0 100644 --- a/example/browser/src/index.ts +++ b/example/browser/src/index.ts @@ -1,10 +1,49 @@ -import generator, { Entity, Response } from 'megalodon' +import generator from 'megalodon' -const BASE_URL: string = 'https://mastodon.social' +const BASE_URL: string = process.env.MASTODON_URL! +const ACCESS_TOKEN: string = process.env.MASTODON_ACCESS_TOKEN! +console.log(BASE_URL) console.log('start') -const client = generator('mastodon', BASE_URL) +const client = generator('mastodon', BASE_URL, ACCESS_TOKEN) -client.getInstance().then((res: Response) => { - console.log(res) +const stream = client.localSocket() +stream.on('connect', () => { + console.log('connect') +}) + +stream.on('pong', () => { + console.log('pong') +}) + +stream.on('update', (status: Entity.Status) => { + console.log(status) +}) + +stream.on('notification', (notification: Entity.Notification) => { + console.log(notification) +}) + +stream.on('delete', (id: number) => { + console.log(id) +}) + +stream.on('error', (err: Error) => { + console.error(err) +}) + +stream.on('status_update', (status: Entity.Status) => { + console.log('updated: ', status.url) +}) + +stream.on('heartbeat', () => { + console.log('thump.') +}) + +stream.on('close', () => { + console.log('close') +}) + +stream.on('parser-error', (err: Error) => { + console.error(err) }) diff --git a/example/browser/webpack.config.js b/example/browser/webpack.config.js index e1c6a497c..4602a0061 100644 --- a/example/browser/webpack.config.js +++ b/example/browser/webpack.config.js @@ -39,7 +39,9 @@ module.exports = { plugins: [ new webpack.DefinePlugin({ 'process.browser': true, - 'process.env.NODE_DEBUG': false + 'process.env.NODE_DEBUG': false, + 'process.env.MASTODON_URL': `"${process.env.MASTODON_URL}"`, + 'process.env.MASTODON_ACCESS_TOKEN': `"${process.env.MASTODON_ACCESS_TOKEN}"` }), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], diff --git a/example/typescript/src/mastodon/streaming.ts b/example/typescript/src/mastodon/streaming.ts index 0d0ef2d2a..57c16e88c 100644 --- a/example/typescript/src/mastodon/streaming.ts +++ b/example/typescript/src/mastodon/streaming.ts @@ -1,18 +1,12 @@ import generator, { Entity, WebSocketInterface } from 'megalodon' -declare var process: { - env: { - MASTODON_ACCESS_TOKEN: string - } -} +const BASE_URL: string = process.env.MASTODON_STREAMING_URL! -const BASE_URL: string = 'wss://streaming.fedibird.com' - -const access_token: string = process.env.MASTODON_ACCESS_TOKEN +const access_token: string = process.env.MASTODON_ACCESS_TOKEN! const client = generator('mastodon', BASE_URL, access_token) -const stream: WebSocketInterface = client.userSocket() +const stream: WebSocketInterface = client.localSocket() stream.on('connect', () => { console.log('connect') }) diff --git a/megalodon/package.json b/megalodon/package.json index 9daaef638..dc9b83e8d 100644 --- a/megalodon/package.json +++ b/megalodon/package.json @@ -64,7 +64,8 @@ "socks-proxy-agent": "^8.0.2", "typescript": "5.2.2", "uuid": "^9.0.1", - "ws": "8.14.2" + "ws": "8.14.2", + "isomorphic-ws": "^5.0.0" }, "devDependencies": { "@types/core-js": "^2.5.6", diff --git a/megalodon/src/default.ts b/megalodon/src/default.ts index 0194b3dcc..67b00cacb 100644 --- a/megalodon/src/default.ts +++ b/megalodon/src/default.ts @@ -1,3 +1,11 @@ export const NO_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob' export const DEFAULT_SCOPE = ['read', 'write', 'follow'] export const DEFAULT_UA = 'megalodon' + +export function isBrowser() { + if (typeof window !== 'undefined') { + return true + } else { + return false + } +} diff --git a/megalodon/src/firefish/web_socket.ts b/megalodon/src/firefish/web_socket.ts index 3fa8cf262..f38e7656a 100644 --- a/megalodon/src/firefish/web_socket.ts +++ b/megalodon/src/firefish/web_socket.ts @@ -1,4 +1,4 @@ -import WS from 'ws' +import WS from 'isomorphic-ws' import dayjs, { Dayjs } from 'dayjs' import { v4 as uuid } from 'uuid' import { EventEmitter } from 'events' @@ -6,6 +6,7 @@ import { WebSocketInterface } from '../megalodon' import proxyAgent, { ProxyConfig } from '../proxy_config' import FirefishAPI from './api_client' import { UnknownNotificationTypeError } from '../notification' +import { isBrowser } from '../default' /** * WebSocket @@ -120,16 +121,24 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac * Connect to the endpoint. */ private _connect(): WS { - let options: WS.ClientOptions = { - headers: this.headers - } - if (this.proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(this.proxyConfig) - }) + const requestURL = `${this.url}?i=${this._accessToken}` + if (isBrowser()) { + // This is browser. + // We can't pass options when browser: https://github.com/heineiuo/isomorphic-ws#limitations + const cli = new WS(requestURL) + return cli + } else { + let options: WS.ClientOptions = { + headers: this.headers + } + if (this.proxyConfig) { + options = Object.assign(options, { + agent: proxyAgent(this.proxyConfig) + }) + } + const cli: WS = new WS(requestURL, options) + return cli } - const cli: WS = new WS(`${this.url}?i=${this._accessToken}`, options) - return cli } /** @@ -252,37 +261,43 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac * @param client A WebSocket instance. */ private _bindSocket(client: WS) { - client.on('close', (code: number, _reason: Buffer) => { - if (code === 1000) { + client.onclose = event => { + // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 + if (event.code === 1000) { this.emit('close', {}) } else { - console.log(`Closed connection with ${code}`) + console.log(`Closed connection with ${event.code}`) + // If already called close method, it does not retry. if (!this._connectionClosed) { this._reconnect() } } - }) - client.on('pong', () => { - this._pongWaiting = false - this.emit('pong', {}) - this._pongReceivedTimestamp = dayjs() - // It is required to anonymous function since get this scope in checkAlive. - setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) - }) - client.on('open', () => { + } + client.onopen = _event => { this.emit('connect', {}) this._channel() - // Call first ping event. - setTimeout(() => { - client.ping('') - }, 10000) - }) - client.on('message', (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary, this._channelID) - }) - client.on('error', (err: Error) => { - this.emit('error', err) - }) + if (!isBrowser()) { + // Call first ping event. + setTimeout(() => { + client.ping('') + }, 10000) + } + } + client.onmessage = event => { + this.parser.parse(event, this._channelID) + } + client.onerror = event => { + this.emit('error', event.target) + } + if (!isBrowser()) { + client.on('pong', () => { + this._pongWaiting = false + this.emit('pong', {}) + this._pongReceivedTimestamp = dayjs() + // It is required to anonymous function since get this scope in checkAlive. + setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) + }) + } } /** @@ -338,11 +353,12 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac */ export class Parser extends EventEmitter { /** - * @param message Message body of websocket. + * @param message Message event of websocket. * @param channelID Parse only messages which has same channelID. */ - public parse(data: WS.Data, isBinary: boolean, channelID: string) { - const message = isBinary ? data : data.toString() + public parse(ev: WS.MessageEvent, channelID: string) { + const data = ev.data + const message = data.toString() if (typeof message !== 'string') { this.emit('heartbeat', {}) return diff --git a/megalodon/src/mastodon.ts b/megalodon/src/mastodon.ts index 2acc9c7e1..5a009770c 100644 --- a/megalodon/src/mastodon.ts +++ b/megalodon/src/mastodon.ts @@ -3,7 +3,7 @@ import FormData from 'form-data' import parseLinkHeader from 'parse-link-header' import MastodonAPI from './mastodon/api_client' -import WebSocket from './mastodon/web_socket' +import Streaming from './mastodon/web_socket' import { MegalodonInterface, NoImplementedError } from './megalodon' import Response from './response' import Entity from './entity' @@ -3144,27 +3144,27 @@ export default class Mastodon implements MegalodonInterface { // ====================================== // WebSocket // ====================================== - public userSocket(): WebSocket { + public userSocket(): Streaming { return this.client.socket('/api/v1/streaming', 'user') } - public publicSocket(): WebSocket { + public publicSocket(): Streaming { return this.client.socket('/api/v1/streaming', 'public') } - public localSocket(): WebSocket { + public localSocket(): Streaming { return this.client.socket('/api/v1/streaming', 'public:local') } - public tagSocket(tag: string): WebSocket { + public tagSocket(tag: string): Streaming { return this.client.socket('/api/v1/streaming', 'hashtag', `tag=${tag}`) } - public listSocket(list_id: string): WebSocket { + public listSocket(list_id: string): Streaming { return this.client.socket('/api/v1/streaming', 'list', `list=${list_id}`) } - public directSocket(): WebSocket { + public directSocket(): Streaming { return this.client.socket('/api/v1/streaming', 'direct') } } diff --git a/megalodon/src/mastodon/api_client.ts b/megalodon/src/mastodon/api_client.ts index 58f56d101..887fbaa2e 100644 --- a/megalodon/src/mastodon/api_client.ts +++ b/megalodon/src/mastodon/api_client.ts @@ -1,7 +1,7 @@ import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' import objectAssignDeep from 'object-assign-deep' -import WebSocket from './web_socket' +import Streaming from './web_socket' import Response from '../response' import { RequestCanceledError } from '../cancel' import proxyAgent, { ProxyConfig } from '../proxy_config' @@ -25,7 +25,7 @@ namespace MastodonAPI { postForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> del(path: string, params?: any, headers?: { [key: string]: string }): Promise> cancel(): void - socket(path: string, stream: string, params?: string): WebSocket + socket(path: string, stream: string, params?: string): Streaming } /** @@ -428,12 +428,12 @@ namespace MastodonAPI { * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 * @returns WebSocket, which inherits from EventEmitter */ - public socket(path: string, stream: string, params?: string): WebSocket { + public socket(path: string, stream: string, params?: string): Streaming { if (!this.accessToken) { throw new Error('accessToken is required') } const url = this.baseUrl + path - const streaming = new WebSocket(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig) + const streaming = new Streaming(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig) process.nextTick(() => { streaming.start() }) diff --git a/megalodon/src/mastodon/web_socket.ts b/megalodon/src/mastodon/web_socket.ts index 28bf38a66..52517f102 100644 --- a/megalodon/src/mastodon/web_socket.ts +++ b/megalodon/src/mastodon/web_socket.ts @@ -1,17 +1,17 @@ -import WS from 'ws' +import WS from 'isomorphic-ws' import dayjs, { Dayjs } from 'dayjs' import { EventEmitter } from 'events' import proxyAgent, { ProxyConfig } from '../proxy_config' import { WebSocketInterface } from '../megalodon' import MastodonAPI from './api_client' import { UnknownNotificationTypeError } from '../notification' +import { isBrowser } from '../default' /** - * WebSocket - * Pleroma is not support streaming. It is support websocket instead of streaming. - * So this class connect to Phoenix websocket for Pleroma. + * Streaming + * Connect WebSocket streaming endpoint. */ -export default class WebSocket extends EventEmitter implements WebSocketInterface { +export default class Streaming extends EventEmitter implements WebSocketInterface { public url: string public stream: string public params: string | null @@ -168,17 +168,24 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac parameter.push(`access_token=${accessToken}`) } const requestURL: string = `${url}/?${parameter.join('&')}` - let options: WS.ClientOptions = { - headers: headers - } - if (proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(proxyConfig) - }) - } + if (isBrowser()) { + // This is browser. + // We can't pass options when browser: https://github.com/heineiuo/isomorphic-ws#limitations + const cli = new WS(requestURL) + return cli + } else { + let options: WS.ClientOptions = { + headers: headers + } + if (proxyConfig) { + options = Object.assign(options, { + agent: proxyAgent(proxyConfig) + }) + } - const cli: WS = new WS(requestURL, options) - return cli + const cli: WS = new WS(requestURL, options) + return cli + } } /** @@ -199,38 +206,43 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac * @param client A WebSocket instance. */ private _bindSocket(client: WS) { - client.on('close', (code: number, _reason: Buffer) => { + client.onclose = event => { // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 - if (code === 1000) { + if (event.code === 1000) { this.emit('close', {}) } else { - console.log(`Closed connection with ${code}`) + console.log(`Closed connection with ${event.code}`) // If already called close method, it does not retry. if (!this._connectionClosed) { this._reconnect() } } - }) - client.on('pong', () => { - this._pongWaiting = false - this.emit('pong', {}) - this._pongReceivedTimestamp = dayjs() - // It is required to anonymous function since get this scope in checkAlive. - setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) - }) - client.on('open', () => { + } + client.onopen = _event => { this.emit('connect', {}) - // Call first ping event. - setTimeout(() => { - client.ping('') - }, 10000) - }) - client.on('message', (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary) - }) - client.on('error', (err: Error) => { - this.emit('error', err) - }) + if (!isBrowser()) { + // Call first ping event. + setTimeout(() => { + client.ping('') + }, 10000) + } + } + client.onmessage = event => { + this.parser.parse(event) + } + client.onerror = event => { + this.emit('error', event.target) + } + + if (!isBrowser()) { + client.on('pong', () => { + this._pongWaiting = false + this.emit('pong', {}) + this._pongReceivedTimestamp = dayjs() + // It is required to anonymous function since get this scope in checkAlive. + setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) + }) + } } /** @@ -295,10 +307,11 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac */ export class Parser extends EventEmitter { /** - * @param message Message body of websocket. + * @param message Message event of websocket. */ - public parse(data: WS.Data, isBinary: boolean) { - const message = isBinary ? data : data.toString() + public parse(ev: WS.MessageEvent) { + const data = ev.data + const message = data.toString() if (typeof message !== 'string') { this.emit('heartbeat', {}) return diff --git a/megalodon/src/pleroma/web_socket.ts b/megalodon/src/pleroma/web_socket.ts index f96ea5dc5..083e38e64 100644 --- a/megalodon/src/pleroma/web_socket.ts +++ b/megalodon/src/pleroma/web_socket.ts @@ -1,4 +1,4 @@ -import WS from 'ws' +import WS from 'isomorphic-ws' import dayjs, { Dayjs } from 'dayjs' import { EventEmitter } from 'events' @@ -6,6 +6,7 @@ import proxyAgent, { ProxyConfig } from '../proxy_config' import { WebSocketInterface } from '../megalodon' import PleromaAPI from './api_client' import { UnknownNotificationTypeError } from '../notification' +import { isBrowser } from '../default' /** * WebSocket @@ -169,17 +170,24 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac parameter.push(`access_token=${accessToken}`) } const requestURL: string = `${url}/?${parameter.join('&')}` - let options: WS.ClientOptions = { - headers: headers - } - if (proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(proxyConfig) - }) - } + if (isBrowser()) { + // This is browser. + // We can't pass options when browser: https://github.com/heineiuo/isomorphic-ws#limitations + const cli = new WS(requestURL) + return cli + } else { + let options: WS.ClientOptions = { + headers: headers + } + if (proxyConfig) { + options = Object.assign(options, { + agent: proxyAgent(proxyConfig) + }) + } - const cli: WS = new WS(requestURL, options) - return cli + const cli: WS = new WS(requestURL, options) + return cli + } } /** @@ -200,38 +208,43 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac * @param client A WebSocket instance. */ private _bindSocket(client: WS) { - client.on('close', (code: number, _reason: Buffer) => { + client.onclose = event => { // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 - if (code === 1000) { + if (event.code === 1000) { this.emit('close', {}) } else { - console.log(`Closed connection with ${code}`) + console.log(`Closed connection with ${event.code}`) // If already called close method, it does not retry. if (!this._connectionClosed) { this._reconnect() } } - }) - client.on('pong', () => { - this._pongWaiting = false - this.emit('pong', {}) - this._pongReceivedTimestamp = dayjs() - // It is required to anonymous function since get this scope in checkAlive. - setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) - }) - client.on('open', () => { + } + client.onopen = _event => { this.emit('connect', {}) - // Call first ping event. - setTimeout(() => { - client.ping('') - }, 10000) - }) - client.on('message', (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary) - }) - client.on('error', (err: Error) => { - this.emit('error', err) - }) + if (!isBrowser()) { + // Call first ping event. + setTimeout(() => { + client.ping('') + }, 10000) + } + } + client.onmessage = event => { + this.parser.parse(event) + } + client.onerror = event => { + this.emit('error', event.target) + } + + if (!isBrowser()) { + client.on('pong', () => { + this._pongWaiting = false + this.emit('pong', {}) + this._pongReceivedTimestamp = dayjs() + // It is required to anonymous function since get this scope in checkAlive. + setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) + }) + } } /** @@ -296,10 +309,11 @@ export default class WebSocket extends EventEmitter implements WebSocketInterfac */ export class Parser extends EventEmitter { /** - * @param message Message body of websocket. + * @param message Message event of websocket. */ - public parse(data: WS.Data, isBinary: boolean) { - const message = isBinary ? data : data.toString() + public parse(ev: WS.MessageEvent) { + const data = ev.data + const message = data.toString() if (typeof message !== 'string') { this.emit('heartbeat', {}) return diff --git a/megalodon/test/unit/webo_socket.spec.ts b/megalodon/test/unit/mastodon/web_socket.spec.ts similarity index 68% rename from megalodon/test/unit/webo_socket.spec.ts rename to megalodon/test/unit/mastodon/web_socket.spec.ts index bb9f997a5..136e59318 100644 --- a/megalodon/test/unit/webo_socket.spec.ts +++ b/megalodon/test/unit/mastodon/web_socket.spec.ts @@ -1,4 +1,5 @@ import { Parser } from '@/mastodon/web_socket' +import WS from 'isomorphic-ws' import Entity from '@/entity' const account: Entity.Account = { @@ -87,22 +88,30 @@ describe('Parser', () => { describe('parse', () => { describe('message is heartbeat', () => { describe('message is an object', () => { - const message = Buffer.alloc(0) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: Buffer.alloc(0) + } it('should be called', () => { const spy = jest.fn() parser.once('heartbeat', spy) - parser.parse(message, true) + parser.parse(message) expect(spy).toHaveBeenCalledWith({}) }) }) describe('message is empty string', () => { - const message: string = '' + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: '' + } it('should be called', () => { const spy = jest.fn() parser.once('heartbeat', spy) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(spy).toHaveBeenCalledWith({}) }) }) @@ -110,30 +119,38 @@ describe('Parser', () => { describe('message is not json', () => { describe('event is delete', () => { - const message = JSON.stringify({ - event: 'delete', - payload: '12asdf34' - }) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: JSON.stringify({ + event: 'delete', + payload: '12asdf34' + }) + } it('should be called', () => { const spy = jest.fn() parser.once('delete', spy) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(spy).toHaveBeenCalledWith('12asdf34') }) }) describe('event is not delete', () => { - const message = JSON.stringify({ - event: 'event', - payload: '12asdf34' - }) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: JSON.stringify({ + event: 'event', + payload: '12asdf34' + }) + } it('should be called', () => { const error = jest.fn() const deleted = jest.fn() parser.once('error', error) parser.once('delete', deleted) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(error).toHaveBeenCalled() expect(deleted).not.toHaveBeenCalled() }) @@ -142,40 +159,52 @@ describe('Parser', () => { describe('message is json', () => { describe('event is update', () => { - const message = JSON.stringify({ - event: 'update', - payload: JSON.stringify(status) - }) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: JSON.stringify({ + event: 'update', + payload: JSON.stringify(status) + }) + } it('should be called', () => { const spy = jest.fn() parser.once('update', spy) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(spy).toHaveBeenCalledWith(status) }) }) describe('event is notification', () => { - const message = JSON.stringify({ - event: 'notification', - payload: JSON.stringify(notification) - }) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: JSON.stringify({ + event: 'notification', + payload: JSON.stringify(notification) + }) + } it('should be called', () => { const spy = jest.fn() parser.once('notification', spy) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(spy).toHaveBeenCalledWith(notification) }) }) describe('event is conversation', () => { - const message = JSON.stringify({ - event: 'conversation', - payload: JSON.stringify(conversation) - }) + const message: WS.MessageEvent = { + type: 'message', + target: '' as any, + data: JSON.stringify({ + event: 'conversation', + payload: JSON.stringify(conversation) + }) + } it('should be called', () => { const spy = jest.fn() parser.once('conversation', spy) - parser.parse(Buffer.from(message), false) + parser.parse(message) expect(spy).toHaveBeenCalledWith(conversation) }) }) diff --git a/yarn.lock b/yarn.lock index a23f0ec19..cba42f453 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3052,6 +3052,11 @@ isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isomorphic-ws@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf" + integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw== + istanbul-lib-coverage@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec"