'use strict'; /** * openvibe-shared/serve — a site serves its OWN pinned copy of the shared browser files (the OpenVibe * Frame and friends) at /shared/*, with content-addressed URLs, instead of loading whatever * openvibe.network happens to run. The site's pin then decides what its pages execute, and its pages * keep their navbar and footer while openvibe.network is down. * * const serve = require('openvibe-shared/serve'); * app.use('/shared', serve.handler()); * `` → /shared/navbar.js?v= * * A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else * for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the * copies on openvibe.network. Only the files openvibe-shared/files lists are served, plus the share * cards (social/.jpg, openvibe-shared/social), the installable app icons * (icons/[-maskable]-.png, openvibe-shared/app-icons) and the web app manifest built * per service at manifest/.webmanifest. * * JS/CSS are minified at serve time (openvibe-shared/minify) and cached by content hash: the minify * runs once per release per file, then the immutable ?v= URL does the rest. A file the minifier * cannot handle is served raw; a minified body is used only when it is smaller than the raw file. */ const fs = require('fs'); const files = require('./files'); const social = require('./social'); const appIcons = require('./app-icons'); const appIcon = require('./app-icon'); const cache = require('./cache-policy'); const { hashAsset } = require('./assets'); const { minifyFile } = require('./minify'); const hashes = new Map(); const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name)); function hashOf(name) { if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name)))); return hashes.get(name); } /** The content-addressed URL of a browser file under `base` (default /shared). */ function url(name, base = '/shared') { if (!files.isBrowserFile(name)) throw new Error(`openvibe-shared/serve: ${name} is not a browser file`); return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`; } const MANIFEST_RE = /^manifest\/([a-z0-9][a-z0-9-]{0,40})\.webmanifest$/; /** `short_name` is the part after "OpenVibe." when that fits in 12 characters; otherwise the full name. */ function shortName(name) { const suffix = typeof name === 'string' && name.startsWith('OpenVibe.') ? name.slice('OpenVibe.'.length) : ''; return suffix && suffix.length <= 12 ? suffix : name; } /** The web app manifest for a service, from its pre-rendered name/tagline and hashed icon URLs. */ function manifestFor(service) { const site = appIcons.site(service); if (!site || !appIcons.has(service)) return null; return { name: site.name, short_name: shortName(site.name), description: site.description || '', start_url: '/', scope: '/', display: 'standalone', background_color: appIcon.BG, theme_color: '#0d131d', icons: [ { src: appIcons.url(service, 192), sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: appIcons.url(service, 512), sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: appIcons.url(service, 192, { maskable: true }), sizes: '192x192', type: 'image/png', purpose: 'maskable' }, { src: appIcons.url(service, 512, { maskable: true }), sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], id: '/', }; } /** `/shared/manifest/.webmanifest?v=`, or null when there is no icon set for the service. */ function manifestUrl(service) { const data = manifestFor(service); if (!data) return null; return `/shared/manifest/${service}.webmanifest?v=${hashAsset(JSON.stringify(data))}`; } function contentType(name) { if (name.endsWith('.css')) return 'text/css; charset=utf-8'; if (name.endsWith('.jpg')) return 'image/jpeg'; if (name.endsWith('.png')) return 'image/png'; if (name.endsWith('.woff2')) return 'font/woff2'; if (name.endsWith('.svg')) return 'image/svg+xml; charset=utf-8'; return 'application/javascript; charset=utf-8'; } function vOf(req) { return req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v'); } function cacheHeaders(res, name, hash, v) { res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 })); res.setHeader('ETag', `"${hash}"`); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('X-Content-Type-Options', 'nosniff'); } // Minified bodies by `${name}:${hash}`: minification runs once per release, then the cache and the // immutable ?v= URL do the rest. Bounded, because a site never serves more than this package lists. const bodies = new Map(); /** The bytes to serve for `name`: minified when JS/CSS, raw otherwise. Fail-open to raw. */ async function bodyFor(name) { const hash = hashOf(name); const key = `${name}:${hash}`; const hit = bodies.get(key); if (hit) return hit; if (bodies.size > 300) bodies.clear(); const raw = fs.readFileSync(fileOf(name)); if (!name.endsWith('.js') && !name.endsWith('.css')) { const entry = { body: raw, minified: false }; bodies.set(key, entry); return entry; } try { const body = await minifyFile(name, raw); const entry = body.length < raw.length ? { body, minified: true } : { body: raw, minified: false }; bodies.set(key, entry); return entry; } catch { const entry = { body: raw, minified: false }; bodies.set(key, entry); return entry; } } /** Express/connect handler for app.use('/shared', handler()). */ function handler() { return function openvibeShared(req, res, next) { if (req.method !== 'GET' && req.method !== 'HEAD') return next(); const name = String(req.path || '').replace(/^\/+/, ''); const manifest = MANIFEST_RE.exec(name); if (manifest) { const data = manifestFor(manifest[1]); if (!data) return next(); const json = JSON.stringify(data); const hash = hashAsset(json); const v = vOf(req); res.setHeader('Content-Type', 'application/manifest+json'); cacheHeaders(res, name, hash, v); if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); } if (req.method === 'HEAD') return res.end(); return res.end(json); } if (!files.isBrowserFile(name) && !social.isCard(name) && !appIcons.isIcon(name)) return next(); const hash = hashOf(name); const v = vOf(req); res.setHeader('Content-Type', contentType(name)); cacheHeaders(res, name, hash, v); if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); } if (req.method === 'HEAD') return res.end(); bodyFor(name).then(({ body }) => res.end(body)).catch(next); }; } module.exports = { handler, url, hashOf, manifestUrl, _bodyFor: bodyFor };