what-server 0.12.2 → 0.12.4
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/dist/index.min.js +8 -8
- package/dist/islands.min.js +2 -1
- package/dist/node.min.js +8 -8
- package/index.d.ts +153 -14
- package/package.json +3 -3
- package/src/adapter/core.js +17 -4
- package/src/form.js +12 -5
- package/src/index.js +262 -18
- package/src/islands.js +161 -35
package/src/index.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import {
|
|
6
6
|
h,
|
|
7
7
|
_isAriaAttr,
|
|
8
|
+
_beginComponentSSR,
|
|
9
|
+
_endComponentSSR,
|
|
10
|
+
_mapArrayToArray,
|
|
8
11
|
getServerContext,
|
|
9
12
|
runWithServerContext,
|
|
10
13
|
beginHeadCollection,
|
|
@@ -58,6 +61,94 @@ export function renderToHydratableString(vnode) {
|
|
|
58
61
|
return _renderHydratable(vnode);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// <ErrorBoundary> and <Suspense> are not elements. They return vnodes carrying
|
|
65
|
+
// the internal marker tags '__errorBoundary' / '__suspense', which every client
|
|
66
|
+
// path routes to a boundary handler (dom.js:349-353). The server had no such
|
|
67
|
+
// routing outside renderToString's suspense case, so the marker tags fell
|
|
68
|
+
// through to the generic element renderer and three things went wrong at once:
|
|
69
|
+
//
|
|
70
|
+
// <ErrorBoundary> -> <__errorBoundary><p>ok</p></__errorBoundary>
|
|
71
|
+
// <Suspense> (hydratable) -> <__suspense boundary="[object Object]"
|
|
72
|
+
// fallback="[object Object]">...
|
|
73
|
+
//
|
|
74
|
+
// An invalid element name in the response, the boundary's internal props
|
|
75
|
+
// stringified into attributes, and, worst of the three, a component that threw
|
|
76
|
+
// during SSR took down the WHOLE page render instead of being contained:
|
|
77
|
+
// renderToString and renderToHydratableString both propagated the error, so the
|
|
78
|
+
// one construct whose entire purpose is to stop a subtree failure from becoming
|
|
79
|
+
// a page failure did nothing on the server. The stream path did not throw but
|
|
80
|
+
// emitted an HTML comment where the fallback belonged.
|
|
81
|
+
//
|
|
82
|
+
// A thenable is deliberately re-thrown rather than caught: that is a suspended
|
|
83
|
+
// resource, not an error, and it belongs to the nearest <Suspense>.
|
|
84
|
+
const BOUNDARY_TAGS = new Set(['__errorBoundary', '__suspense']);
|
|
85
|
+
|
|
86
|
+
// <Portal> is client-only by the framework's own decision: Portal() returns null
|
|
87
|
+
// when there is no `document` (helpers.js:153). That guard is environmental, not
|
|
88
|
+
// a server check, so SSR under a DOM shim (jsdom, happy-dom, a test harness, any
|
|
89
|
+
// runtime that polyfills document) sailed past it, built the vnode, and handed
|
|
90
|
+
// the server a '__portal' tag it had no branch for. The result was
|
|
91
|
+
//
|
|
92
|
+
// <__portal container="[object HTMLDivElement]"><p>inside</p></__portal>
|
|
93
|
+
//
|
|
94
|
+
// which is three wrongs at once: an invalid element, a DOM node stringified into
|
|
95
|
+
// an attribute, and the portal's content emitted INLINE at the portal's own
|
|
96
|
+
// position rather than at its target. Hydration would then move it, so the
|
|
97
|
+
// server markup contradicted the client's on purpose.
|
|
98
|
+
//
|
|
99
|
+
// Rendering nothing is the answer that agrees with the no-document path, so the
|
|
100
|
+
// same app produces the same HTML whether or not a shim happens to be loaded.
|
|
101
|
+
const SERVER_SKIPPED_TAGS = new Set(['__portal']);
|
|
102
|
+
|
|
103
|
+
// The props a component is called with. The server has to hand a component
|
|
104
|
+
// exactly what the client hands it, and it did not.
|
|
105
|
+
//
|
|
106
|
+
// dom.js:593 collapses the children list before it becomes a prop, and when
|
|
107
|
+
// there are NO children it sets no `children` key at all, so an ordinary JS
|
|
108
|
+
// default parameter
|
|
109
|
+
//
|
|
110
|
+
// function SkipLink({ children = 'Skip to content' }) { ... }
|
|
111
|
+
//
|
|
112
|
+
// applies. The server passed `children: vnode.children` verbatim, which is `[]`
|
|
113
|
+
// for a childless component, and `[]` is defined, so the default NEVER applied
|
|
114
|
+
// server-side. A server-rendered <SkipLink /> shipped `<a href="#main"></a>`: a
|
|
115
|
+
// link with no accessible name (a WCAG 2.4.4 failure) until the client hydrated
|
|
116
|
+
// and replaced it, which is exactly the case where the markup has to stand on
|
|
117
|
+
// its own. The divergence is general, not specific to SkipLink: every component
|
|
118
|
+
// with a defaulted children prop rendered one thing on the client and another on
|
|
119
|
+
// the server. The same blanket override also discarded children passed as a
|
|
120
|
+
// plain PROP (`h(Card, { children: body })`), which the client keeps precisely
|
|
121
|
+
// because there are no vnode children to replace it with.
|
|
122
|
+
//
|
|
123
|
+
// "Childless" has to mean what it means on the client: an EMPTY list. h() drops
|
|
124
|
+
// null/false/true children (h.js:48,66) but keeps '' as the string child it is,
|
|
125
|
+
// so `<Comp>{''}</Comp>` has one child and the default must NOT apply there.
|
|
126
|
+
// Only length 0 counts, plus a hand-built vnode carrying no children array.
|
|
127
|
+
//
|
|
128
|
+
// Not yet matched: the client also UNWRAPS a single child (`[child]` -> `child`).
|
|
129
|
+
// Aligning that here alone would break <Island>, which puts the prop straight
|
|
130
|
+
// back onto a vnode's children (islands.js:243) where the element renderer
|
|
131
|
+
// requires an array. That one needs both sides changed together.
|
|
132
|
+
function _componentProps(vnode) {
|
|
133
|
+
const children = vnode.children;
|
|
134
|
+
if (!children || children.length === 0) return { ...vnode.props };
|
|
135
|
+
return { ...vnode.props, children };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _boundaryFallback(vnode, error) {
|
|
139
|
+
const fallback = vnode.props && vnode.props.fallback;
|
|
140
|
+
if (typeof fallback !== 'function') return fallback;
|
|
141
|
+
const reset = (vnode.props && vnode.props.reset) || (() => {});
|
|
142
|
+
try {
|
|
143
|
+
return fallback({ error, reset });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (_isDevMode) {
|
|
146
|
+
console.warn(`[what-server] <ErrorBoundary> fallback threw during SSR: ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
61
152
|
function _renderHydratable(vnode) {
|
|
62
153
|
if (vnode == null || vnode === false || vnode === true) return '';
|
|
63
154
|
|
|
@@ -71,6 +162,20 @@ function _renderHydratable(vnode) {
|
|
|
71
162
|
return `<!--$-->${_renderHydratable(vnode())}<!--/$-->`;
|
|
72
163
|
}
|
|
73
164
|
|
|
165
|
+
// Compiled keyed list: an inserter taking (parent, marker), not a thunk.
|
|
166
|
+
// Calling it with no arguments threw and SSR swallowed it, so every compiled
|
|
167
|
+
// keyed list server-rendered as an empty container. See _mapArrayToArray.
|
|
168
|
+
if (typeof vnode === 'function' && vnode._mapArray) {
|
|
169
|
+
try {
|
|
170
|
+
return `<!--$-->${_renderHydratable(_mapArrayToArray(vnode))}<!--/$-->`;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
173
|
+
console.warn('[what-server] Error rendering keyed list in SSR:', e.message);
|
|
174
|
+
}
|
|
175
|
+
return '<!--$--><!--/$-->';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
74
179
|
// Reactive function child — wrap in dynamic content markers
|
|
75
180
|
if (typeof vnode === 'function') {
|
|
76
181
|
try {
|
|
@@ -88,11 +193,30 @@ function _renderHydratable(vnode) {
|
|
|
88
193
|
return `<!--[]-->${vnode.map(_renderHydratable).join('')}<!--/[]-->`;
|
|
89
194
|
}
|
|
90
195
|
|
|
196
|
+
if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return '';
|
|
197
|
+
|
|
198
|
+
// Boundary markers render their subtree, never themselves.
|
|
199
|
+
if (BOUNDARY_TAGS.has(vnode.tag)) {
|
|
200
|
+
try {
|
|
201
|
+
return (vnode.children || []).map(_renderHydratable).join('');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
const suspended = e && typeof e.then === 'function';
|
|
204
|
+
if (suspended && vnode.tag === '__errorBoundary') throw e; // belongs to <Suspense>
|
|
205
|
+
if (!suspended && vnode.tag === '__suspense') throw e; // belongs to <ErrorBoundary>
|
|
206
|
+
return _renderHydratable(_boundaryFallback(vnode, suspended ? null : e));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
91
210
|
// Component — add hydration key to root element
|
|
92
211
|
if (typeof vnode.tag === 'function') {
|
|
93
212
|
const hkId = nextHydrationId();
|
|
94
|
-
const
|
|
95
|
-
|
|
213
|
+
const ctx = _beginComponentSSR(vnode.tag);
|
|
214
|
+
let html;
|
|
215
|
+
try {
|
|
216
|
+
html = _renderHydratable(vnode.tag(_componentProps(vnode)));
|
|
217
|
+
} finally {
|
|
218
|
+
_endComponentSSR(ctx);
|
|
219
|
+
}
|
|
96
220
|
// Inject data-hk into the first element tag if present
|
|
97
221
|
return injectHydrationKey(html, hkId);
|
|
98
222
|
}
|
|
@@ -119,6 +243,19 @@ function injectHydrationKey(html, hkId) {
|
|
|
119
243
|
const prefix = match[1];
|
|
120
244
|
const tagName = match[2];
|
|
121
245
|
const insertAt = prefix.length + 1 + tagName.length; // after '<tagName'
|
|
246
|
+
|
|
247
|
+
// One element, one key. Every component in a chain injects into the first
|
|
248
|
+
// element of its rendered output, and a component that returns another
|
|
249
|
+
// component (the most ordinary composition there is) resolves to the SAME
|
|
250
|
+
// element at every level, so `Outer -> Middle -> Inner -> <p>` emitted
|
|
251
|
+
// `<p data-hk="h0" data-hk="h1" data-hk="h2">`: a duplicate attribute, which
|
|
252
|
+
// is invalid HTML, and browsers silently keep only the first. The innermost
|
|
253
|
+
// component is the one that actually owns the element, and it wins because
|
|
254
|
+
// it renders first, so an existing key means there is nothing to do here.
|
|
255
|
+
const openTagEnd = html.indexOf('>', insertAt);
|
|
256
|
+
const openTag = html.slice(insertAt, openTagEnd === -1 ? undefined : openTagEnd);
|
|
257
|
+
if (/[\s"']data-hk\s*=/.test(openTag) || /^\s*data-hk\s*=/.test(openTag)) return html;
|
|
258
|
+
|
|
122
259
|
return html.slice(0, insertAt) + ` data-hk="${hkId}"` + html.slice(insertAt);
|
|
123
260
|
}
|
|
124
261
|
return html;
|
|
@@ -146,6 +283,18 @@ export function renderToString(vnode) {
|
|
|
146
283
|
return renderToString(vnode());
|
|
147
284
|
}
|
|
148
285
|
|
|
286
|
+
// Compiled keyed list: see _renderHydratable above.
|
|
287
|
+
if (typeof vnode === 'function' && vnode._mapArray) {
|
|
288
|
+
try {
|
|
289
|
+
return renderToString(_mapArrayToArray(vnode));
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
292
|
+
console.warn('[what-server] Error rendering keyed list in SSR:', e.message);
|
|
293
|
+
}
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
149
298
|
// Reactive function child — call to get value
|
|
150
299
|
if (typeof vnode === 'function') {
|
|
151
300
|
try {
|
|
@@ -163,24 +312,42 @@ export function renderToString(vnode) {
|
|
|
163
312
|
return vnode.map(renderToString).join('');
|
|
164
313
|
}
|
|
165
314
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
315
|
+
if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return '';
|
|
316
|
+
|
|
317
|
+
// Boundary markers render their subtree, never themselves.
|
|
318
|
+
//
|
|
319
|
+
// <Suspense>: if a child suspends (throws a thenable), show the fallback, since
|
|
320
|
+
// a synchronous render cannot await. renderToStringAsync / renderToStream await
|
|
321
|
+
// the pending resources and re-render with real content.
|
|
322
|
+
//
|
|
323
|
+
// <ErrorBoundary>: if a child throws a real error, show the fallback. Before
|
|
324
|
+
// this branch existed the error propagated out of renderToString and killed the
|
|
325
|
+
// whole page response, which is precisely the failure an ErrorBoundary exists
|
|
326
|
+
// to prevent.
|
|
327
|
+
if (BOUNDARY_TAGS.has(vnode.tag)) {
|
|
170
328
|
try {
|
|
171
329
|
return (vnode.children || []).map(renderToString).join('');
|
|
172
330
|
} catch (e) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
331
|
+
const suspended = e && typeof e.then === 'function';
|
|
332
|
+
if (suspended && vnode.tag === '__errorBoundary') throw e; // belongs to <Suspense>
|
|
333
|
+
if (!suspended && vnode.tag === '__suspense') throw e; // belongs to <ErrorBoundary>
|
|
334
|
+
return renderToString(_boundaryFallback(vnode, suspended ? null : e));
|
|
177
335
|
}
|
|
178
336
|
}
|
|
179
337
|
|
|
180
338
|
// Component
|
|
339
|
+
//
|
|
340
|
+
// Run it under a component context. Calling it bare left the component stack
|
|
341
|
+
// empty, so every context-dependent hook threw and the render failed outright
|
|
342
|
+
// rather than degrading. The context has to stay on the stack while the
|
|
343
|
+
// result is rendered, because useContext resolves by walking parent contexts.
|
|
181
344
|
if (typeof vnode.tag === 'function') {
|
|
182
|
-
const
|
|
183
|
-
|
|
345
|
+
const ctx = _beginComponentSSR(vnode.tag);
|
|
346
|
+
try {
|
|
347
|
+
return renderToString(vnode.tag(_componentProps(vnode)));
|
|
348
|
+
} finally {
|
|
349
|
+
_endComponentSSR(ctx);
|
|
350
|
+
}
|
|
184
351
|
}
|
|
185
352
|
|
|
186
353
|
// Element
|
|
@@ -315,6 +482,20 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
315
482
|
return;
|
|
316
483
|
}
|
|
317
484
|
|
|
485
|
+
// Compiled keyed list: see _renderHydratable above.
|
|
486
|
+
if (typeof vnode === 'function' && vnode._mapArray) {
|
|
487
|
+
let rows = null;
|
|
488
|
+
try {
|
|
489
|
+
rows = runWithServerContext(ctx, () => _mapArrayToArray(vnode));
|
|
490
|
+
} catch (e) {
|
|
491
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
492
|
+
console.warn('[what-server] Error rendering keyed list in stream SSR:', e.message);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (rows) yield* renderToStream(rows, ctx);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
318
499
|
// Reactive function child — call to get value
|
|
319
500
|
if (typeof vnode === 'function') {
|
|
320
501
|
try {
|
|
@@ -335,6 +516,24 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
335
516
|
return;
|
|
336
517
|
}
|
|
337
518
|
|
|
519
|
+
if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return;
|
|
520
|
+
|
|
521
|
+
// Error boundary: render the subtree, and on a real error emit the fallback
|
|
522
|
+
// rather than the generic component-error comment the catch below would
|
|
523
|
+
// produce. Rendered eagerly into a string instead of streamed, because a
|
|
524
|
+
// boundary that has already yielded half its subtree cannot take it back.
|
|
525
|
+
if (vnode.tag === '__errorBoundary') {
|
|
526
|
+
let html;
|
|
527
|
+
try {
|
|
528
|
+
html = runWithServerContext(ctx, () => (vnode.children || []).map(renderToString).join(''));
|
|
529
|
+
} catch (e) {
|
|
530
|
+
if (e && typeof e.then === 'function') throw e; // suspended; belongs to <Suspense>
|
|
531
|
+
html = runWithServerContext(ctx, () => renderToString(_boundaryFallback(vnode, e)));
|
|
532
|
+
}
|
|
533
|
+
yield html;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
338
537
|
// Suspense boundary — render the subtree, awaiting any suspended resources,
|
|
339
538
|
// then emit the resolved content. (In-order; out-of-order swap is a future
|
|
340
539
|
// enhancement.) The synchronous render runs inside the threaded ctx.
|
|
@@ -361,10 +560,14 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
361
560
|
}
|
|
362
561
|
|
|
363
562
|
if (typeof vnode.tag === 'function') {
|
|
563
|
+
// The frame stays open across the yields below: useContext resolves by
|
|
564
|
+
// walking parent contexts, so a Provider's context has to outlive the
|
|
565
|
+
// streaming of its own subtree.
|
|
566
|
+
const componentCtx = _beginComponentSSR(vnode.tag);
|
|
364
567
|
try {
|
|
365
568
|
const result = runWithServerContext(
|
|
366
569
|
ctx,
|
|
367
|
-
() => vnode.tag(
|
|
570
|
+
() => vnode.tag(_componentProps(vnode))
|
|
368
571
|
);
|
|
369
572
|
// Support async components
|
|
370
573
|
const resolved = result instanceof Promise ? await result : result;
|
|
@@ -376,6 +579,8 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
376
579
|
yield _isDevMode
|
|
377
580
|
? `<!-- SSR Error: ${escapeHtml(e.message || 'Component error')} -->`
|
|
378
581
|
: `<!-- SSR Error -->`;
|
|
582
|
+
} finally {
|
|
583
|
+
_endComponentSSR(componentCtx);
|
|
379
584
|
}
|
|
380
585
|
return;
|
|
381
586
|
}
|
|
@@ -414,10 +619,19 @@ export function definePage(config) {
|
|
|
414
619
|
// Generate static HTML for a page
|
|
415
620
|
export function generateStaticPage(page, data = {}) {
|
|
416
621
|
const ctx = createRenderContext(data);
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
622
|
+
// Render the page as a vnode instead of calling the component and handing the
|
|
623
|
+
// result over. `page.component(data)` ran BARE, outside the component frame
|
|
624
|
+
// renderToString establishes, so nothing was on the component stack and every
|
|
625
|
+
// context-dependent hook threw: useState, useSignal, useEffect, useMemo,
|
|
626
|
+
// useRef, onMount and <Ctx.Provider> all resolve through
|
|
627
|
+
// getCurrentComponent(). One hook anywhere in the page component's own body
|
|
628
|
+
// meant THE documented static-generation entry point could not render it at
|
|
629
|
+
// all, which rules out most real pages.
|
|
630
|
+
//
|
|
631
|
+
// `data` still reaches the component as its props (the same convention
|
|
632
|
+
// renderPage uses), and the body HTML this produces is byte-identical for a
|
|
633
|
+
// page that does not use hooks, so what wrapDocument receives is unchanged.
|
|
634
|
+
const html = runWithServerContext(ctx, () => renderToString(h(page.component, data)));
|
|
421
635
|
const islands = page.islands || [];
|
|
422
636
|
|
|
423
637
|
return wrapDocument({
|
|
@@ -539,10 +753,40 @@ function assertSafeTag(tag) {
|
|
|
539
753
|
|
|
540
754
|
function renderAttrs(props) {
|
|
541
755
|
let out = '';
|
|
542
|
-
for (const [key,
|
|
756
|
+
for (const [key, rawVal] of Object.entries(props)) {
|
|
543
757
|
if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
|
|
544
758
|
const lowerKey = key.toLowerCase();
|
|
545
759
|
if (lowerKey.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
|
|
760
|
+
|
|
761
|
+
// A remaining function value is a reactive accessor, so CALL it. Every
|
|
762
|
+
// client path already does (setProp and setAttr both resolve a function
|
|
763
|
+
// value); only the server did not, and fell through to String(val), which
|
|
764
|
+
// stringifies a function as its SOURCE TEXT. So the documented way to make
|
|
765
|
+
// an attribute reactive,
|
|
766
|
+
//
|
|
767
|
+
// <span className={() => theme()}>
|
|
768
|
+
//
|
|
769
|
+
// server-rendered as class="() => theme()": the page shipped JavaScript
|
|
770
|
+
// source in its class attribute, lost the real class until hydration
|
|
771
|
+
// replaced it, and any CSS keyed on that class did not apply to the HTML a
|
|
772
|
+
// crawler or a no-JS visitor saw. what-router's <Link> hit it on every
|
|
773
|
+
// link, because Link always passes a thunk as `class`.
|
|
774
|
+
//
|
|
775
|
+
// Fail soft on a throw, matching the rest of this function: one attribute
|
|
776
|
+
// that cannot resolve must not take down the whole response.
|
|
777
|
+
let val = rawVal;
|
|
778
|
+
if (typeof val === 'function') {
|
|
779
|
+
try {
|
|
780
|
+
val = val();
|
|
781
|
+
} catch (e) {
|
|
782
|
+
if (_isDevMode) {
|
|
783
|
+
console.warn(`[what-server] Skipping attribute ${JSON.stringify(key)}: its value threw during SSR: ${e.message}`);
|
|
784
|
+
}
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// A resolved value that is itself a function is not an attribute value.
|
|
788
|
+
if (typeof val === 'function') continue;
|
|
789
|
+
}
|
|
546
790
|
// aria-*/role are enumerated, so `false` is a real value and must survive:
|
|
547
791
|
// an absent `aria-expanded` means "unsupported", `aria-expanded="false"`
|
|
548
792
|
// means "collapsed". Every other attribute keeps HTML boolean semantics,
|
package/src/islands.js
CHANGED
|
@@ -473,54 +473,180 @@ export function enhance(selector, handler) {
|
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Recover the double-submit CSRF token for a form.
|
|
478
|
+
*
|
|
479
|
+
* The meta tag alone is not enough. A cached page (mode 'static' or 'hybrid') is
|
|
480
|
+
* shared between visitors, so the adapter deliberately does NOT embed a
|
|
481
|
+
* per-visitor token in it; the token lives only in the cookie. A form inside
|
|
482
|
+
* such a page therefore has no meta tag to read, and blocking on that alone
|
|
483
|
+
* refused perfectly valid submissions. `<Form>` also emits the token as a hidden
|
|
484
|
+
* field, which is the same value, so all three sources are checked.
|
|
485
|
+
*/
|
|
486
|
+
function readFormCsrfToken(form) {
|
|
487
|
+
const meta = document.querySelector('meta[name="csrf-token"]')
|
|
488
|
+
|| document.querySelector('meta[name="what-csrf-token"]');
|
|
489
|
+
if (meta) return meta.getAttribute('content');
|
|
490
|
+
|
|
491
|
+
const field = form.querySelector('input[name="what-csrf-token"], input[name="_csrf"]');
|
|
492
|
+
if (field && field.value) return field.value;
|
|
493
|
+
|
|
494
|
+
const cookie = document.cookie.match(/(?:^|;\s*)what-csrf=([^;]+)/);
|
|
495
|
+
return cookie ? decodeURIComponent(cookie[1]) : null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Serialize a form the way the browser would, so an enhanced submit and a plain
|
|
500
|
+
* one are indistinguishable to the server.
|
|
501
|
+
*
|
|
502
|
+
* Two details that look pedantic and are not:
|
|
503
|
+
* - newlines in every text entry normalize to CRLF. The urlencoded and
|
|
504
|
+
* multipart serializers both do this per spec; URLSearchParams does not, so
|
|
505
|
+
* a <textarea> round-tripped different bytes with JS on than with JS off.
|
|
506
|
+
* - a File under a non-multipart encoding contributes only its NAME, which is
|
|
507
|
+
* what a native submit sends. Sending "[object File]" would be worse than
|
|
508
|
+
* useless, and silently sending nothing hides a real mistake.
|
|
509
|
+
*/
|
|
510
|
+
function formEntries(form, submitter, multipart) {
|
|
511
|
+
const data = new FormData(form);
|
|
512
|
+
|
|
513
|
+
// FormData(form) never includes the submit button, so a multi-button form
|
|
514
|
+
// could not tell the server which button was pressed. Native submits include
|
|
515
|
+
// the submitter's name/value.
|
|
516
|
+
if (submitter && submitter.name) {
|
|
517
|
+
data.append(submitter.name, submitter.value ?? '');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (multipart) return data;
|
|
521
|
+
|
|
522
|
+
const params = new URLSearchParams();
|
|
523
|
+
let droppedFile = null;
|
|
524
|
+
for (const [key, value] of data) {
|
|
525
|
+
if (typeof value === 'string') {
|
|
526
|
+
params.append(key, value.replace(/\r\n|\r|\n/g, '\r\n'));
|
|
527
|
+
} else {
|
|
528
|
+
droppedFile = droppedFile || key;
|
|
529
|
+
params.append(key, value.name ?? '');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (droppedFile && typeof console !== 'undefined') {
|
|
534
|
+
console.warn(
|
|
535
|
+
`[what] Form field "${droppedFile}" holds a file, but the form is not ` +
|
|
536
|
+
'enctype="multipart/form-data", so only the file NAME is sent. This is what ' +
|
|
537
|
+
'a plain HTML submit does too. Add enctype="multipart/form-data" to upload ' +
|
|
538
|
+
'the bytes.'
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return params;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Form enhancement: submit via fetch instead of a full page load.
|
|
546
|
+
*
|
|
547
|
+
* The encoding is not a detail. `/__what_action` parses
|
|
548
|
+
* application/x-www-form-urlencoded or JSON, never multipart, so an enhanced
|
|
549
|
+
* submit of a default form has to use the same encoding as the plain HTML submit
|
|
550
|
+
* it replaces. Posting a FormData object unconditionally produced a multipart
|
|
551
|
+
* body the endpoint could not parse: the action id went missing and every
|
|
552
|
+
* enhanced `<Form>` failed with 400 while the no-JS path kept working.
|
|
553
|
+
*
|
|
554
|
+
* The encoding now follows the form's own `enctype`, exactly as the browser
|
|
555
|
+
* would, so a form that declares multipart still uploads its files.
|
|
556
|
+
*
|
|
557
|
+
* On success the action endpoint answers 303 to the form's `_redirect` target,
|
|
558
|
+
* which fetch follows. The page is then navigated there so the enhanced path
|
|
559
|
+
* lands where the unenhanced one would. Cancel the `form:response` event to keep
|
|
560
|
+
* the page put and handle the response yourself.
|
|
561
|
+
*/
|
|
477
562
|
export function enhanceForms(selector = 'form[data-enhance]') {
|
|
478
563
|
enhance(selector, (form) => {
|
|
479
|
-
form.addEventListener('submit', async (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const formData = new FormData(form);
|
|
483
|
-
const method = form.method.toUpperCase() || 'POST';
|
|
484
|
-
const action = form.action || location.href;
|
|
564
|
+
form.addEventListener('submit', async (event) => {
|
|
565
|
+
event.preventDefault();
|
|
485
566
|
|
|
486
567
|
try {
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
568
|
+
// getAttribute, never the properties. HTMLFormElement is
|
|
569
|
+
// [LegacyOverrideBuiltIns]: a field named "method" or "action" SHADOWS
|
|
570
|
+
// form.method / form.action with the input element itself. Reading
|
|
571
|
+
// `form.method.toUpperCase()` then threw a TypeError after
|
|
572
|
+
// preventDefault() had already run, so the submit produced no fetch, no
|
|
573
|
+
// form:error, and no native fallback. It just did nothing.
|
|
574
|
+
const submitter = event.submitter || null;
|
|
575
|
+
const attr = (name) => (submitter && submitter.getAttribute(`form${name}`))
|
|
576
|
+
|| form.getAttribute(name);
|
|
577
|
+
|
|
578
|
+
const method = (attr('method') || 'get').toUpperCase();
|
|
579
|
+
const action = new URL(attr('action') || location.href, location.href);
|
|
580
|
+
const enctype = (attr('enctype') || '').toLowerCase();
|
|
581
|
+
const multipart = enctype === 'multipart/form-data';
|
|
582
|
+
|
|
583
|
+
const entries = formEntries(form, submitter, multipart);
|
|
584
|
+
|
|
585
|
+
// CSRF is about protecting OUR endpoint. A form aimed at another origin
|
|
586
|
+
// must neither be blocked by our token policy nor be handed our token:
|
|
587
|
+
// attaching it would export the visitor's double-submit secret to a
|
|
588
|
+
// third party.
|
|
589
|
+
const sameOrigin = action.origin === location.origin;
|
|
590
|
+
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
|
591
|
+
|
|
592
|
+
if (sameOrigin) {
|
|
593
|
+
const csrfToken = readFormCsrfToken(form);
|
|
594
|
+
const noCsrf = form.getAttribute('data-no-csrf') === 'true';
|
|
595
|
+
if (!csrfToken && !noCsrf) {
|
|
596
|
+
console.warn(
|
|
597
|
+
'[what] Form submission blocked: no CSRF token found. ' +
|
|
598
|
+
'Add a <meta name="what-csrf-token"> tag (csrfMetaTag() emits it) ' +
|
|
599
|
+
'or set data-no-csrf="true" on the form to opt out.'
|
|
600
|
+
);
|
|
601
|
+
form.dispatchEvent(new CustomEvent('form:error', {
|
|
602
|
+
bubbles: true,
|
|
603
|
+
detail: { error: new Error('Missing CSRF token') },
|
|
604
|
+
}));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
|
505
608
|
}
|
|
506
609
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
610
|
+
let body;
|
|
611
|
+
if (method === 'GET') {
|
|
612
|
+
// A native GET submit REPLACES the query string with the form data.
|
|
613
|
+
// The previous code built the params and then threw them away, so an
|
|
614
|
+
// enhanced GET form fetched a bare URL with none of its fields.
|
|
615
|
+
action.search = String(entries);
|
|
616
|
+
} else if (multipart) {
|
|
617
|
+
// Hand FormData straight to fetch so the browser writes the boundary.
|
|
618
|
+
body = entries;
|
|
619
|
+
} else {
|
|
620
|
+
body = entries;
|
|
621
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
512
622
|
}
|
|
513
623
|
|
|
514
|
-
const response = await fetch(action, {
|
|
624
|
+
const response = await fetch(action.href, {
|
|
515
625
|
method,
|
|
516
|
-
body
|
|
626
|
+
body,
|
|
517
627
|
headers,
|
|
628
|
+
credentials: 'same-origin',
|
|
518
629
|
});
|
|
519
630
|
|
|
520
|
-
|
|
631
|
+
const responseEvent = new CustomEvent('form:response', {
|
|
521
632
|
bubbles: true,
|
|
522
|
-
|
|
523
|
-
|
|
633
|
+
cancelable: true,
|
|
634
|
+
detail: { response, ok: response.ok, redirected: response.redirected },
|
|
635
|
+
});
|
|
636
|
+
const proceed = form.dispatchEvent(responseEvent);
|
|
637
|
+
|
|
638
|
+
// Same-origin only. A native submit would follow an off-site redirect,
|
|
639
|
+
// but a framework default that can navigate the page to another origin
|
|
640
|
+
// on a server's say-so is not a default worth having. Listen for
|
|
641
|
+
// form:response if you need that.
|
|
642
|
+
if (proceed && response.ok && response.redirected) {
|
|
643
|
+
let target = null;
|
|
644
|
+
try {
|
|
645
|
+
const url = new URL(response.url, location.href);
|
|
646
|
+
if (url.origin === location.origin) target = url.href;
|
|
647
|
+
} catch { /* unparseable: do not navigate */ }
|
|
648
|
+
if (target) location.assign(target);
|
|
649
|
+
}
|
|
524
650
|
} catch (error) {
|
|
525
651
|
form.dispatchEvent(new CustomEvent('form:error', {
|
|
526
652
|
bubbles: true,
|