@webjsdev/server 0.8.10 → 0.8.12

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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Server HTML response cache with TTL and on-demand revalidation: the
3
+ * no-build equivalent of Next.js's Full Route Cache + ISR.
4
+ *
5
+ * A fully-static / inert route re-runs the entire SSR pipeline (layout
6
+ * chain, renderToString, metadata merge, importmap splice) on every
7
+ * request even though it proves identical HTML each time. This module
8
+ * caches the rendered HTML in the existing pluggable store
9
+ * (`getStore()` / `memoryStore` in dev, `redisStore` when configured)
10
+ * under a namespaced key, and serves it WITHOUT re-running the page
11
+ * function on a hit within the revalidation window.
12
+ *
13
+ * SAFETY: caching is OPT-IN and conservative. A wrongly-cached per-user
14
+ * page served to the wrong visitor is a data leak, so the page author
15
+ * MUST opt in by declaring a revalidation window, and the framework
16
+ * applies several defense-in-depth guards before it stores anything.
17
+ *
18
+ * The opt-in trigger is `export const revalidate = N` (seconds) on the
19
+ * page module (the Next idiom, read once cheaply before the cache lookup).
20
+ * The contract: declaring `revalidate` is the author asserting "this page
21
+ * is the same for everyone for N seconds". A page that reads `cookies()` /
22
+ * a session (per-user output) MUST NOT set `revalidate`.
23
+ *
24
+ * @module html-cache
25
+ */
26
+
27
+ import { getStore } from './cache.js';
28
+ import { STREAM_MARKER } from './conditional-get.js';
29
+ import { publishedBuildId } from './importmap.js';
30
+ import { dynamicAccessed } from './context.js';
31
+
32
+ /** Namespace prefix for every cached-HTML key, so a flush can target it. */
33
+ const KEY_PREFIX = 'webjs:html:';
34
+
35
+ /**
36
+ * Internal response header `ssrPage` stamps on a render that opted into the
37
+ * HTML cache (its value is the revalidate TTL in seconds). The response
38
+ * funnel reads it, re-checks the guards against the FINAL response (after
39
+ * segment middleware, which may have appended a per-user Set-Cookie the SSR
40
+ * side could not see), writes the cache, and strips the marker so it never
41
+ * reaches the client. Mirrors the BUFFERED / STREAM marker pattern.
42
+ */
43
+ export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
44
+
45
+ /**
46
+ * Generation counter folded into every key namespace. `revalidateAll()`
47
+ * bumps it so every previously-cached HTML entry becomes unreachable in one
48
+ * step (the CacheStore interface has no key-scan primitive, so a global
49
+ * clear cannot enumerate keys), and the store TTL eventually reclaims the
50
+ * orphaned entries. A single process clears its own memory store this way
51
+ * synchronously; a Redis-backed multi-process deploy bumps per process and
52
+ * leans on the TTL for the rest (a best-effort global flush).
53
+ */
54
+ let _generation = 0;
55
+
56
+ /**
57
+ * Read the revalidation window (seconds) a page module opted into via
58
+ * `export const revalidate = N` (the Next idiom). Returns a positive finite
59
+ * number of seconds, or `null` when the page did not opt in (the default:
60
+ * no server HTML caching, current behavior).
61
+ *
62
+ * `revalidate = 0`, a negative, NaN, Infinity, or a non-number is treated
63
+ * as "no caching" (opt-out), matching the Next semantics where 0 means
64
+ * always-dynamic. The trigger is the page-module export ONLY (read once,
65
+ * cheaply, before the cache lookup), so a per-user page that never declares
66
+ * it is never cached.
67
+ *
68
+ * @param {Record<string, any> | null | undefined} pageModule
69
+ * @returns {number | null}
70
+ */
71
+ export function readRevalidate(pageModule) {
72
+ const raw = pageModule ? pageModule.revalidate : undefined;
73
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null;
74
+ return raw;
75
+ }
76
+
77
+ /**
78
+ * The cache key for a request: the FULL URL (path + search string), since
79
+ * `searchParams` change page output. Normalized to path + sorted query so
80
+ * `?a=1&b=2` and `?b=2&a=1` share an entry. Two more discriminators are
81
+ * folded into the namespace:
82
+ *
83
+ * - the in-process generation, so `revalidateAll()` (a generation bump)
84
+ * makes every prior key unreachable in one step;
85
+ * - the published build id (the importmap fingerprint), so a NEW DEPLOY
86
+ * naturally writes and reads under fresh keys. The cached HTML bakes the
87
+ * deploy's `data-webjs-build` importmap into its boot script, so a Redis
88
+ * store that survives a deploy must NOT let a v2 process serve a v1-body
89
+ * (resolving modules against stale vendor URLs). Folding the build id in
90
+ * means a deploy effectively invalidates all cached HTML for free.
91
+ *
92
+ * @param {URL} url
93
+ * @returns {string}
94
+ */
95
+ export function htmlCacheKey(url) {
96
+ const params = [...url.searchParams.entries()].sort(([a], [b]) =>
97
+ a < b ? -1 : a > b ? 1 : 0
98
+ );
99
+ const search = params.length
100
+ ? '?' + params.map(([k, v]) => `${k}=${v}`).join('&')
101
+ : '';
102
+ const build = publishedBuildId() || 'nobuild';
103
+ return `${KEY_PREFIX}${build}:${_generation}:${url.pathname}${search}`;
104
+ }
105
+
106
+ /**
107
+ * Read a cached HTML entry for a URL. Returns the parsed record (body +
108
+ * the headers needed to faithfully rebuild the response) or null on a
109
+ * miss / expiry / parse error (fail open to a fresh render).
110
+ *
111
+ * @param {URL} url
112
+ * @returns {Promise<{ body: string, contentType: string, cacheControl: string, status: number } | null>}
113
+ */
114
+ export async function readHtmlCache(url) {
115
+ try {
116
+ const raw = await getStore().get(htmlCacheKey(url));
117
+ if (!raw) return null;
118
+ const rec = JSON.parse(raw);
119
+ if (!rec || typeof rec.body !== 'string') return null;
120
+ return rec;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Store a rendered HTML response for a URL with TTL = revalidate seconds.
128
+ * Best-effort: a store error never affects the live response.
129
+ *
130
+ * @param {URL} url
131
+ * @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
132
+ * @param {number} revalidateSeconds
133
+ */
134
+ export async function writeHtmlCache(url, rec, revalidateSeconds) {
135
+ try {
136
+ await getStore().set(htmlCacheKey(url), JSON.stringify(rec), revalidateSeconds * 1000);
137
+ } catch {
138
+ /* a store write failure must never crash the response */
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Decide whether a freshly-rendered Response is SAFE to cache. This is the
144
+ * defense-in-depth gate that runs AFTER the page opted in via `revalidate`.
145
+ * Returns true only when every guard passes:
146
+ *
147
+ * - status 200 (an error / redirect / 404 is request-specific)
148
+ * - NOT a streamed Suspense body (it cannot be buffered cheaply, and an
149
+ * unflushed stream has no stable bytes to cache)
150
+ * - NO non-framework Set-Cookie. A page that sets a session / per-user
151
+ * cookie is per-user output and must not be shared. The framework's own
152
+ * CSRF cookie (`webjs_csrf`) is allowed and re-minted per response on a
153
+ * cache hit, so its presence does not block caching.
154
+ * - CSP is OFF. With CSP enabled the inline boot script carries a fresh
155
+ * per-request nonce, so the body varies per request and a cached body
156
+ * would replay a stale nonce that the response's CSP header rejects.
157
+ *
158
+ * @param {Response} res
159
+ * @param {{ cspEnabled?: boolean }} [guards]
160
+ * @returns {boolean}
161
+ */
162
+ export function isCacheableResponse(res, guards = {}) {
163
+ if (res.status !== 200) return false;
164
+ if (res.headers.has(STREAM_MARKER)) return false;
165
+ if (guards.cspEnabled) return false;
166
+ if (hasNonFrameworkSetCookie(res)) return false;
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * True when the response carries a Set-Cookie OTHER than the framework's
172
+ * own CSRF cookie. Reads each cookie individually via `getSetCookie()`, the
173
+ * only correct way to enumerate multiple Set-Cookie values (a combined
174
+ * `get('set-cookie')` cannot be split safely, since a cookie value or an
175
+ * Expires date can contain a comma). When `getSetCookie` is unavailable
176
+ * (a runtime older than Node 24) this FAILS SAFE: it reports a
177
+ * non-framework cookie (do not cache) rather than parsing only the first of
178
+ * a combined header and wrongly judging it framework-only.
179
+ *
180
+ * @param {Response} res
181
+ * @returns {boolean}
182
+ */
183
+ function hasNonFrameworkSetCookie(res) {
184
+ const h = res.headers;
185
+ if (typeof h.getSetCookie !== 'function') {
186
+ // No reliable per-cookie enumeration: fail safe (treat as per-user).
187
+ return h.has('set-cookie');
188
+ }
189
+ for (const c of h.getSetCookie()) {
190
+ const name = c.split('=', 1)[0].trim().toLowerCase();
191
+ if (name !== 'webjs_csrf') return true;
192
+ }
193
+ return false;
194
+ }
195
+
196
+ /**
197
+ * Evict the cached HTML for one path (server-side, on-demand
198
+ * revalidation: the no-build ISR revalidation hook). A server action that
199
+ * mutates the data a cached page renders calls this so the next request
200
+ * re-renders. Distinct from the client-side `revalidate()` (which evicts
201
+ * the browser snapshot cache).
202
+ *
203
+ * The path may include a search string. A bare path with no query evicts
204
+ * ONLY the no-query entry; pass the exact `path?query` to target a
205
+ * specific query variant, or call `revalidateAll()` to clear everything.
206
+ *
207
+ * @param {string} path e.g. '/blog' or '/blog?page=2'
208
+ * @returns {Promise<void>}
209
+ */
210
+ export async function revalidatePath(path) {
211
+ if (typeof path !== 'string' || !path) return;
212
+ // Build the same normalized key readHtmlCache / writeHtmlCache produce.
213
+ let url;
214
+ try {
215
+ url = new URL(path, 'http://internal.invalid');
216
+ } catch {
217
+ return;
218
+ }
219
+ try {
220
+ await getStore().delete(htmlCacheKey(url));
221
+ } catch {
222
+ /* a store delete failure is non-fatal: the TTL still expires the entry */
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
228
+ * (the SSR opted it into caching), re-check every guard against the FINAL
229
+ * response and, when it passes, buffer the body and store it under the URL
230
+ * key with TTL = the marked revalidate seconds. Always strips the marker so
231
+ * it never reaches the client. Returns the same response (the marker removal
232
+ * mutates its headers in place). Best-effort: any failure leaves the live
233
+ * response untouched. The CSP guard was already applied on the SSR side (the
234
+ * marker is only stamped when CSP is off), so it is not re-checked here.
235
+ *
236
+ * @param {Request} req
237
+ * @param {Response} res
238
+ * @param {URL} url
239
+ * @returns {Promise<Response>}
240
+ */
241
+ export async function commitHtmlCache(req, res, url) {
242
+ const marker = res.headers.get(HTML_CACHE_MARKER);
243
+ if (!marker) return res;
244
+ res.headers.delete(HTML_CACHE_MARKER);
245
+ const revalidateSeconds = Number(marker);
246
+ if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) return res;
247
+ // Per-user leak defense (#241). The page set `revalidate` (asserting "same
248
+ // for everyone"), but if the render actually read per-user request state
249
+ // (cookies() / headers() / getSession()), its body varies by visitor and
250
+ // must NOT be cached, even though it set no new Set-Cookie. Fail safe (skip
251
+ // caching) and warn the author ONCE per path so the wrong `revalidate` is
252
+ // visible without spamming the log.
253
+ if (dynamicAccessed()) {
254
+ warnDynamicRevalidateOnce(url.pathname);
255
+ return res;
256
+ }
257
+ // Re-check status / streaming / cookie guards against the final response.
258
+ if (!isCacheableResponse(res)) return res;
259
+ try {
260
+ const body = await res.clone().text();
261
+ await writeHtmlCache(
262
+ url,
263
+ {
264
+ body,
265
+ contentType: res.headers.get('content-type') || 'text/html; charset=utf-8',
266
+ cacheControl: res.headers.get('cache-control') || 'no-store',
267
+ status: res.status,
268
+ },
269
+ revalidateSeconds,
270
+ );
271
+ } catch {
272
+ /* a buffer / store failure must never affect the live response */
273
+ }
274
+ return res;
275
+ }
276
+
277
+ /**
278
+ * Paths already warned about a `revalidate` on a per-user page, so the
279
+ * console.warn fires once per offending route rather than once per request.
280
+ * @type {Set<string>}
281
+ */
282
+ const _warnedDynamicPaths = new Set();
283
+
284
+ /** @param {string} pathname */
285
+ function warnDynamicRevalidateOnce(pathname) {
286
+ if (_warnedDynamicPaths.has(pathname)) return;
287
+ _warnedDynamicPaths.add(pathname);
288
+ console.warn(
289
+ `[webjs] not caching ${pathname}: it exported \`revalidate\` but read ` +
290
+ `per-user request state (cookies() / headers() / getSession()) during ` +
291
+ `render, so its output varies by visitor. Remove \`revalidate\` from a ` +
292
+ `per-user page, or stop reading request state if it is the same for ` +
293
+ `everyone. The page is being served fresh (uncached) to avoid a leak.`
294
+ );
295
+ }
296
+
297
+ /** Evict ALL cached HTML for this process. @returns {void} */
298
+ export function revalidateAll() {
299
+ _generation++;
300
+ }
301
+
302
+ /** Internal: current generation, folded into keys by `htmlCacheKey`. */
303
+ export function htmlCacheGeneration() {
304
+ return _generation;
305
+ }
package/src/importmap.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { digestHex } from './crypto-utils.js';
4
4
  import { jsonForScriptTag } from './script-tag-json.js';
5
+ import { withBasePath } from './base-path.js';
5
6
 
6
7
  // Local attribute escaper. Matches ssr.js's escapeAttr (the source
7
8
  // of truth for HTML attribute escaping in this package). Kept inline
@@ -26,6 +27,45 @@ function escapeAttr(s) {
26
27
  /** @type {Record<string, string>} */
27
28
  let _extraEntries = {};
28
29
 
30
+ /**
31
+ * The normalized `webjs.basePath` for a sub-path deployment (issue #256),
32
+ * `''` (the default) for a root mount. When non-empty, every same-origin
33
+ * absolute importmap TARGET (the `/__webjs/core/*` core entries and any
34
+ * same-origin `/__webjs/vendor/*` local vendor target) is prefixed with it
35
+ * so module resolution works under the prefix. A cross-origin `https://`
36
+ * CDN vendor target is absolute and is left untouched. Set once at boot by
37
+ * `setBasePath`, which recomputes the importmap hash so `importMapHash()`
38
+ * stays synchronous on the hot path.
39
+ *
40
+ * @type {string}
41
+ */
42
+ let _basePath = '';
43
+
44
+ /**
45
+ * Bind the importmap to a sub-path deployment's base path (issue #256).
46
+ * Called once at boot by `dev.js`. With the empty default the map is
47
+ * byte-identical to a root mount. The importmap hash is recomputed eagerly
48
+ * (like `setCoreInstall` / `setVendorEntries`) so `importMapHash()` stays
49
+ * synchronous on the per-request SSR path.
50
+ *
51
+ * @param {string} basePath the normalized base path (`''` = root mount)
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function setBasePath(basePath) {
55
+ _basePath = basePath || '';
56
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
57
+ }
58
+
59
+ /**
60
+ * The active base path, for callers that prefix their own emitted URLs
61
+ * against the same value (ssr.js's boot specifiers, preloads, reload src).
62
+ *
63
+ * @returns {string}
64
+ */
65
+ export function basePath() {
66
+ return _basePath;
67
+ }
68
+
29
69
  /**
30
70
  * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
31
71
  * Populated only when a pin file with `integrity` is present;
@@ -292,7 +332,12 @@ export function buildImportMap() {
292
332
  // even though the content didn't actually change.
293
333
  /** @type {Record<string, string>} */
294
334
  const imports = {};
295
- for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
335
+ // Prefix every same-origin absolute target with the sub-path base
336
+ // (issue #256). `withBasePath` is a no-op when the base path is empty
337
+ // (so a root-mounted app's map is byte-identical) and leaves a
338
+ // cross-origin `https://` CDN vendor target untouched (only the
339
+ // framework's own `/__webjs/*` and same-origin vendor targets move).
340
+ for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
296
341
 
297
342
  // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
298
343
  // Safari 18.4+, Firefox flagged). Browsers without support ignore
@@ -305,11 +350,17 @@ export function buildImportMap() {
305
350
  // unrelated pin file edits, and leak removed URLs to the wire.
306
351
  const out = { imports };
307
352
  const usedUrls = new Set(Object.values(imports));
308
- const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
353
+ // Integrity keys are the FINAL post-rewrite URLs, so prefix a same-origin
354
+ // local vendor key with the base path to match its (now prefixed) imports
355
+ // value. A cross-origin CDN key is untouched by `withBasePath` and lines
356
+ // up with its unprefixed imports value.
357
+ const intKeys = Object.keys(_vendorIntegrity)
358
+ .filter(k => usedUrls.has(withBasePath(k, _basePath)))
359
+ .sort();
309
360
  if (intKeys.length) {
310
361
  /** @type {Record<string, string>} */
311
362
  const integrity = {};
312
- for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
363
+ for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
313
364
  out.integrity = integrity;
314
365
  }
315
366
  return out;