what-server 0.8.4 → 0.11.0

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/src/index.js CHANGED
@@ -2,7 +2,22 @@
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 } from 'what-core';
5
+ import { h, runWithServerContext, beginHeadCollection, endHeadCollection } from 'what-core';
6
+ import { serializeState } from './serialize.js';
7
+ import { getIslandStoresSnapshot } from './islands.js';
8
+ import { csrfMetaTag } from './actions.js';
9
+
10
+ // Build a fresh render-scoped server context (head sink, loader data, resources).
11
+ function createRenderContext(loaderData) {
12
+ return {
13
+ head: beginHeadCollection(),
14
+ loaderData,
15
+ resources: new Map(),
16
+ resourceCounter: 0,
17
+ boundaryCounter: 0,
18
+ suspended: [],
19
+ };
20
+ }
6
21
 
7
22
  // --- Hydration ID Generator ---
8
23
  let _hydrationIdCounter = 0;
@@ -122,6 +137,20 @@ export function renderToString(vnode) {
122
137
  return vnode.map(renderToString).join('');
123
138
  }
124
139
 
140
+ // Suspense boundary — render children; if a child suspends (throws a thenable),
141
+ // show the fallback (a synchronous render cannot await). renderToStringAsync /
142
+ // renderToStream await the pending resources and re-render with real content.
143
+ if (vnode.tag === '__suspense') {
144
+ try {
145
+ return (vnode.children || []).map(renderToString).join('');
146
+ } catch (e) {
147
+ if (e && typeof e.then === 'function') {
148
+ return renderToString(vnode.props && vnode.props.fallback);
149
+ }
150
+ throw e;
151
+ }
152
+ }
153
+
125
154
  // Component
