domma-cms 1.8.26 → 1.8.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +3 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/page-editor-arrange.js +1 -1
- package/admin/js/lib/seo-structured-data.js +1 -0
- package/admin/js/templates/page-editor.html +16 -0
- package/admin/js/views/page-editor.js +12 -12
- package/docs/cache.md +25 -0
- package/package.json +1 -1
- package/server/routes/api/cache.js +3 -1
- package/server/routes/docs-public.js +1 -1
- package/server/routes/public.js +14 -52
- package/server/server.js +7 -0
- package/server/services/assetCache.js +141 -0
- package/server/services/cache/index.js +54 -6
- package/server/services/content.js +61 -0
- package/server/services/htmlResponse.js +141 -0
- package/server/services/renderer.js +30 -0
package/server/routes/public.js
CHANGED
|
@@ -18,7 +18,6 @@ import {viewerRoleKey} from '../services/userRoles.js';
|
|
|
18
18
|
import {hooks} from '../services/hooks.js';
|
|
19
19
|
import {getConfig} from '../config.js';
|
|
20
20
|
import * as cache from '../services/cache/index.js';
|
|
21
|
-
import {createHash} from 'crypto';
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* Derive the absolute origin for the current request.
|
|
@@ -40,55 +39,10 @@ export function getBaseUrl(request) {
|
|
|
40
39
|
return `${request.protocol}://${host}`;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* @param {string} str
|
|
48
|
-
* @returns {string}
|
|
49
|
-
*/
|
|
50
|
-
function escapeHtml(str) {
|
|
51
|
-
return String(str)
|
|
52
|
-
.replace(/&/g, '&')
|
|
53
|
-
.replace(/</g, '<')
|
|
54
|
-
.replace(/>/g, '>')
|
|
55
|
-
.replace(/"/g, '"')
|
|
56
|
-
.replace(/'/g, ''');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/*
|
|
60
|
-
* Send an HTML page with headers that make a browser CHECK before reusing it.
|
|
61
|
-
*
|
|
62
|
-
* Public pages went out with no Cache-Control, no ETag and no Last-Modified at
|
|
63
|
-
* all. With nothing to go on a browser applies heuristic caching, so an edited
|
|
64
|
-
* page could keep showing the old content on a normal refresh - the server was
|
|
65
|
-
* serving the new one the whole time. That is indistinguishable from a CMS bug
|
|
66
|
-
* and was reported as one.
|
|
67
|
-
*
|
|
68
|
-
* `no-cache` does not mean "do not store": it means store it, then revalidate
|
|
69
|
-
* before every reuse. Paired with an ETag that makes the common case a 304 with
|
|
70
|
-
* no body rather than a full re-download.
|
|
71
|
-
*
|
|
72
|
-
* `private` because these responses are role-dependent - the response cache
|
|
73
|
-
* keys on role for exactly that reason - and a shared proxy must never hand one
|
|
74
|
-
* visitor a page rendered for another's permissions.
|
|
75
|
-
*/
|
|
76
|
-
export function sendHtml(request, reply, html) {
|
|
77
|
-
const etag = `"${createHash('sha1').update(html).digest('base64')}"`;
|
|
78
|
-
reply.header('cache-control', 'private, no-cache');
|
|
79
|
-
reply.header('etag', etag);
|
|
80
|
-
|
|
81
|
-
// If-None-Match may carry a list, and a weak validator's W/ prefix.
|
|
82
|
-
const seen = String(request.headers['if-none-match'] || '')
|
|
83
|
-
.split(',')
|
|
84
|
-
.map((t) => t.trim().replace(/^W\//, ''));
|
|
85
|
-
if (seen.includes(etag)) return reply.code(304).send();
|
|
86
|
-
|
|
87
|
-
// Deliberately no reply.code() here: the 404 handlers have already called
|
|
88
|
-
// reply.status(404), and forcing 200 would turn every not-found into a
|
|
89
|
-
// soft 404 that search engines index as a real page.
|
|
90
|
-
return reply.type('text/html; charset=utf-8').send(html);
|
|
91
|
-
}
|
|
42
|
+
// Validators, the 304 and the compressed body kept per encoding live in
|
|
43
|
+
// services/htmlResponse.js; re-exported because docs-public.js imports it from here.
|
|
44
|
+
import {sendHtml} from '../services/htmlResponse.js';
|
|
45
|
+
export {sendHtml};
|
|
92
46
|
|
|
93
47
|
export async function publicRoutes(fastify) {
|
|
94
48
|
// Admin panel: serve index.html for all /admin/* paths (SPA fallback)
|
|
@@ -249,6 +203,7 @@ export async function publicRoutes(fastify) {
|
|
|
249
203
|
if (isDraft) {
|
|
250
204
|
reply.header('cache-control', 'no-store, private');
|
|
251
205
|
reply.header('x-robots-tag', 'noindex, nofollow');
|
|
206
|
+
reply.header('x-dm-cache', 'bypass');
|
|
252
207
|
return reply.type('text/html; charset=utf-8').send(await render());
|
|
253
208
|
}
|
|
254
209
|
|
|
@@ -256,6 +211,7 @@ export async function publicRoutes(fastify) {
|
|
|
256
211
|
// so these pages must render against the expiry ledger on every request.
|
|
257
212
|
if (page.hasExpiry) {
|
|
258
213
|
reply.header('cache-control', 'no-store, private');
|
|
214
|
+
reply.header('x-dm-cache', 'bypass');
|
|
259
215
|
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
260
216
|
return reply.type('text/html; charset=utf-8').send(await render());
|
|
261
217
|
}
|
|
@@ -265,6 +221,7 @@ export async function publicRoutes(fastify) {
|
|
|
265
221
|
// must not share an entry. Anonymous viewers of such a view get no rows
|
|
266
222
|
// at all (views.js), which is one output, so they still share the cache.
|
|
267
223
|
if (page.perViewer && userObj) {
|
|
224
|
+
reply.header('x-dm-cache', 'bypass');
|
|
268
225
|
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
269
226
|
return sendHtml(request, reply, await render());
|
|
270
227
|
}
|
|
@@ -276,9 +233,14 @@ export async function publicRoutes(fastify) {
|
|
|
276
233
|
const cacheKey = `page:${urlPath}:m${pageMtime}:role${viewerRoleKey(userObj)}:o${baseUrl}`;
|
|
277
234
|
// The footer's blocks (site.json footer.blocks) are on every page: editing one must reach the cached pages.
|
|
278
235
|
const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site', ...footerBlockCacheTags(getConfig('site'))];
|
|
279
|
-
|
|
236
|
+
// x-dm-cache says which way this response went - hit, miss, off (the
|
|
237
|
+
// cache is disabled) or bypass (a draft, an expiring or a per-viewer
|
|
238
|
+
// page, never cached) - so a slow page can be read from the browser.
|
|
239
|
+
const {value: html, status} = await cache.wrapStatus(cacheKey, render, {tags: cacheTags});
|
|
240
|
+
reply.header('x-dm-cache', status);
|
|
280
241
|
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
281
|
-
|
|
242
|
+
// cached: this HTML is shared, so its compressed body is kept too (htmlResponse.js).
|
|
243
|
+
return sendHtml(request, reply, html, {cached: true});
|
|
282
244
|
});
|
|
283
245
|
}
|
|
284
246
|
|
package/server/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import multipart from '@fastify/multipart';
|
|
|
14
14
|
import rateLimit from '@fastify/rate-limit';
|
|
15
15
|
import helmet from '@fastify/helmet';
|
|
16
16
|
import compress from '@fastify/compress';
|
|
17
|
+
import {registerAssetCache} from './services/assetCache.js';
|
|
17
18
|
import path from 'path';
|
|
18
19
|
import fs from 'fs/promises';
|
|
19
20
|
import {fileURLToPath} from 'url';
|
|
@@ -159,6 +160,12 @@ if (publicCorsPrefixes.length) {
|
|
|
159
160
|
await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
|
|
160
161
|
await app.register(rateLimit, rateLimitOptions());
|
|
161
162
|
|
|
163
|
+
// Text assets (scripts, stylesheets, SVG, JSON) from memory, compressed once
|
|
164
|
+
// per encoding - services/assetCache.js. Its onSend hook must be added BEFORE
|
|
165
|
+
// compress registers its own, so compress finds content-encoding set and
|
|
166
|
+
// steps aside.
|
|
167
|
+
registerAssetCache(app);
|
|
168
|
+
|
|
162
169
|
// Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
|
|
163
170
|
// content types (images, fonts, video) and responses under threshold.
|
|
164
171
|
await app.register(compress, {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static assets from memory, compressed once.
|
|
3
|
+
*
|
|
4
|
+
* @fastify/static streams every stylesheet and script from disk on every
|
|
5
|
+
* request - stat, open, read, close - and @fastify/compress then brotli's the
|
|
6
|
+
* stream for every visitor. On a busy site that was a whole core for about a
|
|
7
|
+
* thousand assets a second, all of it repeated work: the files do not change
|
|
8
|
+
* between requests. This hook, placed BEFORE @fastify/compress's, keeps the
|
|
9
|
+
* served form of a text asset in memory - the bytes as sent, per encoding
|
|
10
|
+
* (br, gzip, or identity for a client that accepts none) - and answers the
|
|
11
|
+
* next request with the buffer. The file is still stat'ed each time, so an
|
|
12
|
+
* edit (a new mtime or size) is seen at once, and @fastify/static still does
|
|
13
|
+
* everything else: it resolved the path, set the type, the cache-control the
|
|
14
|
+
* mount asked for, the ETag and Last-Modified, and answered the 304s, the
|
|
15
|
+
* HEADs and the 404s before this runs. The hook only swaps the stream for a
|
|
16
|
+
* buffer, so a file @fastify/static would not have served is never served
|
|
17
|
+
* here either.
|
|
18
|
+
*
|
|
19
|
+
* Only what is worth it: GET, a 200, a compressible type (the same rule
|
|
20
|
+
* @fastify/compress uses), no Range, a file between 1 KB and `maxFileBytes`.
|
|
21
|
+
* Images, fonts and media stream as before. On while the response cache is
|
|
22
|
+
* on, like the page memo - the admin's cache switch means all of it - and
|
|
23
|
+
* emptied when the cache is cleared or re-initialised. Bounded by bytes, not
|
|
24
|
+
* entries, with the least recently served evicted first.
|
|
25
|
+
*/
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import * as cache from './cache/index.js';
|
|
28
|
+
import {encode, pickEncoding} from './htmlResponse.js';
|
|
29
|
+
|
|
30
|
+
/** @fastify/compress's own rule for what is text enough to compress. */
|
|
31
|
+
const COMPRESSIBLE = /^text\/(?!event-stream)|(?:\+|\/)json(?:;|$)|(?:\+|\/)text(?:;|$)|(?:\+|\/)xml(?:;|$)|javascript(?:;|$)/u;
|
|
32
|
+
|
|
33
|
+
const DEFAULTS = {maxBytes: 64 * 1024 * 1024, maxFileBytes: 2 * 1024 * 1024, minBytes: 1024};
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, {mtimeMs: number, size: number, body: Buffer}>} key = encoding + NUL + path */
|
|
36
|
+
const _entries = new Map();
|
|
37
|
+
/** @type {Map<string, Promise<Buffer>>} */
|
|
38
|
+
const _inflight = new Map();
|
|
39
|
+
let _bytes = 0;
|
|
40
|
+
let _limits = {...DEFAULTS};
|
|
41
|
+
const _stats = {hits: 0, misses: 0, evictions: 0};
|
|
42
|
+
|
|
43
|
+
cache.onInvalidate((reason) => {
|
|
44
|
+
// Tags name pages, collections and blocks, never files; a file's own
|
|
45
|
+
// change is seen through its mtime. A clear, a re-init or the cache going
|
|
46
|
+
// off empties this too.
|
|
47
|
+
if (reason !== 'tags') clearAssetCache();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function clearAssetCache() {
|
|
51
|
+
_entries.clear();
|
|
52
|
+
_inflight.clear();
|
|
53
|
+
_bytes = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assetCacheStats() {
|
|
57
|
+
return {entries: _entries.size, bytes: _bytes, maxBytes: _limits.maxBytes, hits: _stats.hits, misses: _stats.misses, evictions: _stats.evictions};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function _resetAssetCacheForTests() {
|
|
61
|
+
clearAssetCache();
|
|
62
|
+
_stats.hits = 0;
|
|
63
|
+
_stats.misses = 0;
|
|
64
|
+
_stats.evictions = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is this a stream @fastify/static made for a file? @fastify/send opens the
|
|
69
|
+
* file with fs.createReadStream, which carries the path it opened.
|
|
70
|
+
*/
|
|
71
|
+
function fileOf(payload) {
|
|
72
|
+
return payload && typeof payload.pipe === 'function' && typeof payload.path === 'string' ? payload.path : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function bodyFor(file, st, encoding) {
|
|
76
|
+
const key = `${encoding}\0${file}`;
|
|
77
|
+
const kept = _entries.get(key);
|
|
78
|
+
if (kept && kept.mtimeMs === st.mtimeMs && kept.size === st.size) {
|
|
79
|
+
_entries.delete(key);
|
|
80
|
+
_entries.set(key, kept); // most recently served last
|
|
81
|
+
_stats.hits++;
|
|
82
|
+
return kept.body;
|
|
83
|
+
}
|
|
84
|
+
if (_inflight.has(key)) return _inflight.get(key);
|
|
85
|
+
_stats.misses++;
|
|
86
|
+
const making = (async () => {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await fs.readFile(file);
|
|
89
|
+
const body = encoding === 'identity' ? raw : await encode(raw, encoding);
|
|
90
|
+
if (kept) { _bytes -= kept.body.length; _entries.delete(key); }
|
|
91
|
+
_entries.set(key, {mtimeMs: st.mtimeMs, size: st.size, body});
|
|
92
|
+
_bytes += body.length;
|
|
93
|
+
while (_bytes > _limits.maxBytes && _entries.size > 1) {
|
|
94
|
+
const [oldestKey, oldest] = _entries.entries().next().value;
|
|
95
|
+
_entries.delete(oldestKey);
|
|
96
|
+
_bytes -= oldest.body.length;
|
|
97
|
+
_stats.evictions++;
|
|
98
|
+
}
|
|
99
|
+
return body;
|
|
100
|
+
} finally {
|
|
101
|
+
_inflight.delete(key);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
_inflight.set(key, making);
|
|
105
|
+
return making;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add the onSend hook. Call BEFORE registering @fastify/compress, so this
|
|
110
|
+
* runs first and compress sees `content-encoding` already set and steps aside.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('fastify').FastifyInstance} app
|
|
113
|
+
* @param {{maxBytes?: number, maxFileBytes?: number, minBytes?: number}} [limits]
|
|
114
|
+
*/
|
|
115
|
+
export function registerAssetCache(app, limits = {}) {
|
|
116
|
+
_limits = {...DEFAULTS, ...limits};
|
|
117
|
+
app.addHook('onSend', async (request, reply, payload) => {
|
|
118
|
+
if (request.method !== 'GET' || reply.statusCode !== 200 || !cache.isEnabled()) return payload;
|
|
119
|
+
const file = fileOf(payload);
|
|
120
|
+
if (!file || request.headers.range) return payload;
|
|
121
|
+
if (!COMPRESSIBLE.test(String(reply.getHeader('content-type') || ''))) return payload;
|
|
122
|
+
if (reply.getHeader('content-encoding')) return payload; // a precompressed file @fastify/static served itself
|
|
123
|
+
|
|
124
|
+
let st;
|
|
125
|
+
try { st = await fs.stat(file); } catch { return payload; }
|
|
126
|
+
if (!st.isFile() || st.size < _limits.minBytes || st.size > _limits.maxFileBytes) return payload;
|
|
127
|
+
|
|
128
|
+
const encoding = pickEncoding(request.headers['accept-encoding']) || 'identity';
|
|
129
|
+
let body;
|
|
130
|
+
try { body = await bodyFor(file, st, encoding); } catch { return payload; }
|
|
131
|
+
|
|
132
|
+
payload.destroy(); // the stream @fastify/static opened: release its file descriptor
|
|
133
|
+
reply.removeHeader('accept-ranges');
|
|
134
|
+
reply.header('content-length', body.length);
|
|
135
|
+
if (encoding !== 'identity') {
|
|
136
|
+
reply.header('content-encoding', encoding);
|
|
137
|
+
reply.header('vary', 'accept-encoding');
|
|
138
|
+
}
|
|
139
|
+
return body;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -9,6 +9,9 @@ let _lastConfig = null;
|
|
|
9
9
|
/** @type {Map<string, Promise<*>>} */
|
|
10
10
|
const _inflight = new Map();
|
|
11
11
|
|
|
12
|
+
/** Called with a reason whenever anything leaves the cache - see onInvalidate(). */
|
|
13
|
+
const _invalidateListeners = new Set();
|
|
14
|
+
|
|
12
15
|
const _stats = {
|
|
13
16
|
hits: 0,
|
|
14
17
|
misses: 0,
|
|
@@ -28,6 +31,7 @@ const _stats = {
|
|
|
28
31
|
*/
|
|
29
32
|
export async function initCache(config = {}) {
|
|
30
33
|
_lastConfig = {...config};
|
|
34
|
+
_notifyInvalidate('init');
|
|
31
35
|
_stats.initialisedAt = new Date().toISOString();
|
|
32
36
|
if (config.enabled === false) {
|
|
33
37
|
_driver = new NoneDriver();
|
|
@@ -72,6 +76,7 @@ export async function setEnabled(enabled) {
|
|
|
72
76
|
} else {
|
|
73
77
|
_enabled = false;
|
|
74
78
|
_inflight.clear();
|
|
79
|
+
_notifyInvalidate('disabled');
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -79,6 +84,28 @@ export function isEnabled() {
|
|
|
79
84
|
return _enabled;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Hear about every invalidation: a tag flush, a clear, a re-init or the cache
|
|
89
|
+
* being switched off. Anything that keeps its own derived copy of what the
|
|
90
|
+
* response cache holds (content.js's parsed-page memo) drops it here, so it
|
|
91
|
+
* can never be fresher or staler than the cache itself. The listener gets
|
|
92
|
+
* `(reason, tags)` and its errors are swallowed - an invalidation must never
|
|
93
|
+
* fail because an onlooker threw.
|
|
94
|
+
*
|
|
95
|
+
* @param {(reason: 'tags'|'clear'|'init'|'disabled', tags?: string[]) => void} fn
|
|
96
|
+
* @returns {() => void} unsubscribe
|
|
97
|
+
*/
|
|
98
|
+
export function onInvalidate(fn) {
|
|
99
|
+
_invalidateListeners.add(fn);
|
|
100
|
+
return () => _invalidateListeners.delete(fn);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _notifyInvalidate(reason, tags) {
|
|
104
|
+
for (const fn of _invalidateListeners) {
|
|
105
|
+
try { fn(reason, tags); } catch { /* an onlooker's problem, not the cache's */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
export async function get(key) {
|
|
83
110
|
return _driver.get(_vk(key));
|
|
84
111
|
}
|
|
@@ -91,13 +118,17 @@ export async function invalidateTags(tags) {
|
|
|
91
118
|
if (!Array.isArray(tags) || tags.length === 0) return;
|
|
92
119
|
_stats.invalidations++;
|
|
93
120
|
_stats.lastInvalidationAt = new Date().toISOString();
|
|
94
|
-
|
|
121
|
+
const out = await _driver.invalidateTags(tags);
|
|
122
|
+
_notifyInvalidate('tags', tags);
|
|
123
|
+
return out;
|
|
95
124
|
}
|
|
96
125
|
|
|
97
126
|
export async function clear() {
|
|
98
127
|
_inflight.clear();
|
|
99
128
|
_stats.lastClearedAt = new Date().toISOString();
|
|
100
|
-
|
|
129
|
+
const out = await _driver.clear();
|
|
130
|
+
_notifyInvalidate('clear');
|
|
131
|
+
return out;
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
/**
|
|
@@ -151,19 +182,35 @@ export function getStats() {
|
|
|
151
182
|
* @param {{ttlSeconds?: number, tags?: string[]}} [opts]
|
|
152
183
|
*/
|
|
153
184
|
export async function wrap(key, producer, opts = {}) {
|
|
185
|
+
return (await wrapStatus(key, producer, opts)).value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* wrap(), and which way it went: `{value, status}` with status `hit`, `miss`
|
|
190
|
+
* (this call produced it, or waited on the in-flight production) or `off`
|
|
191
|
+
* (the cache is disabled and the producer simply ran). The public route puts
|
|
192
|
+
* the status on the response as `x-dm-cache`, so a slow page can be told
|
|
193
|
+
* apart from a page that is never cached without reading the server's logs.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} key
|
|
196
|
+
* @param {() => Promise<*>} producer
|
|
197
|
+
* @param {{ttlSeconds?: number, tags?: string[]}} [opts]
|
|
198
|
+
* @returns {Promise<{value: *, status: 'hit'|'miss'|'off'}>}
|
|
199
|
+
*/
|
|
200
|
+
export async function wrapStatus(key, producer, opts = {}) {
|
|
154
201
|
if (!_enabled) {
|
|
155
202
|
const raw = await producer();
|
|
156
|
-
return _stripCacheTags(raw);
|
|
203
|
+
return {value: _stripCacheTags(raw), status: 'off'};
|
|
157
204
|
}
|
|
158
205
|
|
|
159
206
|
const vkey = _vk(key);
|
|
160
207
|
const hit = await _driver.get(vkey);
|
|
161
208
|
if (hit !== undefined) {
|
|
162
209
|
_stats.hits++;
|
|
163
|
-
return hit;
|
|
210
|
+
return {value: hit, status: 'hit'};
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
if (_inflight.has(vkey)) return _inflight.get(vkey);
|
|
213
|
+
if (_inflight.has(vkey)) return {value: await _inflight.get(vkey), status: 'miss'};
|
|
167
214
|
|
|
168
215
|
_stats.misses++;
|
|
169
216
|
const promise = (async () => {
|
|
@@ -183,7 +230,7 @@ export async function wrap(key, producer, opts = {}) {
|
|
|
183
230
|
})();
|
|
184
231
|
|
|
185
232
|
_inflight.set(vkey, promise);
|
|
186
|
-
return promise;
|
|
233
|
+
return {value: await promise, status: 'miss'};
|
|
187
234
|
}
|
|
188
235
|
|
|
189
236
|
function _vk(key) {
|
|
@@ -207,6 +254,7 @@ function _stripCacheTags(raw) {
|
|
|
207
254
|
}
|
|
208
255
|
|
|
209
256
|
export function _resetForTests() {
|
|
257
|
+
_notifyInvalidate('init');
|
|
210
258
|
_driver = new NoneDriver();
|
|
211
259
|
_enabled = false;
|
|
212
260
|
_lastConfig = null;
|
|
@@ -16,6 +16,63 @@ const CONTENT_DIR = config.content.contentDir;
|
|
|
16
16
|
const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
|
|
17
17
|
const MEDIA_DIR = config.content.mediaDir;
|
|
18
18
|
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Parsed-page memo
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
//
|
|
23
|
+
// Every public request decides whether the viewer may see the page BEFORE the
|
|
24
|
+
// response cache is consulted, and that decision (resolvePublishedPage) reads
|
|
25
|
+
// the page with getPage(), which parses the whole body - shortcodes,
|
|
26
|
+
// collections, sanitising - to learn `hasExpiry`, `perViewer` and the cache
|
|
27
|
+
// tags. So a response-cache "hit" still cost a full anonymous parse, 3-4 ms on
|
|
28
|
+
// an ordinary page and the whole of the per-request CPU; the cache only ever
|
|
29
|
+
// saved the second parse. This memo keeps the anonymous parse (no user, no
|
|
30
|
+
// expiry ledger) per file, keyed on the file's mtime and size, and is emptied
|
|
31
|
+
// whenever ANYTHING leaves the response cache (cache.onInvalidate), so it is
|
|
32
|
+
// exactly as fresh as the cache: an edit changes the mtime, a collection or
|
|
33
|
+
// block save flushes both. It is on only while the response cache is on - in
|
|
34
|
+
// development every read parses, as before - and a short TTL bounds anything
|
|
35
|
+
// the hooks do not know about. Entries are cloned in and out, so a caller
|
|
36
|
+
// that edits the object it was handed edits its own copy.
|
|
37
|
+
const PAGE_MEMO_MAX = 500;
|
|
38
|
+
const PAGE_MEMO_TTL_MS = 5 * 60 * 1000;
|
|
39
|
+
/** @type {Map<string, {mtimeMs: number, size: number, at: number, page: object}>} */
|
|
40
|
+
const _pageMemo = new Map();
|
|
41
|
+
const _pageMemoStats = {hits: 0, misses: 0};
|
|
42
|
+
cache.onInvalidate(() => _pageMemo.clear());
|
|
43
|
+
|
|
44
|
+
/** For the cache status route and tests: how the memo is doing. */
|
|
45
|
+
export function pageMemoStats() {
|
|
46
|
+
return {size: _pageMemo.size, max: PAGE_MEMO_MAX, hits: _pageMemoStats.hits, misses: _pageMemoStats.misses};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function _resetPageMemoForTests() {
|
|
50
|
+
_pageMemo.clear();
|
|
51
|
+
_pageMemoStats.hits = 0;
|
|
52
|
+
_pageMemoStats.misses = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readPageMemo(filePath) {
|
|
56
|
+
const st = await fs.stat(filePath);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const kept = _pageMemo.get(filePath);
|
|
59
|
+
if (kept && kept.mtimeMs === st.mtimeMs && kept.size === st.size && now - kept.at < PAGE_MEMO_TTL_MS) {
|
|
60
|
+
_pageMemo.delete(filePath);
|
|
61
|
+
_pageMemo.set(filePath, kept); // most recently used last
|
|
62
|
+
_pageMemoStats.hits++;
|
|
63
|
+
return structuredClone(kept.page);
|
|
64
|
+
}
|
|
65
|
+
_pageMemoStats.misses++;
|
|
66
|
+
const page = await readPageFile(filePath);
|
|
67
|
+
try {
|
|
68
|
+
_pageMemo.set(filePath, {mtimeMs: st.mtimeMs, size: st.size, at: now, page: structuredClone(page)});
|
|
69
|
+
} catch {
|
|
70
|
+
return page; // something in it cannot be cloned: hand it over unmemoised
|
|
71
|
+
}
|
|
72
|
+
while (_pageMemo.size > PAGE_MEMO_MAX) _pageMemo.delete(_pageMemo.keys().next().value);
|
|
73
|
+
return page;
|
|
74
|
+
}
|
|
75
|
+
|
|
19
76
|
// ---------------------------------------------------------------------------
|
|
20
77
|
// Pages
|
|
21
78
|
// ---------------------------------------------------------------------------
|
|
@@ -71,6 +128,10 @@ export async function getPageMtime(urlPath) {
|
|
|
71
128
|
export async function getPage(urlPath, opts = {}) {
|
|
72
129
|
const filePath = await resolveExistingFilePath(urlPath);
|
|
73
130
|
try {
|
|
131
|
+
// Only the anonymous, ledger-free read is memoised: a viewer's read
|
|
132
|
+
// differs per role ([menu], row-scoped [view]) and an expiry read
|
|
133
|
+
// writes to the ledger.
|
|
134
|
+
if (!opts.user && !opts.expiry && cache.isEnabled()) return await readPageMemo(filePath);
|
|
74
135
|
return await readPageFile(filePath, {user: opts.user || null, expiry:opts.expiry});
|
|
75
136
|
} catch {
|
|
76
137
|
return null;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending a rendered page: validators, and the compressed body kept per
|
|
3
|
+
* encoding.
|
|
4
|
+
*
|
|
5
|
+
* @fastify/compress compresses every response as it leaves, so a page that
|
|
6
|
+
* came out of the response cache was still brotli'd on every request - after
|
|
7
|
+
* the parsed-page memo that was most of what a cache hit cost (a 40 KB page
|
|
8
|
+
* is ~2 ms of brotli at quality 4, in the thread pool but paid for all the
|
|
9
|
+
* same). Here the compressed body is cached too, addressed by the content:
|
|
10
|
+
* the key is the encoding plus the sha1 of the HTML, the same hash the ETag
|
|
11
|
+
* is made from. Content-addressed means it can never be stale - a different
|
|
12
|
+
* page is a different hash - so it needs no tags and no invalidation, and two
|
|
13
|
+
* cache keys that render the same HTML (two base URLs, say) share one body.
|
|
14
|
+
* Its memory is bounded by the cache's own LRU and TTL.
|
|
15
|
+
*
|
|
16
|
+
* Only a response that came from the response cache asks for it (`cached`):
|
|
17
|
+
* a 404 carries the requested path, a per-viewer page is one visitor's, and
|
|
18
|
+
* caching those would fill the cache with bodies nobody asks for twice.
|
|
19
|
+
* Everything else goes out as HTML and @fastify/compress does what it did.
|
|
20
|
+
*
|
|
21
|
+
* The negotiation and the parameters match @fastify/compress (br before
|
|
22
|
+
* gzip, brotli quality 4, zlib's default gzip level, the 1 KB threshold), so
|
|
23
|
+
* a visitor gets the same bytes either way; compress sees `content-encoding`
|
|
24
|
+
* already set and steps aside.
|
|
25
|
+
*/
|
|
26
|
+
import {createHash} from 'node:crypto';
|
|
27
|
+
import zlib from 'node:zlib';
|
|
28
|
+
import {promisify} from 'node:util';
|
|
29
|
+
import * as cache from './cache/index.js';
|
|
30
|
+
|
|
31
|
+
const brotli = promisify(zlib.brotliCompress);
|
|
32
|
+
const gzip = promisify(zlib.gzip);
|
|
33
|
+
|
|
34
|
+
/** Responses under this many bytes are not worth compressing - @fastify/compress's threshold. */
|
|
35
|
+
export const COMPRESS_THRESHOLD = 1024;
|
|
36
|
+
/** Offered in order of preference: the tie-break when the client rates them equally. */
|
|
37
|
+
export const ENCODINGS = ['br', 'gzip'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which of our encodings the client prefers, or null for none (no header, or
|
|
41
|
+
* nothing acceptable). `*` and `x-gzip` count as gzip, as @fastify/compress
|
|
42
|
+
* reads them; a `q=0` rules an encoding out.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|undefined} header - the Accept-Encoding value
|
|
45
|
+
* @param {string[]} [offered]
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
export function pickEncoding(header, offered = ENCODINGS) {
|
|
49
|
+
if (!header) return null;
|
|
50
|
+
const q = new Map();
|
|
51
|
+
for (const part of String(header).toLowerCase().split(',')) {
|
|
52
|
+
const [rawName, ...params] = part.trim().split(';');
|
|
53
|
+
let name = rawName.trim();
|
|
54
|
+
if (!name) continue;
|
|
55
|
+
if (name === '*' || name === 'x-gzip') name = 'gzip';
|
|
56
|
+
let weight = 1;
|
|
57
|
+
for (const p of params) {
|
|
58
|
+
const m = p.trim().match(/^q=([0-9.]+)$/);
|
|
59
|
+
if (m) weight = Number(m[1]);
|
|
60
|
+
}
|
|
61
|
+
if (!q.has(name) || q.get(name) < weight) q.set(name, weight);
|
|
62
|
+
}
|
|
63
|
+
let best = null;
|
|
64
|
+
let bestQ = 0;
|
|
65
|
+
for (const enc of offered) {
|
|
66
|
+
const weight = q.get(enc) ?? 0;
|
|
67
|
+
if (weight > bestQ) { best = enc; bestQ = weight; }
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The compressed body, the way @fastify/compress would have made it.
|
|
74
|
+
*
|
|
75
|
+
* @param {string|Buffer} input
|
|
76
|
+
* @param {string} encoding - 'br' | 'gzip'
|
|
77
|
+
* @returns {Promise<Buffer>}
|
|
78
|
+
*/
|
|
79
|
+
export function encode(input, encoding) {
|
|
80
|
+
if (!Buffer.isBuffer(input)) input = Buffer.from(String(input), 'utf8');
|
|
81
|
+
if (encoding === 'br') {
|
|
82
|
+
return brotli(input, {params: {
|
|
83
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
84
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: input.length,
|
|
85
|
+
}});
|
|
86
|
+
}
|
|
87
|
+
if (encoding === 'gzip') return gzip(input);
|
|
88
|
+
throw new Error(`Unknown encoding: ${encoding}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** encode() for a page's HTML. */
|
|
92
|
+
export const encodeHtml = encode;
|
|
93
|
+
|
|
94
|
+
/** The validator's content part: the same hash whatever the encoding. */
|
|
95
|
+
export function htmlHash(html) {
|
|
96
|
+
return createHash('sha1').update(html).digest('base64');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Send a rendered page: cache-control, an ETag, a 304 when the browser
|
|
101
|
+
* already has it, and - for a response that came from the response cache -
|
|
102
|
+
* the compressed body from the cache, made once.
|
|
103
|
+
*
|
|
104
|
+
* The ETag is the content hash, suffixed with the encoding when the body is
|
|
105
|
+
* compressed (`"<hash>-br"`): a strong validator names a representation, and
|
|
106
|
+
* the bytes differ per encoding. A browser's If-None-Match is matched on the
|
|
107
|
+
* hash whatever suffix it carries, so a visitor who first got gzip and now
|
|
108
|
+
* accepts brotli still gets their 304.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately no reply.code() on the success path: the 404 handlers call
|
|
111
|
+
* reply.status(404) before sending, and forcing 200 would turn every
|
|
112
|
+
* not-found into a soft 404 that search engines index as a real page.
|
|
113
|
+
*
|
|
114
|
+
* @param {import('fastify').FastifyRequest} request
|
|
115
|
+
* @param {import('fastify').FastifyReply} reply
|
|
116
|
+
* @param {string} html
|
|
117
|
+
* @param {{cached?: boolean}} [opts] - cached: the HTML came from the response cache, so its
|
|
118
|
+
* compressed form may be kept too
|
|
119
|
+
*/
|
|
120
|
+
export async function sendHtml(request, reply, html, {cached = false} = {}) {
|
|
121
|
+
const hash = htmlHash(html);
|
|
122
|
+
const encoding = cached && Buffer.byteLength(html) >= COMPRESS_THRESHOLD
|
|
123
|
+
? pickEncoding(request.headers['accept-encoding'])
|
|
124
|
+
: null;
|
|
125
|
+
const etag = `"${hash}${encoding ? `-${encoding}` : ''}"`;
|
|
126
|
+
reply.header('cache-control', 'private, no-cache');
|
|
127
|
+
reply.header('etag', etag);
|
|
128
|
+
reply.header('vary', 'accept-encoding');
|
|
129
|
+
|
|
130
|
+
// If-None-Match may carry a list, and a weak validator's W/ prefix.
|
|
131
|
+
const seen = String(request.headers['if-none-match'] || '')
|
|
132
|
+
.split(',')
|
|
133
|
+
.map((t) => t.trim().replace(/^W\//, ''));
|
|
134
|
+
if (seen.some((t) => t === `"${hash}"` || t.startsWith(`"${hash}-`))) return reply.code(304).send();
|
|
135
|
+
|
|
136
|
+
if (!encoding) return reply.type('text/html; charset=utf-8').send(html);
|
|
137
|
+
|
|
138
|
+
const body = await cache.wrap(`enc:${encoding}:${hash}`, () => encodeHtml(html, encoding), {tags: ['encoded']});
|
|
139
|
+
reply.header('content-encoding', encoding);
|
|
140
|
+
return reply.type('text/html; charset=utf-8').send(body);
|
|
141
|
+
}
|
|
@@ -687,6 +687,10 @@ function buildSeoMeta({page, site, baseUrl, title, description, image}) {
|
|
|
687
687
|
});
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// The page's own structured data (frontmatter `seo.schema` - the editor's
|
|
691
|
+
// SEO tab): one object or a list, each emitted as its own block.
|
|
692
|
+
jsonLd.push(...schemaBlocks(page.seo?.schema));
|
|
693
|
+
|
|
690
694
|
return {
|
|
691
695
|
title,
|
|
692
696
|
description,
|
|
@@ -703,6 +707,32 @@ function buildSeoMeta({page, site, baseUrl, title, description, image}) {
|
|
|
703
707
|
};
|
|
704
708
|
}
|
|
705
709
|
|
|
710
|
+
/**
|
|
711
|
+
* The JSON-LD blocks a page declares in `seo.schema`: an object, a list of
|
|
712
|
+
* them, or a JSON string of either (a hand-written frontmatter may hold it as
|
|
713
|
+
* text). Anything that is not a plain object with a string `@type` is dropped
|
|
714
|
+
* rather than emitted broken, and `@context` defaults to schema.org, which is
|
|
715
|
+
* what every example and validator expects. Pure.
|
|
716
|
+
*
|
|
717
|
+
* @param {unknown} value
|
|
718
|
+
* @returns {object[]}
|
|
719
|
+
*/
|
|
720
|
+
export function schemaBlocks(value) {
|
|
721
|
+
let v = value;
|
|
722
|
+
if (typeof v === 'string') {
|
|
723
|
+
if (!v.trim()) return [];
|
|
724
|
+
try { v = JSON.parse(v); } catch { return []; }
|
|
725
|
+
}
|
|
726
|
+
const list = Array.isArray(v) ? v : [v];
|
|
727
|
+
const out = [];
|
|
728
|
+
for (const item of list) {
|
|
729
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
730
|
+
if (typeof item['@type'] !== 'string' || !item['@type'].trim()) continue;
|
|
731
|
+
out.push(item['@context'] ? item : {'@context': 'https://schema.org', ...item});
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
706
736
|
/** An absolute URL for og:image and JSON-LD: crawlers ignore relative ones. */
|
|
707
737
|
function absoluteUrl(url, origin) {
|
|
708
738
|
const u = String(url || '').trim();
|