From 8c921c8f7da44d59ec9a3a098cb6c3579bcdb840 Mon Sep 17 00:00:00 2001
From: "richard.petersen" <richard.petersen@open-xchange.com>
Date: Wed, 4 May 2022 11:50:01 +0000
Subject: [PATCH] Add redis-based cache to allow scalability

---
 .env.defaults                                 |   2 +
 .gitlab-ci.yml                                |  17 +-
 .gitlab/autodeploy/kubernetes-resources.yaml  | 217 ++++++++----
 .gitlab/autodeploy/values.yaml                |   6 +-
 README.md                                     |  18 +-
 .../templates/deployment.yaml                 |  10 +
 helm/core-ui-middleware/values.yaml           |   7 +
 helm/values/develop.yaml                      |  17 -
 integration/.eslintrc                         |   6 +
 integration/.mocharc.cjs                      |   3 +
 integration/caching_test.js                   |  63 ++++
 integration/update-version_test.js            | 131 +++++++
 package.json                                  |   8 +-
 .mocharc.cjs => spec/.mocharc.cjs             |   0
 spec/file-depencies_test.js                   |   8 +-
 spec/file_caching_test.js                     | 197 +++++++++--
 spec/headers_test.js                          |  25 +-
 spec/meta_test.js                             |   7 +-
 spec/redirect_test.js                         |   8 +-
 spec/redis_test.js                            |   5 +
 spec/server_test.js                           |  75 +---
 spec/startup_test.js                          |  57 ---
 spec/util.js                                  |  14 +-
 src/create-app.js                             |  54 +++
 src/create-queues.js                          |  15 +
 src/createApp.js                              | 159 ---------
 src/errors.js                                 |   1 +
 src/files.js                                  | 179 ++++------
 src/index.js                                  |   4 +-
 src/manifests.js                              | 151 +++-----
 src/meta.js                                   |  18 +-
 src/middlewares/final-handler.js              |  18 +
 src/middlewares/load-from-cache.js            |  20 ++
 src/middlewares/load-from-server.js           |  22 ++
 src/middlewares/save-to-cache.js              |  20 ++
 src/middlewares/version.js                    |  15 +
 src/redis.js                                  |  66 ++++
 src/routes/health.js                          |  22 ++
 src/routes/manifests.js                       |  18 +
 src/routes/metadata.js                        |  17 +
 src/routes/redirects.js                       |  17 +
 src/util.js                                   |  17 +
 src/version.js                                |  63 ++++
 yarn.lock                                     | 334 +++++++++++++++++-
 44 files changed, 1481 insertions(+), 650 deletions(-)
 delete mode 100644 helm/values/develop.yaml
 create mode 100644 integration/.eslintrc
 create mode 100644 integration/.mocharc.cjs
 create mode 100644 integration/caching_test.js
 create mode 100644 integration/update-version_test.js
 rename .mocharc.cjs => spec/.mocharc.cjs (100%)
 create mode 100644 spec/redis_test.js
 delete mode 100644 spec/startup_test.js
 create mode 100644 src/create-app.js
 create mode 100644 src/create-queues.js
 delete mode 100644 src/createApp.js
 create mode 100644 src/errors.js
 create mode 100644 src/middlewares/final-handler.js
 create mode 100644 src/middlewares/load-from-cache.js
 create mode 100644 src/middlewares/load-from-server.js
 create mode 100644 src/middlewares/save-to-cache.js
 create mode 100644 src/middlewares/version.js
 create mode 100644 src/redis.js
 create mode 100644 src/routes/health.js
 create mode 100644 src/routes/manifests.js
 create mode 100644 src/routes/metadata.js
 create mode 100644 src/routes/redirects.js
 create mode 100644 src/version.js

diff --git a/.env.defaults b/.env.defaults
index f72b89e..76d680e 100644
--- a/.env.defaults
+++ b/.env.defaults
@@ -3,3 +3,5 @@ CACHE_TTL=30000
 PORT=8080
 LOG_LEVEL=info
 APP_ROOT=/
+
+REDIS_PREFIX=ui-middleware
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8c0776b..059d11d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,13 +1,22 @@
 include:
   - project: 'sre/ci-building-blocks'
     file: 'nodejs.yml'
-    ref: '3.0.0'
+    ref: '3.1.0'
 
-variables:
-  OX_COMPONENT: core-ui-middleware
+integration tests:
+  extends: .unit tests
+  services:
+    - redis:latest
+  script:
+    - yarn --non-interactive --no-progress -s
+    - yarn integration --ci --coverage
+  variables:
+    # app specific settings
+    REDIS_HOST: redis
 
 deploy helm chart:
   extends: .auto-deploy-helm-chart
-  after_script:
+  script:
+    - !reference [.auto-deploy-helm-chart, script]
     - envsubst < .gitlab/autodeploy/kubernetes-resources.yaml > tmp-k8s-resources.yaml
     - kubectl apply -f tmp-k8s-resources.yaml
diff --git a/.gitlab/autodeploy/kubernetes-resources.yaml b/.gitlab/autodeploy/kubernetes-resources.yaml
index 51a09a8..3d4a3a7 100644
--- a/.gitlab/autodeploy/kubernetes-resources.yaml
+++ b/.gitlab/autodeploy/kubernetes-resources.yaml
@@ -1,86 +1,149 @@
 apiVersion: v1
 kind: Service
 metadata:
-  name: ${OX_COMPONENT}-mw-http-api
+  name: main-core-mw-http-api
 spec:
   type: ExternalName
-  externalName: main-core-mw-http-api.appsuite-stack-1494-main.svc.cluster.local
+  externalName: main-core-mw-http-api.main-e2e-stack.svc.cluster.local
   ports:
-    - name: http
-      protocol: TCP
-      port: 80
-      targetPort: 80
-
+    - port: 80
+      name: http
 ---
-apiVersion: networking.k8s.io/v1
-kind: Ingress
+apiVersion: networking.istio.io/v1alpha3
+kind: VirtualService
 metadata:
-  name: ${OX_COMPONENT}
+  name: preview-app
 spec:
-  rules:
-    - host: ui-middleware-${CI_COMMIT_REF_SLUG}.k3s.os2.oxui.de
-      http:
-        paths:
-          - path: /
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}
-                port:
-                  name: http
-          - path: /api/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /ajax/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /servlet/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /realtime/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /infostore/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /socket.io/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /oxguard/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
-          - path: /webservices/
-            pathType: Prefix
-            backend:
-              service:
-                name: ${OX_COMPONENT}-mw-http-api
-                port:
-                  name: http
+  gateways:
+    - mesh
+    - istio-system/default-gateway
+  hosts:
+    - "${PREVIEW_APP_NAME}.dev.oxui.de"
+  http:
+    - match:
+        - uri:
+            prefix: /api/oxguard/
+      name: guard-routes
+      rewrite:
+        uri: /oxguard/
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /pks/
+      name: guard-pgp-routes
+      rewrite:
+        uri: /oxguard/pgp/
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /appsuite/api/
+      name: appsuite-api
+      rewrite:
+        uri: /api/
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /api
+      name: api-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /ajax/
+      name: ajax-routes
+      rewrite:
+        uri: /api/
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /servlet/
+      name: servlet-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /realtime/
+      name: realtime-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /infostore/
+      name: infostore-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /socket.io/
+      name: socket-io-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /rt2/
+      name: rt2-routes
+      rewrite:
+        uri: /rt2/
+      route:
+        - destination:
+            host: main-core-mw-http-api
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /webservices/
+      name: soap-routes
+      route:
+        - destination:
+            host: main-core-mw-http-api
+    - match:
+        - uri:
+            prefix: /office
+      name: office-service
+      rewrite:
+        uri: /
+      route:
+        - destination:
+            host: ${OX_COMPONENT}
+            port:
+              number: 80
+    - match:
+        - uri:
+            prefix: /
+      name: ui-middleware
+      route:
+        - destination:
+            host: ${OX_COMPONENT}
+            port:
+              number: 80
\ No newline at end of file
diff --git a/.gitlab/autodeploy/values.yaml b/.gitlab/autodeploy/values.yaml
index 3b6a056..36753db 100644
--- a/.gitlab/autodeploy/values.yaml
+++ b/.gitlab/autodeploy/values.yaml
@@ -3,7 +3,11 @@ replicaCount: 1
 containerPort: 8080
 
 baseUrls:
-  - http://main-core-ui.appsuite-stack-1494-main.svc.cluster.local
+  - http://main-core-ui.main-e2e-stack.svc.cluster.local
+
+redis:
+  host: main-redis-master.main-e2e-stack.svc.cluster.local
+  prefix: ${CI_COMMIT_REF_SLUG}-${OX_COMPONENT}
 
 ingress:
   enabled: false
diff --git a/README.md b/README.md
index 0eddaec..2ade22c 100644
--- a/README.md
+++ b/README.md
@@ -42,14 +42,22 @@ It is possible to horizontally scale the UI Middleware, as more clients are fetc
 | `PORT`          | Exposed port                    | `"8080"` |
 | `CACHE_TTL`     | Vite manifest caching time      | `30000`  |
 | `LOG_LEVEL`     | Pino log level                  | `"info"` |
+| `REDIS_HOST`    | Redis host (required)           |          |
+| `REDIS_PORT`    | Redis port (optional)           | `6379`   |
+| `REDIS_DB`      | Redis DB, e.g. `"1"` (optional) | null     |
+| `REDIS_PASSWORD`| Redis password (optional)       | null     |
 
 **kubernetes**
 
-| Parameter      | Description                     | Default  |
-|----------------|---------------------------------|----------|
-| `port`         | Exposed port                    | `"8080"` |
-| `cacheTTL`     | Vite manifest caching time      | `30000`  |
-| `logLevel`     | Pino log level                  | `"info"` |
+| Parameter       | Description                     | Default  |
+|-----------------|---------------------------------|----------|
+| `port`          | Exposed port                    | `"8080"` |
+| `cacheTTL`      | Vite manifest caching time      | `30000`  |
+| `logLevel`      | Pino log level                  | `"info"` |
+| `redis.host`    | Redis host                      |          |
+| `redis.port`    | Redis port (optional)           | `6379`   |
+| `redis.db`      | Redis DB, e.g. `"1"` (optional) | null     |
+| `redis.password`| Redis password (optional)       | null     |
 
 ## Ingress
 
diff --git a/helm/core-ui-middleware/templates/deployment.yaml b/helm/core-ui-middleware/templates/deployment.yaml
index a3f173a..a394d48 100644
--- a/helm/core-ui-middleware/templates/deployment.yaml
+++ b/helm/core-ui-middleware/templates/deployment.yaml
@@ -27,6 +27,16 @@ spec:
               value: "{{ .Values.logLevel }}"
             - name: APP_ROOT
               value: "{{ .Values.appRoot }}"
+            - name: REDIS_HOST
+              value: "{{ required "redis.host required" .Values.redis.host }}"
+            - name: REDIS_PORT
+              value: "{{ .Values.redis.port | default 6379 }}"
+            - name: REDIS_DB
+              value: "{{ .Values.redis.db | int }}"
+            - name: REDIS_PASSWORD
+              value: "{{ .Values.redis.password }}"
+            - name: REDIS_PREFIX
+              value: "{{ .Values.redis.prefix }}"
           ports:
             - name: http
               containerPort: {{ .Values.containerPort | default 8080 }}
diff --git a/helm/core-ui-middleware/values.yaml b/helm/core-ui-middleware/values.yaml
index 1181c57..a9bb9fe 100644
--- a/helm/core-ui-middleware/values.yaml
+++ b/helm/core-ui-middleware/values.yaml
@@ -103,3 +103,10 @@ cacheTTL: 30000
 logLevel: info
 baseUrls: []
 appRoot: '/'
