what-server 0.8.4 → 0.10.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,21 @@
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
+
9
+ // Build a fresh render-scoped server context (head sink, loader data, resources).
10
+ function createRenderContext(loaderData) {
11
+ return {
12
+ head: beginHeadCollection(),
13
+ loaderData,
14
+ resources: new Map(),
15
+ resourceCounter: 0,
16
+ boundaryCounter: 0,
17
+ suspended: [],
18
+ };
19
+ }
6
20
 
7
21
  // --- Hydration ID Generator ---
8
22
  let _hydrationIdCounter = 0;
@@ -122,6 +136,20 @@ export function renderToString(vnode) {
122
136
  return vnode.map(renderToString).join('');
123
137
  }
124
138
 
139
+ // Suspense boundary — render children; if a child suspends (throws a thenable),
140
+ // show the fallback (a synchronous render cannot await). renderToStringAsync /
141
+ // renderToStream await the pending resources and re-render with real content.
142
+ if (vnode.tag === '__suspense') {
143
+ try {
144
+ return (vnode.children || []).map(renderToString).join('');
145
+ } catch (e) {
146
+ if (e && typeof e.then === 'function') {
147
+ return renderToString(vnode.props && vnode.props.fallback);
148
+ }
149
+ throw e;
150
+ }
151
+ }
152
+
125
153
  // Component
