@webjsdev/server 0.9.0 → 0.10.1

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.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -1,10 +1,16 @@
1
- import { isNotFound, isRedirect, isForbidden, isUnauthorized, FORM_ACTION_FIELD } from '@webjsdev/core';
1
+ import { isNotFound, isRedirect, isForbidden, isUnauthorized, FORM_ACTION_FIELD, splitActionId } from '@webjsdev/core';
2
+ import { headerSafeLocation } from './redirect-location.js';
2
3
  import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized } from './ssr.js';
3
4
  import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
- import { getBodyLimits } from './context.js';
5
+ import { getBodyLimits, refreshHeaderValue } from './context.js';
6
+ import { readFlash, encodeFlash, flashSetCookie, appRelativePath } from './action-flash.js';
7
+ import { basePath as appBasePath } from './importmap.js';
8
+ import { withBasePath } from './base-path.js';
5
9
  import { propagateTrustedRemoteIp } from './rate-limit.js';
6
- import { makeThenable } from './thenable-params.js';
10
+ import { makeThenable, searchParamsRecord } from './thenable-params.js';
7
11
  import { lookupActionIdentity } from './form-action-identity.js';
12
+ import { originalAction } from './action-seed.js';
13
+ import { declaredParamCount, declaredParamDefaults } from './action-params.js';
8
14
  import { verifyOrigin } from './csrf.js';
9
15
  import { actionMethod, actionConfigFn, actionMiddleware, resolveTags, RESERVED_CONFIG } from './action-config.js';
10
16
  import { runValidate } from './actions.js';
@@ -13,7 +19,6 @@ import { runActionChain } from './action-middleware.js';
13
19
  import { runWithActionSignal } from './action-signal.js';
14
20
  import { isStreamable } from './action-stream.js';
15
21
  import { revalidateTags } from './cache-tags.js';
16
- import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
17
22
 
18
23
  /**
19
24
  * The form-submission dispatcher (#1155): a non-GET request to a PAGE's own url
@@ -73,7 +78,9 @@ export const ACTION_SKEW_MESSAGE =
73
78
  * success (PRG redirect). A result is a failure when ANY of these hold:
74
79
  * - `result.success === false` (explicit), OR
75
80
  * - `result.fieldErrors` is present (per-field validation messages), OR
76
- * - `result.error` is present AND `result.success !== true`.
81
+ * - `result.error` is present AND `result.success !== true`, OR
82
+ * - `result.errors` is present AND `result.success !== true` (the shape
83
+ * Next.js examples return from a `useActionState` action, `{ errors }`).
77
84
  *
78
85
  * Success is the explicit `success: true`, or a bare value (or
79
86
  * undefined/null) carrying no error markers. This means an action that returns
@@ -88,6 +95,7 @@ function isFailureResult(result) {
88
95
  if (result.success === false) return true;
89
96
  if (result.fieldErrors != null) return true;
90
97
  if (result.error != null && result.success !== true) return true;
98
+ if (result.errors != null && result.success !== true) return true;
91
99
  return false;
92
100
  }
93
101
 
@@ -440,6 +448,18 @@ function methodNotAllowed() {
440
448
  */
