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.
@@ -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
- function safeLocalPath(value) {
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
- // Generate a deterministic ID — prefer crypto.getRandomValues, fall back to a
84
- // monotonic counter (never Math.random, which is not cryptographically safe and
85
- // produces predictable IDs in some runtimes).
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)}`;
@@ -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
- try { return await request.json(); } catch { return {}; }
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 body = await readJsonBody(request);
168
- const out = await revalidateWebhook({ headers: headersToObject(request.headers), body });
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,
@@ -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
- for await (const chunk of req) chunks.push(chunk);
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);
@@ -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 { h, runWithServerContext, beginHeadCollection, endHeadCollection } from 'what-core';
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
- yield* renderToStream(vnode(), ctx);
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
- yield* renderToStream(vnode(), ctx);
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 = vnode.tag({ ...vnode.props, children: vnode.children });
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 vnode = page.component(data);
384
- const html = renderToString(vnode);
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
- if (key.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
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 "&lt;script&gt;". 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, '&amp;')
@@ -589,12 +641,11 @@ export {
589
641
  getRevalidationHandler,
590
642
  } from './revalidation-registry.js';
591
643
 
592
- // Deploy adapters — framework-agnostic core + Node / static / edge wrappers
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
- // Global reactive store that persists across islands and page navigations
29
-
30
- const sharedStores = new Map();
31
-
32
- export function createIslandStore(name, initialState) {
33
- if (sharedStores.has(name)) {
34
- return sharedStores.get(name);
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
- sharedStores.set(name, store);
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 (sharedStores.has(name)) {
82
- return sharedStores.get(name);
165
+ if (typeof document !== 'undefined') {
166
+ return createConcreteStore(browserStores, name, fallbackInitial);
83
167
  }
84
- return createIslandStore(name, fallbackInitial);
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 sharedStores) {
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
- const storeScript = document.querySelector('script[data-island-stores]');
207
- if (storeScript) {
208
- hydrateIslandStores(storeScript.textContent);
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 hydrate = async () => {
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: [...sharedStores.keys()],
539
+ stores,
437
540
  };
438
541
  return status;
439
542
  }