@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/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 { dynamicAccessed } from './context.js';
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 cache key for a request: the FULL URL (path + search string), since
113
- * `searchParams` change page output. Normalized to path + sorted query so
114
- * `?a=1&b=2` and `?b=2&a=1` share an entry. Two more discriminators are
115
- * folded into the namespace:
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
- * @param {string} path e.g. '/blog' or '/blog?page=2'
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
- try {
232
- url = new URL(path, 'http://internal.invalid');
233
- } catch {
234
- return;
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
@@ -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,
@@ -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
- return (signal) => {
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'); process.exit(0); },
427
- (err) => { logger.error('server close error', { err: String(err) }); process.exit(1); },
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 page-action body rebuild in
130
- * `page-action.js`), the out-of-band WeakMap key is a new object, so the trusted
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') ||
@@ -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 page-action pages) are what a valid href points
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 page-action re-render (actionData / a non-200
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 page `action` submission the caller passes
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 page-action.js so the method is preserved.)
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 page-action write path (page-action.js) so
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 (the page-action
678
- // re-render passes the exact module whose `action` just ran, so the
679
- // failure re-render shares that single evaluation instead of re-importing
680
- // and re-running the module's top-level side effects).
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>}[], nextId: number }} ctx
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 page-action re-render is a 422 carrying 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
- const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
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). Exported so page-action.js loads the page module the same
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
- export async function loadModule(file, dev) {
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 page `action` or a `route` handler
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