@webjsdev/server 0.8.91 → 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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -1,18 +1,24 @@
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';
17
+ import { formInput } from './action-input.js';
11
18
  import { runActionChain } from './action-middleware.js';
12
19
  import { runWithActionSignal } from './action-signal.js';
13
20
  import { isStreamable } from './action-stream.js';
14
21
  import { revalidateTags } from './cache-tags.js';
15
- import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
16
22
 
17
23
  /**
18
24
  * The form-submission dispatcher (#1155): a non-GET request to a PAGE's own url
@@ -72,7 +78,9 @@ export const ACTION_SKEW_MESSAGE =
72
78
  * success (PRG redirect). A result is a failure when ANY of these hold:
73
79
  * - `result.success === false` (explicit), OR
74
80
  * - `result.fieldErrors` is present (per-field validation messages), OR
75
- * - `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 }`).
76
84
  *
77
85
  * Success is the explicit `success: true`, or a bare value (or
78
86
  * undefined/null) carrying no error markers. This means an action that returns
@@ -87,6 +95,7 @@ function isFailureResult(result) {
87
95
  if (result.success === false) return true;
88
96
  if (result.fieldErrors != null) return true;
89
97
  if (result.error != null && result.success !== true) return true;
98
+ if (result.errors != null && result.success !== true) return true;
90
99
  return false;
91
100
  }
92
101
 
@@ -439,6 +448,18 @@ function methodNotAllowed() {
439
448
  */
440
449
  export async function runFormAction(route, params, url, req, ssrOpts, deps) {
441
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
+ });
442
463
 
443
464
  // Not a form body at all (a stray JSON POST, a probe): the page path exists
444
465
  // and only renders. Answered before the body is touched.
@@ -484,7 +505,20 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
484
505
  // 422 re-render) from seeing a key it did not put there.
485
506
  formData.delete(FORM_ACTION_FIELD);
486
507
 
487
- 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);
488
522
  if (!found.ok) {
489
523
  // The action's module threw at import (a bad env var read at module scope,
490
524
  // a syntax error in dev). That is a server fault, not skew: telling the
@@ -492,8 +526,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
492
526
  // load, and swallowing the error would leave nothing in the log or the APM
493
527
  // sink. Surface it the way any other action throw is surfaced.
494
528
  if (found.reason === 'load-failed') {
495
- if (typeof onError === 'function') onError(found.error);
496
- return await formActionErrorResponse(found.error, ssrOpts.dev);
529
+ return errorPage(found.error);
497
530
  }
498
531
  if (found.reason === 'skew') {
499
532
  return ssrPage(route, params, url, {
@@ -527,35 +560,77 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
527
560
  );
528
561
  }
529
562
 
530
- // A form-bound action always receives the `FormData` (#1155). `validate` is
531
- // the typing seam: it takes the FormData and its transform-return becomes the
532
- // action's typed input, the same `runValidate` contract the RPC path uses.
533
- let args = [formData];
563
+ // A form-bound action receives the submitted fields as ONE object that reads
564
+ // both ways (`action-input.js`): `input.title`, the shape the same action gets
565
+ // over RPC and through `route()`, and `input.get('title')`, the FormData
566
+ // readers. It used to receive the raw FormData, so a validator written for
567
+ // the RPC shape read `undefined` and answered every submission with a 422
568
+ // (#1664). `validate`, middleware, the action and `invalidates` all see it.
569
+ // The raw `formData` local keeps feeding the #1581 submitted-values path.
570
+ const warn = ssrOpts.dev ? (logger && logger.warn ? (m) => logger.warn(m) : (m) => console.warn(m)) : undefined;
571
+ let args = [formInput(formData, { warn, label: fnName })];
534
572
  const validate = actionConfigFn(mod, 'validate');
535
573
  if (typeof validate === 'function') {
536
- const v = runValidate(validate, args[0]);
574
+ const v = await runValidate(validate, args[0]);
537
575
  if (!v.ok) {
538
- if (v.thrown !== undefined) {
539
- // 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
540
579
  // escape reaches the handler's last-resort catch, which reports the
541
580
  // SAME error to `onError` a second time and answers a bare 500 with no
542
581
  // digest, so the one thing that makes a prod error diagnosable is lost.
543
582
  // The RPC path does exactly this (`actionErrorResponse` in actions.js).
544
- if (typeof onError === 'function') onError(v.thrown);
545
- return await formActionErrorResponse(v.thrown, ssrOpts.dev);
583
+ return errorPage(v.thrown);
546
584
  }
547
- // A structured failure envelope is a NORMAL result the page renders, so
548
- // it takes the 422 re-render path exactly as a failing action would,
549
- // 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).
550
590
  return ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(v.result, formData), status: 422 });
551
591
  }
552
592
  args = [v.value];
553
593
  }
554
594
 
555
- 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;
556
614
  /** @type {ActionResult | undefined} */