441
449
  export async function runFormAction(route, params, url, req, ssrOpts, deps) {
442
450
  const { actionIndex, allowedOrigins = [], onError, logger } = deps;
451
+ // An action that throws answers with the page's nearest error.ts (else
452
+ // global-error, else the default 500 page), the way a failed page render does
453
+ // and the way Next.js answers a server action that throws (#1686): not a bare
454
+ // text 500. The boundary sees a sanitized error in production, and the sink
455
+ // hears it once, as an action error.
456
+ /** @param {unknown} e */
457
+ // `throw undefined` would read as "no error" downstream (renderError is
458
+ // checked against undefined), so it becomes a real Error first.
459
+ const errorPage = (e) => ssrPage(route, params, url, {
460
+ ...ssrOpts, req, onError,
461
+ renderError: e === undefined ? new Error('[webjs] the action threw undefined') : e,
462
+ });
443
463
 
444
464
  // Not a form body at all (a stray JSON POST, a probe): the page path exists
445
465
  // and only renders. Answered before the body is touched.
@@ -449,7 +469,8 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
449
469
  // Sec-Fetch-Site check the RPC endpoint applies (`csrf.js`). The page `action`
450
470
  // export this replaces had none, and was shielded only by SameSite=Lax cookies.
451
471
  const origin = verifyOrigin(req, allowedOrigins);
452
- if (!origin.ok) return new Response('CSRF validation failed', { status: 403 });
472
+ // The reason names the mismatched Origin / host and the fix, never a secret.
473
+ if (!origin.ok) return new Response(`CSRF validation failed: ${origin.reason}`, { status: 403 });
453
474
 
454
475
  let formData = new FormData();
455
476
  let actionReq = req;
@@ -485,7 +506,20 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
485
506
  // 422 re-render) from seeing a key it did not put there.
486
507
  formData.delete(FORM_ACTION_FIELD);
487
508
 
488
- const found = await lookupActionIdentity(actionIndex, id);
509
+ // `<form action=${rsvp.bind(null, slug)}>` submits `<hash>/<fn>#<args>`: the
510
+ // action's own identity, then the bound arguments, which the call below puts
511
+ // ahead of the form input. A visitor can rewrite them like any field, so the
512
+ // action authorizes them (as in Next, where an action is a public endpoint).
513
+ /** @type {unknown[]} */
514
+ let bound;
515
+ let baseId;
516
+ try {
517
+ ({ id: baseId, args: bound } = splitActionId(id));
518
+ } catch {
519
+ return new Response('Malformed bound action arguments', { status: 400 });
520
+ }
521
+
522
+ const found = await lookupActionIdentity(actionIndex, baseId);
489
523
  if (!found.ok) {
490
524
  // The action's module threw at import (a bad env var read at module scope,
491
525
  // a syntax error in dev). That is a server fault, not skew: telling the
@@ -493,8 +527,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
493
527
  // load, and swallowing the error would leave nothing in the log or the APM
494
528
  // sink. Surface it the way any other action throw is surfaced.
495
529
  if (found.reason === 'load-failed') {
496
- if (typeof onError === 'function') onError(found.error);
497
- return await formActionErrorResponse(found.error, ssrOpts.dev);
530
+ return errorPage(found.error);
498
531
  }
499
532
  if (found.reason === 'skew') {
500
533
  return ssrPage(route, params, url, {
@@ -539,29 +572,66 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
539
572
  let args = [formInput(formData, { warn, label: fnName })];
540
573
  const validate = actionConfigFn(mod, 'validate');
541
574
  if (typeof validate === 'function') {
542
- const v = runValidate(validate, args[0]);
575
+ const v = await runValidate(validate, args[0]);
543
576
  if (!v.ok) {
544
- if (v.thrown !== undefined) {
545
- // A THROWN validator is answered here rather than rethrown. Letting it
577
+ if (v.thrown !== undefined && !v.invalid) {
578
+ // A validator that crashed (any throw but a schema library's
579
+ // validation error) is answered here rather than rethrown. Letting it
546
580
  // escape reaches the handler's last-resort catch, which reports the
547
581
  // SAME error to `onError` a second time and answers a bare 500 with no
548
582
  // digest, so the one thing that makes a prod error diagnosable is lost.
549
583
  // The RPC path does exactly this (`actionErrorResponse` in actions.js).
550
- if (typeof onError === 'function') onError(v.thrown);
551
- return await formActionErrorResponse(v.thrown, ssrOpts.dev);
584
+ return errorPage(v.thrown);
552
585
  }
553
- // A structured failure envelope is a NORMAL result the page renders, so
554
- // it takes the 422 re-render path exactly as a failing action would,
555
- // submitted values included (#1581).
586
+ // A structured failure envelope, or a schema library's validation error
587
+ // (a throw carrying `issues`, mapped to fieldErrors by runValidate for
588
+ // every transport), is a NORMAL result the page renders, so it takes the
589
+ // 422 re-render path exactly as a failing action would, submitted values
590
+ // included (#1581).
556
591
  return ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(v.result, formData), status: 422 });
557
592
  }
558
593
  args = [v.value];
559
594
  }
560
595
 
561
- const searchParams = Object.fromEntries(url.searchParams.entries());
596
+ const searchParams = searchParamsRecord(url.searchParams);
597
+ // Next.js's `useActionState` contract: an action declaring TWO parameters is
598
+ // called `(prevState, formData)`, prevState being what this same action
599
+ // returned last time (carried by the signed flash cookie; undefined on the
600
+ // first submission). validate sees the single input; middleware and
601
+ // invalidates see the call's arguments, the bound ones first, as over RPC.
602
+ // Bound arguments come before both, so `(slug, prevState, formData)` reads
603
+ // its prevState once the bound parameters are counted off, as in Next.
604
+ // The count is the DECLARED one (#1686): `fn.length` stops at the first
605
+ // default, so `(prevState = initial, formData)` would read as 0, and that
606
+ // default is how an action supplies the first submission's state.
607
+ // The form data's slot is the parameter after prevState, and it has no
608
+ // default in that shape: `(input, opts = {})` is an ordinary action whose
609
+ // optional second parameter must not turn its input into a prevState.
610
+ const original = originalAction(fn);
611
+ const defaults = declaredParamDefaults(original);
612
+ const withPrevState = declaredParamCount(original) - bound.length >= 2 && !(defaults && defaults[bound.length + 1]);
613
+ const flash = withPrevState ? await readFlash(req) : null;
614
+ const prevState = flash && flash.id === id ? flash.result : undefined;
562
615
  /** @type {ActionResult | undefined} */
563
616
  let result;
564
617
  let ranAction = false;
618
+ // What a COMPLETED action owes the cache and the browser, whichever way it
619
+ // finished (a returned result or a thrown redirect()): its `invalidates` tags
620
+ // evicted and reported, and any revalidatePath() reported. A middleware
621
+ // short-circuit (the action never ran) evicts nothing.
622
+ const completionHeaders = async () => {
623
+ /** @type {string[]} */
624
+ let invalidated = [];
625
+ if (ranAction) {
626
+ invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), [...bound, ...args]);
627
+ if (invalidated.length) await revalidateTags(invalidated);
628
+ }
629
+ /** @type {Record<string, string> | null} */
630
+ let headers = invalidated.length ? { 'x-webjs-invalidate': invalidated.join(',') } : null;
631
+ const refresh = refreshHeaderValue();
632
+ if (refresh) headers = { ...(headers || {}), 'x-webjs-refresh': refresh };
633
+ return headers;
634
+ };
565
635
  try {
566
636
  const middleware = actionMiddleware(mod);
567
637
  result = await runWithActionSignal(actionReq.signal, () =>
@@ -569,7 +639,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
569
639
  middleware,
570
640
  {
571
641
  request: actionReq,
572
- args,
642
+ args: [...bound, ...args],
573
643
  signal: actionReq.signal,
574
644
  // Route context a form middleware may want, absent on the RPC path
575
645
  // because an RPC call has no page. Params are awaitable AND
@@ -578,17 +648,25 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
578
648
  searchParams: makeThenable(searchParams),
579
649
  url,
580
650
  },
581
- () => { ranAction = true; return fn(...args); },
651
+ () => { ranAction = true; return withPrevState ? fn(...bound, prevState, ...args) : fn(...bound, ...args); },
582
652
  ));
583
653
  } catch (err) {
584
654
  if (isRedirect(err)) {
585
655
  const e = /** @type any */ (err);
586
- // A thrown redirect from an action (a POST) defaults to 307 Temporary
587
- // Redirect, which is method-preserving so the action's intent survives
588
- // the bounce; an explicit `redirect(url, status)` overrides it. This is
589
- // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
590
- // the SUCCESS-result path below.
591
- return new Response(null, { status: e.status || 307, headers: { location: e.url } });
656
+ // A thrown redirect from a form action ALWAYS answers 303 See Other, as
657
+ // Next.js does for a server action: the browser follows with a GET, so
658
+ // the mutation runs once. A 307 (the old default) re-POSTed the body,
659
+ // identity field included, and ran the action a second time on the
660
+ // target page. An explicit status is ignored here for the same reason.
661
+ // The action finished its work before throwing (create, then redirect),
662
+ // so it evicts and reports exactly as a returned result does.
663
+ const done = await completionHeaders();
664
+ const res = new Response(null, { status: 303, headers: { location: headerSafeLocation(e.url), ...done } });
665
+ // The action finished, so an earlier flash (a failure kept as the next
666
+ // prevState) no longer describes it: in Next the redirect unmounts the
667
+ // form and its state goes with it.
668
+ res.headers.append('set-cookie', flashSetCookie(null));
669
+ return res;
592
670
  }
593
671
  // `route` and `params` ride along so the boundary renders inside its
594
672
  // layouts, exactly as it does on the page-render path (#1298).
@@ -606,8 +684,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
606
684
  // boundary as the page-render path (#848), not a generic 500.
607
685
  if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
608
686
  if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url, route, params });