126
155
  if (typeof vnode.tag === 'function') {
127
156
  const result = vnode.tag({ ...vnode.props, children: vnode.children });
@@ -141,10 +170,110 @@ export function renderToString(vnode) {
141
170
  return `${open}${inner}</${tag}>`;
142
171
  }
143
172
 
173
+ // --- Render to String + collected <head> ---
174
+ // Like renderToString, but captures any <Head> tags declared anywhere in the
175
+ // tree into a render-scoped sink and returns them as escaped <head> HTML.
176
+ //
177
+ // Concurrency: renderToString is synchronous, so the render context set by
178
+ // runWithServerContext lives within one uninterrupted tick — safe under
179
+ // concurrent requests (no two renders interleave in the sync path).
180
+ export function renderToStringWithHead(vnode) {
181
+ const ctx = createRenderContext(undefined);
182
+ const body = runWithServerContext(ctx, () => renderToString(vnode));
183
+ return { body, head: endHeadCollection(ctx.head) };
184
+ }
185
+
186
+ // --- Render a page module (loader + component) ---
187
+ // Runs the page's `export const loader` (if any) BEFORE the synchronous render,
188
+ // so the resolved value is plain data by the time the render reads it. The
189
+ // loader receives { params, query, request }. Its result is passed to the page
190
+ // component as a `loaderData` prop and is readable anywhere via useLoaderData().
191
+ //
192
+ // `pageModule` is `{ default: Component, loader? }` (the shape file-router emits).
193
+ export async function renderPage(pageModule, reqCtx = {}) {
194
+ const Component = pageModule.default || pageModule;
195
+ const loaderData = typeof pageModule.loader === 'function'
196
+ ? await pageModule.loader(reqCtx)
197
+ : undefined;
198
+ const ctx = createRenderContext(loaderData);
199
+ const params = reqCtx.params || {};
200
+ const body = runWithServerContext(ctx, () =>
201
+ renderToString(h(Component, { ...params, loaderData }))
202
+ );
203
+ return { body, head: endHeadCollection(ctx.head), loaderData };
204
+ }
205
+
206
+ // --- Async render (resolves Suspense / createResource data) ---
207
+ // Renders, then awaits any resources that suspended, then re-renders with the
208
+ // resolved data — repeating until the tree is stable. Returns the body, the
209
+ // collected head, and the resolved resources for the hydration payload.
210
+ const MAX_RESOLVE_PASSES = 12;
211
+
212
+ export async function renderToStringAsync(vnode, ctx) {
213
+ if (!ctx) ctx = createRenderContext(undefined);
214
+ let body = '';
215
+ for (let pass = 0; pass < MAX_RESOLVE_PASSES; pass++) {
216
+ body = runWithServerContext(ctx, () => renderToString(vnode));
217
+ const pending = [...ctx.resources.values()]
218
+ .filter((r) => r.status === 'pending')
219
+ .map((r) => r.promise);
220
+ if (pending.length === 0) break;
221
+ await Promise.all(pending);
222
+ }
223
+ const resources = {};
224
+ for (const [k, v] of ctx.resources) if (v.status === 'ready') resources[k] = v.value;
225
+ return { body, head: endHeadCollection(ctx.head), loaderData: ctx.loaderData, resources, ctx };
226
+ }
227
+
228
+ // --- Render a complete HTML document (loader + data + head + hydration payload) ---
229
+ // The full-stack entry: runs the loader, renders (resolving async resources),
230
+ // and emits one consolidated <script id="__what_data"> with { loaderData,
231
+ // resources, islandStores } for the client to hydrate from without refetching.
232
+ export async function renderDocument(pageModule, reqCtx = {}, options = {}) {
233
+ const Component = pageModule.default || pageModule;
234
+ const loaderData = typeof pageModule.loader === 'function'
235
+ ? await pageModule.loader(reqCtx)
236
+ : undefined;
237
+ const ctx = createRenderContext(loaderData);
238
+ const params = reqCtx.params || {};
239
+ const { body, head, resources } = await renderToStringAsync(
240
+ h(Component, { ...params, loaderData }),
241
+ ctx
242
+ );
243
+ const payload = {
244
+ loaderData: loaderData ?? null,
245
+ resources,
246
+ islandStores: getIslandStoresSnapshot(),
247
+ };
248
+ return wrapHtmlDocument({ body, head, payload, options });
249
+ }
250
+
251
+ function wrapHtmlDocument({ body, head, payload, options = {} }) {
252
+ const lang = options.lang || 'en';
253
+ const dataScript = `<script id="__what_data" type="application/json">${serializeState(payload)}</script>`;
254
+ const clientScript = options.clientEntry
255
+ ? `<script type="module" src="${escapeHtml(options.clientEntry)}"></script>`
256
+ : '';
257
+ // CSRF auto-provisioning: when the caller (e.g. the deploy adapter with
258
+ // default-on CSRF) passes the per-request token, embed it as the meta tag
259
+ // the client action() wrapper and plain <form> posts read it from.
260
+ const csrfHead = options.csrfToken ? csrfMetaTag(options.csrfToken) : '';
261
+ const extraHead = csrfHead + (options.head || '');
262
+ const bodyClass = options.bodyClass ? ` class="${escapeHtml(options.bodyClass)}"` : '';
263
+ return (
264
+ `<!DOCTYPE html><html lang="${escapeHtml(lang)}"><head>` +
265
+ `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
266
+ `${head || ''}${extraHead}</head><body${bodyClass}>` +
267
+ `${body}${dataScript}${clientScript}</body></html>`
268
+ );
269
+ }
270
+
144
271
  // --- Stream Render ---
145
- // Returns an async iterator for streaming SSR.
272
+ // Returns an async iterator for streaming SSR. `ctx` is threaded explicitly so
273
+ // concurrent streams never share state across `await` points.
146
274
 
147
- export async function* renderToStream(vnode) {
275
+ export async function* renderToStream(vnode, ctx) {
276
+ if (ctx === undefined) ctx = createRenderContext(undefined);
148
277
  if (vnode == null || vnode === false || vnode === true) return;
149
278
 
150
279
  if (typeof vnode === 'string' || typeof vnode === 'number') {
@@ -154,14 +283,14 @@ export async function* renderToStream(vnode) {
154
283
 
155
284
  // Signal — unwrap by calling it
156
285
  if (typeof vnode === 'function' && vnode._signal) {
157
- yield* renderToStream(vnode());
286
+ yield* renderToStream(vnode(), ctx);
158
287
  return;
159
288
  }
160
289
 
161
290
  // Reactive function child — call to get value
162
291
  if (typeof vnode === 'function') {
163
292
  try {
164
- yield* renderToStream(vnode());
293
+ yield* renderToStream(vnode(), ctx);
165
294
  } catch (e) {
166
295
  if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
167
296
  console.warn('[what-server] Error rendering reactive function in stream SSR:', e.message);
@@ -172,8 +301,33 @@ export async function* renderToStream(vnode) {
172
301
 
173
302
  if (Array.isArray(vnode)) {
174
303
  for (const child of vnode) {
175
- yield* renderToStream(child);
304
+ yield* renderToStream(child, ctx);
305
+ }
306
+ return;
307
+ }
308
+
309
+ // Suspense boundary — render the subtree, awaiting any suspended resources,
310
+ // then emit the resolved content. (In-order; out-of-order swap is a future
311
+ // enhancement.) The synchronous render runs inside the threaded ctx.
312
+ if (vnode.tag === '__suspense') {
313
+ let html = null;
314
+ for (let attempt = 0; attempt < MAX_RESOLVE_PASSES && html === null; attempt++) {
315
+ let suspended = null;
316
+ try {
317
+ html = runWithServerContext(ctx, () => (vnode.children || []).map(renderToString).join(''));
318
+ } catch (e) {
319
+ if (e && typeof e.then === 'function') suspended = e;
320
+ else throw e;
321
+ }
322
+ if (html === null) {
323
+ const pending = [...ctx.resources.values()].filter((r) => r.status === 'pending').map((r) => r.promise);
324
+ await Promise.all([suspended, ...pending].filter(Boolean));
325
+ }
326
+ }
327
+ if (html === null) {
328
+ html = runWithServerContext(ctx, () => renderToString(vnode.props && vnode.props.fallback));
176
329
  }
330
+ yield html;
177
331
  return;
178
332
  }
179
333
 
@@ -182,7 +336,7 @@ export async function* renderToStream(vnode) {
182
336
  const result = vnode.tag({ ...vnode.props, children: vnode.children });
183
337
  // Support async components
184
338
  const resolved = result instanceof Promise ? await result : result;
185
- yield* renderToStream(resolved);
339
+ yield* renderToStream(resolved, ctx);
186
340
  } catch (e) {
187
341
  if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
188
342
  console.warn('[what-server] Error rendering component in stream SSR:', e.message);
@@ -204,7 +358,7 @@ export async function* renderToStream(vnode) {
204
358
  yield String(rawInner);
205
359
  } else {
206
360
  for (const child of children) {
207
- yield* renderToStream(child);
361
+ yield* renderToStream(child, ctx);
208
362
  }
209
363
  }
210
364
  yield `</${tag}>`;
@@ -327,12 +481,26 @@ function _resolveInnerHTML(props) {
327
481
  return null;
328
482
  }
329
483
 
484
+ // Attribute NAMES are emitted verbatim into the HTML, so they must be
485
+ // validated (values are escaped, names cannot be). Anything outside this
486
+ // pattern (spaces, quotes, equals, ...) could otherwise inject attributes:
487
+ // h('div', { 'x" onload="alert(1)': '' }) -> <div x" onload="alert(1)>
488
+ // Allows letters, digits, '_', ':', '.', '-' — covers data-*, aria-*,
489
+ // xlink:href, SVG names like stroke-width, and namespaced attributes.
490
+ const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;
491
+
330
492
  function renderAttrs(props) {
331
493
  let out = '';
332
494
  for (const [key, val] of Object.entries(props)) {
333
495
  if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
334
496
  if (key.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
335
497
  if (val === false || val == null) continue;
498
+ if (!SAFE_ATTR_NAME.test(key)) {
499
+ if (_isDevMode) {
500
+ console.warn(`[what-server] Skipping invalid attribute name in SSR: ${JSON.stringify(key)}`);
501
+ }
502
+ continue;
503
+ }
336
504
 
337
505
  if (key === 'className' || key === 'class') {
338
506
  out += ` class="${escapeHtml(String(val))}"`;
@@ -362,7 +530,7 @@ function isUnsafeUrlAttribute(key, val) {
362
530
  const normalizedKey = key.toLowerCase();
363
531
  if (!URL_ATTRS.has(normalizedKey)) return false;
364
532
  const normalizedValue = String(val).trim().replace(/[\u0000-\u001f\u007f\s]+/g, '').toLowerCase();
365
- return normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:');
533
+ return normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:') || normalizedValue.startsWith('data:');
366
534
  }
367
535
 
368
536
  const URL_ATTRS = new Set([
@@ -404,3 +572,29 @@ export {
404
572
  validateCsrfToken,
405
573
  csrfMetaTag,
406
574
  } from './actions.js';
575
+
576
+ // Served server actions: wire the /__what_action route (Node + fetch adapters)
577
+ export {
578
+ createActionHandler,
579
+ nodeActionMiddleware,
580
+ fetchActionHandler,
581
+ } from './action-handler.js';
582
+
583
+ // Revalidation registry — app code calls revalidatePath/revalidateTag; the
584
+ // deploy adapter binds a what-isr engine via setRevalidationHandler.
585
+ export {
586
+ revalidatePath,
587
+ revalidateTag,
588
+ setRevalidationHandler,
589
+ getRevalidationHandler,
590
+ } from './revalidation-registry.js';
591
+
592
+ // Deploy adapters — framework-agnostic core + Node / static / edge wrappers
593
+ export { createRequestHandler } from './adapter/core.js';
594
+ export { createServer, toNodeListener, whatMiddleware } from './adapter/node.js';
595
+ export { exportStatic } from './adapter/static.js';
596
+ export { createCloudflareHandler } from './adapter/cloudflare.js';
597
+ export { createVercelHandler, buildVercelOutput } from './adapter/vercel.js';
598
+
599
+ // Safe state serialization for inlining into <script> tags (AUDIT-2026-06-06 M13)
600
+ export { serializeState } from './serialize.js';
package/src/islands.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // 'action' - Hydrate on first user interaction (click, focus, hover)
18
18
 
19
19
  import { mount, hydrate, signal, batch } from 'what-core';
20
+ import { serializeState } from './serialize.js';
20
21
 
21
22
  const islandRegistry = new Map();
22
23
  const hydratedIslands = new Set();
@@ -83,13 +84,22 @@ export function useIslandStore(name, fallbackInitial = {}) {
83
84
  return createIslandStore(name, fallbackInitial);
84
85
  }
85
86
 
86
- // Serialize all shared stores for SSR
87
+ // Serialize all shared stores for SSR.
88
+ // Uses serializeState (not bare JSON.stringify) so user-controlled store values
89
+ // containing "</script>" cannot break out of the <script> tag this is embedded
90
+ // in. (AUDIT-2026-06-06 H3)
87
91
  export function serializeIslandStores() {
92
+ return serializeState(getIslandStoresSnapshot());
93
+ }
94
+
95
+ // Raw (unserialized) snapshot of all shared island stores, so renderDocument can
96
+ // merge it into the single consolidated #__what_data payload (one serialize pass).
97
+ export function getIslandStoresSnapshot() {
88
98
  const data = {};
89
99
  for (const [name, store] of sharedStores) {
90
100
  data[name] = store._getSnapshot();
91
101
  }
92
- return JSON.stringify(data);
102
+ return data;
93
103
  }
94
104
 
95
105
  // Hydrate shared stores from SSR data
@@ -0,0 +1,37 @@
1
+ // Revalidation registry — the indirection that lets app code call
2
+ // revalidatePath()/revalidateTag() from `what-framework/server` while the actual
3
+ // cache engine lives in the optional `what-isr` package. The deploy adapter
4
+ // binds the engine at startup via setRevalidationHandler(); until then these are
5
+ // safe no-ops (with a dev hint).
6
+
7
+ let _handler = null;
8
+
9
+ const isDev = typeof process !== 'undefined' ? process.env?.NODE_ENV !== 'production' : true;
10
+
11
+ /** Bind a cache engine: setRevalidationHandler({ revalidatePath, revalidateTag }). */
12
+ export function setRevalidationHandler(handler) {
13
+ _handler = handler;
14
+ }
15
+
16
+ export function getRevalidationHandler() {
17
+ return _handler;
18
+ }
19
+
20
+ export async function revalidatePath(path, options) {
21
+ if (_handler && _handler.revalidatePath) return _handler.revalidatePath(path, options);
22
+ if (isDev) {
23
+ console.warn(
24
+ `[what] revalidatePath('${path}') had no effect: no cache engine is bound. ` +
25
+ 'Create a what-isr engine and bind it in your adapter (setRevalidationHandler).'
26
+ );
27
+ }
28
+ }
29
+
30
+ export async function revalidateTag(tag, options) {
31
+ if (_handler && _handler.revalidateTag) return _handler.revalidateTag(tag, options);
32
+ if (isDev) {
33
+ console.warn(
34
+ `[what] revalidateTag('${tag}') had no effect: no cache engine is bound.`
35
+ );
36
+ }
37
+ }
@@ -0,0 +1,34 @@
1
+ // Safe serialization of state for inlining into an HTML <script> tag.
2
+ //
3
+ // Stateless on purpose: this module holds no shared state, so it is safe for
4
+ // the bundler to inline into multiple server entry points without creating
5
+ // divergent instances (unlike islands.js's sharedStores).
6
+ //
7
+ // JSON.stringify alone is NOT safe to drop inside <script>...</script>: a value
8
+ // containing "</script>" (or "<!--", "<script") breaks out of the element and
9
+ // injects markup -- stored XSS when any value is user-controlled
10
+ // (AUDIT-2026-06-06 H3). Escaping "<", ">", "&" as \uXXXX keeps the output
11
+ // valid JSON (so JSON.parse on hydrate still works) while making it inert in
12
+ // HTML. U+2028/U+2029 are also escaped: they are valid in JSON strings but are
13
+ // illegal in JS string literals and can break inline script parsing.
14
+
15
+ // Built via new RegExp from escape sequences so this source file contains no
16
+ // invisible separator characters. Matches: < > & U+2028 U+2029.
17
+ const SCRIPT_UNSAFE = new RegExp('[<>&\\u2028\\u2029]', 'g');
18
+
19
+ const ESCAPES = {
20
+ 0x3c: '\\u003c', // <
21
+ 0x3e: '\\u003e', // >
22
+ 0x26: '\\u0026', // &
23
+ 0x2028: '\\u2028',
24
+ 0x2029: '\\u2029',
25
+ };
26
+
27
+ /**
28
+ * Serialize a value to a JSON string that is safe to embed verbatim inside an
29
+ * HTML <script> element. Always use this instead of bare JSON.stringify when
30
+ * inlining hydration/state payloads into server-rendered HTML.
31
+ */
32
+ export function serializeState(value) {
33
+ return JSON.stringify(value).replace(SCRIPT_UNSAFE, (c) => ESCAPES[c.charCodeAt(0)]);
34
+ }