openvibers/openvibe.shared serve.js

serve.js

main 7.3 KB · 165 lines · javascript

1'use strict';
2/**
3 * openvibe-shared/serve — a site serves its OWN pinned copy of the shared browser files (the OpenVibe
4 * Frame and friends) at /shared/*, with content-addressed URLs, instead of loading whatever
5 * openvibe.network happens to run. The site's pin then decides what its pages execute, and its pages
6 * keep their navbar and footer while openvibe.network is down.
7 *
8 * const serve = require('openvibe-shared/serve');
9 * app.use('/shared', serve.handler());
10 * `<script src="${serve.url('navbar.js')}" defer></script>` → /shared/navbar.js?v=<content hash>
11 *
12 * A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
13 * for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
14 * copies on openvibe.network. Only the files openvibe-shared/files lists are served, plus the share
15 * cards (social/<service>.jpg, openvibe-shared/social), the installable app icons
16 * (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built
17 * per service at manifest/<service>.webmanifest.
18 *
19 * JS/CSS are minified at serve time (openvibe-shared/minify) and cached by content hash: the minify
20 * runs once per release per file, then the immutable ?v= URL does the rest. A file the minifier
21 * cannot handle is served raw; a minified body is used only when it is smaller than the raw file.
22 */
23const fs = require('fs');
24const files = require('./files');
25const social = require('./social');
26const appIcons = require('./app-icons');
27const appIcon = require('./app-icon');
28const cache = require('./cache-policy');
29const { hashAsset } = require('./assets');
30const { minifyFile } = require('./minify');
31
32const hashes = new Map();
33const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));
34function hashOf(name) {
35 if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));
36 return hashes.get(name);
37}
38
39/** The content-addressed URL of a browser file under `base` (default /shared). */
40function url(name, base = '/shared') {
41 if (!files.isBrowserFile(name)) throw new Error(`openvibe-shared/serve: ${name} is not a browser file`);
42 return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`;
43}
44
45const MANIFEST_RE = /^manifest\/([a-z0-9][a-z0-9-]{0,40})\.webmanifest$/;
46
47/** `short_name` is the part after "OpenVibe." when that fits in 12 characters; otherwise the full name. */
48function shortName(name) {
49 const suffix = typeof name === 'string' && name.startsWith('OpenVibe.') ? name.slice('OpenVibe.'.length) : '';
50 return suffix && suffix.length <= 12 ? suffix : name;
51}
52
53/** The web app manifest for a service, from its pre-rendered name/tagline and hashed icon URLs. */
54function manifestFor(service) {
55 const site = appIcons.site(service);
56 if (!site || !appIcons.has(service)) return null;
57 return {
58 name: site.name,
59 short_name: shortName(site.name),
60 description: site.description || '',
61 start_url: '/',
62 scope: '/',
63 display: 'standalone',
64 background_color: appIcon.BG,
65 theme_color: '#0d131d',
66 icons: [
67 { src: appIcons.url(service, 192), sizes: '192x192', type: 'image/png', purpose: 'any' },
68 { src: appIcons.url(service, 512), sizes: '512x512', type: 'image/png', purpose: 'any' },
69 { src: appIcons.url(service, 192, { maskable: true }), sizes: '192x192', type: 'image/png', purpose: 'maskable' },
70 { src: appIcons.url(service, 512, { maskable: true }), sizes: '512x512', type: 'image/png', purpose: 'maskable' },
71 ],
72 id: '/',
73 };
74}
75
76/** `/shared/manifest/<service>.webmanifest?v=<hash>`, or null when there is no icon set for the service. */
77function manifestUrl(service) {
78 const data = manifestFor(service);
79 if (!data) return null;
80 return `/shared/manifest/${service}.webmanifest?v=${hashAsset(JSON.stringify(data))}`;
81}
82
83function contentType(name) {
84 if (name.endsWith('.css')) return 'text/css; charset=utf-8';
85 if (name.endsWith('.jpg')) return 'image/jpeg';
86 if (name.endsWith('.png')) return 'image/png';
87 if (name.endsWith('.woff2')) return 'font/woff2';
88 if (name.endsWith('.svg')) return 'image/svg+xml; charset=utf-8';
89 return 'application/javascript; charset=utf-8';
90}
91
92function vOf(req) {
93 return req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
94}
95
96function cacheHeaders(res, name, hash, v) {
97 res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
98 res.setHeader('ETag', `"${hash}"`);
99 res.setHeader('Access-Control-Allow-Origin', '*');
100 res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
101 res.setHeader('X-Content-Type-Options', 'nosniff');
102}
103
104// Minified bodies by `${name}:${hash}`: minification runs once per release, then the cache and the
105// immutable ?v= URL do the rest. Bounded, because a site never serves more than this package lists.
106const bodies = new Map();
107
108/** The bytes to serve for `name`: minified when JS/CSS, raw otherwise. Fail-open to raw. */
109async function bodyFor(name) {
110 const hash = hashOf(name);
111 const key = `${name}:${hash}`;
112 const hit = bodies.get(key);
113 if (hit) return hit;
114 if (bodies.size > 300) bodies.clear();
115 const raw = fs.readFileSync(fileOf(name));
116 if (!name.endsWith('.js') && !name.endsWith('.css')) {
117 const entry = { body: raw, minified: false };
118 bodies.set(key, entry);
119 return entry;
120 }
121 try {
122 const body = await minifyFile(name, raw);
123 const entry = body.length < raw.length ? { body, minified: true } : { body: raw, minified: false };
124 bodies.set(key, entry);
125 return entry;
126 } catch {
127 const entry = { body: raw, minified: false };
128 bodies.set(key, entry);
129 return entry;
130 }
131}
132
133/** Express/connect handler for app.use('/shared', handler()). */
134function handler() {
135 return function openvibeShared(req, res, next) {
136 if (req.method !== 'GET' && req.method !== 'HEAD') return next();
137 const name = String(req.path || '').replace(/^\/+/, '');
138
139 const manifest = MANIFEST_RE.exec(name);
140 if (manifest) {
141 const data = manifestFor(manifest[1]);
142 if (!data) return next();
143 const json = JSON.stringify(data);
144 const hash = hashAsset(json);
145 const v = vOf(req);
146 res.setHeader('Content-Type', 'application/manifest+json');
147 cacheHeaders(res, name, hash, v);
148 if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
149 if (req.method === 'HEAD') return res.end();
150 return res.end(json);
151 }
152
153 if (!files.isBrowserFile(name) && !social.isCard(name) && !appIcons.isIcon(name)) return next();
154 const hash = hashOf(name);
155 const v = vOf(req);
156 res.setHeader('Content-Type', contentType(name));
157 cacheHeaders(res, name, hash, v);
158 if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
159 if (req.method === 'HEAD') return res.end();
160 bodyFor(name).then(({ body }) => res.end(body)).catch(next);
161 };
162}
163
164module.exports = { handler, url, hashOf, manifestUrl, _bodyFor: bodyFor };
165