domma-cms 1.8.25 → 1.8.27

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.27] - 2026-10-19
4
+
5
+ - **Pages come out of the cache about twice as fast.** Every public request decided whether you may see the page before it looked in the response cache, and that decision parsed the whole page - shortcodes, collections and all - so a cached page still cost a full parse on every visit. The parsed page is now kept per file (keyed on the file's time and size, emptied whenever anything leaves the response cache, and only while the cache is on), so a cached page costs a file stat and a lookup. On a 40 KB home page: 230 to 540 pages a second, the typical response from 41 ms to 18 ms under load.
6
+ - **A cached page is compressed once, not on every visit.** The response cache held the page as HTML and brotli ran on it for every visitor; the compressed body is now kept as well, per encoding, addressed by the page's own content hash (so it can never be stale and needs no invalidation). After the change above this was most of what a cached page still cost. The ETag now names the encoding (`"<hash>-br"`); a browser holding the other one's validator still gets its 304.
7
+ - **`x-dm-cache` on every page response** says which way it went: `hit`, `miss`, `off` (the cache is disabled) or `bypass` (a draft, an expiring page or a per-viewer page, never cached). `GET /api/cache/status` gains `pageMemo` (size, hits, misses).
8
+
9
+ ## [1.8.26] - 2026-10-18
10
+
11
+ - **Updates clear out their old backups.** Every `npx domma-cms update` copies the engine and plugins into `.domma-backups/` first, and nothing ever removed those copies - some sites held 250+ of up to 490 MB each. After a successful update it now keeps the newest 7, plus one about a week, a month, three, six and twelve months old (at most 16), and nothing older than a year. Backups you put in that folder yourself, under any other name, are never touched.
12
+
3
13
  ## [1.8.25] - 2026-10-18
4
14
 
5
15
  - **Tutorial: The Editor.** A walk through a working session in the editor - the list, the tabs, coloured text, the live preview, saving and drafts, and the settings (font, wrapping, smooth typing, the admin menu folding away). Under Documentation › Tutorials, first in the list on every site.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Update backups, for the engine updater.
3
+ *
4
+ * Every update copies the engine's code, structural configs and plugins into
5
+ * .domma-backups/<timestamp>/ (about 500 MB on a site with plugins) and nothing
6
+ * ever removed them: a site that rolled a few times a day had 250+ of them. The
7
+ * updater now keeps the newest few plus one backup per age band - about a week,
8
+ * a month, three months, six months and a year old - and nothing older than a
9
+ * year. Only the names the updater writes are counted: anything else in the
10
+ * folder was put there by hand and is left alone.
11
+ *
12
+ * Each band keeps its OLDEST backup - that is what lets one move up: it ages out
13
+ * of its band into the next. Past the first week a band also keeps its NEWEST,
14
+ * the next in line, so when the oldest moves on the band is never left empty
15
+ * (with only the oldest kept, the year band stood empty 44% of the time at three
16
+ * updates a day). So at most 7 + 1 + 2 x 4 = 16 are kept.
17
+ *
18
+ * @module bin/lib/backup-retention
19
+ */
20
+
21
+ import {existsSync, readdirSync, rmSync} from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ /** How many of the newest update backups a site keeps outright. */
25
+ export const KEEP_UPDATE_BACKUPS = 7;
26
+
27
+ const DAY = 24 * 60 * 60 * 1000;
28
+
29
+ /** Age bands, in days: [from, to). The first starts at 0 so a backup can survive long enough to reach a week. */
30
+ export const BACKUP_BANDS = [[0, 7], [7, 30], [30, 90], [90, 180], [180, 365]];
31
+
32
+ /** `new Date().toISOString()` with `:` and `.` made `-` - the updater's own names. */
33
+ const UPDATE_BACKUP_NAME = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/;
34
+
35
+ /** When an update backup was taken, from its name; null for a name the updater did not write. */
36
+ export function backupTime(name) {
37
+ const m = UPDATE_BACKUP_NAME.exec(name);
38
+ if (!m) return null;
39
+ const t = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
40
+ return Number.isNaN(t) ? null : t;
41
+ }
42
+
43
+ /** The update backups to remove from a listing, oldest first. Pure: `now` is a timestamp in ms. */
44
+ export function backupsToPrune(names, now = Date.now(), keep = KEEP_UPDATE_BACKUPS) {
45
+ const ours = names
46
+ .map(name => ({name, at: backupTime(name)}))
47
+ .filter(b => b.at !== null)
48
+ .sort((a, b) => a.at - b.at);
49
+ const kept = new Set(ours.slice(-keep).map(b => b.name));
50
+ BACKUP_BANDS.forEach(([from, to], i) => {
51
+ const inBand = ours.filter(b => now - b.at >= from * DAY && now - b.at < to * DAY);
52
+ if (!inBand.length) return;
53
+ kept.add(inBand[0].name);
54
+ if (i > 0) kept.add(inBand.at(-1).name);
55
+ });
56
+ return ours.filter(b => !kept.has(b.name)).map(b => b.name);
57
+ }
58
+
59
+ /** Apply the retention rule to `dir`. Returns the names removed. */
60
+ export function pruneUpdateBackups(dir, now = Date.now()) {
61
+ if (!existsSync(dir)) return [];
62
+ const names = readdirSync(dir, {withFileTypes: true}).filter(e => e.isDirectory()).map(e => e.name);
63
+ const doomed = backupsToPrune(names, now);
64
+ for (const name of doomed) rmSync(path.join(dir, name), {recursive: true, force: true});
65
+ return doomed;
66
+ }
package/bin/update.js CHANGED
@@ -15,6 +15,7 @@ import {createInterface} from 'node:readline';
15
15
  import {fileURLToPath} from 'node:url';
16
16
  import {deepMergeNewKeys} from './lib/config-merge.js';
17
17
  import {shouldReplacePlugin} from './lib/plugin-version.js';
18
+ import {KEEP_UPDATE_BACKUPS, pruneUpdateBackups} from './lib/backup-retention.js';
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Paths
@@ -567,6 +568,13 @@ export default async function update(_positional, flags) {
567
568
  console.log('');
568
569
  }
569
570
 
571
+ // -------------------------------------------------------------------------
572
+ // 13b. Thin the update backups (bin/lib/backup-retention.js) - done last, so a failed update
573
+ // never costs a backup, and only when this run took one.
574
+ // -------------------------------------------------------------------------
575
+
576
+ const prunedBackups = backupPath ? pruneUpdateBackups(path.join(cwd, '.domma-backups')) : [];
577
+
570
578
  // -------------------------------------------------------------------------
571
579
  // 14. Summary
572
580
  // -------------------------------------------------------------------------
@@ -581,6 +589,9 @@ export default async function update(_positional, flags) {
581
589
 
582
590
  if (backupPath) {
583
591
  info(`Backup saved to: .domma-backups/${path.basename(backupPath)}/`);
592
+ if (prunedBackups.length) {
593
+ info(`Removed ${prunedBackups.length} older update backup(s): the newest ${KEEP_UPDATE_BACKUPS} are kept, plus one about a week, a month, 3, 6 and 12 months old.`);
594
+ }
584
595
  }
585
596
 
586
597
  if (newPlugins.length > 0) {
@@ -1280,9 +1280,10 @@ The four Tools below were plugins until 0.67.0 and are now part of the CMS. They
1280
1280
  - Registers new plugins, disabled except the protective ones such as Security.
1281
1281
  - Adds only new keys to `server.json`, `auth.json`, `content.json` and `presets.json`.
1282
1282
  - Swaps the CMS dependencies but keeps dependencies you added, self-heals `.gitignore`, updates the marker and runs `npm install`.
1283
+ - Thins `.domma-backups/` after a successful update: the newest 7 update backups, plus the oldest and newest backup aged 7-30 days, 30-90 days, 90-180 days and 180-365 days (and the oldest under a week), so one about a week, a month, three, six and twelve months old is always there - at most 16. Nothing older than a year is kept, and backups not named by the updater are never touched (unreleased).
1283
1284
 
1284
1285
  Options: `--yes`, `--no-backup`, `--no-install`, `--dry-run` (preview only), `--force` (reinstall the same version).
1285
- Where: `bin/update.js`, `bin/lib/config-merge.js`, `bin/lib/plugin-version.js` - Since: 0.6.5.
1286
+ Where: `bin/update.js`, `bin/lib/config-merge.js`, `bin/lib/plugin-version.js`, `bin/lib/backup-retention.js` - Since: 0.6.5.
1286
1287
  - **`npm run setup`** - The terminal setup wizard:
1287
1288
  1. Generates `JWT_SECRET` and `INSTANCE_SECRET`.
1288
1289
  2. Creates the first admin if there are no users (password of at least 8 characters).
package/docs/cache.md CHANGED
@@ -73,6 +73,27 @@ All endpoints require an admin Bearer token. The dashboard has a cache widget sh
73
73
 
74
74
  When you toggle the cache **off**, the in-memory data is preserved - toggle back on and your warm entries are still there. When you turn it **off in `config/cache.json`** and restart, the next boot starts with a `NoneDriver` and no data.
75
75
 
76
+ ## Seeing whether a page was cached
77
+
78
+ Every page response carries `x-dm-cache`:
79
+
80
+ | Value | Meaning |
81
+ |---|---|
82
+ | `hit` | served from the response cache |
83
+ | `miss` | rendered on this request and stored |
84
+ | `off` | the cache is disabled; rendered |
85
+ | `bypass` | never cached: a draft preview, a page with expiring shortcodes, or a per-viewer page for a signed-in visitor |
86
+
87
+ Open the browser's network panel, or `curl -sI https://example.com/ | grep x-dm-cache`.
88
+
89
+ ## The parsed-page memo
90
+
91
+ Before the response cache is asked, the route decides whether the viewer may see the page at all (`resolvePublishedPage()`), and that decision reads the page with `getPage()`, which parses the body to learn whether it expires, renders per viewer, and which collections and blocks it uses. Without help a cache hit still paid for that parse. `content.js` keeps the anonymous parse per file, keyed on the file's mtime and size, emptied through `cache.onInvalidate()` whenever anything leaves the response cache, and only while the cache is enabled. A viewer's read (`{user}`) or an expiry read is never memoised. `GET /api/cache/status` reports it as `pageMemo`.
92
+
93
+ ## The compressed body
94
+
95
+ `services/htmlResponse.js` `sendHtml()`: a page that came from the response cache (`{cached: true}` - the page route and the docs route) is also kept compressed, one entry per encoding under `enc:<br|gzip>:<sha1 of the HTML>`. Content-addressed, so never stale and never invalidated; bounded by the cache's LRU and TTL. The negotiation and parameters match @fastify/compress (brotli quality 4), which steps aside when `content-encoding` is already set. A 404, a draft or a per-viewer page is not kept and goes out through @fastify/compress as before.
96
+
76
97
  ## Thundering-herd safety
77
98
 
78
99
  If three concurrent requests hit a cold key, the producer runs exactly once - the other two await the first's result via an in-flight promise map. This matters for popular pages on cache cold-start: without coalescing, N parallel requests trigger N parallel renders, each writing the same value back to the cache.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.25",
3
+ "version": "1.8.27",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -11,6 +11,7 @@ import fs from 'fs/promises';
11
11
  import path from 'path';
12
12
  import {fileURLToPath} from 'url';
13
13
  import * as cache from '../../services/cache/index.js';
14
+ import {pageMemoStats} from '../../services/content.js';
14
15
  import {authenticate, requireAdmin} from '../../middleware/auth.js';
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -37,7 +38,7 @@ async function writeCacheConfig(cfg) {
37
38
  export async function cacheRoutes(fastify) {
38
39
  const adminOnly = {preHandler: [authenticate, requireAdmin]};
39
40
 
40
- fastify.get('/cache/status', adminOnly, async () => cache.getStats());
41
+ fastify.get('/cache/status', adminOnly, async () => ({...cache.getStats(), pageMemo: pageMemoStats()}));
41
42
 
42
43
  fastify.get('/cache/keys', adminOnly, async () => ({entries: cache.listEntries()}));
43
44
 
@@ -41,7 +41,7 @@ export async function docsPublicRoutes(fastify) {
41
41
  return reply.type('text/html; charset=utf-8').send(await render404(`/domma-docs${docPath ? '/' + docPath : ''}`));
42
42
  }
43
43
  // private + ETag: the response is role-dependent, so no shared proxy may reuse it.
44
- return sendHtml(request, reply, html);
44
+ return sendHtml(request, reply, html, {cached: true}); // renderDocPage() caches per role
45
45
  };
46
46
 
47
47
  fastify.get('/domma-docs', handle); // exact - params['*'] === '' (home landing)
@@ -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, '&amp;')
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
 
@@ -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,138 @@
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} html
76
+ * @param {string} encoding - 'br' | 'gzip'
77
+ * @returns {Promise<Buffer>}
78
+ */
79
+ export function encodeHtml(html, encoding) {
80
+ const input = Buffer.from(html, '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
+ /** The validator's content part: the same hash whatever the encoding. */
92
+ export function htmlHash(html) {
93
+ return createHash('sha1').update(html).digest('base64');
94
+ }
95
+
96
+ /**
97
+ * Send a rendered page: cache-control, an ETag, a 304 when the browser
98
+ * already has it, and - for a response that came from the response cache -
99
+ * the compressed body from the cache, made once.
100
+ *
101
+ * The ETag is the content hash, suffixed with the encoding when the body is
102
+ * compressed (`"<hash>-br"`): a strong validator names a representation, and
103
+ * the bytes differ per encoding. A browser's If-None-Match is matched on the
104
+ * hash whatever suffix it carries, so a visitor who first got gzip and now
105
+ * accepts brotli still gets their 304.
106
+ *
107
+ * Deliberately no reply.code() on the success path: the 404 handlers call
108
+ * reply.status(404) before sending, and forcing 200 would turn every
109
+ * not-found into a soft 404 that search engines index as a real page.
110
+ *
111
+ * @param {import('fastify').FastifyRequest} request
112
+ * @param {import('fastify').FastifyReply} reply
113
+ * @param {string} html
114
+ * @param {{cached?: boolean}} [opts] - cached: the HTML came from the response cache, so its
115
+ * compressed form may be kept too
116
+ */
117
+ export async function sendHtml(request, reply, html, {cached = false} = {}) {
118
+ const hash = htmlHash(html);
119
+ const encoding = cached && Buffer.byteLength(html) >= COMPRESS_THRESHOLD
120
+ ? pickEncoding(request.headers['accept-encoding'])
121
+ : null;
122
+ const etag = `"${hash}${encoding ? `-${encoding}` : ''}"`;
123
+ reply.header('cache-control', 'private, no-cache');
124
+ reply.header('etag', etag);
125
+ reply.header('vary', 'accept-encoding');
126
+
127
+ // If-None-Match may carry a list, and a weak validator's W/ prefix.
128
+ const seen = String(request.headers['if-none-match'] || '')
129
+ .split(',')
130
+ .map((t) => t.trim().replace(/^W\//, ''));
131
+ if (seen.some((t) => t === `"${hash}"` || t.startsWith(`"${hash}-`))) return reply.code(304).send();
132
+
133
+ if (!encoding) return reply.type('text/html; charset=utf-8').send(html);
134
+
135
+ const body = await cache.wrap(`enc:${encoding}:${hash}`, () => encodeHtml(html, encoding), {tags: ['encoded']});
136
+ reply.header('content-encoding', encoding);
137
+ return reply.type('text/html; charset=utf-8').send(body);
138
+ }