diff --git a/.env.defaults b/.env.defaults index f72b89e9751395a61c03ebf1aa16dff2ee7a8cdf..76d680e0621515e4c2bde4491f5a781ace24cc2e 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 8c0776bb3a94bd3ea1b495da3929e9443c7cbf59..059d11d93b13f7c318d58c57af9ee27a021433ae 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 51a09a82a29b4c31cc97347ff31c5a4860c9c581..3d4a3a7dc8f7f1da65d7c7c0d38a72d8b730a8f5 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 3b6a05611637ef89893d18ab2623f7e0cb99c87f..36753db2d2ade153cd76b27cc6a948c274f8d335 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 0eddaec77ba2031479ac95869683ca2c1d6935f8..2ade22c775b06cb1a2a6ed07fec394e52627df76 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 a3f173a305032fbd529b3226554ea9ba6000b231..a394d48de219c4bf6c225f9091e7f5e9edadf1e5 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 1181c57e273839cd445369ac26e1fce8a1301daa..a9bb9fe8706fba89ffddc6d7392455314fea426f 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 0108291f05a5f0480babadbbf366676d122c73c8..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..4016c2b5b0a5135ae63ef8e0c471c9bc61d77935 --- /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 0000000000000000000000000000000000000000..c02759d3046f27a1fa7501aa36fa46a12b3a8ac8 --- /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 0000000000000000000000000000000000000000..0a15a5e9529eee60b713dd049c80eb4a684ec143 --- /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 0000000000000000000000000000000000000000..2a540e633e2ab77b91eca6fad9229a11ba4a0181 --- /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 ac66f9a84f6fe9bf351373d5e56924f9eaa567c9..8ab328c69ada90841c2eba847d0cb3f6d8517a13 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 8c0bfff91825d74b2cbb7f33723dacc5cf0ae7e8..ed85b7a10e16d0a5f30b90104d03a61eb49de9d2 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 ccad2ea2497841b369e7722cb77cfe851207cfd9..9e48498b0055a73df954611fdf6453a43cd74f5b 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 f499f03dc4f293d8261df7bee4ae4b0db5d5f1dc..3f5aeef8dd1461f4c1aa16bed8a6f37e92872022 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 de22eb0cb318ca2cb3707796ac537d66e8f1a4de..264e8d226526e89c1eb3517681f531cae4f62606 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 1981c7d657ebbd6e68f1a7b4f9e5e9f05765c942..5bf0aa7e1f44aa87f64db2851b8f07b558b9dbb5 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 0000000000000000000000000000000000000000..1fc53a4ed5202996cb946076029bf8296dfb3c2b --- /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 cc7592fb505d343259c5bc00a4025808529a0e34..b1d3c547e2fbae3a7eeb3dbaf9dc70d35c0f7b09 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 fee579f5f242566353ab6a74ebf851dea6b1917d..0000000000000000000000000000000000000000 --- 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 6449dbfe52ac4aaa5e13de04c19ddb86c243092a..f7bb5a99154eecb81d007c9e6ac14c25b0b18448 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 0000000000000000000000000000000000000000..dcfeb49cac1b300463bd8e954512e36c44efabe1 --- /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 0000000000000000000000000000000000000000..7c0dfea69270149c71472a04644831b28341bd16 --- /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 564780fb2eace96e7facee5bb00489b625fd0bfc..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..0e1f90f375ce3d8093bb0a4d6a790246cef16f55 --- /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 b787fdeae86bb5f4486a95578d45a36977d5bb95..235fd3ff015ca873b694020fb7b251955b672acc 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 5f45f39f9a32d10b39e4be5b3e468762749e9f6f..6cbdee3bc426a6f8becc06c2cb6d64830f398f9a 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 1598854667dac2f53a7d941cd628aab076c78447..f3f1ca0fa63df5576340e71921612e43b86275a7 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 f14ec34c9febac0a9089237714617118cb9ea280..5aadcc1db2361bb92d24485e934120a4b6238492 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 0000000000000000000000000000000000000000..826a35b3e6aa81429deab49163fe5d099b52a20e --- /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 0000000000000000000000000000000000000000..3555df14ff08875e17d1f1c8b27c749b88adc4f5 --- /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 0000000000000000000000000000000000000000..e02e8d0d12bb42a950510027eb21c264780109f6 --- /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 0000000000000000000000000000000000000000..0a384945e922f6a652ce561ab11cd4c9f3cdd62f --- /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 0000000000000000000000000000000000000000..f62ecbc14a45c7dc84f35ec72a5297040b606936 --- /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 0000000000000000000000000000000000000000..8dc4b80d473d5180f06e922a45da22407b7e5b4f --- /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 0000000000000000000000000000000000000000..f4f26f834cd33d7adb6622c1ce71218d39f52921 --- /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 0000000000000000000000000000000000000000..608a96354df2b5bd2574511ba569c4b26318d8bf --- /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 0000000000000000000000000000000000000000..e93fcba426bd56b42b168a22a4771aeb057a5fe0 --- /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 0000000000000000000000000000000000000000..8c73035451153efe7c26525cc4b006250e8ab793 --- /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 018a517395797976d9b7e8c6b70b7c598e763e5b..3fe5da810ce1026aa3b16fdc242006567ca935a8 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 0000000000000000000000000000000000000000..1c0e38c38614d9bb5b1c849e77b256a6e690bf27 --- /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 12f0610cbfc225bea488b80b5746740ddb03638a..28f79d3b3aee28ff775898cc33d6c912d0cbc9b3 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"