609
- if (typeof onError === 'function') onError(err);
610
- return await formActionErrorResponse(err, ssrOpts.dev);
687
+ return errorPage(err);
611
688
  }
612
689
 
613
690
  // A streamed return (#489) has no consumer here. The RPC stub decodes frames;
@@ -619,8 +696,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
619
696
  `The action ${fnName} returned a stream, which a form submission cannot consume. `
620
697
  + 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
621
698
  );
622
- if (typeof onError === 'function') onError(err);
623
- return await formActionErrorResponse(err, ssrOpts.dev);
699
+ return errorPage(err);
624
700
  }
625
701
 
626
702
  // Only a COMPLETED action evicts its `invalidates` tags; a middleware
@@ -634,14 +710,10 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
634
710
  // after a successful PRG the client learns nothing from it. The redirect's
635
711
  // own re-render is server-side and seeds fresh data, so what remains exposed
636
712
  // is only a later client refetch of a separately cached GET key.
637
- let invalidated = [];
638
- if (ranAction) {
639
- invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), args);
640
- if (invalidated.length) await revalidateTags(invalidated);
641
- }
642
- const invalidateHeader = invalidated.length
643
- ? { 'x-webjs-invalidate': invalidated.join(',') }
644
- : null;
713
+ // revalidatePath() in the action is reported too: the router drops its
714
+ // cached copies of those paths when it applies an in-place response (the
715
+ // redirect a success answers with re-renders the target on the server anyway).
716
+ const invalidateHeader = await completionHeaders();
645
717
 
