@webjsdev/server 0.8.58 → 0.8.59
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.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +92 -27
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +55 -15
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/html-cache.js
CHANGED
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
import { getStore } from './cache.js';
|
|
28
28
|
import { STREAM_MARKER } from './conditional-get.js';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
|
-
import { publishedBuildId } from './importmap.js';
|
|
31
|
-
import {
|
|
30
|
+
import { publishedBuildId, basePath } from './importmap.js';
|
|
31
|
+
import { stripBasePath } from './base-path.js';
|
|
32
|
+
import { dynamicAccessed, getRequest } from './context.js';
|
|
32
33
|
|
|
33
34
|
/** Namespace prefix for every cached-HTML key, so a flush can target it. */
|
|
34
35
|
const KEY_PREFIX = 'webjs:html:';
|
|
@@ -109,11 +110,37 @@ export function readRevalidate(pageModule) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
* The
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* The origin component of a cache key. `url.origin` is already the normalized
|
|
114
|
+
* lowercase `scheme://host[:port]` form for an http(s) URL; a non-special
|
|
115
|
+
* scheme serializes to the literal string `null`, which is a fine constant key
|
|
116
|
+
* component (such a URL never reaches a cacheable page render anyway).
|
|
116
117
|
*
|
|
118
|
+
* @param {URL} url
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function originKey(url) {
|
|
122
|
+
return url.origin;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The cache key for a request: the ORIGIN plus the full path + search string,
|
|
127
|
+
* since both `ctx.url.origin` and `searchParams` change page output.
|
|
128
|
+
* Normalized to path + sorted query so `?a=1&b=2` and `?b=2&a=1` share an
|
|
129
|
+
* entry. Four discriminators are folded into the namespace:
|
|
130
|
+
*
|
|
131
|
+
* - the origin (#1097). This cache is SHARED across visitors, and `ctx.url`
|
|
132
|
+
* is built from `X-Forwarded-Host` / `X-Forwarded-Proto`, which neither
|
|
133
|
+
* Cloudflare nor Railway strips from a client request. Without the origin
|
|
134
|
+
* in the key, one request carrying `X-Forwarded-Host: evil.example` baked
|
|
135
|
+
* an attacker-chosen origin into the shared body, and every later visitor
|
|
136
|
+
* to that path was served it until the entry expired: a poisoned
|
|
137
|
+
* `og:image`, canonical link, OAuth callback URL, or absolute asset URL.
|
|
138
|
+
* With the origin in the key, a clean request resolves its origin from the
|
|
139
|
+
* proxy-set `Host` and reads its OWN entry, so the hostile body is
|
|
140
|
+
* unreachable from any origin an attacker does not already control. A
|
|
141
|
+
* single-host deploy has exactly one origin and so exactly one key per URL,
|
|
142
|
+
* unchanged hit rate; a genuine multi-host deploy gets correctly separated
|
|
143
|
+
* entries instead of cross-serving one host's HTML to another;
|
|
117
144
|
* - the in-process generation, so `revalidateAll()` (a generation bump)
|
|
118
145
|
* makes every prior key unreachable in one step;
|
|
119
146
|
* - the published build id (the importmap fingerprint), so a NEW DEPLOY
|
|
@@ -143,7 +170,7 @@ export function htmlCacheKey(url) {
|
|
|
143
170
|
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
144
171
|
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
145
172
|
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
146
|
-
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
|
|
173
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
/**
|
|
@@ -221,17 +248,59 @@ export function isCacheableResponse(res, guards = {}) {
|
|
|
221
248
|
* ONLY the no-query entry; pass the exact `path?query` to target a
|
|
222
249
|
* specific query variant, or call `revalidateAll()` to clear everything.
|
|
223
250
|
*
|
|
224
|
-
*
|
|
251
|
+
* ORIGIN RESOLUTION (#1097). Cache keys carry the request's origin, so a bare
|
|
252
|
+
* path does not name one entry, it names one per origin the path was cached
|
|
253
|
+
* under. Two forms resolve it:
|
|
254
|
+
*
|
|
255
|
+
* 1. The CURRENT request's origin, when one is ambient. A mutation runs inside
|
|
256
|
+
* the request of the user who triggered it, and that user is on the same
|
|
257
|
+
* origin as the pages being evicted, so a bare path is exact for the
|
|
258
|
+
* ordinary "server action writes, then evicts" flow, which is every
|
|
259
|
+
* in-repo example and everything the docs show.
|
|
260
|
+
* 2. An ABSOLUTE url (`https://app.example/blog`) names its origin outright.
|
|
261
|
+
* This is the form for a caller with NO ambient request: a cron job, a
|
|
262
|
+
* queue worker, an `instrumentation.js` timer. Under `webjs.basePath` pass
|
|
263
|
+
* the PUBLIC url and the mount prefix is stripped for you, so it keys the
|
|
264
|
+
* same entry the write stored.
|
|
265
|
+
*
|
|
266
|
+
* A bare path with no ambient request cannot be resolved and WARNS ONCE naming
|
|
267
|
+
* the path, rather than silently evicting nothing.
|
|
268
|
+
*
|
|
269
|
+
* There is deliberately no process-local registry of "origins seen so far"
|
|
270
|
+
* standing in for case 2. The origin comes from `X-Forwarded-Host`, which is
|
|
271
|
+
* client-supplied and forwarded rather than stripped (the whole premise of
|
|
272
|
+
* #1097), so such a registry is attacker-writable: a flood of hostile hosts
|
|
273
|
+
* against a cold process either fills a bounded registry before the real origin
|
|
274
|
+
* is ever seen, or grows an unbounded one. Either way eviction silently stops
|
|
275
|
+
* working, which is worse than the explicit contract above.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} path e.g. '/blog', '/blog?page=2', or 'https://app.example/blog'
|
|
225
278
|
* @returns {Promise<void>}
|
|
226
279
|
*/
|
|
227
280
|
export async function revalidatePath(path) {
|
|
228
281
|
if (typeof path !== 'string' || !path) return;
|
|
229
|
-
// Build the same normalized key readHtmlCache / writeHtmlCache produce.
|
|
230
282
|
let url;
|
|
231
|
-
|
|
232
|
-
url = new URL(path
|
|
233
|
-
|
|
234
|
-
|
|
283
|
+
if (/^https?:\/\//i.test(path)) {
|
|
284
|
+
try { url = new URL(path); } catch { return; }
|
|
285
|
+
// An absolute url is the PUBLIC one, so under `webjs.basePath` it carries
|
|
286
|
+
// the mount prefix. Cache entries key on the app-root-relative path (the
|
|
287
|
+
// write strips it in dev.js for exactly this reason), so strip it here too
|
|
288
|
+
// or the caller who was told to use this form, the one with no ambient
|
|
289
|
+
// request, computes a key nothing was ever stored under. A path that is
|
|
290
|
+
// not under the base path is left alone, matching the write's behaviour.
|
|
291
|
+
const base = basePath();
|
|
292
|
+
if (base) {
|
|
293
|
+
const stripped = stripBasePath(url.pathname, base);
|
|
294
|
+
if (stripped !== null) url.pathname = stripped;
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
const origin = ambientOrigin();
|
|
298
|
+
if (!origin) {
|
|
299
|
+
warnUnresolvedOriginOnce(path);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Build the same normalized key readHtmlCache / writeHtmlCache produce.
|
|
303
|
+
try { url = new URL(path, origin); } catch { return; }
|
|
235
304
|
}
|
|
236
305
|
try {
|
|
237
306
|
await getStore().delete(htmlCacheKey(url));
|
|
@@ -240,6 +309,41 @@ export async function revalidatePath(path) {
|
|
|
240
309
|
}
|
|
241
310
|
}
|
|
242
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The current request's origin, or '' when there is no ambient request. Never
|
|
314
|
+
* throws: an eviction helper must not take down the mutation that called it.
|
|
315
|
+
*
|
|
316
|
+
* @returns {string}
|
|
317
|
+
*/
|
|
318
|
+
function ambientOrigin() {
|
|
319
|
+
try {
|
|
320
|
+
const req = getRequest();
|
|
321
|
+
return req ? new URL(req.url).origin : '';
|
|
322
|
+
} catch {
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Paths already warned about an unresolvable bare-path eviction, so the warning
|
|
329
|
+
* fires once per offending path rather than once per invocation.
|
|
330
|
+
* @type {Set<string>}
|
|
331
|
+
*/
|
|
332
|
+
const _warnedUnresolvedPaths = new Set();
|
|
333
|
+
|
|
334
|
+
/** @param {string} path */
|
|
335
|
+
function warnUnresolvedOriginOnce(path) {
|
|
336
|
+
if (_warnedUnresolvedPaths.has(path)) return;
|
|
337
|
+
_warnedUnresolvedPaths.add(path);
|
|
338
|
+
console.warn(
|
|
339
|
+
`[webjs] revalidatePath(${JSON.stringify(path)}) evicted nothing: HTML cache ` +
|
|
340
|
+
`keys carry the request origin, and this call has no ambient request to take ` +
|
|
341
|
+
`one from (a cron job, a queue worker, a boot-time timer). Pass an absolute ` +
|
|
342
|
+
`url instead, e.g. revalidatePath('https://your-host${path.startsWith('/') ? path : '/' + path}'), ` +
|
|
343
|
+
`or call it from a server action, where the triggering request supplies the origin.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
243
347
|
/**
|
|
244
348
|
* Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
|
|
245
349
|
* (the SSR opted it into caching), re-check every guard against the FINAL
|
package/src/listener-bun.js
CHANGED
|
@@ -154,7 +154,7 @@ export function startBunListener(ctx) {
|
|
|
154
154
|
const shutdown = makeShutdown({ closeServer, hub, logger });
|
|
155
155
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
156
156
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
157
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
157
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
158
158
|
|
|
159
159
|
return {
|
|
160
160
|
server,
|
package/src/listener-core.js
CHANGED
|
@@ -383,7 +383,7 @@ export class SseHub {
|
|
|
383
383
|
* Install once-only process error handlers. Idempotent across multiple
|
|
384
384
|
* `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
|
|
385
385
|
* @param {import('./logger.js').Logger} logger
|
|
386
|
-
* @param {() => void} onFatal
|
|
386
|
+
* @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
|
|
387
387
|
*/
|
|
388
388
|
export function installProcessHandlers(logger, onFatal) {
|
|
389
389
|
if (/** @type any */ (globalThis).__webjsProcHandlers) return;
|
|
@@ -405,12 +405,37 @@ export function installProcessHandlers(logger, onFatal) {
|
|
|
405
405
|
* `closeServer()` thunk that resolves once the server has stopped accepting
|
|
406
406
|
* connections (node `server.close`, Bun `server.stop(true)`). Closes the SSE hub,
|
|
407
407
|
* then drains, then exits; hard-exits after 10s if the drain hangs.
|
|
408
|
+
*
|
|
409
|
+
* Exit code, in full. Three things can force a 1, and only their absence
|
|
410
|
+
* leaves a 0:
|
|
411
|
+
* - a drain that FAILS, so a rejected `closeServer()` and a drain still
|
|
412
|
+
* hanging at the 10s deadline both exit 1 whatever started them;
|
|
413
|
+
* - a FATAL start (the `uncaughtException` handler calling `onFatal`), which
|
|
414
|
+
* exits 1 even when the drain is perfectly clean;
|
|
415
|
+
* - a FATAL arriving MID-DRAIN, which upgrades a shutdown an operator signal
|
|
416
|
+
* had already begun (see the upgrade-only note on `code` below).
|
|
417
|
+
* So 0 does NOT mean "a signal started it", it means an operator asked for the
|
|
418
|
+
* stop AND it drained cleanly AND nothing crashed on the way out. A supervisor
|
|
419
|
+
* reads only this code (systemd `Restart=on-failure`, Docker, Railway, and a CI
|
|
420
|
+
* step running a `test/bun/*.mjs` proof script, whose failed top-level assertion
|
|
421
|
+
* arrives here as an uncaught exception, #1092), so every one of those has to
|
|
422
|
+
* come back non-zero.
|
|
408
423
|
* @param {{ closeServer: () => Promise<unknown>, hub: SseHub, logger: import('./logger.js').Logger }} opts
|
|
409
|
-
* @returns {(signal: string) => void}
|
|
424
|
+
* @returns {(signal: string, opts?: { fatal?: boolean }) => void}
|
|
410
425
|
*/
|
|
411
426
|
export function makeShutdown({ closeServer, hub, logger }) {
|
|
412
427
|
let shuttingDown = false;
|
|
413
|
-
|
|
428
|
+
// Read at EXIT time, not at entry, so a fatal arriving mid-drain still counts.
|
|
429
|
+
// The drain window is up to 10s wide and in-flight requests are still running
|
|
430
|
+
// in it, so an uncaught exception landing there is an ordinary event, not an
|
|
431
|
+
// exotic race. Because the re-entrancy guard below drops that second call, a
|
|
432
|
+
// code captured at entry would stay 0 and the crashed process would report a
|
|
433
|
+
// successful stop, which is the exact failure this whole mechanism exists to
|
|
434
|
+
// prevent. Upgrade only (1 never falls back to 0), so a signal arriving during
|
|
435
|
+
// a fatal shutdown cannot launder the crash into a success either.
|
|
436
|
+
let code = 0;
|
|
437
|
+
return (signal, { fatal = false } = {}) => {
|
|
438
|
+
if (fatal) code = 1;
|
|
414
439
|
if (shuttingDown) return;
|
|
415
440
|
shuttingDown = true;
|
|
416
441
|
logger.info(`received ${signal}, shutting down`);
|
|
@@ -420,11 +445,16 @@ export function makeShutdown({ closeServer, hub, logger }) {
|
|
|
420
445
|
process.exit(1);
|
|
421
446
|
}, 10_000);
|
|
422
447
|
if (typeof hard.unref === 'function') hard.unref();
|
|
448
|
+
// Disarm before exiting. In production `process.exit` ends everything so this
|
|
449
|
+
// is redundant, but it keeps the deadline from outliving the decision it was
|
|
450
|
+
// guarding, which is what let a stubbed-exit test leave a live timer armed to
|
|
451
|
+
// kill an unrelated process 10s later.
|
|
452
|
+
const done = (c) => { clearTimeout(hard); process.exit(c); };
|
|
423
453
|
Promise.resolve()
|
|
424
454
|
.then(closeServer)
|
|
425
455
|
.then(
|
|
426
|
-
() => { logger.info('bye');
|
|
427
|
-
(err) => { logger.error('server close error', { err: String(err) });
|
|
456
|
+
() => { logger.info('bye'); done(code); },
|
|
457
|
+
(err) => { logger.error('server close error', { err: String(err) }); done(1); },
|
|
428
458
|
);
|
|
429
459
|
};
|
|
430
460
|
}
|
package/src/rate-limit.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { getStore } from './cache.js';
|
|
26
|
+
// Aliased on purpose. `rateLimit()` binds a local `const trustProxy` from its
|
|
27
|
+
// own options, which would shadow a same-named import inside that function and
|
|
28
|
+
// leave the middleware path on the pre-override behaviour while the direct
|
|
29
|
+
// `clientIp` path changed. `forwarded.js` imports nothing, so this adds no cycle.
|
|
30
|
+
import { trustProxy as proxyIsTrusted } from './forwarded.js';
|
|
31
|
+
|
|
32
|
+
/** Module-scope latch so the override warns once per process, never per request. */
|
|
33
|
+
let warnedProxyOverride = false;
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* @param {{
|
|
@@ -126,8 +134,8 @@ function trustedRemoteIp(req) {
|
|
|
126
134
|
|
|
127
135
|
/**
|
|
128
136
|
* Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
|
|
129
|
-
* (#756). When the framework re-wraps a request (the
|
|
130
|
-
* `
|
|
137
|
+
* (#756). When the framework re-wraps a request (the form-submission body rebuild
|
|
138
|
+
* in `form-dispatch.js`), the out-of-band WeakMap key is a new object, so the trusted
|
|
131
139
|
* IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
|
|
132
140
|
* the rebuild copied over. Propagating the trusted value via the WeakMap keeps
|
|
133
141
|
* `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
|
|
@@ -167,12 +175,32 @@ export function propagateTrustedRemoteIp(src, dst) {
|
|
|
167
175
|
* trust-proxy reintroduces the spoofability this option exists to
|
|
168
176
|
* defend against.
|
|
169
177
|
*
|
|
178
|
+
* `WEBJS_NO_TRUST_PROXY=1` OVERRIDES `trustProxy: true` (#1254). The env
|
|
179
|
+
* switch is the operator's statement about the deployment topology and it can
|
|
180
|
+
* only ever SUBTRACT trust, so when it is set this call falls back to the
|
|
181
|
+
* default path above: the framework-stamped peer, or `_anon_` when there is
|
|
182
|
+
* none, and never a forwarded-IP header. The override logs once per process.
|
|
183
|
+
* An app that genuinely sits behind a trusted proxy must unset the flag; the
|
|
184
|
+
* two settings answer the same question about one socket and one topology, so
|
|
185
|
+
* leaving them in disagreement buckets every visitor behind that proxy onto
|
|
186
|
+
* one key.
|
|
187
|
+
*
|
|
170
188
|
* @param {Request} req
|
|
171
189
|
* @param {{ trustProxy?: boolean }} [opts]
|
|
172
190
|
* @returns {string}
|
|
173
191
|
*/
|
|
174
192
|
export function clientIp(req, opts = {}) {
|
|
175
|
-
if (opts.trustProxy === true) {
|
|
193
|
+
if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
|
|
194
|
+
warnedProxyOverride = true;
|
|
195
|
+
// No request data in this line, ever. It must not become a log of
|
|
196
|
+
// client-supplied header values.
|
|
197
|
+
console.warn(
|
|
198
|
+
'[webjs] WEBJS_NO_TRUST_PROXY=1 overrides trustProxy: true; ' +
|
|
199
|
+
'forwarded-IP headers are ignored and the framework-stamped peer is used. ' +
|
|
200
|
+
'Unset the env var if a trusted proxy really is in front of this process.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (opts.trustProxy === true && proxyIsTrusted()) {
|
|
176
204
|
return (
|
|
177
205
|
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
178
206
|
req.headers.get('cf-connecting-ip') ||
|
package/src/route-types.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Design choices:
|
|
12
12
|
* - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
|
|
13
13
|
* navigable HTML page, so its path is excluded from the navigable `Route`
|
|
14
|
-
* union. Pages (including
|
|
14
|
+
* union. Pages (including ones hosting a bound form) are what a valid href points
|
|
15
15
|
* at.
|
|
16
16
|
* - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
|
|
17
17
|
* page's directory with route groups `(group)` stripped and `_private`
|
package/src/ssr.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js';
|
|
5
5
|
import { withBasePath } from './base-path.js';
|
|
6
6
|
import { withAssetHash } from './asset-hash.js';
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
@@ -41,7 +41,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
41
41
|
// and is threaded back through `opts.pageModule` so renderChain reuses the
|
|
42
42
|
// same evaluation (no double-load). A cache HIT serves the stored HTML
|
|
43
43
|
// without re-running the page function. Skipped entirely (no opt-in read,
|
|
44
|
-
// no double behaviour) for the
|
|
44
|
+
// no double behaviour) for the form-action re-render (actionData / a non-200
|
|
45
45
|
// status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
|
|
46
46
|
// on the request and must not be shared under the full-URL key.
|
|
47
47
|
const cacheEligible =
|
|
@@ -297,7 +297,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
297
297
|
outBody,
|
|
298
298
|
closer,
|
|
299
299
|
suspenseCtx,
|
|
300
|
-
// Normally 200. After a failed
|
|
300
|
+
// Normally 200. After a failed form-action submission the caller passes
|
|
301
301
|
// 422 (or another 4xx) so the re-rendered page with field errors carries
|
|
302
302
|
// the right status for both the no-JS reload and the enhanced swap (#244).
|
|
303
303
|
opts.status || 200,
|
|
@@ -346,7 +346,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
346
346
|
// navigation (an auth bounce, a gate). 302 Found is the conventional
|
|
347
347
|
// code there, so it is the default when the caller did not pick one. An
|
|
348
348
|
// explicit `redirect(url, status)` overrides it. (Action redirects, a
|
|
349
|
-
// POST, default to 307 in
|
|
349
|
+
// POST, default to 307 in form-dispatch.js so the method is preserved.)
|
|
350
350
|
return new Response(null, { status: e.status || 302, headers: { location: e.url } });
|
|
351
351
|
}
|
|
352
352
|
if (isNotFound(err)) {
|
|
@@ -459,7 +459,7 @@ export async function ssrNotFound(notFoundFile, opts) {
|
|
|
459
459
|
/**
|
|
460
460
|
* 403 response for a thrown forbidden() (#848). Renders the nearest
|
|
461
461
|
* forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
|
|
462
|
-
* page-render catch (ssr.js) and the
|
|
462
|
+
* page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
|
|
463
463
|
* both behave identically.
|
|
464
464
|
* @param {{ forbiddens?: string[] }} route
|
|
465
465
|
* @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
|
|
@@ -674,10 +674,13 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
674
674
|
}
|
|
675
675
|
|
|
676
676
|
async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
|
|
677
|
-
// Reuse a caller-supplied page module when present
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
677
|
+
// Reuse a caller-supplied page module when present. The only producer is the
|
|
678
|
+
// HTML-cache path above, which loads the module to read `export const
|
|
679
|
+
// revalidate` and threads it back so this does not load it a second time.
|
|
680
|
+
// (The removed page `action` export used to be a second producer: it ran in
|
|
681
|
+
// the page module, so the 422 re-render could share that evaluation. A form
|
|
682
|
+
// action lives in a `.server.*` module instead, so the re-render loads the
|
|
683
|
+
// page module itself, exactly as a plain render does.)
|
|
681
684
|
const page = pageModule || await loadModule(route.file, dev);
|
|
682
685
|
if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
|
|
683
686
|
let tree = await page.default(ctx);
|
|
@@ -1657,6 +1660,36 @@ function wrapHead(opts) {
|
|
|
1657
1660
|
// importmap-rails) applies nonce on every modulepreload tag for
|
|
1658
1661
|
// the same reason.
|
|
1659
1662
|
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
1663
|
+
// Core runtime modulepreload (#1118). Every module the boot imports pulls
|
|
1664
|
+
// `@webjsdev/core`, but the boot script names only page and component URLs,
|
|
1665
|
+
// so without this hint the browser discovers core only after one of those is
|
|
1666
|
+
// fetched AND parsed: one full round trip into the load, which is exactly
|
|
1667
|
+
// where the pre-boot click window lives on a cold, throttled connection.
|
|
1668
|
+
//
|
|
1669
|
+
// The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
|
|
1670
|
+
// map's targets are already base-path-prefixed and content-hashed), so it is
|
|
1671
|
+
// byte-identical to what the import resolves to. A differing href makes the
|
|
1672
|
+
// browser treat the preload and the import as two resources and fetch core
|
|
1673
|
+
// twice. That is why this copies the vendor loop below, not the module loop
|
|
1674
|
+
// above. `vendorPreloadTargets` still excludes core deliberately; this hint
|
|
1675
|
+
// is emitted from the head builder, not the vendor path.
|
|
1676
|
+
//
|
|
1677
|
+
// Gated on the boot actually shipping something. A fully elided page ships no
|
|
1678
|
+
// boot module and must not be handed a preload for a runtime it never loads
|
|
1679
|
+
// (#780), which also keeps it off `global-error.{js,ts}`, whose document is
|
|
1680
|
+
// returned verbatim with no importmap and no boot script.
|
|
1681
|
+
if (opts.moduleUrls.length || lazyEntries) {
|
|
1682
|
+
const coreMap = buildImportMap();
|
|
1683
|
+
const coreHref = coreMap.imports['@webjsdev/core'];
|
|
1684
|
+
if (coreHref) {
|
|
1685
|
+
const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
|
|
1686
|
+
const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
|
|
1687
|
+
linkTags.push(
|
|
1688
|
+
`<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
|
|
1689
|
+
`${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1660
1693
|
// Sub-path deployment (issue #256): the modulepreload href is prefixed with
|
|
1661
1694
|
// the base path (a no-op when empty), but `crossorigin` / `integrity` are
|
|
1662
1695
|
// decided on the ORIGINAL url, so the integrity lookup still keys on the
|
|
@@ -2039,7 +2072,7 @@ function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elida
|
|
|
2039
2072
|
* @param {string} prefix
|
|
2040
2073
|
* @param {string} bodyHtml
|
|
2041
2074
|
* @param {string} closer
|
|
2042
|
-
* @param {{ pending: {id: string, promise: Promise<unknown
|
|
2075
|
+
* @param {{ pending: {id: string, promise: Promise<unknown>, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} ctx
|
|
2043
2076
|
* @param {number} status
|
|
2044
2077
|
* @param {Request | undefined} req
|
|
2045
2078
|
* @param {URL | undefined} url
|
|
@@ -2052,7 +2085,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2052
2085
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
2053
2086
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
2054
2087
|
// opts in to caching explicitly via metadata.cacheControl. A non-200 does
|
|
2055
|
-
// NOT inherit it (#1140): the
|
|
2088
|
+
// NOT inherit it (#1140): the form-action re-render is a 422 carrying the
|
|
2056
2089
|
// submitter's own field values and errors, which must never be handed to a
|
|
2057
2090
|
// shared cache just because the page opted into public caching.
|
|
2058
2091
|
headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
|
|
@@ -2095,7 +2128,15 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2095
2128
|
try {
|
|
2096
2129
|
const resolved = await p.promise;
|
|
2097
2130
|
const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
|
|
2098
|
-
|
|
2131
|
+
// Carry the boundary's form scope (#1207). This is a fresh scan
|
|
2132
|
+
// that cannot see the shell the boundary sits in, so without it
|
|
2133
|
+
// a `<button formaction=${fn}>` inside a bound form's boundary
|
|
2134
|
+
// reads as form-less and is refused, and the catch below turns
|
|
2135
|
+
// that into an empty boundary on a 200 in production. 'unknown'
|
|
2136
|
+
// when the entry predates the field: defer rather than refuse.
|
|
2137
|
+
const html = await renderToString(resolved, {
|
|
2138
|
+
ssr: true, suspenseCtx: sub, formScope: p.formScope || 'unknown',
|
|
2139
|
+
});
|
|
2099
2140
|
ctx.nextId = sub.nextId;
|
|
2100
2141
|
for (const n of sub.pending) ctx.pending.push(n);
|
|
2101
2142
|
return { id: p.id, html };
|
|
@@ -2141,13 +2182,12 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2141
2182
|
* serves a single evaluation; in dev a cache-bust query forces a fresh
|
|
2142
2183
|
* evaluation so source edits take effect (which also re-runs the module's
|
|
2143
2184
|
* top-level side effects, the reason pages/layouts must keep their top level
|
|
2144
|
-
* side-effect-free).
|
|
2145
|
-
* way the SSR re-render does.
|
|
2185
|
+
* side-effect-free).
|
|
2146
2186
|
*
|
|
2147
2187
|
* @param {string} file
|
|
2148
2188
|
* @param {boolean} dev
|
|
2149
2189
|
*/
|
|
2150
|
-
|
|
2190
|
+
async function loadModule(file, dev) {
|
|
2151
2191
|
const url = pathToFileURL(file).toString();
|
|
2152
2192
|
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
2153
2193
|
return import(url + bust);
|
package/src/stream.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The payload is plain HTML, a `<webjs-stream action target>` wrapping one
|
|
7
7
|
* `<template>`. These helpers compose that string for the two delivery paths,
|
|
8
8
|
* a content-negotiated HTTP form response and a `broadcast()` / WS message, so
|
|
9
|
-
* an app never hand-writes the markup. A
|
|
9
|
+
* an app never hand-writes the markup. A form-bound action or a `route` handler
|
|
10
10
|
* branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
|
|
11
11
|
* router asked for a stream and a normal render otherwise (the JS-off degrade).
|
|
12
12
|
*
|
package/src/testing.d.ts
CHANGED
|
@@ -42,6 +42,25 @@ export declare function withCookies(init: RequestInit | undefined, cookieValue:
|
|
|
42
42
|
/** Merge a session cookie string into a `RequestInit`'s `Cookie` header. */
|
|
43
43
|
export declare function withSessionCookie(init: RequestInit | undefined, sessionCookie: string): RequestInit;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Submit a page's `<form action={action}>` the way a browser with JS OFF does:
|
|
47
|
+
* render the page, reuse the identity the server put in its hidden
|
|
48
|
+
* `__webjs_action` field, and POST it back with the given fields. A POST that
|
|
49
|
+
* omits that field is not a form submission and is answered `405`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function submitForm(
|
|
52
|
+
handle: Handle,
|
|
53
|
+
path: string,
|
|
54
|
+
fields?: Record<string, string> | URLSearchParams | FormData,
|
|
55
|
+
opts?: {
|
|
56
|
+
index?: number;
|
|
57
|
+
match?: string | RegExp;
|
|
58
|
+
cookies?: string;
|
|
59
|
+
submitPath?: string;
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
},
|
|
62
|
+
): Promise<Response>;
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
|
|
47
66
|
* default) and capture the genuine signed session `Set-Cookie`.
|
package/src/testing.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
+
import { FORM_ACTION_FIELD } from '@webjsdev/core';
|
|
36
37
|
import { getSerializer } from './serializer.js';
|
|
37
38
|
import { join, sep } from 'node:path';
|
|
38
39
|
|
|
@@ -200,6 +201,118 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
|
|
|
200
201
|
return { cookies, setCookies, response: res };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Submit a page's `<form action=${action}>` the way a browser with JS OFF does
|
|
206
|
+
* (#1155): render the page, take the identity the server put in its hidden
|
|
207
|
+
* `__webjs_action` field, and POST it back with the author's fields.
|
|
208
|
+
*
|
|
209
|
+
* A bound form carries that field because the identity is what tells the
|
|
210
|
+
* dispatcher WHICH action to run, so a hand-written POST that omits it is not a
|
|
211
|
+
* form submission at all and is answered `405`. Reproducing the scrape in every
|
|
212
|
+
* app test is how that ends up looking like a database problem instead: the
|
|
213
|
+
* status is wrong, an assertion fails, and a `catch` around it reports a skip.
|
|
214
|
+
* Hence a helper, so the identity is never the test's business.
|
|
215
|
+
*
|
|
216
|
+
* The form is located by INDEX (`opts.index`, default the first on the page),
|
|
217
|
+
* which is enough for the common one-form page. For a page with several, pass
|
|
218
|
+
* `opts.match`: a string or RegExp that must appear in the form's markup, so
|
|
219
|
+
* the test names the form by something an author recognizes (a heading, a field
|
|
220
|
+
* name, an id) rather than by counting.
|
|
221
|
+
*
|
|
222
|
+
* @param {Handle} handle
|
|
223
|
+
* @param {string} path the page to render AND, by default, to submit back to
|
|
224
|
+
* @param {Record<string, string> | URLSearchParams | FormData} [fields]
|
|
225
|
+
* @param {{
|
|
226
|
+
* index?: number,
|
|
227
|
+
* match?: string | RegExp,
|
|
228
|
+
* cookies?: string,
|
|
229
|
+
* submitPath?: string,
|
|
230
|
+
* headers?: Record<string, string>,
|
|
231
|
+
* }} [opts]
|
|
232
|
+
* @returns {Promise<Response>} the submission response (a `303` on success, a
|
|
233
|
+
* `422` re-render on a failure result)
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* The `<form>...</form>` blocks of a rendered page.
|
|
237
|
+
*
|
|
238
|
+
* A bare `/<form[\s\S]*?<\/form>/g` gets two things wrong that a test helper
|
|
239
|
+
* must not, because both fail SILENTLY: a form inside an HTML comment is
|
|
240
|
+
* matched (so `opts.index` counts a form that is not on the page), and a
|
|
241
|
+
* `</form>` sitting inside an ATTRIBUTE VALUE ends the match early (so the
|
|
242
|
+
* identity field falls outside it and the helper reports an unbound form).
|
|
243
|
+
* Comments are stripped first, and the scan skips quoted attribute values while
|
|
244
|
+
* looking for the end of the start tag.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} html
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
function findForms(html) {
|
|
250
|
+
const src = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
251
|
+
const out = [];
|
|
252
|
+
const open = /<form\b/gi;
|
|
253
|
+
let m;
|
|
254
|
+
while ((m = open.exec(src))) {
|
|
255
|
+
// Walk the start tag, skipping quoted values so `data-x="<form>"` is inert.
|
|
256
|
+
let i = m.index + m[0].length;
|
|
257
|
+
let quote = '';
|
|
258
|
+
for (; i < src.length; i++) {
|
|
259
|
+
const c = src[i];
|
|
260
|
+
if (quote) { if (c === quote) quote = ''; continue; }
|
|
261
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
262
|
+
if (c === '>') break;
|
|
263
|
+
}
|
|
264
|
+
const close = src.toLowerCase().indexOf('</form>', i);
|
|
265
|
+
if (close === -1) continue;
|
|
266
|
+
out.push(src.slice(m.index, close + '</form>'.length));
|
|
267
|
+
open.lastIndex = close;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function submitForm(handle, path, fields = {}, opts = {}) {
|
|
273
|
+
const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
|
|
274
|
+
const page = await testRequest(handle, path, getInit);
|
|
275
|
+
const html = await page.text();
|
|
276
|
+
|
|
277
|
+
const forms = findForms(html);
|
|
278
|
+
const candidates = opts.match
|
|
279
|
+
? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
|
|
280
|
+
: forms;
|
|
281
|
+
const form = candidates[opts.index || 0];
|
|
282
|
+
if (!form) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
|
|
285
|
+
+ `(status ${page.status}, ${forms.length} form(s) on the page).`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
|
|
289
|
+
if (!id) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`submitForm: the <form> on ${path} carries no __webjs_action field, so it `
|
|
292
|
+
+ 'is not bound to a server action. Write it as `<form action=${myAction}>` '
|
|
293
|
+
+ 'with the action imported from a `use server` module.',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = new URLSearchParams();
|
|
298
|
+
body.set(FORM_ACTION_FIELD, id[1]);
|
|
299
|
+
const entries = fields instanceof URLSearchParams || fields instanceof FormData
|
|
300
|
+
? fields.entries()
|
|
301
|
+
: Object.entries(fields);
|
|
302
|
+
for (const [k, v] of entries) body.append(k, String(v));
|
|
303
|
+
|
|
304
|
+
const init = {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
|
|
307
|
+
body: body.toString(),
|
|
308
|
+
};
|
|
309
|
+
return await testRequest(
|
|
310
|
+
handle,
|
|
311
|
+
opts.submitPath || path,
|
|
312
|
+
opts.cookies ? withCookies(init, opts.cookies) : init,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
203
316
|
/**
|
|
204
317
|
* Compute the RPC endpoint path for an action, addressing it the same way the
|
|
205
318
|
* generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
|