diff --git a/docker-compose.yml b/docker-compose.yml index 7a610f142..a4d9f9b31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,12 @@ services: ports: - "10001:5601" + redis: + tty: true + image: redis:6.0-alpine + ports: + - "10002:6379" + keyword-analysis: << : *node working_dir: /base-cms/services/keyword-analysis @@ -87,6 +93,7 @@ services: <<: *env <<: *env-mongo <<: *env-newrelic + REDIS_CACHE_DSN: ${REDIS_CACHE_DSN-redis://redis} APOLLO_ENGINE_ENABLED: ${APOLLO_ENGINE_ENABLED-false} APOLLO_ENGINE_API_KEY: ${APOLLO_ENGINE_API_KEY-} GRAPHQL_CACHE_CONTROL_ENABLED: ${GRAPHQL_CACHE_CONTROL_ENABLED-false} @@ -104,6 +111,7 @@ services: EXPOSED_PORT: 10100 depends_on: - mongodb + - redis - google-data-api ports: - "10100:80" @@ -117,6 +125,7 @@ services: <<: *env-newrelic GRAPHQL_PLAYGROUND_ENABLED: 1 MONGO_DSN: ${MONGO_DSN_LEONIS-mongodb://mongodb:27017} + REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_LEONIS-redis://redis} ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-} ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)} BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-} @@ -140,6 +149,7 @@ services: <<: *env-newrelic GRAPHQL_PLAYGROUND_ENABLED: 1 MONGO_DSN: ${MONGO_DSN_TAURON-mongodb://mongodb:27017} + REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_TAURON-redis://redis} ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-} ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)} BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-} @@ -163,6 +173,7 @@ services: <<: *env-newrelic GRAPHQL_PLAYGROUND_ENABLED: 1 MONGO_DSN: ${MONGO_DSN_VIRGON-mongodb://mongodb:27017} + REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_VIRGON-redis://redis} ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-} ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)} BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-} @@ -173,6 +184,7 @@ services: EXPOSED_PORT: 10103 depends_on: - mongodb + - redis - google-data-api ports: - "10103:80" diff --git a/packages/db/src/index.js b/packages/db/src/index.js index ca922ffbd..10fbb24ad 100644 --- a/packages/db/src/index.js +++ b/packages/db/src/index.js @@ -1,9 +1,11 @@ +const EJSON = require('mongodb-extended-json'); const BaseDB = require('./basedb'); const MongoDB = require('./mongodb'); const createMongoClient = require('./create-mongo-client'); const createBaseDB = require('./create-basedb'); module.exports = { + EJSON, BaseDB, MongoDB, createMongoClient, diff --git a/packages/express-apollo/package.json b/packages/express-apollo/package.json index 826b70088..5a0ff42b8 100644 --- a/packages/express-apollo/package.json +++ b/packages/express-apollo/package.json @@ -14,6 +14,7 @@ "@parameter1/base-cms-graphql-fragment-types": "^2.0.0", "apollo-cache-inmemory": "^1.6.6", "apollo-client": "^2.6.10", + "apollo-link-context": "^1.0.20", "apollo-link-http": "^1.5.17", "node-fetch": "^2.6.1" }, diff --git a/packages/express-apollo/src/create-client.js b/packages/express-apollo/src/create-client.js index 29ee13fe3..069f3dbc1 100644 --- a/packages/express-apollo/src/create-client.js +++ b/packages/express-apollo/src/create-client.js @@ -2,6 +2,7 @@ const fetch = require('node-fetch'); const { ApolloClient } = require('apollo-client'); const { InMemoryCache } = require('apollo-cache-inmemory'); const { createHttpLink } = require('apollo-link-http'); +const { setContext } = require('apollo-link-context'); const fragmentMatcher = require('@parameter1/base-cms-graphql-fragment-types/fragment-matcher'); const rootConfig = { @@ -9,9 +10,16 @@ const rootConfig = { ssrMode: true, }; -module.exports = (uri, config, linkConfig) => new ApolloClient({ - ...config, - ...rootConfig, - link: createHttpLink({ fetch, ...linkConfig, uri }), - cache: new InMemoryCache({ fragmentMatcher }), -}); +module.exports = (uri, config, linkConfig, contextFn) => { + const contextLink = setContext((ctx) => { + if (typeof contextFn === 'function') return contextFn(ctx); + return undefined; + }); + const httpLink = createHttpLink({ fetch, ...linkConfig, uri }); + return new ApolloClient({ + ...config, + ...rootConfig, + link: contextLink.concat(httpLink), + cache: new InMemoryCache({ fragmentMatcher }), + }); +}; diff --git a/packages/express-apollo/src/middleware.js b/packages/express-apollo/src/middleware.js index 5bc7470bb..27cfa4ee9 100644 --- a/packages/express-apollo/src/middleware.js +++ b/packages/express-apollo/src/middleware.js @@ -1,7 +1,10 @@ const createClient = require('./create-client'); -module.exports = (uri, config, linkConfig) => (req, res, next) => { - const apollo = createClient(uri, config, linkConfig); +module.exports = (uri, config, linkConfig, contextFn) => (req, res, next) => { + const apollo = createClient(uri, config, linkConfig, (ctx) => { + if (typeof contextFn === 'function') return contextFn({ req, res, ctx }); + return undefined; + }); req.apollo = apollo; res.locals.apollo = apollo; next(); diff --git a/packages/marko-web/express/apollo.js b/packages/marko-web/express/apollo.js index 0781235c8..956f2829d 100644 --- a/packages/marko-web/express/apollo.js +++ b/packages/marko-web/express/apollo.js @@ -1,5 +1,20 @@ const { apolloClient } = require('@parameter1/base-cms-express-apollo'); +const { parseBooleanHeader } = require('@parameter1/base-cms-utils'); module.exports = (app, uri, config = {}) => { - app.use(apolloClient(uri, config, config.link)); + /** + * Force sets GraphQL cache headers when cookies are present on the site. + * This can either force enable cache when globally disabled, or force disable cache + * when globally enabled. + */ + const contextFn = ({ req }) => { + const headers = ['x-cache-site-context', 'x-cache-responses'].reduce((o, key) => { + const cookie = req.cookies[key]; + if (!cookie) return o; // do nothing if no cookie is set. + // otherwise parse the value and set the GraphQL header to match. + return { ...o, [key]: parseBooleanHeader(cookie) }; + }, {}); + return { headers }; + }; + app.use(apolloClient(uri, config, config.link, contextFn)); }; diff --git a/packages/marko-web/express/index.js b/packages/marko-web/express/index.js index d296b2285..13422149a 100644 --- a/packages/marko-web/express/index.js +++ b/packages/marko-web/express/index.js @@ -24,6 +24,8 @@ module.exports = (config = {}) => { siteId, sitePackage, graphqlUri, + gqlCacheResponses, + gqlCacheSiteContext, } = config; const distDir = path.resolve(rootDir, 'dist'); const app = express(); @@ -70,7 +72,11 @@ module.exports = (config = {}) => { }); // Register apollo client and server proxy. - const headers = buildRequestHeaders({ tenantKey, siteId }); + const headers = { + ...buildRequestHeaders({ tenantKey, siteId }), + ...(gqlCacheSiteContext && { 'x-cache-site-context': 'true' }), + ...(gqlCacheResponses && { 'x-cache-responses': 'true' }), + }; apollo(app, graphqlUri, { name: sitePackage.name, version: sitePackage.version, diff --git a/packages/marko-web/start-server.js b/packages/marko-web/start-server.js index 5190c14b0..0375998b2 100644 --- a/packages/marko-web/start-server.js +++ b/packages/marko-web/start-server.js @@ -2,7 +2,7 @@ require('marko/node-require'); const http = require('http'); const path = require('path'); const { createTerminus } = require('@godaddy/terminus'); -const { isFunction: isFn } = require('@parameter1/base-cms-utils'); +const { isFunction: isFn, parseBooleanHeader } = require('@parameter1/base-cms-utils'); const errorHandlers = require('./express/error-handlers'); const express = require('./express'); const loadMore = require('./express/load-more'); @@ -37,6 +37,10 @@ module.exports = async ({ redirectHandler, sitemapsHeaders, + // Cache settings. + gqlCacheResponses = parseBooleanHeader(env.CACHE_GQL_RESPONSES), + gqlCacheSiteContext = parseBooleanHeader(env.CACHE_GQL_SITE_CONTEXT), + // Terminus settings. timeout = 1000, signals = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'], @@ -71,6 +75,8 @@ module.exports = async ({ sitePackage, embeddedMediaHandlers, sitemapsHeaders, + gqlCacheResponses, + gqlCacheSiteContext, }); // Await required services here... diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 288090127..3adc16558 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -12,6 +12,7 @@ const getPublishedContentCriteria = require('./get-published-content-criteria'); const isDev = require('./is-dev'); const isFunction = require('./is-function'); const isObject = require('./is-object'); +const parseBooleanHeader = require('./parse-boolean-header'); const parseDelimitedString = require('./parse-delimited-string'); const randomElementId = require('./random-element-id'); const sleep = require('./sleep'); @@ -32,6 +33,7 @@ module.exports = { isDev, isFunction, isObject, + parseBooleanHeader, parseDelimitedString, randomElementId, sleep, diff --git a/packages/utils/src/parse-boolean-header.js b/packages/utils/src/parse-boolean-header.js new file mode 100644 index 000000000..7e12ada65 --- /dev/null +++ b/packages/utils/src/parse-boolean-header.js @@ -0,0 +1,5 @@ +module.exports = (value) => { + const falsey = ['false', '0']; + if (falsey.includes(value)) return false; + return Boolean(value); +}; diff --git a/services/graphql-server/package.json b/services/graphql-server/package.json index 98addf34f..4646cfe8c 100644 --- a/services/graphql-server/package.json +++ b/services/graphql-server/package.json @@ -40,6 +40,7 @@ "graphql-tools": "^4.0.8", "graphql-type-json": "^0.2.4", "helmet": "^3.23.3", + "ioredis": "^4.19.4", "jsonwebtoken": "^8.5.1", "micro": "^9.3.4", "moment": "^2.29.1", diff --git a/services/graphql-server/src/dataloaders/utils/run-queries.js b/services/graphql-server/src/dataloaders/utils/run-queries.js index 643bac14d..ff5138587 100644 --- a/services/graphql-server/src/dataloaders/utils/run-queries.js +++ b/services/graphql-server/src/dataloaders/utils/run-queries.js @@ -8,6 +8,7 @@ const hashOptions = { encoding: 'base64', replacer: (v) => { if (v instanceof ObjectID) return `${v}`; + if (typeof v === 'object' && /^[a-f0-9]{24}$/.test(v)) return `${v}`; return v; }, }; diff --git a/services/graphql-server/src/env.js b/services/graphql-server/src/env.js index ff3206a17..a17413e4c 100644 --- a/services/graphql-server/src/env.js +++ b/services/graphql-server/src/env.js @@ -12,6 +12,7 @@ const { nonemptystr } = custom; module.exports = cleanEnv(process.env, { MONGO_DSN: nonemptystr({ desc: 'The Base MongoDB connection URL.' }), + REDIS_CACHE_DSN: nonemptystr({ desc: 'The Redis DSN where cache values should be saved.' }), GRAPHQL_ENDPOINT: nonemptystr({ desc: 'The GraphQL endpoint', default: '/' }), PORT: port({ desc: 'The internal port to run on.', default: 80 }), EXPOSED_PORT: port({ desc: 'The external port to run on.', default: 80 }), diff --git a/services/graphql-server/src/graphql/plugins/redis-cache.js b/services/graphql-server/src/graphql/plugins/redis-cache.js new file mode 100644 index 000000000..60ac22329 --- /dev/null +++ b/services/graphql-server/src/graphql/plugins/redis-cache.js @@ -0,0 +1,144 @@ +const { createHash } = require('crypto'); +const { parseBooleanHeader } = require('@parameter1/base-cms-utils'); +const redis = require('../../redis'); + +/** + * + * @param {GraphQLRequestContext} requestContext + */ +const isGraphQLQuery = (requestContext) => { + const { operation } = requestContext; + return operation && operation.operation === 'query'; +}; + +/** + * + * @param {GraphQLRequestContext} requestContext + */ +const isIntrospectionQuery = (requestContext) => { + const { operation } = requestContext; + return operation.selectionSet.selections.every((selection) => { + const fieldName = selection.name.value; + return fieldName.startsWith('__'); + }); +}; + +/** + * + * @param {object} obj The object to use to generate the key. + */ +const stringifyCacheKey = obj => createHash('sha256').update(JSON.stringify(obj)).digest('hex'); + +/** + * + * @param {string} tenant The tenant key. Is used in the key prefix. + * @param {object} obj The object to use to generate the key. + */ +const createKey = (tenant, obj) => `base_gql:${tenant}:${stringifyCacheKey(obj)}`; + +const setHeader = (http, key, value) => { + if (http) http.headers.set(key, `${value}`); +}; + +/** + * @todo Eventually this should adhere to `@cacheControl` directives. + * @todo Adjust header-based cache enabling to hook function + */ +class RedisCacheGraphQLPlugin { + /** + * + * @param {object} params + * @param {boolean} [params.enabled=true] Whether cache is globally enabled + * @param {function} [params.onCacheError] A function to invoke when a cache-set error is thrown. + */ + constructor({ enabled = true, onCacheError } = {}) { + this.enabled = enabled; + this.redis = redis; + this.onCacheError = onCacheError; + } + + /** + * + */ + requestDidStart() { + let cacheKeyObj; + let cacheKey; + let age; + + return { + /** + * + * @param {GraphQLRequestContext} requestContext + */ + responseForOperation: async (requestContext) => { + if (!this.canCache(requestContext)) return null; + const { context } = requestContext; + + cacheKeyObj = { + source: requestContext.source, // the raw query source string + operationName: requestContext.operationName, // the op name (if set) + variables: { ...(requestContext.request.variables || {}) }, // all query vars + context: { siteId: context.site.id() }, // specific context variables + }; + + cacheKey = createKey(context.tenant, cacheKeyObj); + + const serialized = await this.redis.get(cacheKey); + if (!serialized) return null; + + const cacheValue = JSON.parse(serialized); + // eslint-disable-next-line no-param-reassign + requestContext.metrics.responseCacheHit = true; + age = Math.round((Date.now() - cacheValue.cacheTime) / 1000); + return { data: cacheValue.data }; + }, + + /** + * + * @param {GraphQLRequestContext} requestContext + */ + willSendResponse: (requestContext) => { + if (!this.canCache(requestContext)) return; + const { response } = requestContext; + const { http } = response; + const { responseCacheHit: hit } = requestContext.metrics; + setHeader(http, 'x-cache', hit ? 'hit' : 'miss'); + + if (hit) { + // do not write cache for cached responses. but set the age header. + if (age != null) setHeader(http, 'age', age); + return; + } + + const cacheValue = { + data: response.data, + cacheTime: Date.now(), + }; + if (!cacheKey) throw new Error('Unable to get cache key from previous hook.'); + + // set cache but do not await + const { onCacheError } = this; + redis.set(cacheKey, JSON.stringify(cacheValue), 'EX', 30).catch((e) => { + if (typeof onCacheError === 'function') onCacheError(e); + }); + }, + }; + } + + /** + * + * @param {GraphQLRequestContext} requestContext + */ + canCache(requestContext) { + if (!this.enabled) return false; + const { request, response } = requestContext; + const { http } = request; + if (!http) return false; + const cacheEnabled = parseBooleanHeader(http.headers.get('x-cache-responses')); + if (!cacheEnabled) return false; + const { errors } = response || {}; + return isGraphQLQuery(requestContext) && !isIntrospectionQuery(requestContext) && !errors; + } +} + +module.exports = RedisCacheGraphQLPlugin; diff --git a/services/graphql-server/src/graphql/types/object-id.js b/services/graphql-server/src/graphql/types/object-id.js index 4a4dd1593..c9880294b 100644 --- a/services/graphql-server/src/graphql/types/object-id.js +++ b/services/graphql-server/src/graphql/types/object-id.js @@ -20,6 +20,7 @@ module.exports = new GraphQLScalarType({ if (typeof value === 'string') { return value; } + if (/^[a-f0-9]{24}$/.test(value)) return `${value}`; throw new Error(`${Object.getPrototypeOf(value).constructor.name} not convertible to string.`); }, parseLiteral(ast) { diff --git a/services/graphql-server/src/redis.js b/services/graphql-server/src/redis.js new file mode 100644 index 000000000..2eb1b5f1b --- /dev/null +++ b/services/graphql-server/src/redis.js @@ -0,0 +1,4 @@ +const Redis = require('ioredis'); +const { REDIS_CACHE_DSN } = require('./env'); + +module.exports = new Redis(REDIS_CACHE_DSN); diff --git a/services/graphql-server/src/routes/graphql.js b/services/graphql-server/src/routes/graphql.js index 05104146e..8ac622d38 100644 --- a/services/graphql-server/src/routes/graphql.js +++ b/services/graphql-server/src/routes/graphql.js @@ -2,7 +2,7 @@ const { ApolloServer } = require('apollo-server-express'); const { get } = require('@parameter1/base-cms-object-path'); const { getFromRequest } = require('@parameter1/base-cms-tenant-context'); const { Router } = require('express'); -const { isObject } = require('@parameter1/base-cms-utils'); +const { isObject, parseBooleanHeader } = require('@parameter1/base-cms-utils'); const { requestParser: canonicalRules } = require('@parameter1/base-cms-canonical-path'); const ApolloNewrelicExtension = require('apollo-newrelic-extension'); const createAuthContext = require('../auth-context/create'); @@ -24,6 +24,7 @@ const { GRAPHQL_PLAYGROUND_ENABLED, GRAPHQL_TRACING_ENABLED, } = require('../env'); +const RedisCacheGraphQLPlugin = require('../graphql/plugins/redis-cache'); const { keys } = Object; const router = Router(); @@ -57,7 +58,12 @@ const server = new ApolloServer({ const loaders = createLoaders(basedb); // Load the (optional) site context from the database. - const site = await loadSiteContext({ siteId, basedb, tenant }); + const site = await loadSiteContext({ + siteId, + basedb, + tenant, + enableCache: parseBooleanHeader(req.get('x-cache-site-context')), + }); // Load the (optional) Base4 REST API client. // Some GraphQL mutations require this. @@ -99,6 +105,9 @@ const server = new ApolloServer({ if (code === 'INTERNAL_SERVER_ERROR') newrelic.noticeError(e); return e; }, + plugins: [ + new RedisCacheGraphQLPlugin({ onCacheError: newrelic.noticeError.bind(newrelic) }), + ], }); server.applyMiddleware({ app: router, path: GRAPHQL_ENDPOINT }); diff --git a/services/graphql-server/src/services.js b/services/graphql-server/src/services.js index 2fc5fa011..af7abcceb 100644 --- a/services/graphql-server/src/services.js +++ b/services/graphql-server/src/services.js @@ -1,8 +1,14 @@ const { filterDsn } = require('@parameter1/base-cms-db/utils'); const basedb = require('./basedb')('test'); +const redis = require('./redis'); const { log } = require('./output'); const pkg = require('../package.json'); +const redisConnect = new Promise((resolve, reject) => { + redis.on('connect', resolve); + redis.on('error', reject); +}); + const pingWriteArgs = [{ _id: pkg.name }, { $set: { last: new Date() } }, { upsert: true }]; const start = (name, promise, url) => { @@ -27,15 +33,18 @@ const ping = (name, promise) => promise.then(() => `${name} pinged successfully. module.exports = { start: () => Promise.all([ start('BaseDB', basedb.client.connect(), filterDsn), + start('Redis', redisConnect), ]), stop: () => Promise.all([ stop('BaseDB', basedb.client.close()), + stop('Redis', redis.quit()), ]), ping: async () => { const collection = await basedb.client.collection('platform', 'pings'); return Promise.all([ ping('BaseDB', basedb.client.command({ ping: 1 })), ping('BaseDB write', collection.updateOne(...pingWriteArgs)), + ping('Redis', redis.ping()), ]); }, }; diff --git a/services/graphql-server/src/site-context/load.js b/services/graphql-server/src/site-context/load.js index eec115ae5..61238f159 100644 --- a/services/graphql-server/src/site-context/load.js +++ b/services/graphql-server/src/site-context/load.js @@ -1,25 +1,49 @@ -const { MongoDB } = require('@parameter1/base-cms-db'); +const { MongoDB, EJSON } = require('@parameter1/base-cms-db'); +const newrelic = require('../newrelic'); +const redis = require('../redis'); const SiteContext = require('./index'); const { ObjectID } = MongoDB; -module.exports = async ({ basedb, siteId, tenant }) => { +module.exports = async ({ + basedb, + siteId, + tenant, + enableCache, +}) => { if (!siteId) return new SiteContext(); - const projection = { - name: 1, - host: 1, - decription: 1, - language: 1, - imageHost: 1, - assetHost: 1, - date: 1, - }; - const site = await basedb.findOne('platform.Product', { - status: 1, - type: 'Site', - _id: new ObjectID(siteId), - }, { projection }); + const cacheKey = `base_gql_site:${siteId}`; + + let hit = false; + let site; + + // attempt to load site from cache. + const serialized = enableCache ? await redis.get(cacheKey) : null; + if (serialized) { + hit = true; + site = EJSON.parse(serialized); + } else { + const projection = { + name: 1, + host: 1, + decription: 1, + language: 1, + imageHost: 1, + assetHost: 1, + date: 1, + }; + site = await basedb.findOne('platform.Product', { + status: 1, + type: 'Site', + _id: new ObjectID(siteId), + }, { projection }); + } + if (!site) throw new Error(`No site was found for tenant '${tenant}' using ID '${siteId}'`); if (!site.host) throw new Error(`No site host is set for tenant '${tenant}' using ID '${siteId}'`); + + // set the site object to cache, but do not await + if (enableCache && !hit) redis.set(cacheKey, EJSON.stringify(site), 'EX', 600).catch(newrelic.noticeError.bind(newrelic)); + return new SiteContext(site); }; diff --git a/yarn.lock b/yarn.lock index f8b3faa8d..191bc8372 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3714,6 +3714,14 @@ apollo-graphql@^0.6.0: apollo-env "^0.6.5" lodash.sortby "^4.7.0" +apollo-link-context@^1.0.20: + version "1.0.20" + resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.20.tgz#1939ac5dc65d6dff0c855ee53521150053c24676" + integrity sha512-MLLPYvhzNb8AglNsk2NcL9AvhO/Vc9hn2ZZuegbhRHGet3oGr0YH9s30NS9+ieoM0sGT11p7oZ6oAILM/kiRBA== + dependencies: + apollo-link "^1.2.14" + tslib "^1.9.3" + apollo-link-http-common@^0.2.16: version "0.2.16" resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz#756749dafc732792c8ca0923f9a40564b7c59ecc" @@ -5456,6 +5464,11 @@ cloneable-readable@^1.0.0: process-nextick-args "^2.0.0" readable-stream "^2.3.5" +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + coa@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" @@ -6540,7 +6553,7 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -denque@^1.4.1: +denque@^1.1.0, denque@^1.4.1: version "1.5.0" resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== @@ -9875,6 +9888,22 @@ invert-kv@^2.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== +ioredis@^4.19.4: + version "4.19.4" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.19.4.tgz#11112005f87ad3acac247ada3b22eb31b947f7c7" + integrity sha512-3haQWw9dpEjcfVcRktXlayVNrrqvvc2io7Q/uiV2UsYw8/HC2YwwJr78Wql7zu5bzwci0x9bZYA69U7KkevAvw== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.1.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + p-map "^2.1.0" + redis-commands "1.6.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.0.1" + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -10986,6 +11015,11 @@ lodash.defaultsdeep@^4.6.1: resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -14397,6 +14431,23 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redis-commands@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.6.0.tgz#36d4ca42ae9ed29815cdb30ad9f97982eba1ce23" + integrity sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + referrer-policy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e" @@ -15588,6 +15639,11 @@ stackframe@^1.1.0: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.0.tgz#e3fc2eb912259479c9822f7d1f1ff365bd5cbc83" integrity sha512-Vx6W1Yvy+AM1R/ckVwcHQHV147pTPBKWCRLrXMuPrFVfvBUc3os7PR1QLIWCMhPpRg5eX9ojzbQIMLGBwyLjqg== +standard-as-callback@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.0.1.tgz#ed8bb25648e15831759b6023bdb87e6b60b38126" + integrity sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg== + state-toggle@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.1.tgz#c3cb0974f40a6a0f8e905b96789eb41afa1cde3a"