diff --git a/spec/dependencies_test.js b/spec/dependencies_test.js index f89e4ebc5111a3f05b4fa18b43f1891386a3e3fd..e97e4193d393212cb5e144ac88f08d334383f48f 100644 --- a/spec/dependencies_test.js +++ b/spec/dependencies_test.js @@ -3,7 +3,7 @@ import { describe, it, expect } from '@jest/globals' import { viteManifestToDeps } from '../src/manifests.js' describe('Vite manifest parsing', () => { - it('should', () => { + it('works for simple modules', () => { const deps = viteManifestToDeps({ '../io.ox/guidedtours/i18n.de_DE.js': { file: 'io.ox/guidedtours/i18n.de_DE.js', @@ -96,7 +96,16 @@ describe('Vite manifest parsing', () => { }, '_preload-helper-a7bbbf37.js': { file: 'io.ox/guidedtours/preload-helper-a7bbbf37.js' - }, + } + }) + expect(typeof deps).toBe('object') + expect(Object.keys(deps).length).toBe(8) + expect(deps['io.ox/guidedtours/main.07676e21.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js']) + expect(deps['io.ox/guidedtours/multifactor.22d3e17d.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js', 'io.ox/guidedtours/main.07676e21.js', 'io.ox/guidedtours/assets/multifactor.91962241.css']) + }) + + it('exports assets as entrypoints without dependencies', async () => { + const deps = viteManifestToDeps({ 'themes/icons/alarm.svg': { file: 'assets/alarm.6d2fbb40.js', src: 'themes/icons/alarm.svg', @@ -107,10 +116,59 @@ describe('Vite manifest parsing', () => { meta: {} } }) - expect(typeof deps).toBe('object') - expect(Object.keys(deps).length).toBe(8) - expect(deps['io.ox/guidedtours/main.07676e21.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js']) - expect(deps['io.ox/guidedtours/multifactor.22d3e17d.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js', 'io.ox/guidedtours/main.07676e21.js', 'io.ox/guidedtours/assets/multifactor.91962241.css']) + expect(Object.keys(deps).length).toBe(2) expect(deps['assets/alarm.6d2fbb40.js']).toEqual(['assets/alarm.310541a0.svg']) + expect(deps['assets/alarm.310541a0.svg']).toEqual([]) + }) + + it('exports css as entrypoints without dependencies', async () => { + const deps = viteManifestToDeps({ + 'main.js': { + file: 'main.js', + src: 'main.js', + isEntry: true, + css: ['assets/main.3b761440.css'], + meta: {} + } + }) + expect(deps['main.js']).toEqual(['assets/main.3b761440.css']) + expect(deps['assets/main.3b761440.css']).toEqual([]) + }) + + it('separately exports HTML entrypoints', async () => { + const deps = viteManifestToDeps({ + 'index.html': { + file: 'index.html.js', + src: 'index.html', + isEntry: true, + imports: ['main.js', '_preload-helper.a295b1c6.js', '_vendor.ae457d06.js'], + meta: {} + }, + '_preload-helper.a295b1c6.js': { file: 'assets/preload-helper.a295b1c6.js' }, + '_vendor.ae457d06.js': { + file: 'assets/vendor.ae457d06.js', + isDynamicEntry: true, + dynamicImports: ['io.ox/core/a11y.js'] + }, + 'io.ox/core/a11y.js': { + file: 'io.ox/core/a11y.js', + src: 'io.ox/core/a11y.js', + isEntry: true, + isDynamicEntry: true, + imports: ['_vendor.ae457d06.js'], + meta: {} + }, + 'main.js': { + file: 'main.js', + src: 'main.js', + isEntry: true, + imports: ['_preload-helper.a295b1c6.js', '_vendor.ae457d06.js', 'io.ox/core/a11y.js'], + css: ['assets/main.3b761440.css'], + meta: {} + } + }) + + expect(Object.keys(deps).length).toBe(7) + expect(deps['index.html']).toEqual(['index.html.js']) }) }) diff --git a/spec/file_caching_test.js b/spec/file_caching_test.js new file mode 100644 index 0000000000000000000000000000000000000000..7a30e60ecb5595ccecfdb863010107800fc94c06 --- /dev/null +++ b/spec/file_caching_test.js @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals' +import mockfs from 'mock-fs' +import request from 'supertest' +import { createApp } from '../src/createApp' +import { createMockServer, generateSimpleViteManifest, getRandomPort } from './util.js' + +describe('File caching service', () => { + let app + let mockserver + const port = getRandomPort() + + beforeAll(() => { + mockfs({ + './config/manifests': { + 'urls.yaml': `manifests: + - http://localhost:${port}/manifest.json` + } + }) + app = createApp() + }) + + afterAll(() => { + mockfs.restore() + }) + + beforeEach(async () => { + mockserver = await createMockServer({ port }) + mockserver.respondWith({ + '/manifest.json': generateSimpleViteManifest({ + 'example.js': { imports: ['test.txt'] }, + 'test.txt': { }, + 'main.css': {}, + 'index.html': { + file: 'index.html.js', + isEntry: true, + imports: ['example.js'], + css: ['main.css'] + } + }), + '/example.js': (req, res) => res.setHeader('content-type', 'application/javascript').status(200).send('this is example'), + '/test.txt': (req, res) => res.setHeader('content-type', 'text/plain').status(200).send('this is test'), + '/index.html.js': (req, res) => res.setHeader('content-type', 'application/javascript').status(200).send('this is index.html.js'), + '/index.html': (req, res) => res.setHeader('content-type', 'text/html').status(200).send('<html><head></head><body>it\'s me</body></html>'), + '/main.css': (req, res) => res.setHeader('content-type', 'text/css').status(200).send('.foo { color: #000; }') + + }) + }) + + afterEach(() => { + mockserver.close() + process.env.CACHE_TTL = 30000 + }) + + it('serves files defined in manifest.json file', async () => { + const response = await request(app).get('/example.js') + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('application/javascript; charset=utf-8') + expect(response.text).toBe('this is example') + const response2 = await request(app).get('/test.txt') + expect(response2.statusCode).toBe(200) + expect(response2.headers['content-type']).toBe('text/plain; charset=utf-8') + expect(response2.text).toBe('this is test') + }) + + it('serves css files', async () => { + const response = await request(app).get('/main.css') + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('text/css; charset=utf-8') + }) + + it('serves / as index.html', async () => { + const response = await request(app).get('/') + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('text/html; charset=utf-8') + expect(response.text).toBe('<html><head></head><body>it\'s me</body></html>') + }) +}) diff --git a/spec/util.js b/spec/util.js index cd78917e26d22d4267a7739756124a81dfd506cd..5453dd9650845087c34812614d2faaaf1f28ff5b 100644 --- a/spec/util.js +++ b/spec/util.js @@ -11,7 +11,11 @@ export async function createMockServer ({ port }) { }) server.respondWith = function (routes) { for (const [route, data] of Object.entries(routes)) { - app.get(route, (req, res) => res.json(data)) + if (typeof data === 'function') { + app.get(route, data) + } else { + app.get(route, (req, res) => res.json(data)) + } } } return server @@ -19,11 +23,12 @@ export async function createMockServer ({ port }) { export function generateSimpleViteManifest (mapping) { const viteManifest = {} - for (const [file, namespace] of Object.entries(mapping)) { + for (const [file, value] of Object.entries(mapping)) { viteManifest[file] = { file, - meta: namespace ? { manifests: [{ namespace }] } : {} + meta: typeof value === 'string' ? { manifests: [{ namespace: value }] } : {} } + if (typeof value === 'object') Object.assign(viteManifest[file], value) } return viteManifest } diff --git a/src/createApp.js b/src/createApp.js index e4cca76a12cfa3fae792725785b9532d902a248d..ffdaa7800eec5ef5f2f64fefb328d266b9c020d3 100644 --- a/src/createApp.js +++ b/src/createApp.js @@ -19,6 +19,7 @@ import swaggerUi from 'swagger-ui-express' import yaml from 'js-yaml' import fs from 'fs' import { getDependencies, getOxManifests } from './manifests.js' +import { fileCache } from './files.js' const ignorePaths = ['/ready', '/healthy'] const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8')) @@ -61,6 +62,20 @@ export function createApp () { } }) + app.get('/', async (req, res, next) => { + await getDependencies() + const { 'content-type': contentType, content } = fileCache.get('/index.html') + if (content) return res.setHeader('content-type', contentType).status(200).send(content) + next() + }) + + app.use(async (req, res, next) => { + await getDependencies() + const { 'content-type': contentType, content } = fileCache.get(req.path) + if (content) return res.setHeader('content-type', contentType).status(200).send(content) + next() + }) + app.use(function (err, req, res, next) { logger.error(err) res.status(500).end() diff --git a/src/files.js b/src/files.js new file mode 100644 index 0000000000000000000000000000000000000000..9d2c45b3bd9f5253b26f94ac00d7e889ab535bf0 --- /dev/null +++ b/src/files.js @@ -0,0 +1,40 @@ +import fetch from 'node-fetch' + +class FileCache { + async warmUp (manifests, deps) { + const cache = Object.fromEntries(await (async function () { + const files = Object.keys(deps) + const chunkSize = Math.ceil(files.length / 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) { + console.error('could not find manifest for', file) + return null + } + const response = await fetch(new URL(file, manifest.meta.baseUrl)) + if (!response.ok) return null + return [file, { + 'content-type': response.headers.get('content-type'), + content: await response.buffer() + }] + } catch (e) { console.error(e) } + }))).filter(data => Array.isArray(data) && data.length === 2)) + } + return result + }())) + this._cache = cache + } + + get (path) { + return this?._cache[path.slice(1)] || {} + } +} + +export const fileCache = new FileCache() diff --git a/src/manifests.js b/src/manifests.js index 4b95b7fd616429d07ec1c43dcf25b299524525d8..44b925028d725c5be9acf5f6c250d8c197983f16 100644 --- a/src/manifests.js +++ b/src/manifests.js @@ -2,6 +2,8 @@ import fs from 'fs/promises' import yaml from 'js-yaml' import fetch from 'node-fetch' import path from 'path' +import { URL } from 'url' +import { fileCache } from './files.js' export const loadViteManifests = (() => { let cache @@ -17,11 +19,17 @@ export const loadViteManifests = (() => { // vite manifests contains a set of objects with the vite-manifests // from the corresponding registered services const viteManifests = await Promise.all(urls.map(async url => { + const { origin } = new URL(url) // fetch the manifests const result = await fetch(url) if (!result.ok) throw new Error(`Failed to load manifest for url ${result.url} (Status: ${result.status}: ${result.statusText})`) try { - return result.json() + const manifest = await result.json() + for (const file in manifest) { + manifest[file].meta = manifest[file].meta || {} + manifest[file].meta.baseUrl = origin + } + return manifest } catch (err) { throw new Error(`Failed to load manifest for url ${result.url}: ${err}`) } @@ -73,7 +81,10 @@ export const getOxManifests = (() => { export function viteManifestToDeps (viteManifest) { const deps = {} - for (const { file, imports, css, assets } of Object.values(viteManifest)) { + for (const [codePoint, { isEntry, file, imports, css, assets }] of Object.entries(viteManifest)) { + if (isEntry && codePoint.endsWith('.html')) deps[codePoint] = [file] + if (Array.isArray(assets)) assets.forEach(asset => { deps[asset] = [] }) + if (Array.isArray(css)) css.forEach(css => { deps[css] = [] }) deps[file] = [] .concat(imports?.map(path => viteManifest[path].file)) .concat(css) @@ -90,6 +101,7 @@ export const getDependencies = (() => { const viteManifest = await loadViteManifests() if (viteManifest !== prevViteManifest) { depCache = viteManifestToDeps(viteManifest) + await fileCache.warmUp(viteManifest, depCache) prevViteManifest = viteManifest } return depCache