@webjsdev/server 0.8.56 → 0.8.58
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/package.json +2 -2
- package/src/actions.js +5 -3
- package/src/asset-hash.js +66 -4
- package/src/conditional-get.js +32 -9
- package/src/dev.js +44 -8
- package/src/forwarded.js +165 -10
- package/src/listener-bun.js +80 -3
- package/src/ssr.js +118 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.58",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@webjsdev/core": "^0.7.
|
|
31
|
+
"@webjsdev/core": "^0.7.46",
|
|
32
32
|
"ws": "^8.20.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/actions.js
CHANGED
|
@@ -267,7 +267,7 @@ export function serveServerOnlyStub(relPath) {
|
|
|
267
267
|
`This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
|
|
268
268
|
`Either add 'use server' at the top of the file to expose its exports as RPC, ` +
|
|
269
269
|
`or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
|
|
270
|
-
`Docs: https://
|
|
270
|
+
`Docs: https://webjs.dev/docs/server-actions`;
|
|
271
271
|
return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
|
|
272
272
|
throw new Error(${JSON.stringify(msg)});
|
|
273
273
|
`;
|
|
@@ -563,8 +563,10 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
563
563
|
* Build a GET action's response (#488): the serialized body plus a weak ETag
|
|
564
564
|
* (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
|
|
565
565
|
* and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
|
|
566
|
-
*
|
|
567
|
-
*
|
|
566
|
+
* deferring to the conditional-GET funnel: this branch already hashed the
|
|
567
|
+
* serialized body, so it can answer without the funnel re-reading it. (The
|
|
568
|
+
* funnel would now accept these too, since #1140 stopped excluding `private`,
|
|
569
|
+
* but the short-circuit here still runs first and is cheaper.)
|
|
568
570
|
* @param {unknown} result
|
|
569
571
|
* @param {Record<string, unknown>} mod
|
|
570
572
|
* @param {unknown[]} args
|
package/src/asset-hash.js
CHANGED
|
@@ -159,10 +159,32 @@ export function assetHashFor(absPath) {
|
|
|
159
159
|
const bytes = readFileSync(absPath);
|
|
160
160
|
const h = createHash('sha256').update(bytes);
|
|
161
161
|
// An app module's served body is elision-transformed, so fold the verdict
|
|
162
|
-
// fingerprint in (see `_elisionFp`). Core
|
|
163
|
-
// transformed, so they hash over their bytes alone
|
|
164
|
-
//
|
|
165
|
-
|
|
162
|
+
// fingerprint in (see `_elisionFp`). Core and `public/` files are never
|
|
163
|
+
// transformed, so they hash over their bytes alone, which the two
|
|
164
|
+
// exclusions below enforce rather than merely assume. Empty fp leaves an
|
|
165
|
+
// app module's hash at exactly `sha256(bytes)`.
|
|
166
|
+
//
|
|
167
|
+
// Both exclusions are EXPLICIT because the containment test alone admits
|
|
168
|
+
// them, and the intent above is otherwise silently false:
|
|
169
|
+
//
|
|
170
|
+
// - `public/` sits under `_appDir`. Inert while only module specifiers
|
|
171
|
+
// reached here, but `asset()` (#1194) routes public paths through this
|
|
172
|
+
// function.
|
|
173
|
+
// - `_coreDir` sits under `_appDir` too in a REAL install, where it
|
|
174
|
+
// resolves to `<appDir>/node_modules/@webjsdev/core`. It escapes only
|
|
175
|
+
// in this monorepo, where the workspace symlink lands outside, which
|
|
176
|
+
// is exactly why no in-repo test ever caught it.
|
|
177
|
+
//
|
|
178
|
+
// Neither file set is elision-transformed (core is served verbatim by
|
|
179
|
+
// `fileResponse`), so folding the verdict in buys nothing and costs
|
|
180
|
+
// correctness: any deploy that merely flips an UNRELATED component between
|
|
181
|
+
// elidable and interactive would move their urls, re-downloading the whole
|
|
182
|
+
// core runtime plus every marked stylesheet, image, and script even though
|
|
183
|
+
// not one of those bytes changed. That is the precise cost a content hash
|
|
184
|
+
// exists to avoid.
|
|
185
|
+
if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)
|
|
186
|
+
&& !absPath.startsWith(join(_appDir, 'public') + sep)
|
|
187
|
+
&& !(_coreDir && absPath.startsWith(_coreDir + sep))) {
|
|
166
188
|
h.update('\0');
|
|
167
189
|
h.update(_elisionFp);
|
|
168
190
|
}
|
|
@@ -267,6 +289,46 @@ export function withAssetHash(url, basePath = '') {
|
|
|
267
289
|
return `${url}${sepChar}v=${hash}`;
|
|
268
290
|
}
|
|
269
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Resolve one author-marked asset path to its fingerprinted url. This is the
|
|
294
|
+
* body behind the isomorphic `asset()` helper (#1194); `dev.js` installs it
|
|
295
|
+
* via `setAssetUrlProvider` at boot.
|
|
296
|
+
*
|
|
297
|
+
* The `public/` gate is defensive, not decorative. `resolveUrlToFile` maps any
|
|
298
|
+
* same-origin path under the PROJECT ROOT, so an app that passed request- or
|
|
299
|
+
* user-derived data in (`asset(user.avatarPath)`) would otherwise read and
|
|
300
|
+
* publish a content hash for `/.env`, `/db/app.db`, or a `.server.ts`, leaking
|
|
301
|
+
* both that the file exists and a fingerprint of its exact bytes for files the
|
|
302
|
+
* serve path deliberately 404s. Mirror the static-asset route instead: under
|
|
303
|
+
* `public/`, no traversal. Anything else returns unchanged WITHOUT touching
|
|
304
|
+
* the disk, so a rejected path costs nothing and reveals nothing.
|
|
305
|
+
*
|
|
306
|
+
* A fragment is split before hashing. `withAssetHash` appends the query, so
|
|
307
|
+
* passing the whole thing would emit `/x.svg#icon?v=H`: the browser then
|
|
308
|
+
* requests `/x.svg` with no query (losing the immutable caching this exists
|
|
309
|
+
* for) and the fragment becomes `icon?v=H`, matching no element id.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} p the author-supplied path
|
|
312
|
+
* @param {string} [bp] the active base path
|
|
313
|
+
* @returns {string} the fingerprinted url, or `p` unchanged
|
|
314
|
+
*/
|
|
315
|
+
export function resolveAssetUrl(p, bp = '') {
|
|
316
|
+
if (typeof p !== 'string' || p[0] !== '/' || p[1] === '/') return p;
|
|
317
|
+
let probe = p;
|
|
318
|
+
if (bp && probe.startsWith(bp + '/')) probe = probe.slice(bp.length);
|
|
319
|
+
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
|
|
320
|
+
let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
|
|
321
|
+
try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
|
|
322
|
+
if (decoded.includes('..') || !decoded.startsWith('/public/')) return p;
|
|
323
|
+
const h = p.indexOf('#');
|
|
324
|
+
const path = h === -1 ? p : p.slice(0, h);
|
|
325
|
+
const frag = h === -1 ? '' : p.slice(h);
|
|
326
|
+
// An author-supplied query is left alone: it may carry meaning we do not
|
|
327
|
+
// own, and appending would merge with `&`.
|
|
328
|
+
if (path.includes('?')) return p;
|
|
329
|
+
return withAssetHash(path, bp) + frag;
|
|
330
|
+
}
|
|
331
|
+
|
|
270
332
|
/**
|
|
271
333
|
* Static `import` specifier with positional indices. Mirrors module-graph.js's
|
|
272
334
|
* `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
|
package/src/conditional-get.js
CHANGED
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
* uniformly.
|
|
11
11
|
*
|
|
12
12
|
* What is EXCLUDED, and why:
|
|
13
|
-
* - `no-store`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `no-store` responses (the default for a dynamic page), which forbid
|
|
14
|
+
* storage outright, so there is nothing to validate. `private` is NOT
|
|
15
|
+
* excluded: it forbids SHARED storage, not validation, and the ETag hashes
|
|
16
|
+
* THIS response's body, so two users with different bodies get different
|
|
17
|
+
* ETags and neither can match the other's, while two users with identical
|
|
18
|
+
* bodies are asking about identical bytes, where a 304 discloses nothing
|
|
19
|
+
* (#1140).
|
|
20
|
+
* That is what lets the client router's partial responses stay `private`
|
|
21
|
+
* without losing their 304s.
|
|
16
22
|
* - Any body the framework did not positively mark as fully buffered. The
|
|
17
23
|
* funnel only hashes a response that ALREADY carries an `ETag` (a serve
|
|
18
24
|
* branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
|
|
@@ -77,11 +83,28 @@ const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
|
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
85
|
* Is this response cacheable enough to carry a validator? Cacheable means a
|
|
80
|
-
* 200 whose `Cache-Control` is present and does not forbid
|
|
81
|
-
* `no-store`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
86
|
+
* 200 whose `Cache-Control` is present and does not forbid STORAGE. Only
|
|
87
|
+
* `no-store` is excluded. `no-cache` is INCLUDED: it means "revalidate before
|
|
88
|
+
* reuse", and a 304 is exactly that revalidation answer, so dev's `no-cache`
|
|
89
|
+
* assets still benefit.
|
|
90
|
+
*
|
|
91
|
+
* `private` is INCLUDED too (#1140). It was excluded on the theory that
|
|
92
|
+
* per-user content must never get a "cross-session" 304, but that cannot
|
|
93
|
+
* happen: the ETag is a hash of THIS response's body, and a 304 is returned
|
|
94
|
+
* only when the client's own `If-None-Match` matches it. Two users with
|
|
95
|
+
* different bodies get different ETags, so neither can ever match the other's;
|
|
96
|
+
* two users with identical bodies are asking about identical bytes, where a
|
|
97
|
+
* 304 is the correct answer and carries no body either way. `private` already
|
|
98
|
+
* forbids the one real hazard, a SHARED cache storing the response.
|
|
99
|
+
*
|
|
100
|
+
* Widening this is what makes #1140's fragment downgrade safe to ship. Before
|
|
101
|
+
* that change a reduced fragment INHERITED the page's public header, so it
|
|
102
|
+
* already passed this check and already carried a validator; marking fragments
|
|
103
|
+
* `private` would have taken that away, and since the router fetches them with
|
|
104
|
+
* `cache: 'no-cache'` (#1131), every prefetch and soft navigation would have
|
|
105
|
+
* re-downloaded the whole fragment. The exclusion is dropped rather than
|
|
106
|
+
* special-cased because it was never justified on its own terms either, per
|
|
107
|
+
* the reasoning above.
|
|
85
108
|
*
|
|
86
109
|
* @param {Response} res
|
|
87
110
|
* @returns {boolean}
|
|
@@ -90,7 +113,7 @@ function isCacheable(res) {
|
|
|
90
113
|
if (res.status !== 200) return false;
|
|
91
114
|
const cc = res.headers.get('cache-control');
|
|
92
115
|
if (!cc) return false;
|
|
93
|
-
return !/(?:^|,)\s*
|
|
116
|
+
return !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cc);
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
/**
|
package/src/dev.js
CHANGED
|
@@ -93,7 +93,8 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
|
|
|
93
93
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
94
94
|
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
95
95
|
import { readAllowedOrigins } from './csrf.js';
|
|
96
|
-
import {
|
|
96
|
+
import { setAssetUrlProvider } from '@webjsdev/core';
|
|
97
|
+
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
|
|
97
98
|
import { urlFromRequest } from './forwarded.js';
|
|
98
99
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
99
100
|
import {
|
|
@@ -624,6 +625,21 @@ export async function createRequestHandler(opts) {
|
|
|
624
625
|
// id is a stable deploy fingerprint independent of per-file content hashes.
|
|
625
626
|
setAssetRoots({ appDir, coreDir, enabled: !dev });
|
|
626
627
|
|
|
628
|
+
// Install the resolver behind the isomorphic `asset()` helper (#1194), the
|
|
629
|
+
// same provider seam `cspNonce()` uses. An author writes
|
|
630
|
+
// `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
|
|
631
|
+
// server; the browser has no provider and returns the path unchanged.
|
|
632
|
+
//
|
|
633
|
+
// The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
|
|
634
|
+
// maps any same-origin path under the PROJECT ROOT, so an app that passed
|
|
635
|
+
// request- or user-derived data here (`asset(user.avatarPath)`) would
|
|
636
|
+
// otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
|
|
637
|
+
// `.server.ts`, leaking both existence and a fingerprint of the bytes for
|
|
638
|
+
// files the serve path deliberately 404s. Mirror the static-asset route:
|
|
639
|
+
// under `public/`, no traversal. Everything else returns unchanged, having
|
|
640
|
+
// never touched the disk.
|
|
641
|
+
setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
|
|
642
|
+
|
|
627
643
|
// SSR action-result seeding (#472). Install the process-global module load
|
|
628
644
|
// hook NOW, at boot, before any `'use server'` action module is imported (ESM
|
|
629
645
|
// caches by URL, so a module loaded before the hook would never be faceted).
|
|
@@ -1263,8 +1279,9 @@ export async function createRequestHandler(opts) {
|
|
|
1263
1279
|
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
1264
1280
|
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
1265
1281
|
// 304 carries the validators a shared cache and the client router need.
|
|
1266
|
-
// A no-store
|
|
1267
|
-
//
|
|
1282
|
+
// A no-store response, a non-GET/HEAD, and a streaming Suspense body are
|
|
1283
|
+
// all skipped (see conditional-get.js). A `private` response is NOT
|
|
1284
|
+
// skipped: private forbids shared storage, not validation (#1140). Logged with the final
|
|
1268
1285
|
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
1269
1286
|
let conditioned = merged;
|
|
1270
1287
|
try {
|
|
@@ -2259,21 +2276,40 @@ async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
2259
2276
|
}
|
|
2260
2277
|
|
|
2261
2278
|
/**
|
|
2262
|
-
*
|
|
2279
|
+
* Root-middleware filename candidates, in resolution order.
|
|
2280
|
+
*
|
|
2281
|
+
* Every other routing convention accepts all four extensions (the router
|
|
2282
|
+
* matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
|
|
2283
|
+
* and both the scaffold and the dev supervisor write / watch `middleware.ts`.
|
|
2284
|
+
* This lookup used to be the single literal `middleware.js`, so a root
|
|
2285
|
+
* `middleware.ts` was silently never loaded: no error, no warning, the app
|
|
2286
|
+
* just ran with no global middleware. TypeScript is the documented default
|
|
2287
|
+
* for an app, so that was the common case, and it went unnoticed because the
|
|
2288
|
+
* failure is invisible (a missing middleware looks exactly like an app that
|
|
2289
|
+
* has none). `.ts` is tried first to match the dev supervisor's order.
|
|
2290
|
+
*/
|
|
2291
|
+
const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* Load the optional top-level `middleware.{ts,js,mts,mjs}`.
|
|
2263
2295
|
* @param {string} appDir
|
|
2264
2296
|
* @param {boolean} dev
|
|
2265
2297
|
* @param {import('./logger.js').Logger} logger
|
|
2266
2298
|
*/
|
|
2267
2299
|
async function loadMiddleware(appDir, dev, logger) {
|
|
2268
|
-
|
|
2269
|
-
|
|
2300
|
+
let file = null;
|
|
2301
|
+
for (const name of ROOT_MIDDLEWARE_FILES) {
|
|
2302
|
+
const candidate = join(appDir, name);
|
|
2303
|
+
if (await exists(candidate)) { file = candidate; break; }
|
|
2304
|
+
}
|
|
2305
|
+
if (!file) return null;
|
|
2270
2306
|
const url = pathToFileURL(file).toString();
|
|
2271
2307
|
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
2272
2308
|
try {
|
|
2273
2309
|
const mod = await import(url + bust);
|
|
2274
2310
|
return typeof mod.default === 'function' ? mod.default : null;
|
|
2275
2311
|
} catch (e) {
|
|
2276
|
-
logger.error('failed to load middleware
|
|
2312
|
+
logger.error('failed to load root middleware', { file, err: String(e) });
|
|
2277
2313
|
return null;
|
|
2278
2314
|
}
|
|
2279
2315
|
}
|
|
@@ -2575,7 +2611,7 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
|
|
|
2575
2611
|
`Replace enum / namespace / parameter-property / legacy-decorator / ` +
|
|
2576
2612
|
`import = require constructs with their erasable equivalents. ` +
|
|
2577
2613
|
`Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
|
|
2578
|
-
`Docs: https://
|
|
2614
|
+
`Docs: https://webjs.dev/docs/typescript`
|
|
2579
2615
|
// Prod: terse, no path leak, no Node-message leak (Node's
|
|
2580
2616
|
// message can include source snippets). Operators get the
|
|
2581
2617
|
// detail in server logs above.
|
package/src/forwarded.js
CHANGED
|
@@ -32,23 +32,178 @@
|
|
|
32
32
|
* @returns {URL}
|
|
33
33
|
*/
|
|
34
34
|
export function urlFromRequest(req) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]);
|
|
36
|
+
const rawHost = /** @type {string|undefined} */ (req.headers.host) || '';
|
|
37
|
+
const u = resolveOrigin(proto || 'http', host, rawHost);
|
|
38
|
+
// Assign the request target's parts rather than RESOLVING it against `u`.
|
|
39
|
+
// Resolving is unsafe: a request line may carry `//evil.com/x`, a
|
|
40
|
+
// scheme-relative reference, so `new URL('//evil.com/x', 'https://real-host')`
|
|
41
|
+
// resolves to `https://evil.com/x`, handing over the origin AND silently
|
|
42
|
+
// rewriting the path to `/x` so a different route matches. A request target
|
|
43
|
+
// is a path, never a full reference, so it is assigned as one.
|
|
44
|
+
const m = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(req.url || '/');
|
|
45
|
+
u.pathname = (m && m[1]) || '/';
|
|
46
|
+
u.search = (m && m[2]) || '';
|
|
47
|
+
u.hash = (m && m[3]) || '';
|
|
48
|
+
return u;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the origin both entry points agree on, as an origin-only URL.
|
|
53
|
+
*
|
|
54
|
+
* Sharing this is what keeps the ORIGIN decision identical across the two
|
|
55
|
+
* shells rather than a coincidence of two similar-looking expressions. The
|
|
56
|
+
* layering matters: start from a known-good placeholder, apply the raw `Host`,
|
|
57
|
+
* then let the forwarded host override it. Each assignment goes through
|
|
58
|
+
* `setHost`, which ignores a value it cannot parse, so a junk header falls back
|
|
59
|
+
* to the previous layer rather than 500ing the request or collapsing to
|
|
60
|
+
* localhost.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} proto http / https. `normalizeProto` guarantees it for a
|
|
63
|
+
* forwarded value; the callers' fallbacks pass the url's own scheme, which is
|
|
64
|
+
* http or https for any request a listener shell can receive.
|
|
65
|
+
* @param {string | null} forwardedHost
|
|
66
|
+
* @param {string} rawHost the `Host` header
|
|
67
|
+
* @returns {URL} an origin-only URL
|
|
68
|
+
*/
|
|
69
|
+
function resolveOrigin(proto, forwardedHost, rawHost) {
|
|
70
|
+
const u = new URL(`${proto}://localhost`);
|
|
71
|
+
if (rawHost) setHost(u, rawHost);
|
|
72
|
+
if (forwardedHost) setHost(u, forwardedHost);
|
|
73
|
+
return u;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Assign an authority, clearing any port the previous layer left behind.
|
|
78
|
+
*
|
|
79
|
+
* The `host` setter only updates the port when the new value CARRIES one, so
|
|
80
|
+
* layering `X-Forwarded-Host: docs.webjs.dev` over `Host: container:3000`
|
|
81
|
+
* otherwise yields `docs.webjs.dev:3000`: the public hostname wearing the
|
|
82
|
+
* internal port. Clearing first makes each layer a full replacement.
|
|
83
|
+
*
|
|
84
|
+
* @param {URL} u
|
|
85
|
+
* @param {string} value
|
|
86
|
+
*/
|
|
87
|
+
function setHost(u, value) {
|
|
88
|
+
let probe;
|
|
89
|
+
try {
|
|
90
|
+
probe = new URL(`${u.protocol}//${value}`);
|
|
91
|
+
} catch {
|
|
92
|
+
// Not a parseable authority (`a b`, `[`, a port over 65535). Leave the
|
|
93
|
+
// previous layer in place rather than throwing, so a junk header is never a
|
|
94
|
+
// 500 on the fetch path or a failed WS handshake.
|
|
95
|
+
return;
|
|
41
96
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
u.hostname = probe.hostname;
|
|
98
|
+
// Assign the port unconditionally, including the empty string, so a value
|
|
99
|
+
// carrying no port clears one the previous layer left behind.
|
|
100
|
+
u.port = probe.port;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply the forwarded headers to an ALREADY-PARSED url, for a shell whose
|
|
105
|
+
* request is a web `Request` (Bun) rather than a node `IncomingMessage`.
|
|
106
|
+
*
|
|
107
|
+
* `urlFromRequest` above cannot be reused directly: it reads `req.headers` as a
|
|
108
|
+
* plain node object, while a web `Request` exposes a `Headers` instance whose
|
|
109
|
+
* values come from `.get()`. Reusing it against a `Request` silently reads
|
|
110
|
+
* `undefined` for every header, so it LOOKS wired up while changing nothing.
|
|
111
|
+
* Both entry points funnel through `readForwarded` (the trust switch, the
|
|
112
|
+
* allowed schemes, the comma-chain rule) and `resolveOrigin` (the host
|
|
113
|
+
* layering), so the HEADER and ORIGIN decisions cannot drift. What each shell
|
|
114
|
+
* RECEIVES still differs: node gets an origin-form request target it treats as
|
|
115
|
+
* a path, Bun gets a url its own parser already resolved, so an absolute-form
|
|
116
|
+
* request line routes differently between them. The security-relevant part, the
|
|
117
|
+
* origin, is decided here for both.
|
|
118
|
+
*
|
|
119
|
+
* Returns the SAME URL instance when nothing changes, so the caller can use
|
|
120
|
+
* identity to skip rebuilding a request on the hot path. That fast path also
|
|
121
|
+
* requires the url's own authority to already AGREE with the `Host` header:
|
|
122
|
+
* `Bun.serve` reports an absolute-form request line (`GET http://evil/x`) as
|
|
123
|
+
* the request's url, so returning early on "no forwarded headers" would let an
|
|
124
|
+
* unproxied app hand a client full control of `ctx.url.origin`. When they
|
|
125
|
+
* disagree the origin is rebuilt from `Host` and the client's authority is
|
|
126
|
+
* discarded. A normal request has `url.host === Host`, so the optimization is
|
|
127
|
+
* intact for every real request.
|
|
128
|
+
*
|
|
129
|
+
* The origin is rebuilt through the shared `resolveOrigin`, and the path parts
|
|
130
|
+
* are COPIED onto it rather than re-parsed against it. Re-parsing is unsafe:
|
|
131
|
+
* `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
|
|
132
|
+
* reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
|
|
133
|
+
* `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
|
|
134
|
+
* TLS-terminating proxy already sets to take over the origin AND silently
|
|
135
|
+
* change which route matched.
|
|
136
|
+
*
|
|
137
|
+
* The host FALLBACK is the `Host` header, not `url.host`, to match
|
|
138
|
+
* `urlFromRequest` exactly: node builds from the raw header string, so with
|
|
139
|
+
* `Host: webjs.dev:80` + `X-Forwarded-Proto: https` it judges the `:80` against
|
|
140
|
+
* https and keeps it. Falling back to the already-normalized `url.host` dropped
|
|
141
|
+
* it (port 80 is http's default), which made the two shells disagree on the
|
|
142
|
+
* exact proto-only shape this helper exists for.
|
|
143
|
+
*
|
|
144
|
+
* @param {URL} url the url as the local listener saw it
|
|
145
|
+
* @param {Headers} headers the web `Request` headers
|
|
146
|
+
* @returns {URL} the corrected url, or `url` itself when unchanged
|
|
147
|
+
*/
|
|
148
|
+
export function applyForwarded(url, headers) {
|
|
149
|
+
const { host, proto } = readForwarded((n) => headers.get(n));
|
|
150
|
+
const rawHost = headers.get('host') || url.host;
|
|
151
|
+
if (!host && !proto && url.host === rawHost) return url;
|
|
152
|
+
// `url.protocol` carries its trailing colon; the forwarded header does not.
|
|
153
|
+
const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, rawHost);
|
|
154
|
+
if (origin.host === url.host && origin.protocol === url.protocol) return url;
|
|
155
|
+
// Copy the path across verbatim. Never re-parse it against the new origin:
|
|
156
|
+
// `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
|
|
157
|
+
// reference, so resolving would resolve the authority out of the PATH and
|
|
158
|
+
// hand an attacker the origin (plus a different matched route) using only the
|
|
159
|
+
// `X-Forwarded-Proto` every TLS-terminating proxy already sets.
|
|
160
|
+
origin.pathname = url.pathname;
|
|
161
|
+
origin.search = url.search;
|
|
162
|
+
origin.hash = url.hash;
|
|
163
|
+
return origin;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read the forwarded host / proto through a header getter, honoring the
|
|
168
|
+
* `WEBJS_NO_TRUST_PROXY=1` opt-out. The one place the trust decision and the
|
|
169
|
+
* comma-chain rule live, shared by the node and Bun entry points above.
|
|
170
|
+
*
|
|
171
|
+
* @param {(name: string) => string | string[] | undefined | null} getHeader
|
|
172
|
+
* @returns {{ host: string | null, proto: string | null }}
|
|
173
|
+
*/
|
|
174
|
+
function readForwarded(getHeader) {
|
|
175
|
+
if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
|
|
176
|
+
return {
|
|
177
|
+
host: firstHeaderValue(getHeader('x-forwarded-host')),
|
|
178
|
+
proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Accept only `http` / `https` as a forwarded scheme (case-insensitively; the
|
|
184
|
+
* URL normal form is lowercase, and comparing a raw `HTTPS` against
|
|
185
|
+
* `url.protocol` otherwise misses the no-op case).
|
|
186
|
+
*
|
|
187
|
+
* Anything else is dropped rather than honored. A non-special scheme is the
|
|
188
|
+
* damaging shape: `X-Forwarded-Proto: javascript` produced
|
|
189
|
+
* `javascript://host/path`, whose `origin` is the literal string `null`, so
|
|
190
|
+
* every absolute URL the app derived became `null/...`. A proxy in front of an
|
|
191
|
+
* HTTP server only ever forwards http or https, so an allowlist costs nothing.
|
|
192
|
+
*
|
|
193
|
+
* @param {string | null} p
|
|
194
|
+
* @returns {string | null}
|
|
195
|
+
*/
|
|
196
|
+
function normalizeProto(p) {
|
|
197
|
+
if (!p) return null;
|
|
198
|
+
const v = p.toLowerCase();
|
|
199
|
+
return v === 'http' || v === 'https' ? v : null;
|
|
45
200
|
}
|
|
46
201
|
|
|
47
202
|
/**
|
|
48
203
|
* Pick the first comma-separated value from a header that may be a
|
|
49
204
|
* string, an array of strings, or undefined.
|
|
50
205
|
*
|
|
51
|
-
* @param {string | string[] | undefined} h
|
|
206
|
+
* @param {string | string[] | undefined | null} h
|
|
52
207
|
* @returns {string | null}
|
|
53
208
|
*/
|
|
54
209
|
function firstHeaderValue(h) {
|
package/src/listener-bun.js
CHANGED
|
@@ -45,7 +45,8 @@ import { EventEmitter } from 'node:events';
|
|
|
45
45
|
import { Readable, pipeline } from 'node:stream';
|
|
46
46
|
import { matchApi } from './router.js';
|
|
47
47
|
import { registerClient } from './broadcast.js';
|
|
48
|
-
import { setTrustedRemoteIp } from './rate-limit.js';
|
|
48
|
+
import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
|
|
49
|
+
import { applyForwarded } from './forwarded.js';
|
|
49
50
|
import {
|
|
50
51
|
isCompressible,
|
|
51
52
|
isEventsPath,
|
|
@@ -104,7 +105,7 @@ export function startBunListener(ctx) {
|
|
|
104
105
|
// head-start during SSR compute, not the preloads themselves.
|
|
105
106
|
|
|
106
107
|
stampRemoteIp(req, srv);
|
|
107
|
-
const resp = await app.handle(req);
|
|
108
|
+
const resp = await app.handle(forwardedRequest(req, url));
|
|
108
109
|
return compress ? await maybeCompress(resp, req) : resp;
|
|
109
110
|
} catch (e) {
|
|
110
111
|
logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
|
|
@@ -237,7 +238,11 @@ async function bunUpgrade(req, srv, ctx) {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
const wrapper = new BunWsAdapter();
|
|
240
|
-
|
|
241
|
+
// Behind a proxy the handshake arrives as plain http on the internal hop, so
|
|
242
|
+
// the handler request carries the ORIGINAL scheme + host too (the node WS path
|
|
243
|
+
// gets this from `urlFromRequest` in `buildRequestFromUpgrade`). Routing above
|
|
244
|
+
// keyed on `url.pathname`, which the origin swap leaves untouched.
|
|
245
|
+
const handlerReq = upgradeRequest(req, applyForwarded(url, req.headers));
|
|
241
246
|
// Stamp the framework-trusted socket IP on the handler request so a `WS`
|
|
242
247
|
// handler's `clientIp(req)` returns the real peer, not a spoofed inbound
|
|
243
248
|
// `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
|
|
@@ -317,6 +322,78 @@ function bunSseResponse(req, hub, app) {
|
|
|
317
322
|
});
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Rebuild the request behind a TLS-terminating reverse proxy so `req.url`
|
|
327
|
+
* carries the ORIGINAL scheme + host (`X-Forwarded-Proto` / `X-Forwarded-Host`).
|
|
328
|
+
*
|
|
329
|
+
* Why the request itself and not just a threaded url: everything downstream
|
|
330
|
+
* (the framework's own `new URL(req.url)` reads AND a user's `route.ts` doing
|
|
331
|
+
* `new URL(req.url).origin`) derives from `req.url`, so correcting only a side
|
|
332
|
+
* channel would fix the framework's absolute URLs and leave app code broken.
|
|
333
|
+
* The node shell gets this for free, since `toWebRequest` builds its `Request`
|
|
334
|
+
* FROM the already-corrected `urlFromRequest(req)`; Bun hands us a `Request`
|
|
335
|
+
* whose url reflects the internal `http://container` view, so the correction
|
|
336
|
+
* has to be applied here for the two shells to agree.
|
|
337
|
+
*
|
|
338
|
+
* Hot path: `applyForwarded` returns the SAME url instance when nothing
|
|
339
|
+
* changes, so an app with no proxy in front (local dev, direct exposure,
|
|
340
|
+
* `WEBJS_NO_TRUST_PROXY=1`) does ZERO extra work and keeps the #756 no-clone
|
|
341
|
+
* behaviour. Behind a proxy this costs one `Request` construction per request,
|
|
342
|
+
* which is still strictly less than the node shell, which constructs one
|
|
343
|
+
* unconditionally. The trusted IP is carried across the rebuild the same way
|
|
344
|
+
* the base-path rewrite in `dev.js` does it (#773), so `clientIp` stays
|
|
345
|
+
* authoritative and the inbound spoofable header is never consulted.
|
|
346
|
+
*
|
|
347
|
+
* @param {Request} req
|
|
348
|
+
* @param {URL} url the url as `Bun.serve` saw it
|
|
349
|
+
* @returns {Request} `req` itself when unchanged, else the corrected request
|
|
350
|
+
*/
|
|
351
|
+
function forwardedRequest(req, url) {
|
|
352
|
+
const fwd = applyForwarded(url, req.headers);
|
|
353
|
+
if (fwd === url) return req;
|
|
354
|
+
// SECURITY (#778): drop any client-supplied `x-webjs-remote-ip` before it
|
|
355
|
+
// rides onto the rebuilt request. `trustedRemoteIp` reads the WeakMap first,
|
|
356
|
+
// so the header cannot win today, but every other rebuild site strips it
|
|
357
|
+
// (`dev.js`'s base-path rewrite, `upgradeRequest` below) and leaving it
|
|
358
|
+
// attached would re-arm the spoof the moment any later code path rebuilds
|
|
359
|
+
// this request without carrying the WeakMap across.
|
|
360
|
+
const headers = new Headers(req.headers);
|
|
361
|
+
headers.delete('x-webjs-remote-ip');
|
|
362
|
+
const next = new Request(fwd, { ...requestInitFrom(req), headers });
|
|
363
|
+
propagateTrustedRemoteIp(req, next);
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The parts of a `Request` that must survive the rebuild, spelled out because
|
|
369
|
+
* the header strip above needs its own `Headers` and passing the `Request` as
|
|
370
|
+
* the init would ignore them.
|
|
371
|
+
*
|
|
372
|
+
* `duplex: 'half'` is required by the spec whenever a body is present, and
|
|
373
|
+
* `mode: 'navigate'` cannot be passed to the constructor, so it is dropped.
|
|
374
|
+
*
|
|
375
|
+
* @param {Request} req
|
|
376
|
+
*/
|
|
377
|
+
function requestInitFrom(req) {
|
|
378
|
+
/** @type {any} */
|
|
379
|
+
const init = {
|
|
380
|
+
method: req.method,
|
|
381
|
+
signal: req.signal,
|
|
382
|
+
credentials: req.credentials,
|
|
383
|
+
cache: req.cache,
|
|
384
|
+
redirect: req.redirect,
|
|
385
|
+
referrer: req.referrer,
|
|
386
|
+
referrerPolicy: req.referrerPolicy,
|
|
387
|
+
integrity: req.integrity,
|
|
388
|
+
};
|
|
389
|
+
if (req.mode && req.mode !== 'navigate') init.mode = req.mode;
|
|
390
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
391
|
+
init.body = req.body;
|
|
392
|
+
init.duplex = 'half';
|
|
393
|
+
}
|
|
394
|
+
return init;
|
|
395
|
+
}
|
|
396
|
+
|
|
320
397
|
/**
|
|
321
398
|
* Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
|
|
322
399
|
* out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
|
package/src/ssr.js
CHANGED
|
@@ -164,6 +164,11 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
164
164
|
frameRes.headers.append('vary', 'X-Webjs-Frame');
|
|
165
165
|
// #1009: a subtree sliced from a REDUCED render inherits its variance.
|
|
166
166
|
if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
|
|
167
|
+
// No privateFragment call here on purpose: this response is built
|
|
168
|
+
// WITHOUT page metadata, so it is already `no-store` and the call
|
|
169
|
+
// would be dead code with nothing to assert against. The property is
|
|
170
|
+
// locked by a test instead, which fails if the frame response ever
|
|
171
|
+
// starts carrying the page's cacheControl.
|
|
167
172
|
return frameRes;
|
|
168
173
|
}
|
|
169
174
|
}
|
|
@@ -304,15 +309,25 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
304
309
|
);
|
|
305
310
|
// REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
|
|
306
311
|
// outer-layout chrome, so these bytes are only valid for a request that
|
|
307
|
-
// sent a matching `have`.
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
312
|
+
// sent a matching `have`. Left shared-cacheable, a CDN edge could store the
|
|
313
|
+
// reduced body under the URL and serve a chrome-less fragment to a fresh
|
|
314
|
+
// full-page navigation (measured live: GET / was 73,534 bytes, GET / + have
|
|
315
|
+
// was 57,035, byte-identical headers otherwise).
|
|
316
|
+
//
|
|
317
|
+
// TWO markings, and the order of trust matters (#1140). `privateFragment`
|
|
318
|
+
// is the guarantee: it forbids SHARED storage outright, so no CDN can hold
|
|
319
|
+
// this body at all. `Vary` is belt-and-braces for caches that honour it,
|
|
320
|
+
// NOT the protection, because Cloudflare and others honour only
|
|
321
|
+
// `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a
|
|
322
|
+
// normal page's headers and cache key are unchanged.
|
|
323
|
+
//
|
|
324
|
+
// The internal #241 revalidate cache is already safe by construction:
|
|
325
|
+
// `cacheEligible` excludes any request that carries x-webjs-have, so a
|
|
326
|
+
// reduced body is never stored under the URL-only key.
|
|
327
|
+
if (reduced) {
|
|
328
|
+
res.headers.append('vary', 'X-Webjs-Have');
|
|
329
|
+
privateFragment(res);
|
|
330
|
+
}
|
|
316
331
|
// Server HTML cache write (#241). The page opted in via `revalidate`, so
|
|
317
332
|
// FLAG this candidate for the response funnel rather than writing here: the
|
|
318
333
|
// store decision must see the FINAL response (after segment middleware,
|
|
@@ -465,6 +480,82 @@ export async function ssrUnauthorized(route, opts) {
|
|
|
465
480
|
return htmlResponse(html, 401, opts.req, opts.url);
|
|
466
481
|
}
|
|
467
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Downgrade a PARTIAL response so no shared cache can store it (#1140).
|
|
485
|
+
*
|
|
486
|
+
* A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid
|
|
487
|
+
* only for a client that sent it. It is marked `Vary` for that header, but
|
|
488
|
+
* `Vary` is not a guarantee in practice: Cloudflare honours only
|
|
489
|
+
* `Accept-Encoding` and several other shared caches are just as selective.
|
|
490
|
+
* Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page
|
|
491
|
+
* that opted into public caching was handing CDNs a chrome-less fragment under
|
|
492
|
+
* the full page's URL, to be served to whoever navigated there next. Measured
|
|
493
|
+
* on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment,
|
|
494
|
+
* identical `Cache-Control` on both.
|
|
495
|
+
*
|
|
496
|
+
* The reduced path is the ONLY caller. A `<webjs-frame>` subtree is sliced by a
|
|
497
|
+
* request header too, but its response is built without page metadata, so it is
|
|
498
|
+
* already `no-store` and needs no downgrade; that property is locked by a test
|
|
499
|
+
* rather than by a call here. A NEW partial-response shape does not get this
|
|
500
|
+
* treatment for free: call this from its response site.
|
|
501
|
+
*
|
|
502
|
+
* `private` fixes that at the source instead of relying on `Vary` being
|
|
503
|
+
* respected: no shared cache may store it, while the browser that asked for it
|
|
504
|
+
* still may. The freshness directives are preserved, so a returning client
|
|
505
|
+
* keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces
|
|
506
|
+
* for caches that do honour it.
|
|
507
|
+
*
|
|
508
|
+
* The response KEEPS its validator. `private` forbids shared storage, not
|
|
509
|
+
* validation, so the funnel still attaches an ETag (see conditional-get.js,
|
|
510
|
+
* which stopped excluding `private` for exactly this reason). That matters:
|
|
511
|
+
* the router fetches partials with `cache: 'no-cache'` (#1131), so without a
|
|
512
|
+
* validator every prefetch and soft navigation would re-download the whole
|
|
513
|
+
* fragment instead of being answered 304.
|
|
514
|
+
*
|
|
515
|
+
* @param {Response} res
|
|
516
|
+
*/
|
|
517
|
+
export function privateFragment(res) {
|
|
518
|
+
const cc = res.headers.get('cache-control') || '';
|
|
519
|
+
// No header at all is not "nothing to do": a response with no Cache-Control
|
|
520
|
+
// is heuristically storable by a shared cache (RFC 9111), which is precisely
|
|
521
|
+
// the hazard here. Fail CLOSED.
|
|
522
|
+
if (!cc) {
|
|
523
|
+
res.headers.set('cache-control', 'private');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Split on commas that are NOT inside a quoted argument: a directive may
|
|
527
|
+
// carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive
|
|
528
|
+
// split tears those apart.
|
|
529
|
+
const directives = [];
|
|
530
|
+
let buf = '';
|
|
531
|
+
let inQuotes = false;
|
|
532
|
+
for (const ch of cc) {
|
|
533
|
+
if (ch === '"') inQuotes = !inQuotes;
|
|
534
|
+
if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; }
|
|
535
|
+
buf += ch;
|
|
536
|
+
}
|
|
537
|
+
directives.push(buf.trim());
|
|
538
|
+
|
|
539
|
+
// `name` is everything before the first `=`, trimmed, so `s-maxage = 600`
|
|
540
|
+
// and `private="x-user"` are recognised as the directives they are.
|
|
541
|
+
const nameOf = (d) => d.split('=')[0].trim().toLowerCase();
|
|
542
|
+
// Already unshareable: leave it exactly as the author wrote it. Only a BARE
|
|
543
|
+
// `private` counts. The qualified form (`private="x-user"`) marks just the
|
|
544
|
+
// NAMED header fields private per RFC 9111, leaving the response itself
|
|
545
|
+
// storable by a shared cache, so it must still be downgraded.
|
|
546
|
+
const isBare = (d) => !d.includes('=');
|
|
547
|
+
if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return;
|
|
548
|
+
|
|
549
|
+
// `private` joins the drop list because a bare one is prepended below: a
|
|
550
|
+
// qualified `private="x-user"` left in place would emit the directive twice,
|
|
551
|
+
// and a cache that resolves a repeat by taking the last occurrence would read
|
|
552
|
+
// the qualified form, which per RFC 9111 leaves the response shared-storable.
|
|
553
|
+
const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']);
|
|
554
|
+
const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d)));
|
|
555
|
+
kept.unshift('private');
|
|
556
|
+
res.headers.set('cache-control', kept.join(', '));
|
|
557
|
+
}
|
|
558
|
+
|
|
468
559
|
/**
|
|
469
560
|
* Build an HTML Response. Sets no cookie: action CSRF is an Origin /
|
|
470
561
|
* Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
|
|
@@ -476,8 +567,10 @@ export async function ssrUnauthorized(route, opts) {
|
|
|
476
567
|
*/
|
|
477
568
|
function htmlResponse(html, status, req, url, metadata) {
|
|
478
569
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
479
|
-
// Default: no caching. Pages are dynamic by default: the developer
|
|
480
|
-
//
|
|
570
|
+
// Default: no caching. Pages are dynamic by default: the developer opts in
|
|
571
|
+
// explicitly via metadata.cacheControl. No non-200 guard here, unlike
|
|
572
|
+
// streamingHtmlResponse: every caller of THIS builder passes no metadata, so
|
|
573
|
+
// the value is already the no-store default and a guard would be dead code.
|
|
481
574
|
headers.set('cache-control', metadata?.cacheControl || 'no-store');
|
|
482
575
|
// X-Webjs-Build carries the published build id so the client
|
|
483
576
|
// router can detect post-deploy importmap changes on EVERY
|
|
@@ -487,10 +580,11 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
487
580
|
// applySwap and publishedBuildId() in importmap.js.
|
|
488
581
|
headers.set('x-webjs-build', publishedBuildId());
|
|
489
582
|
headers.set('x-webjs-src', appSourceId());
|
|
490
|
-
// Buffered (string) body: opt into the conditional-GET funnel
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
583
|
+
// Buffered (string) body: opt into the conditional-GET funnel.
|
|
584
|
+
// A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The
|
|
585
|
+
// funnel excludes only the no-store default; a `private` page IS validated,
|
|
586
|
+
// which is what keeps the router's partial responses cheap (#1140).
|
|
587
|
+
// See conditional-get.js.
|
|
494
588
|
headers.set(BUFFERED_MARKER, '1');
|
|
495
589
|
return new Response(html, { status, headers });
|
|
496
590
|
}
|
|
@@ -650,8 +744,10 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
|
|
|
650
744
|
const body = await renderToString(tree, { ssr: true, suspenseCtx });
|
|
651
745
|
// REDUCED response (#1009): the outer layouts were skipped, so these
|
|
652
746
|
// bytes are only valid for a client that already HAS them. The caller
|
|
653
|
-
// marks the response `
|
|
654
|
-
//
|
|
747
|
+
// marks the response `private` so no shared cache can store it, and
|
|
748
|
+
// additionally `Vary: X-Webjs-Have` for caches that honour it. The
|
|
749
|
+
// `private` is the guarantee, not the Vary: Cloudflare and others honour
|
|
750
|
+
// only `Accept-Encoding` (#1140).
|
|
655
751
|
return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
|
|
656
752
|
}
|
|
657
753
|
const mod = await loadModule(route.layouts[i], dev);
|
|
@@ -1955,8 +2051,11 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1955
2051
|
const encoder = new TextEncoder();
|
|
1956
2052
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
1957
2053
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
1958
|
-
// opts in to caching explicitly via metadata.cacheControl.
|
|
1959
|
-
|
|
2054
|
+
// opts in to caching explicitly via metadata.cacheControl. A non-200 does
|
|
2055
|
+
// NOT inherit it (#1140): the page-action re-render is a 422 carrying the
|
|
2056
|
+
// submitter's own field values and errors, which must never be handed to a
|
|
2057
|
+
// shared cache just because the page opted into public caching.
|
|
2058
|
+
headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
|
|
1960
2059
|
// See htmlResponse: published build id on every response for the
|
|
1961
2060
|
// client router's importmap-mismatch detection on partial swaps.
|
|
1962
2061
|
headers.set('x-webjs-build', publishedBuildId());
|