@webjsdev/server 0.8.57 → 0.8.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/dev-overlay.js
CHANGED
|
@@ -9,14 +9,83 @@
|
|
|
9
9
|
* Security: the overlay is built with `createElement` + `textContent` only,
|
|
10
10
|
* NEVER `innerHTML`, so a hostile error message / file path / code frame is
|
|
11
11
|
* rendered as inert text and can never inject markup or script.
|
|
12
|
+
*
|
|
13
|
+
* Scope (#1047): a `render` frame is stamped server-side with the URL that
|
|
14
|
+
* produced it, and this module is the single gate deciding whether a frame
|
|
15
|
+
* belongs on the page currently being viewed. Without it, a body-level overlay
|
|
16
|
+
* outlives every client-router swap (which works strictly inside the keyed
|
|
17
|
+
* boundary comment ranges), and any render of a page this tab is NOT looking at
|
|
18
|
+
* raises an overlay over the one it is, in every open tab, since the frame fans
|
|
19
|
+
* out over the shared SSE channel. (The other half of #1047, a link prefetch
|
|
20
|
+
* reporting a frame at all, is cut server-side in `dev.js`, not here.)
|
|
21
|
+
*
|
|
22
|
+
* The gate cannot be a plain refuse-and-drop, because the SSE frame is pushed
|
|
23
|
+
* DURING the render, before the navigation response is even sent, so it reaches
|
|
24
|
+
* the browser while `location` is still the old page. So there are two slots: a
|
|
25
|
+
* RENDERED frame and a PENDING one the gate refused. A refused frame is held,
|
|
26
|
+
* and the nav sync re-evaluates it once the URL advances.
|
|
27
|
+
*
|
|
28
|
+
* A held frame is only ever rendered for the navigation it actually belongs to,
|
|
29
|
+
* which is what `__wjNavSeq` is for. A frame that arrives while a navigation is
|
|
30
|
+
* IN FLIGHT is that navigation's; one that arrived while the tab sat idle is
|
|
31
|
+
* not, and must never paint on a later visit to that url, because by then the
|
|
32
|
+
* page may well render fine. An idle-time frame comes from a render this tab
|
|
33
|
+
* did not navigate for: another tab's page, or a background fetch of some other
|
|
34
|
+
* url. NOT from a link prefetch, which reports no frame at all. Both look
|
|
35
|
+
* identical once held, so the seq records which navigation was in flight when
|
|
36
|
+
* the frame landed, and the sync renders it only if that is still the one
|
|
37
|
+
* finishing. `webjs:before-cache` is the nav-START signal (the router snapshots
|
|
38
|
+
* the page it is leaving before it fetches); a navigation that does not emit one
|
|
39
|
+
* leaves the seq unchanged, which fails toward SHOWING the frame, the same way
|
|
40
|
+
* the rest of the gate fails open.
|
|
12
41
|
*/
|
|
13
42
|
|
|
14
43
|
/** The single live overlay element, or null. */
|
|
15
44
|
let __wjOverlay = null;
|
|
45
|
+
/** The frame the live overlay was built from, or null. */
|
|
46
|
+
let __wjFrame = null;
|
|
47
|
+
/** The most recent frame the scope gate refused, awaiting a URL that matches. */
|
|
48
|
+
let __wjPending = null;
|
|
49
|
+
/** The navigation that was in flight when `__wjPending` arrived. */
|
|
50
|
+
let __wjPendingSeq = -1;
|
|
51
|
+
/** Bumped at the start of each client-router navigation. */
|
|
52
|
+
let __wjNavSeq = 0;
|
|
16
53
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
54
|
+
/** The path the gate compares a frame's url against. */
|
|
55
|
+
function __wjCurrentPath() {
|
|
56
|
+
return typeof location !== 'undefined' ? location.pathname + location.search : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare a frame url against a browser path. Encoding-tolerant on purpose: a
|
|
61
|
+
* mismatch fails CLOSED (no overlay), so a percent-encoding difference on a
|
|
62
|
+
* dynamic segment would otherwise hide a genuine error.
|
|
63
|
+
*/
|
|
64
|
+
function __wjSamePath(a, b) {
|
|
65
|
+
if (a === b) return true;
|
|
66
|
+
try { return decodeURI(a) === decodeURI(b); } catch (_) { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when `f` is a frame the scope gate applies to (a url-stamped render). */
|
|
70
|
+
function __wjScoped(f) {
|
|
71
|
+
return !!f && f.kind === 'render' && typeof f.url === 'string' && f.url !== '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Take the overlay off the page. Leaves the pending slot alone. */
|
|
75
|
+
function __wjRemoveOverlay() {
|
|
19
76
|
if (__wjOverlay) { __wjOverlay.remove(); __wjOverlay = null; }
|
|
77
|
+
__wjFrame = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Remove the overlay if one is showing, and forget any pending frame. This is
|
|
82
|
+
* what the Dismiss button calls, so a frame the user dismissed by hand is never
|
|
83
|
+
* resurrected by a later navigation.
|
|
84
|
+
*/
|
|
85
|
+
export function dismissDevOverlay() {
|
|
86
|
+
__wjRemoveOverlay();
|
|
87
|
+
__wjPending = null;
|
|
88
|
+
__wjPendingSeq = -1;
|
|
20
89
|
}
|
|
21
90
|
|
|
22
91
|
/** Append a styled text row to `parent`. */
|
|
@@ -30,11 +99,27 @@ function __wjRow(parent, css, text) {
|
|
|
30
99
|
|
|
31
100
|
/**
|
|
32
101
|
* Render the dev error overlay for a frame, replacing any prior one.
|
|
33
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* A url-stamped `render` frame for a DIFFERENT path than `currentPath` is not
|
|
104
|
+
* rendered; it is held in the pending slot for `syncDevOverlayToLocation` to
|
|
105
|
+
* re-evaluate once the URL advances (#1047). The gate runs BEFORE any removal,
|
|
106
|
+
* so a refused frame never wipes what is on screen (a live `rebuild` /
|
|
107
|
+
* `ts-strip` overlay describes a still-broken build and must survive it) and
|
|
108
|
+
* never paints even briefly. A frame with no `url`, or any other kind, renders
|
|
109
|
+
* exactly as it always did.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ kind?: string, message?: string, file?: string|null, line?: number|null, column?: number|null, codeFrame?: string|null, hint?: string|null, url?: string|null }} f
|
|
112
|
+
* @param {string} [currentPath] defaults to `location.pathname + location.search`
|
|
34
113
|
*/
|
|
35
|
-
export function renderDevOverlay(f) {
|
|
36
|
-
dismissDevOverlay();
|
|
37
|
-
|
|
114
|
+
export function renderDevOverlay(f, currentPath) {
|
|
115
|
+
if (!f) { dismissDevOverlay(); return; }
|
|
116
|
+
const here = currentPath === undefined ? __wjCurrentPath() : currentPath;
|
|
117
|
+
if (__wjScoped(f) && !__wjSamePath(f.url, here)) {
|
|
118
|
+
__wjPending = f;
|
|
119
|
+
__wjPendingSeq = __wjNavSeq;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
__wjRemoveOverlay();
|
|
38
123
|
const o = document.createElement('div');
|
|
39
124
|
o.setAttribute('data-webjs-error-overlay', '');
|
|
40
125
|
o.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(10,10,12,.92);color:#e6e6e6;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto';
|
|
@@ -72,4 +157,131 @@ export function renderDevOverlay(f) {
|
|
|
72
157
|
o.appendChild(card);
|
|
73
158
|
(document.body || document.documentElement).appendChild(o);
|
|
74
159
|
__wjOverlay = o;
|
|
160
|
+
__wjFrame = f;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Re-evaluate the scope gate against the page now being viewed (#1047). Called
|
|
165
|
+
* on every client-router navigation, so an overlay the navigation just made
|
|
166
|
+
* stale comes down, and a frame that arrived before the URL advanced (the usual
|
|
167
|
+
* ordering, since the SSE frame is pushed during the render that produces the
|
|
168
|
+
* navigation response) goes up.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} [currentPath] defaults to `location.pathname + location.search`
|
|
171
|
+
*/
|
|
172
|
+
export function syncDevOverlayToLocation(currentPath) {
|
|
173
|
+
const here = currentPath === undefined ? __wjCurrentPath() : currentPath;
|
|
174
|
+
// The BACKSTOP for an overlay element this module does not own. Any such node
|
|
175
|
+
// is unremovable and its Dismiss button carries no listener, so it would sit
|
|
176
|
+
// there forever: a `document.body.replaceChildren` from cached HTML is the way
|
|
177
|
+
// that happens. The router's OWN back/forward snapshot is already handled
|
|
178
|
+
// upstream, since `before-cache` detaches the overlay across its `outerHTML`
|
|
179
|
+
// read, so what is left here is everything else (a snapshot cached before this
|
|
180
|
+
// client installed its listeners, or any other wholesale body replacement).
|
|
181
|
+
// Also notice when the node we did own was replaced out from under us: it is
|
|
182
|
+
// detached, so `.remove()` would no-op and the slot would lie about what is
|
|
183
|
+
// on screen.
|
|
184
|
+
if (typeof document !== 'undefined') {
|
|
185
|
+
const live = __wjOverlay && __wjOverlay.isConnected ? __wjOverlay : null;
|
|
186
|
+
__wjOverlay = live;
|
|
187
|
+
document.querySelectorAll('[data-webjs-error-overlay]').forEach((el) => {
|
|
188
|
+
if (el !== live) el.remove();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// A live overlay whose page we have navigated away from comes down. An
|
|
192
|
+
// unscoped overlay (rebuild / ts-strip) is untouched: it describes the build,
|
|
193
|
+
// not one URL, and only the next successful rebuild clears it.
|
|
194
|
+
if (__wjScoped(__wjFrame) && !__wjSamePath(__wjFrame.url, here)) __wjRemoveOverlay();
|
|
195
|
+
// Still the right frame for this page, but its node was swept away by the
|
|
196
|
+
// restore above: put a REAL one back, with a working Dismiss button.
|
|
197
|
+
if (__wjFrame && !__wjOverlay) renderDevOverlay(__wjFrame, here);
|
|
198
|
+
// The pending frame is consumed by this navigation either way. It renders
|
|
199
|
+
// only if this is BOTH the page it names and the navigation it belongs to;
|
|
200
|
+
// a frame held from an idle-time render (another tab's page, a background
|
|
201
|
+
// fetch of some other url) is dropped, because by the time you actually
|
|
202
|
+
// visit that url the page may render perfectly well, and an overlay over it
|
|
203
|
+
// would be the very bug this whole gate exists to stop.
|
|
204
|
+
const p = __wjPending;
|
|
205
|
+
const seq = __wjPendingSeq;
|
|
206
|
+
__wjPending = null;
|
|
207
|
+
__wjPendingSeq = -1;
|
|
208
|
+
if (p && seq === __wjNavSeq && __wjSamePath(p.url, here)) renderDevOverlay(p, here);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Mark the start of a client-router navigation (#1047), so a frame arriving
|
|
213
|
+
* from here on is known to belong to it. Exported for the browser test; the
|
|
214
|
+
* shipping wiring is `installDevOverlayNavSync`.
|
|
215
|
+
*/
|
|
216
|
+
export function markDevOverlayNavStart() {
|
|
217
|
+
__wjNavSeq++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Wire the scope gate to the client router's navigation events (#1047), so the
|
|
222
|
+
* overlay tracks the page actually on screen. Browser-safe and dev-only: the
|
|
223
|
+
* reload client that calls this is served only in dev.
|
|
224
|
+
*
|
|
225
|
+
* Three events, each for a distinct reason:
|
|
226
|
+
* - `webjs:navigate` on `document`, the applied-navigation signal, dispatched
|
|
227
|
+
* after `history.pushState` so `location` is already current.
|
|
228
|
+
* - `popstate` on `window`, because a back/forward restore served from the
|
|
229
|
+
* router's snapshot cache returns before it dispatches `webjs:navigate`.
|
|
230
|
+
* - `webjs:before-cache` on `document`, which does two things. It is the
|
|
231
|
+
* nav-START marker (the router dispatches it from `snapshotCurrent`, at the
|
|
232
|
+
* top of every navigation and form submission, before the fetch), the only
|
|
233
|
+
* signal separating a frame belonging to the navigation in flight from one
|
|
234
|
+
* held since the tab was idle. And it detaches the overlay across the
|
|
235
|
+
* snapshot read, re-attaching it a microtask later, so the cached HTML
|
|
236
|
+
* carries no copy. What it must NOT do is strip the overlay for good, which
|
|
237
|
+
* the event's own contract invites: it fires on EVERY navigation, so that
|
|
238
|
+
* would tear a `rebuild` / `ts-strip` overlay off the page the moment you
|
|
239
|
+
* clicked any link, while the build was still broken.
|
|
240
|
+
*
|
|
241
|
+
* @param {{ document?: any, window?: any, getPath?: () => string }} [opts] injection seam for the browser test
|
|
242
|
+
* @returns {() => void} an uninstall thunk
|
|
243
|
+
*/
|
|
244
|
+
export function installDevOverlayNavSync(opts) {
|
|
245
|
+
const o = opts || {};
|
|
246
|
+
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
247
|
+
const win = o.window || (typeof window !== 'undefined' ? window : null);
|
|
248
|
+
const getPath = o.getPath || __wjCurrentPath;
|
|
249
|
+
const onNav = () => syncDevOverlayToLocation(getPath());
|
|
250
|
+
const onBeforeCache = () => {
|
|
251
|
+
markDevOverlayNavStart();
|
|
252
|
+
// Keep the overlay out of the snapshot the router is about to take. It
|
|
253
|
+
// reads `outerHTML` SYNCHRONOUSLY right after this event, so detaching here
|
|
254
|
+
// and re-attaching in a microtask (which runs once that read has returned,
|
|
255
|
+
// and long before any paint) means the cached HTML never carries a copy,
|
|
256
|
+
// while the overlay on screen never flickers.
|
|
257
|
+
//
|
|
258
|
+
// This is not the strip that used to live here. That one dropped the frame
|
|
259
|
+
// permanently, so any link click tore down a `rebuild` / `ts-strip`
|
|
260
|
+
// overlay while the build was still broken; this puts the SAME node back.
|
|
261
|
+
// The sweep in `syncDevOverlayToLocation` stays as the backstop, because
|
|
262
|
+
// it cannot cover every ordering on its own: under an opt-in view
|
|
263
|
+
// transition the router defers the body swap past the sync, so a copy
|
|
264
|
+
// parsed in afterwards would otherwise sit there undismissable.
|
|
265
|
+
const el = __wjOverlay;
|
|
266
|
+
if (!el || !el.isConnected) return;
|
|
267
|
+
const parent = el.parentNode;
|
|
268
|
+
el.remove();
|
|
269
|
+
queueMicrotask(() => {
|
|
270
|
+
// Only if it is still the live overlay: a frame arriving meanwhile may
|
|
271
|
+
// have replaced it, or the user may have dismissed it.
|
|
272
|
+
if (__wjOverlay === el && !el.isConnected && parent) parent.appendChild(el);
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
if (doc) {
|
|
276
|
+
doc.addEventListener('webjs:navigate', onNav);
|
|
277
|
+
doc.addEventListener('webjs:before-cache', onBeforeCache);
|
|
278
|
+
}
|
|
279
|
+
if (win) win.addEventListener('popstate', onNav);
|
|
280
|
+
return () => {
|
|
281
|
+
if (doc) {
|
|
282
|
+
doc.removeEventListener('webjs:navigate', onNav);
|
|
283
|
+
doc.removeEventListener('webjs:before-cache', onBeforeCache);
|
|
284
|
+
}
|
|
285
|
+
if (win) win.removeEventListener('popstate', onNav);
|
|
286
|
+
};
|
|
75
287
|
}
|
package/src/dev.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
15
15
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
16
16
|
import { generateRouteTypes } from './route-types.js';
|
|
17
17
|
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
18
|
-
import {
|
|
18
|
+
import { runFormAction } from './form-dispatch.js';
|
|
19
19
|
import { handleApi } from './api.js';
|
|
20
20
|
import {
|
|
21
21
|
buildActionIndex,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
hasUseServerDirective,
|
|
27
27
|
hashFile,
|
|
28
28
|
} from './actions.js';
|
|
29
|
-
import {
|
|
29
|
+
import { registerActionHooks } from './action-seed.js';
|
|
30
30
|
import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
|
|
31
31
|
import { stripTypeScript, ensureStripper } from './ts-strip.js';
|
|
32
32
|
import { defaultLogger } from './logger.js';
|
|
@@ -93,7 +93,9 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
|
|
|
93
93
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
94
94
|
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
95
95
|
import { readAllowedOrigins } from './csrf.js';
|
|
96
|
-
import {
|
|
96
|
+
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
97
|
+
import { resolveActionIdentity } from './form-action-identity.js';
|
|
98
|
+
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
|
|
97
99
|
import { urlFromRequest } from './forwarded.js';
|
|
98
100
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
99
101
|
import {
|
|
@@ -624,13 +626,32 @@ export async function createRequestHandler(opts) {
|
|
|
624
626
|
// id is a stable deploy fingerprint independent of per-file content hashes.
|
|
625
627
|
setAssetRoots({ appDir, coreDir, enabled: !dev });
|
|
626
628
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
629
|
+
// Install the resolver behind the isomorphic `asset()` helper (#1194), the
|
|
630
|
+
// same provider seam `cspNonce()` uses. An author writes
|
|
631
|
+
// `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
|
|
632
|
+
// server; the browser has no provider and returns the path unchanged.
|
|
633
|
+
//
|
|
634
|
+
// The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
|
|
635
|
+
// maps any same-origin path under the PROJECT ROOT, so an app that passed
|
|
636
|
+
// request- or user-derived data here (`asset(user.avatarPath)`) would
|
|
637
|
+
// otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
|
|
638
|
+
// `.server.ts`, leaking both existence and a fingerprint of the bytes for
|
|
639
|
+
// files the serve path deliberately 404s. Mirror the static-asset route:
|
|
640
|
+
// under `public/`, no traversal. Everything else returns unchanged, having
|
|
641
|
+
// never touched the disk.
|
|
642
|
+
setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
|
|
643
|
+
|
|
644
|
+
// The `'use server'` load hook. Install it NOW, at boot, before any action
|
|
645
|
+
// module is imported (ESM caches by URL, so a module loaded before the hook
|
|
646
|
+
// would never be wrapped). Read once (not per-rebuild): the hook is global
|
|
647
|
+
// and cannot be cleanly un-installed, so toggling needs a restart.
|
|
648
|
+
//
|
|
649
|
+
// It installs UNCONDITIONALLY, and only seed collection (#472) follows the
|
|
650
|
+
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
651
|
+
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
652
|
+
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
653
|
+
await registerActionHooks({ seed: await readSeedEnabled(appDir) });
|
|
654
|
+
|
|
634
655
|
|
|
635
656
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
636
657
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
@@ -741,7 +762,7 @@ export async function createRequestHandler(opts) {
|
|
|
741
762
|
// package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
|
|
742
763
|
// overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
|
|
743
764
|
// unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
|
|
744
|
-
// (used inside route handlers) enforces the same cap the RPC and
|
|
765
|
+
// (used inside route handlers) enforces the same cap the RPC and form-dispatch
|
|
745
766
|
// paths do.
|
|
746
767
|
const bodyLimits = await readBodyLimitsFromApp(appDir);
|
|
747
768
|
|
|
@@ -781,14 +802,27 @@ export async function createRequestHandler(opts) {
|
|
|
781
802
|
regenerateRules: dev ? await readRegenerateRules(appDir) : [],
|
|
782
803
|
};
|
|
783
804
|
|
|
805
|
+
// Teach the renderers how to turn a real server-action function into the
|
|
806
|
+
// `<hash>/<fn>` identity a form submits (#1155). The hook path is
|
|
807
|
+
// process-global and needs no index; `state.actionIndex` is consulted only as
|
|
808
|
+
// a hash memo, and by the scan fallback for a runtime with no hook. In a
|
|
809
|
+
// process running several handlers the fallback therefore resolves against
|
|
810
|
+
// the most recently booted one, which is the same scope the asset-url
|
|
811
|
+
// provider already has.
|
|
812
|
+
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
|
|
813
|
+
|
|
784
814
|
/**
|
|
785
815
|
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
786
816
|
* the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
|
|
787
817
|
* response or crash the server (a frame-build failure is swallowed). No file
|
|
788
818
|
* path or source is ever built in prod (the early return), so nothing leaks.
|
|
789
819
|
*
|
|
820
|
+
* `info.url` stamps a `render` frame with the URL that produced it (#1047), so
|
|
821
|
+
* the browser overlay can refuse a frame for a page the tab is not viewing.
|
|
822
|
+
* A `ts-strip` / `rebuild` frame passes none and stays unscoped.
|
|
823
|
+
*
|
|
790
824
|
* @param {unknown} error
|
|
791
|
-
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
|
|
825
|
+
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
|
|
792
826
|
*/
|
|
793
827
|
function reportDevError(error, info = {}) {
|
|
794
828
|
if (!dev) return;
|
|
@@ -1165,7 +1199,7 @@ export async function createRequestHandler(opts) {
|
|
|
1165
1199
|
|
|
1166
1200
|
// Make the resolved body-size limits (issue #237) readable from the
|
|
1167
1201
|
// request scope, so `readBody` inside a route handler enforces the same
|
|
1168
|
-
// cap the framework's own RPC /
|
|
1202
|
+
// cap the framework's own RPC / form-dispatch body reads do.
|
|
1169
1203
|
setBodyLimits(state.bodyLimits);
|
|
1170
1204
|
|
|
1171
1205
|
let pathname = '/';
|
|
@@ -1263,8 +1297,9 @@ export async function createRequestHandler(opts) {
|
|
|
1263
1297
|
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
1264
1298
|
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
1265
1299
|
// 304 carries the validators a shared cache and the client router need.
|
|
1266
|
-
// A no-store
|
|
1267
|
-
//
|
|
1300
|
+
// A no-store response, a non-GET/HEAD, and a streaming Suspense body are
|
|
1301
|
+
// all skipped (see conditional-get.js). A `private` response is NOT
|
|
1302
|
+
// skipped: private forbids shared storage, not validation (#1140). Logged with the final
|
|
1268
1303
|
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
1269
1304
|
let conditioned = merged;
|
|
1270
1305
|
try {
|
|
@@ -1332,7 +1367,7 @@ export async function createRequestHandler(opts) {
|
|
|
1332
1367
|
// to the inbound `x-webjs-remote-ip` header that the copied
|
|
1333
1368
|
// `req.headers` still carries, which a client can spoof. So strip
|
|
1334
1369
|
// that header on the rebuild and propagate the framework-trusted IP
|
|
1335
|
-
// across the new object, the same pattern
|
|
1370
|
+
// across the new object, the same pattern form-dispatch.js uses.
|
|
1336
1371
|
const headers = new Headers(req.headers);
|
|
1337
1372
|
headers.delete('x-webjs-remote-ip');
|
|
1338
1373
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
@@ -1822,7 +1857,7 @@ function startNodeListener(ctx) {
|
|
|
1822
1857
|
// Catch-all process handlers: log, but don't tear the process down on a
|
|
1823
1858
|
// single mishandled promise. Uncaught exceptions are different: state may be
|
|
1824
1859
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
1825
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
1860
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
1826
1861
|
|
|
1827
1862
|
return {
|
|
1828
1863
|
server,
|
|
@@ -2162,14 +2197,30 @@ async function handleCore(req, ctx) {
|
|
|
2162
2197
|
}
|
|
2163
2198
|
|
|
2164
2199
|
// Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
|
|
2165
|
-
// is
|
|
2166
|
-
// action runs inside the page's segment middleware,
|
|
2167
|
-
// (303)
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2200
|
+
// is a form submission (#1155): the `__webjs_action` hidden field names the
|
|
2201
|
+
// server action to run, it runs inside the page's segment middleware, and the
|
|
2202
|
+
// result either PRG-redirects (303), re-renders the same page (422) with
|
|
2203
|
+
// field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
|
|
2204
|
+
// no action identity is a 405 on a path that exists but only renders.
|
|
2170
2205
|
{
|
|
2171
2206
|
const page = matchPage(state.routeTable, path);
|
|
2172
2207
|
if (page) {
|
|
2208
|
+
// The URL this render is FOR, in the form the browser sees it (#1047), so
|
|
2209
|
+
// the overlay's scope gate can compare it against `location.pathname +
|
|
2210
|
+
// location.search`. Two details are load-bearing: the RAW `url.pathname`
|
|
2211
|
+
// (not the decoded `path`) is used, because `location.pathname` is
|
|
2212
|
+
// percent-encoded too; and the base path is put back, because the ingress
|
|
2213
|
+
// strip already removed it from `url` while the browser's location still
|
|
2214
|
+
// carries it, so without this the gate would suppress every overlay on a
|
|
2215
|
+
// sub-path deploy.
|
|
2216
|
+
const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
|
|
2217
|
+
// A speculative link prefetch must never raise an overlay (#1047): the
|
|
2218
|
+
// user is only hovering a link to a page that throws, and the page they
|
|
2219
|
+
// are actually looking at is fine. Dropping the hook also keeps the throw
|
|
2220
|
+
// out of `state.lastDevError`, so the SSE replay cannot hand it to a
|
|
2221
|
+
// freshly-connected tab either. The APM `onError` sink below still fires,
|
|
2222
|
+
// because the render really did throw.
|
|
2223
|
+
const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
|
|
2173
2224
|
const ssrOpts = {
|
|
2174
2225
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
2175
2226
|
serverFiles: state.actionIndex.fileToHash,
|
|
@@ -2196,17 +2247,43 @@ async function handleCore(req, ctx) {
|
|
|
2196
2247
|
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
2197
2248
|
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
2198
2249
|
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
2199
|
-
onDevError: dev
|
|
2250
|
+
onDevError: dev && !isPrefetch
|
|
2251
|
+
? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
|
|
2252
|
+
: undefined,
|
|
2200
2253
|
};
|
|
2201
2254
|
if (method === 'GET' || method === 'HEAD') {
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2255
|
+
// A successful render of URL U supersedes a RETAINED render error for
|
|
2256
|
+
// that same URL (#1047), so a reconnecting tab is not handed a frame the
|
|
2257
|
+
// page has since recovered from. Keyed on BOTH frame identity and url:
|
|
2258
|
+
// identity so a frame this very render just reported is never wiped, url
|
|
2259
|
+
// so a good render of an unrelated page cannot erase an error that is
|
|
2260
|
+
// still current for the page the user is looking at (the #893 gap the
|
|
2261
|
+
// retention exists to close). Dev-only, and skipped for a prefetch,
|
|
2262
|
+
// which is not the user's view of anything.
|
|
2263
|
+
const handler = dev && !isPrefetch
|
|
2264
|
+
? async () => {
|
|
2265
|
+
const before = state.lastDevError;
|
|
2266
|
+
const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2267
|
+
if (
|
|
2268
|
+
before && state.lastDevError === before
|
|
2269
|
+
&& before.kind === 'render' && before.url === devErrorUrl
|
|
2270
|
+
) state.lastDevError = null;
|
|
2271
|
+
return resp;
|
|
2272
|
+
}
|
|
2273
|
+
: () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2208
2274
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2209
2275
|
}
|
|
2276
|
+
// Every non-GET/HEAD to a page runs the page's segment middleware, and
|
|
2277
|
+
// the dispatcher always answers with a Response (a 405 when there is no
|
|
2278
|
+
// action to run), so a middleware that post-processes `await next()`
|
|
2279
|
+
// never has to handle an absent one.
|
|
2280
|
+
const deps = {
|
|
2281
|
+
actionIndex: state.actionIndex,
|
|
2282
|
+
allowedOrigins,
|
|
2283
|
+
onError: reportError ? (e) => reportError(e, req, 'action') : undefined,
|
|
2284
|
+
};
|
|
2285
|
+
const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
|
|
2286
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2210
2287
|
}
|
|
2211
2288
|
}
|
|
2212
2289
|
|
|
@@ -2828,6 +2905,11 @@ function __webjsApplyError(data) {
|
|
|
2828
2905
|
let f; try { f = JSON.parse(data); } catch (_) { return; }
|
|
2829
2906
|
renderDevOverlay(f);
|
|
2830
2907
|
}
|
|
2908
|
+
// Keep the overlay tracking the page actually on screen (#1047): a render frame
|
|
2909
|
+
// is scoped to the URL that produced it, so navigating away takes it down, and a
|
|
2910
|
+
// frame that arrived before the URL advanced goes up once it does. The gate
|
|
2911
|
+
// itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
|
|
2912
|
+
installDevOverlayNavSync();
|
|
2831
2913
|
// Never reload INTO a server that is still restarting (Node's node --watch
|
|
2832
2914
|
// briefly kills the process on an edit), which would paint a style-less,
|
|
2833
2915
|
// half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
|