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.
@@ -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
- * Escape user-controlled strings before interpolating into HTML.
45
- * Prevents reflected XSS in error pages.
46
- *
47
- * @param {string} str
48
- * @returns {string}
49
- */
50
- function escapeHtml(str) {
51
- return String(str)
52
- .replace(/&/g, '&')
53
- .replace(/</g, '&lt;')
54
- .replace(/>/g, '&gt;')
55
- .replace(/"/g, '&quot;')
56
- .replace(/'/g, '&#39;');
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
- const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
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
- return sendHtml(request, reply, html);
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
- return _driver.invalidateTags(tags);
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
- return _driver.clear();
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();