@webjsdev/server 0.9.0 → 0.10.0

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 (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  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 +96 -14
  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 +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. 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.
@@ -485,7 +505,20 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
485
505
  // 422 re-render) from seeing a key it did not put there.
486
506
  formData.delete(FORM_ACTION_FIELD);
487
507
 
488
- const found = await lookupActionIdentity(actionIndex, id);
508
+ // `<form action=${rsvp.bind(null, slug)}>` submits `<hash>/<fn>#<args>`: the
509
+ // action's own identity, then the bound arguments, which the call below puts
510
+ // ahead of the form input. A visitor can rewrite them like any field, so the
511
+ // action authorizes them (as in Next, where an action is a public endpoint).
512
+ /** @type {unknown[]} */
513
+ let bound;
514
+ let baseId;
515
+ try {
516
+ ({ id: baseId, args: bound } = splitActionId(id));
517
+ } catch {
518
+ return new Response('Malformed bound action arguments', { status: 400 });
519
+ }
520
+
521
+ const found = await lookupActionIdentity(actionIndex, baseId);
489
522
  if (!found.ok) {
490
523
  // The action's module threw at import (a bad env var read at module scope,
491
524
  // a syntax error in dev). That is a server fault, not skew: telling the
@@ -493,8 +526,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
493
526
  // load, and swallowing the error would leave nothing in the log or the APM
494
527
  // sink. Surface it the way any other action throw is surfaced.
495
528
  if (found.reason === 'load-failed') {
496
- if (typeof onError === 'function') onError(found.error);
497
- return await formActionErrorResponse(found.error, ssrOpts.dev);
529
+ return errorPage(found.error);
498
530
  }
499
531
  if (found.reason === 'skew') {
500
532
  return ssrPage(route, params, url, {
@@ -539,29 +571,66 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
539
571
  let args = [formInput(formData, { warn, label: fnName })];
540
572
  const validate = actionConfigFn(mod, 'validate');
541
573
  if (typeof validate === 'function') {
542
- const v = runValidate(validate, args[0]);
574
+ const v = await runValidate(validate, args[0]);
543
575
  if (!v.ok) {
544
- if (v.thrown !== undefined) {
545
- // A THROWN validator is answered here rather than rethrown. Letting it
576
+ if (v.thrown !== undefined && !v.invalid) {
577
+ // A validator that crashed (any throw but a schema library's
578
+ // validation error) is answered here rather than rethrown. Letting it
546
579
  // escape reaches the handler's last-resort catch, which reports the
547
580
  // SAME error to `onError` a second time and answers a bare 500 with no
548
581
  // digest, so the one thing that makes a prod error diagnosable is lost.
549
582
  // 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);
583
+ return errorPage(v.thrown);
552
584
  }
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).
585
+ // A structured failure envelope, or a schema library's validation error
586
+ // (a throw carrying `issues`, mapped to fieldErrors by runValidate for
587
+ // every transport), is a NORMAL result the page renders, so it takes the
588
+ // 422 re-render path exactly as a failing action would, submitted values
589
+ // included (#1581).
556
590
  return ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(v.result, formData), status: 422 });
557
591
  }
558
592
  args = [v.value];
559
593
  }
560
594
 
561
- const searchParams = Object.fromEntries(url.searchParams.entries());
595
+ const searchParams = searchParamsRecord(url.searchParams);
596
+ // Next.js's `useActionState` contract: an action declaring TWO parameters is
597
+ // called `(prevState, formData)`, prevState being what this same action
598
+ // returned last time (carried by the signed flash cookie; undefined on the
599
+ // first submission). validate sees the single input; middleware and
600
+ // invalidates see the call's arguments, the bound ones first, as over RPC.
601
+ // Bound arguments come before both, so `(slug, prevState, formData)` reads
602
+ // its prevState once the bound parameters are counted off, as in Next.
603
+ // The count is the DECLARED one (#1686): `fn.length` stops at the first
604
+ // default, so `(prevState = initial, formData)` would read as 0, and that
605
+ // default is how an action supplies the first submission's state.
606
+ // The form data's slot is the parameter after prevState, and it has no
607
+ // default in that shape: `(input, opts = {})` is an ordinary action whose
608
+ // optional second parameter must not turn its input into a prevState.
609
+ const original = originalAction(fn);
610
+ const defaults = declaredParamDefaults(original);
611
+ const withPrevState = declaredParamCount(original) - bound.length >= 2 && !(defaults && defaults[bound.length + 1]);
612
+ const flash = withPrevState ? await readFlash(req) : null;
613
+ const prevState = flash && flash.id === id ? flash.result : undefined;
562
614
  /** @type {ActionResult | undefined} */
563
615
  let result;
564
616
  let ranAction = false;
617
+ // What a COMPLETED action owes the cache and the browser, whichever way it
618
+ // finished (a returned result or a thrown redirect()): its `invalidates` tags
619
+ // evicted and reported, and any revalidatePath() reported. A middleware
620
+ // short-circuit (the action never ran) evicts nothing.
621
+ const completionHeaders = async () => {
622
+ /** @type {string[]} */
623
+ let invalidated = [];
624
+ if (ranAction) {
625
+ invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), [...bound, ...args]);
626
+ if (invalidated.length) await revalidateTags(invalidated);
627
+ }
628
+ /** @type {Record<string, string> | null} */
629
+ let headers = invalidated.length ? { 'x-webjs-invalidate': invalidated.join(',') } : null;
630
+ const refresh = refreshHeaderValue();
631
+ if (refresh) headers = { ...(headers || {}), 'x-webjs-refresh': refresh };
632
+ return headers;
633
+ };
565
634
  try {
566
635
  const middleware = actionMiddleware(mod);
567
636
  result = await runWithActionSignal(actionReq.signal, () =>
@@ -569,7 +638,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
569
638
  middleware,
570
639
  {
571
640
  request: actionReq,
572
- args,
641
+ args: [...bound, ...args],
573
642
  signal: actionReq.signal,
574
643
  // Route context a form middleware may want, absent on the RPC path
575
644
  // because an RPC call has no page. Params are awaitable AND
@@ -578,17 +647,25 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
578
647
  searchParams: makeThenable(searchParams),
579
648
  url,
580
649
  },
581
- () => { ranAction = true; return fn(...args); },
650
+ () => { ranAction = true; return withPrevState ? fn(...bound, prevState, ...args) : fn(...bound, ...args); },
582
651
  ));
583
652
  } catch (err) {
584
653
  if (isRedirect(err)) {
585
654
  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 } });
