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/dist/actions.js +32 -1
- package/dist/actions.js.map +3 -3
- package/dist/actions.min.js +1 -1
- package/dist/actions.min.js.map +4 -4
- package/dist/index.js +531 -23
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +10 -10
- package/dist/index.min.js.map +4 -4
- package/dist/islands.js +23 -1
- package/dist/islands.js.map +3 -3
- package/dist/islands.min.js +1 -1
- package/dist/islands.min.js.map +4 -4
- package/package.json +8 -2
- package/src/action-handler.js +149 -0
- package/src/actions.js +13 -1
- package/src/adapter/cloudflare.js +18 -0
- package/src/adapter/core.js +112 -0
- package/src/adapter/node.js +77 -0
- package/src/adapter/static.js +62 -0
- package/src/adapter/vercel.js +29 -0
- package/src/index.js +184 -9
- package/src/islands.js +12 -2
- package/src/revalidation-registry.js +37 -0
- package/src/serialize.js +34 -0
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
|
|
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
|
+
}
|
package/src/serialize.js
ADDED
|
@@ -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
|
+
}
|