+
+redis:
+  host: ''
+  port: 6379
+  db: 0
+  password: null
+  prefix: ui-middleware
diff --git a/helm/values/develop.yaml b/helm/values/develop.yaml
deleted file mode 100644
index 0108291..0000000
--- a/helm/values/develop.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-replicaCount: 1
-containerPort: 8080
-baseUrls:
-  - https://ui-middleware-dummy.k3s.os.oxui.de/manifest.json
-ingress:
-  enabled: true
-  hosts:
-    - host: ui-middleware-${CI_COMMIT_REF_SLUG}.k3s.os.oxui.de
-      paths:
-        - path: /
-          pathType: ImplementationSpecific
-
-image:
-  tag: main
-  pullPolicy: Always
-imagePullSecrets:
-  - name: gitlab-registry-credentials
diff --git a/integration/.eslintrc b/integration/.eslintrc
new file mode 100644
index 0000000..4016c2b
--- /dev/null
+++ b/integration/.eslintrc
@@ -0,0 +1,6 @@
+{
+  "extends": [
+    "plugin:mocha/recommended"
+  ],
+  "plugins": ["mocha"]
+}
diff --git a/integration/.mocharc.cjs b/integration/.mocharc.cjs
new file mode 100644
index 0000000..c02759d
--- /dev/null
+++ b/integration/.mocharc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+  spec: ['integration/**/*_test.js'],
+}
\ No newline at end of file
diff --git a/integration/caching_test.js b/integration/caching_test.js
new file mode 100644
index 0000000..0a15a5e
--- /dev/null
+++ b/integration/caching_test.js
@@ -0,0 +1,63 @@
+import request from 'supertest'
+import { expect } from 'chai'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from '../spec/util.js'
+import { Response } from 'node-fetch'
+import { client } from '../src/redis.js'
+import * as td from 'testdouble'
+import { getRedisKey } from '../src/util.js'
+
+describe('File caching service', function () {
+  let app
+
+  beforeEach(async function () {
+    await client.flushdb()
+    mockConfig({ urls: ['http://ui-server/'] })
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({
+          'index.html': {}
+        }),
+        '/index.html': () => new Response('<html><head></head><body>it\'s me</body></html>', { headers: { 'content-type': 'text/html' } })
+      }
+    })
+    app = await mockApp()
+  })
+
+  afterEach(async function () {
+    td.reset()
+  })
+
+  it('caches manifest data', async function () {
+    const response = await request(app).get('/manifests')
+    expect(response.statusCode).to.equal(200)
+    const version = response.headers.version
+
+    expect(await client.get(getRedisKey({ version, name: 'viteManifests' }))).to.equal('{"index.html":{"file":"index.html","meta":{"baseUrl":"http://ui-server/"}}}')
+    expect(await client.get(getRedisKey({ version, name: 'oxManifests' }))).to.equal('[]')
+  })
+
+  it('caches html files', async function () {
+    const response = await request(app).get('/index.html')
+    expect(response.statusCode).to.equal(200)
+    const version = response.headers.version
+
+    expect(await client.get(getRedisKey({ version, name: '/index.html:meta' }))).to.equal('{"headers":{"content-type":"text/html","dependencies":false},"sha256Sum":"iFSuC3aK6EN/ASUamuZ+j3xZXI9eBdIlxtVDFjn7y1I="}')
+    expect(await client.get(getRedisKey({ version, name: '/index.html:body' }))).to.equal('<html><head></head><body>it\'s me</body></html>')
+  })
+
+  it('serves files from redis and stores them in local cache', async function () {
+    const version = '12345'
+    await client.set(getRedisKey({ version, name: '/demo.js:meta' }), '{"headers":{"content-type":"application/javascript","dependencies":false}}')
+    await client.set(getRedisKey({ version, name: '/demo.js:body' }), 'console.log("Demo")')
+
+    const response = await request(app).get('/demo.js').set('version', version)
+    expect(response.statusCode).to.equal(200)
+
+    // just for testing purposes, delete the keys from redis to make sure, it is served from local cache
+    await client.del(getRedisKey({ version, name: '/demo.js:meta' }))
+    await client.del(getRedisKey({ version, name: '/demo.js:body' }))
+
+    const response2 = await request(app).get('/demo.js').set('version', version)
+    expect(response2.statusCode).to.equal(200)
+  })
+})
diff --git a/integration/update-version_test.js b/integration/update-version_test.js
new file mode 100644
index 0000000..2a540e6
--- /dev/null
+++ b/integration/update-version_test.js
@@ -0,0 +1,131 @@
+import request from 'supertest'
+import { expect } from 'chai'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from '../spec/util.js'
+import { Response } from 'node-fetch'
+import { client, closeQueue, getQueue, getQueues, pubClient } from '../src/redis.js'
+import * as td from 'testdouble'
+import { getRedisKey } from '../src/util.js'
+
+describe('Updates the version', function () {
+  let app
+
+  beforeEach(async function () {
+    // need to set the redis-prefix. Otherwise, the bull workers will interfere
+    process.env.REDIS_PREFIX = Math.random()
+    await client.flushdb()
+    mockConfig({ urls: ['http://ui-server/'] })
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({
+          'index.html': {}
+        }),
+        '/index.html': () => new Response('<html><head></head><body>it\'s me</body></html>', { headers: { 'content-type': 'text/html' } }),
+        '/meta.json': td.when(td.func()(td.matchers.anything())).thenReturn(
+          new Response(JSON.stringify({ commitSha: '1' }), { headers: { 'Content-Type': 'application/json' } }),
+          new Response(JSON.stringify({ commitSha: '2' }), { headers: { 'Content-Type': 'application/json' } })
+        )
+      }
+    })
+    app = await mockApp()
+  })
+
+  afterEach(async function () {
+    td.reset()
+    process.env.CACHE_TTL = 30000
+    for (const queue of getQueues()) {
+      await closeQueue(queue.name)
+    }
+    // reset, after the queues were removed
+    process.env.REDIS_PREFIX = 'ui-middleware'
+  })
+
+  it('with manually triggered job', async function () {
+    // need to do this with dynamic import such that the mocked config is used
+    await import('../src/create-queues.js').then(({ default: createQueues }) => createQueues())
+
+    const responseBeforeUpdate = await request(app).get('/index.html')
+    expect(responseBeforeUpdate.statusCode).to.equal(200)
+    expect(responseBeforeUpdate.headers.version).to.equal('85101541')
+
+    const job = await getQueue('update-version').add({})
+
+    const newVersion = await job.finished()
+    expect(newVersion).to.equal('85102502')
+
+    const responseAfterUpdate = await request(app).get('/index.html')
+    expect(responseAfterUpdate.statusCode).to.equal(200)
+    expect(responseAfterUpdate.headers.version).to.equal('85102502')
+  })
+
+  it('with automatically triggered job', async function () {
+    process.env.CACHE_TTL = 100
+
+    const responseBeforeUpdate = await request(app).get('/index.html')
+    expect(responseBeforeUpdate.statusCode).to.equal(200)
+    expect(responseBeforeUpdate.headers.version).to.equal('85101541')
+
+    // need to do this with dynamic import such that the mocked config is used
+    await import('../src/create-queues.js').then(({ default: createQueues }) => createQueues())
+
+    // pause the queue to prevent any further updates
+    const queue = getQueue('update-version')
+    await new Promise(resolve => queue.on('global:completed', (jobId, result) => {
+      queue.pause()
+      resolve()
+    }))
+
+    const responseAfterUpdate = await request(app).get('/index.html')
+    expect(responseAfterUpdate.statusCode).to.equal(200)
+    expect(responseAfterUpdate.headers.version).to.equal('85102502')
+  })
+
+  it('receives version update via redis event', async function () {
+    // need to do this with dynamic import such that the mocked config is used
+    await import('../src/create-queues.js').then(({ default: createQueues }) => createQueues())
+
+    const responseBeforeUpdate = await request(app).get('/index.html')
+    expect(responseBeforeUpdate.statusCode).to.equal(200)
+    expect(responseBeforeUpdate.headers.version).to.equal('85101541')
+
+    // just publish event, don't change the value on redis.
+    pubClient.publish(getRedisKey({ name: 'updateLatestVersion' }), '1234')
+    await new Promise(resolve => setTimeout(resolve, 10))
+
+    const responseAfterUpdate = await request(app).get('/index.html')
+    expect(responseAfterUpdate.statusCode).to.equal(200)
+    expect(responseAfterUpdate.headers.version).to.equal('1234')
+  })
+
+  describe('with initial version', function () {
+    beforeEach(async function () {
+      td.reset()
+      // need to set the redis-prefix. Otherwise, the bull workers will interfere
+      process.env.REDIS_PREFIX = Math.random()
+      await client.flushdb()
+      // preconfigure redis
+      await client.set(getRedisKey({ name: 'latestVersion' }), '12345')
+      mockConfig({ urls: ['http://ui-server/'] })
+      mockFetch({
+        'http://ui-server': {
+          '/manifest.json': generateSimpleViteManifest({
+            'index.html': {}
+          }),
+          '/index.html': () => new Response('<html><head></head><body>it\'s me</body></html>', { headers: { 'content-type': 'text/html' } }),
+          '/meta.json': td.when(td.func()(td.matchers.anything())).thenReturn(
+            new Response(JSON.stringify({ commitSha: '1' }), { headers: { 'Content-Type': 'application/json' } }),
+            new Response(JSON.stringify({ commitSha: '2' }), { headers: { 'Content-Type': 'application/json' } })
+          )
+        }
+      })
+      app = await mockApp()
+    })
+
+    it('uses version from redis if present', async function () {
+      app = await mockApp()
+
+      const response = await request(app).get('/index.html')
+      expect(response.statusCode).to.equal(200)
+      expect(response.headers.version).to.equal('12345')
+    })
+  })
+})
diff --git a/package.json b/package.json
index ac66f9a..8ab328c 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
     "start": "node src/index.js",
     "dev": "nodemon index.js",
     "prepare": "husky install",
-    "test": "LOG_LEVEL=error mocha --loader=testdouble",
+    "test": "LOG_LEVEL=error mocha --loader=testdouble --config spec/.mocharc.cjs",
+    "integration": "LOG_LEVEL=error mocha --loader=testdouble --config integration/.mocharc.cjs --exit",
     "release-chart": "cd helm/core-ui-middleware/ && npx --package=@open-xchange/release-it -- release-it",
     "release-app": "npx --package=@open-xchange/release-it@latest -- release-it-auto-keepachangelog",
     "release": "yarn release-chart && yarn release-app"
@@ -20,11 +21,15 @@
     "@cloudnative/health-connect": "^2.1.0",
     "@open-xchange/logging": "^0.0.9",
     "@open-xchange/metrics": "^0.0.1",
+    "bull": "^4.8.2",
     "dotenv-defaults": "^5.0.0",
     "express": "^4.17.1",
     "helmet": "^5.0.1",
+    "http-errors": "^2.0.0",
+    "ioredis": "^4.28.5",
     "js-yaml": "^4.0.0",
     "node-fetch": "^3.1.0",
