@webjsdev/server 0.8.9 → 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 +6 -1
- package/package.json +1 -1
- package/src/actions.js +49 -7
- package/src/api.js +16 -1
- package/src/auth.js +25 -3
- package/src/body-limit.js +291 -0
- 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 +139 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +397 -31
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/html-cache.js +305 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/session.js +4 -0
- package/src/ssr.js +142 -24
- package/src/vendor.js +9 -6
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-based invalidation for server `cache()` (the Next.js revalidateTag
|
|
3
|
+
* model), built as a THIN key-index over the existing pluggable store
|
|
4
|
+
* (`get` / `set` / `delete` in `cache.js`), NOT a new subsystem.
|
|
5
|
+
*
|
|
6
|
+
* The problem it solves: `cache(fn, { key, ttl })` can only be invalidated
|
|
7
|
+
* by calling `wrapped.invalidate()` from the module that owns the wrapper,
|
|
8
|
+
* and even then only the no-args base key (arg-specific keys leak until
|
|
9
|
+
* their TTL). There was no way for an unrelated mutation (createComment)
|
|
10
|
+
* to invalidate a related read (postById) without importing every wrapper.
|
|
11
|
+
*
|
|
12
|
+
* The index: when a cached result is stored, `cache-fn.js` also records the
|
|
13
|
+
* mapping `tag -> cacheKey` here. Each tag holds a JSON array of the cache
|
|
14
|
+
* keys tagged with it, under the namespaced store key `cache:tag:<tag>`.
|
|
15
|
+
* `revalidateTag(tag)` reads that array, deletes every cache key in it, then
|
|
16
|
+
* clears the index entry. A mutation in ANY module can therefore evict every
|
|
17
|
+
* read tagged `'posts'` across modules with one explicit call.
|
|
18
|
+
*
|
|
19
|
+
* It stays a thin index over `store.get/set/delete`: no new store method, a
|
|
20
|
+
* plain JSON array (a Set is trivial in the memory store; the same get/set of
|
|
21
|
+
* a JSON array works for Redis). The cohesive companion for HTML paths is
|
|
22
|
+
* `revalidatePath` in `html-cache.js`; together they are the server cache
|
|
23
|
+
* invalidation surface (this one for `cache()` DATA, that one for cached HTML).
|
|
24
|
+
*
|
|
25
|
+
* MULTI-INSTANCE CAVEAT (mirrors #241): the index is a plain read-modify-write
|
|
26
|
+
* of a JSON array, NOT atomic across processes. With a shared Redis store,
|
|
27
|
+
* `revalidateTag` deletes the keys it can see and reaches every instance for
|
|
28
|
+
* those keys, but two instances appending to the same tag concurrently can
|
|
29
|
+
* lose an append (last write wins), so a freshly-stored key on a peer might
|
|
30
|
+
* miss eviction and live until its TTL. The tag index entry itself also
|
|
31
|
+
* carries the cache TTL, so the index self-prunes and never grows unbounded.
|
|
32
|
+
* For strict cross-instance invalidation, prefer a short `ttl` as the floor.
|
|
33
|
+
*
|
|
34
|
+
* @module cache-tags
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { getStore } from './cache.js';
|
|
38
|
+
|
|
39
|
+
/** Namespace prefix for every tag-index key, parallel to `cache:` entries. */
|
|
40
|
+
const TAG_PREFIX = 'cache:tag:';
|
|
41
|
+
|
|
42
|
+
/** @param {string} tag @returns {string} */
|
|
43
|
+
function tagKey(tag) {
|
|
44
|
+
return `${TAG_PREFIX}${tag}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the cache-key set stored under one tag. Returns a plain array (empty
|
|
49
|
+
* on a miss / parse error, failing open). The store holds a JSON array.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} tag
|
|
52
|
+
* @returns {Promise<string[]>}
|
|
53
|
+
*/
|
|
54
|
+
async function readTagKeys(tag) {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await getStore().get(tagKey(tag));
|
|
57
|
+
if (!raw) return [];
|
|
58
|
+
const arr = JSON.parse(raw);
|
|
59
|
+
return Array.isArray(arr) ? arr : [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Append a cache key to every given tag's index (deduped). Called by
|
|
67
|
+
* `cache-fn.js` right after it stores a cached result. The index entry
|
|
68
|
+
* carries the same TTL as the cached value so it self-prunes and the tag
|
|
69
|
+
* index never outgrows the data it points at.
|
|
70
|
+
*
|
|
71
|
+
* Best-effort: a store failure here never affects the cached result that was
|
|
72
|
+
* already written (the value is still served; only its taggability is lost).
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} tags
|
|
75
|
+
* @param {string} cacheKey
|
|
76
|
+
* @param {number} [ttlMs]
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
export async function addKeyToTags(tags, cacheKey, ttlMs) {
|
|
80
|
+
if (!Array.isArray(tags) || tags.length === 0) return;
|
|
81
|
+
const store = getStore();
|
|
82
|
+
for (const tag of tags) {
|
|
83
|
+
if (typeof tag !== 'string' || !tag) continue;
|
|
84
|
+
try {
|
|
85
|
+
const keys = await readTagKeys(tag);
|
|
86
|
+
if (keys.includes(cacheKey)) continue;
|
|
87
|
+
keys.push(cacheKey);
|
|
88
|
+
await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
|
|
89
|
+
} catch {
|
|
90
|
+
/* a tag-index write failure must never affect the cached value */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Evict every cached entry tagged with `tag`, then clear the tag index.
|
|
97
|
+
* A mutating server action calls this after a write so the next read of any
|
|
98
|
+
* tagged query recomputes. Works across modules: the read tagged `'posts'`
|
|
99
|
+
* in one module is invalidated by a `revalidateTag('posts')` issued from
|
|
100
|
+
* any other.
|
|
101
|
+
*
|
|
102
|
+
* ```js
|
|
103
|
+
* // modules/comments/actions/create-comment.server.ts
|
|
104
|
+
* 'use server';
|
|
105
|
+
* import { revalidateTag } from '@webjsdev/server';
|
|
106
|
+
* export async function createComment(input) {
|
|
107
|
+
* await prisma.comment.create({ data: input });
|
|
108
|
+
* await revalidateTag('post:' + input.postId); // postById(postId) recomputes
|
|
109
|
+
* return { success: true };
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @param {string} tag
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
export async function revalidateTag(tag) {
|
|
117
|
+
if (typeof tag !== 'string' || !tag) return;
|
|
118
|
+
const store = getStore();
|
|
119
|
+
const keys = await readTagKeys(tag);
|
|
120
|
+
for (const k of keys) {
|
|
121
|
+
try {
|
|
122
|
+
await store.delete(k);
|
|
123
|
+
} catch {
|
|
124
|
+
/* a delete failure is non-fatal: the entry still expires via its TTL */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await store.delete(tagKey(tag));
|
|
129
|
+
} catch {
|
|
130
|
+
/* non-fatal */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convenience: `revalidateTag` for several tags in one call. A mutation that
|
|
136
|
+
* touches multiple cached surfaces (e.g. a post AND the post list) evicts
|
|
137
|
+
* them together.
|
|
138
|
+
*
|
|
139
|
+
* @param {string[]} tags
|
|
140
|
+
* @returns {Promise<void>}
|
|
141
|
+
*/
|
|
142
|
+
export async function revalidateTags(tags) {
|
|
143
|
+
if (!Array.isArray(tags)) return;
|
|
144
|
+
for (const tag of tags) {
|
|
145
|
+
await revalidateTag(tag);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 7232 conditional GET (ETag + If-None-Match -> 304).
|
|
3
|
+
*
|
|
4
|
+
* One shared funnel that, given a Request and the buffered Response the
|
|
5
|
+
* pipeline produced, attaches a content-hash `ETag` when the response is
|
|
6
|
+
* cacheable and missing one, then turns a matching `If-None-Match` into a
|
|
7
|
+
* `304 Not Modified` with no body. Wired once at the response funnel in
|
|
8
|
+
* `dev.js`'s `handle()`, so it covers SSR HTML pages, static assets, app
|
|
9
|
+
* source modules, vendor / core runtime modules, and route-handler bodies
|
|
10
|
+
* uniformly.
|
|
11
|
+
*
|
|
12
|
+
* What is EXCLUDED, and why:
|
|
13
|
+
* - `no-store` / `private` responses (the default for dynamic, per-user
|
|
14
|
+
* pages). Never enable a cross-session 304 on private content: a shared
|
|
15
|
+
* cache keyed on the URL could serve one user's validator to another.
|
|
16
|
+
* - Any body the framework did not positively mark as fully buffered. The
|
|
17
|
+
* funnel only hashes a response that ALREADY carries an `ETag` (a serve
|
|
18
|
+
* branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
|
|
19
|
+
* marker the buffered serve branches stamp on a string / bytes body. It
|
|
20
|
+
* never calls `arrayBuffer()` on an unmarked body. This is the guard that
|
|
21
|
+
* keeps a user `route.{js,ts}` handler returning a `ReadableStream` (and
|
|
22
|
+
* especially an SSE `text/event-stream`, whose stream NEVER ends) from
|
|
23
|
+
* being buffered into memory or, worse, awaited forever so the response
|
|
24
|
+
* hangs. A web `Response` exposes a `ReadableStream` body for a string and
|
|
25
|
+
* for a live stream alike, with no public way to tell them apart, so a
|
|
26
|
+
* positive opt-in marker is the only safe discriminator.
|
|
27
|
+
* - Streaming Suspense bodies, flagged with `X-Webjs-Stream: 1` by the SSR
|
|
28
|
+
* pipeline (a stricter form of the unmarked-body rule: the marker is
|
|
29
|
+
* stripped and the response returned untouched). Streaming responses are
|
|
30
|
+
* not conditional-GET cached, by design.
|
|
31
|
+
* - Non-GET / non-HEAD methods, and any status other than 200. A validator
|
|
32
|
+
* is only meaningful for a successful, replayable read.
|
|
33
|
+
*
|
|
34
|
+
* The ETag is WEAK (`W/"..."`). It is computed over the UNCOMPRESSED body
|
|
35
|
+
* bytes, then the prod compression step (`sendWebResponse` in `dev.js`) may
|
|
36
|
+
* stamp `Content-Encoding: gzip` / `br` on the SAME response. A STRONG
|
|
37
|
+
* validator must be unique per content-coding (RFC 7232 2.3.3), so reusing one
|
|
38
|
+
* strong ETag across identity / gzip / br would be non-conformant; a WEAK
|
|
39
|
+
* validator is the conformant choice for a hash shared across codings, and
|
|
40
|
+
* `If-None-Match` already uses weak comparison so a `304` still fires.
|
|
41
|
+
*
|
|
42
|
+
* The ETag is computed over the response's OWN body bytes, so an identical
|
|
43
|
+
* body yields an identical ETag across requests. Per-response varying bits
|
|
44
|
+
* that ride RESPONSE HEADERS (the `x-webjs-build` id, a `set-cookie` CSRF
|
|
45
|
+
* token, the CSP nonce on the header) are NOT part of the body hash, so they
|
|
46
|
+
* do not destabilise the ETag. The one body-level varying input is the CSP
|
|
47
|
+
* nonce stamped INTO the inline boot script: when CSP is enabled the HTML
|
|
48
|
+
* body changes every request, so its ETag changes every request and a 304 is
|
|
49
|
+
* simply never produced for that page (correct, not a bug). CSP is off by
|
|
50
|
+
* default, so the common case has a stable body and a stable ETag.
|
|
51
|
+
*
|
|
52
|
+
* @module conditional-get
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { digestHex } from './crypto-utils.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Internal header a buffered serve branch stamps to opt a response into
|
|
59
|
+
* conditional GET. Stripped at the funnel; it never reaches a client. A web
|
|
60
|
+
* `Response` exposes a `ReadableStream` body whether it was built from a
|
|
61
|
+
* string, bytes, or a live stream, so this explicit marker is how the funnel
|
|
62
|
+
* KNOWS a body is safe to hash without risking buffering a route handler's
|
|
63
|
+
* stream or hanging on an SSE response.
|
|
64
|
+
*/
|
|
65
|
+
export const BUFFERED_MARKER = 'x-webjs-buffered';
|
|
66
|
+
|
|
67
|
+
/** Internal header the SSR pipeline stamps on a genuinely-streamed body. */
|
|
68
|
+
export const STREAM_MARKER = 'x-webjs-stream';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Headers that must not ride a 304 response (it has no body). Everything
|
|
72
|
+
* else (ETag, Cache-Control, Vary, the framework's X-Webjs-Build /
|
|
73
|
+
* X-Request-Id, Set-Cookie) is preserved so a shared cache and the client
|
|
74
|
+
* router behave identically to a 200.
|
|
75
|
+
*/
|
|
76
|
+
const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Is this response cacheable enough to carry a validator? Cacheable means a
|
|
80
|
+
* 200 whose `Cache-Control` is present and does not forbid storage. A
|
|
81
|
+
* `no-store` or `private` response is excluded (private / per-user content
|
|
82
|
+
* must never get a cross-session 304). `no-cache` is INCLUDED: it means
|
|
83
|
+
* "revalidate before reuse", and a 304 is exactly that revalidation answer,
|
|
84
|
+
* so dev's `no-cache` assets still benefit.
|
|
85
|
+
*
|
|
86
|
+
* @param {Response} res
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function isCacheable(res) {
|
|
90
|
+
if (res.status !== 200) return false;
|
|
91
|
+
const cc = res.headers.get('cache-control');
|
|
92
|
+
if (!cc) return false;
|
|
93
|
+
return !/(?:^|,)\s*(?:no-store|private)\s*(?:,|$)/i.test(cc);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse an `If-None-Match` request header and test it against an ETag.
|
|
98
|
+
* Honors the `*` wildcard and a comma-separated list, and compares
|
|
99
|
+
* weak-insensitively (a `W/` prefix on either side is ignored for the
|
|
100
|
+
* comparison, per RFC 7232 weak-comparison semantics, which is the correct
|
|
101
|
+
* function for `If-None-Match`).
|
|
102
|
+
*
|
|
103
|
+
* @param {string | null} header the raw `If-None-Match` value
|
|
104
|
+
* @param {string} etag the response ETag, e.g. `"abc123"`
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function ifNoneMatchSatisfied(header, etag) {
|
|
108
|
+
if (!header || !etag) return false;
|
|
109
|
+
const want = stripWeak(etag);
|
|
110
|
+
for (const raw of header.split(',')) {
|
|
111
|
+
const tok = raw.trim();
|
|
112
|
+
if (tok === '*') return true;
|
|
113
|
+
if (stripWeak(tok) === want) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {string} tag */
|
|
119
|
+
function stripWeak(tag) {
|
|
120
|
+
return tag.startsWith('W/') ? tag.slice(2) : tag;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply conditional-GET semantics to a finished, buffered Response.
|
|
125
|
+
*
|
|
126
|
+
* Returns either the same response (now possibly carrying an `ETag`) or, when
|
|
127
|
+
* the request's `If-None-Match` matches, a fresh `304 Not Modified` with no
|
|
128
|
+
* body and the validators / caching headers preserved. A streaming,
|
|
129
|
+
* non-cacheable, or non-GET/HEAD response is returned unchanged.
|
|
130
|
+
*
|
|
131
|
+
* @param {Request} req
|
|
132
|
+
* @param {Response} res
|
|
133
|
+
* @returns {Promise<Response>}
|
|
134
|
+
*/
|
|
135
|
+
export async function applyConditionalGet(req, res) {
|
|
136
|
+
const method = req.method.toUpperCase();
|
|
137
|
+
// Always consume the internal buffered marker so it never reaches a client.
|
|
138
|
+
const buffered = res.headers.has(BUFFERED_MARKER);
|
|
139
|
+
if (buffered) res.headers.delete(BUFFERED_MARKER);
|
|
140
|
+
// A genuinely streamed Suspense response is flagged by the SSR pipeline.
|
|
141
|
+
// Strip that marker too and skip conditional-GET so the live stream is
|
|
142
|
+
// never consumed.
|
|
143
|
+
if (res.headers.has(STREAM_MARKER)) {
|
|
144
|
+
res.headers.delete(STREAM_MARKER);
|
|
145
|
+
return res;
|
|
146
|
+
}
|
|
147
|
+
if (method !== 'GET' && method !== 'HEAD') return res;
|
|
148
|
+
if (!isCacheable(res)) return res;
|
|
149
|
+
|
|
150
|
+
let etag = res.headers.get('etag');
|
|
151
|
+
if (!etag) {
|
|
152
|
+
// Only hash a body the framework positively marked as fully buffered.
|
|
153
|
+
// Without the marker we must NOT read the body, because a user route
|
|
154
|
+
// handler returning a ReadableStream would be buffered into memory, and
|
|
155
|
+
// an SSE (text/event-stream) stream never ends so the await would hang
|
|
156
|
+
// forever. A web Response exposes a ReadableStream body for a string and
|
|
157
|
+
// for a live stream alike, so the marker is the only safe discriminator.
|
|
158
|
+
if (!buffered) return res;
|
|
159
|
+
let bytes;
|
|
160
|
+
try {
|
|
161
|
+
// Clone so the caller's body is never consumed. Safe here because a
|
|
162
|
+
// marked body is built from a string / bytes, so the clone resolves
|
|
163
|
+
// at once.
|
|
164
|
+
bytes = new Uint8Array(await res.clone().arrayBuffer());
|
|
165
|
+
} catch {
|
|
166
|
+
// A body that refuses to buffer (should not happen for a marked branch)
|
|
167
|
+
// is left without a validator rather than crashing the funnel.
|
|
168
|
+
return res;
|
|
169
|
+
}
|
|
170
|
+
// WEAK validator. The hash is over the uncompressed body and is reused
|
|
171
|
+
// across identity / gzip / br codings (compression runs after this), which
|
|
172
|
+
// a STRONG ETag may not do (RFC 7232 2.3.3). If-None-Match weak-compares.
|
|
173
|
+
etag = `W/"${(await digestHex('SHA-1', bytes)).slice(0, 16)}"`;
|
|
174
|
+
res.headers.set('etag', etag);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ifNoneMatchSatisfied(req.headers.get('if-none-match'), etag)) {
|
|
178
|
+
const headers = new Headers(res.headers);
|
|
179
|
+
for (const h of STRIP_ON_304) headers.delete(h);
|
|
180
|
+
return new Response(null, { status: 304, headers });
|
|
181
|
+
}
|
|
182
|
+
return res;
|
|
183
|
+
}
|
package/src/context.js
CHANGED
|
@@ -10,7 +10,32 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
10
10
|
*
|
|
11
11
|
* Strictly server-side: importing this module on the client is a bug.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* `cspNonce` holds the per-request CSP nonce when CSP is enabled
|
|
14
|
+
* (issue #233). It is minted in the request handler and written here via
|
|
15
|
+
* `setCspNonce`, so the same value the `Content-Security-Policy` header
|
|
16
|
+
* carries is what `cspNonce()` returns for the inline boot script.
|
|
17
|
+
*
|
|
18
|
+
* `bodyLimits` holds the resolved request body-size caps (issue #237) so
|
|
19
|
+
* `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
|
|
20
|
+
* server state) can enforce the same limit the RPC and page-action paths do. The
|
|
21
|
+
* handler writes it per request via `setBodyLimits`.
|
|
22
|
+
*
|
|
23
|
+
* `requestId` holds the per-request correlation id (issue #239). The handler
|
|
24
|
+
* mints a `crypto.randomUUID()` at the start of every request (or honors an
|
|
25
|
+
* inbound `X-Request-Id` from a trusted upstream proxy) and stores it here via
|
|
26
|
+
* `setRequestId`, so server-side app code can read it with `requestId()` to
|
|
27
|
+
* stamp it on its own logs / outbound calls, and the framework includes it in
|
|
28
|
+
* the access log, the error log, and the `X-Request-Id` response header.
|
|
29
|
+
*
|
|
30
|
+
* `dynamicAccess` is set the moment the render reads per-user request state
|
|
31
|
+
* through a framework helper (`cookies()`, `headers()`, `getSession()`),
|
|
32
|
+
* mirroring Next.js auto-marking a route dynamic on a `cookies()` / `headers()`
|
|
33
|
+
* read (#241). The server HTML cache reads it at commit time and REFUSES to
|
|
34
|
+
* cache a page that opted into `revalidate` but actually varies per user, so a
|
|
35
|
+
* wrong `revalidate` on a cookie-reading page fails safe (uncached) instead of
|
|
36
|
+
* leaking one visitor's view to another.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number }, requestId?: string, dynamicAccess?: boolean }} Store
|
|
14
39
|
*/
|
|
15
40
|
|
|
16
41
|
/** @type {AsyncLocalStorage<Store>} */
|
|
@@ -33,10 +58,102 @@ export function getRequest() {
|
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
61
|
+
* Set the per-request CSP nonce on the current AsyncLocalStorage store.
|
|
62
|
+
* Called by the request handler when CSP is enabled, AFTER it mints the
|
|
63
|
+
* nonce and BEFORE the page renders, so `cspNonce()` returns this exact
|
|
64
|
+
* value during SSR (the same value the response's
|
|
65
|
+
* `Content-Security-Policy` header carries: one source, no drift).
|
|
66
|
+
*
|
|
67
|
+
* A no-op outside a request scope, or when CSP is disabled (the handler
|
|
68
|
+
* simply never calls it, so the store's `cspNonce` stays undefined and
|
|
69
|
+
* `cspNonce()` falls through to '').
|
|
70
|
+
*
|
|
71
|
+
* @param {string} nonce
|
|
72
|
+
*/
|
|
73
|
+
export function setCspNonce(nonce) {
|
|
74
|
+
const store = als.getStore();
|
|
75
|
+
if (store) store.cspNonce = nonce;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set the per-request resolved body-size limits on the current store (issue
|
|
80
|
+
* #237). The handler computes them once at boot (`readBodyLimits`) and stamps
|
|
81
|
+
* them on every request so `readBody` (json.js), which runs inside route
|
|
82
|
+
* handlers with no access to the server state, can enforce the same cap.
|
|
83
|
+
*
|
|
84
|
+
* @param {{ json: number, multipart: number }} limits
|
|
85
|
+
*/
|
|
86
|
+
export function setBodyLimits(limits) {
|
|
87
|
+
const store = als.getStore();
|
|
88
|
+
if (store) store.bodyLimits = limits;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read the per-request body-size limits, or null outside a request scope.
|
|
93
|
+
* @returns {{ json: number, multipart: number } | null}
|
|
94
|
+
*/
|
|
95
|
+
export function getBodyLimits() {
|
|
96
|
+
return als.getStore()?.bodyLimits ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Set the per-request correlation id on the current store (issue #239). The
|
|
101
|
+
* handler calls this at the start of every request with either an inbound
|
|
102
|
+
* `X-Request-Id` (a trusted upstream proxy's trace id) or a freshly minted
|
|
103
|
+
* `crypto.randomUUID()`. A no-op outside a request scope.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} id
|
|
106
|
+
*/
|
|
107
|
+
export function setRequestId(id) {
|
|
108
|
+
const store = als.getStore();
|
|
109
|
+
if (store) store.requestId = id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The correlation id for the in-flight request (issue #239), or `null`
|
|
114
|
+
* outside a request scope. Server-side app code (pages, layouts, server
|
|
115
|
+
* actions, route handlers, middleware) reads it to correlate its own logs and
|
|
116
|
+
* outbound calls with the framework's access / error logs and the
|
|
117
|
+
* `X-Request-Id` response header, all of which carry the same id.
|
|
118
|
+
*
|
|
119
|
+
* @returns {string | null}
|
|
120
|
+
*/
|
|
121
|
+
export function requestId() {
|
|
122
|
+
return als.getStore()?.requestId ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Mark the in-flight request as having read per-user state (issue #241). The
|
|
127
|
+
* framework's request-state readers (`cookies()`, `headers()`, `getSession()`)
|
|
128
|
+
* call this, so the server HTML cache can tell at commit time that the render
|
|
129
|
+
* actually depends on the visitor and refuse to cache it even when the page
|
|
130
|
+
* declared `revalidate`. A no-op outside a request scope.
|
|
131
|
+
*/
|
|
132
|
+
export function markDynamicAccess() {
|
|
133
|
+
const store = als.getStore();
|
|
134
|
+
if (store) store.dynamicAccess = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True when the in-flight render read per-user request state via a framework
|
|
139
|
+
* helper (issue #241). Read by the server HTML cache's commit step to fail
|
|
140
|
+
* safe (do not cache) on a per-user page that wrongly set `revalidate`.
|
|
141
|
+
* Returns false outside a request scope.
|
|
142
|
+
*
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function dynamicAccessed() {
|
|
146
|
+
return als.getStore()?.dynamicAccess === true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Server-only implementation of the CSP nonce reader. Returns the
|
|
151
|
+
* per-request nonce that the handler MINTED and stored (issue #233) when
|
|
152
|
+
* CSP is enabled. Falls back to parsing an INBOUND
|
|
153
|
+
* `Content-Security-Policy` request header (the legacy consume-only
|
|
154
|
+
* behaviour) so an app sitting behind a proxy that already mints a nonce
|
|
155
|
+
* still works without enabling webjs's own CSP. Returns '' when neither
|
|
156
|
+
* is in scope.
|
|
40
157
|
*
|
|
41
158
|
* The public `cspNonce()` function lives in `@webjsdev/core` so user
|
|
42
159
|
* layouts / pages can import it without dragging server-only deps
|
|
@@ -46,18 +163,16 @@ export function getRequest() {
|
|
|
46
163
|
* `cspNonce()` returns '' (empty `nonce=""` attribute, browser
|
|
47
164
|
* ignores it).
|
|
48
165
|
*/
|
|
49
|
-
// The regex captures the first `nonce-...` token anywhere in the
|
|
50
|
-
// header. Webjs uses a single per-request nonce shared across
|
|
51
|
-
// directives that emit it (the standard CSP3 single-nonce model),
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// would need to become directive-scoped. Kept identical to the
|
|
55
|
-
// matching helper in ssr.js so both paths interpret the header the
|
|
56
|
-
// same way.
|
|
166
|
+
// The regex fallback captures the first `nonce-...` token anywhere in the
|
|
167
|
+
// inbound CSP header. Webjs uses a single per-request nonce shared across
|
|
168
|
+
// all directives that emit it (the standard CSP3 single-nonce model), so
|
|
169
|
+
// reading the first match is correct. Kept identical to the matching
|
|
170
|
+
// helper in ssr.js so both paths interpret the header the same way.
|
|
57
171
|
setCspNonceProvider(() => {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
60
|
-
|
|
172
|
+
const store = als.getStore();
|
|
173
|
+
if (!store) return '';
|
|
174
|
+
if (typeof store.cspNonce === 'string') return store.cspNonce;
|
|
175
|
+
const csp = store.req?.headers.get('content-security-policy') || '';
|
|
61
176
|
const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
|
|
62
177
|
return match ? match[1] : '';
|
|
63
178
|
});
|
|
@@ -75,6 +190,10 @@ export { cspNonce };
|
|
|
75
190
|
export function headers() {
|
|
76
191
|
const req = getRequest();
|
|
77
192
|
if (!req) throw new Error('headers(): called outside a request scope');
|
|
193
|
+
// Reading request headers makes the render per-user (an Authorization /
|
|
194
|
+
// Accept-Language / Cookie read varies the output), so mark the request
|
|
195
|
+
// dynamic so the HTML cache excludes it even under `revalidate` (#241).
|
|
196
|
+
markDynamicAccess();
|
|
78
197
|
return req.headers;
|
|
79
198
|
}
|
|
80
199
|
|
|
@@ -89,6 +208,10 @@ export function headers() {
|
|
|
89
208
|
export function cookies() {
|
|
90
209
|
const req = getRequest();
|
|
91
210
|
if (!req) throw new Error('cookies(): called outside a request scope');
|
|
211
|
+
// Reading cookies makes the render per-user (a logged-in vs logged-out body
|
|
212
|
+
// keys off an auth cookie), so mark the request dynamic so the HTML cache
|
|
213
|
+
// excludes it even under `revalidate`, the core leak defense (#241).
|
|
214
|
+
markDynamicAccess();
|
|
92
215
|
const map = parseCookies(req);
|
|
93
216
|
return {
|
|
94
217
|
get: (name) => map[name],
|