126
154
  if (typeof vnode.tag === 'function') {
127
155
  const result = vnode.tag({ ...vnode.props, children: vnode.children });
@@ -141,10 +169,106 @@ export function renderToString(vnode) {
141
169
  return `${open}${inner}</${tag}>`;
142
170
  }
143
171
 
172
+ // --- Render to String + collected <head> ---
173
+ // Like renderToString, but captures any <Head> tags declared anywhere in the
174
+ // tree into a render-scoped sink and returns them as escaped <head> HTML.
175
+ //
176
+ // Concurrency: renderToString is synchronous, so the render context set by
177
+ // runWithServerContext lives within one uninterrupted tick — safe under
178
+ // concurrent requests (no two renders interleave in the sync path).
179
+ export function renderToStringWithHead(vnode) {
180
+ const ctx = createRenderContext(undefined);
181
+ const body = runWithServerContext(ctx, () => renderToString(vnode));
182
+ return { body, head: endHeadCollection(ctx.head) };
183
+ }
184
+
185
+ // --- Render a page module (loader + component) ---
186
+ // Runs the page's `export const loader` (if any) BEFORE the synchronous render,
187
+ // so the resolved value is plain data by the time the render reads it. The
188
+ // loader receives { params, query, request }. Its result is passed to the page
189
+ // component as a `loaderData` prop and is readable anywhere via useLoaderData().
190
+ //
191
+ // `pageModule` is `{ default: Component, loader? }` (the shape file-router emits).
192
+ export async function renderPage(pageModule, reqCtx = {}) {
193
+ const Component = pageModule.default || pageModule;
194
+ const loaderData = typeof pageModule.loader === 'function'
195
+ ? await pageModule.loader(reqCtx)
196
+ : undefined;
197
+ const ctx = createRenderContext(loaderData);
198
+ const params = reqCtx.params || {};
199
+ const body = runWithServerContext(ctx, () =>
200
+ renderToString(h(Component, { ...params, loaderData }))
201
+ );
202
+ return { body, head: endHeadCollection(ctx.head), loaderData };
203
+ }
204
+
205
+ // --- Async render (resolves Suspense / createResource data) ---
206
+ // Renders, then awaits any resources that suspended, then re-renders with the
207
+ // resolved data — repeating until the tree is stable. Returns the body, the
208
+ // collected head, and the resolved resources for the hydration payload.
209
+ const MAX_RESOLVE_PASSES = 12;
210
+
211
+ export async function renderToStringAsync(vnode, ctx) {
212
+ if (!ctx) ctx = createRenderContext(undefined);
213
+ let body = '';
214
+ for (let pass = 0; pass < MAX_RESOLVE_PASSES; pass++) {
215
+ body = runWithServerContext(ctx, () => renderToString(vnode));
216
+ const pending = [...ctx.resources.values()]
217
+ .filter((r) => r.status === 'pending')
218
+ .map((r) => r.promise);
219
+ if (pending.length === 0) break;
220
+ await Promise.all(pending);
221
+ }
222
+ const resources = {};
223
+ for (const [k, v] of ctx.resources) if (v.status === 'ready') resources[k] = v.value;
224
+ return { body, head: endHeadCollection(ctx.head), loaderData: ctx.loaderData, resources, ctx };
225
+ }
226
+
227
+ // --- Render a complete HTML document (loader + data + head + hydration payload) ---
228
+ // The full-stack entry: runs the loader, renders (resolving async resources),
229
+ // and emits one consolidated <script id="__what_data"> with { loaderData,
230
+ // resources, islandStores } for the client to hydrate from without refetching.
231
+ export async function renderDocument(pageModule, reqCtx = {}, options = {}) {
232
+ const Component = pageModule.default || pageModule;
233
+ const loaderData = typeof pageModule.loader === 'function'
234
+ ? await pageModule.loader(reqCtx)
235
+ : undefined;
236
+ const ctx = createRenderContext(loaderData);
237
+ const params = reqCtx.params || {};
238
+ const { body, head, resources } = await renderToStringAsync(
239
+ h(Component, { ...params, loaderData }),
240
+ ctx
241
+ );
242
+ const payload = {
243
+ loaderData: loaderData ?? null,
244
+ resources,
245
+ islandStores: getIslandStoresSnapshot(),
246
+ };
247
+ return wrapHtmlDocument({ body, head, payload, options });
248
+ }
249
+
250
+ function wrapHtmlDocument({ body, head, payload, options = {} }) {
251
+ const lang = options.lang || 'en';
252
+ const dataScript = `<script id="__what_data" type="application/json">${serializeState(payload)}</script>`;
253
+ const clientScript = options.clientEntry
254
+ ? `<script type="module" src="${escapeHtml(options.clientEntry)}"></script>`
255
+ : '';
256
+ const extraHead = options.head || '';
257
+ const bodyClass = options.bodyClass ? ` class="${escapeHtml(options.bodyClass)}"` : '';
258
+ return (
259
+ `<!DOCTYPE html><html lang="${escapeHtml(lang)}"><head>` +
260
+ `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
261
+ `${head || ''}${extraHead}</head><body${bodyClass}>` +
262
+ `${body}${dataScript}${clientScript}</body></html>`
263
+ );
264
+ }
265
+
144
266
  // --- Stream Render ---
145
- // Returns an async iterator for streaming SSR.
267
+ // Returns an async iterator for streaming SSR. `ctx` is threaded explicitly so
268
+ // concurrent streams never share state across `await` points.
146
269
 
147
- export async function* renderToStream(vnode) {
270
+ export async function* renderToStream(vnode, ctx) {
271
+ if (ctx === undefined) ctx = createRenderContext(undefined);
148
272
  if (vnode == null || vnode === false || vnode === true) return;
149
273
 
150
274
  if (typeof vnode === 'string' || typeof vnode === 'number') {
@@ -154,14 +278,14 @@ export async function* renderToStream(vnode) {
154
278
 
155
279
  // Signal — unwrap by calling it
156
280
  if (typeof vnode === 'function' && vnode._signal) {
157
- yield* renderToStream(vnode());
281
+ yield* renderToStream(vnode(), ctx);
158
282
  return;
159
283
  }
160
284
 
161
285
  // Reactive function child — call to get value
162
286
  if (typeof vnode === 'function') {
163
287
  try {
164
- yield* renderToStream(vnode());
288
+ yield* renderToStream(vnode(), ctx);
165
289
  } catch (e) {
166
290
  if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
167
291
  console.warn('[what-server] Error rendering reactive function in stream SSR:', e.message);
@@ -172,8 +296,33 @@ export async function* renderToStream(vnode) {
172
296
 
173
297
  if (Array.isArray(vnode)) {
174
298
  for (const child of vnode) {
175
- yield* renderToStream(child);
299
+ yield* renderToStream(child, ctx);
300
+ }
301
+ return;
302
+ }
303
+
304
+ // Suspense boundary — render the subtree, awaiting any suspended resources,
305
+ // then emit the resolved content. (In-order; out-of-order swap is a future
306
+ // enhancement.) The synchronous render runs inside the threaded ctx.
307
+ if (vnode.tag === '__suspense') {
308
+ let html = null;
309
+ for (let attempt = 0; attempt < MAX_RESOLVE_PASSES && html === null; attempt++) {
310
+ let suspended = null;
311
+ try {
312
+ html = runWithServerContext(ctx, () => (vnode.children || []).map(renderToString).join(''));
313
+ } catch (e) {
314
+ if (e && typeof e.then === 'function') suspended = e;
315
+ else throw e;
316
+ }
317
+ if (html === null) {
318
+ const pending = [...ctx.resources.values()].filter((r) => r.status === 'pending').map((r) => r.promise);
319
+ await Promise.all([suspended, ...pending].filter(Boolean));
320
+ }
321
+ }
322
+ if (html === null) {
323
+ html = runWithServerContext(ctx, () => renderToString(vnode.props && vnode.props.fallback));
176
324
  }
325
+ yield html;
177
326
  return;
178
327
  }
179
328
 
@@ -182,7 +331,7 @@ export async function* renderToStream(vnode) {
182
331
  const result = vnode.tag({ ...vnode.props, children: vnode.children });
183
332
  // Support async components
184
333
  const resolved = result instanceof Promise ? await result : result;
185
- yield* renderToStream(resolved);
334
+ yield* renderToStream(resolved, ctx);
186
335
  } catch (e) {
187
336
  if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
188
337
  console.warn('[what-server] Error rendering component in stream SSR:', e.message);
@@ -204,7 +353,7 @@ export async function* renderToStream(vnode) {
204
353
  yield String(rawInner);
205
354
  } else {
206
355
  for (const child of children) {
207
- yield* renderToStream(child);
356
+ yield* renderToStream(child, ctx);
208
357
  }
209
358
  }
210
359
  yield `</${tag}>`;
@@ -362,7 +511,7 @@ function isUnsafeUrlAttribute(key, val) {
362
511
  const normalizedKey = key.toLowerCase();
363
512
  if (!URL_ATTRS.has(normalizedKey)) return false;
364
513
  const normalizedValue = String(val).trim().replace(/[\u0000-\u001f\u007f\s]+/g, '').toLowerCase();
365
- return normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:');
514
+ return normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:') || normalizedValue.startsWith('data:');
366
515
  }
367
516
 
368
517
  const URL_ATTRS = new Set([
@@ -404,3 +553,29 @@ export {
404
553
  validateCsrfToken,
405
554
  csrfMetaTag,
406
555
  } from './actions.js';
556
+
557
+ // Served server actions: wire the /__what_action route (Node + fetch adapters)
558
+ export {
559
+ createActionHandler,
560
+ nodeActionMiddleware,
561
+ fetchActionHandler,
562
+ } from './action-handler.js';
563
+
564
+ // Revalidation registry — app code calls revalidatePath/revalidateTag; the
565
+ // deploy adapter binds a what-cache engine via setRevalidationHandler.
566
+ export {
567
+ revalidatePath,
568
+ revalidateTag,
569
+ setRevalidationHandler,
570
+ getRevalidationHandler,
571
+ } from './revalidation-registry.js';
572
+
573
+ // Deploy adapters — framework-agnostic core + Node / static / edge wrappers
574
+ export { createRequestHandler } from './adapter/core.js';
575
+ export { createServer, toNodeListener, whatMiddleware } from './adapter/node.js';
576
+ export { exportStatic } from './adapter/static.js';
577
+ export { createCloudflareHandler } from './adapter/cloudflare.js';
578
+ export { createVercelHandler, buildVercelOutput } from './adapter/vercel.js';
579
+
580
+ // Safe state serialization for inlining into <script> tags (AUDIT-2026-06-06 M13)
581
+ 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-cache` 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-cache 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
+ }