diff --git a/.sentryclirc b/.sentryclirc new file mode 100644 index 000000000..4f553c225 --- /dev/null +++ b/.sentryclirc @@ -0,0 +1,4 @@ +[defaults] +url = https://sentry.io/ +org = lbry +project = lbry-desktop \ No newline at end of file diff --git a/lbrytv/webpack.config.js b/lbrytv/webpack.config.js index 679d5100e..a987f442f 100644 --- a/lbrytv/webpack.config.js +++ b/lbrytv/webpack.config.js @@ -4,11 +4,53 @@ const merge = require('webpack-merge'); const baseConfig = require('../webpack.base.config.js'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const { DefinePlugin, ProvidePlugin } = require('webpack'); +const SentryWebpackPlugin = require('@sentry/webpack-plugin'); const STATIC_ROOT = path.resolve(__dirname, '../static/'); const UI_ROOT = path.resolve(__dirname, '../ui/'); const DIST_ROOT = path.resolve(__dirname, 'dist/'); const WEB_PLATFORM_ROOT = __dirname; +const isProduction = process.env.NODE_ENV === 'production'; +const hasSentryToken = process.env.SENTRY_AUTH_TOKEN !== undefined; + +let plugins = [ + new CopyWebpackPlugin([ + { + from: `${STATIC_ROOT}/index-web.html`, + to: `${DIST_ROOT}/index.html`, + }, + { + from: `${STATIC_ROOT}/img/favicon.png`, + to: `${DIST_ROOT}/public/favicon.png`, + }, + { + from: `${STATIC_ROOT}/img/v1-og.png`, + to: `${DIST_ROOT}/public/v1-og.png`, + }, + { + from: `${STATIC_ROOT}/font/`, + to: `${DIST_ROOT}/public/font/`, + }, + ]), + new DefinePlugin({ + IS_WEB: JSON.stringify(true), + 'process.env.SDK_API_URL': JSON.stringify(process.env.SDK_API_URL || LBRY_TV_API), + }), + new ProvidePlugin({ + __: ['i18n.js', '__'], + }), +]; + +if (isProduction && hasSentryToken) { + plugins.push( + new SentryWebpackPlugin({ + include: './dist', + ignoreFile: '.sentrycliignore', + ignore: ['node_modules', 'webpack.config.js'], + configFile: 'sentry.properties', + }) + ); +} const webConfig = { target: 'web', @@ -17,7 +59,7 @@ const webConfig = { }, output: { filename: '[name].js', - path: __dirname + '/dist/public/', + path: path.join(__dirname, 'dist/public/'), publicPath: '/public/', }, devServer: { @@ -55,33 +97,7 @@ const webConfig = { fs: `${WEB_PLATFORM_ROOT}/stubs/fs.js`, }, }, - plugins: [ - new CopyWebpackPlugin([ - { - from: `${STATIC_ROOT}/index-web.html`, - to: `${DIST_ROOT}/index.html`, - }, - { - from: `${STATIC_ROOT}/img/favicon.png`, - to: `${DIST_ROOT}/public/favicon.png`, - }, - { - from: `${STATIC_ROOT}/img/v1-og.png`, - to: `${DIST_ROOT}/public/v1-og.png`, - }, - { - from: `${STATIC_ROOT}/font/`, - to: `${DIST_ROOT}/public/font/`, - }, - ]), - new DefinePlugin({ - IS_WEB: JSON.stringify(true), - 'process.env.SDK_API_URL': JSON.stringify(process.env.SDK_API_URL || LBRY_TV_API), - }), - new ProvidePlugin({ - __: ['i18n.js', '__'], - }), - ], + plugins, }; module.exports = merge(baseConfig, webConfig); diff --git a/package.json b/package.json index 87500b5c8..20c3be09e 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ }, "main": "./dist/electron/main.js", "scripts": { - "compile:electron": "webpack --config webpack.electron.config.js", + "compile:electron": "node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js --config webpack.electron.config.js", "compile:web": "cd ./lbrytv && webpack --config webpack.config.js", "compile": "cross-env NODE_ENV=production yarn compile:electron && cross-env NODE_ENV=production yarn compile:web", "dev": "yarn dev:electron", "dev:electron": "cross-env NODE_ENV=development node ./electron/devServer.js", - "dev:web": "cd ./lbrytv && yarn dev", + "dev:web": "yarn compile:web && cd ./lbrytv && yarn dev", "dev:web-server": "cross-env NODE_ENV=development yarn compile:web && concurrently \"cross-env NODE_ENV=development yarn compile:web --watch\" \"cd ./lbrytv && yarn dev:server\"", "dev:internal-apis": "LBRY_API_URL='http://localhost:8080' yarn dev:electron", "dev:iatv": "LBRY_API_URL='http://localhost:15400' SDK_API_URL='http://localhost:15100' yarn dev:web", @@ -72,6 +72,8 @@ "@reach/menu-button": "^0.1.18", "@reach/rect": "^0.2.1", "@reach/tabs": "^0.1.5", + "@sentry/browser": "^5.12.1", + "@sentry/webpack-plugin": "^1.10.0", "@types/three": "^0.93.1", "adm-zip": "^0.4.13", "async-exit-hook": "^2.0.1", diff --git a/ui/analytics.js b/ui/analytics.js index 4aea724db..4e645deb2 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -1,6 +1,7 @@ // @flow import { Lbryio } from 'lbryinc'; import ReactGA from 'react-ga'; +import * as Sentry from '@sentry/browser'; import { history } from './store'; // @if TARGET='app' import Native from 'native'; @@ -21,7 +22,7 @@ ElectronCookies.enable({ // @endif type Analytics = { - error: string => void, + error: ({}, {}) => Promise, pageView: string => void, setUser: Object => void, toggle: (boolean, ?boolean) => void, @@ -48,10 +49,18 @@ type LogPublishParams = { let analyticsEnabled: boolean = isProduction; const analytics: Analytics = { - error: message => { - if (analyticsEnabled && isProduction) { - Lbryio.call('event', 'desktop_error', { error_message: message }); - } + error: (error, errorInfo) => { + return new Promise(resolve => { + if (analyticsEnabled && isProduction) { + Sentry.withScope(scope => { + scope.setExtras(errorInfo); + const eventId = Sentry.captureException(error); + resolve(eventId); + }); + } else { + resolve(null); + } + }); }, pageView: path => { if (analyticsEnabled) { diff --git a/ui/component/errorBoundary/view.jsx b/ui/component/errorBoundary/view.jsx index cbcc3c783..bb0876351 100644 --- a/ui/component/errorBoundary/view.jsx +++ b/ui/component/errorBoundary/view.jsx @@ -4,8 +4,6 @@ import React from 'react'; import Yrbl from 'component/yrbl'; import Button from 'component/button'; import { withRouter } from 'react-router'; -import Native from 'native'; -import { Lbry } from 'lbry-redux'; import analytics from 'analytics'; import I18nMessage from 'component/i18nMessage'; @@ -18,12 +16,13 @@ type Props = { type State = { hasError: boolean, + eventId: ?string, }; class ErrorBoundary extends React.Component { constructor() { super(); - this.state = { hasError: false }; + this.state = { hasError: false, eventId: undefined }; (this: any).refresh = this.refresh.bind(this); } @@ -32,38 +31,24 @@ class ErrorBoundary extends React.Component { return { hasError: true }; } - componentDidCatch(error: { stack: string }) { - let errorMessage = 'Uncaught error\n'; - - // @if TARGET='web' - errorMessage += 'lbry.tv\n'; - errorMessage += `page: ${window.location.pathname + window.location.search}\n`; - errorMessage += error.stack; - analytics.error(errorMessage); - - // @endif - // @if TARGET='app' - Native.getAppVersionInfo().then(({ localVersion }) => { - Lbry.version().then(({ lbrynet_version: sdkVersion }) => { - errorMessage += `app version: ${localVersion}\n`; - errorMessage += `sdk version: ${sdkVersion}\n`; - errorMessage += `page: ${window.location.href.split('.html')[1]}\n`; - errorMessage += `${error.stack}`; - analytics.error(errorMessage); - }); + componentDidCatch(error, errorInfo) { + analytics.error(error, errorInfo).then(eventId => { + this.setState({ eventId }); }); - // @endif } refresh() { const { history } = this.props; + // use history.replace instead of history.push so the user can't click back to the errored page history.replace(''); this.setState({ hasError: false }); } render() { - if (this.state.hasError) { + const { hasError, eventId } = this.state; + + if (hasError) { return (
{ ), }} > - There was an error. It's been reported and will be fixed. Try %refreshing_the_app_link% to fix it. If - that doesn't work, try pressing Ctrl+R/Cmd+R. + There was an error. Try %refreshing_the_app_link% to fix it. If that doesn't work, try pressing + Ctrl+R/Cmd+R. } /> + {eventId === null && ( +
+ + {__('You are not currently sharing diagnostic data so this error was not reported.')} + +
+ )} + {eventId && ( +
+ {__('Error ID: %eventId%', { eventId })} +
+ )}
); } diff --git a/ui/component/sideNavigation/view.jsx b/ui/component/sideNavigation/view.jsx index a337a5fe5..e724377e9 100644 --- a/ui/component/sideNavigation/view.jsx +++ b/ui/component/sideNavigation/view.jsx @@ -8,6 +8,7 @@ import Tag from 'component/tag'; import StickyBox from 'react-sticky-box/dist/esnext'; import Spinner from 'component/spinner'; import usePersistedState from 'effects/use-persisted-state'; +import useIsMobile from 'effects/use-is-mobile'; // @if TARGET='web' import Ads from 'lbrytv/component/ads'; // @endif @@ -41,6 +42,7 @@ function SideNavigation(props: Props) { } = props; const { pathname } = location; const isAuthenticated = Boolean(email); + const isMobile = useIsMobile(); const [sideInformation, setSideInformation] = usePersistedState( 'side-navigation:information', getSideInformation(pathname) @@ -87,7 +89,7 @@ function SideNavigation(props: Props) { // @if TARGET='web' if (obscureSideNavigation) { - return ( + return isMobile ? null : ( diff --git a/ui/index.jsx b/ui/index.jsx index 570b616dc..85ba75901 100644 --- a/ui/index.jsx +++ b/ui/index.jsx @@ -1,5 +1,5 @@ import 'babel-polyfill'; - +import * as Sentry from '@sentry/browser'; import ErrorBoundary from 'component/errorBoundary'; import App from 'component/app'; import SnackBar from 'component/snackBar'; @@ -43,6 +43,14 @@ import 'scss/all.scss'; // These overrides can't live in lbrytv/ because they need to use the same instance of `Lbry` import apiPublishCallViaWeb from 'lbrytv/setup/publish'; +// Sentry error logging setup +// Will only work if you have a SENTRY_AUTH_TOKEN env +// We still add code in analytics.js to send the error to sentry manually +// If it's caught by componentDidCatch in component/errorBoundary, it will not bubble up to this error reporter +if (process.env.NODE_ENV === 'production') { + Sentry.init({ dsn: 'https://f93af3fa9c94470d9a0a22602cce3154@sentry.io/1877677' }); +} + const PROXY_PATH = 'api/v1/proxy'; export const SDK_API_URL = `${process.env.SDK_API_URL}/${PROXY_PATH}` || `https://api.lbry.tv/${PROXY_PATH}`; diff --git a/ui/scss/init/_gui.scss b/ui/scss/init/_gui.scss index 73e2f4dea..654e7f32f 100644 --- a/ui/scss/init/_gui.scss +++ b/ui/scss/init/_gui.scss @@ -231,6 +231,12 @@ a { } } +.error-wrapper { + background-color: var(--color-error); + padding: var(--spacing-small); + border-radius: var(--border-radius); +} + .error-text { color: var(--color-text-error); } diff --git a/webpack.base.config.js b/webpack.base.config.js index c913b6b23..02101840c 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -12,14 +12,12 @@ const STATIC_ROOT = path.resolve(__dirname, 'static/'); let baseConfig = { mode: ifProduction('production', 'development'), - devtool: ifProduction(false, 'eval-source-map'), + devtool: ifProduction('source-map', 'eval-cheap-source-map'), optimization: { minimizer: [ new TerserPlugin({ parallel: true, - terserOptions: { - mangle: true, - }, + sourceMap: true, }), ], }, @@ -87,6 +85,7 @@ let baseConfig = { __static: `"${path.join(__dirname, 'static').replace(/\\/g, '\\\\')}"`, 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), 'process.env.LBRY_API_URL': JSON.stringify(process.env.LBRY_API_URL), + 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), }), ], }; diff --git a/webpack.electron.config.js b/webpack.electron.config.js index d3131decd..32527f885 100644 --- a/webpack.electron.config.js +++ b/webpack.electron.config.js @@ -83,6 +83,26 @@ if (process.env.NODE_ENV === 'production') { }); } +let plugins = [ + new DefinePlugin({ + IS_WEB: JSON.stringify(false), + }), + new ProvidePlugin({ + __: ['i18n.js', '__'], + }), +]; + +// if (hasSentryToken) { +// plugins.push( +// new SentryWebpackPlugin({ +// include: './dist', +// ignoreFile: '.sentrycliignore', +// ignore: ['node_modules', 'webpack.config.js', 'webworkers'], +// configFile: 'sentry.properties', +// }) +// ); +// } + const renderConfig = { target: 'electron-renderer', entry: { @@ -110,15 +130,7 @@ const renderConfig = { }, ], }, - plugins: [ - // new BundleAnalyzerPlugin(), - new DefinePlugin({ - IS_WEB: JSON.stringify(false), - }), - new ProvidePlugin({ - __: ['i18n.js', '__'], - }), - ], + plugins, }; module.exports = [merge(baseConfig, mainConfig), merge(baseConfig, renderConfig)]; diff --git a/yarn.lock b/yarn.lock index f05d4b61a..24a08d67f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1140,6 +1140,77 @@ dependencies: any-observable "^0.3.0" +"@sentry/browser@^5.12.1": + version "5.12.1" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.12.1.tgz#dc1f268595269fb7277f55eb625c7e92d76dc01b" + integrity sha512-Zl7VdppUxctyaoqMSEhnDJp2rrupx8n8N2n3PSooH74yhB2Z91nt84mouczprBsw3JU1iggGyUw9seRFzDI1hw== + dependencies: + "@sentry/core" "5.12.0" + "@sentry/types" "5.12.0" + "@sentry/utils" "5.12.0" + tslib "^1.9.3" + +"@sentry/cli@^1.49.0": + version "1.49.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.49.0.tgz#174152978acbe6023986a8fb0b247cf58b4653d8" + integrity sha512-Augz7c42Cxz/xWQ/NOVjUGePKVA370quvskWbCICMUwxcTvKnCLI+7KDdzEoCexj4MSuxFfBzLnrrn4w2+c9TQ== + dependencies: + fs-copy-file-sync "^1.1.1" + https-proxy-agent "^3.0.0" + mkdirp "^0.5.1" + node-fetch "^2.1.2" + progress "2.0.0" + proxy-from-env "^1.0.0" + +"@sentry/core@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.12.0.tgz#d6380c4ef7beee5f418ac1d0e5be86a2de2af449" + integrity sha512-wY4rsoX71QsGpcs9tF+OxKgDPKzIFMRvFiSRcJoPMfhFsTilQ/CBMn/c3bDtWQd9Bnr/ReQIL6NbnIjUsPHA4Q== + dependencies: + "@sentry/hub" "5.12.0" + "@sentry/minimal" "5.12.0" + "@sentry/types" "5.12.0" + "@sentry/utils" "5.12.0" + tslib "^1.9.3" + +"@sentry/hub@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.12.0.tgz#5e8c8f249f5bdbeb8cc4ec02c2ccc53a67f2cc02" + integrity sha512-3k7yE8BEVJsKx8mR4LcI4IN0O8pngmq44OcJ/fRUUBAPqsT38jsJdP2CaWhdlM1jiNUzUDB1ktBv6/lY+VgcoQ== + dependencies: + "@sentry/types" "5.12.0" + "@sentry/utils" "5.12.0" + tslib "^1.9.3" + +"@sentry/minimal@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.12.0.tgz#2611e2aa520c1edb7999e6de51bd65ec66341757" + integrity sha512-fk73meyz4k4jCg9yzbma+WkggsfEIQWI2e2TWfYsRGcrV3RnlSrXyM4D91/A8Bjx10SNezHPUFHjasjlHXOkyA== + dependencies: + "@sentry/hub" "5.12.0" + "@sentry/types" "5.12.0" + tslib "^1.9.3" + +"@sentry/types@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.12.0.tgz#5367e53c74261beea01502e3f7b6f3d822682a31" + integrity sha512-aZbBouBLrKB8wXlztriIagZNmsB+wegk1Jkl6eprqRW/w24Sl/47tiwH8c5S4jYTxdAiJk+SAR10AAuYmIN3zg== + +"@sentry/utils@5.12.0": + version "5.12.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.12.0.tgz#62967f934a3ee6d21472eac0219084e37225933e" + integrity sha512-fYUadGLbfTCbs4OG5hKCOtv2jrNE4/8LHNABy9DwNJ/t5DVtGqWAZBnxsC+FG6a3nVqCpxjFI9AHlYsJ2wsf7Q== + dependencies: + "@sentry/types" "5.12.0" + tslib "^1.9.3" + +"@sentry/webpack-plugin@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-1.10.0.tgz#7f7727b18dbdd3eaeb0998102be89d8733ddbca5" + integrity sha512-keT6cH8732bFjdH/v+C/UwbJu6byZ5L8t7QRLjgj+fqDyc/RlVEw9VzIQSJGUtd2XgImpdduWzgxythLkwKJjg== + dependencies: + "@sentry/cli" "^1.49.0" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -1462,6 +1533,13 @@ agent-base@4, agent-base@^4.1.0: dependencies: es6-promisify "^5.0.0" +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + ajv-errors@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" @@ -5293,6 +5371,11 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-copy-file-sync@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fs-copy-file-sync/-/fs-copy-file-sync-1.1.1.tgz#11bf32c096c10d126e5f6b36d06eece776062918" + integrity sha512-2QY5eeqVv4m2PfyMiEuy9adxNP+ajf+8AR05cEi+OAzPcOj90hvFImeZhTmKLBgSd9EvG33jsD7ZRxsx9dThkQ== + fs-extra@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" @@ -6055,6 +6138,14 @@ https-proxy-agent@^2.2.0: agent-base "^4.1.0" debug "^3.1.0" +https-proxy-agent@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" + integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + humanize-plus@^1.8.1: version "1.8.2" resolved "https://registry.yarnpkg.com/humanize-plus/-/humanize-plus-1.8.2.tgz#a65b34459ad6367adbb3707a82a3c9f916167030" @@ -8060,7 +8151,7 @@ node-emoji@^1.8.1: dependencies: lodash.toarray "^4.4.0" -node-fetch@^2.1.1, node-fetch@^2.3.0: +node-fetch@^2.1.1, node-fetch@^2.1.2, node-fetch@^2.3.0: version "2.6.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== @@ -9739,6 +9830,11 @@ progress-stream@^1.1.0: speedometer "~0.1.2" through2 "~0.2.3" +progress@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" + integrity sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8= + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -9771,6 +9867,11 @@ proxy-addr@~2.0.5: forwarded "~0.1.2" ipaddr.js "1.9.0" +proxy-from-env@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" + integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4= + proxy-polyfill@0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/proxy-polyfill/-/proxy-polyfill-0.1.6.tgz#ef41ec6c66f534db15db36c54493a62d184b364e" @@ -12145,7 +12246,7 @@ tryer@^1.0.0: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== -tslib@^1.9.0: +tslib@^1.9.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==