diff --git a/spec/headers_test.js b/spec/headers_test.js new file mode 100644 index 0000000000000000000000000000000000000000..efe76447e82cff70e624d96b4e55513a6c397fbb --- /dev/null +++ b/spec/headers_test.js @@ -0,0 +1,63 @@ +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('Responses contain custom headers', () => { + let app + let mockserver + const port = getRandomPort() + + beforeAll(() => { + mockfs({ + './config/manifests': { + 'urls.yaml': `manifests: + - http://localhost:${port}/api/manifest.json` + } + }) + app = createApp() + }) + + afterAll(() => { + mockfs.restore() + }) + + beforeEach(async () => { + mockserver = await createMockServer({ port }) + mockserver.respondWith({ + '/api/manifest.json': generateSimpleViteManifest({ + 'example.js': {}, + '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'), + '/index.html.js': (req, res) => res.setHeader('content-type', 'application/javascript').status(200).send('this is index.html.js'), + '/main.css': (req, res) => res.setHeader('content-type', 'text/css').status(200).send('.foo { color: #000; }'), + '/index.html': (req, res) => res.setHeader('content-type', 'text/html').status(200).send('<html><head></head><body>it\'s me</body></html>') + }) + await request(app).get('/ready') + }) + + afterEach(() => { + mockserver.close() + process.env.CACHE_TTL = 30000 + }) + + it('index.html has version', async () => { + const response = await request(app).get('/index.html') + expect(response.statusCode).toBe(200) + expect(response.headers.version).toMatch(/\d*\.3220550168/) + }) + + it('javascript file contains dependencies', async () => { + const response = await request(app).get('/index.html.js') + expect(response.statusCode).toBe(200) + expect(response.headers.dependencies).toEqual('example.js,main.css') + }) +}) diff --git a/src/createApp.js b/src/createApp.js index f14a68a0031cfa4fad1273a53397e3f26337d0be..44066101ef8fad489dbbc8edabb7b8cafd27042a 100644 --- a/src/createApp.js +++ b/src/createApp.js @@ -18,7 +18,7 @@ import promBundle from 'express-prom-bundle' import swaggerUi from 'swagger-ui-express' import yaml from 'js-yaml' import fs from 'fs' -import { getDependencies, getOxManifests } from './manifests.js' +import { getDependencies, getOxManifests, getVersion } from './manifests.js' import { fileCache } from './files.js' const ignorePaths = ['/ready', '/healthy'] @@ -62,6 +62,12 @@ export function createApp () { 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()) @@ -86,14 +92,28 @@ export function createApp () { app.use(async (req, res, next) => { const { 'content-type': contentType, content } = fileCache.get(req.path) - if (content) return res.setHeader('content-type', contentType).status(200).send(content) + if (content) { + const allDependencies = await getDependencies() + const dependencies = allDependencies[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) return res.setHeader('content-type', contentType).status(200).send(content) + if (content) { + const allDependencies = await getDependencies() + const dependencies = allDependencies[req.path.substr(1)] || [] + return res + .setHeader('content-type', contentType) + .setHeader('dependencies', dependencies.join(',')) + .status(200).send(content) + } } catch (e) {} next() }) diff --git a/src/files.js b/src/files.js index 46ceb809ddca2c31227512f29cb05638675c95e9..2ae2bb511e49879f752ae0f45a55b40d459f83a5 100644 --- a/src/files.js +++ b/src/files.js @@ -4,7 +4,7 @@ import { config } from './config.js' async function fetchData (path, baseUrl) { const response = await fetch(new URL(path, baseUrl)) - if (!response.ok) throw new Error('Error fetching file') + if (!response.ok) throw new Error(`Error fetching file: ${path}`) const content = await response.buffer() const sha256Sum = crypto.createHash('sha256').update(content).digest('base64') return [path, { diff --git a/src/manifests.js b/src/manifests.js index a043e2786f67ce4a784793c892433f574d0a6546..3660508715bb08a0d9f8321e90a5fc1b205e301a 100644 --- a/src/manifests.js +++ b/src/manifests.js @@ -3,6 +3,7 @@ import path from 'path' import { URL } from 'url' import { fileCache } from './files.js' import { config } from './config.js' +import { hash } from './util.js' export const loadViteManifests = (() => { let cache @@ -104,3 +105,20 @@ export const getDependencies = (() => { return depCache } })() + +export const getVersion = (() => { + let prevViteManifest + let version + let versionString + return async function getVersion () { + const viteManifest = await loadViteManifests() + if (viteManifest !== prevViteManifest) { + const newVersion = hash(viteManifest) + if (newVersion !== version) { + versionString = `${+new Date()}.${newVersion}` + version = newVersion + } + } + return versionString + } +})() diff --git a/src/swagger.yaml b/src/swagger.yaml index 0765cbfb1a849a9dcc98c02e6f3546a05904e070..aecf47a0d23024df40f0ae124faeaa0f12db7503 100644 --- a/src/swagger.yaml +++ b/src/swagger.yaml @@ -1,15 +1,34 @@ openapi: 3.0.1 +tags: + - name: code loading +servers: + - http://localhost:3000/ info: title: Manifest Service version: 1.0.0 description: Micro service that collects manifest files from different services and merge them into a single file + contact: + email: ui-team@open-xchange.com paths: /manifests: get: + operationId: manifests_get + tags: + - code loading summary: App Suite UI compatible version of manifests data + description: | + Manifest data prepared for consumption by App Suite UI. The data + can be used to determine the timing when to load certain files + from the server and depending on the users configuration also if + the files should be loaded at all. responses: '200': description: manifests data in JSON format + headers: + version: + schema: + type: string + description: The version of the manifest files content: application/json: schema: @@ -43,10 +62,20 @@ paths: example: '!smartphone' /dependencies: get: + operationId: dependencies_get + tags: + - code loading summary: Mapping of all files to all of its dependencies + description: | + The data can be used to construct a dependency tree for any given file of a project. responses: '200': description: dependency information for all files of the manifest + headers: + version: + schema: + type: string + description: The version of the manifest files content: application/json: schema: diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000000000000000000000000000000000000..0229cba7f435a4cd3554dacc1452e07a9b78ef48 --- /dev/null +++ b/src/util.js @@ -0,0 +1,16 @@ +// totaly awesome hash function. Do not use this for encryption (crypto.subtle.digest etc would be overkill for this) +export function hash (array) { + const string = array.toString() + if (!string.length) throw new Error('TypeError: Unexpected data to calculate hash from') + + let hash = 0 + let char + + for (let i = 0; i < string.length; i++) { + char = string.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash |= 0 + } + + return new Uint32Array([hash]).toString() +}