@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server HTML response cache with TTL and on-demand revalidation: the
|
|
3
|
+
* no-build equivalent of Next.js's Full Route Cache + ISR.
|
|
4
|
+
*
|
|
5
|
+
* A fully-static / inert route re-runs the entire SSR pipeline (layout
|
|
6
|
+
* chain, renderToString, metadata merge, importmap splice) on every
|
|
7
|
+
* request even though it proves identical HTML each time. This module
|
|
8
|
+
* caches the rendered HTML in the existing pluggable store
|
|
9
|
+
* (`getStore()` / `memoryStore` in dev, `redisStore` when configured)
|
|
10
|
+
* under a namespaced key, and serves it WITHOUT re-running the page
|
|
11
|
+
* function on a hit within the revalidation window.
|
|
12
|
+
*
|
|
13
|
+
* SAFETY: caching is OPT-IN and conservative. A wrongly-cached per-user
|
|
14
|
+
* page served to the wrong visitor is a data leak, so the page author
|
|
15
|
+
* MUST opt in by declaring a revalidation window, and the framework
|
|
16
|
+
* applies several defense-in-depth guards before it stores anything.
|
|
17
|
+
*
|
|
18
|
+
* The opt-in trigger is `export const revalidate = N` (seconds) on the
|
|
19
|
+
* page module (the Next idiom, read once cheaply before the cache lookup).
|
|
20
|
+
* The contract: declaring `revalidate` is the author asserting "this page
|
|
21
|
+
* is the same for everyone for N seconds". A page that reads `cookies()` /
|
|
22
|
+
* a session (per-user output) MUST NOT set `revalidate`.
|
|
23
|
+
*
|
|
24
|
+
* @module html-cache
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getStore } from './cache.js';
|
|
28
|
+
import { STREAM_MARKER } from './conditional-get.js';
|
|
29
|
+
import { publishedBuildId } from './importmap.js';
|
|
30
|
+
import { dynamicAccessed } from './context.js';
|
|
31
|
+
|
|
32
|
+
/** Namespace prefix for every cached-HTML key, so a flush can target it. */
|
|
33
|
+
const KEY_PREFIX = 'webjs:html:';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Internal response header `ssrPage` stamps on a render that opted into the
|
|
37
|
+
* HTML cache (its value is the revalidate TTL in seconds). The response
|
|
38
|
+
* funnel reads it, re-checks the guards against the FINAL response (after
|
|
39
|
+
* segment middleware, which may have appended a per-user Set-Cookie the SSR
|
|
40
|
+
* side could not see), writes the cache, and strips the marker so it never
|
|
41
|
+
* reaches the client. Mirrors the BUFFERED / STREAM marker pattern.
|
|
42
|
+
*/
|
|
43
|
+
export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generation counter folded into every key namespace. `revalidateAll()`
|
|
47
|
+
* bumps it so every previously-cached HTML entry becomes unreachable in one
|
|
48
|
+
* step (the CacheStore interface has no key-scan primitive, so a global
|
|
49
|
+
* clear cannot enumerate keys), and the store TTL eventually reclaims the
|
|
50
|
+
* orphaned entries. A single process clears its own memory store this way
|
|
51
|
+
* synchronously; a Redis-backed multi-process deploy bumps per process and
|
|
52
|
+
* leans on the TTL for the rest (a best-effort global flush).
|
|
53
|
+
*/
|
|
54
|
+
let _generation = 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the revalidation window (seconds) a page module opted into via
|
|
58
|
+
* `export const revalidate = N` (the Next idiom). Returns a positive finite
|
|
59
|
+
* number of seconds, or `null` when the page did not opt in (the default:
|
|
60
|
+
* no server HTML caching, current behavior).
|
|
61
|
+
*
|
|
62
|
+
* `revalidate = 0`, a negative, NaN, Infinity, or a non-number is treated
|
|
63
|
+
* as "no caching" (opt-out), matching the Next semantics where 0 means
|
|
64
|
+
* always-dynamic. The trigger is the page-module export ONLY (read once,
|
|
65
|
+
* cheaply, before the cache lookup), so a per-user page that never declares
|
|
66
|
+
* it is never cached.
|
|
67
|
+
*
|
|
68
|
+
* @param {Record<string, any> | null | undefined} pageModule
|
|
69
|
+
* @returns {number | null}
|
|
70
|
+
*/
|
|
71
|
+
export function readRevalidate(pageModule) {
|
|
72
|
+
const raw = pageModule ? pageModule.revalidate : undefined;
|
|
73
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null;
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The cache key for a request: the FULL URL (path + search string), since
|
|
79
|
+
* `searchParams` change page output. Normalized to path + sorted query so
|
|
80
|
+
* `?a=1&b=2` and `?b=2&a=1` share an entry. Two more discriminators are
|
|
81
|
+
* folded into the namespace:
|
|
82
|
+
*
|
|
83
|
+
* - the in-process generation, so `revalidateAll()` (a generation bump)
|
|
84
|
+
* makes every prior key unreachable in one step;
|
|
85
|
+
* - the published build id (the importmap fingerprint), so a NEW DEPLOY
|
|
86
|
+
* naturally writes and reads under fresh keys. The cached HTML bakes the
|
|
87
|
+
* deploy's `data-webjs-build` importmap into its boot script, so a Redis
|
|
88
|
+
* store that survives a deploy must NOT let a v2 process serve a v1-body
|
|
89
|
+
* (resolving modules against stale vendor URLs). Folding the build id in
|
|
90
|
+
* means a deploy effectively invalidates all cached HTML for free.
|
|
91
|
+
*
|
|
92
|
+
* @param {URL} url
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function htmlCacheKey(url) {
|
|
96
|
+
const params = [...url.searchParams.entries()].sort(([a], [b]) =>
|
|
97
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
98
|
+
);
|
|
99
|
+
const search = params.length
|
|
100
|
+
? '?' + params.map(([k, v]) => `${k}=${v}`).join('&')
|
|
101
|
+
: '';
|
|
102
|
+
const build = publishedBuildId() || 'nobuild';
|
|
103
|
+
return `${KEY_PREFIX}${build}:${_generation}:${url.pathname}${search}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read a cached HTML entry for a URL. Returns the parsed record (body +
|
|
108
|
+
* the headers needed to faithfully rebuild the response) or null on a
|
|
109
|
+
* miss / expiry / parse error (fail open to a fresh render).
|
|
110
|
+
*
|
|
111
|
+
* @param {URL} url
|
|
112
|
+
* @returns {Promise<{ body: string, contentType: string, cacheControl: string, status: number } | null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function readHtmlCache(url) {
|
|
115
|
+
try {
|
|
116
|
+
const raw = await getStore().get(htmlCacheKey(url));
|
|
117
|
+
if (!raw) return null;
|
|
118
|
+
const rec = JSON.parse(raw);
|
|
119
|
+
if (!rec || typeof rec.body !== 'string') return null;
|
|
120
|
+
return rec;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Store a rendered HTML response for a URL with TTL = revalidate seconds.
|
|
128
|
+
* Best-effort: a store error never affects the live response.
|
|
129
|
+
*
|
|
130
|
+
* @param {URL} url
|
|
131
|
+
* @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
|
|
132
|
+
* @param {number} revalidateSeconds
|
|
133
|
+
*/
|
|
134
|
+
export async function writeHtmlCache(url, rec, revalidateSeconds) {
|
|
135
|
+
try {
|
|
136
|
+
await getStore().set(htmlCacheKey(url), JSON.stringify(rec), revalidateSeconds * 1000);
|
|
137
|
+
} catch {
|
|
138
|
+
/* a store write failure must never crash the response */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Decide whether a freshly-rendered Response is SAFE to cache. This is the
|
|
144
|
+
* defense-in-depth gate that runs AFTER the page opted in via `revalidate`.
|
|
145
|
+
* Returns true only when every guard passes:
|
|
146
|
+
*
|
|
147
|
+
* - status 200 (an error / redirect / 404 is request-specific)
|
|
148
|
+
* - NOT a streamed Suspense body (it cannot be buffered cheaply, and an
|
|
149
|
+
* unflushed stream has no stable bytes to cache)
|
|
150
|
+
* - NO non-framework Set-Cookie. A page that sets a session / per-user
|
|
151
|
+
* cookie is per-user output and must not be shared. The framework's own
|
|
152
|
+
* CSRF cookie (`webjs_csrf`) is allowed and re-minted per response on a
|
|
153
|
+
* cache hit, so its presence does not block caching.
|
|
154
|
+
* - CSP is OFF. With CSP enabled the inline boot script carries a fresh
|
|
155
|
+
* per-request nonce, so the body varies per request and a cached body
|
|
156
|
+
* would replay a stale nonce that the response's CSP header rejects.
|
|
157
|
+
*
|
|
158
|
+
* @param {Response} res
|
|
159
|
+
* @param {{ cspEnabled?: boolean }} [guards]
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
export function isCacheableResponse(res, guards = {}) {
|
|
163
|
+
if (res.status !== 200) return false;
|
|
164
|
+
if (res.headers.has(STREAM_MARKER)) return false;
|
|
165
|
+
if (guards.cspEnabled) return false;
|
|
166
|
+
if (hasNonFrameworkSetCookie(res)) return false;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* True when the response carries a Set-Cookie OTHER than the framework's
|
|
172
|
+
* own CSRF cookie. Reads each cookie individually via `getSetCookie()`, the
|
|
173
|
+
* only correct way to enumerate multiple Set-Cookie values (a combined
|
|
174
|
+
* `get('set-cookie')` cannot be split safely, since a cookie value or an
|
|
175
|
+
* Expires date can contain a comma). When `getSetCookie` is unavailable
|
|
176
|
+
* (a runtime older than Node 24) this FAILS SAFE: it reports a
|
|
177
|
+
* non-framework cookie (do not cache) rather than parsing only the first of
|
|
178
|
+
* a combined header and wrongly judging it framework-only.
|
|
179
|
+
*
|
|
180
|
+
* @param {Response} res
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function hasNonFrameworkSetCookie(res) {
|
|
184
|
+
const h = res.headers;
|
|
185
|
+
if (typeof h.getSetCookie !== 'function') {
|
|
186
|
+
// No reliable per-cookie enumeration: fail safe (treat as per-user).
|
|
187
|
+
return h.has('set-cookie');
|
|
188
|
+
}
|
|
189
|
+
for (const c of h.getSetCookie()) {
|
|
190
|
+
const name = c.split('=', 1)[0].trim().toLowerCase();
|
|
191
|
+
if (name !== 'webjs_csrf') return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Evict the cached HTML for one path (server-side, on-demand
|
|
198
|
+
* revalidation: the no-build ISR revalidation hook). A server action that
|
|
199
|
+
* mutates the data a cached page renders calls this so the next request
|
|
200
|
+
* re-renders. Distinct from the client-side `revalidate()` (which evicts
|
|
201
|
+
* the browser snapshot cache).
|
|
202
|
+
*
|
|
203
|
+
* The path may include a search string. A bare path with no query evicts
|
|
204
|
+
* ONLY the no-query entry; pass the exact `path?query` to target a
|
|
205
|
+
* specific query variant, or call `revalidateAll()` to clear everything.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} path e.g. '/blog' or '/blog?page=2'
|
|
208
|
+
* @returns {Promise<void>}
|
|
209
|
+
*/
|
|
210
|
+
export async function revalidatePath(path) {
|
|
211
|
+
if (typeof path !== 'string' || !path) return;
|
|
212
|
+
// Build the same normalized key readHtmlCache / writeHtmlCache produce.
|
|
213
|
+
let url;
|
|
214
|
+
try {
|
|
215
|
+
url = new URL(path, 'http://internal.invalid');
|
|
216
|
+
} catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await getStore().delete(htmlCacheKey(url));
|
|
221
|
+
} catch {
|
|
222
|
+
/* a store delete failure is non-fatal: the TTL still expires the entry */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
|
|
228
|
+
* (the SSR opted it into caching), re-check every guard against the FINAL
|
|
229
|
+
* response and, when it passes, buffer the body and store it under the URL
|
|
230
|
+
* key with TTL = the marked revalidate seconds. Always strips the marker so
|
|
231
|
+
* it never reaches the client. Returns the same response (the marker removal
|
|
232
|
+
* mutates its headers in place). Best-effort: any failure leaves the live
|
|
233
|
+
* response untouched. The CSP guard was already applied on the SSR side (the
|
|
234
|
+
* marker is only stamped when CSP is off), so it is not re-checked here.
|
|
235
|
+
*
|
|
236
|
+
* @param {Request} req
|
|
237
|
+
* @param {Response} res
|
|
238
|
+
* @param {URL} url
|
|
239
|
+
* @returns {Promise<Response>}
|
|
240
|
+
*/
|
|
241
|
+
export async function commitHtmlCache(req, res, url) {
|
|
242
|
+
const marker = res.headers.get(HTML_CACHE_MARKER);
|
|
243
|
+
if (!marker) return res;
|
|
244
|
+
res.headers.delete(HTML_CACHE_MARKER);
|
|
245
|
+
const revalidateSeconds = Number(marker);
|
|
246
|
+
if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) return res;
|
|
247
|
+
// Per-user leak defense (#241). The page set `revalidate` (asserting "same
|
|
248
|
+
// for everyone"), but if the render actually read per-user request state
|
|
249
|
+
// (cookies() / headers() / getSession()), its body varies by visitor and
|
|
250
|
+
// must NOT be cached, even though it set no new Set-Cookie. Fail safe (skip
|
|
251
|
+
// caching) and warn the author ONCE per path so the wrong `revalidate` is
|
|
252
|
+
// visible without spamming the log.
|
|
253
|
+
if (dynamicAccessed()) {
|
|
254
|
+
warnDynamicRevalidateOnce(url.pathname);
|
|
255
|
+
return res;
|
|
256
|
+
}
|
|
257
|
+
// Re-check status / streaming / cookie guards against the final response.
|
|
258
|
+
if (!isCacheableResponse(res)) return res;
|
|
259
|
+
try {
|
|
260
|
+
const body = await res.clone().text();
|
|
261
|
+
await writeHtmlCache(
|
|
262
|
+
url,
|
|
263
|
+
{
|
|
264
|
+
body,
|
|
265
|
+
contentType: res.headers.get('content-type') || 'text/html; charset=utf-8',
|
|
266
|
+
cacheControl: res.headers.get('cache-control') || 'no-store',
|
|
267
|
+
status: res.status,
|
|
268
|
+
},
|
|
269
|
+
revalidateSeconds,
|
|
270
|
+
);
|
|
271
|
+
} catch {
|
|
272
|
+
/* a buffer / store failure must never affect the live response */
|
|
273
|
+
}
|
|
274
|
+
return res;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Paths already warned about a `revalidate` on a per-user page, so the
|
|
279
|
+
* console.warn fires once per offending route rather than once per request.
|
|
280
|
+
* @type {Set<string>}
|
|
281
|
+
*/
|
|
282
|
+
const _warnedDynamicPaths = new Set();
|
|
283
|
+
|
|
284
|
+
/** @param {string} pathname */
|
|
285
|
+
function warnDynamicRevalidateOnce(pathname) {
|
|
286
|
+
if (_warnedDynamicPaths.has(pathname)) return;
|
|
287
|
+
_warnedDynamicPaths.add(pathname);
|
|
288
|
+
console.warn(
|
|
289
|
+
`[webjs] not caching ${pathname}: it exported \`revalidate\` but read ` +
|
|
290
|
+
`per-user request state (cookies() / headers() / getSession()) during ` +
|
|
291
|
+
`render, so its output varies by visitor. Remove \`revalidate\` from a ` +
|
|
292
|
+
`per-user page, or stop reading request state if it is the same for ` +
|
|
293
|
+
`everyone. The page is being served fresh (uncached) to avoid a leak.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Evict ALL cached HTML for this process. @returns {void} */
|
|
298
|
+
export function revalidateAll() {
|
|
299
|
+
_generation++;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Internal: current generation, folded into keys by `htmlCacheKey`. */
|
|
303
|
+
export function htmlCacheGeneration() {
|
|
304
|
+
return _generation;
|
|
305
|
+
}
|
package/src/json.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stringify as wjStringify, parse as wjParse } from '@webjsdev/core';
|
|
2
|
-
import { getRequest } from './context.js';
|
|
2
|
+
import { getRequest, getBodyLimits } from './context.js';
|
|
3
3
|
import { RPC_CONTENT_TYPE } from './actions.js';
|
|
4
|
+
import { readTextBounded, BodyLimitError, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Content-negotiated JSON helper for API routes (`route.js` handlers).
|
|
@@ -51,11 +52,19 @@ export async function json(data, init = {}) {
|
|
|
51
52
|
* that accept rich bodies from the `richFetch` helper but plain JSON
|
|
52
53
|
* from everyone else.
|
|
53
54
|
*
|
|
55
|
+
* Enforces the request body-size limit (issue #237): an over-limit body throws
|
|
56
|
+
* a `BodyLimitError`, which the API dispatcher (`handleApi`) maps to a 413, so a
|
|
57
|
+
* `route.{js,ts}` handler doing `await readBody(req)` is protected with no extra
|
|
58
|
+
* code. The over-limit body is never buffered whole (see `readTextBounded`).
|
|
59
|
+
*
|
|
54
60
|
* @param {Request} req
|
|
55
61
|
*/
|
|
56
62
|
export async function readBody(req) {
|
|
57
63
|
const ct = req.headers.get('content-type') || '';
|
|
58
|
-
const
|
|
64
|
+
const limits = getBodyLimits();
|
|
65
|
+
const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
|
|
66
|
+
const { tooLarge, text } = await readTextBounded(req, limit);
|
|
67
|
+
if (tooLarge) throw new BodyLimitError();
|
|
59
68
|
if (!text) return null;
|
|
60
69
|
if (ct.includes(RPC_CONTENT_TYPE)) return wjParse(text);
|
|
61
70
|
return JSON.parse(text);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-version preflight guard (issue #238).
|
|
3
|
+
*
|
|
4
|
+
* webjs depends on Node 24+ built-ins: `module.stripTypeScriptTypes` (the
|
|
5
|
+
* no-build TypeScript strip) and recursive `fs.watch` (dev live-reload). On an
|
|
6
|
+
* older Node the failure surfaces late and cryptically (a strip error or a
|
|
7
|
+
* missing API deep inside a request), not as a clear "you need Node 24+". This
|
|
8
|
+
* module is the single early preflight that fails fast with an actionable
|
|
9
|
+
* message naming the exact version found and the version required.
|
|
10
|
+
*
|
|
11
|
+
* The check is a PURE function (`checkNodeVersion`) so it unit-tests with
|
|
12
|
+
* injected version strings, no spawning an old Node. `assertNodeVersion` is the
|
|
13
|
+
* thin side-effecting wrapper the CLI and the server entry both call: it prints
|
|
14
|
+
* to stderr + exits non-zero (CLI), or throws a clear Error (embedded server),
|
|
15
|
+
* depending on the `onFail` mode.
|
|
16
|
+
*
|
|
17
|
+
* The minimum is sourced from ONE place, this package's own `engines.node`
|
|
18
|
+
* field, so it never drifts from what npm enforces on install.
|
|
19
|
+
*/
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the leading major-version integer out of a Node version string.
|
|
24
|
+
* Handles `'24.1.0'`, `'v24.1.0'`, and prerelease tags like
|
|
25
|
+
* `'24.0.0-nightly20240101'`. Returns `NaN` when no leading integer is found.
|
|
26
|
+
* @param {string} version
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function parseMajor(version) {
|
|
30
|
+
const m = String(version).trim().match(/^v?(\d+)/);
|
|
31
|
+
return m ? Number(m[1]) : NaN;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse the minimum major version out of an `engines.node` range like
|
|
36
|
+
* `'>=24.0.0'`, `'>= 24'`, or `'24.x'`. Returns `NaN` when no integer is found.
|
|
37
|
+
* @param {string} engines
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
export function parseRequiredMajor(engines) {
|
|
41
|
+
const m = String(engines).match(/(\d+)/);
|
|
42
|
+
return m ? Number(m[1]) : NaN;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure version check. Compares the running Node major against the required
|
|
47
|
+
* minimum and returns a structured result (no side effects).
|
|
48
|
+
* @param {string} current the running Node version (e.g. `process.versions.node`)
|
|
49
|
+
* @param {number} requiredMajor the minimum acceptable major version
|
|
50
|
+
* @returns {{ ok: boolean, current: string, currentMajor: number, requiredMajor: number, message: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function checkNodeVersion(current, requiredMajor) {
|
|
53
|
+
const currentMajor = parseMajor(current);
|
|
54
|
+
// If we cannot parse the running version, fail open: do not block a runtime
|
|
55
|
+
// that reports an unusual version string. The deep APIs guard themselves.
|
|
56
|
+
const ok = Number.isNaN(currentMajor) || currentMajor >= requiredMajor;
|
|
57
|
+
const message = ok
|
|
58
|
+
? ''
|
|
59
|
+
: `webjs requires Node ${requiredMajor}+ but found Node ${current}. ` +
|
|
60
|
+
`webjs is buildless and relies on Node ${requiredMajor}'s built-in ` +
|
|
61
|
+
`TypeScript strip (module.stripTypeScriptTypes) and recursive fs.watch, ` +
|
|
62
|
+
`neither of which exists on Node ${currentMajor}. ` +
|
|
63
|
+
`Upgrade to Node ${requiredMajor} or newer (see https://nodejs.org).`;
|
|
64
|
+
return { ok, current, currentMajor, requiredMajor, message };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the minimum required Node major from this package's own
|
|
69
|
+
* `engines.node` field, so the minimum lives in exactly one place. Falls back
|
|
70
|
+
* to 24 if the field is missing or unparseable (defensive only).
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
export function requiredNodeMajor() {
|
|
74
|
+
try {
|
|
75
|
+
const require = createRequire(import.meta.url);
|
|
76
|
+
const pkg = require('../package.json');
|
|
77
|
+
const major = parseRequiredMajor(pkg?.engines?.node || '');
|
|
78
|
+
if (!Number.isNaN(major)) return major;
|
|
79
|
+
} catch {}
|
|
80
|
+
return 24;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Side-effecting preflight: assert the running Node satisfies the minimum.
|
|
85
|
+
* On an unsupported Node, either exits the process non-zero (CLI, `onFail:
|
|
86
|
+
* 'exit'`) or throws a clear Error (embedded server, `onFail: 'throw'`).
|
|
87
|
+
* A no-op on a supported Node.
|
|
88
|
+
* @param {{ current?: string, requiredMajor?: number, onFail?: 'exit'|'throw' }} [opts]
|
|
89
|
+
* @returns {void}
|
|
90
|
+
*/
|
|
91
|
+
export function assertNodeVersion(opts = {}) {
|
|
92
|
+
const current = opts.current ?? process.versions.node;
|
|
93
|
+
const requiredMajor = opts.requiredMajor ?? requiredNodeMajor();
|
|
94
|
+
const onFail = opts.onFail ?? 'throw';
|
|
95
|
+
const result = checkNodeVersion(current, requiredMajor);
|
|
96
|
+
if (result.ok) return;
|
|
97
|
+
if (onFail === 'exit') {
|
|
98
|
+
console.error(result.message);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
throw new Error(result.message);
|
|
102
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { isNotFound, isRedirect } from '@webjsdev/core';
|
|
2
|
+
import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
|
|
3
|
+
import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
|
|
4
|
+
import { getBodyLimits } from './context.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Page server actions: a `page.{js,ts}` may export an `action` function that
|
|
8
|
+
* handles a non-GET/HEAD submission to the page's own URL. This is webjs's
|
|
9
|
+
* Remix-style page-action path, adapted to the no-build progressive-enhancement
|
|
10
|
+
* model: a `<form method="POST">` submits with JS disabled, the action runs on
|
|
11
|
+
* the server, and a validation failure re-renders the SAME page with field
|
|
12
|
+
* errors and the user's typed values preserved.
|
|
13
|
+
*
|
|
14
|
+
* Behavior (see #244):
|
|
15
|
+
* - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
|
|
16
|
+
* render does (3xx / 404). A thrown `redirect()` may target an external URL
|
|
17
|
+
* (it is the explicit nav sentinel, author-controlled).
|
|
18
|
+
* - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
|
|
19
|
+
* present, else to the page's own path (Post/Redirect/Get, so a reload does
|
|
20
|
+
* not resubmit). `result.redirect` MUST be a same-site local path (see
|
|
21
|
+
* `sameSiteRedirect`), a non-local value is ignored to avoid an
|
|
22
|
+
* open-redirect through a user-controlled action result.
|
|
23
|
+
* - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
|
|
24
|
+
* the result on `ctx.actionData`, so the page template can read
|
|
25
|
+
* `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
|
|
26
|
+
*
|
|
27
|
+
* Failure detection is robust (it does not require a literal `success: false`):
|
|
28
|
+
* see `isFailureResult`.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {{
|
|
31
|
+
* success?: boolean,
|
|
32
|
+
* data?: unknown,
|
|
33
|
+
* error?: string,
|
|
34
|
+
* fieldErrors?: Record<string,string>,
|
|
35
|
+
* values?: Record<string,string>,
|
|
36
|
+
* status?: number,
|
|
37
|
+
* redirect?: string,
|
|
38
|
+
* }} ActionResult
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether an action result is a FAILURE (re-render the page) rather than a
|
|
43
|
+
* success (PRG redirect). A result is a failure when ANY of these hold:
|
|
44
|
+
* - `result.success === false` (explicit), OR
|
|
45
|
+
* - `result.fieldErrors` is present (per-field validation messages), OR
|
|
46
|
+
* - `result.error` is present AND `result.success !== true`.
|
|
47
|
+
*
|
|
48
|
+
* Success is the explicit `success: true`, or a bare value (or
|
|
49
|
+
* undefined/null) carrying no error markers. This means an action that returns
|
|
50
|
+
* `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
|
|
51
|
+
* is still treated as a failure and its error is surfaced, not swallowed.
|
|
52
|
+
*
|
|
53
|
+
* @param {ActionResult | null | undefined} result
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isFailureResult(result) {
|
|
57
|
+
if (!result || typeof result !== 'object') return false;
|
|
58
|
+
if (result.success === false) return true;
|
|
59
|
+
if (result.fieldErrors != null) return true;
|
|
60
|
+
if (result.error != null && result.success !== true) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Restrict a page action's `result.redirect` to a SAME-SITE local target.
|
|
66
|
+
* Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
|
|
67
|
+
* Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
|
|
68
|
+
* URL. A user-controlled redirect target is an open-redirect vector, so a
|
|
69
|
+
* non-local value is dropped and the caller falls back to the page's own path.
|
|
70
|
+
*
|
|
71
|
+
* A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
|
|
72
|
+
* routed through here: that is the author-controlled escape hatch for a
|
|
73
|
+
* legitimate external redirect.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} target
|
|
76
|
+
* @returns {string | null} the safe local path, or null when not same-site
|
|
77
|
+
*/
|
|
78
|
+
function sameSiteRedirect(target) {
|
|
79
|
+
if (typeof target !== 'string') return null;
|
|
80
|
+
// Must start with a single slash (a leading `//` is protocol-relative and
|
|
81
|
+
// would navigate cross-origin).
|
|
82
|
+
if (!target.startsWith('/') || target.startsWith('//')) return null;
|
|
83
|
+
// A backslash after the leading slash (`/\evil.com`) is normalized by some
|
|
84
|
+
// browsers into a protocol-relative URL, so reject it too.
|
|
85
|
+
if (target.startsWith('/\\')) return null;
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a page module and return its `action` export, or null if it has none.
|
|
91
|
+
* Uses ssr.js's shared `loadModule`, so page-action loading is consistent with
|
|
92
|
+
* the SSR re-render. In prod the URL is stable and Node's module cache serves
|
|
93
|
+
* one evaluation; in dev a cache-bust forces a fresh evaluation.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} file absolute path to the page module
|
|
96
|
+
* @param {boolean} dev
|
|
97
|
+
* @returns {Promise<{ action: Function, module: Record<string, unknown> } | null>}
|
|
98
|
+
*/
|
|
99
|
+
export async function loadPageAction(file, dev) {
|
|
100
|
+
try {
|
|
101
|
+
const mod = await loadModule(file, dev);
|
|
102
|
+
return typeof mod.action === 'function'
|
|
103
|
+
? { action: /** @type {Function} */ (mod.action), module: mod }
|
|
104
|
+
: null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the submitted body ONCE, bounded by the form/multipart limit (issue
|
|
112
|
+
* #237), and return both a `FormData` (handed to the action as `formData`) and a
|
|
113
|
+
* rebuilt `Request` carrying the already-read bytes (handed to the action as
|
|
114
|
+
* `request`, so it can still call `request.json()` / `request.formData()`). The
|
|
115
|
+
* body is consumed off the ORIGINAL request directly, NOT via `req.clone()`: a
|
|
116
|
+
* tee'd clone whose reader is cancelled mid-stream (the over-limit case)
|
|
117
|
+
* deadlocks the untaken branch, hanging the response.
|
|
118
|
+
*
|
|
119
|
+
* An over-limit body is reported as `tooLarge` (the caller returns 413) and is
|
|
120
|
+
* never buffered whole. A form posts more than a JSON RPC call (textarea, small
|
|
121
|
+
* upload), so it uses the higher `multipart` cap. A non-form content type yields
|
|
122
|
+
* an empty FormData so the action signature stays stable; the rebuilt request
|
|
123
|
+
* still carries the raw bytes for the action to parse however it likes.
|
|
124
|
+
*
|
|
125
|
+
* @param {Request} req
|
|
126
|
+
* @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
|
|
127
|
+
*/
|
|
128
|
+
async function parseFormBody(req) {
|
|
129
|
+
const ct = req.headers.get('content-type') || '';
|
|
130
|
+
const limits = getBodyLimits();
|
|
131
|
+
const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
|
|
132
|
+
const { tooLarge, bytes } = await readBytesBounded(req, limit);
|
|
133
|
+
if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
|
|
134
|
+
|
|
135
|
+
// Rebuild a fresh Request from the bytes so the action can re-read the body.
|
|
136
|
+
const headers = new Headers(req.headers);
|
|
137
|
+
const rebuilt = new Request(req.url, {
|
|
138
|
+
method: req.method,
|
|
139
|
+
headers,
|
|
140
|
+
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
|
|
144
|
+
let formData = new FormData();
|
|
145
|
+
if (isForm) {
|
|
146
|
+
// Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
|
|
147
|
+
const forParse = new Request(req.url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: ct ? { 'content-type': ct } : undefined,
|
|
150
|
+
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
151
|
+
});
|
|
152
|
+
formData = await forParse.formData();
|
|
153
|
+
}
|
|
154
|
+
return { tooLarge: false, formData, request: rebuilt };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run a page `action` for a non-GET/HEAD request and produce the HTTP response.
|
|
159
|
+
* The caller has already confirmed the path matches a page route AND that the
|
|
160
|
+
* page module exports an `action` (via `loadPageAction`). The action runs inside
|
|
161
|
+
* the same segment middleware as the page (the caller wraps this).
|
|
162
|
+
*
|
|
163
|
+
* The failure re-render reuses the SAME page module instance whose `action` just
|
|
164
|
+
* ran (passed through as `pageModule`), so the page module is loaded once per
|
|
165
|
+
* POST rather than evaluated a second time.
|
|
166
|
+
*
|
|
167
|
+
* @param {import('./router.js').PageRoute} route
|
|
168
|
+
* @param {Record<string,string>} params
|
|
169
|
+
* @param {URL} url
|
|
170
|
+
* @param {{ action: Function, module: Record<string, unknown> }} loaded the page module's `action` plus the loaded module
|
|
171
|
+
* @param {Request} req
|
|
172
|
+
* @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
|
|
173
|
+
* @returns {Promise<Response>}
|
|
174
|
+
*/
|
|
175
|
+
export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
|
|
176
|
+
const { action, module: pageModule } = loaded;
|
|
177
|
+
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
178
|
+
let formData = new FormData();
|
|
179
|
+
// The body is read ONCE here (bounded). `actionReq` is a rebuilt request the
|
|
180
|
+
// action can re-read; on a parse failure it falls back to the original `req`.
|
|
181
|
+
let actionReq = req;
|
|
182
|
+
try {
|
|
183
|
+
const parsed = await parseFormBody(req);
|
|
184
|
+
// Over the form/multipart limit (issue #237): 413 before the action runs.
|
|
185
|
+
if (parsed.tooLarge) return payloadTooLarge();
|
|
186
|
+
formData = parsed.formData;
|
|
187
|
+
actionReq = parsed.request;
|
|
188
|
+
} catch {
|
|
189
|
+
formData = new FormData();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @type {ActionResult | undefined} */
|
|
193
|
+
let result;
|
|
194
|
+
try {
|
|
195
|
+
result = await action({ request: actionReq, params, searchParams, url, formData });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (isRedirect(err)) {
|
|
198
|
+
const e = /** @type any */ (err);
|
|
199
|
+
// A thrown redirect from an action is honored as the page render does.
|
|
200
|
+
// Use the action's chosen status (307/308) so an explicit redirect()
|
|
201
|
+
// keeps its semantics; PRG (303) is the SUCCESS-result path below.
|
|
202
|
+
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
|
|
203
|
+
}
|
|
204
|
+
if (isNotFound(err)) {
|
|
205
|
+
return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isFailureResult(result)) {
|
|
211
|
+
// SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
|
|
212
|
+
// honored when it is a same-site local path; otherwise fall back to the
|
|
213
|
+
// page's own path so a poisoned value cannot become an open redirect.
|
|
214
|
+
const ownPath = (url.pathname + url.search) || '/';
|
|
215
|
+
const safe = result ? sameSiteRedirect(result.redirect) : null;
|
|
216
|
+
return new Response(null, { status: 303, headers: { location: safe || ownPath } });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// FAILURE: re-render the SAME page with the action result available on
|
|
220
|
+
// ctx.actionData, status 422. Repopulation is the page author's job (native
|
|
221
|
+
// `value=${actionData.values?.field}`). Pass the already-loaded page module so
|
|
222
|
+
// the re-render shares this POST's single evaluation.
|
|
223
|
+
const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
|
|
224
|
+
return ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status, pageModule });
|
|
225
|
+
}
|
package/src/session.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { getStore } from './cache.js';
|
|
24
|
+
import { markDynamicAccess } from './context.js';
|
|
24
25
|
|
|
25
26
|
// -- Web Crypto helpers ------------------------------------------------------
|
|
26
27
|
// Same shape as auth.js. We duplicate here rather than share a module
|
|
@@ -378,5 +379,8 @@ export function session(opts = {}) {
|
|
|
378
379
|
export function getSession(req) {
|
|
379
380
|
const s = sessionMap.get(req);
|
|
380
381
|
if (!s) throw new Error('getSession() called outside of session middleware');
|
|
382
|
+
// A session read is per-user, so mark the request dynamic so the server HTML
|
|
383
|
+
// cache excludes it even when the page declared `revalidate` (#241).
|
|
384
|
+
markDynamicAccess();
|
|
381
385
|
return s;
|
|
382
386
|
}
|