646
718
  // An action MAY return a `Response` directly (e.g. a content-negotiated
647
719
  // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
@@ -656,10 +728,32 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
656
728
  // page's own path so a poisoned value cannot become an open redirect.
657
729
  const ownPath = (url.pathname + url.search) || '/';
658
730
  const safe = result ? sameSiteRedirect(result.redirect) : null;
659
- return new Response(null, {
731
+ // The PUBLIC target the browser goes to. The action's own `redirect` is
732
+ // written as the browser sees it (with the `webjs.basePath` mount, as an
733
+ // author's links are), while the page's own path comes from the request,
734
+ // which lost the mount at ingress, so it gets the mount back. Without it a
735
+ // mounted app's success redirect left the app (`Location: /f`).
736
+ const mount = appBasePath();
737
+ const target = safe || withBasePath(ownPath, mount);
738
+ const res = new Response(null, {
660
739
  status: 303,
661
- headers: { location: safe || ownPath, ...invalidateHeader },
740
+ // Header-safe, as every thrown redirect() is: a non-Latin-1 target
741
+ // would otherwise throw while the Response is built and turn a
742
+ // successful write into a 500.
743
+ headers: { location: headerSafeLocation(target), ...invalidateHeader },
662
744
  });
745
+ // The result survives the redirect (Next's useActionState shows a success
746
+ // message after a submit): the page it lands on reads it as actionData
747
+ // once, and the next submission of this action gets it as prevState.
748
+ // A success with nothing to carry still replaces an earlier flash, so the
749
+ // next submission's prevState is never a stale failure.
750
+ // Bound to the page the redirect lands on, so no other page shows it,
751
+ // app-root-relative as the render compares it: the mount stripped ONCE
752
+ // from the public target.
753
+ const path = appRelativePath(new URL(target, url).pathname, mount);
754
+ const value = result !== undefined && result !== null ? await encodeFlash({ id, result, shown: false, path }) : null;
755
+ res.headers.append('set-cookie', flashSetCookie(value));
756
+ return res;
663
757
  }
664
758
 
665
759
  // FAILURE: re-render the SAME page with the action result available on
@@ -674,32 +768,14 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
674
768
  if (invalidateHeader && page instanceof Response) {
675
769
  for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
676
770
  }
771
+ // The failure is already on screen, so it is stored as SHOWN: only the next
772
+ // submission's prevState reads it.
773
+ if (page instanceof Response) {
774
+ const value = await encodeFlash({ id, result, shown: true });
775
+ if (value) {
776
+ try { page.headers.append('set-cookie', flashSetCookie(value)); } catch { /* immutable: skip the carry */ }
777
+ }
778
+ }
677
779
  return page;
678
780
  }
