diff --git a/cspell.json b/cspell.json index 629754d..be61343 100644 --- a/cspell.json +++ b/cspell.json @@ -8,6 +8,7 @@ "automock", "bitauth", "bitjson", + "BNR", "Bowser", "cimg", "circleci", @@ -33,11 +34,14 @@ "libauth", "mindmeld", "mkdir", + "MULT", "multistream", "ndarray", "Onnx", "onnxruntime", + "preconfigured", "prettierignore", + "retuned", "rohit", "sandboxed", "SSDK", @@ -45,6 +49,7 @@ "trackingid", "transcoding", "transpiled", + "tunables", "typedoc", "Unregisters", "untracked", diff --git a/jest.config.js b/jest.config.js index 485333d..9a04782 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,4 +3,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', rootDir: './src', + transform: { + '^.+\\.tsx?$': 'ts-jest', + '\\.worker\\.js$': '/../jest.raw-transform.js', + }, }; diff --git a/jest.raw-transform.js b/jest.raw-transform.js new file mode 100644 index 0000000..91a8cda --- /dev/null +++ b/jest.raw-transform.js @@ -0,0 +1,13 @@ +module.exports = { + /** + * Turns a *.worker.js file into a string module, matching how rollup-plugin-string + * inlines it at build time. Since tests swap in a fake Worker, the worker file's + * contents are never run — we only ever need it as a string, not as runnable code. + * + * @param sourceText - The raw worker file contents. + * @returns The transformed module source for Jest. + */ + process(sourceText) { + return { code: `module.exports = ${JSON.stringify(sourceText)};` }; + }, +}; diff --git a/package.json b/package.json index ffd340b..e47e0aa 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "rollup": "^2.63.0", "rollup-plugin-dts": "^4.1.0", "rollup-plugin-execute": "^1.1.1", + "rollup-plugin-string": "^3.0.0", "rollup-plugin-typescript2": "^0.31.1", "semantic-release": "^19.0.2", "ts-jest": "^27.1.2", diff --git a/rollup.config.js b/rollup.config.js index d210631..8da594b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,8 +2,13 @@ import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import dts from 'rollup-plugin-dts'; import execute from 'rollup-plugin-execute'; +import { string } from 'rollup-plugin-string'; import typescript from 'rollup-plugin-typescript2'; +// Bundle *.worker.js as a string so the probe can start it from a Blob URL +// with no separate file to load. +const workerString = string({ include: '**/*.worker.js' }); + export default [ { input: 'src/index.ts', @@ -18,6 +23,7 @@ export default [ }, ], plugins: [ + workerString, typescript({ useTsconfigDeclarationDir: true }), resolve({ browser: true, extensions: ['.js', '.ts'] }), commonjs(), @@ -32,7 +38,11 @@ export default [ format: 'es', file: 'dist/types.d.ts', }, - plugins: [dts(), execute(['rm -f dist/types/*', 'mv dist/types.d.ts dist/types/index.d.ts'])], + plugins: [ + workerString, + dts(), + execute(['rm -f dist/types/*', 'mv dist/types.d.ts dist/types/index.d.ts']), + ], watch: true, }, ]; diff --git a/src/index.ts b/src/index.ts index ac1bd5d..9a43256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './wasm-runtime-probe'; export * from './browser-info'; export * from './cpu-info'; export * from './system-info'; diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts new file mode 100644 index 0000000..2f1aabc --- /dev/null +++ b/src/wasm-runtime-probe.spec.ts @@ -0,0 +1,176 @@ +import { WasmRuntimeProbe, WasmRuntimeStatus } from './wasm-runtime-probe'; +import { CapabilityState } from './web-capabilities'; + +interface FakeReply { + ok: boolean; + wasmMs?: number; + jsMs?: number; +} + +// Shared state that controls how the mock Worker behaves in the current test. +let workerReply: FakeReply | undefined; +let workerConstructCount = 0; + +/** + * Fake Worker for jsdom, which has no real one. On postMessage it replies immediately with whatever + * {@link workerReply} the test set, or stays silent so we can test the timeout path. + */ +class MockWorker { + onmessage: ((event: { data: FakeReply }) => void) | null = null; + + onerror: (() => void) | null = null; + + /** + * Counts how many workers were created, so the caching test can check it. + */ + constructor() { + workerConstructCount += 1; + } + + /** + * Sends the configured reply back to the probe, or nothing if none is set. + */ + postMessage(): void { + if (workerReply && this.onmessage) { + this.onmessage({ data: workerReply }); + } + } + + /** + * Does nothing; just matches the real Worker API. + */ + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function + terminate(): void {} +} + +describe('WasmRuntimeProbe', () => { + const originalWebAssembly = globalThis.WebAssembly; + + beforeEach(() => { + // Clear the per-page cache so each test starts fresh (private, reached via a cast). + (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; + workerReply = undefined; + workerConstructCount = 0; + }); + + afterEach(() => { + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + + it('should return DISABLED when WebAssembly is hard-disabled', async () => { + expect.assertions(5); + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.DISABLED); + expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); + expect(result.ratio).toBeNull(); + expect(result.wasmMs).toBeNull(); + expect(result.jsMs).toBeNull(); + }); + + it('should return UNKNOWN when Web Workers are not available', async () => { + expect.assertions(2); + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.capability).toBe(CapabilityState.UNKNOWN); + }); + + describe('worker benchmark', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'Worker', { + writable: true, + configurable: true, + value: MockWorker, + }); + Object.defineProperty(URL, 'createObjectURL', { + writable: true, + configurable: true, + value: jest.fn(() => 'blob:mock'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + writable: true, + configurable: true, + value: jest.fn(), + }); + }); + + afterEach(() => { + delete (globalThis as { Worker?: unknown }).Worker; + delete (URL as { createObjectURL?: unknown }).createObjectURL; + delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; + }); + + it('should return SLOW when the wasm/js ratio is below the threshold', async () => { + expect.assertions(5); + workerReply = { ok: true, wasmMs: 25, jsMs: 100 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.SLOW); + expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); + expect(result.ratio).toBe(0.25); + expect(result.wasmMs).toBe(25); + expect(result.jsMs).toBe(100); + }); + + it('should return OK when the wasm/js ratio is at or above the threshold', async () => { + expect.assertions(5); + workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.OK); + expect(result.capability).toBe(CapabilityState.CAPABLE); + expect(result.ratio).toBe(1.1); + expect(result.wasmMs).toBe(110); + expect(result.jsMs).toBe(100); + }); + + it('should return UNKNOWN when the worker reports a failure', async () => { + expect.assertions(1); + workerReply = { ok: false }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + }); + + it('should return UNKNOWN when jsMs is not a positive number', async () => { + expect.assertions(1); + workerReply = { ok: true, wasmMs: 10, jsMs: 0 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + }); + + it('should return UNKNOWN when the worker does not reply before the timeout', async () => { + expect.assertions(1); + jest.useFakeTimers(); + workerReply = undefined; // never replies + + const promise = WasmRuntimeProbe.check(); + jest.advanceTimersByTime(3000); + const result = await promise; + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + jest.useRealTimers(); + }); + + it('should cache the result so repeated calls run the benchmark only once', async () => { + expect.assertions(2); + workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + + const first = WasmRuntimeProbe.check(); + const second = WasmRuntimeProbe.check(); + + expect(first).toBe(second); + await first; + expect(workerConstructCount).toBe(1); + }); + }); +}); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts new file mode 100644 index 0000000..bbbe4ed --- /dev/null +++ b/src/wasm-runtime-probe.ts @@ -0,0 +1,182 @@ +import { CapabilityState, WebCapabilities } from './web-capabilities'; +import WORKER_SRC from './wasm-runtime-probe.worker'; + +/** Possible results of the WASM runtime probe. */ +export enum WasmRuntimeStatus { + OK = 'ok', + SLOW = 'slow', + DISABLED = 'disabled', + UNKNOWN = 'unknown', +} + +/** + * Result of the WASM runtime probe. Used to decide whether to allow real-time + * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a + * slow interpreter. + */ +export interface WasmRuntimeResult { + status: WasmRuntimeStatus; + capability: CapabilityState; + ratio: number | null; // wasmMs / jsMs, kept raw so the cutoff can be tuned later + wasmMs: number | null; + jsMs: number | null; +} + +// Calibrated cutoff for the wasm/js ratio. +const SLOW_RATIO_THRESHOLD = 0.6; +const WORKER_TIMEOUT_MS = 3000; + +/** + * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. + * + * @param status - The probe {@link WasmRuntimeStatus}. + * @returns The corresponding {@link CapabilityState}. + */ +const statusToCapability = (status: WasmRuntimeStatus): CapabilityState => { + switch (status) { + case WasmRuntimeStatus.OK: + return CapabilityState.CAPABLE; + case WasmRuntimeStatus.SLOW: + case WasmRuntimeStatus.DISABLED: + return CapabilityState.NOT_CAPABLE; + default: + return CapabilityState.UNKNOWN; + } +}; + +interface WorkerReply { + ok: boolean; + wasmMs?: number; + jsMs?: number; +} + +/** + * Checks whether this browser runs WebAssembly at full (JIT) speed or through a + * slow interpreter, by timing the same loop in WASM vs JS. This catches the case + * where WASM is present but too slow for real-time effects (e.g. Edge with JIT + * turned off). The quick "disabled" check is instant; the timed benchmark runs + * off the main thread. The result is cached, so it runs at most once per page. + */ +export class WasmRuntimeProbe { + private static cachedResult?: Promise; + + /** + * Runs the probe (cached per page) and resolves with the classified result. + * + * Times the same loop in WASM and JS off the main thread and compares them as a + * ratio (wasmMs / jsMs), which normalizes for the user's CPU. When the engine + * isn't running at full JIT speed the ratio drops below a calibrated threshold, + * and the probe reports {@link WasmRuntimeStatus.SLOW}. + * + * @returns A promise that resolves with the {@link WasmRuntimeResult}. + */ + static check(): Promise { + if (!this.cachedResult) { + this.cachedResult = this.run(); + } + return this.cachedResult; + } + + /** + * Builds a {@link WasmRuntimeResult} from a status and optional raw measurements. + * + * @param status - The classified {@link WasmRuntimeStatus}. + * @param extra - Optional raw measurements to include. + * @param extra.ratio - The wasmMs / jsMs ratio. + * @param extra.wasmMs - The measured WASM time in milliseconds. + * @param extra.jsMs - The measured JS time in milliseconds. + * @returns The assembled {@link WasmRuntimeResult}. + */ + private static buildResult( + status: WasmRuntimeStatus, + extra?: { ratio?: number; wasmMs?: number; jsMs?: number } + ): WasmRuntimeResult { + return { + status, + capability: statusToCapability(status), + ratio: extra?.ratio ?? null, + wasmMs: extra?.wasmMs ?? null, + jsMs: extra?.jsMs ?? null, + }; + } + + /** + * Runs the checks in order: the instant "disabled" check, then the timed Worker benchmark. + * + * @returns A promise that resolves with the {@link WasmRuntimeResult}. + */ + private static async run(): Promise { + if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { + return this.buildResult(WasmRuntimeStatus.DISABLED); + } + + // Can't run the benchmark without a Web Worker and a Blob URL. + if ( + WebCapabilities.supportsWorker() === CapabilityState.NOT_CAPABLE || + typeof URL === 'undefined' || + !URL.createObjectURL + ) { + return this.buildResult(WasmRuntimeStatus.UNKNOWN); + } + + const started = this.startWorker(); + if (!started) { + return this.buildResult(WasmRuntimeStatus.UNKNOWN); + } + + const { worker, url } = started; + try { + // eslint-disable-next-line jsdoc/require-jsdoc + const msg = await new Promise((resolve) => { + const timer = setTimeout(() => resolve({ ok: false }), WORKER_TIMEOUT_MS); + // eslint-disable-next-line jsdoc/require-jsdoc + worker.onmessage = (e: MessageEvent) => { + clearTimeout(timer); + resolve(e.data); + }; + // eslint-disable-next-line jsdoc/require-jsdoc + worker.onerror = () => { + clearTimeout(timer); + resolve({ ok: false }); + }; + worker.postMessage('start'); + }); + + if ( + !msg.ok || + typeof msg.jsMs !== 'number' || + msg.jsMs <= 0 || + typeof msg.wasmMs !== 'number' + ) { + return this.buildResult(WasmRuntimeStatus.UNKNOWN); + } + + const ratio = Number((msg.wasmMs / msg.jsMs).toFixed(2)); + const status = ratio < SLOW_RATIO_THRESHOLD ? WasmRuntimeStatus.SLOW : WasmRuntimeStatus.OK; + return this.buildResult(status, { + ratio, + wasmMs: Number(msg.wasmMs.toFixed(2)), + jsMs: Number(msg.jsMs.toFixed(2)), + }); + } finally { + worker.terminate(); + URL.revokeObjectURL(url); + } + } + + /** + * Starts the benchmark worker from the inline source (via a Blob URL). + * + * @returns The worker and its Blob URL, or undefined if creation fails. + */ + private static startWorker(): { worker: Worker; url: string } | undefined { + let url: string | undefined; + try { + url = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'text/javascript' })); + return { worker: new Worker(url), url }; + } catch { + if (url) URL.revokeObjectURL(url); + return undefined; + } + } +} diff --git a/src/wasm-runtime-probe.worker.d.ts b/src/wasm-runtime-probe.worker.d.ts new file mode 100644 index 0000000..6bbab45 --- /dev/null +++ b/src/wasm-runtime-probe.worker.d.ts @@ -0,0 +1,7 @@ +/** + * Tells TypeScript that importing `wasm-runtime-probe.worker.js` gives a string. + * The build (rollup-plugin-string) and the tests (jest raw transform) both turn + * that file into this default string export. + */ +declare const workerSource: string; +export default workerSource; diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js new file mode 100644 index 0000000..d24ba9c --- /dev/null +++ b/src/wasm-runtime-probe.worker.js @@ -0,0 +1,117 @@ +/* + * wasm-runtime-probe.worker.js — measures how long the same loop takes in WASM vs JS, off the main thread. + * + * Inlined as a string at build time and started from a Blob URL by wasm-runtime-probe.ts; + * kept as a real file so it stays readable. + * + * Protocol: main thread posts 'start'; replies { ok: true, wasmMs, jsMs } or { ok: false }. + */ +self.onmessage = function onProbeStart() { + try { + // Standard LCG constants (Numerical Recipes): acc = acc * MULT + INC is a cheap + // arithmetic loop the JIT can't optimize away, so it's a fair CPU benchmark. The + // WASM and JS loops share them so both do identical work; changing them + // invalidates the calibrated threshold. + var LCG_MULT = 1664525; // multiplier + var LCG_INC = 1013904223; // increment + var ITERATIONS = 5000000; + var SAMPLE_RUNS = 7; // keep the median of this many runs + + // Build a tiny WASM module in memory that exports bench(n) — nothing to fetch. + // Bytes use LEB128: encodeU32 for lengths/counts, encodeI32 for signed values. + var encodeU32 = function encodeU32(value) { + var out = []; + do { + var byte = value & 0x7f; + value >>>= 7; + if (value) byte |= 0x80; + out.push(byte); + } while (value); + return out; + }; + + var encodeI32 = function encodeI32(value) { + var out = []; + var more = true; + while (more) { + var byte = value & 0x7f; + value >>= 7; + if ((value === 0 && !(byte & 0x40)) || (value === -1 && byte & 0x40)) { + more = false; + } else { + byte |= 0x80; + } + out.push(byte); + } + return out; + }; + + // A section is one labelled block of the file: [id, length, ...bytes]. + var section = function section(id, bytes) { + return [id].concat(encodeU32(bytes.length)).concat(bytes); + }; + + var I32 = 0x7f; // WASM's code for the 32-bit integer type. + + var buildWasmLoopModule = function buildWasmLoopModule() { + var typeSec = section(1, encodeU32(1).concat([0x60, 0x01, I32, 0x01, I32])); // bench's type: takes one i32, returns one i32 + var funcSec = section(3, encodeU32(1).concat([0x00])); // function 0 uses signature 0 + var name = 'bench'.split('').map(function toCharCode(c) { + return c.charCodeAt(0); + }); + var exportSec = section( + 7, + encodeU32(1).concat(encodeU32(name.length)).concat(name).concat([0x00, 0x00]) + ); // export the function as "bench" + // Function body — 2 locals (i, acc), then the loop: + // acc = acc * LCG_MULT + LCG_INC; i += 1; if (i < n) loop; return acc + var body = encodeU32(1) + .concat(encodeU32(2)) + .concat([I32, 0x03, 0x40, 0x20, 0x02, 0x41]) + .concat(encodeI32(LCG_MULT)) + .concat([0x6c, 0x41]) + .concat(encodeI32(LCG_INC)) + .concat([ + 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00, + 0x0b, 0x20, 0x02, 0x0b, + ]); + var codeSec = section(10, encodeU32(1).concat(encodeU32(body.length)).concat(body)); + // "\0asm" + version 1 + the four sections + return new Uint8Array( + [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) + ); + }; + + // Median of several runs, so one slow run (e.g. a background hiccup) is ignored. + var medianRuntimeMs = function medianRuntimeMs(loopFn) { + loopFn(ITERATIONS); // warm-up run (not timed) + var samples = []; + for (var k = 0; k < SAMPLE_RUNS; k++) { + var start = performance.now(); + loopFn(ITERATIONS); + samples.push(performance.now() - start); + } + samples.sort(function ascending(a, b) { + return a - b; + }); + return samples[Math.floor(samples.length / 2)]; + }; + + var runWasmLoop = new WebAssembly.Instance( + new WebAssembly.Module(buildWasmLoopModule()) + ).exports.bench; + var runJsLoop = function runJsLoop(n) { + var acc = 0; + for (var k = 0; k < n; k++) { + acc = (Math.imul(acc, LCG_MULT) + LCG_INC) | 0; + } + return acc; + }; + + var wasmMs = medianRuntimeMs(runWasmLoop); + var jsMs = medianRuntimeMs(runJsLoop); + self.postMessage({ ok: true, wasmMs: wasmMs, jsMs: jsMs }); + } catch (err) { + self.postMessage({ ok: false }); + } +}; diff --git a/src/web-capabilities.spec.ts b/src/web-capabilities.spec.ts index a605481..fe027a3 100644 --- a/src/web-capabilities.spec.ts +++ b/src/web-capabilities.spec.ts @@ -292,6 +292,34 @@ describe('WebCapabilities', () => { }); }); + describe('supportsWorker', () => { + const originalWorker = (globalThis as { Worker?: unknown }).Worker; + + /** + * Minimal stand-in for the Worker constructor, which jsdom does not provide. + */ + class MockWorker {} + + afterEach(() => { + // Restore the Worker global mutated by individual cases. + (globalThis as { Worker?: unknown }).Worker = originalWorker; + }); + + it('should return CAPABLE when Web Workers are available', () => { + expect.assertions(1); + (globalThis as { Worker?: unknown }).Worker = MockWorker; + + expect(WebCapabilities.supportsWorker()).toBe(CapabilityState.CAPABLE); + }); + + it('should return NOT_CAPABLE when Web Workers are not available', () => { + expect.assertions(1); + delete (globalThis as { Worker?: unknown }).Worker; + + expect(WebCapabilities.supportsWorker()).toBe(CapabilityState.NOT_CAPABLE); + }); + }); + describe('supportsEncodingCodec', () => { let isChromeSpy: jest.SpyInstance; let isEdgeSpy: jest.SpyInstance; diff --git a/src/web-capabilities.ts b/src/web-capabilities.ts index a486abf..5a0d593 100644 --- a/src/web-capabilities.ts +++ b/src/web-capabilities.ts @@ -134,6 +134,16 @@ export class WebCapabilities { : CapabilityState.NOT_CAPABLE; } + /** + * Checks whether the browser supports Web Workers, which run scripts on a + * background thread separate from the main UI thread. + * + * @returns A {@link CapabilityState}. + */ + static supportsWorker(): CapabilityState { + return typeof Worker === 'function' ? CapabilityState.CAPABLE : CapabilityState.NOT_CAPABLE; + } + /** * Checks whether the browser supports RTCPeerConnection. This is needed, * because some users install browser extensions that remove RTCPeerConnection. diff --git a/yarn.lock b/yarn.lock index 1a045c2..4e2a3ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3281,6 +3281,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" + integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== + estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" @@ -6462,6 +6467,13 @@ rollup-plugin-execute@^1.1.1: resolved "https://registry.yarnpkg.com/rollup-plugin-execute/-/rollup-plugin-execute-1.1.1.tgz#ee7bcb293e48bc599232b66b66473763e3cb8965" integrity sha512-isCNR/VrwlEfWJMwsnmt5TBRod8dW1IjVRxcXCBrxDmVTeA1IXjzeLSS3inFBmRD7KDPlo38KSb2mh5v5BoWgA== +rollup-plugin-string@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-string/-/rollup-plugin-string-3.0.0.tgz#fed2d6301fae1e59eb610957df757ef13fada3f0" + integrity sha512-vqyzgn9QefAgeKi+Y4A7jETeIAU1zQmS6VotH6bzm/zmUQEnYkpIGRaOBPY41oiWYV4JyBoGAaBjYMYuv+6wVw== + dependencies: + rollup-pluginutils "^2.4.1" + rollup-plugin-typescript2@^0.31.1: version "0.31.2" resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.31.2.tgz#463aa713a7e2bf85b92860094b9f7fb274c5a4d8" @@ -6474,6 +6486,13 @@ rollup-plugin-typescript2@^0.31.1: resolve "^1.20.0" tslib "^2.3.1" +rollup-pluginutils@^2.4.1: + version "2.8.2" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" + integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + dependencies: + estree-walker "^0.6.1" + rollup@^2.63.0: version "2.79.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"