diff --git a/integration/caching_test.js b/integration/caching_test.js index 0a15a5e9529eee60b713dd049c80eb4a684ec143..66f1fa62228f59f3a44d68a0a82debbb7f700d76 100644 --- a/integration/caching_test.js +++ b/integration/caching_test.js @@ -41,7 +41,7 @@ describe('File caching service', function () { 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:meta' }))).to.equal('{"sha256Sum":"iFSuC3aK6EN/ASUamuZ+j3xZXI9eBdIlxtVDFjn7y1I=","headers":{"content-type":"text/html","dependencies":false}}') expect(await client.get(getRedisKey({ version, name: '/index.html:body' }))).to.equal('<html><head></head><body>it\'s me</body></html>') }) diff --git a/spec/file_caching_test.js b/spec/file_caching_test.js index 9e48498b0055a73df954611fdf6453a43cd74f5b..db08ac9b764180ef2db97163e6002434b205c5ec 100644 --- a/spec/file_caching_test.js +++ b/spec/file_caching_test.js @@ -267,4 +267,49 @@ describe('File caching service', function () { expect(response5.statusCode).to.equal(200) expect(response5.text).to.equal('second') }) + + it('only fetches files once, even when requested simultanously', 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) + const [res1, res2] = await Promise.all([ + request(app).get('/example.js'), + request(app).get('/example.js') + ]) + expect(res1.statusCode).to.equal(200) + expect(res2.statusCode).to.equal(200) + expect(spy.callCount).to.equal(1) + }) + + it('only fetches manifests once, even when requested simultanously', async function () { + let spy + mockFetch({ + 'http://ui-server': { + '/manifest.json': spy = sandbox.spy(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + return new Response(JSON.stringify(generateSimpleViteManifest({})), { headers: { 'content-type': 'application/json' } }) + }), + '/example.js': () => new Response('this is example', { headers: { 'content-type': 'application/javascript' } }) + } + }) + app = await mockApp() + + expect(spy.callCount).to.equal(0) + const [res1, res2] = await Promise.all([ + request(app).get('/manifests'), + request(app).get('/example.js') + ]) + expect(res1.statusCode).to.equal(200) + expect(res2.statusCode).to.equal(200) + expect(spy.callCount).to.equal(1) + }) }) diff --git a/src/cache.js b/src/cache.js index a781e291a7f6aa8449006c082cba1947efe75953..3076a23ab432dc93b9615c9f38660900a2b32934 100644 --- a/src/cache.js +++ b/src/cache.js @@ -2,6 +2,13 @@ import * as redis from './redis.js' const cache = {} +export async function setAsync (key, asyncValue) { + cache[key] = asyncValue + const value = await asyncValue + await set(key, value) + return value +} + export function set (key, value) { if (cache[key] === value) return cache[key] = value diff --git a/src/create-app.js b/src/create-app.js index dcfeb49cac1b300463bd8e954512e36c44efabe1..10b21bff537aaa769154d83fec112b96101e490b 100644 --- a/src/create-app.js +++ b/src/create-app.js @@ -7,9 +7,7 @@ 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 serveFilesMiddleware from './middlewares/serve-files.js' import finalHandlerMiddleware from './middlewares/final-handler.js' import health from './routes/health.js' @@ -40,14 +38,12 @@ export function createApp () { 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(serveFilesMiddleware) app.use(finalHandlerMiddleware) return app diff --git a/src/files.js b/src/files.js index d193ffc995f4beb72c1bfc5f078c0771869dc424..dfe10b898c349d5886ba2ecf759bf25cad7be7aa 100644 --- a/src/files.js +++ b/src/files.js @@ -50,12 +50,15 @@ export async function fetchFileWithHeaders ({ path, version }) { return Promise.any(config.urls.map(baseUrl => fetchFileWithHeadersFromBaseUrl(path, baseUrl, version))) } -export async function saveToCache ({ version, path, body, headers, ...rest }) { - if (typeof body !== 'string' && !(body instanceof Buffer)) body = JSON.stringify(body) - return Promise.all([ - cache.set(getRedisKey({ version, name: `${path}:body` }), body), - cache.set(getRedisKey({ version, name: `${path}:meta` }), JSON.stringify({ headers, ...rest })) +export async function saveAsyncToCache ({ version, path, promise }) { + await Promise.all([ + cache.setAsync(getRedisKey({ version, name: `${path}:body` }), promise.then(({ body }) => { + if (typeof body !== 'string' && !(body instanceof Buffer)) return JSON.stringify(body) + return body + })), + cache.setAsync(getRedisKey({ version, name: `${path}:meta` }), promise.then(({ body, ...rest }) => JSON.stringify(rest))) ]) + return promise } export async function loadFromCache ({ version, path }) { diff --git a/src/manifests.js b/src/manifests.js index f70c176725467a3def662999783c598a39ea64ef..9cb1bdd1d4e4a4855f5b6a241b85e8c4f5b1a4dc 100644 --- a/src/manifests.js +++ b/src/manifests.js @@ -4,10 +4,7 @@ import { getRedisKey, viteManifestToDeps, viteToOxManifest } from './util.js' import { logger } from './logger.js' import * as cache from './cache.js' -export async function getViteManifests ({ version }) { - const manifests = await cache.get(getRedisKey({ version, name: 'viteManifests' })) - if (manifests) return JSON.parse(manifests) - +export async function fetchViteManifests () { // 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 => { @@ -28,29 +25,37 @@ export async function getViteManifests ({ version }) { })) // combine all manifests by keys. With duplicates, last wins - const newManifests = viteManifests.reduce((memo, manifest) => Object.assign(memo, manifest), {}) - await cache.set(getRedisKey({ version, name: 'viteManifests' }), JSON.stringify(newManifests)) - return newManifests + return viteManifests.reduce((memo, manifest) => Object.assign(memo, manifest), {}) } -export async function getOxManifests ({ version }) { - const manifests = await cache.get(getRedisKey({ version, name: 'oxManifests' })) - if (manifests) return JSON.parse(manifests) +export async function getViteManifests ({ version }) { + let manifests = await cache.get(getRedisKey({ version, name: 'viteManifests' })) + if (!manifests) { + manifests = await cache.setAsync(getRedisKey({ version, name: 'viteManifests' }), fetchViteManifests().then(m => JSON.stringify(m))) + } + return JSON.parse(manifests) +} - const viteManifests = await getViteManifests({ version }) - const newManifests = viteToOxManifest(viteManifests) - await cache.set(getRedisKey({ version, name: 'oxManifests' }), JSON.stringify(newManifests)) - return newManifests +export async function getOxManifests ({ version }) { + let manifests = await cache.get(getRedisKey({ version, name: 'oxManifests' })) + if (!manifests) { + manifests = await cache.setAsync(getRedisKey({ version, name: 'oxManifests' }), (async () => { + const viteManifests = await getViteManifests({ version }) + return JSON.stringify(viteToOxManifest(viteManifests)) + })()) + } + return JSON.parse(manifests) } export async function getDependencies ({ version }) { - const deps = await cache.get(getRedisKey({ version, name: 'dependencies' })) - if (deps) return JSON.parse(deps) - - const viteManifests = await getViteManifests({ version }) - const newDeps = viteManifestToDeps(viteManifests) - await cache.set(getRedisKey({ version, name: 'dependencies' }), JSON.stringify(newDeps)) - return newDeps + let deps = await cache.get(getRedisKey({ version, name: 'dependencies' })) + if (!deps) { + deps = await await cache.setAsync(getRedisKey({ version, name: 'dependencies' }), (async () => { + const viteManifests = await getViteManifests({ version }) + return JSON.stringify(viteManifestToDeps(viteManifests)) + })()) + } + return JSON.parse(deps) } export async function getCSSDependenciesFor ({ file, version }) { diff --git a/src/meta.js b/src/meta.js index 06a56c78599dab7ebfddb317114df97ae92cdb13..0b99feb499c7b3f6e904242e1d7ba5d388f162d0 100644 --- a/src/meta.js +++ b/src/meta.js @@ -3,11 +3,8 @@ import fetch from 'node-fetch' import * as cache from './cache.js' import { getRedisKey } from './util.js' -export async function getMergedMetadata ({ version }) { - const metadata = await cache.get(getRedisKey({ version, name: 'mergedMetadata' })) - if (metadata) return JSON.parse(metadata) - - const newMetadata = await Promise.all(config.urls.map(async url => { +export async function fetchMergedMetadata () { + const metadata = await Promise.all(config.urls.map(async url => { const { origin } = new URL(url) try { const response = await fetch(new URL('meta.json', origin)) @@ -18,7 +15,7 @@ export async function getMergedMetadata ({ version }) { } })) - newMetadata.push({ + metadata.push({ id: 'ui-middleware', name: 'UI Middleware', buildDate: process.env.BUILD_TIMESTAMP, @@ -27,7 +24,13 @@ export async function getMergedMetadata ({ version }) { }) // only return when contains data - const filtered = newMetadata.filter(Boolean) - await cache.set(getRedisKey({ version, name: 'mergedMetadata' }), JSON.stringify(filtered)) - return filtered + return metadata.filter(Boolean) +} + +export async function getMergedMetadata ({ version }) { + let metadata = await cache.get(getRedisKey({ version, name: 'mergedMetadata' })) + if (!metadata) { + metadata = await cache.setAsync(getRedisKey({ version, name: 'mergedMetadata' }), fetchMergedMetadata().then(m => JSON.stringify(m))) + } + return JSON.parse(metadata) } diff --git a/src/middlewares/load-from-cache.js b/src/middlewares/load-from-cache.js deleted file mode 100644 index 3555df14ff08875e17d1f1c8b27c749b88adc4f5..0000000000000000000000000000000000000000 --- a/src/middlewares/load-from-cache.js +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index e02e8d0d12bb42a950510027eb21c264780109f6..0000000000000000000000000000000000000000 --- a/src/middlewares/load-from-server.js +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 0a384945e922f6a652ce561ab11cd4c9f3cdd62f..0000000000000000000000000000000000000000 --- a/src/middlewares/save-to-cache.js +++ /dev/null @@ -1,20 +0,0 @@ -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/serve-files.js b/src/middlewares/serve-files.js new file mode 100644 index 0000000000000000000000000000000000000000..569718e6861cbd784f7c2f28f5f477eea920d444 --- /dev/null +++ b/src/middlewares/serve-files.js @@ -0,0 +1,28 @@ +import { fetchFileWithHeaders, loadFromCache, saveAsyncToCache } from '../files.js' +import { NotFoundError } from '../errors.js' +import createError from 'http-errors' + +export default async function (req, res, next) { + try { + if (req.method !== 'GET') return next() + const version = res.version + let data = await loadFromCache({ + path: req.path, + version + }) + if (!data) { + const path = req.path === '/' ? '/index.html' : req.path + data = await saveAsyncToCache({ version, path, promise: fetchFileWithHeaders({ path, version }) }) + } + + const { body, headers, sha256Sum } = data + res.set(headers) + res.locals.sha256Sum = sha256Sum + res.send(body) + } catch (err) { + 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) + } +} diff --git a/src/routes/manifests.js b/src/routes/manifests.js index 608a96354df2b5bd2574511ba569c4b26318d8bf..5d75dd2bbf4ac6a019d766c8dcb6f8ad72be8609 100644 --- a/src/routes/manifests.js +++ b/src/routes/manifests.js @@ -6,12 +6,9 @@ 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' } + res.send(await getOxManifests({ version: res.version })) } catch (err) { next(err) - } finally { - next() } }) diff --git a/src/routes/metadata.js b/src/routes/metadata.js index e93fcba426bd56b42b168a22a4771aeb057a5fe0..ebe28e7628bc47308d93ed9f05c87774db3bf9b3 100644 --- a/src/routes/metadata.js +++ b/src/routes/metadata.js @@ -5,12 +5,9 @@ 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' } + res.send(await getMergedMetadata({ version: res.version })) } catch (err) { next(err) - } finally { - next() } })