@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.
- package/README.md +4 -1
- package/index.d.ts +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +97 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +142 -66
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/form-dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
551
|
-
return await formActionErrorResponse(v.thrown, ssrOpts.dev);
|
|
584
|
+
return errorPage(v.thrown);
|
|
552
585
|
}
|
|
553
|
-
// A structured failure envelope
|
|
554
|
-
//
|
|
555
|
-
//
|
|
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 =
|
|
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
|
|
587
|
-
//
|
|
588
|
-
// the
|
|
589
|
-
//
|
|
590
|
-
// the
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/src/instrumentation.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
438
|
-
//
|
|
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;
|