diff --git a/packages/browser/package.json b/packages/browser/package.json index cbbc58d458da..3af056d1cc9b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -27,6 +27,7 @@ "btoa": "^1.2.1", "chai": "^4.1.2", "chokidar": "^3.0.2", + "fake-indexeddb": "^4.0.1", "karma": "^6.3.16", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^2.2.0", diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 8185cbc6259e..381055dcbef0 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -21,10 +21,14 @@ const INTEGRATIONS = { export { INTEGRATIONS as Integrations }; // DO NOT DELETE THESE COMMENTS! -// We want to exclude Replay from CDN bundles, so we remove the block below with our -// excludeReplay Rollup plugin when generating bundles. Everything between -// ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN and _END__ is removed for bundles. +// We want to exclude Replay/Offline from CDN bundles, so we remove the block below with our +// makeExcludeBlockPlugin Rollup plugin when generating bundles. Everything between +// ROLLUP_EXCLUDE_*_FROM_BUNDLES_BEGIN and _END__ is removed for bundles. -// __ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN__ +// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_BEGIN__ export { Replay } from '@sentry/replay'; -// __ROLLUP_EXCLUDE_FROM_BUNDLES_END__ +// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_END__ + +// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_BEGIN__ +export { makeBrowserOfflineTransport } from './transports/offline'; +// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_END__ diff --git a/packages/browser/src/transports/offline.ts b/packages/browser/src/transports/offline.ts new file mode 100644 index 000000000000..9bb3e5dbfe2e --- /dev/null +++ b/packages/browser/src/transports/offline.ts @@ -0,0 +1,158 @@ +import type { OfflineStore, OfflineTransportOptions } from '@sentry/core'; +import { makeOfflineTransport } from '@sentry/core'; +import type { Envelope, InternalBaseTransportOptions, Transport } from '@sentry/types'; +import type { TextDecoderInternal } from '@sentry/utils'; +import { parseEnvelope, serializeEnvelope } from '@sentry/utils'; + +// 'Store', 'promisifyRequest' and 'createStore' were originally copied from the 'idb-keyval' package before being +// modified and simplified: https://github.com/jakearchibald/idb-keyval +// +// At commit: 0420a704fd6cbb4225429c536b1f61112d012fca +// Original licence: + +// Copyright 2016, Jake Archibald +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +type Store = (callback: (store: IDBObjectStore) => T | PromiseLike) => Promise; + +function promisifyRequest(request: IDBRequest | IDBTransaction): Promise { + return new Promise((resolve, reject) => { + // @ts-ignore - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-ignore - file size hacks + request.onabort = request.onerror = () => reject(request.error); + }); +} + +/** Create or open an IndexedDb store */ +export function createStore(dbName: string, storeName: string): Store { + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + const dbp = promisifyRequest(request); + + return callback => dbp.then(db => callback(db.transaction(storeName, 'readwrite').objectStore(storeName))); +} + +function keys(store: IDBObjectStore): Promise { + return promisifyRequest(store.getAllKeys() as IDBRequest); +} + +/** Insert into the store */ +export function insert(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise { + return store(store => { + return keys(store).then(keys => { + if (keys.length >= maxQueueSize) { + return; + } + + // We insert with an incremented key so that the entries are popped in order + store.put(value, Math.max(...keys, 0) + 1); + return promisifyRequest(store.transaction); + }); + }); +} + +/** Pop the oldest value from the store */ +export function pop(store: Store): Promise { + return store(store => { + return keys(store).then(keys => { + if (keys.length === 0) { + return undefined; + } + + return promisifyRequest(store.get(keys[0])).then(value => { + store.delete(keys[0]); + return promisifyRequest(store.transaction).then(() => value); + }); + }); + }); +} + +interface BrowserOfflineTransportOptions extends OfflineTransportOptions { + /** + * Name of indexedDb database to store envelopes in + * Default: 'sentry-offline' + */ + dbName?: string; + /** + * Name of indexedDb object store to store envelopes in + * Default: 'queue' + */ + storeName?: string; + /** + * Maximum number of envelopes to store + * Default: 30 + */ + maxQueueSize?: number; + /** + * Only required for testing on node.js + * @ignore + */ + textDecoder?: TextDecoderInternal; +} + +function createIndexedDbStore(options: BrowserOfflineTransportOptions): OfflineStore { + let store: Store | undefined; + + // Lazily create the store only when it's needed + function getStore(): Store { + if (store == undefined) { + store = createStore(options.dbName || 'sentry-offline', options.storeName || 'queue'); + } + + return store; + } + + return { + insert: async (env: Envelope) => { + try { + const serialized = await serializeEnvelope(env, options.textEncoder); + await insert(getStore(), serialized, options.maxQueueSize || 30); + } catch (_) { + // + } + }, + pop: async () => { + try { + const deserialized = await pop(getStore()); + if (deserialized) { + return parseEnvelope( + deserialized, + options.textEncoder || new TextEncoder(), + options.textDecoder || new TextDecoder(), + ); + } + } catch (_) { + // + } + + return undefined; + }, + }; +} + +function makeIndexedDbOfflineTransport( + createTransport: (options: T) => Transport, +): (options: T & BrowserOfflineTransportOptions) => Transport { + return options => createTransport({ ...options, createStore: createIndexedDbStore }); +} + +/** + * Creates a transport that uses IndexedDb to store events when offline. + */ +export function makeBrowserOfflineTransport( + createTransport: (options: T) => Transport, +): (options: T & BrowserOfflineTransportOptions) => Transport { + return makeIndexedDbOfflineTransport(makeOfflineTransport(createTransport)); +} diff --git a/packages/browser/test/unit/transports/offline.test.ts b/packages/browser/test/unit/transports/offline.test.ts new file mode 100644 index 000000000000..b224df725bc6 --- /dev/null +++ b/packages/browser/test/unit/transports/offline.test.ts @@ -0,0 +1,111 @@ +import 'fake-indexeddb/auto'; + +import { createTransport } from '@sentry/core'; +import type { + EventEnvelope, + EventItem, + InternalBaseTransportOptions, + TransportMakeRequestResponse, +} from '@sentry/types'; +import { createEnvelope } from '@sentry/utils'; +import { TextDecoder, TextEncoder } from 'util'; + +import { MIN_DELAY } from '../../../../core/src/transports/offline'; +import { createStore, insert, makeBrowserOfflineTransport, pop } from '../../../src/transports/offline'; + +function deleteDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const transportOptions = { + recordDroppedEvent: () => undefined, // noop + textEncoder: new TextEncoder(), + textDecoder: new TextDecoder(), +}; + +type MockResult = T | Error; + +export const createTestTransport = (...sendResults: MockResult[]) => { + let sendCount = 0; + + return { + getSendCount: () => sendCount, + baseTransport: (options: InternalBaseTransportOptions) => + createTransport(options, () => { + return new Promise((resolve, reject) => { + const next = sendResults.shift(); + + if (next instanceof Error) { + reject(next); + } else { + sendCount += 1; + resolve(next as TransportMakeRequestResponse | undefined); + } + }); + }), + }; +}; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +describe('makeOfflineTransport', () => { + beforeAll(async () => { + await deleteDatabase('sentry'); + }); + + it('indexedDb wrappers insert and pop', async () => { + const store = createStore('test', 'test'); + const found = await pop(store); + expect(found).toBeUndefined(); + + await insert(store, 'test1', 30); + await insert(store, new Uint8Array([1, 2, 3, 4, 5]), 30); + + const found2 = await pop(store); + expect(found2).toEqual('test1'); + const found3 = await pop(store); + expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + + const found4 = await pop(store); + expect(found4).toBeUndefined(); + }); + + it('Queues and retries envelope if wrapped transport throws error', async () => { + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }, { statusCode: 200 }); + let queuedCount = 0; + const transport = makeBrowserOfflineTransport(baseTransport)({ + ...transportOptions, + shouldStore: () => { + queuedCount += 1; + return true; + }, + }); + const result = await transport.send(ERROR_ENVELOPE); + + expect(result).toEqual({}); + + await delay(MIN_DELAY * 2); + + expect(getSendCount()).toEqual(0); + expect(queuedCount).toEqual(1); + + // Sending again will retry the queued envelope too + const result2 = await transport.send(ERROR_ENVELOPE); + expect(result2).toEqual({ statusCode: 200 }); + + await delay(MIN_DELAY * 2); + + expect(queuedCount).toEqual(1); + expect(getSendCount()).toEqual(2); + }); +}); diff --git a/packages/integration-tests/suites/transport/offline/init.js b/packages/integration-tests/suites/transport/offline/init.js new file mode 100644 index 000000000000..a69f8a32b32a --- /dev/null +++ b/packages/integration-tests/suites/transport/offline/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport), +}); diff --git a/packages/integration-tests/suites/transport/offline/queued/subject.js b/packages/integration-tests/suites/transport/offline/queued/subject.js new file mode 100644 index 000000000000..9075d7bac69a --- /dev/null +++ b/packages/integration-tests/suites/transport/offline/queued/subject.js @@ -0,0 +1,3 @@ +setTimeout(() => { + Sentry.captureMessage(`foo ${Math.random()}`); +}, 500); diff --git a/packages/integration-tests/suites/transport/offline/queued/test.ts b/packages/integration-tests/suites/transport/offline/queued/test.ts new file mode 100644 index 000000000000..61020eceb79b --- /dev/null +++ b/packages/integration-tests/suites/transport/offline/queued/test.ts @@ -0,0 +1,51 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests } from '../../../../utils/helpers'; + +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +sentryTest('should queue and retry events when they fail to send', async ({ getLocalTestPath, page }) => { + // makeBrowserOfflineTransport is not included in any CDN bundles + if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_')) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + // This would be the obvious way to test offline support but it doesn't appear to work! + // await context.setOffline(true); + + let abortedCount = 0; + + // Abort all envelope requests so the event gets queued + await page.route(/ingest\.sentry\.io/, route => { + abortedCount += 1; + return route.abort(); + }); + await page.goto(url); + await delay(1_000); + await page.unroute(/ingest\.sentry\.io/); + + expect(abortedCount).toBe(1); + + // The previous event should now be queued + + // This will force the page to be reloaded and a new event to be sent + const eventData = await getMultipleSentryEnvelopeRequests(page, 3, { url, timeout: 10_000 }); + + // Filter out any client reports + const events = eventData.filter(e => !('discarded_events' in e)) as Event[]; + + expect(events).toHaveLength(2); + + // The next two events will be message events starting with 'foo' + expect(events[0].message?.startsWith('foo')); + expect(events[1].message?.startsWith('foo')); + + // But because these are two different events, they should have different random numbers in the message + expect(events[0].message !== events[1].message); +}); diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 2699b00918f4..b72f905fcbd4 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -115,7 +115,7 @@ function concatBuffers(buffers: Uint8Array[]): Uint8Array { return merged; } -interface TextDecoderInternal { +export interface TextDecoderInternal { decode(input?: Uint8Array): string; } diff --git a/rollup/bundleHelpers.js b/rollup/bundleHelpers.js index 43a5f26563d9..ffc9b1c796d0 100644 --- a/rollup/bundleHelpers.js +++ b/rollup/bundleHelpers.js @@ -16,7 +16,7 @@ import { makeSucrasePlugin, makeTerserPlugin, makeTSPlugin, - makeExcludeReplayPlugin, + makeExcludeBlockPlugin, makeSetSDKSourcePlugin, } from './plugins/index.js'; import { mergePlugins } from './utils'; @@ -24,8 +24,16 @@ import { mergePlugins } from './utils'; const BUNDLE_VARIANTS = ['.js', '.min.js', '.debug.min.js']; export function makeBaseBundleConfig(options) { - const { bundleType, entrypoints, jsVersion, licenseTitle, outputFileBase, packageSpecificConfig, includeReplay } = - options; + const { + bundleType, + entrypoints, + jsVersion, + licenseTitle, + outputFileBase, + packageSpecificConfig, + includeReplay, + includeOffline, + } = options; const nodeResolvePlugin = makeNodeResolvePlugin(); const sucrasePlugin = makeSucrasePlugin(); @@ -33,7 +41,8 @@ export function makeBaseBundleConfig(options) { const markAsBrowserBuildPlugin = makeBrowserBuildPlugin(true); const licensePlugin = makeLicensePlugin(licenseTitle); const tsPlugin = makeTSPlugin(jsVersion.toLowerCase()); - const excludeReplayPlugin = makeExcludeReplayPlugin(); + const excludeReplayPlugin = makeExcludeBlockPlugin('REPLAY'); + const excludeOfflineTransport = makeExcludeBlockPlugin('OFFLINE'); // The `commonjs` plugin is the `esModuleInterop` of the bundling world. When used with `transformMixedEsModules`, it // will include all dependencies, imported or required, in the final bundle. (Without it, CJS modules aren't included @@ -54,6 +63,10 @@ export function makeBaseBundleConfig(options) { standAloneBundleConfig.plugins.push(excludeReplayPlugin); } + if (!includeOffline) { + standAloneBundleConfig.plugins.push(excludeOfflineTransport); + } + // used by `@sentry/integrations` and `@sentry/wasm` (bundles which need to be combined with a stand-alone SDK bundle) const addOnBundleConfig = { // These output settings are designed to mimic an IIFE. We don't use Rollup's `iife` format because we don't want to diff --git a/rollup/plugins/bundlePlugins.js b/rollup/plugins/bundlePlugins.js index 7e48452e3d33..be91a495fc1e 100644 --- a/rollup/plugins/bundlePlugins.js +++ b/rollup/plugins/bundlePlugins.js @@ -187,8 +187,12 @@ export function makeTSPlugin(jsVersion) { * from the browser and browser+tracing bundles. * If we need to add more such guards in the future, we might want to refactor this into a more generic plugin. */ -export function makeExcludeReplayPlugin() { - const replacementRegex = /\/\/ __ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN__(.|\n)*__ROLLUP_EXCLUDE_FROM_BUNDLES_END__/m; +export function makeExcludeBlockPlugin(type) { + const replacementRegex = new RegExp( + `\\/\\/ __ROLLUP_EXCLUDE_${type}_FROM_BUNDLES_BEGIN__(.|\n)*__ROLLUP_EXCLUDE_${type}_FROM_BUNDLES_END__`, + 'm', + ); + const browserIndexFilePath = path.resolve(__dirname, '../../packages/browser/src/index.ts'); const plugin = { diff --git a/yarn.lock b/yarn.lock index 916ba81c60b7..35b07c2c0aa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7084,6 +7084,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-arraybuffer-es6@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" + integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== + base64-arraybuffer@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" @@ -10274,6 +10279,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -11999,6 +12011,13 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fake-indexeddb@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.1.tgz#09bb2468e21d0832b2177e894765fb109edac8fb" + integrity sha512-hFRyPmvEZILYgdcLBxVdHLik4Tj3gDTu/g7s9ZDOiU3sTNiGx+vEu1ri/AMsFJUZ/1sdRbAVrEcKndh3sViBcA== + dependencies: + realistic-structured-clone "^3.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -20883,6 +20902,15 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +realistic-structured-clone@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35" + integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q== + dependencies: + domexception "^1.0.1" + typeson "^6.1.0" + typeson-registry "^1.0.0-alpha.20" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -23762,6 +23790,13 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== + dependencies: + punycode "^2.1.1" + tr46@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" @@ -24078,6 +24113,20 @@ typescript@~4.5.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typeson-registry@^1.0.0-alpha.20: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" + integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== + dependencies: + base64-arraybuffer-es6 "^0.7.0" + typeson "^6.0.0" + whatwg-url "^8.4.0" + +typeson@^6.0.0, typeson@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" + integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== + ua-parser-js@^0.7.18, ua-parser-js@^0.7.30: version "0.7.33" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" @@ -25090,6 +25139,15 @@ whatwg-url@^8.0.0, whatwg-url@^8.5.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" +whatwg-url@^8.4.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" + integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== + dependencies: + lodash "^4.7.0" + tr46 "^2.1.0" + webidl-conversions "^6.1.0" + when@~3.6.x: version "3.6.4" resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e"