+    "sinon": "^13.0.1",
     "swagger-ui-express": "^4.1.6"
   },
   "devDependencies": {
@@ -37,6 +42,7 @@
     "eslint-plugin-n": "^15.0.1",
     "eslint-plugin-promise": "^6.0.0",
     "husky": ">=6",
+    "ioredis-mock": "^7.2.0",
     "lint-staged": ">=10",
     "mocha": "^9.2.1",
     "nodemon": "^2.0.7",
diff --git a/.mocharc.cjs b/spec/.mocharc.cjs
similarity index 100%
rename from .mocharc.cjs
rename to spec/.mocharc.cjs
diff --git a/spec/file-depencies_test.js b/spec/file-depencies_test.js
index 8c0bfff..ed85b7a 100644
--- a/spec/file-depencies_test.js
+++ b/spec/file-depencies_test.js
@@ -1,14 +1,16 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
 import { expect } from 'chai'
 import { Response } from 'node-fetch'
 import * as td from 'testdouble'
+import RedisMock from 'ioredis-mock'
 
 describe('JS files with dependencies contain events', function () {
   let app
 
   before(async function () {
     mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
     mockFetch({
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({
@@ -30,6 +32,10 @@ describe('JS files with dependencies contain events', function () {
     app = await mockApp()
   })
 
+  afterEach(async function () {
+    await new RedisMock().flushdb()
+  })
+
   after(function () {
     td.reset()
   })
diff --git a/spec/file_caching_test.js b/spec/file_caching_test.js
index ccad2ea..9e48498 100644
--- a/spec/file_caching_test.js
+++ b/spec/file_caching_test.js
@@ -1,19 +1,24 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
 import fs from 'fs'
 import { expect } from 'chai'
 import * as td from 'testdouble'
 import { Response } from 'node-fetch'
+import RedisMock from 'ioredis-mock'
+import sinon from 'sinon'
 
 const image = fs.readFileSync('./spec/media/image.png')
 const imageStat = fs.statSync('./spec/media/image.png')
+const sandbox = sinon.createSandbox()
 
 describe('File caching service', function () {
   let app
+  let redis
 
-  before(async function () {
+  beforeEach(async function () {
     let count = 0
     mockConfig({ urls: ['http://ui-server/'] })
+    redis = mockRedis()
     mockFetch({
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({
@@ -54,58 +59,43 @@ describe('File caching service', function () {
     app = await mockApp()
   })
 
-  after(function () {
+  afterEach(async function () {
+    await new RedisMock().flushdb()
     td.reset()
   })
 
   it('serves files defined in manifest.json file', async function () {
     const response = await request(app).get('/example.js')
     expect(response.statusCode).to.equal(200)
-    expect(response.headers['content-type']).to.equal('application/javascript')
+    expect(response.headers['content-type']).to.equal('application/javascript; charset=utf-8')
     expect(response.text).to.equal('this is example')
     // expect(response.headers['content-security-policy']).to.contain('sha256-NzZhMTE2Njc2YTgyNTZmZTdlZGVjZDU3YTNmYzRjNmM1OWZkMTI2NjRkYzZmMWM3YTkwMGU3ZTdhNDlhZmVlMwo=')
     const response2 = await request(app).get('/test.txt')
     expect(response2.statusCode).to.equal(200)
-    expect(response2.headers['content-type']).to.equal('text/plain')
+    expect(response2.headers['content-type']).to.equal('text/plain; charset=utf-8')
     expect(response2.text).to.equal('this is test')
   })
 
   it('serves css files', async function () {
     const response = await request(app).get('/main.css')
     expect(response.statusCode).to.equal(200)
-    expect(response.headers['content-type']).to.equal('text/css')
+    expect(response.headers['content-type']).to.equal('text/css; charset=utf-8')
     // expect(response.headers['content-security-policy']).to.contain('sha256-YjRiYWRlYTVhYmM5ZTZkNjE2ZGM4YjcwZWRlNzUxMmU0YjgxY2UxMWExOTI2ZjM1NzM1M2Y2MWJjNmUwMmZjMwo=')
   })
 
   it('serves / as index.html', async function () {
     const response = await request(app).get('/')
     expect(response.statusCode).to.equal(200)
-    expect(response.headers['content-type']).to.equal('text/html')
+    expect(response.headers['content-type']).to.equal('text/html; charset=utf-8')
     expect(response.text).to.equal('<html><head></head><body>it\'s me</body></html>')
   })
 
-  it('adds / to dependencies', async function () {
-    const response = await request(app).get('/dependencies')
-    expect(response.statusCode).to.equal(200)
-    const deps = JSON.parse(response.text)
-    expect(deps['/']).to.deep.equal([])
-  })
-
   it('directly fetches files not referenced in manifest.json files from the upstream servers', async function () {
     const response = await request(app).get('/favicon.ico')
     expect(response.statusCode).to.equal(200)
     expect(response.text).to.equal('not really a favicon, though')
   })
 
-  it('caches files not referenced in manifest.json fetched from upstream servers', async function () {
-    let response = await request(app).get('/test.svg')
-    expect(response.statusCode).to.equal(200)
-    expect(String(response.body)).to.equal('<svg></svg>')
-    response = await request(app).get('/test.svg')
-    expect(response.statusCode).to.equal(200)
-    expect(String(response.body)).to.equal('<svg></svg>')
-  })
-
   it('returns 404 if file can not be resolved', async function () {
     const response = await request(app).get('/unknown-file.txt')
     expect(response.statusCode).to.equal(404)
@@ -115,5 +105,166 @@ describe('File caching service', function () {
     const response = await request(app).get('/image.png')
     expect(response.statusCode).to.equal(200)
     expect(response.body.length === imageStat.size)
+    expect(response.body).to.deep.equal(image)
+  })
+
+  it('only fetches files once', async function () {
+    let spy
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({}),
+        '/example.js': spy = sandbox.spy(() => {
+          return new Response('this is example', { headers: { 'content-type': 'application/javascript' } })
+        })
+      }
+    })
+    app = await mockApp()
+
+    expect(spy.callCount).to.equal(0)
+    let response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+    expect(spy.callCount).to.equal(1)
+    response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+    expect(spy.callCount).to.equal(1)
+  })
+
+  it('only fetches files once, but deliver from local cache', async function () {
+    let spy
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({}),
+        '/example.js': spy = sandbox.spy(() => {
+          return new Response('this is example', { headers: { 'content-type': 'application/javascript' } })
+        })
+      }
+    })
+    app = await mockApp()
+
+    expect(spy.callCount).to.equal(0)
+    let response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+    expect(spy.callCount).to.equal(1)
+    // delete file from redis
+    await redis.client.del(`${response.headers.version}:/example.js:body`)
+    await redis.client.del(`${response.headers.version}:/example.js:meta`)
+    // and fetch once more
+    response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+    expect(spy.callCount).to.equal(1)
+  })
+
+  it('delivers binary files from cache', async function () {
+    let spy
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({}),
+        '/image.png': spy = sandbox.spy(() => {
+          return new Response(image, {
+            headers: {
+              'Content-Type': 'image/png',
+              'Content-Length': imageStat.size
+            }
+          })
+        })
+      }
+    })
+    app = await mockApp()
+    expect(spy.callCount).to.equal(0)
+    let response = await request(app).get('/image.png')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.equal(image)
+    expect(spy.callCount).to.equal(1)
+    response = await request(app).get('/image.png')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.equal(image)
+    expect(spy.callCount).to.equal(1)
+  })
+
+  it('a file is not cached again, if loaded from cache', async function () {
+    const spy = sandbox.spy(redis.client, 'set')
+
+    let response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+
+    // called 4 times.
+    // once for manifests
+    // once for dependencies
+    // two times for for example.js (meta and body)
+    expect(spy.callCount).to.equal(4)
+
+    response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+
+    // should still be called 4 times, because everything is in cache
+    expect(spy.callCount).to.equal(4)
+  })
+
+  it('requests known file only from one server', async function () {
+    let spy1, spy2
+    mockConfig({ urls: ['http://ui-server1/', 'http://ui-server2/'] })
+    // we have example.js in both files. the first one will be overwritten and therefore not be called
+    mockFetch({
+      'http://ui-server1': {
+        '/manifest.json': generateSimpleViteManifest({
+          'example.js': { }
+        }),
+        '/example.js': spy1 = sandbox.spy(() => {
+          return new Response('example', { headers: { 'content-type': 'text/plain' } })
+        })
+      },
+      'http://ui-server2': {
+        '/manifest.json': generateSimpleViteManifest({
+          'example.js': { }
+        }),
+        '/example.js': spy2 = sandbox.spy(() => {
+          return new Response('example', { headers: { 'content-type': 'text/plain' } })
+        })
+      }
+    })
+    app = await mockApp()
+
+    const response = await request(app).get('/example.js')
+    expect(response.statusCode).to.equal(200)
+    expect(spy1.callCount).to.equal(0)
+    expect(spy2.callCount).to.equal(1)
+  })
+
+  it('serves cached files with version', async function () {
+    // we have example.js in both files. the first one will be overwritten and therefore not be called
+    mockFetch({
+      'http://ui-server': {
+        '/manifest.json': generateSimpleViteManifest({
+          'example.js': { }
+        }),
+        '/example.js': td.when(td.func()(td.matchers.anything())).thenReturn(
+          new Response('first', { headers: { 'content-type': 'text/plain' } }),
+          new Response('second', { headers: { 'content-type': 'text/plain' } })
+        )
+      }
+    })
+    app = await mockApp()
+
+    const response1 = await request(app).get('/example.js').set('version', 1234)
+    expect(response1.statusCode).to.equal(200)
+    expect(response1.text).to.equal('first')
+
+    const response2 = await request(app).get('/example.js')
+    expect(response2.statusCode).to.equal(200)
+    expect(response2.text).to.equal('second')
+
+    const latestVersion = response2.headers['latest-version']
+
+    const response3 = await request(app).get('/example.js').set('version', 1234)
+    expect(response3.statusCode).to.equal(200)
+    expect(response3.text).to.equal('first')
+
+    const response4 = await request(app).get('/example.js')
+    expect(response4.statusCode).to.equal(200)
+    expect(response4.text).to.equal('second')
+
+    const response5 = await request(app).get('/example.js').set('version', latestVersion)
+    expect(response5.statusCode).to.equal(200)
+    expect(response5.text).to.equal('second')
   })
 })
diff --git a/spec/headers_test.js b/spec/headers_test.js
index f499f03..3f5aeef 100644
--- a/spec/headers_test.js
+++ b/spec/headers_test.js
@@ -1,14 +1,16 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
 import { expect } from 'chai'
 import { Response } from 'node-fetch'
 import * as td from 'testdouble'
+import RedisMock from 'ioredis-mock'
 
 describe('Responses contain custom headers', function () {
   let app
 
   before(async function () {
     mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
     mockFetch({
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({
@@ -27,9 +29,14 @@ describe('Responses contain custom headers', function () {
         '/main.css': () => new Response('.foo { color: #000; }', { headers: { 'content-type': 'text/css' } })
       }
     })
+    mockRedis()
     app = await mockApp()
   })
 
+  afterEach(async function () {
+    await new RedisMock().flushdb()
+  })
+
   after(function () {
     td.reset()
   })
@@ -40,6 +47,13 @@ describe('Responses contain custom headers', function () {
     expect(response.headers.version).to.equal('3215668592')
   })
 
+  it('serves requested version', async function () {
+    const response = await request(app).get('/index.html.js').set('version', '123456')
+    expect(response.statusCode).to.equal(200)
+    expect(response.headers.version).to.equal('123456')
+    expect(response.headers['latest-version']).to.equal('3215668592')
+  })
+
   it('javascript file contains dependencies', async function () {
     const response = await request(app).get('/index.html.js')
     expect(response.statusCode).to.equal(200)
@@ -49,7 +63,10 @@ describe('Responses contain custom headers', function () {
   describe('with different files', function () {
     beforeEach(async function () {
       td.reset()
+      await new RedisMock().flushdb()
+
       mockConfig({ urls: ['http://ui-server/'] })
+      mockRedis()
       mockFetch({
         'http://ui-server': {
           '/manifest.json': generateSimpleViteManifest({
@@ -72,7 +89,10 @@ describe('Responses contain custom headers', function () {
   describe('with meta.json', function () {
     beforeEach(async function () {
       td.reset()
+      await new RedisMock().flushdb()
+
       mockConfig({ urls: ['http://ui-server/'] })
+      mockRedis()
       mockFetch({
         'http://ui-server': {
           '/manifest.json': generateSimpleViteManifest({
@@ -96,7 +116,10 @@ describe('Responses contain custom headers', function () {
   describe('with different meta.json', function () {
     beforeEach(async function () {
       td.reset()
+      await new RedisMock().flushdb()
+
       mockConfig({ urls: ['http://ui-server/'] })
+      mockRedis()
       mockFetch({
         'http://ui-server': {
           '/manifest.json': generateSimpleViteManifest({
diff --git a/spec/meta_test.js b/spec/meta_test.js
index de22eb0..264e8d2 100644
--- a/spec/meta_test.js
+++ b/spec/meta_test.js
@@ -1,8 +1,9 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
 import { expect } from 'chai'
 import { Response } from 'node-fetch'
 import * as td from 'testdouble'
+import RedisMock from 'ioredis-mock'
 
 describe('Responses contain custom headers', function () {
   let fetchConfig
@@ -10,6 +11,7 @@ describe('Responses contain custom headers', function () {
 
   before(async function () {
     mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
     mockFetch(fetchConfig = {
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({
@@ -26,7 +28,8 @@ describe('Responses contain custom headers', function () {
     td.reset()
   })
 
-  afterEach(function () {
+  afterEach(async function () {
+    await new RedisMock().flushdb()
     delete process.env.APP_VERSION
     delete process.env.BUILD_TIMESTAMP
     delete process.env.CI_COMMIT_SHA
diff --git a/spec/redirect_test.js b/spec/redirect_test.js
index 1981c7d..5bf0aa7 100644
--- a/spec/redirect_test.js
+++ b/spec/redirect_test.js
@@ -1,13 +1,15 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockConfig, mockFetch, mockApp } from './util.js'
+import { generateSimpleViteManifest, mockConfig, mockFetch, mockApp, mockRedis } from './util.js'
 import { expect } from 'chai'
 import * as td from 'testdouble'
+import RedisMock from 'ioredis-mock'
 
 describe('Redirects', function () {
   let app
 
   before(async function () {
     mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
     mockFetch({
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({ 'example.js': 'test' }),
@@ -17,6 +19,10 @@ describe('Redirects', function () {
     app = await mockApp()
   })
 
+  afterEach(async function () {
+    await new RedisMock().flushdb()
+  })
+
   after(function () {
     td.reset()
   })
diff --git a/spec/redis_test.js b/spec/redis_test.js
new file mode 100644
index 0000000..1fc53a4
--- /dev/null
+++ b/spec/redis_test.js
@@ -0,0 +1,5 @@
+describe('Redis', function () {
+  it('first instance updates version', async function () {
+    // TODO
+  })
+})
diff --git a/spec/server_test.js b/spec/server_test.js
index cc7592f..b1d3c54 100644
--- a/spec/server_test.js
+++ b/spec/server_test.js
@@ -1,8 +1,8 @@
 import request from 'supertest'
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
+import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
 import { expect } from 'chai'
 import * as td from 'testdouble'
-import { Response } from 'node-fetch'
+import RedisMock from 'ioredis-mock'
 
 describe('UI Middleware', function () {
   let app
@@ -10,6 +10,7 @@ describe('UI Middleware', function () {
 
   beforeEach(async function () {
     mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
     mockFetch(fetchConfig = {
       'http://ui-server': {
         '/manifest.json': generateSimpleViteManifest({ 'example.js': 'test' }),
@@ -19,8 +20,9 @@ describe('UI Middleware', function () {
     app = await mockApp()
   })
 
-  afterEach(function () {
+  afterEach(async function () {
     td.reset()
+    await new RedisMock().flushdb()
     process.env.CACHE_TTL = 30000
   })
 
@@ -64,73 +66,6 @@ describe('UI Middleware', function () {
       expect(response2.statusCode).to.equal(200)
       expect(response2.body).to.deep.equal([{ namespace: 'test', path: 'example' }])
     })
-
-    it('refreshes manifest data after caching timeout', async function () {
-      process.env.CACHE_TTL = 0
-      app = await mockApp()
-
-      const response = await request(app).get('/manifests')
-      expect(response.statusCode).to.equal(200)
-      expect(response.body).to.deep.equal([{ namespace: 'test', path: 'example' }])
-
-      const refreshedCache = new Promise(resolve => {
-        fetchConfig['http://ui-server'] = {
-          '/manifest.json': generateSimpleViteManifest({ 'example.js': 'other' }),
-          '/example.js': () => {
-            try {
-              return new Response('new content')
-            } finally {
-              resolve()
-            }
-          }
-        }
-      })
-
-      // trigger update
-      await request(app).get('/manifests')
-      // wait some time
-      await refreshedCache
-
-      const response2 = await request(app).get('/manifests')
-      expect(response2.statusCode).to.equal(200)
-      expect(response2.body).to.deep.equal([{ namespace: 'other', path: 'example' }])
-    })
-
-    it('only updates the version hash when the caches are warm', async function () {
-      process.env.CACHE_TTL = 0
-
-      // fetch the file to get the initial version
-      let response = await request(app).get('/example.js')
-      expect(response.text).to.equal('')
-      const version = response.headers.version
-      await new Promise(resolve => setTimeout(resolve, 1))
-
-      // update resources
-      let resolveExampleJs
-      fetchConfig['http://ui-server'] = {
-        '/manifest.json': generateSimpleViteManifest({ 'example.js': 'other' }),
-        '/example.js': () => new Promise(resolve => (resolveExampleJs = resolve))
-      }
-
-      // fetch file again while the update is still processing
-      // this will also trigger the update
-      response = await request(app).get('/example.js')
-      expect(response.text).to.equal('')
-      expect(response.headers.version).to.equal(version)
-
-      // fetch once again. this will not trigger an update of the file-cache
-      response = await request(app).get('/example.js')
-      expect(response.text).to.equal('')
-      expect(response.headers.version).to.equal(version)
-
-      // resolve the response to the example js file. This will finish the cache warmup
-      resolveExampleJs(new Response('new content'))
-
-      // fetch the file again. Content and version should be updated
-      response = await request(app).get('/example.js')
-      expect(response.text).to.equal('new content')
-      expect(response.headers.version).not.to.equal(version)
-    })
   })
 
   describe('multiple configurations', function () {
diff --git a/spec/startup_test.js b/spec/startup_test.js
deleted file mode 100644
index fee579f..0000000
--- a/spec/startup_test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { generateSimpleViteManifest, mockApp, mockConfig, mockFetch } from './util.js'
-import { expect } from 'chai'
-import * as td from 'testdouble'
-import { Response } from 'node-fetch'
-
-describe('Service startup', function () {
-  before(async function () {
-    mockConfig({ urls: ['http://ui-server/'] })
-  })
-
-  after(function () {
-    td.reset()
-  })
-
-  it('only fetches all files once', async function () {
-    const counters = {
-      '/example.js': 0,
-      '/test.txt': 0,
-      '/index.html.js': 0,
-      '/index.html': 0
-    }
-    mockFetch({
-      'http://ui-server': {
-        '/manifest.json': generateSimpleViteManifest({
-          'example.js': { imports: ['test.txt'] },
-          'test.txt': { },
-          'index.html': {
-            file: 'index.html.js',
-            isEntry: true,
-            imports: ['example.js']
-          }
-        }),
-        '/example.js': () => {
-          counters['/example.js']++
-          return new Response('this is example', { headers: { 'content-type': 'application/javascript' } })
-        },
-        '/test.txt': () => {
-          counters['/test.txt']++
-          return new Response('this is test', { headers: { 'content-type': 'text/plain' } })
-        },
-        '/index.html.js': () => {
-          counters['/index.html.js']++
-          return new Response('this is index.html.js', { headers: { 'content-type': 'application/javascript' } })
-        },
-        '/index.html': () => {
-          counters['/index.html']++
-          return new Response('<html><head></head><body>it\'s me</body></html>', { headers: { 'content-type': 'text/html' } })
-        }
-      }
-    })
-    await mockApp()
-
-    Object.entries(counters).forEach(([key, value]) => {
-      expect(value, `${key}`).to.equal(1)
-    })
-  })
-})
diff --git a/spec/util.js b/spec/util.js
index 6449dbf..f7bb5a9 100644
--- a/spec/util.js
+++ b/spec/util.js
@@ -2,6 +2,7 @@ import * as td from 'testdouble'
 import { register } from 'prom-client'
 import request from 'supertest'
 import { Response } from 'node-fetch'
+import RedisMock from 'ioredis-mock'
 
 export function generateSimpleViteManifest (mapping) {
   const viteManifest = {}
@@ -41,9 +42,20 @@ export function mockFetch (servers = {}) {
   })
 }
 
+export function mockRedis (data = {}) {
+  const mock = {
+    isReady () { return Promise.resolve() },
+    client: new RedisMock({ data }),
+    pubClient: new RedisMock(),
+    subClient: new RedisMock()
+  }
+  td.replaceEsm('../src/redis.js', mock)
+  return mock
+}
+
 export async function mockApp () {
   register.clear()
-  const { createApp } = await import('../src/createApp.js')
+  const { createApp } = await import('../src/create-app.js')
   const app = createApp()
   await request(app).get('/ready')
   return app
diff --git a/src/create-app.js b/src/create-app.js
new file mode 100644
index 0000000..dcfeb49
--- /dev/null
+++ b/src/create-app.js
@@ -0,0 +1,54 @@
+import express from 'express'
+import helmet from 'helmet'
+import { httpLogger } from './logger.js'
+import { metricsMiddleware } from '@open-xchange/metrics'
+import swaggerUi from 'swagger-ui-express'
+import yaml from 'js-yaml'
+import fs from 'fs'
+
+import versionMiddleware from './middlewares/version.js'
+import loadFromCacheMiddleware from './middlewares/load-from-cache.js'
+import loadFromUIServersMiddleware from './middlewares/load-from-server.js'
+import saveToCacheMiddleware from './middlewares/save-to-cache.js'
+import finalHandlerMiddleware from './middlewares/final-handler.js'
+
+import health from './routes/health.js'
+import manifestsRouter from './routes/manifests.js'
+import metadataRouter from './routes/metadata.js'
+import redirectsRouter from './routes/redirects.js'
+
+const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8'))
+
+const metricsMiddlewareInstance = metricsMiddleware()
+
+export function createApp () {
+  const app = express()
+  app.use(express.urlencoded({ extended: true }))
+
+  // Application-level middleware
+  app.use(httpLogger)
+  app.use(helmet({
+    contentSecurityPolicy: false,
+    crossOriginEmbedderPolicy: false,
+    originAgentCluster: false,
+    crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }
+  }))
+  app.use(health)
+  app.use(metricsMiddlewareInstance)
+  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument))
+  app.use('/swagger.json', (req, res) => res.json(swaggerDocument))
+  app.timeout = 30000
+
+  app.use(versionMiddleware)
+  app.use(loadFromCacheMiddleware)
+
+  app.use(manifestsRouter)
+  app.use(metadataRouter)
+  app.use(redirectsRouter)
+
+  app.use(loadFromUIServersMiddleware)
+  app.use(saveToCacheMiddleware)
+  app.use(finalHandlerMiddleware)
+
+  return app
+}
diff --git a/src/create-queues.js b/src/create-queues.js
new file mode 100644
index 0000000..7c0dfea
--- /dev/null
+++ b/src/create-queues.js
@@ -0,0 +1,15 @@
+import { getQueue, subClient } from './redis.js'
+import { updateVersionProcessor, registerLatestVersionListener } from './version.js'
+
+export default function createQueues () {
+  const updateVersionQueue = getQueue('update-version')
+  updateVersionQueue.process(updateVersionProcessor)
+  updateVersionQueue.add({}, {
+    jobId: 'update-version-job',
+    repeat: { every: Number(process.env.CACHE_TTL) },
+    removeOnComplete: true
+  })
+
+  // not a queue but though, used by redis
+  registerLatestVersionListener(subClient)
+}
diff --git a/src/createApp.js b/src/createApp.js
deleted file mode 100644
index 564780f..0000000
--- a/src/createApp.js
+++ /dev/null
@@ -1,159 +0,0 @@
-// Ignore paths for logging, metrics and docs
-// Fast, minimalist web framework for node.
-import express from 'express'
-
-// Helmet helps you secure your Express app by setting various HTTP headers
-import helmet from 'helmet'
-
-// Fastest HTTP logger for Node.js in town
-import { httpLogger, logger } from './logger.js'
-// Readiness and liveness checks middleware
-import health from '@cloudnative/health-connect'
-
-// Prometheus middleware for standard api metrics
-import { metricsMiddleware } from '@open-xchange/metrics'
-import promClient from 'prom-client'
-
-// Swagger UI for api-docs
-import swaggerUi from 'swagger-ui-express'
-import yaml from 'js-yaml'
-import fs from 'fs'
-import { getCSSDependenciesFor, getDependencies, getOxManifests, getVersion, loadViteManifests } from './manifests.js'
-import { fileCache } from './files.js'
-import { getMergedMetadata } from './meta.js'
-import { viteManifestToDeps } from './util.js'
-
-const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8'))
-
-const startUpTimeGauge = new promClient.Gauge({
-  name: 'core_ui_middleware_startup_time',
-  help: 'Time to warm up cache'
-})
-
-const metricsMiddlewareInstance = metricsMiddleware()
-
-export function createApp () {
-  // The app returned by express() is in fact a JavaScript Function, designed to be passed to Node’s HTTP servers as a callback to handle requests.
-
-  const app = express()
-  app.use(express.urlencoded({ extended: true }))
-
-  const healthCheck = new health.HealthChecker()
-  const startupCheck = new health.StartupCheck('warmup cache', async function () {
-    const stopTimer = startUpTimeGauge.startTimer()
-    try {
-      const viteManifests = await loadViteManifests({ warmUp: false })
-      // also need to load ox manifests here, to make sure the cache is warm
-      await getOxManifests()
-      const deps = viteManifestToDeps(viteManifests)
-      await fileCache.warmUp(viteManifests, deps)
-    } catch (e) {
-      logger.error(`Failed to get dependencies: ${e.message}`)
-      throw e
-    } finally {
-      stopTimer()
-    }
-  })
-  healthCheck.registerStartupCheck(startupCheck)
-
-  // Application-level middleware
-  app.use(httpLogger)
-  app.use((req, res, next) => {
-    const { sha256Sum } = fileCache.get(req.path)
-    res.locals.sha256Sum = sha256Sum
-    next()
-  })
-  app.use(helmet({
-    contentSecurityPolicy: false,
-    crossOriginEmbedderPolicy: false,
-    originAgentCluster: false,
-    crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }
-  }))
-  app.use('/healthy', health.LivenessEndpoint(healthCheck))
-  app.use('/ready', health.ReadinessEndpoint(healthCheck))
-  app.use(metricsMiddlewareInstance)
-  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument))
-  app.use('/swagger.json', (req, res) => res.json(swaggerDocument))
-  app.timeout = 30000
-
-  app.use(async (req, res, next) => {
-    const version = await getVersion()
-    if (version) res.setHeader('version', version)
-    next()
-  })
-
-  app.get('/manifests', async (req, res, next) => {
-    try {
-      res.json(await getOxManifests())
-    } catch (err) {
-      next(err)
-    }
-  })
-
-  app.get('/dependencies', async (req, res, next) => {
-    try {
-      res.json(Object.assign({ '/': [] }, await getDependencies()))
-    } catch (err) {
-      next(err)
-    }
-  })
-
-  app.get('/meta', async (req, res, next) => {
-    try {
-      res.json(await getMergedMetadata())
-    } catch (err) {
-      next(err)
-    }
-  })
-
-  app.get('/', async (req, res, next) => {
-    const { 'content-type': contentType, content } = fileCache.get('/index.html')
-    if (content) return res.setHeader('content-type', contentType).status(200).send(content)
-    next()
-  })
-
-  // backwards compatibility for 7.10.x
-  // this should hopefully be resolved with an ingress
-  // or proper config. But is used to be safe on all ends
-  app.get('/ui', async (req, res, next) => {
-    res.redirect(process.env.APP_ROOT)
-  })
-
-  app.post('/redirect', (req, res, next) => {
-    const location = req.body.location || '../busy.html'
-    res.redirect(location)
-  })
-
-  app.use(async (req, res, next) => {
-    const { 'content-type': contentType, content } = fileCache.get(req.path)
-    if (content) {
-      const dependencies = await getCSSDependenciesFor(req.path.substr(1))
-      return res
-        .setHeader('content-type', contentType)
-        .setHeader('dependencies', dependencies.join(','))
-        .status(200).send(content)
-    }
-    next()
-  })
-
-  app.use(async (req, res, next) => {
-    try {
-      const { 'content-type': contentType, content } = await fileCache.fetchAndStore(req.path)
-      if (content) {
-        const dependencies = await getCSSDependenciesFor(req.path.substr(1))
-        return res
-          .setHeader('content-type', contentType)
-          .setHeader('dependencies', dependencies.join(','))
-          .status(200).send(content)
-      }
-    } catch (e) {}
-    next()
-  })
-
-  app.use(function (err, req, res, next) {
-    logger.error(err)
-    res.status(500).end()
-  })
-
-  return app
-}
diff --git a/src/errors.js b/src/errors.js
new file mode 100644
index 0000000..0e1f90f
--- /dev/null
+++ b/src/errors.js
@@ -0,0 +1 @@
+export class NotFoundError extends Error {}
diff --git a/src/files.js b/src/files.js
index b787fde..235fd3f 100644
--- a/src/files.js
+++ b/src/files.js
@@ -1,137 +1,82 @@
 import fetch from 'node-fetch'
 import crypto from 'crypto'
 import { config } from './config.js'
-import promClient from 'prom-client'
+import { getRedisKey, isJSFile } from './util.js'
+import { getCSSDependenciesFor, getViteManifests } from './manifests.js'
+import { client } from './redis.js'
 import { logger } from './logger.js'
-import { isJSFile, viteToOxManifest } from './util.js'
+import { NotFoundError } from './errors.js'
 
-async function fetchData (path, baseUrl, appendix) {
-  const response = await fetch(new URL(path, baseUrl))
-  if (!response.ok) throw new Error(`Error fetching file: ${path}`)
-  const resBuffer = await response.arrayBuffer()
-  const appendixLength = appendix?.length || 0
-  const content = Buffer.alloc(resBuffer.byteLength + appendixLength)
-
-  content.fill(Buffer.from(resBuffer), 0, resBuffer.byteLength)
-  if (appendix) content.write(appendix, resBuffer.byteLength)
-
-  const sha256Sum = crypto.createHash('sha256').update(content).digest('base64')
-  return [path, {
-    'content-type': response.headers.get('content-type'),
-    sha256Sum,
-    content
-  }]
-}
-
-const fileCounter = new promClient.Counter({
-  name: 'core_ui_middleware_file_cache_fetches',
-  help: 'Number of fetched files'
-})
-const fileErrorCounter = new promClient.Counter({
-  name: 'core_ui_middleware_file_cache_fetch_errors',
-  help: 'Number of errors while fetching files'
-})
-
-const pullThroughRequestCounter = new promClient.Counter({
-  name: 'core_ui_middleware_file_cache_pull_through_request_count',
-  help: 'Number of files requested after warmup (not referenced in manifest)'
-})
-
-const pullThroughCacheCounter = new promClient.Counter({
-  name: 'core_ui_middleware_file_cache_pull_through_cache_size',
-  help: 'Number of files added after warmup (not referenced in manifest)'
-})
-
-class FileCache {
-  constructor () {
-    this._cache = {}
-    this._manifests = {}
-    this._hash = ''
-    this._dependencies = {}
-    this._oxManifests = {}
-    this._isCaching = false
-  }
+const fileCache = {}
 
-  async warmUp (manifests, deps) {
-    if (this._hash === manifests.__hash__) return logger.debug(`Tried to warm up the filecache with the same version: ${manifests.__hash__}`)
-    if (this._isCaching) return logger.debug('Cache.warmup is already running')
-    this._isCaching = true
+export async function fetchFileWithHeadersFromBaseUrl (path, baseUrl, version) {
+  const [response, dependencies] = await Promise.all([
+    fetch(new URL(path, baseUrl)),
+    isJSFile(path) && getCSSDependenciesFor({ file: path.substr(1), version })
+  ])
 
-    logger.debug('beginning to warm up cache')
-    pullThroughCacheCounter.reset()
-    pullThroughRequestCounter.reset()
-    const cache = Object.fromEntries(await (async function () {
-      const files = Object.keys(deps)
-      const chunkSize = 50
-      const result = []
-      while (files.length > 0) {
-        result.push.apply(result, (await Promise.all(files.splice(0, chunkSize).map(async file => {
-          try {
-            const manifest = manifests[file] || Object.values(manifests).find(m =>
-              m.file === file ||
-              (m?.assets?.indexOf(file) >= 0) ||
-              (m?.css?.indexOf(file) >= 0)
-            )
-            if (!manifest) {
-              logger.error(`could not find manifest for "${file}"`)
-              return null
-            }
-            let appendix
-            if (manifest.css && isJSFile(file)) {
-              const cssString = manifest.css.map(file => `"${file}"`).join(',')
-              appendix = `\n/*injected by ui-middleware*/document.dispatchEvent(new CustomEvent("load-css",{detail:{css:[${cssString}]}}))`
-            }
-            return await fetchData(file, manifest.meta.baseUrl, appendix)
-          } catch (e) {
-            fileErrorCounter.inc()
-            logger.error(e)
-          }
-        }))).filter(data => Array.isArray(data) && data.length === 2))
-      }
-      fileCounter.inc(result.length)
-      return result
-    }()))
+  if (!response.ok) throw new NotFoundError(`Error fetching file: ${path}`)
 
-    this._cache = cache
-    this._manifests = manifests
-    this._hash = manifests.__hash__
-    this._dependencies = deps
-    this._oxManifests = viteToOxManifest(manifests)
-    this._isCaching = false
+  const cssString = dependencies && dependencies.map(file => `"${file}"`).join(',')
+  const appendix = cssString && `\n/*injected by ui-middleware*/document.dispatchEvent(new CustomEvent("load-css",{detail:{css:[${cssString}]}}))`
+  const resBuffer = await response.arrayBuffer()
+  const appendixLength = appendix?.length || 0
+  const body = Buffer.alloc(resBuffer.byteLength + appendixLength)
 
-    logger.debug('cache warmed up')
-  }
+  body.fill(Buffer.from(resBuffer), 0, resBuffer.byteLength)
+  if (appendix) body.write(appendix, resBuffer.byteLength)
 
-  async fetchAndStore (path) {
-    if (config.urls.length === 0) await config.load()
-    pullThroughRequestCounter.inc()
-    const [[key, value]] =
-      (await Promise.allSettled(config.urls.map(baseUrl => fetchData(path, baseUrl))))
-        .filter(r => r.status === 'fulfilled').map(r => r.value)
-    this._cache[key.slice(1)] = value
-    pullThroughCacheCounter.inc()
-    return value
-  }
+  const sha256Sum = crypto.createHash('sha256').update(body).digest('base64')
 
-  get (path) {
-    return this?._cache[path.slice(1)] || {}
+  return {
+    body,
+    sha256Sum,
+    headers: {
+      'content-type': response.headers.get('content-type'),
+      dependencies
+    }
   }
+}
 
-  get manifests () {
-    return this?._manifests
+export async function fetchFileWithHeaders ({ path, version }) {
+  if (config.urls.length === 0) await config.load()
+
+  const viteManifests = await getViteManifests({ version })
+  const module = viteManifests[path.substr(1)]
+  if (module?.meta?.baseUrl) {
+    try {
+      return fetchFileWithHeadersFromBaseUrl(path, module.meta.baseUrl, version)
+    } catch (err) {
+      logger.debug(`File ${path} had a baseUrl but could not be found on that server: ${err}`)
+    }
   }
 
-  get hash () {
-    return this?._hash
-  }
+  return Promise.any(config.urls.map(baseUrl => fetchFileWithHeadersFromBaseUrl(path, baseUrl, version)))
+}
 
-  get dependencies () {
-    return this?._dependencies
+export async function saveToCache ({ version, path, body, headers, ...rest }) {
+  if (typeof body !== 'string' && !(body instanceof Buffer)) body = JSON.stringify(body)
+  fileCache[version] = fileCache[version] || {}
+  fileCache[version][path] = {
+    body,
+    headers,
+    ...rest
   }
+  return Promise.all([
+    client.set(getRedisKey({ version, name: `${path}:body` }), body),
+    client.set(getRedisKey({ version, name: `${path}:meta` }), JSON.stringify({ headers, ...rest }))
+  ])
+}
 
-  get oxManifests () {
-    return this?._oxManifests
+export async function loadFromCache ({ version, path }) {
+  if (!fileCache[version]?.[path]) {
+    const [body, meta = '{}'] = await Promise.all([
+      client.getBuffer(getRedisKey({ version, name: `${path}:body` })),
+      client.get(getRedisKey({ version, name: `${path}:meta` }))
+    ])
+    if (!body) return
+    fileCache[version] = fileCache[version] || {}
+    fileCache[version][path] = { ...JSON.parse(meta), body }
   }
+  return fileCache[version][path]
 }
-
-export const fileCache = new FileCache()
diff --git a/src/index.js b/src/index.js
index 5f45f39..6cbdee3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,13 +2,15 @@
 // Note: actual env vars supersede .env file and .env file supersedes .env.defaults file
 import { config } from 'dotenv-defaults'
 import { logger } from './logger.js'
-import { createApp } from './createApp.js'
+import { createApp } from './create-app.js'
 import express from 'express'
+import createQueues from './create-queues.js'
 
 config()
 
 const root = express()
 const app = createApp()
+createQueues()
 
 // Binds and listens for connections on the specified host and port
 root.listen(process.env.PORT, () => {
diff --git a/src/manifests.js b/src/manifests.js
index 1598854..f3f1ca0 100644
--- a/src/manifests.js
+++ b/src/manifests.js
@@ -1,118 +1,61 @@
 import fetch from 'node-fetch'
-import { URL } from 'url'
-import { fileCache } from './files.js'
 import { config } from './config.js'
-import { hash, viteManifestToDeps } from './util.js'
+import { getRedisKey, viteManifestToDeps, viteToOxManifest } from './util.js'
 import { logger } from './logger.js'
-
-export const loadViteManifests = (() => {
-  let lastManifest
-  let lastCacheTime
-  let lastHash
-
-  async function getHash () {
-    await config.load()
-    const infos = await Promise.all(config.urls.map(async baseUrl => {
-      try {
-        const response = await fetch(new URL('meta.json', baseUrl))
-        if (!response.ok) throw new Error()
-        const meta = await response.json()
-        const version = meta.commitSha || meta.buildDate || meta.version
-        if (!version) throw new Error()
-        return version
-      } catch (err) {
-        logger.debug(`UI container at ${baseUrl} does not have meta.json. Fall back to version hash based on manifest.`)
-      }
-      try {
-        const response = await fetch(new URL('manifest.json', baseUrl))
-        if (!response.ok) throw new Error()
-        const manifest = await response.json()
-        return hash(manifest)
-      } catch (err) {
-        logger.error(`Cannot fetch manifest from ${baseUrl}. Version info will not be correct.`)
-      }
-    }))
-    return hash(infos)
-  }
-
-  async function reload () {
-    await config.load()
-    // vite manifests contains a set of objects with the vite-manifests
-    // from the corresponding registered services
-    const viteManifests = await Promise.all(config.urls.map(async baseUrl => {
-      // fetch the manifests
-      const result = await fetch(new URL('manifest.json', baseUrl))
-      if (!result.ok) throw new Error(`Failed to load manifest for url ${result.url} (Status: ${result.status}: ${result.statusText})`)
-      try {
-        const manifest = await result.json()
-        for (const file in manifest) {
-          logger.debug(`retrieved ${file} from ${baseUrl}`)
-          manifest[file].meta = manifest[file].meta || {}
-          manifest[file].meta.baseUrl = baseUrl
-        }
-        return manifest
-      } catch (err) {
-        throw new Error(`Failed to load manifest for url ${result.url}: ${err}`)
+import { client } from './redis.js'
+
+export async function getViteManifests ({ version }) {
+  const manifests = await client.get(getRedisKey({ version, name: 'viteManifests' }))
+  if (manifests) return JSON.parse(manifests)
+
+  await config.load()
+  // vite manifests contains a set of objects with the vite-manifests
+  // from the corresponding registered services
+  const viteManifests = await Promise.all(config.urls.map(async baseUrl => {
+    // fetch the manifests
+    const result = await fetch(new URL('manifest.json', baseUrl))
+    if (!result.ok) throw new Error(`Failed to load manifest for url ${result.url} (Status: ${result.status}: ${result.statusText})`)
+    try {
+      const manifest = await result.json()
+      for (const file in manifest) {
+        logger.debug(`retrieved ${file} from ${baseUrl}`)
+        manifest[file].meta = manifest[file].meta || {}
+        manifest[file].meta.baseUrl = baseUrl
       }
-    }))
-
-    // combine all manifests by keys. With duplicates, last wins
-    return viteManifests.reduce((memo, manifest) => Object.assign(memo, manifest), {})
-  }
-
-  return function loadViteManifests ({ useCache = true, warmUp = true } = {}) {
-    const CACHE_TTL = parseInt(process.env.CACHE_TTL)
-    const timeElapsed = () => +((+new Date() - lastCacheTime) / 1000).toFixed(2)
-
-    if (!lastManifest || useCache === false || +new Date() > lastCacheTime + CACHE_TTL) {
-      const promise = (async () => {
-        const newHash = await getHash()
-        if (lastManifest && lastHash === newHash) return
-        const manifest = await reload()
-        if (useCache) logger.info(`reloaded manifests after ${timeElapsed()} seconds`)
-
-        // cache data
-        lastHash = newHash
-        lastManifest = manifest
-
-        if (warmUp) {
-          const deps = viteManifestToDeps(manifest)
-          // asynchronously rewarm the cache
-          fileCache.warmUp(manifest, deps)
-        }
+      return manifest
+    } catch (err) {
+      throw new Error(`Failed to load manifest for url ${result.url}: ${err}`)
+    }
+  }))
 
-        Object.defineProperty(manifest, '__hash__', {
-          enumerable: false,
-          writable: true,
-          value: newHash
-        })
-        return manifest
-      })().catch(err => logger.error(`Could not reload manifests: ${err}`))
-      lastManifest = lastManifest || promise
+  // combine all manifests by keys. With duplicates, last wins
+  const newManifests = viteManifests.reduce((memo, manifest) => Object.assign(memo, manifest), {})
+  await client.set(getRedisKey({ version, name: 'viteManifests' }), JSON.stringify(newManifests))
+  return newManifests
+}
 
-      lastCacheTime = +new Date()
-    }
-    return lastManifest
-  }
-})()
+export async function getOxManifests ({ version }) {
+  const manifests = await client.get(getRedisKey({ version, name: 'oxManifests' }))
+  if (manifests) return JSON.parse(manifests)
 
-export function getOxManifests () {
-  loadViteManifests()
-  return fileCache.oxManifests
+  const viteManifests = await getViteManifests({ version })
+  const newManifests = viteToOxManifest(viteManifests)
+  await client.set(getRedisKey({ version, name: 'oxManifests' }), JSON.stringify(newManifests))
+  return newManifests
 }
 
-export function getDependencies () {
-  loadViteManifests()
-  return fileCache.dependencies
+export async function getDependencies ({ version }) {
+  const deps = await client.get(getRedisKey({ version, name: 'dependencies' }))
+  if (deps) return JSON.parse(deps)
+
+  const viteManifests = await getViteManifests({ version })
+  const newDeps = viteManifestToDeps(viteManifests)
+  await client.set(getRedisKey({ version, name: 'dependencies' }), JSON.stringify(newDeps))
+  return newDeps
 }
 
-export async function getCSSDependenciesFor (file) {
-  const allDependencies = await getDependencies()
+export async function getCSSDependenciesFor ({ file, version }) {
+  const allDependencies = await getDependencies({ version })
   const dependencies = allDependencies[file] || []
   return dependencies.filter(dep => /\.css/i.test(dep))
 }
-
-export function getVersion () {
-  loadViteManifests()
-  return fileCache.hash
-}
diff --git a/src/meta.js b/src/meta.js
index f14ec34..5aadcc1 100644
--- a/src/meta.js
+++ b/src/meta.js
@@ -1,8 +1,14 @@
 import { config } from './config.js'
 import fetch from 'node-fetch'
+import { client } from './redis.js'
+import { getRedisKey } from './util.js'
 
-export async function getMergedMetadata () {
-  const metadata = await Promise.all(config.urls.map(async url => {
+export async function getMergedMetadata ({ version }) {
+  const metadata = await client.get(getRedisKey({ version, name: 'mergedMetadata' }))
+  if (metadata) return JSON.parse(metadata)
+
+  await config.load()
+  const newMetadata = await Promise.all(config.urls.map(async url => {
     const { origin } = new URL(url)
     try {
       const response = await fetch(new URL('meta.json', origin))
@@ -12,13 +18,17 @@ export async function getMergedMetadata () {
       // unhandled
     }
   }))
-  metadata.push({
+
+  newMetadata.push({
     id: 'ui-middleware',
     name: 'UI Middleware',
     buildDate: process.env.BUILD_TIMESTAMP,
     commitSha: process.env.CI_COMMIT_SHA,
     version: process.env.APP_VERSION
   })
+
   // only return when contains data
-  return metadata.filter(Boolean)
+  const filtered = newMetadata.filter(Boolean)
+  await client.set(getRedisKey({ version, name: 'mergedMetadata' }), JSON.stringify(filtered))
+  return filtered
 }
diff --git a/src/middlewares/final-handler.js b/src/middlewares/final-handler.js
new file mode 100644
index 0000000..826a35b
--- /dev/null
+++ b/src/middlewares/final-handler.js
@@ -0,0 +1,18 @@
+import createError from 'http-errors'
+import path from 'path'
+import { logger } from '../logger.js'
+
+export default [function (req, res, next) {
+  const { body, headers } = res
+  if (!body) return next(createError(404, 'File does not exist.'))
+
+  res.type(headers?.['content-type'] || path.extname(req.path) || 'html')
+  if (headers) res.set(headers)
+
+  res.status(200).send(body)
+}, function (err, req, res, next) {
+  if (!err) next()
+  if (err.status >= 400 && err.status < 500) logger.warn(err)
+  else logger.error(err)
+  res.status(err.status || 500).send(err.message || 'Internal server error occured')
+}]
diff --git a/src/middlewares/load-from-cache.js b/src/middlewares/load-from-cache.js
new file mode 100644
index 0000000..3555df1
--- /dev/null
+++ b/src/middlewares/load-from-cache.js
@@ -0,0 +1,20 @@
+import { loadFromCache } from '../files.js'
+
+export default async function (req, res, next) {
+  try {
+    const data = await loadFromCache({
+      path: req.path,
+      version: res.version
+    })
+    if (!data) return
+    const { body, headers, sha256Sum } = data
+    res.body = body
+    res.headers = headers
+    res.locals.sha256Sum = sha256Sum
+    res.cache = false
+  } catch (err) {
+    next(err)
+  } finally {
+    next()
+  }
+}
diff --git a/src/middlewares/load-from-server.js b/src/middlewares/load-from-server.js
new file mode 100644
index 0000000..e02e8d0
--- /dev/null
+++ b/src/middlewares/load-from-server.js
@@ -0,0 +1,22 @@
+import { NotFoundError } from '../errors.js'
+import { fetchFileWithHeaders } from '../files.js'
+import createError from 'http-errors'
+
+export default async function (req, res, next) {
+  try {
+    if (res.body) return
+    const path = req.path === '/' ? '/index.html' : req.path
+    const { body, headers, sha256Sum } = await fetchFileWithHeaders({ path, version: res.version })
+    res.body = body
+    res.headers = headers
+    res.locals.sha256Sum = sha256Sum
+  } catch (err) {
+    // response might be an aggregate error. therefore need to check all errors
+    const errors = err.errors || [err]
+    const fileNotFound = errors.reduce((memo, error) => memo && error instanceof NotFoundError, true)
+    if (fileNotFound) next(createError(404, 'File does not exist.'))
+    else next(err)
+  } finally {
+    next()
+  }
+}
diff --git a/src/middlewares/save-to-cache.js b/src/middlewares/save-to-cache.js
new file mode 100644
index 0000000..0a38494
--- /dev/null
+++ b/src/middlewares/save-to-cache.js
@@ -0,0 +1,20 @@
+import { saveToCache } from '../files.js'
+import { logger } from '../logger.js'
+
+export default async function (req, res, next) {
+  try {
+    if (!res.body) return
+    if (res.cache === false) return
+    saveToCache({
+      version: res.version,
+      path: req.path,
+      body: res.body,
+      headers: res.headers,
+      sha256Sum: res.locals.sha256Sum
+    }).catch(() => logger.error)
+  } catch (err) {
+    next(err)
+  } finally {
+    next()
+  }
+}
diff --git a/src/middlewares/version.js b/src/middlewares/version.js
new file mode 100644
index 0000000..f62ecbc
--- /dev/null
+++ b/src/middlewares/version.js
@@ -0,0 +1,15 @@
+import { getLatestVersion } from '../version.js'
+
+export default async (req, res, next) => {
+  try {
+    const latestVersion = await getLatestVersion()
+    const version = req.get('version') || latestVersion
+    res.setHeader('version', version)
+    res.setHeader('latest-version', latestVersion)
+    res.version = version
+  } catch (err) {
+    next(err)
+  } finally {
+    next()
+  }
+}
diff --git a/src/redis.js b/src/redis.js
new file mode 100644
index 0000000..8dc4b80
--- /dev/null
+++ b/src/redis.js
@@ -0,0 +1,66 @@
+import Redis from 'ioredis'
+import { logger } from './logger.js'
+import Queue from 'bull'
+
+const commonQueueOptions = { enableReadyCheck: false, maxRetriesPerRequest: null }
+
+const createClient = (type, options = {}) => {
+  const client = new Redis({
+    host: process.env.REDIS_HOST,
+    port: process.env.REDIS_PORT || 6379,
+    db: process.env.REDIS_DB,
+    password: process.env.REDIS_PASSWORD,
+    ...options
+  })
+  client.on('ready', () => logger.info(`Connected ${type} to redis on ${process.env.REDIS_HOST}`))
+  client.on('error', (err) => logger.error(`Redis connect error: ${err}`))
+
+  return client
+}
+
+export async function isReady () {
+  if (client.status !== 'ready') throw new Error()
+  if (pubClient.status !== 'ready') throw new Error()
+  if (subClient.status !== 'ready') throw new Error()
+}
+
+export const client = createClient('common client')
+export const pubClient = createClient('pub client')
+export const subClient = createClient('sub client', commonQueueOptions)
+
+/*
+ * Bull specific things are below
+ */
+const queues = {}
+
+export function getQueue (name) {
+  if (queues[name]) return queues[name]
+  return (queues[name] = new Queue(name, {
+    prefix: process.env.REDIS_PREFIX,
+    createClient: function (type) {
+      switch (type) {
+        case 'client':
+          return client
+        case 'subscriber':
+          return subClient.duplicate()
+        default:
+          return client.duplicate(commonQueueOptions)
+      }
+    }
+  }))
+}
+
+export function getQueues () {
+  return Object.values(queues)
+}
+
+export async function closeQueue (name) {
+  if (!queues[name]) throw new Error(`No such queue "${name}" to close.`)
+  const queue = queues[name]
+  delete queues[name]
+  const jobs = await queue.getRepeatableJobs()
+  for (const job of jobs) {
+    queue.removeRepeatableByKey(job.key)
+  }
+  return queue.close()
+}
diff --git a/src/routes/health.js b/src/routes/health.js
new file mode 100644
index 0000000..f4f26f8
--- /dev/null
+++ b/src/routes/health.js
@@ -0,0 +1,22 @@
+import { Router } from 'express'
+import health from '@cloudnative/health-connect'
+import * as redis from '../redis.js'
+import { getLatestVersion } from '../version.js'
+import { once } from '../util.js'
+
+const router = Router()
+const healthCheck = new health.HealthChecker()
+
+const redisReady = new health.ReadinessCheck('Redis ready', redis.isReady)
+healthCheck.registerReadinessCheck(redisReady)
+
+const startupCheck = new health.StartupCheck('check latest version', once(async function () {
+  await getLatestVersion()
+}))
+healthCheck.registerStartupCheck(startupCheck)
+
+router.use('/live', health.LivenessEndpoint(healthCheck))
+router.use('/ready', health.ReadinessEndpoint(healthCheck))
+router.use('/healthy', health.HealthEndpoint(healthCheck))
+
+export default router
diff --git a/src/routes/manifests.js b/src/routes/manifests.js
new file mode 100644
index 0000000..608a963
--- /dev/null
+++ b/src/routes/manifests.js
@@ -0,0 +1,18 @@
+import { Router } from 'express'
+import { getOxManifests } from '../manifests.js'
+
+const router = new Router()
+
+router.get('/manifests', async function (req, res, next) {
+  try {
+    if (res.body) return
+    res.body = await getOxManifests({ version: res.version })
+    res.headers = { 'content-type': 'application/json' }
+  } catch (err) {
+    next(err)
+  } finally {
+    next()
+  }
+})
+
+export default router
diff --git a/src/routes/metadata.js b/src/routes/metadata.js
new file mode 100644
index 0000000..e93fcba
--- /dev/null
+++ b/src/routes/metadata.js
@@ -0,0 +1,17 @@
+import { Router } from 'express'
+import { getMergedMetadata } from '../meta.js'
+
+const router = new Router()
+
+router.get('/meta', async (req, res, next) => {
+  try {
+    res.body = await getMergedMetadata({ version: res.version })
+    res.headers = { 'content-type': 'application/json' }
+  } catch (err) {
+    next(err)
+  } finally {
+    next()
+  }
+})
+
+export default router
diff --git a/src/routes/redirects.js b/src/routes/redirects.js
new file mode 100644
index 0000000..8c73035
--- /dev/null
+++ b/src/routes/redirects.js
@@ -0,0 +1,17 @@
+import { Router } from 'express'
+
+const router = new Router()
+
+// backwards compatibility for 7.10.x
+// this should hopefully be resolved with an ingress
+// or proper config. But is used to be safe on all ends
+router.get('/ui', async (req, res, next) => {
+  res.redirect(process.env.APP_ROOT)
+})
+
+router.post('/redirect', (req, res, next) => {
+  const location = req.body.location || '../busy.html'
+  res.redirect(location)
+})
+
+export default router
diff --git a/src/util.js b/src/util.js
index 018a517..3fe5da8 100644
--- a/src/util.js
+++ b/src/util.js
@@ -54,3 +54,20 @@ export function viteToOxManifest (viteManifests) {
     )
     .flat()
 }
+
+export function getRedisKey ({ version, name }) {
+  if (version && name) return `${process.env.REDIS_PREFIX}:${version}:${name}`
+  return `${process.env.REDIS_PREFIX}:${name}`
+}
+
+export function once (fn, context) {
+  let called = false
+  let res
+  return function () {
+    if (!called) {
+      res = fn.apply(context || this, arguments)
+      called = true
+    }
+    return res
+  }
+}
diff --git a/src/version.js b/src/version.js
new file mode 100644
index 0000000..1c0e38c
--- /dev/null
+++ b/src/version.js
@@ -0,0 +1,63 @@
+import fetch from 'node-fetch'
+import { config } from './config.js'
+import { getRedisKey, hash } from './util.js'
+import { logger } from './logger.js'
+import { client, pubClient } from './redis.js'
+
+let latestVersion
+
+export const fetchLatestVersion = async () => {
+  await config.load()
+  const infos = await Promise.all(config.urls.map(async baseUrl => {
+    try {
+      const response = await fetch(new URL('meta.json', baseUrl))
+      if (!response.ok) throw new Error()
+      const meta = await response.json()
+      const version = meta.commitSha || meta.buildDate || meta.version
+      if (!version) throw new Error()
+      return version
+    } catch (err) {
+      logger.debug(`UI container at ${baseUrl} does not have meta.json. Fall back to version hash based on manifest.`)
+    }
+    try {
+      const response = await fetch(new URL('manifest.json', baseUrl))
+      if (!response.ok) throw new Error()
+      const manifest = await response.json()
+      return hash(manifest)
+    } catch (err) {
+      logger.error(`Cannot fetch manifest from ${baseUrl}. Version info will not be correct.`)
+    }
+  }))
+  return hash(infos)
+}
+
+export async function getLatestVersion () {
+  if (latestVersion) return latestVersion
+
+  const version = await client.get(getRedisKey({ name: 'latestVersion' }))
+  if (version) return (latestVersion = version)
+
+  const newVersion = await fetchLatestVersion()
+  pubClient.publish(getRedisKey({ name: 'updateLatestVersion' }), newVersion)
+  await client.set(getRedisKey({ name: 'latestVersion' }), newVersion)
+  return (latestVersion = newVersion)
+}
+
+export function registerLatestVersionListener (client) {
+  const key = getRedisKey({ name: 'updateLatestVersion' })
+  client.subscribe(key, (errs, count) => logger.info(`Subscribed to ${key}.`))
+  client.on('message', (channel, message) => {
+    if (channel === key) latestVersion = message
+  })
+}
+
+export async function updateVersionProcessor (job) {
+  const [storedVersion, fetchedVersion] = await Promise.all([
+    getLatestVersion(),
+    fetchLatestVersion()
+  ])
+  if (storedVersion === fetchedVersion) return fetchedVersion
+  pubClient.publish(getRedisKey({ name: 'updateLatestVersion' }), fetchedVersion)
+  await client.set(getRedisKey({ name: 'latestVersion' }), fetchedVersion)
+  return fetchedVersion
+}
diff --git a/yarn.lock b/yarn.lock
index 12f0610..28f79d3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -86,6 +86,34 @@
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
   integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
 
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
+  integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.0.0":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1"
+  integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
+  integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
+
 "@szmarczak/http-timer@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
@@ -393,6 +421,21 @@ builtins@^4.0.0:
   dependencies:
     semver "^7.0.0"
 
+bull@^4.8.2:
+  version "4.8.2"
+  resolved "https://registry.yarnpkg.com/bull/-/bull-4.8.2.tgz#0d02fe389777abe29d50fd46d123bc62e074cfcd"
+  integrity sha512-S7CNIL9+vsbLKwOGkUI6mawY5iABKQJLZn5a7KPnxAZrDhFXkrxsHHXLCKUR/+Oqys3Vk5ElWdj0SLtK84b1Nw==
+  dependencies:
+    cron-parser "^4.2.1"
+    debuglog "^1.0.0"
+    get-port "^5.1.1"
+    ioredis "^4.28.5"
+    lodash "^4.17.21"
+    msgpackr "^1.5.2"
+    p-timeout "^3.2.0"
+    semver "^7.3.2"
+    uuid "^8.3.0"
+
 bytes@3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@@ -538,6 +581,11 @@ clone-response@^1.0.2:
   dependencies:
     mimic-response "^1.0.0"
 
+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==
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -628,6 +676,13 @@ cookiejar@^2.1.3:
   resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc"
   integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==
 
+cron-parser@^4.2.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.3.0.tgz#16c3932fa62d0c30708d4200f510d6ca26bf35a2"
+  integrity sha512-mK6qJ6k9Kn0/U7Cv6LKQnReUW3GqAW4exgwmHJGb3tPgcy0LrS+PeqxPPiwL8uW/4IJsMsCZrCc4vf1nnXMjzA==
+  dependencies:
+    luxon "^1.28.0"
+
 cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -673,13 +728,18 @@ debug@^3.2.7:
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.1, debug@^4.3.2, debug@^4.3.3:
+debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
+debuglog@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
+  integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
+
 decamelize@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
@@ -726,6 +786,16 @@ delayed-stream@~1.0.0:
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
+denque@^1.1.0:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf"
+  integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==
+
+depd@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
 depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@@ -744,7 +814,7 @@ dezalgo@1.0.3:
     asap "^2.0.0"
     wrappy "1"
 
-diff@5.0.0:
+diff@5.0.0, diff@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
@@ -1216,6 +1286,20 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.4"
 
+fengari-interop@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/fengari-interop/-/fengari-interop-0.1.3.tgz#3ad37a90e7430b69b365441e9fc0ba168942a146"
+  integrity sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==
+
+fengari@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/fengari/-/fengari-0.1.4.tgz#72416693cd9e43bd7d809d7829ddc0578b78b0bb"
+  integrity sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==
+  dependencies:
+    readline-sync "^1.4.9"
+    sprintf-js "^1.1.1"
+    tmp "^0.0.33"
+
 fetch-blob@^3.1.2, fetch-blob@^3.1.4:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.5.tgz#0077bf5f3fcdbd9d75a0b5362f77dbb743489863"
@@ -1359,6 +1443,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
     has "^1.0.3"
     has-symbols "^1.0.1"
 
+get-port@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
+  integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
+
 get-stream@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
@@ -1535,6 +1624,17 @@ http-errors@1.8.1:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.1"
 
+http-errors@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
 human-signals@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@@ -1617,6 +1717,33 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+ioredis-mock@^7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-7.2.0.tgz#48f006c07ef7f1f93f75e60d8f9035fa46c4ef0a"
+  integrity sha512-xzABBG3NhfDBGxH1KX9n6vs7WGNn9lhcxMT3b+vjynVImxlUV+vOXU+tjGzSUnGmx4IYllA8RqbXN8z6ROMPVA==
+  dependencies:
+    fengari "^0.1.4"
+    fengari-interop "^0.1.3"
+    redis-commands "^1.7.0"
+    standard-as-callback "^2.1.0"
+
+ioredis@^4.28.5:
+  version "4.28.5"
+  resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f"
+  integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==
+  dependencies:
+    cluster-key-slot "^1.1.0"
+    debug "^4.3.1"
+    denque "^1.1.0"
+    lodash.defaults "^4.2.0"
+    lodash.flatten "^4.4.0"
+    lodash.isarguments "^3.1.0"
+    p-map "^2.1.0"
+    redis-commands "1.7.0"
+    redis-errors "^1.2.0"
+    redis-parser "^3.0.0"
+    standard-as-callback "^2.1.0"
+
 ipaddr.js@1.9.1:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -1801,6 +1928,11 @@ is-yarn-global@^0.3.0:
   resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
   integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
 
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -1840,6 +1972,11 @@ json5@^1.0.1:
   dependencies:
     minimist "^1.2.0"
 
+just-extend@^4.0.2:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
+  integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
+
 keyv@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@@ -1921,6 +2058,26 @@ locate-path@^6.0.0:
   dependencies:
     p-locate "^5.0.0"
 
+lodash.defaults@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
+  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+
+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"
+  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
+
+lodash.isarguments@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
+  integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=
+
 lodash.merge@^4.6.2:
   version "4.6.2"
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@@ -1973,6 +2130,11 @@ lru-cache@^6.0.0:
   dependencies:
     yallist "^4.0.0"
 
+luxon@^1.28.0:
+  version "1.28.0"
+  resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf"
+  integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==
+
 make-dir@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@@ -2114,6 +2276,57 @@ ms@2.1.3, ms@^2.1.1:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
+msgpackr-extract-darwin-arm64@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-1.1.0.tgz#d590dffac6b90edc3ab53392f7ec5668ed94638c"
+  integrity sha512-s1kHoT12tS2cCQOv+Wl3I+/cYNJXBPtwQqGA+dPYoXmchhXiE0Nso+BIfvQ5PxbmAyjj54Q5o7PnLTqVquNfZA==
+
+msgpackr-extract-darwin-x64@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-1.1.0.tgz#568cbdf5e819ac120659c02b0dbaabf483523ee3"
+  integrity sha512-yx/H/i12IKg4eWGu/eKdKzJD4jaYvvujQSaVmeOMCesbSQnWo5X6YR9TFjoiNoU9Aexk1KufzL9gW+1DozG1yw==
+
+msgpackr-extract-linux-arm64@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-1.1.0.tgz#c0a30e6687cea4f79115f5762c5fdff90e4a20d4"
+  integrity sha512-AxFle3fHNwz2V4CYDIGFxI6o/ZuI0lBKg0uHI8EcCMUmDE5mVAUWYge5WXmORVvb8sVWyVgFlmi3MTu4Ve6tNQ==
+
+msgpackr-extract-linux-arm@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-1.1.0.tgz#38e8db873b6b3986558bde4d7bb15eacc8743a9e"
+  integrity sha512-0VvSCqi12xpavxl14gMrauwIzHqHbmSChUijy/uo3mpjB1Pk4vlisKpZsaOZvNJyNKj0ACi5jYtbWnnOd7hYGw==
+
+msgpackr-extract-linux-x64@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-1.1.0.tgz#8c44ca5211d9fa6af77be64a8e687c0be0491ce7"
+  integrity sha512-O+XoyNFWpdB8oQL6O/YyzffPpmG5rTNrr1nKLW70HD2ENJUhcITzbV7eZimHPzkn8LAGls1tBaMTHQezTBpFOw==
+
+msgpackr-extract-win32-x64@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-1.1.0.tgz#7bf9bd258e334668842c7532e5e40a60ca3325d7"
+  integrity sha512-6AJdM5rNsL4yrskRfhujVSPEd6IBpgvsnIT/TPowKNLQ62iIdryizPY2PJNFiW3AJcY249AHEiDBXS1cTDPxzA==
+
+msgpackr-extract@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-1.1.4.tgz#665037c1470f225d01d2d735dad0334fff5faae6"
+  integrity sha512-WQbHvsThprXh+EqZYy+SQFEs7z6bNM7a0vgirwUfwUcphWGT2mdPcpyLCNiRsN6w5q5VKJUMblHY+tNEyceb9Q==
+  dependencies:
+    node-gyp-build-optional-packages "^4.3.2"
+  optionalDependencies:
+    msgpackr-extract-darwin-arm64 "1.1.0"
+    msgpackr-extract-darwin-x64 "1.1.0"
+    msgpackr-extract-linux-arm "1.1.0"
+    msgpackr-extract-linux-arm64 "1.1.0"
+    msgpackr-extract-linux-x64 "1.1.0"
+    msgpackr-extract-win32-x64 "1.1.0"
+
+msgpackr@^1.5.2:
+  version "1.5.6"
+  resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.5.6.tgz#cb1b2a92038093d1a5695286a6e99466c3fcd195"
+  integrity sha512-Y1Ia1AYKcz30JOAUyyC0jCicI7SeP8NK+SVCGZIeLg2oQs28wSwW2GbHXktk4ZZmrq9/v2jU0JAbvbp2d1ewpg==
+  optionalDependencies:
+    msgpackr-extract "^1.1.4"
+
 nanoid@3.3.1:
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
@@ -2129,6 +2342,17 @@ negotiator@0.6.3:
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
+nise@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3"
+  integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==
+  dependencies:
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" ">=5"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
+
 node-domexception@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
@@ -2143,6 +2367,11 @@ node-fetch@^3.1.0:
     fetch-blob "^3.1.4"
     formdata-polyfill "^4.0.10"
 
+node-gyp-build-optional-packages@^4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-4.3.2.tgz#82de9bdf9b1ad042457533afb2f67469dc2264bb"
+  integrity sha512-P5Ep3ISdmwcCkZIaBaQamQtWAG0facC89phWZgi5Z3hBU//J6S48OIvyZWSPPf6yQMklLZiqoosWAZUj7N+esA==
+
 nodemon@^2.0.7:
   version "2.0.15"
   resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.15.tgz#504516ce3b43d9dc9a955ccd9ec57550a31a8d4e"
@@ -2257,11 +2486,21 @@ optionator@^0.9.1:
     type-check "^0.4.0"
     word-wrap "^1.2.3"
 
+os-tmpdir@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+
 p-cancelable@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
   integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
 
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
+
 p-limit@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
@@ -2290,6 +2529,11 @@ p-locate@^5.0.0:
   dependencies:
     p-limit "^3.0.2"
 
+p-map@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
+  integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
+
 p-map@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b"
@@ -2297,6 +2541,13 @@ p-map@^4.0.0:
   dependencies:
     aggregate-error "^3.0.0"
 
+p-timeout@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
+  integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
+  dependencies:
+    p-finally "^1.0.0"
+
 p-try@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
@@ -2354,6 +2605,13 @@ path-to-regexp@0.1.7:
   resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
   integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
 
+path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
 path-type@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -2585,11 +2843,33 @@ readdirp@~3.6.0:
   dependencies:
     picomatch "^2.2.1"
 
+readline-sync@^1.4.9:
+  version "1.4.10"
+  resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b"
+  integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==
+
 real-require@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.1.0.tgz#736ac214caa20632847b7ca8c1056a0767df9381"
   integrity sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==
 
+redis-commands@1.7.0, redis-commands@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
+  integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
+
+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"
+
 regexpp@^3.0.0, regexpp@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
@@ -2718,6 +2998,13 @@ semver@^7.0.0, semver@^7.3.4, semver@^7.3.5:
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.3.2:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  dependencies:
+    lru-cache "^6.0.0"
+
 send@0.17.2:
   version "0.17.2"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
@@ -2785,6 +3072,18 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
   integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
 
+sinon@^13.0.1:
+  version "13.0.1"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.1.tgz#2a568beca2084c48985dd98e276e065c81738e3c"
+  integrity sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==
+  dependencies:
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.0.0"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
+
 slash@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -2828,6 +3127,21 @@ split2@^4.0.0:
   resolved "https://registry.yarnpkg.com/split2/-/split2-4.1.0.tgz#101907a24370f85bb782f08adaabe4e281ecf809"
   integrity sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==
 
+sprintf-js@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
+  integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==
+
+standard-as-callback@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
+  integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
+
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
@@ -2965,7 +3279,7 @@ supports-color@^5.3.0, supports-color@^5.5.0:
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
   integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
@@ -3033,6 +3347,13 @@ through@^2.3.8:
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
 
+tmp@^0.0.33:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
+  dependencies:
+    os-tmpdir "~1.0.2"
+
 to-readable-stream@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
@@ -3091,7 +3412,7 @@ type-check@^0.4.0, type-check@~0.4.0:
   dependencies:
     prelude-ls "^1.2.1"
 
-type-detect@^4.0.0, type-detect@^4.0.5:
+type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
@@ -3197,6 +3518,11 @@ utils-merge@1.0.1:
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
+uuid@^8.3.0:
+  version "8.3.2"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
+  integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+
 v8-compile-cache@^2.0.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
-- 
GitLab