From 0bad6fc8dd6aaffaa12cf099ab6bbf7c98d487c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Tesa=C5=99?= Date: Tue, 7 Nov 2023 16:13:03 +0100 Subject: [PATCH] Stagger pusher (#105) * Remove rate limiting * Add at maximum 10% of fetchInterval to waiting time * Use strictObject wherever it's needed * Log the extra time * Fix tests --- packages/api/src/schema.ts | 37 ++++----- packages/e2e/src/pusher/pusher.json | 1 - packages/pusher/README.md | 28 ------- packages/pusher/config/pusher.example.json | 1 - packages/pusher/package.json | 1 - .../pusher/src/api-requests/data-provider.ts | 9 +-- packages/pusher/src/constants.ts | 7 -- packages/pusher/src/fetch-beacon-data.ts | 12 ++- .../pusher/src/heartbeat/heartbeat.test.ts | 12 +-- packages/pusher/src/state.ts | 53 ------------- packages/pusher/src/update-signed-api.ts | 1 + packages/pusher/src/utils.ts | 5 -- packages/pusher/src/validation/schema.ts | 79 ++++++------------- packages/pusher/test/fixtures.ts | 1 - pnpm-lock.yaml | 7 -- 15 files changed, 59 insertions(+), 195 deletions(-) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 8c051747..e30ea815 100644 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -6,14 +6,12 @@ export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM ID'); -export const endpointSchema = z - .object({ - urlPath: z - .string() - .regex(/^\/[\dA-Za-z-]+$/, 'Must start with a slash and contain only alphanumeric characters and dashes'), - delaySeconds: z.number().nonnegative().int(), - }) - .strict(); +export const endpointSchema = z.strictObject({ + urlPath: z + .string() + .regex(/^\/[\dA-Za-z-]+$/, 'Must start with a slash and contain only alphanumeric characters and dashes'), + delaySeconds: z.number().nonnegative().int(), +}); export type Endpoint = z.infer; @@ -26,21 +24,19 @@ export const endpointsSchema = z export const allowedAirnodesSchema = z.union([z.literal('*'), z.array(evmAddressSchema).nonempty()]); -export const configSchema = z - .object({ - endpoints: endpointsSchema, - maxBatchSize: z.number().nonnegative().int(), - port: z.number().nonnegative().int(), - cache: z.object({ - maxAgeSeconds: z.number().nonnegative().int(), - }), - allowedAirnodes: allowedAirnodesSchema, - }) - .strict(); +export const configSchema = z.strictObject({ + endpoints: endpointsSchema, + maxBatchSize: z.number().nonnegative().int(), + port: z.number().nonnegative().int(), + cache: z.strictObject({ + maxAgeSeconds: z.number().nonnegative().int(), + }), + allowedAirnodes: allowedAirnodesSchema, +}); export type Config = z.infer; -export const signedDataSchema = z.object({ +export const signedDataSchema = z.strictObject({ airnode: evmAddressSchema, templateId: evmIdSchema, beaconId: evmIdSchema, @@ -60,6 +56,7 @@ export const envBooleanSchema = z.union([z.literal('true'), z.literal('false')]) // We apply default values to make it convenient to omit certain environment variables. The default values should be // primarily focused on users and production usage. export const envConfigSchema = z + // Intentionally not using strictObject here because we want to allow other environment variables to be present. .object({ LOG_COLORIZE: envBooleanSchema.default('false'), LOG_FORMAT: z diff --git a/packages/e2e/src/pusher/pusher.json b/packages/e2e/src/pusher/pusher.json index 35a39473..e2926857 100644 --- a/packages/e2e/src/pusher/pusher.json +++ b/packages/e2e/src/pusher/pusher.json @@ -109,7 +109,6 @@ "nodeSettings": { "nodeVersion": "0.1.0", "airnodeWalletMnemonic": "diamond result history offer forest diagram crop armed stumble orchard stage glance", - "rateLimiting": { "Mock API": { "maxConcurrency": 25, "minTime": 0 } }, "stage": "local-example" } } diff --git a/packages/pusher/README.md b/packages/pusher/README.md index 2d5c74a5..38dd71cf 100644 --- a/packages/pusher/README.md +++ b/packages/pusher/README.md @@ -323,34 +323,6 @@ An identifier of the deployment stage. This is used to distinguish between diffe `dev`, `staging` or `production`. The stage value can have 256 characters at maximum and can only include lowercase alphanumeric characters and hyphens. -##### `rateLimiting` - -Configuration for rate limiting OIS requests. Rate limiting can be configured for each OIS separately. For example: - -```jsonc -// Defines no rate limiting. -"rateLimiting": { }, -``` - -or - -```jsonc -// Defines rate limiting for OIS with title "Nodary" -"rateLimiting": { "Nodary": { "maxConcurrency": 25, "minTime": 10 } }, -``` - -###### `rateLimiting[]` - -The configuration for the OIS with title ``. - -`maxConcurrency` - -Maximum number of concurrent requests to the OIS. - -`minTime` - -Minimum time in milliseconds between two requests to the OIS. - ## Deployment TODO: Write example how to deploy on AWS diff --git a/packages/pusher/config/pusher.example.json b/packages/pusher/config/pusher.example.json index d0be548e..2b291f6d 100644 --- a/packages/pusher/config/pusher.example.json +++ b/packages/pusher/config/pusher.example.json @@ -95,7 +95,6 @@ "nodeSettings": { "nodeVersion": "0.1.0", "airnodeWalletMnemonic": "${WALLET_MNEMONIC}", - "rateLimiting": { "Nodary": { "maxConcurrency": 25, "minTime": 10 } }, "stage": "local-example" } } diff --git a/packages/pusher/package.json b/packages/pusher/package.json index 62e4edd6..c476e1e7 100644 --- a/packages/pusher/package.json +++ b/packages/pusher/package.json @@ -29,7 +29,6 @@ "@api3/ois": "^2.2.1", "@api3/promise-utils": "^0.4.0", "axios": "^1.5.1", - "bottleneck": "^2.19.5", "dotenv": "^16.3.1", "ethers": "^5.7.2", "express": "^4.18.2", diff --git a/packages/pusher/src/api-requests/data-provider.ts b/packages/pusher/src/api-requests/data-provider.ts index a194d682..8c9afb57 100644 --- a/packages/pusher/src/api-requests/data-provider.ts +++ b/packages/pusher/src/api-requests/data-provider.ts @@ -42,7 +42,6 @@ export const callApi = async ( export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Promise => { const { config: { endpoints, templates, ois: oises, apiCredentials }, - apiLimiters, } = getState(); logger.debug('Making template requests', signedApiUpdate); const { templateIds } = signedApiUpdate; @@ -61,13 +60,7 @@ export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Pr }; }, {}); - const limiter = apiLimiters[operationTemplateId]; - - const goCallApi = await (limiter - ? limiter.schedule({ expiration: 90_000 }, async () => - callApi(ois, operationOisEndpoint, operationApiCallParameters, apiCredentials) - ) - : callApi(ois, operationOisEndpoint, operationApiCallParameters, apiCredentials)); + const goCallApi = await callApi(ois, operationOisEndpoint, operationApiCallParameters, apiCredentials); if (!goCallApi.success) { logger.warn(`Failed to make API call`, { diff --git a/packages/pusher/src/constants.ts b/packages/pusher/src/constants.ts index 36f2e4b0..df7e4d69 100644 --- a/packages/pusher/src/constants.ts +++ b/packages/pusher/src/constants.ts @@ -1,12 +1,5 @@ export const SIGNED_DATA_PUSH_POLLING_INTERVAL = 2500; -export const RANDOM_BACKOFF_MIN_MS = 0; -export const RANDOM_BACKOFF_MAX_MS = 2500; -// The minimum amount of time between HTTP calls to remote APIs per OIS. -export const OIS_MIN_TIME_DEFAULT_MS = 20; -// The maximum number of simultaneously-running HTTP requests to remote APIs per OIS. -export const OIS_MAX_CONCURRENCY_DEFAULT = 10; - export const NO_SIGNED_API_UPDATE_EXIT_CODE = 1; export const NO_FETCH_EXIT_CODE = 2; diff --git a/packages/pusher/src/fetch-beacon-data.ts b/packages/pusher/src/fetch-beacon-data.ts index 360941d8..0093bb11 100644 --- a/packages/pusher/src/fetch-beacon-data.ts +++ b/packages/pusher/src/fetch-beacon-data.ts @@ -15,6 +15,7 @@ export const initiateFetchingBeaconData = () => { const { signedApiUpdates } = config.triggers; + // TODO: Validate using zod schema if (isEmpty(signedApiUpdates)) { logger.error('No signed API updates found. Stopping.'); // eslint-disable-next-line unicorn/no-process-exit @@ -41,8 +42,17 @@ const fetchBeaconDataInLoop = async (signedApiUpdate: SignedApiUpdate) => { logger.warn(`Could not put signed response`, { templateId, signedResponse, errorMessage: goPut.error.message }); } }); + const duration = Date.now() - startTimestamp; + // Take at most 10% of the fetch interval as extra time to avoid all API requests be done at the same time. This + // delay is taken for each interval, so if the system runs for a sufficiently long time, the requests should happen + // at random intervals. + const extraTime = Math.random() * signedApiUpdate.fetchInterval * 1000 * 0.1; + logger.debug('Adding extra time to fetch interval', { + extraTime, + fetchInterval: signedApiUpdate.fetchInterval * 1000, + }); - await sleep(signedApiUpdate.fetchInterval * 1000 - duration); + await sleep(signedApiUpdate.fetchInterval * 1000 - duration + extraTime); } }; diff --git a/packages/pusher/src/heartbeat/heartbeat.test.ts b/packages/pusher/src/heartbeat/heartbeat.test.ts index aec9c2b9..ba148e29 100644 --- a/packages/pusher/src/heartbeat/heartbeat.test.ts +++ b/packages/pusher/src/heartbeat/heartbeat.test.ts @@ -28,9 +28,9 @@ describe(logHeartbeat.name, () => { nodeVersion: '0.1.0', currentTimestamp: '1674172803', deploymentTimestamp: '1674172800', - configHash: '0x126e768ba244efdb790d63a76821047e163dfc502ace09b2546a93075594c286', + configHash: '0xc40fb6dce9a4c5898b344f17ecc922a8ab97096ed92e5e2f8c53edb486ea7730', signature: - '0x24467037db96b652286c30c39ee9611faff07e1c17916f5c154ea7a27dfbc32f308969bdadf586bdaee0951b84819633e126a4fc72e3aa2e98a6eda95ce640081b', + '0xc2adb0ef11cdf50fc382752a73d2314cef28f530d030801a1e301cea2da2f66961c30dd158e20e80ed05819ed8f7ef53e0ca6a73441de7df3bdff209e586e3b71c', }; const rawConfig = JSON.parse(readFileSync(join(__dirname, '../../config/pusher.example.json'), 'utf8')); jest.spyOn(configModule, 'loadRawConfig').mockReturnValue(rawConfig); @@ -50,12 +50,12 @@ describe(verifyHeartbeatLog.name, () => { const jsonLog = { context: { airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC', - configHash: '0x126e768ba244efdb790d63a76821047e163dfc502ace09b2546a93075594c286', + configHash: '0xc40fb6dce9a4c5898b344f17ecc922a8ab97096ed92e5e2f8c53edb486ea7730', currentTimestamp: '1674172803', deploymentTimestamp: '1674172800', nodeVersion: '0.1.0', signature: - '0x24467037db96b652286c30c39ee9611faff07e1c17916f5c154ea7a27dfbc32f308969bdadf586bdaee0951b84819633e126a4fc72e3aa2e98a6eda95ce640081b', + '0xc2adb0ef11cdf50fc382752a73d2314cef28f530d030801a1e301cea2da2f66961c30dd158e20e80ed05819ed8f7ef53e0ca6a73441de7df3bdff209e586e3b71c', stage: 'test', }, level: 'info', @@ -81,10 +81,10 @@ describe(stringifyUnsignedHeartbeatPayload.name, () => { nodeVersion: '0.1.0', currentTimestamp: '1674172803', deploymentTimestamp: '1674172800', - configHash: '0x126e768ba244efdb790d63a76821047e163dfc502ace09b2546a93075594c286', + configHash: '0xc40fb6dce9a4c5898b344f17ecc922a8ab97096ed92e5e2f8c53edb486ea7730', }) ).toBe( - '{"airnode":"0xbF3137b0a7574563a23a8fC8badC6537F98197CC","configHash":"0x126e768ba244efdb790d63a76821047e163dfc502ace09b2546a93075594c286","currentTimestamp":"1674172803","deploymentTimestamp":"1674172800","nodeVersion":"0.1.0","stage":"test"}' + '{"airnode":"0xbF3137b0a7574563a23a8fC8badC6537F98197CC","configHash":"0xc40fb6dce9a4c5898b344f17ecc922a8ab97096ed92e5e2f8c53edb486ea7730","currentTimestamp":"1674172803","deploymentTimestamp":"1674172800","nodeVersion":"0.1.0","stage":"test"}' ); }); }); diff --git a/packages/pusher/src/state.ts b/packages/pusher/src/state.ts index 13718a2e..ac4ebd9a 100644 --- a/packages/pusher/src/state.ts +++ b/packages/pusher/src/state.ts @@ -1,9 +1,6 @@ -import Bottleneck from 'bottleneck'; import { ethers } from 'ethers'; import { last } from 'lodash'; -import { OIS_MAX_CONCURRENCY_DEFAULT, OIS_MIN_TIME_DEFAULT_MS } from './constants'; -import { deriveEndpointId, getRandomId } from './utils'; import type { Config, SignedData, TemplateId } from './validation/schema'; export type TemplateValueStorage = Record; @@ -11,7 +8,6 @@ export type TemplateValueStorage = Record; export interface State { config: Config; templateValues: TemplateValueStorage; - apiLimiters: Record; // We persist the derived Airnode wallet in memory as a performance optimization. airnodeWallet: ethers.Wallet; // The timestamp of when the service was initialized. This can be treated as a "deployment" timestamp. @@ -25,54 +21,6 @@ export const initializeState = (config: Config) => { return state; }; -export const buildApiLimiters = (config: Config) => { - const { ois, nodeSettings, templates } = config; - const { rateLimiting } = nodeSettings; - - if (!ois) { - return {}; - } - - const oisLimiters = Object.fromEntries( - ois.map((ois) => { - if (rateLimiting[ois.title]) { - const { minTime, maxConcurrency } = rateLimiting[ois.title]!; - - return [ - ois.title, - new Bottleneck({ - id: getRandomId(), - minTime: minTime ?? OIS_MIN_TIME_DEFAULT_MS, - maxConcurrent: maxConcurrency ?? OIS_MAX_CONCURRENCY_DEFAULT, - }), - ]; - } - - return [ - ois.title, - new Bottleneck({ - id: getRandomId(), - minTime: OIS_MIN_TIME_DEFAULT_MS, - maxConcurrent: OIS_MAX_CONCURRENCY_DEFAULT, - }), - ]; - }) - ); - const endpointTitles = Object.fromEntries( - ois.flatMap((ois) => ois.endpoints.map((endpoint) => [deriveEndpointId(ois.title, endpoint.name), ois.title])) - ); - - // Make use of the reference/pointer nature of objects - const apiLimiters = Object.fromEntries( - Object.entries(templates).map(([templateId, template]) => { - const title = endpointTitles[template.endpointId]!; - return [templateId, oisLimiters[title]]; - }) - ); - - return apiLimiters; -}; - export const buildTemplateStorages = (config: Config) => { return Object.fromEntries( Object.keys(config.templates).map((templateId) => { @@ -86,7 +34,6 @@ export const getInitialState = (config: Config): State => { return { config, templateValues: buildTemplateStorages(config), - apiLimiters: buildApiLimiters(config), airnodeWallet: ethers.Wallet.fromMnemonic(config.nodeSettings.airnodeWalletMnemonic), deploymentTimestamp: Math.floor(Date.now() / 1000).toString(), }; diff --git a/packages/pusher/src/update-signed-api.ts b/packages/pusher/src/update-signed-api.ts index b01cedf8..49738ec4 100644 --- a/packages/pusher/src/update-signed-api.ts +++ b/packages/pusher/src/update-signed-api.ts @@ -46,6 +46,7 @@ export const initiateUpdatingSignedApi = () => { })) ); + // TODO: Validate using zod schema if (isEmpty(signedApiUpdateDelayGroups)) { logger.error('No signed API updates found. Stopping.'); // eslint-disable-next-line unicorn/no-process-exit diff --git a/packages/pusher/src/utils.ts b/packages/pusher/src/utils.ts index 3884daf8..dc6252c5 100644 --- a/packages/pusher/src/utils.ts +++ b/packages/pusher/src/utils.ts @@ -2,11 +2,6 @@ import { ethers } from 'ethers'; export const sleep = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** - * Generates a random ID used when creating Bottleneck limiters. - */ -export const getRandomId = () => ethers.utils.randomBytes(16).toString(); - export const deriveEndpointId = (oisTitle: string, endpointName: string) => ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(['string', 'string'], [oisTitle, endpointName])); diff --git a/packages/pusher/src/validation/schema.ts b/packages/pusher/src/validation/schema.ts index 68735f18..64273ccd 100644 --- a/packages/pusher/src/validation/schema.ts +++ b/packages/pusher/src/validation/schema.ts @@ -15,22 +15,16 @@ import { z, type SuperRefinement } from 'zod'; import packageJson from '../../package.json'; -export const limiterConfig = z.object({ minTime: z.number(), maxConcurrency: z.number() }); - -export const parameterSchema = z - .object({ - name: z.string(), - type: z.string(), - value: z.string(), - }) - .strict(); +export const parameterSchema = z.strictObject({ + name: z.string(), + type: z.string(), + value: z.string(), +}); -export const templateSchema = z - .object({ - endpointId: config.evmIdSchema, - parameters: z.array(parameterSchema), - }) - .strict(); +export const templateSchema = z.strictObject({ + endpointId: config.evmIdSchema, + parameters: z.array(parameterSchema), +}); export const templatesSchema = z.record(config.evmIdSchema, templateSchema).superRefine((templates, ctx) => { for (const [templateId, template] of Object.entries(templates)) { @@ -59,7 +53,7 @@ export const templatesSchema = z.record(config.evmIdSchema, templateSchema).supe } }); -export const endpointSchema = z.object({ +export const endpointSchema = z.strictObject({ oisTitle: z.string(), endpointName: z.string(), }); @@ -83,26 +77,25 @@ export const endpointsSchema = z.record(endpointSchema).superRefine((endpoints, } }); -export const baseBeaconUpdateSchema = z.object({ +export const baseBeaconUpdateSchema = z.strictObject({ deviationThreshold: z.number(), heartbeatInterval: z.number().int(), }); export const beaconUpdateSchema = z - .object({ + .strictObject({ beaconId: config.evmIdSchema, }) - .merge(baseBeaconUpdateSchema) - .strict(); + .merge(baseBeaconUpdateSchema); -export const signedApiUpdateSchema = z.object({ +export const signedApiUpdateSchema = z.strictObject({ signedApiName: z.string(), templateIds: z.array(config.evmIdSchema), fetchInterval: z.number(), updateDelay: z.number(), }); -export const triggersSchema = z.object({ +export const triggersSchema = z.strictObject({ signedApiUpdates: z.array(signedApiUpdateSchema), }); @@ -205,27 +198,7 @@ const validateTriggerReferences: SuperRefinement<{ } }; -export const rateLimitingSchema = z.record(limiterConfig); - -const validateOisRateLimiterReferences: SuperRefinement<{ - ois: OIS[]; - nodeSettings: NodeSettings; -}> = (config, ctx) => { - const { ois, nodeSettings } = config; - const { rateLimiting } = nodeSettings; - - for (const oisTitle of Object.keys(rateLimiting)) { - if (!ois.some((ois) => ois.title === oisTitle)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `OIS Title "${oisTitle}" in rate limiting does not exist`, - path: ['rateLimiting', oisTitle], - }); - } - } -}; - -export const signedApiSchema = z.object({ +export const signedApiSchema = z.strictObject({ name: z.string(), url: z.string().url(), }); @@ -247,10 +220,9 @@ export const oisesSchema = z.array(oisSchema); export const apisCredentialsSchema = z.array(config.apiCredentialsSchema); -export const nodeSettingsSchema = z.object({ +export const nodeSettingsSchema = z.strictObject({ nodeVersion: z.string().refine((version) => version === packageJson.version, 'Invalid node version'), airnodeWalletMnemonic: z.string().refine((mnemonic) => ethers.utils.isValidMnemonic(mnemonic), 'Invalid mnemonic'), - rateLimiting: rateLimitingSchema, stage: z .string() .regex(/^[\da-z-]{1,256}$/, 'Only lowercase letters, numbers and hyphens are allowed (max 256 characters)'), @@ -259,7 +231,7 @@ export const nodeSettingsSchema = z.object({ export type NodeSettings = z.infer; export const configSchema = z - .object({ + .strictObject({ apiCredentials: apisCredentialsSchema, beaconSets: z.any(), chains: z.any(), @@ -271,15 +243,13 @@ export const configSchema = z templates: templatesSchema, triggers: triggersSchema, }) - .strict() .superRefine(validateTemplatesReferences) .superRefine(validateOisReferences) - .superRefine(validateOisRateLimiterReferences) .superRefine(validateTriggerReferences); export const encodedValueSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/); export const signatureSchema = z.string().regex(/^0x[\dA-Fa-f]{130}$/); -export const signedDataSchema = z.object({ +export const signedDataSchema = z.strictObject({ timestamp: z.string(), encodedValue: encodedValueSchema, signature: signatureSchema, @@ -308,18 +278,14 @@ export type EndpointId = z.infer; export type SignedData = z.infer; export type Endpoint = z.infer; export type Endpoints = z.infer; -export type LimiterConfig = z.infer; -export type RateLimitingConfig = z.infer; export type ApisCredentials = z.infer; export type Parameter = z.infer; export const secretsSchema = z.record(z.string()); -export const signedApiResponseSchema = z - .object({ - count: z.number(), - }) - .strict(); +export const signedApiResponseSchema = z.strictObject({ + count: z.number(), +}); export type SignedApiResponse = z.infer; @@ -328,6 +294,7 @@ export const envBooleanSchema = z.union([z.literal('true'), z.literal('false')]) // We apply default values to make it convenient to omit certain environment variables. The default values should be // primarily focused on users and production usage. export const envConfigSchema = z + // Intentionally not using strictObject here because we want to allow other environment variables to be present. .object({ LOGGER_ENABLED: envBooleanSchema.default('true'), LOG_COLORIZE: envBooleanSchema.default('false'), diff --git a/packages/pusher/test/fixtures.ts b/packages/pusher/test/fixtures.ts index c64c9af5..2b45b09a 100644 --- a/packages/pusher/test/fixtures.ts +++ b/packages/pusher/test/fixtures.ts @@ -104,7 +104,6 @@ export const config: Config = { nodeSettings: { nodeVersion: packageJson.version, airnodeWalletMnemonic: 'diamond result history offer forest diagram crop armed stumble orchard stage glance', - rateLimiting: { Nodary: { maxConcurrency: 25, minTime: 10 } }, stage: 'test', }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86a8eb36..a38eb31b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,9 +148,6 @@ importers: axios: specifier: ^1.5.1 version: 1.5.1 - bottleneck: - specifier: ^2.19.5 - version: 2.19.5 dotenv: specifier: ^16.3.1 version: 16.3.1 @@ -3066,10 +3063,6 @@ packages: - supports-color dev: false - /bottleneck@2.19.5: - resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} - dev: false - /bowser@2.11.0: resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} dev: false