@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,454 @@
1
+ import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
2
+ import {
3
+ escapeAttribute,
4
+ isPromiseLikeUnknown,
5
+ renderNonceAttribute,
6
+ serializeScriptJson,
7
+ } from "./html-internals.js";
8
+ import { createStringSink, type StringHtmlSink } from "./sink.js";
9
+
10
+ export interface AsyncBoundaryOptions {
11
+ catch?: (sink: HtmlSink, error: unknown) => void | PromiseLike<void>;
12
+ hydrationAwaitId?: string;
13
+ }
14
+
15
+ export interface OutOfOrderBoundaryOptions extends AsyncBoundaryOptions {
16
+ hydration?: boolean;
17
+ placeholder?: (sink: HtmlSink) => void | PromiseLike<void>;
18
+ placeholderTag?: string;
19
+ }
20
+
21
+ export interface OutOfOrderReorderScriptOptions {
22
+ nonce?: string;
23
+ src?: string;
24
+ }
25
+
26
+ export interface HydrationScriptOptions {
27
+ nonce?: string;
28
+ }
29
+
30
+ export interface ReactSuspenseScriptOptions {
31
+ nonce?: string;
32
+ src?: string;
33
+ }
34
+
35
+ export interface ReactSuspenseBoundaryOptions extends AsyncBoundaryOptions {
36
+ fallback?: (sink: HtmlSink) => void | PromiseLike<void>;
37
+ nonce?: string;
38
+ }
39
+
40
+ export interface ReactSuspenseClientRenderOptions {
41
+ message?: string;
42
+ stack?: string;
43
+ }
44
+
45
+ export type AsyncBoundaryRender<T> = (
46
+ sink: HtmlSink,
47
+ value: Awaited<T>,
48
+ ) => void | PromiseLike<void>;
49
+
50
+ const outOfOrderBoundaryInstances = new WeakMap<HtmlSink, Map<string, number>>();
51
+
52
+ export async function renderAsyncBoundary<T>(
53
+ sink: HtmlSink,
54
+ value: T,
55
+ render: AsyncBoundaryRender<T>,
56
+ options: AsyncBoundaryOptions = {},
57
+ ): Promise<void> {
58
+ try {
59
+ const resolved = await value;
60
+ await sink.backpressure?.();
61
+ await render(sink, resolved);
62
+ if (options.hydrationAwaitId !== undefined) {
63
+ appendAwaitHydrationData(sink, options.hydrationAwaitId, resolved);
64
+ }
65
+ } catch (error) {
66
+ if (options.catch === undefined) {
67
+ throw error;
68
+ }
69
+
70
+ await sink.backpressure?.();
71
+ await options.catch(sink, error);
72
+ }
73
+ }
74
+
75
+ // Threshold for `<Await>` payload size warnings (UTF-8 byte length of
76
+ // JSON-serialized representation). 100KB warn / 1MB error follow the
77
+ // "you're sending a lot of data" hint pattern used by other frameworks.
78
+ const AWAIT_PAYLOAD_WARN_BYTES = 100 * 1024;
79
+ const AWAIT_PAYLOAD_ERROR_BYTES = 1024 * 1024;
80
+
81
+ function isProductionMode(): boolean {
82
+ // `process` may not exist in cross-runtime environments (Cloudflare/Deno).
83
+ // The server tsconfig does not include `@types/node`, so we look it up
84
+ // through `globalThis` with a minimal local typing.
85
+ const globalProcess = (globalThis as { process?: { env?: Record<string, string | undefined> } })
86
+ .process;
87
+
88
+ try {
89
+ return globalProcess?.env?.["NODE_ENV"] === "production";
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function appendAwaitHydrationData(
96
+ sink: HtmlSink,
97
+ awaitId: string,
98
+ resolved: unknown,
99
+ ): void {
100
+ const serialized = serializeAwaitHydrationValue(resolved, awaitId);
101
+
102
+ if (serialized === undefined) {
103
+ return;
104
+ }
105
+
106
+ const idLiteral = JSON.stringify(awaitId).replaceAll("<", "\\u003c");
107
+ sink.append(
108
+ `<script data-mreact-await=${idLiteral}>(self.__mreactAwaitData||(self.__mreactAwaitData={}))[${idLiteral}]={value:${serialized}}</script>`,
109
+ );
110
+ }
111
+
112
+ // Emits a single console.warn in dev when `resolved` contains shapes the
113
+ // wire format will silently drop or coerce (Date / Map / Set / RegExp /
114
+ // class instance / function / Symbol / nested non-POJO).
115
+ function warnIfNonSerializableAwaitValue(value: unknown, awaitId: string): void {
116
+ if (isProductionMode()) {
117
+ return;
118
+ }
119
+
120
+ if (!containsNonSerializableSurface(value)) {
121
+ return;
122
+ }
123
+
124
+ console.warn(
125
+ `[mreact] <Await value={...}> for "${awaitId}" includes non-serializable ` +
126
+ `data (Date / Map / Set / RegExp / class instance / function / Symbol). ` +
127
+ `The wire format uses JSON.stringify, so the client-side renderer may ` +
128
+ `receive a different shape after the JSON round-trip. Convert to plain ` +
129
+ `JSON-serializable data before it reaches <Await>. See ` +
130
+ `https://github.com/t-k/mreact#streaming-loading-and-await.`,
131
+ );
132
+ }
133
+
134
+ function containsNonSerializableSurface(value: unknown): boolean {
135
+ if (value === null || value === undefined || typeof value !== "object") {
136
+ return false;
137
+ }
138
+
139
+ if (value instanceof Date || value instanceof Map || value instanceof Set || value instanceof RegExp) {
140
+ return true;
141
+ }
142
+
143
+ if (Array.isArray(value)) {
144
+ return value.some((entry) => containsNonSerializableSurface(entry));
145
+ }
146
+
147
+ const proto = Object.getPrototypeOf(value);
148
+
149
+ if (proto !== Object.prototype && proto !== null) {
150
+ return true;
151
+ }
152
+
153
+ for (const entry of Object.values(value as Record<string, unknown>)) {
154
+ if (containsNonSerializableSurface(entry)) {
155
+ return true;
156
+ }
157
+ }
158
+
159
+ return false;
160
+ }
161
+
162
+ function reportAwaitPayloadSize(json: string, awaitId: string): void {
163
+ const byteLength =
164
+ typeof TextEncoder !== "undefined" ? new TextEncoder().encode(json).length : json.length;
165
+
166
+ if (byteLength > AWAIT_PAYLOAD_ERROR_BYTES) {
167
+ console.error(
168
+ `[mreact] <Await> payload for "${awaitId}" is ${(byteLength / 1024 / 1024).toFixed(2)}MB, ` +
169
+ `exceeding the 1MB error threshold. Large payloads inflate HTML response size and ` +
170
+ `client memory pressure. Stream the data via loader or split into smaller boundaries.`,
171
+ );
172
+ return;
173
+ }
174
+
175
+ if (byteLength > AWAIT_PAYLOAD_WARN_BYTES) {
176
+ console.warn(
177
+ `[mreact] large await payload for "${awaitId}": ${(byteLength / 1024).toFixed(1)}KB ` +
178
+ `(over the 100KB warning threshold). Consider streaming the data via loader or ` +
179
+ `splitting into smaller boundaries.`,
180
+ );
181
+ }
182
+ }
183
+
184
+ function serializeAwaitHydrationValue(value: unknown, awaitId: string): string | undefined {
185
+ try {
186
+ const json = JSON.stringify(value);
187
+ if (json === undefined) {
188
+ return undefined;
189
+ }
190
+
191
+ warnIfNonSerializableAwaitValue(value, awaitId);
192
+ reportAwaitPayloadSize(json, awaitId);
193
+
194
+ // Defuse `</script>` and runaway control characters so the data can be
195
+ // safely embedded inside a `<script>` element.
196
+ return json
197
+ .replaceAll("<", "\\u003c")
198
+ .replaceAll("\u2028", "\\u2028")
199
+ .replaceAll("\u2029", "\\u2029");
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ function inheritBackpressure(childSink: StringHtmlSink, parentSink: HtmlSink): StringHtmlSink {
206
+ if (parentSink.backpressure === undefined) {
207
+ return childSink;
208
+ }
209
+
210
+ return {
211
+ ...childSink,
212
+ backpressure: parentSink.backpressure,
213
+ };
214
+ }
215
+
216
+ export function renderOutOfOrderBoundary<T>(
217
+ sink: HtmlSink,
218
+ id: string,
219
+ value: T,
220
+ render: AsyncBoundaryRender<T>,
221
+ options: OutOfOrderBoundaryOptions = {},
222
+ ): void {
223
+ const boundaryId = nextOutOfOrderBoundaryInstanceId(sink, id);
224
+ const placeholderSink = createStringSink();
225
+ void options.placeholder?.(placeholderSink);
226
+ const placeholderTag = normalizeOutOfOrderPlaceholderTag(options.placeholderTag);
227
+ const hydrationStart =
228
+ options.hydration === true ? `<!--mreact-h:start:${encodeURIComponent(boundaryId)}-->` : "";
229
+ const hydrationEnd =
230
+ options.hydration === true ? `<!--mreact-h:end:${encodeURIComponent(boundaryId)}-->` : "";
231
+ sink.append(
232
+ `${hydrationStart}<${placeholderTag} data-mreact-oob-placeholder="${escapeAttribute(boundaryId)}">${placeholderSink.toString()}</${placeholderTag}>${hydrationEnd}`,
233
+ );
234
+
235
+ const task = renderOutOfOrderFragment(sink, boundaryId, value, render, options);
236
+
237
+ if (sink.defer === undefined) {
238
+ void task;
239
+ return;
240
+ }
241
+
242
+ sink.defer(task);
243
+ }
244
+
245
+ function normalizeOutOfOrderPlaceholderTag(tag: unknown): string {
246
+ if (typeof tag !== "string") {
247
+ return "span";
248
+ }
249
+
250
+ const normalized = tag.trim().toLowerCase();
251
+ return /^[a-z][a-z0-9-]*$/.test(normalized) ? normalized : "span";
252
+ }
253
+
254
+ async function renderOutOfOrderFragment<T>(
255
+ sink: HtmlSink,
256
+ id: string,
257
+ value: T,
258
+ render: AsyncBoundaryRender<T>,
259
+ options: OutOfOrderBoundaryOptions,
260
+ ): Promise<void> {
261
+ const fragmentSink = inheritBackpressure(createStringSink(), sink);
262
+ let resolvedValue: unknown;
263
+ let hasResolvedValue = false;
264
+
265
+ await renderAsyncBoundary(
266
+ fragmentSink,
267
+ value,
268
+ async (childSink, resolved) => {
269
+ resolvedValue = resolved;
270
+ hasResolvedValue = true;
271
+ await render(childSink, resolved);
272
+ },
273
+ options.catch === undefined ? {} : { catch: options.catch },
274
+ );
275
+ await fragmentSink.drain();
276
+
277
+ sink.append(
278
+ `<template data-mreact-oob-fragment="${escapeAttribute(id)}">${fragmentSink.toString()}</template>`,
279
+ );
280
+
281
+ if (hasResolvedValue && options.hydrationAwaitId !== undefined) {
282
+ appendAwaitHydrationData(sink, options.hydrationAwaitId, resolvedValue);
283
+ }
284
+ }
285
+
286
+ export function renderOutOfOrderReorderScript(
287
+ sink: HtmlSink,
288
+ options: OutOfOrderReorderScriptOptions = {},
289
+ ): void {
290
+ const nonceAttribute =
291
+ options.nonce === undefined ? "" : ` nonce="${escapeAttribute(options.nonce)}"`;
292
+
293
+ if (options.src !== undefined) {
294
+ sink.append(
295
+ `<script data-mreact-oob-reorder${nonceAttribute} src="${escapeAttribute(options.src)}"></script>`,
296
+ );
297
+ return;
298
+ }
299
+
300
+ sink.append(
301
+ `<script data-mreact-oob-reorder${nonceAttribute}>${outOfOrderReorderScript}</script>`,
302
+ );
303
+ }
304
+
305
+ export function renderReactSuspenseBoundary(
306
+ sink: HtmlSink,
307
+ render: (sink: HtmlSink) => void | PromiseLike<void>,
308
+ ): void | PromiseLike<void> {
309
+ sink.append("<!--$-->");
310
+ const result = render(sink);
311
+
312
+ if (isPromiseLike(result)) {
313
+ return result.then(() => {
314
+ sink.append("<!--/$-->");
315
+ });
316
+ }
317
+
318
+ sink.append("<!--/$-->");
319
+ }
320
+
321
+ export function renderReactSuspenseOutOfOrderBoundary<T>(
322
+ sink: HtmlSink,
323
+ boundaryId: string,
324
+ segmentId: string,
325
+ value: T,
326
+ render: AsyncBoundaryRender<T>,
327
+ options: ReactSuspenseBoundaryOptions = {},
328
+ ): void {
329
+ const actualBoundaryId = nextOutOfOrderBoundaryInstanceId(sink, boundaryId);
330
+ const suffix = actualBoundaryId.slice(boundaryId.length);
331
+ const actualSegmentId = `${segmentId}${suffix}`;
332
+ const fallbackSink = createStringSink();
333
+ void options.fallback?.(fallbackSink);
334
+ sink.append(
335
+ `<!--$?--><template id="${escapeAttribute(actualBoundaryId)}"></template>${fallbackSink.toString()}<!--/$-->`,
336
+ );
337
+
338
+ const task = renderReactSuspenseSegment(
339
+ sink,
340
+ actualBoundaryId,
341
+ actualSegmentId,
342
+ value,
343
+ render,
344
+ options,
345
+ );
346
+
347
+ if (sink.defer === undefined) {
348
+ void task;
349
+ return;
350
+ }
351
+
352
+ sink.defer(task);
353
+ }
354
+
355
+ function nextOutOfOrderBoundaryInstanceId(sink: HtmlSink, id: string): string {
356
+ let instances = outOfOrderBoundaryInstances.get(sink);
357
+
358
+ if (instances === undefined) {
359
+ instances = new Map();
360
+ outOfOrderBoundaryInstances.set(sink, instances);
361
+ }
362
+
363
+ const count = instances.get(id) ?? 0;
364
+ instances.set(id, count + 1);
365
+
366
+ return count === 0 ? id : `${id}-${count.toString(36)}`;
367
+ }
368
+
369
+ export function renderReactSuspenseClientRenderBoundary(
370
+ sink: HtmlSink,
371
+ fallback: (sink: HtmlSink) => void | PromiseLike<void>,
372
+ options: ReactSuspenseClientRenderOptions = {},
373
+ ): void | PromiseLike<void> {
374
+ sink.append(`<!--$!--><template${renderReactSuspenseErrorAttributes(options)}></template>`);
375
+ const result = fallback(sink);
376
+
377
+ if (isPromiseLike(result)) {
378
+ return result.then(() => {
379
+ sink.append("<!--/$-->");
380
+ });
381
+ }
382
+
383
+ sink.append("<!--/$-->");
384
+ }
385
+
386
+ async function renderReactSuspenseSegment<T>(
387
+ sink: HtmlSink,
388
+ boundaryId: string,
389
+ segmentId: string,
390
+ value: T,
391
+ render: AsyncBoundaryRender<T>,
392
+ options: ReactSuspenseBoundaryOptions,
393
+ ): Promise<void> {
394
+ const segmentSink = inheritBackpressure(createStringSink(), sink);
395
+
396
+ await renderAsyncBoundary(
397
+ segmentSink,
398
+ value,
399
+ render,
400
+ options.catch === undefined ? {} : { catch: options.catch },
401
+ );
402
+
403
+ sink.append(
404
+ `<div hidden id="${escapeAttribute(segmentId)}">${segmentSink.toString()}</div>${renderReactSuspenseRevealScript(boundaryId, segmentId, options)}`,
405
+ );
406
+ }
407
+
408
+ function renderReactSuspenseErrorAttributes(options: ReactSuspenseClientRenderOptions): string {
409
+ const message =
410
+ options.message === undefined ? "" : ` data-msg="${escapeAttribute(options.message)}"`;
411
+ const stack = options.stack === undefined ? "" : ` data-stck="${escapeAttribute(options.stack)}"`;
412
+
413
+ return `${message}${stack}`;
414
+ }
415
+
416
+ function renderReactSuspenseRevealScript(
417
+ boundaryId: string,
418
+ segmentId: string,
419
+ options: ReactSuspenseScriptOptions = {},
420
+ ): string {
421
+ if (options.src !== undefined) {
422
+ return `<script data-mreact-react-suspense-reveal${renderNonceAttribute(options.nonce)} src="${escapeAttribute(options.src)}" data-boundary-id="${escapeAttribute(boundaryId)}" data-segment-id="${escapeAttribute(segmentId)}"></script>`;
423
+ }
424
+
425
+ return `<script${renderNonceAttribute(options.nonce)}>${reactSuspenseRevealScriptBody};$RC(${serializeScriptJson(boundaryId)},${serializeScriptJson(segmentId)})</script>`;
426
+ }
427
+
428
+ export function renderHydrationBoundary(
429
+ sink: HtmlSink,
430
+ id: string,
431
+ render: (sink: HtmlSink) => void | PromiseLike<void>,
432
+ ): void | PromiseLike<void> {
433
+ const markerId = encodeURIComponent(id);
434
+ sink.append(`<!--mreact-h:start:${markerId}-->`);
435
+ const result = render(sink);
436
+
437
+ if (isPromiseLike(result)) {
438
+ return result.then(() => {
439
+ sink.append(`<!--mreact-h:end:${markerId}-->`);
440
+ });
441
+ }
442
+
443
+ sink.append(`<!--mreact-h:end:${markerId}-->`);
444
+ }
445
+
446
+ function isPromiseLike(value: unknown): value is PromiseLike<void> {
447
+ return isPromiseLikeUnknown(value);
448
+ }
449
+
450
+ const outOfOrderReorderScript = `(()=>{function apply(root){const fragments=Array.from(root.querySelectorAll("template[data-mreact-oob-fragment]"));for(const fragment of fragments){const id=fragment.getAttribute("data-mreact-oob-fragment");if(id===null)continue;const placeholders=Array.from(root.querySelectorAll("[data-mreact-oob-placeholder]"));const placeholder=placeholders.find((candidate)=>candidate.getAttribute("data-mreact-oob-placeholder")===id);if(placeholder===undefined)continue;placeholder.replaceWith(fragment.content.cloneNode(true));fragment.remove();}}apply(document);new MutationObserver(()=>apply(document)).observe(document.documentElement,{childList:true,subtree:true});})();`;
451
+
452
+ const reactSuspenseRevealScriptBody = `(self.$RC=self.$RC||function(bid,sid){var b=document.getElementById(bid);var s=document.getElementById(sid);if(!b||!s)return;var p=b.parentNode;var e=b.nextSibling;var d=0;var r=[];for(var n=e;n;n=n.nextSibling){if(n.nodeType===8){if(n.data==="$"||n.data==="$?"||n.data==="$!")d++;else if(n.data==="/$"){if(d===0){e=n;break;}d--;}}r.push(n);}for(var i=0;r[i];i++)p.removeChild(r[i]);while(s.firstChild)p.insertBefore(s.firstChild,e);s.remove();b.data="$";})`;
453
+
454
+ export const reactSuspenseRevealExternalScript = `(()=>{${reactSuspenseRevealScriptBody};var s=document.currentScript;if(!s)return;var b=s.getAttribute("data-boundary-id");var seg=s.getAttribute("data-segment-id");if(b!==null&&seg!==null)self.$RC(b,seg);})();`;