655
+ // A thrown redirect from a form action ALWAYS answers 303 See Other, as
656
+ // Next.js does for a server action: the browser follows with a GET, so
657
+ // the mutation runs once. A 307 (the old default) re-POSTed the body,
658
+ // identity field included, and ran the action a second time on the
659
+ // target page. An explicit status is ignored here for the same reason.
660
+ // The action finished its work before throwing (create, then redirect),
661
+ // so it evicts and reports exactly as a returned result does.
662
+ const done = await completionHeaders();
663
+ const res = new Response(null, { status: 303, headers: { location: headerSafeLocation(e.url), ...done } });
664
+ // The action finished, so an earlier flash (a failure kept as the next
665
+ // prevState) no longer describes it: in Next the redirect unmounts the
666
+ // form and its state goes with it.
667
+ res.headers.append('set-cookie', flashSetCookie(null));
668
+ return res;
592
669
  }
593
670
  // `route` and `params` ride along so the boundary renders inside its
594
671
  // layouts, exactly as it does on the page-render path (#1298).
@@ -606,8 +683,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
606
683
  // boundary as the page-render path (#848), not a generic 500.
607
684
  if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
608
685
  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);
686
+ return errorPage(err);
611
687
  }
612
688
 
613
689
  // A streamed return (#489) has no consumer here. The RPC stub decodes frames;
@@ -619,8 +695,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
619
695
  `The action ${fnName} returned a stream, which a form submission cannot consume. `
620
696
  + 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
621
697
  );
622
- if (typeof onError === 'function') onError(err);
623
- return await formActionErrorResponse(err, ssrOpts.dev);
698
+ return errorPage(err);
624
699
  }
625
700
 
626
701
  // Only a COMPLETED action evicts its `invalidates` tags; a middleware
@@ -634,14 +709,10 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
634
709
  // after a successful PRG the client learns nothing from it. The redirect's
635
710
  // own re-render is server-side and seeds fresh data, so what remains exposed
636
711
  // 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;
712
+ // revalidatePath() in the action is reported too: the router drops its
713
+ // cached copies of those paths when it applies an in-place response (the
714
+ // redirect a success answers with re-renders the target on the server anyway).
715
+ const invalidateHeader = await completionHeaders();
645
716
 
646
717
  // An action MAY return a `Response` directly (e.g. a content-negotiated
647
718
  // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
@@ -656,10 +727,32 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
656
727
  // page's own path so a poisoned value cannot become an open redirect.
657
728
  const ownPath = (url.pathname + url.search) || '/';
658
729
  const safe = result ? sameSiteRedirect(result.redirect) : null;
659
- return new Response(null, {
730
+ // The PUBLIC target the browser goes to. The action's own `redirect` is
731
+ // written as the browser sees it (with the `webjs.basePath` mount, as an
732
+ // author's links are), while the page's own path comes from the request,
733
+ // which lost the mount at ingress, so it gets the mount back. Without it a
734
+ // mounted app's success redirect left the app (`Location: /f`).
735
+ const mount = appBasePath();
736
+ const target = safe || withBasePath(ownPath, mount);
737
+ const res = new Response(null, {
660
738
  status: 303,
661
- headers: { location: safe || ownPath, ...invalidateHeader },
739
+ // Header-safe, as every thrown redirect() is: a non-Latin-1 target
740
+ // would otherwise throw while the Response is built and turn a
741
+ // successful write into a 500.
742
+ headers: { location: headerSafeLocation(target), ...invalidateHeader },
662
743
  });
744
+ // The result survives the redirect (Next's useActionState shows a success
745
+ // message after a submit): the page it lands on reads it as actionData
746
+ // once, and the next submission of this action gets it as prevState.
747
+ // A success with nothing to carry still replaces an earlier flash, so the
748
+ // next submission's prevState is never a stale failure.
749
+ // Bound to the page the redirect lands on, so no other page shows it,
750
+ // app-root-relative as the render compares it: the mount stripped ONCE
751
+ // from the public target.
752
+ const path = appRelativePath(new URL(target, url).pathname, mount);
753
+ const value = result !== undefined && result !== null ? await encodeFlash({ id, result, shown: false, path }) : null;
754
+ res.headers.append('set-cookie', flashSetCookie(value));
755
+ return res;
663
756
  }
664
757
 
665
758
  // FAILURE: re-render the SAME page with the action result available on
@@ -674,32 +767,14 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
674
767
  if (invalidateHeader && page instanceof Response) {
675
768
  for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
676
769
  }
770
+ // The failure is already on screen, so it is stored as SHOWN: only the next
771
+ // submission's prevState reads it.
772
+ if (page instanceof Response) {
773
+ const value = await encodeFlash({ id, result, shown: true });
774
+ if (value) {
775
+ try { page.headers.append('set-cookie', flashSetCookie(value)); } catch { /* immutable: skip the carry */ }
776
+ }
777
+ }
677
778
  return page;
678
779
  }
679
780
 
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;