679
781
 
680
- /**
681
- * A thrown form action becomes a 500 whose body is sanitized the same way the
682
- * RPC path's is (#749): dev shows the real message, prod shows a generic one
683
- * plus a correlation digest and logs the full error. A raw driver message or an
684
- * fs path is not author-controlled, so it does not reach the browser, and here
685
- * the browser is showing it to a person rather than parsing it.
686
- *
687
- * @param {unknown} err
688
- * @param {boolean} [dev]
689
- * @returns {Promise<Response>}
690
- */
691
- async function formActionErrorResponse(err, dev) {
692
- if (dev) {
693
- console.error('[webjs] form action threw:', err);
694
- const msg = err instanceof Error ? err.message : String(err);
695
- return new Response(msg, { status: 500 });
696
- }
697
- if (isControlFlowThrow(err)) {
698
- console.error('[webjs] form action threw:', err);
699
- const msg = err instanceof Error ? err.message : String(err);
700
- return new Response(msg, { status: 500 });
701
- }
702
- const digest = await errorDigest(err);
703
- console.error(`[webjs] form action threw [digest=${digest}]:`, err);
704
- return new Response(`${GENERIC_ERROR_MESSAGE} (digest ${digest})`, { status: 500 });
705
- }
package/src/html-cache.js CHANGED
@@ -28,8 +28,8 @@ import { getStore } from './cache.js';
28
28
  import { STREAM_MARKER } from './conditional-get.js';
29
29
  import { createHash } from 'node:crypto';
30
30
  import { publishedBuildId, basePath } from './importmap.js';
31
- import { stripBasePath } from './base-path.js';
32
- import { dynamicAccessed, getRequest } from './context.js';
31
+ import { stripBasePath, withBasePath } from './base-path.js';
32
+ import { dynamicAccessed, getRequest, recordRevalidatedPath } from './context.js';
33
33
  import { shared } from './process-shared.js';
34
34
 
35
35
  /** Namespace prefix for every cached-HTML key, so a flush can target it. */
