what-server 0.11.6 → 0.11.8
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/actions.min.js +1 -1
- package/dist/index.min.js +9 -9
- package/dist/islands.min.js +1 -1
- package/dist/node.min.js +24 -0
- package/index.d.ts +19 -65
- package/islands.d.ts +99 -0
- package/package.json +15 -5
- package/src/action-handler.js +10 -2
- package/src/actions.js +16 -3
- package/src/adapter/core.js +19 -3
- package/src/adapter/node.js +31 -1
- package/src/adapter/static.js +15 -2
- package/src/index.js +64 -13
- package/src/islands.js +132 -29
- package/src/node.js +18 -0
- package/src/serialize.js +1 -1
package/src/action-handler.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { handleActionRequest } from './actions.js';
|
|
10
10
|
|
|
11
11
|
const DEFAULT_BASE_PATH = '/__what_action';
|
|
12
|
-
const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
|
|
12
|
+
export const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
|
|
13
13
|
|
|
14
14
|
function lowerHeaders(headers) {
|
|
15
15
|
if (!headers) return {};
|
|
@@ -51,7 +51,15 @@ function htmlResponse(status, message) {
|
|
|
51
51
|
// "/\evil.com" canonicalizes to http://evil.com (an open redirect). We reject
|
|
52
52
|
// anything starting with two slash-or-backslash chars or containing a
|
|
53
53
|
// backslash, then canonicalize via URL and require the localhost origin.
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// Sibling predicate: isSafeUrl in packages/router/src/index.js. That one gates
|
|
56
|
+
// a client navigation target and legitimately allows absolute http(s), mailto:
|
|
57
|
+
// and tel:. This one gates a server-issued `Location:` header, an
|
|
58
|
+
// attacker-controllable open-redirect primitive, so it must stay strictly
|
|
59
|
+
// narrower: same-origin local paths only. Harden one, re-read the other; do not
|
|
60
|
+
// unify them. packages/server/test/redirect-predicate-parity.test.js gates the
|
|
61
|
+
// ordering. Exported for that test only, not part of the package surface.
|
|
62
|
+
export function safeLocalPath(value) {
|
|
55
63
|
if (typeof value !== 'string' || !value.startsWith('/')) return null;
|
|
56
64
|
// Reject protocol-relative / backslash-smuggled targets up front.
|
|
57
65
|
if (/^[/\\]{2}/.test(value) || value.includes('\\')) return null;
|
package/src/actions.js
CHANGED
|
@@ -78,11 +78,24 @@ export function csrfMetaTag(token) {
|
|
|
78
78
|
// --- Define a server action ---
|
|
79
79
|
|
|
80
80
|
let _actionCounter = 0;
|
|
81
|
+
let _fallbackWarningShown = false;
|
|
81
82
|
|
|
82
83
|
function generateActionId() {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
if (
|
|
85
|
+
!_fallbackWarningShown
|
|
86
|
+
&& typeof process !== 'undefined'
|
|
87
|
+
&& process.env?.NODE_ENV !== 'production'
|
|
88
|
+
) {
|
|
89
|
+
_fallbackWarningShown = true;
|
|
90
|
+
console.warn(
|
|
91
|
+
'[what-server] action() is using a runtime-generated ID because compiler metadata is missing. ' +
|
|
92
|
+
'Build with what-compiler/vite-plugin-what or pass an explicit { id } so client and server bundles agree.'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// Compatibility fallback for direct, uncompiled script users. Normal builds
|
|
96
|
+
// receive a deterministic compiler ID derived from source path + binding.
|
|
97
|
+
// Never use Math.random here; crypto avoids predictable collisions, and the
|
|
98
|
+
// monotonic fallback keeps older runtimes functional.
|
|
86
99
|
const rand = typeof crypto !== 'undefined' && crypto.getRandomValues
|
|
87
100
|
? Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(16).padStart(2, '0')).join('')
|
|
88
101
|
: `c${(++_actionCounter).toString(36)}_${Date.now().toString(36)}`;
|
package/src/adapter/core.js
CHANGED
|
@@ -39,8 +39,14 @@ async function readActionBody(request) {
|
|
|
39
39
|
} catch { return {}; }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Same cap for the revalidate webhook: the secret is only checked once the body
|
|
43
|
+
// is parsed, so an unauthenticated client must never be able to make the origin
|
|
44
|
+
// buffer an unbounded payload before its 401.
|
|
42
45
|
async function readJsonBody(request) {
|
|
43
|
-
|
|
46
|
+
let read;
|
|
47
|
+
try { read = await readFetchBodyCapped(request); } catch { return { json: {} }; }
|
|
48
|
+
if (read.tooLarge) return { tooLarge: true };
|
|
49
|
+
try { return { json: JSON.parse(read.raw) }; } catch { return { json: {} }; }
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
function defaultRenderRoute(documentOptions) {
|
|
@@ -164,8 +170,14 @@ export function createRequestHandler(options = {}) {
|
|
|
164
170
|
|
|
165
171
|
// On-demand revalidation webhook
|
|
166
172
|
if (request.method === 'POST' && pathname === REVALIDATE_PATH && revalidateWebhook) {
|
|
167
|
-
const
|
|
168
|
-
|
|
173
|
+
const read = await readJsonBody(request);
|
|
174
|
+
if (read.tooLarge) {
|
|
175
|
+
return new Response(JSON.stringify({ message: 'Payload too large' }), {
|
|
176
|
+
status: 413,
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const out = await revalidateWebhook({ headers: headersToObject(request.headers), body: read.json });
|
|
169
181
|
return new Response(JSON.stringify(out.body), {
|
|
170
182
|
status: out.status,
|
|
171
183
|
headers: { 'content-type': 'application/json' },
|
|
@@ -187,6 +199,10 @@ export function createRequestHandler(options = {}) {
|
|
|
187
199
|
// NOTE: cached HTML is shared across users, so the per-user CSRF token is
|
|
188
200
|
// NOT embedded in the page here — clients read it from the cookie instead.
|
|
189
201
|
if (cache && config.mode !== 'server') {
|
|
202
|
+
// The cache engine resolves the route's declared `vary` names against
|
|
203
|
+
// these. Without them it fails closed: warns and bypasses on every
|
|
204
|
+
// request, which is how the vary control shipped never executing.
|
|
205
|
+
routeMatch.varyHeaders = headersToObject(request.headers);
|
|
190
206
|
const result = await cache.handle(routeMatch, () => renderRoute(routeMatch));
|
|
191
207
|
return new Response(result.html, {
|
|
192
208
|
status: result.status || 200,
|
package/src/adapter/node.js
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { createRequestHandler } from './core.js';
|
|
7
|
+
import { MAX_BODY_BYTES } from '../action-handler.js';
|
|
8
|
+
|
|
9
|
+
// A Request object only exists after the body is read, so the cap the fetch
|
|
10
|
+
// path enforces would run too late here. Returned instead of a Request when the
|
|
11
|
+
// body is over the limit, so the caller answers 413 without buffering it all.
|
|
12
|
+
const TOO_LARGE = Symbol('what.bodyTooLarge');
|
|
13
|
+
|
|
14
|
+
const TOO_LARGE_RESPONSE = () => new Response(
|
|
15
|
+
JSON.stringify({ message: 'Payload too large' }),
|
|
16
|
+
{ status: 413, headers: { 'content-type': 'application/json' } },
|
|
17
|
+
);
|
|
7
18
|
|
|
8
19
|
async function nodeToWebRequest(req) {
|
|
9
20
|
const host = req.headers.host || 'localhost';
|
|
@@ -14,8 +25,25 @@ async function nodeToWebRequest(req) {
|
|
|
14
25
|
}
|
|
15
26
|
let body;
|
|
16
27
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
28
|
+
const declared = Number(req.headers['content-length']);
|
|
29
|
+
let tooLarge = Number.isFinite(declared) && declared > MAX_BODY_BYTES;
|
|
17
30
|
const chunks = [];
|
|
18
|
-
|
|
31
|
+
let size = 0;
|
|
32
|
+
// Once over the cap the remaining chunks are drained and discarded rather
|
|
33
|
+
// than buffered: memory stays bounded, and the socket survives long enough
|
|
34
|
+
// to carry the 413 back. Chunked transfer or a spoofed Content-Length
|
|
35
|
+
// cannot get past the running total.
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
if (tooLarge) continue;
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > MAX_BODY_BYTES) {
|
|
40
|
+
tooLarge = true;
|
|
41
|
+
chunks.length = 0;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
}
|
|
46
|
+
if (tooLarge) return TOO_LARGE;
|
|
19
47
|
if (chunks.length) body = Buffer.concat(chunks);
|
|
20
48
|
}
|
|
21
49
|
return new Request(url, { method: req.method, headers, body });
|
|
@@ -33,6 +61,7 @@ export function toNodeListener(handler) {
|
|
|
33
61
|
return async function listener(req, res) {
|
|
34
62
|
try {
|
|
35
63
|
const webReq = await nodeToWebRequest(req);
|
|
64
|
+
if (webReq === TOO_LARGE) return await sendWebResponse(res, TOO_LARGE_RESPONSE());
|
|
36
65
|
const webRes = await handler(webReq);
|
|
37
66
|
await sendWebResponse(res, webRes);
|
|
38
67
|
} catch (err) {
|
|
@@ -49,6 +78,7 @@ export function whatMiddleware(options = {}) {
|
|
|
49
78
|
const handler = createRequestHandler(options);
|
|
50
79
|
return async function middleware(req, res, next) {
|
|
51
80
|
const webReq = await nodeToWebRequest(req);
|
|
81
|
+
if (webReq === TOO_LARGE) return await sendWebResponse(res, TOO_LARGE_RESPONSE());
|
|
52
82
|
const webRes = await handler(webReq);
|
|
53
83
|
if (webRes.status === 404 && typeof next === 'function') return next();
|
|
54
84
|
await sendWebResponse(res, webRes);
|
package/src/adapter/static.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// navigation, mirroring Next's _next/data).
|
|
4
4
|
|
|
5
5
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
-
import { join } from 'node:path';
|
|
6
|
+
import { join, resolve, sep } from 'node:path';
|
|
7
7
|
import { matchRoute } from 'what-router/match';
|
|
8
8
|
import { renderDocument, serializeState } from '../index.js';
|
|
9
9
|
|
|
@@ -19,6 +19,19 @@ function buildConcretePath(pattern, params) {
|
|
|
19
19
|
.replace(/[:*](\w+)/g, (_, n) => params[n] ?? '');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// getStaticPaths params come from user data (a CMS slug, a filename), so a value
|
|
23
|
+
// like '../../etc/cron.d/x' would escape outDir once join() collapses it. Every
|
|
24
|
+
// output directory is checked against the resolved outDir before anything is
|
|
25
|
+
// written.
|
|
26
|
+
function resolveInsideOutDir(outDir, urlPath) {
|
|
27
|
+
const root = resolve(outDir);
|
|
28
|
+
const dirPath = resolve(join(outDir, urlPath === '/' ? '' : urlPath));
|
|
29
|
+
if (dirPath !== root && !dirPath.startsWith(root + sep)) {
|
|
30
|
+
throw new Error(`[what-server] Refusing to write outside outDir: ${urlPath}`);
|
|
31
|
+
}
|
|
32
|
+
return dirPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export async function exportStatic({ routes = [], outDir, render, documentOptions = {} } = {}) {
|
|
23
36
|
const written = [];
|
|
24
37
|
|
|
@@ -36,6 +49,7 @@ export async function exportStatic({ routes = [], outDir, render, documentOption
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
for (const urlPath of concrete) {
|
|
52
|
+
const dirPath = resolveInsideOutDir(outDir, urlPath);
|
|
39
53
|
const matched = matchRoute(urlPath, [route]);
|
|
40
54
|
const params = matched ? matched.params : {};
|
|
41
55
|
const reqCtx = { params, query: {} };
|
|
@@ -44,7 +58,6 @@ export async function exportStatic({ routes = [], outDir, render, documentOption
|
|
|
44
58
|
? await render(pageModule, reqCtx)
|
|
45
59
|
: await renderDocument(pageModule, reqCtx, documentOptions);
|
|
46
60
|
|
|
47
|
-
const dirPath = join(outDir, urlPath === '/' ? '' : urlPath);
|
|
48
61
|
await mkdir(dirPath, { recursive: true });
|
|
49
62
|
await writeFile(join(dirPath, 'index.html'), html);
|
|
50
63
|
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// SSR, static site generation, server components.
|
|
3
3
|
// Zero-JS pages by default. Islands opt-in to client JS.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
h,
|
|
7
|
+
getServerContext,
|
|
8
|
+
runWithServerContext,
|
|
9
|
+
beginHeadCollection,
|
|
10
|
+
endHeadCollection,
|
|
11
|
+
} from 'what-core';
|
|
6
12
|
import { serializeState } from './serialize.js';
|
|
7
13
|
import { getIslandStoresSnapshot } from './islands.js';
|
|
8
14
|
import { csrfMetaTag } from './actions.js';
|
|
@@ -13,6 +19,7 @@ function createRenderContext(loaderData) {
|
|
|
13
19
|
head: beginHeadCollection(),
|
|
14
20
|
loaderData,
|
|
15
21
|
resources: new Map(),
|
|
22
|
+
islandStores: new Map(),
|
|
16
23
|
resourceCounter: 0,
|
|
17
24
|
boundaryCounter: 0,
|
|
18
25
|
suspended: [],
|
|
@@ -35,6 +42,10 @@ function nextHydrationId() {
|
|
|
35
42
|
// so the client can reuse the server-rendered DOM.
|
|
36
43
|
|
|
37
44
|
export function renderToHydratableString(vnode) {
|
|
45
|
+
if (typeof document === 'undefined' && !getServerContext()) {
|
|
46
|
+
const ctx = createRenderContext(undefined);
|
|
47
|
+
return runWithServerContext(ctx, () => renderToHydratableString(vnode));
|
|
48
|
+
}
|
|
38
49
|
resetHydrationId();
|
|
39
50
|
return _renderHydratable(vnode);
|
|
40
51
|
}
|
|
@@ -80,6 +91,7 @@ function _renderHydratable(vnode) {
|
|
|
80
91
|
|
|
81
92
|
// Element
|
|
82
93
|
const { tag, props, children } = vnode;
|
|
94
|
+
assertSafeTag(tag);
|
|
83
95
|
const attrs = renderAttrs(props || {});
|
|
84
96
|
const open = `<${tag}${attrs}>`;
|
|
85
97
|
|
|
@@ -108,6 +120,10 @@ function injectHydrationKey(html, hkId) {
|
|
|
108
120
|
// Renders a VNode tree to an HTML string. Used for SSR and static gen.
|
|
109
121
|
|
|
110
122
|
export function renderToString(vnode) {
|
|
123
|
+
if (typeof document === 'undefined' && !getServerContext()) {
|
|
124
|
+
const ctx = createRenderContext(undefined);
|
|
125
|
+
return runWithServerContext(ctx, () => renderToString(vnode));
|
|
126
|
+
}
|
|
111
127
|
if (vnode == null || vnode === false || vnode === true) return '';
|
|
112
128
|
|
|
113
129
|
// Text
|
|
@@ -159,6 +175,7 @@ export function renderToString(vnode) {
|
|
|
159
175
|
|
|
160
176
|
// Element
|
|
161
177
|
const { tag, props, children } = vnode;
|
|
178
|
+
assertSafeTag(tag);
|
|
162
179
|
const attrs = renderAttrs(props || {});
|
|
163
180
|
const open = `<${tag}${attrs}>`;
|
|
164
181
|
|
|
@@ -243,7 +260,7 @@ export async function renderDocument(pageModule, reqCtx = {}, options = {}) {
|
|
|
243
260
|
const payload = {
|
|
244
261
|
loaderData: loaderData ?? null,
|
|
245
262
|
resources,
|
|
246
|
-
islandStores: getIslandStoresSnapshot(),
|
|
263
|
+
islandStores: getIslandStoresSnapshot(ctx),
|
|
247
264
|
};
|
|
248
265
|
return wrapHtmlDocument({ body, head, payload, options });
|
|
249
266
|
}
|
|
@@ -283,14 +300,16 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
283
300
|
|
|
284
301
|
// Signal — unwrap by calling it
|
|
285
302
|
if (typeof vnode === 'function' && vnode._signal) {
|
|
286
|
-
|
|
303
|
+
const value = runWithServerContext(ctx, () => vnode());
|
|
304
|
+
yield* renderToStream(value, ctx);
|
|
287
305
|
return;
|
|
288
306
|
}
|
|
289
307
|
|
|
290
308
|
// Reactive function child — call to get value
|
|
291
309
|
if (typeof vnode === 'function') {
|
|
292
310
|
try {
|
|
293
|
-
|
|
311
|
+
const value = runWithServerContext(ctx, () => vnode());
|
|
312
|
+
yield* renderToStream(value, ctx);
|
|
294
313
|
} catch (e) {
|
|
295
314
|
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
296
315
|
console.warn('[what-server] Error rendering reactive function in stream SSR:', e.message);
|
|
@@ -333,7 +352,10 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
333
352
|
|
|
334
353
|
if (typeof vnode.tag === 'function') {
|
|
335
354
|
try {
|
|
336
|
-
const result =
|
|
355
|
+
const result = runWithServerContext(
|
|
356
|
+
ctx,
|
|
357
|
+
() => vnode.tag({ ...vnode.props, children: vnode.children })
|
|
358
|
+
);
|
|
337
359
|
// Support async components
|
|
338
360
|
const resolved = result instanceof Promise ? await result : result;
|
|
339
361
|
yield* renderToStream(resolved, ctx);
|
|
@@ -349,6 +371,7 @@ export async function* renderToStream(vnode, ctx) {
|
|
|
349
371
|
}
|
|
350
372
|
|
|
351
373
|
const { tag, props, children } = vnode;
|
|
374
|
+
assertSafeTag(tag);
|
|
352
375
|
const attrs = renderAttrs(props || {});
|
|
353
376
|
yield `<${tag}${attrs}>`;
|
|
354
377
|
|
|
@@ -380,8 +403,11 @@ export function definePage(config) {
|
|
|
380
403
|
|
|
381
404
|
// Generate static HTML for a page
|
|
382
405
|
export function generateStaticPage(page, data = {}) {
|
|
383
|
-
const
|
|
384
|
-
const html =
|
|
406
|
+
const ctx = createRenderContext(data);
|
|
407
|
+
const html = runWithServerContext(ctx, () => {
|
|
408
|
+
const vnode = page.component(data);
|
|
409
|
+
return renderToString(vnode);
|
|
410
|
+
});
|
|
385
411
|
const islands = page.islands || [];
|
|
386
412
|
|
|
387
413
|
return wrapDocument({
|
|
@@ -489,11 +515,24 @@ function _resolveInnerHTML(props) {
|
|
|
489
515
|
// xlink:href, SVG names like stroke-width, and namespaced attributes.
|
|
490
516
|
const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;
|
|
491
517
|
|
|
518
|
+
// Tag names are emitted verbatim too, so they get the same treatment:
|
|
519
|
+
// h('div onload=alert(1) x', {}) -> <div onload=alert(1) x>
|
|
520
|
+
// Allows custom elements (my-el), SVG camelCase (clipPath) and the internal
|
|
521
|
+
// '__suspense' marker tag.
|
|
522
|
+
const SAFE_TAG_NAME = /^[a-zA-Z_][a-zA-Z0-9._:-]*$/;
|
|
523
|
+
|
|
524
|
+
function assertSafeTag(tag) {
|
|
525
|
+
if (typeof tag !== 'string' || !SAFE_TAG_NAME.test(tag)) {
|
|
526
|
+
throw new Error(`[what-server] Invalid tag name in SSR: ${JSON.stringify(tag)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
492
530
|
function renderAttrs(props) {
|
|
493
531
|
let out = '';
|
|
494
532
|
for (const [key, val] of Object.entries(props)) {
|
|
495
533
|
if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
|
|
496
|
-
|
|
534
|
+
const lowerKey = key.toLowerCase();
|
|
535
|
+
if (lowerKey.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
|
|
497
536
|
if (val === false || val == null) continue;
|
|
498
537
|
if (!SAFE_ATTR_NAME.test(key)) {
|
|
499
538
|
if (_isDevMode) {
|
|
@@ -501,6 +540,12 @@ function renderAttrs(props) {
|
|
|
501
540
|
}
|
|
502
541
|
continue;
|
|
503
542
|
}
|
|
543
|
+
if (REFUSED_ATTRS.has(lowerKey)) {
|
|
544
|
+
if (_isDevMode) {
|
|
545
|
+
console.warn(`[what-server] Skipping unsafe attribute in SSR: ${JSON.stringify(key)}`);
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
504
549
|
|
|
505
550
|
if (key === 'className' || key === 'class') {
|
|
506
551
|
out += ` class="${escapeHtml(String(val))}"`;
|
|
@@ -533,10 +578,17 @@ function isUnsafeUrlAttribute(key, val) {
|
|
|
533
578
|
return normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:') || normalizedValue.startsWith('data:');
|
|
534
579
|
}
|
|
535
580
|
|
|
581
|
+
// Must stay in step with URL_ATTRS in what-core's dom.js. Both sets are gated by
|
|
582
|
+
// the parity test in test/ssr-security.test.js.
|
|
536
583
|
const URL_ATTRS = new Set([
|
|
537
|
-
'href', 'src', 'action', 'formaction', 'xlink:href',
|
|
584
|
+
'href', 'src', 'action', 'formaction', 'data', 'ping', 'xlink:href',
|
|
538
585
|
]);
|
|
539
586
|
|
|
587
|
+
// Attributes whose value the browser parses as markup or code, so escaping is
|
|
588
|
+
// not a defense: srcdoc is entity-decoded and parsed as a full document, which
|
|
589
|
+
// revives "<script>". These are refused outright.
|
|
590
|
+
const REFUSED_ATTRS = new Set(['srcdoc']);
|
|
591
|
+
|
|
540
592
|
function escapeHtml(str) {
|
|
541
593
|
return str
|
|
542
594
|
.replace(/&/g, '&')
|
|
@@ -589,12 +641,11 @@ export {
|
|
|
589
641
|
getRevalidationHandler,
|
|
590
642
|
} from './revalidation-registry.js';
|
|
591
643
|
|
|
592
|
-
//
|
|
644
|
+
// Runtime-neutral deploy adapters. Node-only adapters are re-exported by the
|
|
645
|
+
// package's conditional Node entry so browser/edge bundles never resolve Node
|
|
646
|
+
// builtins merely because they import a client-safe server action.
|
|
593
647
|
export { createRequestHandler } from './adapter/core.js';
|
|
594
|
-
export { createServer, toNodeListener, whatMiddleware } from './adapter/node.js';
|
|
595
|
-
export { exportStatic } from './adapter/static.js';
|
|
596
648
|
export { createCloudflareHandler } from './adapter/cloudflare.js';
|
|
597
|
-
export { createVercelHandler, buildVercelOutput } from './adapter/vercel.js';
|
|
598
649
|
|
|
599
650
|
// Safe state serialization for inlining into <script> tags (AUDIT-2026-06-06 M13)
|
|
600
651
|
export { serializeState } from './serialize.js';
|
package/src/islands.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// 'media' - Hydrate when media query matches (e.g., mobile-only)
|
|
17
17
|
// 'action' - Hydrate on first user interaction (click, focus, hover)
|
|
18
18
|
|
|
19
|
-
import { mount, hydrate, signal, batch } from 'what-core';
|
|
19
|
+
import { mount, hydrate, signal, batch, getServerContext } from 'what-core';
|
|
20
20
|
import { serializeState } from './serialize.js';
|
|
21
21
|
|
|
22
22
|
const islandRegistry = new Map();
|
|
@@ -25,20 +25,41 @@ const hydrationQueue = [];
|
|
|
25
25
|
let isProcessingQueue = false;
|
|
26
26
|
|
|
27
27
|
// --- Shared Island State ---
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
// Browser stores intentionally persist across islands and client navigations.
|
|
29
|
+
// Server stores belong to the active render context so one request can never
|
|
30
|
+
// observe another request's state. Module-scoped server declarations receive a
|
|
31
|
+
// lightweight handle that resolves to the current request's concrete store.
|
|
32
|
+
|
|
33
|
+
const browserStores = new Map();
|
|
34
|
+
const serverStoreDefinitions = new Map();
|
|
35
|
+
|
|
36
|
+
function cloneInitialState(value) {
|
|
37
|
+
if (typeof structuredClone === 'function') {
|
|
38
|
+
try {
|
|
39
|
+
return structuredClone(value);
|
|
40
|
+
} catch {
|
|
41
|
+
// Fall through for values structuredClone cannot represent. Island
|
|
42
|
+
// state is expected to be serializable, but preserving a shallow copy is
|
|
43
|
+
// a safer compatibility fallback than sharing the original object.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) return value.map(cloneInitialState);
|
|
47
|
+
if (value && typeof value === 'object') {
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
Object.entries(value).map(([key, item]) => [key, cloneInitialState(item)])
|
|
50
|
+
);
|
|
35
51
|
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createConcreteStore(storeMap, name, initialState) {
|
|
56
|
+
if (storeMap.has(name)) return storeMap.get(name);
|
|
36
57
|
|
|
37
58
|
const store = {};
|
|
38
59
|
const signals = {};
|
|
39
60
|
|
|
40
61
|
// Create signals for each key in initial state
|
|
41
|
-
for (const [key, value] of Object.entries(initialState)) {
|
|
62
|
+
for (const [key, value] of Object.entries(cloneInitialState(initialState))) {
|
|
42
63
|
signals[key] = signal(value);
|
|
43
64
|
Object.defineProperty(store, key, {
|
|
44
65
|
get: () => signals[key](),
|
|
@@ -72,16 +93,86 @@ export function createIslandStore(name, initialState) {
|
|
|
72
93
|
});
|
|
73
94
|
};
|
|
74
95
|
|
|
75
|
-
|
|
96
|
+
storeMap.set(name, store);
|
|
76
97
|
return store;
|
|
77
98
|
}
|
|
78
99
|
|
|
100
|
+
function activeServerStoreMap(context) {
|
|
101
|
+
const ctx = context || getServerContext();
|
|
102
|
+
return ctx && ctx.islandStores instanceof Map ? ctx.islandStores : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serverStoreHandle(name, initialState) {
|
|
106
|
+
if (serverStoreDefinitions.has(name)) {
|
|
107
|
+
return serverStoreDefinitions.get(name).handle;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const definition = {
|
|
111
|
+
initialState: cloneInitialState(initialState),
|
|
112
|
+
handle: null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const resolve = () => {
|
|
116
|
+
const storeMap = activeServerStoreMap();
|
|
117
|
+
if (!storeMap) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[what-server] Island store "${name}" was accessed outside an active server render. ` +
|
|
120
|
+
'Read or write module-scoped island stores from a component rendered by renderDocument/renderPage.'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return createConcreteStore(storeMap, name, definition.initialState);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
definition.handle = new Proxy({}, {
|
|
127
|
+
get(_target, property) {
|
|
128
|
+
return Reflect.get(resolve(), property);
|
|
129
|
+
},
|
|
130
|
+
set(_target, property, value) {
|
|
131
|
+
return Reflect.set(resolve(), property, value);
|
|
132
|
+
},
|
|
133
|
+
has(_target, property) {
|
|
134
|
+
return Reflect.has(resolve(), property);
|
|
135
|
+
},
|
|
136
|
+
ownKeys() {
|
|
137
|
+
return Reflect.ownKeys(resolve());
|
|
138
|
+
},
|
|
139
|
+
getOwnPropertyDescriptor(_target, property) {
|
|
140
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(resolve(), property);
|
|
141
|
+
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
serverStoreDefinitions.set(name, definition);
|
|
146
|
+
return definition.handle;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createIslandStore(name, initialState) {
|
|
150
|
+
if (typeof document !== 'undefined') {
|
|
151
|
+
return createConcreteStore(browserStores, name, initialState);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const storeMap = activeServerStoreMap();
|
|
155
|
+
if (storeMap) {
|
|
156
|
+
const definition = serverStoreDefinitions.get(name);
|
|
157
|
+
return createConcreteStore(storeMap, name, definition?.initialState ?? initialState);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return serverStoreHandle(name, initialState);
|
|
161
|
+
}
|
|
162
|
+
|
|
79
163
|
// Get or create a shared store
|
|
80
164
|
export function useIslandStore(name, fallbackInitial = {}) {
|
|
81
|
-
if (
|
|
82
|
-
return
|
|
165
|
+
if (typeof document !== 'undefined') {
|
|
166
|
+
return createConcreteStore(browserStores, name, fallbackInitial);
|
|
83
167
|
}
|
|
84
|
-
|
|
168
|
+
|
|
169
|
+
const storeMap = activeServerStoreMap();
|
|
170
|
+
if (storeMap) {
|
|
171
|
+
const definition = serverStoreDefinitions.get(name);
|
|
172
|
+
return createConcreteStore(storeMap, name, definition?.initialState ?? fallbackInitial);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return serverStoreHandle(name, fallbackInitial);
|
|
85
176
|
}
|
|
86
177
|
|
|
87
178
|
// Serialize all shared stores for SSR.
|
|
@@ -94,9 +185,12 @@ export function serializeIslandStores() {
|
|
|
94
185
|
|
|
95
186
|
// Raw (unserialized) snapshot of all shared island stores, so renderDocument can
|
|
96
187
|
// merge it into the single consolidated #__what_data payload (one serialize pass).
|
|
97
|
-
export function getIslandStoresSnapshot() {
|
|
188
|
+
export function getIslandStoresSnapshot(context) {
|
|
189
|
+
const storeMap = typeof document !== 'undefined'
|
|
190
|
+
? browserStores
|
|
191
|
+
: activeServerStoreMap(context);
|
|
98
192
|
const data = {};
|
|
99
|
-
for (const [name, store] of
|
|
193
|
+
for (const [name, store] of storeMap || []) {
|
|
100
194
|
data[name] = store._getSnapshot();
|
|
101
195
|
}
|
|
102
196
|
return data;
|
|
@@ -202,10 +296,16 @@ export function boostIslandPriority(name, newPriority = 100) {
|
|
|
202
296
|
// --- Client-side hydration ---
|
|
203
297
|
|
|
204
298
|
export function hydrateIslands() {
|
|
205
|
-
// First, hydrate any shared stores from the page
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
299
|
+
// First, hydrate any shared stores from the page. renderDocument emits them
|
|
300
|
+
// inside the consolidated #__what_data payload.
|
|
301
|
+
const dataScript = document.getElementById('__what_data');
|
|
302
|
+
if (dataScript) {
|
|
303
|
+
try {
|
|
304
|
+
const payload = JSON.parse(dataScript.textContent || '{}');
|
|
305
|
+
if (payload && payload.islandStores) hydrateIslandStores(payload.islandStores);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.warn('[what] Failed to parse hydration payload:', e);
|
|
308
|
+
}
|
|
209
309
|
}
|
|
210
310
|
|
|
211
311
|
const islands = document.querySelectorAll('[data-island]');
|
|
@@ -231,7 +331,7 @@ export function hydrateIslands() {
|
|
|
231
331
|
}
|
|
232
332
|
|
|
233
333
|
function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
234
|
-
const
|
|
334
|
+
const hydrateIsland = async () => {
|
|
235
335
|
if (hydratedIslands.has(el)) return;
|
|
236
336
|
hydratedIslands.add(el);
|
|
237
337
|
|
|
@@ -269,17 +369,17 @@ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
|
269
369
|
switch (mode) {
|
|
270
370
|
case 'load':
|
|
271
371
|
// Immediate hydration via queue (respects priority)
|
|
272
|
-
enqueueHydration({ name, priority: priority + 1000, hydrate });
|
|
372
|
+
enqueueHydration({ name, priority: priority + 1000, hydrate: hydrateIsland });
|
|
273
373
|
break;
|
|
274
374
|
|
|
275
375
|
case 'idle':
|
|
276
376
|
if ('requestIdleCallback' in window) {
|
|
277
377
|
requestIdleCallback(() => {
|
|
278
|
-
enqueueHydration({ name, priority, hydrate });
|
|
378
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
279
379
|
});
|
|
280
380
|
} else {
|
|
281
381
|
setTimeout(() => {
|
|
282
|
-
enqueueHydration({ name, priority, hydrate });
|
|
382
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
283
383
|
}, 200);
|
|
284
384
|
}
|
|
285
385
|
break;
|
|
@@ -289,7 +389,7 @@ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
|
289
389
|
for (const entry of entries) {
|
|
290
390
|
if (entry.isIntersecting) {
|
|
291
391
|
obs.disconnect();
|
|
292
|
-
enqueueHydration({ name, priority, hydrate });
|
|
392
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
293
393
|
break;
|
|
294
394
|
}
|
|
295
395
|
}
|
|
@@ -301,11 +401,11 @@ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
|
301
401
|
case 'media': {
|
|
302
402
|
const mq = window.matchMedia(entry.media || '(max-width: 768px)');
|
|
303
403
|
if (mq.matches) {
|
|
304
|
-
enqueueHydration({ name, priority, hydrate });
|
|
404
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
305
405
|
} else {
|
|
306
406
|
mq.addEventListener('change', (e) => {
|
|
307
407
|
if (e.matches) {
|
|
308
|
-
enqueueHydration({ name, priority, hydrate });
|
|
408
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
309
409
|
}
|
|
310
410
|
}, { once: true });
|
|
311
411
|
}
|
|
@@ -317,7 +417,7 @@ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
|
317
417
|
const handler = () => {
|
|
318
418
|
events.forEach(e => el.removeEventListener(e, handler));
|
|
319
419
|
// Boost priority since user interacted
|
|
320
|
-
enqueueHydration({ name, priority: priority + 500, hydrate });
|
|
420
|
+
enqueueHydration({ name, priority: priority + 500, hydrate: hydrateIsland });
|
|
321
421
|
};
|
|
322
422
|
events.forEach(e => el.addEventListener(e, handler, { once: true, passive: true }));
|
|
323
423
|
break;
|
|
@@ -328,7 +428,7 @@ function scheduleHydration(el, entry, props, mode, priority, name, stores) {
|
|
|
328
428
|
break;
|
|
329
429
|
|
|
330
430
|
default:
|
|
331
|
-
enqueueHydration({ name, priority, hydrate });
|
|
431
|
+
enqueueHydration({ name, priority, hydrate: hydrateIsland });
|
|
332
432
|
}
|
|
333
433
|
}
|
|
334
434
|
|
|
@@ -428,12 +528,15 @@ export function enhanceForms(selector = 'form[data-enhance]') {
|
|
|
428
528
|
// --- Debugging ---
|
|
429
529
|
|
|
430
530
|
export function getIslandStatus() {
|
|
531
|
+
const stores = typeof document !== 'undefined'
|
|
532
|
+
? [...browserStores.keys()]
|
|
533
|
+
: [...(activeServerStoreMap()?.keys() || serverStoreDefinitions.keys())];
|
|
431
534
|
const status = {
|
|
432
535
|
registered: [...islandRegistry.keys()],
|
|
433
536
|
hydrated: hydratedIslands.size,
|
|
434
537
|
pending: hydrationQueue.length,
|
|
435
538
|
queue: hydrationQueue.map(t => ({ name: t.name, priority: t.priority })),
|
|
436
|
-
stores
|
|
539
|
+
stores,
|
|
437
540
|
};
|
|
438
541
|
return status;
|
|
439
542
|
}
|