@reckona/mreact-server 0.0.81 → 0.0.83

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.
@@ -0,0 +1,427 @@
1
+ import {
2
+ Fragment,
3
+ Suspense as ReactCompatSuspense,
4
+ isValidElement,
5
+ type ReactCompatElement,
6
+ type ReactCompatNode,
7
+ } from "@reckona/mreact-compat";
8
+ import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
9
+ import { escapeHtmlText as escapeHtml } from "@reckona/mreact-shared/html-escape";
10
+ import {
11
+ renderReactSuspenseBoundary,
12
+ renderReactSuspenseOutOfOrderBoundary,
13
+ type HydrationScriptOptions,
14
+ } from "./boundary.js";
15
+ import {
16
+ isDangerousHtmlAttribute,
17
+ isDangerousHtmlOptIn,
18
+ isUnsafeMetaRefreshContent,
19
+ isUnsafeUrlAttribute,
20
+ } from "./url-safety.js";
21
+ import {
22
+ escapeAttribute,
23
+ isPromiseLikeUnknown,
24
+ renderNonceAttribute,
25
+ serializeScriptJson,
26
+ } from "./html-internals.js";
27
+ import { createStringSink, type StreamRender } from "./sink.js";
28
+ import { renderToReadableStream } from "./stream.js";
29
+
30
+ export interface ScriptAssetOptions {
31
+ src: string;
32
+ nonce?: string;
33
+ integrity?: string;
34
+ crossOrigin?: "anonymous" | "use-credentials";
35
+ }
36
+
37
+ export interface EventHydrationEntry {
38
+ id: string;
39
+ event: string;
40
+ handler: string;
41
+ }
42
+
43
+ export interface EventHydrationManifest {
44
+ version: 1;
45
+ events: EventHydrationEntry[];
46
+ }
47
+
48
+ export interface HtmlResponseOptions {
49
+ headers?: HeadersInit;
50
+ status?: number;
51
+ statusText?: string;
52
+ }
53
+
54
+ export function serializeSsrState(value: unknown): string {
55
+ return serializeScriptJson(value);
56
+ }
57
+
58
+ export function renderSsrState(
59
+ sink: HtmlSink,
60
+ value: unknown,
61
+ options: HydrationScriptOptions = {},
62
+ ): void {
63
+ sink.append(
64
+ `<script type="application/json" data-mreact-ssr-state${renderNonceAttribute(options.nonce)}>${serializeSsrState(value)}</script>`,
65
+ );
66
+ }
67
+
68
+ export function createEventHydrationManifest(
69
+ events: readonly EventHydrationEntry[],
70
+ ): EventHydrationManifest {
71
+ return {
72
+ version: 1,
73
+ events: events.map((event) => ({ ...event })),
74
+ };
75
+ }
76
+
77
+ export function renderEventHydrationManifest(
78
+ sink: HtmlSink,
79
+ manifest: EventHydrationManifest,
80
+ options: HydrationScriptOptions = {},
81
+ ): void {
82
+ sink.append(
83
+ `<script type="application/json" data-mreact-event-manifest${renderNonceAttribute(options.nonce)}>${serializeSsrState(manifest)}</script>`,
84
+ );
85
+ }
86
+
87
+ export function renderScriptAsset(sink: HtmlSink, options: ScriptAssetOptions): void {
88
+ const integrityAttribute =
89
+ options.integrity === undefined ? "" : ` integrity="${escapeAttribute(options.integrity)}"`;
90
+ const crossOrigin =
91
+ options.integrity === undefined ? options.crossOrigin : (options.crossOrigin ?? "anonymous");
92
+ const crossOriginAttribute =
93
+ crossOrigin === undefined ? "" : ` crossorigin="${escapeAttribute(crossOrigin)}"`;
94
+
95
+ sink.append(
96
+ `<script src="${escapeAttribute(options.src)}"${renderNonceAttribute(options.nonce)}${integrityAttribute}${crossOriginAttribute}></script>`,
97
+ );
98
+ }
99
+
100
+ export function html(node: unknown, options: HtmlResponseOptions = {}): Response {
101
+ const headers = new Headers(options.headers);
102
+ const responseOptions: ResponseInit = { headers };
103
+
104
+ if (!headers.has("content-type")) {
105
+ headers.set("content-type", "text/html; charset=utf-8");
106
+ }
107
+
108
+ if (options.status !== undefined) {
109
+ responseOptions.status = options.status;
110
+ }
111
+
112
+ if (options.statusText !== undefined) {
113
+ responseOptions.statusText = options.statusText;
114
+ }
115
+
116
+ return new Response(
117
+ renderToReadableStream((sink) => {
118
+ const state: HtmlRenderState = { suspenseId: 0 };
119
+ return appendReactNode(sink, node, state);
120
+ }),
121
+ responseOptions,
122
+ );
123
+ }
124
+
125
+ export async function renderToString(render: StreamRender): Promise<string> {
126
+ const sink = createStringSink();
127
+
128
+ await render(sink);
129
+ await sink.drain();
130
+
131
+ return sink.toString();
132
+ }
133
+
134
+ interface HtmlRenderState {
135
+ suspenseId: number;
136
+ }
137
+
138
+ function appendReactNode(
139
+ sink: HtmlSink,
140
+ node: unknown,
141
+ state: HtmlRenderState,
142
+ ): void | PromiseLike<void> {
143
+ if (isPromiseLikeNode(node)) {
144
+ return node.then((resolved) => appendReactNode(sink, resolved, state));
145
+ }
146
+
147
+ if (node === null || node === undefined || typeof node === "boolean") {
148
+ return;
149
+ }
150
+
151
+ if (typeof node === "string" || typeof node === "number") {
152
+ sink.append(escapeHtml(node));
153
+ return;
154
+ }
155
+
156
+ if (Array.isArray(node)) {
157
+ return appendReactNodeList(sink, node, state);
158
+ }
159
+
160
+ if (!isValidElement(node)) {
161
+ return;
162
+ }
163
+
164
+ return appendReactElement(sink, node, state);
165
+ }
166
+
167
+ function appendReactNodeList(
168
+ sink: HtmlSink,
169
+ nodes: readonly unknown[],
170
+ state: HtmlRenderState,
171
+ ): void | PromiseLike<void> {
172
+ let chain: PromiseLike<void> | undefined;
173
+
174
+ for (const node of nodes) {
175
+ if (chain !== undefined) {
176
+ chain = chain.then(() => appendReactNode(sink, node, state));
177
+ continue;
178
+ }
179
+
180
+ const result = appendReactNode(sink, node, state);
181
+
182
+ if (isPromiseLike(result)) {
183
+ chain = result;
184
+ }
185
+ }
186
+
187
+ return chain;
188
+ }
189
+
190
+ function appendReactElement(
191
+ sink: HtmlSink,
192
+ element: ReactCompatElement,
193
+ state: HtmlRenderState,
194
+ ): void | PromiseLike<void> {
195
+ if (typeof element.type === "string") {
196
+ return appendHostElement(sink, element, state);
197
+ }
198
+
199
+ if (element.type === Fragment) {
200
+ return appendReactNode(sink, element.props.children, state);
201
+ }
202
+
203
+ if (element.type === ReactCompatSuspense) {
204
+ return appendSuspenseElement(sink, element, state);
205
+ }
206
+
207
+ if (isClassComponentType(element.type)) {
208
+ const instance = new element.type(element.props);
209
+ return appendReactNode(sink, instance.render(), state);
210
+ }
211
+
212
+ if (typeof element.type === "function") {
213
+ return appendReactNode(sink, element.type(element.props), state);
214
+ }
215
+ }
216
+
217
+ function isClassComponentType(
218
+ value: unknown,
219
+ ): value is new (props: Record<string, unknown>) => { render(): ReactCompatNode } {
220
+ return typeof value === "function" && value.prototype?.render !== undefined;
221
+ }
222
+
223
+ function appendHostElement(
224
+ sink: HtmlSink,
225
+ element: ReactCompatElement,
226
+ state: HtmlRenderState,
227
+ ): void | PromiseLike<void> {
228
+ const tagName = element.type as string;
229
+ const innerHtml = (element.props as { dangerouslySetInnerHTML?: { __html?: unknown } })
230
+ .dangerouslySetInnerHTML;
231
+ sink.append(`<${tagName}${renderHtmlAttributes(element.props)}>`);
232
+
233
+ if (innerHtml !== undefined) {
234
+ sink.append(String(innerHtml.__html ?? ""));
235
+ sink.append(`</${tagName}>`);
236
+ return;
237
+ }
238
+
239
+ const result = appendReactNode(sink, element.props.children, state);
240
+
241
+ if (isPromiseLike(result)) {
242
+ return result.then(() => {
243
+ sink.append(`</${tagName}>`);
244
+ });
245
+ }
246
+
247
+ sink.append(`</${tagName}>`);
248
+ }
249
+
250
+ function appendSuspenseElement(
251
+ sink: HtmlSink,
252
+ element: ReactCompatElement,
253
+ state: HtmlRenderState,
254
+ ): void {
255
+ const rendered = renderReactNodeToString(element.props.children, state);
256
+
257
+ if (!isPromiseLikeString(rendered)) {
258
+ renderReactSuspenseBoundary(sink, (boundarySink) => {
259
+ boundarySink.append(rendered);
260
+ });
261
+ return;
262
+ }
263
+
264
+ const id = state.suspenseId;
265
+ state.suspenseId += 1;
266
+ renderReactSuspenseOutOfOrderBoundary(
267
+ sink,
268
+ `B:${id}`,
269
+ `S:${id}`,
270
+ rendered,
271
+ (boundarySink, renderedHtml) => {
272
+ boundarySink.append(renderedHtml);
273
+ },
274
+ {
275
+ fallback(boundarySink) {
276
+ const fallback = renderReactNodeToString(
277
+ (element.props as { fallback?: ReactCompatNode }).fallback,
278
+ state,
279
+ );
280
+
281
+ if (isPromiseLikeString(fallback)) {
282
+ return fallback.then((html) => {
283
+ boundarySink.append(html);
284
+ });
285
+ }
286
+
287
+ boundarySink.append(fallback);
288
+ },
289
+ },
290
+ );
291
+ }
292
+
293
+ function renderReactNodeToString(
294
+ node: unknown,
295
+ state: HtmlRenderState,
296
+ ): string | PromiseLike<string> {
297
+ const sink = createStringSink();
298
+ const result = appendReactNode(sink, node, state);
299
+
300
+ if (isPromiseLike(result)) {
301
+ return result.then(() => sink.toString());
302
+ }
303
+
304
+ return sink.toString();
305
+ }
306
+
307
+ function renderHtmlAttributes(props: Record<string, unknown>): string {
308
+ // Issue 078: <meta http-equiv="refresh" content="0;url=javascript:...">
309
+ // is URL-bearing only when http-equiv is "refresh", so we need both
310
+ // attributes in scope to make the call. Strip the unsafe content
311
+ // before per-attribute rendering.
312
+ const sanitizedProps = sanitizeMetaRefreshProps(props);
313
+ return Object.entries(sanitizedProps)
314
+ .map(([name, value]) => renderHtmlAttribute(name, value))
315
+ .filter((attribute) => attribute !== "")
316
+ .join("");
317
+ }
318
+
319
+ function sanitizeMetaRefreshProps(
320
+ props: Record<string, unknown>,
321
+ ): Record<string, unknown> {
322
+ const httpEquiv = props["http-equiv"] ?? props.httpEquiv;
323
+ const content = props["content"];
324
+ if (typeof httpEquiv !== "string" || typeof content !== "string") return props;
325
+ if (!isUnsafeMetaRefreshContent(httpEquiv, content)) return props;
326
+ // Drop only `content`; keep http-equiv so the developer's intent is
327
+ // still visible in the rendered HTML (debugging hint).
328
+ const sanitized = { ...props };
329
+ delete sanitized["content"];
330
+ return sanitized;
331
+ }
332
+
333
+ // Mirrors `isAttributeNameSafe` in react-dom: an attribute name must start with
334
+ // an ASCII letter (or underscore) and contain only word chars, dot, hyphen, or
335
+ // colon. Anything else is dropped to prevent SSR XSS via spread props
336
+ // (`<div {...userControlled} />`). See docs/issues/resolved Issue 060.
337
+ const VALID_ATTRIBUTE_NAME = /^[A-Za-z_][\w.\-:]*$/;
338
+
339
+ // URL scheme allow/block list is shared with the compiler emit paths
340
+ // (Issues 062 / 073). See packages/server/src/url-safety.ts.
341
+
342
+ function renderHtmlAttribute(name: string, value: unknown): string {
343
+ if (
344
+ name === "children" ||
345
+ name === "dangerouslySetInnerHTML" ||
346
+ name === "key" ||
347
+ name === "ref" ||
348
+ value === false ||
349
+ value === null ||
350
+ value === undefined ||
351
+ typeof value === "function"
352
+ ) {
353
+ return "";
354
+ }
355
+
356
+ const attributeName = toHtmlAttributeName(name);
357
+
358
+ if (!VALID_ATTRIBUTE_NAME.test(attributeName)) {
359
+ return "";
360
+ }
361
+
362
+ // Issue 077: HTML-bearing attributes (`srcdoc`) require the explicit
363
+ // `{ __html: "..." }` opt-in. A plain string value -- even if escaped
364
+ // for the attribute syntax -- decodes back to executable HTML inside
365
+ // the iframe document with the parent's origin.
366
+ if (isDangerousHtmlAttribute(attributeName)) {
367
+ if (!isDangerousHtmlOptIn(value)) {
368
+ return "";
369
+ }
370
+ return ` ${attributeName}="${escapeAttribute(value.__html)}"`;
371
+ }
372
+
373
+ if (value === true) {
374
+ return ` ${attributeName}`;
375
+ }
376
+
377
+ const stringValue = String(value);
378
+
379
+ if (isUnsafeUrlAttribute(attributeName, stringValue)) {
380
+ return "";
381
+ }
382
+
383
+ return ` ${attributeName}="${escapeAttribute(stringValue)}"`;
384
+ }
385
+
386
+ function toHtmlAttributeName(name: string): string {
387
+ return HTML_ATTRIBUTE_ALIASES[name] ?? name;
388
+ }
389
+
390
+ const HTML_ATTRIBUTE_ALIASES: Record<string, string> = {
391
+ acceptCharset: "accept-charset",
392
+ autoFocus: "autofocus",
393
+ autoPlay: "autoplay",
394
+ charSet: "charset",
395
+ className: "class",
396
+ colSpan: "colspan",
397
+ contentEditable: "contenteditable",
398
+ crossOrigin: "crossorigin",
399
+ encType: "enctype",
400
+ formAction: "formaction",
401
+ frameBorder: "frameborder",
402
+ htmlFor: "for",
403
+ httpEquiv: "http-equiv",
404
+ maxLength: "maxlength",
405
+ minLength: "minlength",
406
+ noValidate: "novalidate",
407
+ playsInline: "playsinline",
408
+ readOnly: "readonly",
409
+ rowSpan: "rowspan",
410
+ spellCheck: "spellcheck",
411
+ srcDoc: "srcdoc",
412
+ srcSet: "srcset",
413
+ tabIndex: "tabindex",
414
+ useMap: "usemap",
415
+ };
416
+
417
+ function isPromiseLikeNode(value: unknown): value is PromiseLike<unknown> {
418
+ return isPromiseLikeUnknown(value);
419
+ }
420
+
421
+ function isPromiseLikeString(value: unknown): value is PromiseLike<string> {
422
+ return isPromiseLikeUnknown(value);
423
+ }
424
+
425
+ function isPromiseLike(value: unknown): value is PromiseLike<void> {
426
+ return isPromiseLikeUnknown(value);
427
+ }
@@ -0,0 +1,27 @@
1
+ export function escapeAttribute(value: string): string {
2
+ return value
3
+ .replaceAll("&", "&amp;")
4
+ .replaceAll('"', "&quot;")
5
+ .replaceAll("<", "&lt;")
6
+ .replaceAll(">", "&gt;");
7
+ }
8
+
9
+ export function serializeScriptJson(value: unknown): string {
10
+ return JSON.stringify(value)
11
+ .replaceAll("<", "\\u003c")
12
+ .replaceAll("\u2028", "\\u2028")
13
+ .replaceAll("\u2029", "\\u2029");
14
+ }
15
+
16
+ export function renderNonceAttribute(nonce: string | undefined): string {
17
+ return nonce === undefined ? "" : ` nonce="${escapeAttribute(nonce)}"`;
18
+ }
19
+
20
+ export function isPromiseLikeUnknown(value: unknown): value is PromiseLike<unknown> {
21
+ return (
22
+ typeof value === "object" &&
23
+ value !== null &&
24
+ "then" in value &&
25
+ typeof (value as { then?: unknown }).then === "function"
26
+ );
27
+ }