@@ -278,11 +278,35 @@ export function isCacheableResponse(res, guards = {}) {
278
278
  * is ever seen, or grows an unbounded one. Either way eviction silently stops
279
279
  * working, which is worse than the explicit contract above.
280
280
  *
281
+ * Next.js parity: the optional second argument is Next's type. `'layout'`
282
+ * revalidates the path AND everything below it (here: the whole HTML cache),
283
+ * and a route PATTERN (`'/blog/[slug]'`) revalidates every page it matches
284
+ * (also the whole cache, since entries are keyed by concrete url). When called
285
+ * from a server action invoked over RPC, the paths ride back to the browser on
286
+ * `x-webjs-refresh`, and the page on screen re-renders in place if it is one
287
+ * of them, so the caller sees the fresh data with no reload.
288
+ *
281
289
  * @param {string} path e.g. '/blog', '/blog?page=2', or 'https://app.example/blog'
290
+ * @param {'page' | 'layout'} [type]
282
291
  * @returns {Promise<void>}
283
292
  */
284
- export async function revalidatePath(path) {
293
+ export async function revalidatePath(path, type) {
285
294
  if (typeof path !== 'string' || !path) return;
295
+ const layout = type === 'layout';
296
+ {
297
+ // The browser compares the reported path with location.pathname, which
298
+ // carries webjs.basePath. An absolute url is already the public form; a
299
+ // bare path is app-relative (what the caller writes), so the base path is
300
+ // put back for the report.
301
+ let pathname = path;
302
+ if (/^https?:\/\//i.test(path)) { try { pathname = new URL(path).pathname; } catch { /* keep */ } }
303
+ else pathname = withBasePath(pathname, basePath());
304
+ recordRevalidatedPath(pathname.split('?')[0] || '/', layout);
305
+ }
306
+ if (layout || path.includes('[')) {
307
+ await revalidateAll();
308
+ return;
309
+ }
286
310
  let url;
287
311
  if (/^https?:\/\//i.test(path)) {
288
312
  try { url = new URL(path); } catch { return; }
@@ -367,8 +391,15 @@ export async function commitHtmlCache(req, res, url) {
367
391
  const marker = res.headers.get(HTML_CACHE_MARKER);
368
392
  if (!marker) return res;
369
393
  res.headers.delete(HTML_CACHE_MARKER);
370
- const revalidateSeconds = Number(marker);
394
+ // `<seconds>:<generation>`, the generation the render STARTED under (#1689).
395
+ // A render that straddled a `revalidateAll()` (every dev rebuild ends with
396
+ // one) produced bytes from before the eviction, and writing them now would
397
+ // file them under the new generation's key, where every later request would
398
+ // be served the pre-edit page for the whole window. Skip the write instead.
399
+ const [secondsPart, generationPart] = marker.split(':');
400
+ const revalidateSeconds = Number(secondsPart);
371
401
  if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) return res;
402
+ if (generationPart !== undefined && Number(generationPart) !== _generation.n) return res;
372
403
  // Per-user leak defense (#241). The page set `revalidate` (asserting "same
373
404
  // for everyone"), but if the render actually read per-user request state
374
405
  // (cookies() / headers() / getSession()), its body varies by visitor and
package/src/importmap.js CHANGED
@@ -36,7 +36,7 @@ let _extraEntries = {};
36
36
  /**
37
37
  * Per-build memos (#1644), each `{ epoch, value }` keyed on `buildEpoch()`.
38
38
  * Every setter in this module bumps the epoch, as do the asset-hash roots,
39
- * the elision fingerprint, the hash-cache clear, and the dev rebuild.
39
+ * the serve fingerprint, the hash-cache clear, and the dev rebuild.
40
40
  * @type {{ epoch: number, value: { imports: Record<string, string>, integrity?: Record<string, string> } }}
41
41
  */
42
42
  let _mapMemo = { epoch: 0, value: { imports: {} } };
@@ -550,7 +550,7 @@ export function buildImportMap(opts = {}) {
550
550
  * BYTE-IDENTICAL to the importmap target (same base-path + `?v` rewrite): a
551
551
  * differing href would make the browser treat the preload and the import as two
552
552
  * resources and double-fetch. The matching `integrity` comes from the same map.
553
- * A specifier NOT in the importmap (an unpinned / unreached / elided vendor)
553
+ * A specifier NOT in the importmap (an unpinned / unreached vendor)
554
554
  * yields nothing, so this never over-fetches. Duplicate hrefs are collapsed.
555
555
  *
556
556
  * Framework runtime specifiers (`@webjsdev/core`...) are excluded: core is
@@ -20,6 +20,7 @@ import { stat } from 'node:fs/promises';
20
20
  import { AsyncLocalStorage } from 'node:async_hooks';
21
21
  import { devImport } from './dev-import.js';
22
22
  import { shared } from './process-shared.js';
23
+ import { errorDigest } from './action-error.js';
23
24
 
24
25
  /**
25
26
  * Per-boot handoff channel for a `register()`-registered error sink. An
@@ -73,7 +74,11 @@ export async function findInstrumentationClient(appDir) {
73
74
  *
74
75
  * @param {string} appDir
75
76
  * @param {{ dev?: boolean, logger?: { error?: (...a: any[]) => void } }} [opts]
76
- * @returns {Promise<{ onError: ((error: unknown, ctx?: any) => void) | null }>}
77
+ * The module may also export Next.js's `onRequestError(error, request, context)`,
78
+ * which is returned as `onRequestError` for the request handler to call on
79
+ * every reported server error (see `buildRequestErrorReport`).
80
+ *
81
+ * @returns {Promise<{ onError: ((error: unknown, ctx?: any) => void) | null, onRequestError: OnRequestError | null }>}
77
82
  */
78
83
  export async function runInstrumentation(appDir, opts = {}) {
79
84
  const { dev = false, logger } = opts;
@@ -82,15 +87,18 @@ export async function runInstrumentation(appDir, opts = {}) {
82
87
  const p = join(appDir, name);
83
88
  if (await exists(p)) { file = p; break; }
84
89
  }
85
- if (!file) return { onError: null };
90
+ if (!file) return { onError: null, onRequestError: null };
86
91
  // Per-boot store: setOnError writes here, scoped to THIS run's async context.
87
92
  const store = { onError: null };
93
+ /** @type {OnRequestError | null} */
94
+ let onRequestError = null;
88
95
  await _als.run(store, async () => {
89
96
  try {
90
97
  const mod = await devImport(file, dev);
91
98
  const register = typeof mod.default === 'function'
92
99
  ? mod.default
93
100
  : (typeof mod.register === 'function' ? mod.register : null);
101
+ if (typeof mod.onRequestError === 'function') onRequestError = mod.onRequestError;
94
102
  if (register) await register();
95
103
  } catch (e) {
96
104
  // A failing instrumentation hook must not crash boot: log and continue, the
@@ -98,5 +106,53 @@ export async function runInstrumentation(appDir, opts = {}) {
98
106
  logger?.error?.(`[webjs] instrumentation.{js,ts} register() failed`, { err: String(e) });
99
107
  }
100
108
  });
101
- return { onError: store.onError };
109
+ return { onError: store.onError, onRequestError };
110
+ }
111
+
112
+ /**
113
+ * @typedef {(
114
+ * error: Error & { digest: string },
115
+ * request: { path: string, method: string, headers: Record<string, string> },
116
+ * context: { routerKind: 'App Router', routePath: string, routeType: 'render' | 'route' | 'action' | 'proxy', revalidateReason: undefined, renderSource?: string },
117
+ * ) => void | Promise<void>} OnRequestError
118
+ */
119
+
120
+ /**
121
+ * The internal pipeline phase to Next's `routeType`. A page render, a metadata
122
+ * route, and a last-resort crash in the handler are all `render`; root and
123
+ * segment middleware are `proxy` (Next 16's name for middleware).
124
+ * @type {Record<string, 'render' | 'route' | 'action' | 'proxy'>}
125
+ */
126
+ const ROUTE_TYPE = { ssr: 'render', metadata: 'render', handle: 'render', route: 'route', action: 'action', middleware: 'proxy' };
127
+
128
+ /**
129
+ * Build the three arguments Next.js passes to `onRequestError`, from the error
130
+ * the pipeline caught, the request, and its phase. The error gains a `digest`
131
+ * (the same hash the sanitized production responses carry, so a report maps
132
+ * back to what the client saw) when it does not already have one.
133
+ *
134
+ * @param {unknown} error
135
+ * @param {Request} request
136
+ * @param {string} phase
137
+ * @param {string} [routePath] the app-relative route file path, `/app/blog/[slug]`
138
+ * @returns {Promise<Parameters<OnRequestError>>}
139
+ */
140
+ export async function buildRequestErrorReport(error, request, phase, routePath) {
141
+ /** @type {any} */
142
+ let err = error instanceof Error ? error : new Error(String(error));
143
+ if (typeof err.digest !== 'string') {
144
+ const digest = await errorDigest(err);
145
+ if (Object.isExtensible(err)) err.digest = digest;
146
+ else err = Object.assign(new Error(err.message, { cause: err }), { digest });
147
+ }
148
+ /** @type {Record<string, string>} */
149
+ const headers = {};
150
+ for (const [k, v] of request.headers) headers[k] = v;
151
+ let path = '/';
152
+ try { const u = new URL(request.url); path = u.pathname + u.search; } catch { /* keep / */ }
153
+ return [
154
+ err,
155
+ { path, method: request.method, headers },
156
+ { routerKind: 'App Router', routePath: routePath || path.split('?')[0], routeType: ROUTE_TYPE[phase] || 'render', revalidateReason: undefined },
157
+ ];
102
158
  }
package/src/js-scan.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Low-level lexical scanning helpers shared by the convention validator
3
- * (`check.js`) and the component-elision analyser (`component-elision.js`).
3
+ * (`check.js`), the component scanner and the browser-code signals
4
+ * (`client-signals.js`).
4
5
  *
5
6
  * These are deliberately a hand-rolled lexer, NOT a full TS parse. The
6
7
  * framework prioritises fast dev-time rebuilds; a real parser would be ~50x
@@ -320,7 +321,7 @@ function scanLiterals(src, { placeholder = false, blankStrings = false } = {}) {
320
321
  * Blank ONLY comments, keeping string AND template-literal content verbatim
321
322
  * (position-preserving: same length, newlines kept). The sibling
322
323
  * `redactStringsAndTemplates` blanks templates too, which is wrong for callers
323
- * that need to read inside `html` templates (the elision render-tag scanner) or
324
+ * that need to read inside `html` templates (the render-tag scanner) or
324
325
  * inside string arguments (`whenDefined('tag')`). This keeps both and removes
325
326
  * only comment text, so prose in a comment cannot be read as a real signal
326
327
  * (issue #179). It reuses the same regex-versus-division and tagged-template
@@ -434,8 +435,8 @@ export function extractWebComponentClassBodies(content) {
434
435
  const bodies = [];
435
436
  // The optional `(?:\s*<[^{}();]*?>)?` skips a TypeScript generic parameter
436
437
  // list on the class name (`class Grid<T> extends WebComponent`), so a generic
437
- // component is not missed (which would let elision wrongly strip an
438
- // interactive component, the unsafe direction). The exclusion class keeps the
438
+ // component is not missed (which would leave its class body unlinted, the
439
+ // unsafe direction). The exclusion class keeps the
439
440
  // generic from swallowing the class body / a call, and stops it staying
440
441
  // within the type-parameter list (#753, found differentially).
441
442
  const re = /class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/g;