@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.
- package/index.js +4 -1
- package/package.json +4 -2
- package/src/actions.js +21 -3
- package/src/auth.js +8 -1
- package/src/base-path.js +149 -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 +74 -1
- package/src/dev.js +449 -49
- package/src/html-cache.js +305 -0
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/session.js +4 -0
- package/src/ssr.js +210 -9
- package/src/vendor.js +9 -6
- package/webjs-config.schema.json +147 -0
|
@@ -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
|
@@ -20,7 +20,22 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
20
20
|
* server state) can enforce the same limit the RPC and page-action paths do. The
|
|
21
21
|
* handler writes it per request via `setBodyLimits`.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
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
|
|
24
39
|
*/
|
|
25
40
|
|
|
26
41
|
/** @type {AsyncLocalStorage<Store>} */
|
|
@@ -81,6 +96,56 @@ export function getBodyLimits() {
|
|
|
81
96
|
return als.getStore()?.bodyLimits ?? null;
|
|
82
97
|
}
|
|
83
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
|
+
|
|
84
149
|
/**
|
|
85
150
|
* Server-only implementation of the CSP nonce reader. Returns the
|
|
86
151
|
* per-request nonce that the handler MINTED and stored (issue #233) when
|
|
@@ -125,6 +190,10 @@ export { cspNonce };
|
|
|
125
190
|
export function headers() {
|
|
126
191
|
const req = getRequest();
|
|
127
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();
|
|
128
197
|
return req.headers;
|
|
129
198
|
}
|
|
130
199
|
|
|
@@ -139,6 +208,10 @@ export function headers() {
|
|
|
139
208
|
export function cookies() {
|
|
140
209
|
const req = getRequest();
|
|
141
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();
|
|
142
215
|
const map = parseCookies(req);
|
|
143
216
|
return {
|
|
144
217
|
get: (name) => map[name],
|