557
615
  let result;
558
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
+ };
559
634
  try {
560
635
  const middleware = actionMiddleware(mod);
561
636
  result = await runWithActionSignal(actionReq.signal, () =>
@@ -563,7 +638,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
563
638
  middleware,
564
639
  {
565
640
  request: actionReq,
566
- args,
641
+ args: [...bound, ...args],
567
642
  signal: actionReq.signal,
568
643
  // Route context a form middleware may want, absent on the RPC path
569
644
  // because an RPC call has no page. Params are awaitable AND
@@ -572,17 +647,25 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
572
647
  searchParams: makeThenable(searchParams),
573
648
  url,
574
649
  },
575
- () => { ranAction = true; return fn(...args); },
650
+ () => { ranAction = true; return withPrevState ? fn(...bound, prevState, ...args) : fn(...bound, ...args); },
576
651
  ));
577
652
  } catch (err) {
578
653
  if (isRedirect(err)) {
579
654
  const e = /** @type any */ (err);
580
- // A thrown redirect from an action (a POST) defaults to 307 Temporary
581
- // Redirect, which is method-preserving so the action's intent survives
582
- // the bounce; an explicit `redirect(url, status)` overrides it. This is
583
- // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
584
- // the SUCCESS-result path below.
585
- 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;
586
669
  }
587
670
  // `route` and `params` ride along so the boundary renders inside its
588
671
  // layouts, exactly as it does on the page-render path (#1298).
@@ -600,8 +683,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
600
683
  // boundary as the page-render path (#848), not a generic 500.
601
684
  if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
602
685
  if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url, route, params });
603
- if (typeof onError === 'function') onError(err);
604
- return await formActionErrorResponse(err, ssrOpts.dev);
686
+ return errorPage(err);
605
687
  }
606
688
 
607
689
  // A streamed return (#489) has no consumer here. The RPC stub decodes frames;
@@ -613,8 +695,7 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
613
695
  `The action ${fnName} returned a stream, which a form submission cannot consume. `
614
696
  + 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
615
697
  );
616
- if (typeof onError === 'function') onError(err);
617
- return await formActionErrorResponse(err, ssrOpts.dev);
698
+ return errorPage(err);
618
699
  }
619
700
 
620
701
  // Only a COMPLETED action evicts its `invalidates` tags; a middleware
@@ -628,14 +709,10 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
628
709
  // after a successful PRG the client learns nothing from it. The redirect's
629
710
  // own re-render is server-side and seeds fresh data, so what remains exposed
630
711
  // is only a later client refetch of a separately cached GET key.
631
- let invalidated = [];
632
- if (ranAction) {
633
- invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), args);
634
- if (invalidated.length) await revalidateTags(invalidated);
635
- }
636
- const invalidateHeader = invalidated.length
637
- ? { 'x-webjs-invalidate': invalidated.join(',') }
638
- : 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();
639
716
 
640
717
  // An action MAY return a `Response` directly (e.g. a content-negotiated
641
718
  // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
@@ -650,10 +727,32 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
650
727
  // page's own path so a poisoned value cannot become an open redirect.
651
728
  const ownPath = (url.pathname + url.search) || '/';
652
729
  const safe = result ? sameSiteRedirect(result.redirect) : null;
653
- 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, {
654
738
  status: 303,
655
- 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 },
656
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;
657
756
  }
658
757
 
659
758
  // FAILURE: re-render the SAME page with the action result available on
@@ -668,32 +767,14 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
668
767
  if (invalidateHeader && page instanceof Response) {
669
768
  for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
670
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
+ }
671
778
  return page;
672
779
  }
673
780
 
674
- /**
675
- * A thrown form action becomes a 500 whose body is sanitized the same way the
676
- * RPC path's is (#749): dev shows the real message, prod shows a generic one
677
- * plus a correlation digest and logs the full error. A raw driver message or an
678
- * fs path is not author-controlled, so it does not reach the browser, and here
679
- * the browser is showing it to a person rather than parsing it.
680
- *
681
- * @param {unknown} err
682
- * @param {boolean} [dev]
683
- * @returns {Promise<Response>}
684
- */
685
- async function formActionErrorResponse(err, dev) {
686
- if (dev) {
687
- console.error('[webjs] form action threw:', err);
688
- const msg = err instanceof Error ? err.message : String(err);
689
- return new Response(msg, { status: 500 });
690
- }
691
- if (isControlFlowThrow(err)) {
692
- console.error('[webjs] form action threw:', err);
693
- const msg = err instanceof Error ? err.message : String(err);
694
- return new Response(msg, { status: 500 });
695
- }
696
- const digest = await errorDigest(err);
697
- console.error(`[webjs] form action threw [digest=${digest}]:`, err);
698
- return new Response(`${GENERIC_ERROR_MESSAGE} (digest ${digest})`, { status: 500 });
699
- }
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;