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/README.md +148 -0
- 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 +743 -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 +331 -0
- package/src/actions.js +13 -1
- package/src/adapter/cloudflare.js +18 -0
- package/src/adapter/core.js +203 -0
- package/src/adapter/node.js +77 -0
- package/src/adapter/static.js +62 -0
- package/src/adapter/vercel.js +93 -0
- package/src/index.js +203 -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,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
|
|
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
|
+
}
|
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
|
+
}
|