diff --git a/app/package.json b/app/package.json index aeec5dc..83fb316 100644 --- a/app/package.json +++ b/app/package.json @@ -14,6 +14,8 @@ "@material-ui/lab": "^3.0.0-alpha.30", "@material-ui/styles": "^3.0.0-alpha.10", "@types/diff": "^4.0.1", + "@types/get-value": "^3.0.1", + "@types/js-base64": "^2.3.1", "@types/node": "^10.12.18", "@types/prismjs": "^1.9.1", "@types/react": "^16.7.18", @@ -32,8 +34,11 @@ "diff": "^4.0.1", "electron-nucleus": "^1.11.0", "electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git#dist", + "get-value": "^3.0.1", "html-webpack-plugin": "^4.0.0-beta.5", "jquery": "^3.3.1", + "js-base64": "^2.5.1", + "json-to-ast": "^2.1.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "moment": "^2.24.0", diff --git a/app/src/actions/Publish.ts b/app/src/actions/Publish.ts index 38cf70c..a821c3b 100644 --- a/app/src/actions/Publish.ts +++ b/app/src/actions/Publish.ts @@ -2,6 +2,7 @@ import { Action, ActionTypes } from '../reducers/Publish' import { AppState } from '../reducers' import { Dispatch } from 'redux' import { makePublishEvent, rendererEvents } from '../../../events' +import { Base64Message } from '../../../backend/src/Model/Base64Message'; export const setTopic = (topic?: string): Action => { return { @@ -42,7 +43,7 @@ export const publish = (connectionId: string) => (dispatch: Dispatch, ge const publishEvent = makePublishEvent(connectionId) const mqttMessage = { topic, - payload: state.publish.payload, + payload: state.publish.payload ? Base64Message.fromString(state.publish.payload) : null, retain: state.publish.retain, qos: state.publish.qos, } diff --git a/app/src/actions/Settings.ts b/app/src/actions/Settings.ts index 8223971..ac33784 100644 --- a/app/src/actions/Settings.ts +++ b/app/src/actions/Settings.ts @@ -12,6 +12,7 @@ import { SettingsState, TopicOrder, } from '../reducers/Settings' +import { Base64Message } from '../../../backend/src/Model/Base64Message'; const settingsIdentifier: StorageIdentifier> = { id: 'Settings', @@ -110,7 +111,10 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch, get return true } - const messageMatches = (node.message && typeof node.message.value === 'string' && node.message.value.toLowerCase().indexOf(filterStr) !== -1) + const messageMatches = node.message + && node.message.value + && Base64Message.toUnicodeString(node.message.value).toLowerCase().indexOf(filterStr) !== -1 + return Boolean(messageMatches) } diff --git a/app/src/components/BrokerStatistics.tsx b/app/src/components/BrokerStatistics.tsx index 5015272..93133ef 100644 --- a/app/src/components/BrokerStatistics.tsx +++ b/app/src/components/BrokerStatistics.tsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux' import { StyleRulesCallback, withStyles } from '@material-ui/core/styles' import { TopicViewModel } from '../TopicViewModel' import { Typography } from '@material-ui/core' +import { Base64Message } from '../../../backend/src/Model/Base64Message'; const abbreviate = require('number-abbreviate') @@ -109,7 +110,7 @@ class BrokerStatistics extends React.Component { return null } - let value = node.message && node.message.value + let value = (node.message && node.message.value) ? parseFloat(Base64Message.toUnicodeString(node.message.value)) : NaN value = !isNaN(value) ? abbreviate(value) : value return ( diff --git a/app/src/components/Sidebar/Sidebar.tsx b/app/src/components/Sidebar/Sidebar.tsx index 9e14500..a90ecd9 100644 --- a/app/src/components/Sidebar/Sidebar.tsx +++ b/app/src/components/Sidebar/Sidebar.tsx @@ -6,7 +6,7 @@ import Delete from '@material-ui/icons/Delete' import ExpandMore from '@material-ui/icons/ExpandMore' import NodeStats from './NodeStats' import Topic from './Topic' -import ValuePanel from './ValueRenderer/Panel' +import ValuePanel from './ValueRenderer/ValuePanel' import { AppState } from '../../reducers' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' diff --git a/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx b/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx index 9da6fd4..17930c9 100644 --- a/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx +++ b/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx @@ -4,6 +4,7 @@ import BarChart from '@material-ui/icons/BarChart' import DateFormatter from '../../helper/DateFormatter' import History from '../History' import { TopicViewModel } from '../../../TopicViewModel' +import { Base64Message } from '../../../../../backend/src/Model/Base64Message'; const PlotHistory = React.lazy(() => import('./PlotHistory')) @@ -56,7 +57,11 @@ class MessageHistory extends React.Component { selected: message && message === this.props.selected, })) - const numericMessages = history.filter(message => !isNaN(parseFloat(message.value))) + const numericMessages = history + .map((message: q.Message) => { + const number = message.value ? parseFloat(Base64Message.toUnicodeString(message.value)) : NaN + return { x: number, y: message.received.getTime() } + }).filter(data => !isNaN(data.x)) const showPlot = numericMessages.length >= 2 return ( @@ -72,10 +77,10 @@ class MessageHistory extends React.Component { ) } - public renderPlot(numericMessages: q.Message[]) { + public renderPlot(data: {x: number, y: number}[]) { return ( Loading...}> - + ) } diff --git a/app/src/components/Sidebar/ValueRenderer/PlotHistory.tsx b/app/src/components/Sidebar/ValueRenderer/PlotHistory.tsx index 6a9b846..db4df6c 100644 --- a/app/src/components/Sidebar/ValueRenderer/PlotHistory.tsx +++ b/app/src/components/Sidebar/ValueRenderer/PlotHistory.tsx @@ -3,10 +3,11 @@ import * as React from 'react' import DateFormatter from '../../helper/DateFormatter' import { default as ReactResizeDetector } from 'react-resize-detector' import 'react-vis/dist/style.css' +import { Base64Message } from '../../../../../backend/src/Model/Base64Message'; const { XYPlot, LineMarkSeries, Hint, YAxis, HorizontalGridLines } = require('react-vis') interface Props { - messages: q.Message[] + data: {x: number, y: number}[] } interface Stats { @@ -25,12 +26,7 @@ class PlotHistory extends React.Component { } public render() { - const data = this.props.messages.map((message) => { - return { - x: message.received.getTime(), - y: parseFloat(message.value), - } - }) + const data = this.props.data return (
diff --git a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx index 726f62c..c0dc0e2 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx @@ -27,6 +27,7 @@ import { withStyles, Theme, } from '@material-ui/core' +import { Base64Message } from '../../../../../backend/src/Model/Base64Message'; interface Props { node?: q.TreeNode @@ -50,7 +51,7 @@ class ValuePanel extends React.Component { const { node, classes } = this.props const { detailsStyle, summaryStyle } = this.panelStyle() - const copyValue = node && node.message ? : null + const copyValue = (node && node.message && node.message.value) ? : null return ( diff --git a/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx b/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx index 7b8c177..05d23b9 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx @@ -5,6 +5,7 @@ import { AppState } from '../../../reducers' import { connect } from 'react-redux' import { default as ReactResizeDetector } from 'react-resize-detector' import { ValueRendererDisplayMode } from '../../../reducers/Settings' +import { Base64Message } from '../../../../../backend/src/Model/Base64Message'; interface Props { message: q.Message @@ -42,25 +43,33 @@ class ValueRenderer extends React.Component { compareMessage = message } + if (!message.value) { + return null + } + + const compareValue = compareMessage.value || message.value + const str = Base64Message.toUnicodeString(message.value) + const compareStr = Base64Message.toUnicodeString(compareValue) + let json try { - json = JSON.parse(message.value) + json = JSON.parse(str) } catch (error) { - return this.renderRawValue(message.value, compareMessage.value) + return this.renderRawValue(str, compareStr) } if (typeof json === 'string') { - return this.renderRawValue(message.value, compareMessage.value) + return this.renderRawValue(str, compareStr) } else if (typeof json === 'number') { - return this.renderRawValue(message.value, compareMessage.value) + return this.renderRawValue(str, compareStr) } else if (typeof json === 'boolean') { - return this.renderRawValue(message.value, compareMessage.value) + return this.renderRawValue(str, compareStr) } else { - const current = this.messageToPrettyJson(message) || message.value - const compare = this.messageToPrettyJson(compareMessage) || compareMessage.value + const current = this.messageToPrettyJson(str) || str + const compare = this.messageToPrettyJson(compareStr) || compareStr const language = current && compare ? 'json' : undefined - return this.renderDiff(current, compare, language) + return this.renderDiff(str, compareStr, language) } } @@ -75,13 +84,9 @@ class ValueRenderer extends React.Component { ) } - private messageToPrettyJson(message?: q.Message): string | undefined { - if (!message || !message.value) { - return undefined - } - + private messageToPrettyJson(str: string): string | undefined { try { - const json = JSON.parse(message.value) + const json = JSON.parse(str) return JSON.stringify(json, undefined, ' ') } catch { return undefined diff --git a/app/src/components/Tree/TreeNodeTitle.tsx b/app/src/components/Tree/TreeNodeTitle.tsx index c675604..223ba17 100644 --- a/app/src/components/Tree/TreeNodeTitle.tsx +++ b/app/src/components/Tree/TreeNodeTitle.tsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux' import * as q from '../../../../backend/src/Model' import { withStyles, Theme } from '@material-ui/core' import { TopicViewModel } from '../../TopicViewModel' +import { Base64Message } from '../../../../backend/src/Model/Base64Message'; const debounce = require('lodash.debounce') export interface TreeNodeProps extends React.HTMLAttributes { @@ -35,8 +36,8 @@ class TreeNodeTitle extends React.Component { } private renderValue() { - return this.props.treeNode.message && this.props.treeNode.message.length > 0 - ? = {this.props.treeNode.message.value.toString().slice(0, 120)} + return this.props.treeNode.message && this.props.treeNode.message.value && this.props.treeNode.message.length > 0 + ? = {Base64Message.toUnicodeString(this.props.treeNode.message.value).toString().slice(0, 120)} : null } diff --git a/app/yarn.lock b/app/yarn.lock index bb10be3..d54f37e 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -120,6 +120,16 @@ resolved "https://registry.yarnpkg.com/@types/diff/-/diff-4.0.1.tgz#471950ff97e2205ab02404bd7ac960fac074d886" integrity sha512-BFob98KGKIqjcJjbmO/g+daJinDOtSUs27e0NTirODSUHYhUUAVXmq/nKIrmRFsOHrOWj5mncvByrzyw1X1PNQ== +"@types/get-value@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/get-value/-/get-value-3.0.1.tgz#67da2663d2a632fb2ed33693479176b2841749ca" + integrity sha512-Pla+0sjwKHH9d5aejg9hSXd+NxlKnaLutTpckENtCNTnrC+EmAsQsqhp+WJX8LgOBDDiRtiBj5kX8PwAfL+KdQ== + +"@types/js-base64@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/js-base64/-/js-base64-2.3.1.tgz#c39f14f129408a3d96a1105a650d8b2b6eeb4168" + integrity sha512-4RKbhIDGC87s4EBy2Cp2/5S2O6kmCRcZnD5KRCq1q9z2GhBte1+BdsfVKCpG8yKpDGNyEE2G6IqFIh6W2YwWPA== + "@types/jss@^9.5.6": version "9.5.8" resolved "https://registry.yarnpkg.com/@types/jss/-/jss-9.5.8.tgz#258391f42211c042fc965508d505cbdc579baa5b" @@ -1062,6 +1072,11 @@ clsx@^1.0.2: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.3.tgz#e164004f4064b372888f20fdafbd436fb960bac9" integrity sha512-xLoSw6DMp7YvbEeLrQJBcWWRRerdHrU1WHoL1hYJOKUeDpVMRq7pv7NI2JHQbCRAe5ptINNzhdYmtfN6MsdCUw== +code-error-fragment@0.0.230: + version "0.0.230" + resolved "https://registry.yarnpkg.com/code-error-fragment/-/code-error-fragment-0.0.230.tgz#d736d75c832445342eca1d1fedbf17d9618b14d7" + integrity sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -2387,6 +2402,13 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= +get-value@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-3.0.1.tgz#5efd2a157f1d6a516d7524e124ac52d0a39ef5a8" + integrity sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA== + dependencies: + isobject "^3.0.1" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -2465,6 +2487,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gzip-size@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.0.0.tgz#a55ecd99222f4c48fd8c01c625ce3b349d0a0e80" @@ -3094,6 +3121,11 @@ jquery@^3.3.1: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" integrity sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg== +js-base64@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121" + integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw== + "js-tokens@^3.0.0 || ^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3129,6 +3161,14 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= +json-to-ast@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json-to-ast/-/json-to-ast-2.1.0.tgz#041a9fcd03c0845036acb670d29f425cea4faaf9" + integrity sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ== + dependencies: + code-error-fragment "0.0.230" + grapheme-splitter "^1.0.4" + json3@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" diff --git a/backend/src/DataSource/MqttSource.ts b/backend/src/DataSource/MqttSource.ts index 46faaf3..4f50fa6 100644 --- a/backend/src/DataSource/MqttSource.ts +++ b/backend/src/DataSource/MqttSource.ts @@ -3,6 +3,7 @@ import * as Url from 'url' import { Client, connect as mqttConnect } from 'mqtt' import { DataSource, DataSourceStateMachine } from './' import { MqttMessage } from '../../../events' +import { Base64Message } from '../Model/Base64Message'; export interface MqttOptions { url: string @@ -84,7 +85,12 @@ export class MqttSource implements DataSource { } public publish(msg: MqttMessage) { - this.client && this.client.publish(msg.topic, msg.payload, { qos: msg.qos, retain: msg.retain }) + if (this.client) { + this.client.publish( + msg.topic, + msg.payload ? Base64Message.toUnicodeString(msg.payload) : '', + { qos: msg.qos, retain: msg.retain }) + } } public disconnect() { diff --git a/backend/src/Model/Base64Message.ts b/backend/src/Model/Base64Message.ts new file mode 100644 index 0000000..1470c9a --- /dev/null +++ b/backend/src/Model/Base64Message.ts @@ -0,0 +1,27 @@ +import { Base64 } from 'js-base64' + +export class Base64Message { + private base64Message: string + public length: number + + private constructor(base64Str: string) { + this.base64Message = base64Str + this.length = base64Str.length + } + + public static toUnicodeString(message: Base64Message) { + return Base64.decode(message.base64Message) + } + + public static fromBuffer(buffer: Buffer) { + return new Base64Message(buffer.toString('base64')) + } + + public static fromString(str: string) { + return new Base64Message(Base64.encode(str)) + } + + public static toDataUri(message: Base64Message, mimeType: string) { + return `data:${mimeType};base64,${message.base64Message}` + } +} diff --git a/backend/src/Model/Message.ts b/backend/src/Model/Message.ts index 14a5f66..85bc5a8 100644 --- a/backend/src/Model/Message.ts +++ b/backend/src/Model/Message.ts @@ -1,5 +1,7 @@ +import { Base64Message } from './Base64Message' + export interface Message { - value?: any | undefined + value?: Base64Message length: number received: Date } diff --git a/backend/src/Model/Tree.ts b/backend/src/Model/Tree.ts index 67932cb..33b2b35 100644 --- a/backend/src/Model/Tree.ts +++ b/backend/src/Model/Tree.ts @@ -32,7 +32,7 @@ export class Tree extends TreeNode { public applyUnmergedChanges() { this.unmergedMessages.forEach((msg) => { const edges = msg.topic.split('/') - const node = TreeNodeFactory.fromEdgesAndValue(edges, msg.payload) + const node = TreeNodeFactory.fromEdgesAndValue(edges, msg.payload) node.mqttMessage = msg if (!this.nodeFilter || this.nodeFilter(node)) { diff --git a/backend/src/Model/TreeNodeFactory.ts b/backend/src/Model/TreeNodeFactory.ts index 305637c..7b47534 100644 --- a/backend/src/Model/TreeNodeFactory.ts +++ b/backend/src/Model/TreeNodeFactory.ts @@ -1,3 +1,4 @@ +import { Base64Message } from './Base64Message' import { Edge, Tree, TreeNode } from './' interface HasLength { @@ -18,10 +19,10 @@ export abstract class TreeNodeFactory { node.sourceEdge!.target = node } - public static fromEdgesAndValue(edgeNames: string[], value?: T): TreeNode { + public static fromEdgesAndValue(edgeNames: string[], value?: Base64Message | null): TreeNode { const node = new TreeNode() node.setMessage({ - value, + value: value || undefined, length: value ? value.length : 0, received: new Date(), }) diff --git a/events/Events.ts b/events/Events.ts index c1e270e..8c9b9d9 100644 --- a/events/Events.ts +++ b/events/Events.ts @@ -1,6 +1,7 @@ import { DataSourceState, MqttOptions } from '../backend/src/DataSource' import { UpdateInfo } from 'builder-util-runtime' +import { Base64Message } from '../backend/src/Model/Base64Message'; export { UpdateInfo } from 'builder-util-runtime' @@ -37,7 +38,7 @@ export const updateAvailable: Event = { export interface MqttMessage { topic: string, - payload: any, + payload: Base64Message | null, qos: 0 | 1 | 2, retain: boolean } diff --git a/package.json b/package.json index f5454f2..4edc0cf 100644 --- a/package.json +++ b/package.json @@ -86,11 +86,13 @@ "webdriverio": "5.4" }, "dependencies": { + "@types/js-base64": "^2.3.1", "about-window": "^1.12.1", "electron-is-dev": "^1.0.1", "electron-log": "^2.2.17", "electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git#dist", "electron-updater": "^4.0.6", + "js-base64": "^2.5.1", "lowdb": "^1.0.0", "mqtt": "^2.18.8", "sha1": "^1.1.1" diff --git a/yarn.lock b/yarn.lock index bb8f070..cc27b88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -126,6 +126,11 @@ dependencies: "@types/node" "*" +"@types/js-base64@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/js-base64/-/js-base64-2.3.1.tgz#c39f14f129408a3d96a1105a650d8b2b6eeb4168" + integrity sha512-4RKbhIDGC87s4EBy2Cp2/5S2O6kmCRcZnD5KRCq1q9z2GhBte1+BdsfVKCpG8yKpDGNyEE2G6IqFIh6W2YwWPA== + "@types/lodash@*": version "4.14.121" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.121.tgz#9327e20d49b95fc2bf983fc2f045b2c6effc80b9" @@ -2090,6 +2095,11 @@ istanbul-reports@^2.0.1: dependencies: handlebars "^4.0.11" +js-base64@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121" + integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw== + js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"