@webjsdev/server 0.8.10 → 0.8.11
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/index.js +3 -1
- package/package.json +1 -1
- package/src/actions.js +12 -2
- package/src/auth.js +8 -1
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +74 -1
- package/src/dev.js +188 -27
- package/src/html-cache.js +305 -0
- package/src/session.js +4 -0
- package/src/ssr.js +102 -2
- package/src/vendor.js +9 -6
package/src/dev.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
2
|
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { digestHex } from './crypto-utils.js';
|
|
5
4
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
6
5
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
7
6
|
import { createRequire } from 'node:module';
|
|
@@ -68,7 +67,8 @@ import {
|
|
|
68
67
|
import { defaultLogger } from './logger.js';
|
|
69
68
|
import { assertNodeVersion } from './node-version.js';
|
|
70
69
|
import { applyEnvValidation } from './env-schema.js';
|
|
71
|
-
import { withRequest, setCspNonce, setBodyLimits } from './context.js';
|
|
70
|
+
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
|
|
71
|
+
import { buildInfoResponse } from './build-info.js';
|
|
72
72
|
import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
|
|
73
73
|
import { attachWebSocket } from './websocket.js';
|
|
74
74
|
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
|
|
@@ -80,10 +80,44 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
|
80
80
|
function kebab(name) {
|
|
81
81
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
|
|
86
|
+
* from a trusted upstream proxy so a trace id propagates across services; mint
|
|
87
|
+
* a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
|
|
88
|
+
* and validated against a conservative token charset so a hostile client
|
|
89
|
+
* cannot inject control chars / a header-splitting payload (the value is
|
|
90
|
+
* echoed back in the `X-Request-Id` response header). On any mismatch we fall
|
|
91
|
+
* back to a minted id rather than trust the junk.
|
|
92
|
+
*
|
|
93
|
+
* @param {Request} req
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function resolveRequestId(req) {
|
|
97
|
+
const inbound = req.headers.get('x-request-id');
|
|
98
|
+
if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
|
|
99
|
+
return crypto.randomUUID();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a path should be access-logged (issue #239). The framework's own
|
|
104
|
+
* `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
|
|
105
|
+
* are high-frequency infrastructure traffic, not app requests, so logging them
|
|
106
|
+
* would just spam the access log. App routes (including app-authored
|
|
107
|
+
* `/api/*`) are logged.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} pathname
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
function shouldAccessLog(pathname) {
|
|
113
|
+
return !pathname.startsWith('/__webjs/');
|
|
114
|
+
}
|
|
83
115
|
import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
|
|
84
116
|
import { urlFromRequest } from './forwarded.js';
|
|
85
117
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
86
118
|
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
|
|
119
|
+
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
|
|
120
|
+
import { commitHtmlCache } from './html-cache.js';
|
|
87
121
|
|
|
88
122
|
const MIME = {
|
|
89
123
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -294,6 +328,7 @@ export async function readServerTimeoutsFromApp(appDir) {
|
|
|
294
328
|
* appDir: string,
|
|
295
329
|
* dev?: boolean,
|
|
296
330
|
* logger?: import('./logger.js').Logger,
|
|
331
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
297
332
|
* onReload?: () => void,
|
|
298
333
|
* }} opts
|
|
299
334
|
*/
|
|
@@ -321,6 +356,30 @@ export async function createRequestHandler(opts) {
|
|
|
321
356
|
await applyEnvValidation(appDir, { dev: !!opts.dev });
|
|
322
357
|
const dev = !!opts.dev;
|
|
323
358
|
const logger = opts.logger || defaultLogger({ dev });
|
|
359
|
+
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
360
|
+
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
361
|
+
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
362
|
+
// middleware that threw. BEST-EFFORT by contract: a throwing onError is
|
|
363
|
+
// caught here so it can never crash the response, and the framework's own
|
|
364
|
+
// sanitized 500 / existing error behavior is unchanged (the hook is purely
|
|
365
|
+
// additive). The sink receives the error plus the correlation id so it can
|
|
366
|
+
// tie the report to the same id the access log and X-Request-Id carry.
|
|
367
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
368
|
+
/**
|
|
369
|
+
* Invoke the app's onError sink defensively. The phase is a coarse label of
|
|
370
|
+
* where the pipeline caught the error, for the sink's own grouping.
|
|
371
|
+
* @param {unknown} error
|
|
372
|
+
* @param {Request} request
|
|
373
|
+
* @param {string} phase
|
|
374
|
+
*/
|
|
375
|
+
function reportError(error, request, phase) {
|
|
376
|
+
if (!onError) return;
|
|
377
|
+
try {
|
|
378
|
+
onError(error, { request, requestId: getRequestId(), phase });
|
|
379
|
+
} catch (e) {
|
|
380
|
+
logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
324
383
|
const coreDir = locateCoreDir(appDir);
|
|
325
384
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
326
385
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -679,6 +738,14 @@ export async function createRequestHandler(opts) {
|
|
|
679
738
|
/** @param {Request} req */
|
|
680
739
|
function handle(req) {
|
|
681
740
|
return withRequest(req, async () => {
|
|
741
|
+
// Correlation id (issue #239): honor an inbound X-Request-Id from a
|
|
742
|
+
// trusted upstream proxy, else mint a fresh UUID. Stored on the request
|
|
743
|
+
// scope FIRST so everything downstream (the SSR, server actions, the
|
|
744
|
+
// access / error log, the onError sink, the response header) reads the
|
|
745
|
+
// same id, threading one trace id across services.
|
|
746
|
+
const reqId = resolveRequestId(req);
|
|
747
|
+
setRequestId(reqId);
|
|
748
|
+
|
|
682
749
|
// CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
|
|
683
750
|
// it on the request scope BEFORE producing the response, so the SSR
|
|
684
751
|
// pipeline's `cspNonce()` reads this exact value and stamps it on the
|
|
@@ -693,20 +760,45 @@ export async function createRequestHandler(opts) {
|
|
|
693
760
|
// cap the framework's own RPC / page-action body reads do.
|
|
694
761
|
setBodyLimits(state.bodyLimits);
|
|
695
762
|
|
|
696
|
-
|
|
763
|
+
let pathname = '/';
|
|
764
|
+
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
765
|
+
const startedAt = performance.now();
|
|
766
|
+
|
|
767
|
+
let res;
|
|
768
|
+
try {
|
|
769
|
+
res = await produce(req);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
// A throw escaping produce() is the last-resort 500 (every interior
|
|
772
|
+
// path catches its own errors, but a surprise still must not crash the
|
|
773
|
+
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
774
|
+
// preserving the prior behavior plus the new APM hook.
|
|
775
|
+
reportError(e, req, 'handle');
|
|
776
|
+
logger.error?.('[webjs] request pipeline threw', {
|
|
777
|
+
requestId: reqId,
|
|
778
|
+
method: req.method,
|
|
779
|
+
path: pathname,
|
|
780
|
+
err: e instanceof Error ? e.stack : String(e),
|
|
781
|
+
});
|
|
782
|
+
res = new Response('Server error', { status: 500 });
|
|
783
|
+
}
|
|
784
|
+
|
|
697
785
|
// Merge in the secure-by-default headers plus the per-path config
|
|
698
786
|
// (issue #232) as the final step, so app middleware, route
|
|
699
787
|
// handlers, and `expose` headers (already on `res`) always win.
|
|
700
788
|
// Applied to every served response (documents, assets, the core
|
|
701
789
|
// runtime, probes), since the defaults are universally safe.
|
|
702
|
-
let
|
|
703
|
-
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
704
|
-
const merged = applySecurityHeaders(res, {
|
|
790
|
+
let merged = applySecurityHeaders(res, {
|
|
705
791
|
pathname,
|
|
706
792
|
https: webRequestIsHttps(req),
|
|
707
793
|
prod: !dev,
|
|
708
794
|
rules: headerRules,
|
|
709
795
|
});
|
|
796
|
+
|
|
797
|
+
// Expose the correlation id on the response (issue #239) so a client /
|
|
798
|
+
// proxy can read it from the X-Request-Id header. Never clobber an id an
|
|
799
|
+
// upstream / the app already set on the response.
|
|
800
|
+
if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
|
|
801
|
+
|
|
710
802
|
// Emit the Content-Security-Policy header carrying the SAME minted
|
|
711
803
|
// nonce the SSR'd scripts got (no drift). Set only when CSP is
|
|
712
804
|
// enabled; never clobber a CSP header the app already set (in
|
|
@@ -725,7 +817,49 @@ export async function createRequestHandler(opts) {
|
|
|
725
817
|
/* a malformed policy must not take the request down: serve without CSP */
|
|
726
818
|
}
|
|
727
819
|
}
|
|
728
|
-
|
|
820
|
+
|
|
821
|
+
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
822
|
+
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
823
|
+
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
824
|
+
// the funnel's guard re-checks, so a per-user response is not cached) and
|
|
825
|
+
// before conditional-GET can swap it for a bodiless 304. The marker is
|
|
826
|
+
// stripped here regardless. Best-effort: a store failure is swallowed.
|
|
827
|
+
try {
|
|
828
|
+
const reqUrl = new URL(req.url);
|
|
829
|
+
merged = await commitHtmlCache(req, merged, reqUrl);
|
|
830
|
+
} catch { /* never let the cache write crash the response */ }
|
|
831
|
+
|
|
832
|
+
// Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
|
|
833
|
+
// a cacheable response missing one, and turn a matching If-None-Match
|
|
834
|
+
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
835
|
+
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
836
|
+
// 304 carries the validators a shared cache and the client router need.
|
|
837
|
+
// A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
|
|
838
|
+
// body are all skipped (see conditional-get.js). Logged with the final
|
|
839
|
+
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
840
|
+
let conditioned = merged;
|
|
841
|
+
try {
|
|
842
|
+
conditioned = await applyConditionalGet(req, merged);
|
|
843
|
+
} catch { /* never let validator computation crash the response */ }
|
|
844
|
+
|
|
845
|
+
// Structured access log (issue #239): ONE info line per handled request
|
|
846
|
+
// at the single response funnel, carrying only method / path / status /
|
|
847
|
+
// duration / requestId (no bodies, no secrets). Suppressed for the
|
|
848
|
+
// framework's own /__webjs/* probe + static traffic so it does not spam.
|
|
849
|
+
// Best-effort: a logger that throws must not take the response down.
|
|
850
|
+
if (shouldAccessLog(pathname)) {
|
|
851
|
+
try {
|
|
852
|
+
logger.info?.('request', {
|
|
853
|
+
requestId: reqId,
|
|
854
|
+
method: req.method,
|
|
855
|
+
path: pathname,
|
|
856
|
+
status: conditioned.status,
|
|
857
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
858
|
+
});
|
|
859
|
+
} catch { /* never let logging crash the response */ }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return conditioned;
|
|
729
863
|
});
|
|
730
864
|
}
|
|
731
865
|
|
|
@@ -751,6 +885,13 @@ export async function createRequestHandler(opts) {
|
|
|
751
885
|
if (probePath === '/__webjs/health') {
|
|
752
886
|
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
753
887
|
}
|
|
888
|
+
// Build-info probe (issue #239): which build is live? Answered before
|
|
889
|
+
// ensureReady like the other probes (it depends only on the package
|
|
890
|
+
// version + already-published build id + process info, never the app
|
|
891
|
+
// analysis). No secrets.
|
|
892
|
+
if (probePath === '/__webjs/version') {
|
|
893
|
+
return buildInfoResponse();
|
|
894
|
+
}
|
|
754
895
|
if (probePath === '/__webjs/ready') {
|
|
755
896
|
const noStore = { 'cache-control': 'no-store' };
|
|
756
897
|
if (!readyDone) {
|
|
@@ -790,12 +931,13 @@ export async function createRequestHandler(opts) {
|
|
|
790
931
|
// Build all whole-app analysis on the first request (memoized), before
|
|
791
932
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
792
933
|
await ensureReady();
|
|
793
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev });
|
|
934
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, cspEnabled: cspConfig.enabled });
|
|
794
935
|
if (state.middleware) {
|
|
795
936
|
try {
|
|
796
937
|
return await state.middleware(req, next);
|
|
797
938
|
} catch (e) {
|
|
798
|
-
|
|
939
|
+
reportError(e, req, 'middleware');
|
|
940
|
+
logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
|
|
799
941
|
return new Response('Server error', { status: 500 });
|
|
800
942
|
}
|
|
801
943
|
}
|
|
@@ -861,6 +1003,7 @@ export async function createRequestHandler(opts) {
|
|
|
861
1003
|
* dev?: boolean,
|
|
862
1004
|
* compress?: boolean,
|
|
863
1005
|
* logger?: import('./logger.js').Logger,
|
|
1006
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
864
1007
|
* }} opts
|
|
865
1008
|
*/
|
|
866
1009
|
export async function startServer(opts) {
|
|
@@ -1111,7 +1254,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1111
1254
|
}
|
|
1112
1255
|
|
|
1113
1256
|
async function handleCore(req, ctx) {
|
|
1114
|
-
const { state, appDir, coreDir, dev } = ctx;
|
|
1257
|
+
const { state, appDir, coreDir, dev, reportError, cspEnabled } = ctx;
|
|
1115
1258
|
const url = new URL(req.url);
|
|
1116
1259
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1117
1260
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -1136,7 +1279,10 @@ async function handleCore(req, ctx) {
|
|
|
1136
1279
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
1137
1280
|
if (actMatch) {
|
|
1138
1281
|
if (method !== 'POST') return new Response('POST only', { status: 405 });
|
|
1139
|
-
|
|
1282
|
+
// Pass the onError sink (issue #239): a server action that throws
|
|
1283
|
+
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1284
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1285
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1140
1286
|
}
|
|
1141
1287
|
|
|
1142
1288
|
// expose()d server actions (first-class REST), with optional CORS support.
|
|
@@ -1157,7 +1303,12 @@ async function handleCore(req, ctx) {
|
|
|
1157
1303
|
} else {
|
|
1158
1304
|
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
1159
1305
|
if (exposed) {
|
|
1160
|
-
|
|
1306
|
+
// Pass the onError sink (issue #239): an exposed REST handler that throws
|
|
1307
|
+
// unexpectedly is reported to the APM hook before the sanitized 500, the
|
|
1308
|
+
// same as the RPC action path (phase 'action' covers both server-action
|
|
1309
|
+
// invocation shapes).
|
|
1310
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1311
|
+
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
|
|
1161
1312
|
return withCors(resp, exposed.route, req);
|
|
1162
1313
|
}
|
|
1163
1314
|
}
|
|
@@ -1281,6 +1432,7 @@ async function handleCore(req, ctx) {
|
|
|
1281
1432
|
});
|
|
1282
1433
|
}
|
|
1283
1434
|
} catch (e) {
|
|
1435
|
+
if (reportError) reportError(e, req, 'metadata');
|
|
1284
1436
|
if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
|
|
1285
1437
|
return new Response('Internal error', { status: 500 });
|
|
1286
1438
|
}
|
|
@@ -1309,6 +1461,14 @@ async function handleCore(req, ctx) {
|
|
|
1309
1461
|
elidableComponents: state.elidableComponents,
|
|
1310
1462
|
inertRouteModules: state.inertRouteModules,
|
|
1311
1463
|
notFoundFile: state.routeTable.notFound,
|
|
1464
|
+
// Server HTML cache (#241): a CSP-enabled page emits a fresh
|
|
1465
|
+
// per-request nonce into its body, so its bytes vary per request and
|
|
1466
|
+
// it must never be HTML-cached. Pass the flag so the cache guard skips
|
|
1467
|
+
// it. CSP is off by default, so the common case stays cacheable.
|
|
1468
|
+
cspEnabled,
|
|
1469
|
+
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
1470
|
+
// reported to the APM hook with the active request's correlation id.
|
|
1471
|
+
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
1312
1472
|
};
|
|
1313
1473
|
if (method === 'GET' || method === 'HEAD') {
|
|
1314
1474
|
const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
@@ -1577,16 +1737,15 @@ async function fileResponse(abs, opts) {
|
|
|
1577
1737
|
try {
|
|
1578
1738
|
const data = await readFile(abs);
|
|
1579
1739
|
const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1740
|
+
// The body is fully buffered (read into `data`), so opt it into the
|
|
1741
|
+
// conditional-GET funnel, which is the single place that hashes the bytes
|
|
1742
|
+
// into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
|
|
1743
|
+
const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
|
|
1744
|
+
headers['cache-control'] = opts.dev
|
|
1745
|
+
? 'no-cache'
|
|
1746
|
+
: opts.immutable
|
|
1587
1747
|
? 'public, max-age=31536000, immutable'
|
|
1588
1748
|
: 'public, max-age=3600';
|
|
1589
|
-
}
|
|
1590
1749
|
return new Response(data, { status: 200, headers });
|
|
1591
1750
|
} catch {
|
|
1592
1751
|
return new Response('Not found', { status: 404 });
|
|
@@ -1611,13 +1770,13 @@ async function jsModuleResponse(abs, dev, elideOpts) {
|
|
|
1611
1770
|
const code = elideImportsFromSource(
|
|
1612
1771
|
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1613
1772
|
);
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
}
|
|
1773
|
+
// Buffered (string) body, so opt into the conditional-GET funnel for the
|
|
1774
|
+
// weak ETag + 304 (see fileResponse).
|
|
1775
|
+
const headers = {
|
|
1776
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
1777
|
+
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1778
|
+
[BUFFERED_MARKER]: '1',
|
|
1779
|
+
};
|
|
1621
1780
|
return new Response(code, { status: 200, headers });
|
|
1622
1781
|
}
|
|
1623
1782
|
|
|
@@ -1670,6 +1829,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1670
1829
|
headers: {
|
|
1671
1830
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1672
1831
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1832
|
+
[BUFFERED_MARKER]: '1',
|
|
1673
1833
|
},
|
|
1674
1834
|
});
|
|
1675
1835
|
}
|
|
@@ -1726,6 +1886,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1726
1886
|
headers: {
|
|
1727
1887
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1728
1888
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
1889
|
+
[BUFFERED_MARKER]: '1',
|
|
1729
1890
|
},
|
|
1730
1891
|
});
|
|
1731
1892
|
}
|
|
@@ -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
|
+
}
|