@fictjs/ssr 0.25.0 → 0.27.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 +169 -52
- package/dist/experimental.cjs +1 -1
- package/dist/experimental.d.cts +1 -1
- package/dist/experimental.d.ts +1 -1
- package/dist/experimental.js +1 -1
- package/dist/experimental.node.cjs +5 -0
- package/dist/experimental.node.d.cts +2 -0
- package/dist/experimental.node.d.ts +2 -0
- package/dist/experimental.node.js +4 -0
- package/dist/fict-stream-runtime.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.node.cjs +10 -0
- package/dist/index.node.d.cts +2 -0
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +4 -0
- package/dist/node-session-carrier-CTo-1Awq.cjs +8 -0
- package/dist/node-session-carrier-ClqRbdoQ.js +12 -0
- package/dist/node-session-carrier-ClqRbdoQ.js.map +1 -0
- package/dist/render-core-D0nOT_Pe.js +1824 -0
- package/dist/render-core-D0nOT_Pe.js.map +1 -0
- package/dist/render-core-D_EheVwh.cjs +1863 -0
- package/dist/{render-core-BCYvLfHF.d.ts → render-core-KCiJV_SK.d.cts} +22 -1
- package/dist/render-core-KCiJV_SK.d.cts.map +1 -0
- package/dist/{render-core-BCYvLfHF.d.cts → render-core-KCiJV_SK.d.ts} +22 -1
- package/dist/render-core-KCiJV_SK.d.ts.map +1 -0
- package/dist/stream-runtime.cjs +1 -1
- package/dist/stream-runtime.d.cts +2 -0
- package/dist/stream-runtime.d.cts.map +1 -1
- package/dist/stream-runtime.d.ts +2 -0
- package/dist/stream-runtime.d.ts.map +1 -1
- package/dist/stream-runtime.js +1 -1
- package/dist/stream-runtime.js.map +1 -1
- package/package.json +15 -5
- package/dist/render-core-BCYvLfHF.d.cts.map +0 -1
- package/dist/render-core-BCYvLfHF.d.ts.map +0 -1
- package/dist/render-core-BLLUhkYy.js +0 -969
- package/dist/render-core-BLLUhkYy.js.map +0 -1
- package/dist/render-core-CWudV9yi.cjs +0 -1008
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
import { createStreamRuntimeCode } from "./stream-runtime.js";
|
|
2
|
+
import { render } from "@fictjs/runtime";
|
|
3
|
+
import { __fictCreateSSRSession, __fictDisableSSR, __fictEnableSSR, __fictGetCurrentSSRSession, __fictGetScopeRegistry, __fictRetainSSRSession, __fictRunWithSSRSession, __fictSerializeSSRState, __fictSerializeSSRStateForScopes, __fictSetSSRScopeIdentifierPrefix, __fictSetSSRStreamHooks, assertValidDOMAttributeName, assertValidDOMElementName } from "@fictjs/runtime/internal";
|
|
4
|
+
import { parseHTML } from "linkedom";
|
|
5
|
+
//#region src/node-require.ts
|
|
6
|
+
/**
|
|
7
|
+
* Resolve Node's module-local `require` without introducing a Node builtin
|
|
8
|
+
* import into the edge-compatible ESM build.
|
|
9
|
+
*
|
|
10
|
+
* The CJS build replaces `__FICT_NODE_REQUIRE__` with its local `require`.
|
|
11
|
+
* Source/ESM execution can still opt in through a host-provided global.
|
|
12
|
+
*/
|
|
13
|
+
function getNodeRequire() {
|
|
14
|
+
if (typeof __FICT_NODE_REQUIRE__ === "function") return __FICT_NODE_REQUIRE__;
|
|
15
|
+
const direct = globalThis.require;
|
|
16
|
+
if (typeof direct === "function") return direct;
|
|
17
|
+
try {
|
|
18
|
+
return Function("return typeof require === \"function\" ? require : null")();
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/globals.ts
|
|
25
|
+
function installGlobals(window, document) {
|
|
26
|
+
const win = window;
|
|
27
|
+
const required = {
|
|
28
|
+
window: win,
|
|
29
|
+
document,
|
|
30
|
+
self: win,
|
|
31
|
+
Node: win.Node,
|
|
32
|
+
Element: win.Element,
|
|
33
|
+
HTMLElement: win.HTMLElement,
|
|
34
|
+
SVGElement: win.SVGElement,
|
|
35
|
+
Document: win.Document,
|
|
36
|
+
DocumentFragment: win.DocumentFragment,
|
|
37
|
+
Text: win.Text,
|
|
38
|
+
Comment: win.Comment
|
|
39
|
+
};
|
|
40
|
+
const optional = {
|
|
41
|
+
Range: win.Range,
|
|
42
|
+
Event: win.Event,
|
|
43
|
+
CustomEvent: win.CustomEvent,
|
|
44
|
+
MutationObserver: win.MutationObserver,
|
|
45
|
+
DOMParser: win.DOMParser,
|
|
46
|
+
getComputedStyle: win.getComputedStyle?.bind(win)
|
|
47
|
+
};
|
|
48
|
+
const missing = Object.entries(required).filter(([, value]) => value === void 0).map(([key]) => key);
|
|
49
|
+
if (missing.length) throw new Error(`[fict/ssr] Missing DOM globals: ${missing.join(", ")}`);
|
|
50
|
+
const globals = {
|
|
51
|
+
...required,
|
|
52
|
+
...optional
|
|
53
|
+
};
|
|
54
|
+
const keys = Object.keys(globals);
|
|
55
|
+
const snapshot = captureGlobals(keys);
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const value = globals[key];
|
|
58
|
+
if (value !== void 0) globalThis[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return () => restoreGlobals(snapshot);
|
|
61
|
+
}
|
|
62
|
+
function installManifest(manifest) {
|
|
63
|
+
if (!manifest) return () => {};
|
|
64
|
+
let resolved;
|
|
65
|
+
if (typeof manifest === "string") {
|
|
66
|
+
const raw = readTextFileFromPath(manifest);
|
|
67
|
+
resolved = JSON.parse(raw);
|
|
68
|
+
} else resolved = manifest;
|
|
69
|
+
const session = __fictGetCurrentSSRSession();
|
|
70
|
+
if (session) {
|
|
71
|
+
const previous = session.manifest;
|
|
72
|
+
session.manifest = resolved;
|
|
73
|
+
return () => {
|
|
74
|
+
session.manifest = previous;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const key = "__FICT_MANIFEST__";
|
|
78
|
+
const snapshot = {
|
|
79
|
+
exists: Object.prototype.hasOwnProperty.call(globalThis, key),
|
|
80
|
+
value: globalThis[key]
|
|
81
|
+
};
|
|
82
|
+
globalThis[key] = resolved;
|
|
83
|
+
return () => {
|
|
84
|
+
if (snapshot.exists) globalThis[key] = snapshot.value;
|
|
85
|
+
else delete globalThis[key];
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function captureGlobals(keys) {
|
|
89
|
+
const snapshot = [];
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
const exists = Object.prototype.hasOwnProperty.call(globalThis, key);
|
|
92
|
+
const value = globalThis[key];
|
|
93
|
+
snapshot.push({
|
|
94
|
+
key,
|
|
95
|
+
exists,
|
|
96
|
+
value
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return snapshot;
|
|
100
|
+
}
|
|
101
|
+
function restoreGlobals(snapshot) {
|
|
102
|
+
for (const entry of snapshot) if (entry.exists) globalThis[entry.key] = entry.value;
|
|
103
|
+
else delete globalThis[entry.key];
|
|
104
|
+
}
|
|
105
|
+
function readTextFileFromPath(path) {
|
|
106
|
+
const deno = globalThis.Deno;
|
|
107
|
+
if (deno && typeof deno.readTextFileSync === "function") return deno.readTextFileSync(path);
|
|
108
|
+
const nodeRequire = getNodeRequire();
|
|
109
|
+
if (nodeRequire) return nodeRequire("node:fs").readFileSync(path, "utf8");
|
|
110
|
+
throw new Error("[fict/ssr] `manifest` as file path is only supported when Deno.readTextFileSync or CommonJS require is available. Pass a manifest object in Node ESM or edge runtimes.");
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/html-serializer.ts
|
|
114
|
+
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
115
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
116
|
+
const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
|
|
117
|
+
const MATHML_TEXT_INTEGRATION_EXCEPTIONS = /* @__PURE__ */ new Set(["mglyph", "malignmark"]);
|
|
118
|
+
const HTML_VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
119
|
+
"area",
|
|
120
|
+
"base",
|
|
121
|
+
"basefont",
|
|
122
|
+
"bgsound",
|
|
123
|
+
"br",
|
|
124
|
+
"col",
|
|
125
|
+
"embed",
|
|
126
|
+
"hr",
|
|
127
|
+
"img",
|
|
128
|
+
"input",
|
|
129
|
+
"keygen",
|
|
130
|
+
"link",
|
|
131
|
+
"meta",
|
|
132
|
+
"param",
|
|
133
|
+
"source",
|
|
134
|
+
"track",
|
|
135
|
+
"wbr"
|
|
136
|
+
]);
|
|
137
|
+
const HTML_RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
138
|
+
"iframe",
|
|
139
|
+
"noembed",
|
|
140
|
+
"noframes",
|
|
141
|
+
"noscript",
|
|
142
|
+
"script",
|
|
143
|
+
"style",
|
|
144
|
+
"xmp"
|
|
145
|
+
]);
|
|
146
|
+
const HTML_RCDATA_ELEMENTS = /* @__PURE__ */ new Set(["textarea", "title"]);
|
|
147
|
+
const HTML_TABLE_SECTION_ELEMENTS = /* @__PURE__ */ new Set([
|
|
148
|
+
"tbody",
|
|
149
|
+
"thead",
|
|
150
|
+
"tfoot"
|
|
151
|
+
]);
|
|
152
|
+
const HTML_TEXT_ONLY_ELEMENTS = /* @__PURE__ */ new Set([
|
|
153
|
+
...HTML_RAW_TEXT_ELEMENTS,
|
|
154
|
+
"plaintext",
|
|
155
|
+
"textarea",
|
|
156
|
+
"title"
|
|
157
|
+
]);
|
|
158
|
+
const HTML_DOCUMENT_STRUCTURE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
159
|
+
"frameset",
|
|
160
|
+
"head",
|
|
161
|
+
"html"
|
|
162
|
+
]);
|
|
163
|
+
const HTML_UNSAFE_RESUMABLE_HOST_PARENTS = /* @__PURE__ */ new Set([
|
|
164
|
+
"colgroup",
|
|
165
|
+
"option",
|
|
166
|
+
"optgroup",
|
|
167
|
+
"select",
|
|
168
|
+
"table",
|
|
169
|
+
"tr",
|
|
170
|
+
...HTML_TABLE_SECTION_ELEMENTS,
|
|
171
|
+
...HTML_TEXT_ONLY_ELEMENTS,
|
|
172
|
+
...HTML_DOCUMENT_STRUCTURE_ELEMENTS
|
|
173
|
+
]);
|
|
174
|
+
const HTML_HOST_SENSITIVE_CONTEXT_CHILDREN = /* @__PURE__ */ new Map([
|
|
175
|
+
["details", /* @__PURE__ */ new Set(["summary"])],
|
|
176
|
+
["fieldset", /* @__PURE__ */ new Set(["legend"])],
|
|
177
|
+
["audio", /* @__PURE__ */ new Set(["source", "track"])],
|
|
178
|
+
["video", /* @__PURE__ */ new Set(["source", "track"])],
|
|
179
|
+
["ruby", /* @__PURE__ */ new Set(["rp", "rt"])],
|
|
180
|
+
["figure", /* @__PURE__ */ new Set(["figcaption"])],
|
|
181
|
+
["map", /* @__PURE__ */ new Set(["area"])]
|
|
182
|
+
]);
|
|
183
|
+
const HTML_LIST_ITEM_SCAN_BARRIERS = /* @__PURE__ */ new Set([
|
|
184
|
+
"applet",
|
|
185
|
+
"article",
|
|
186
|
+
"aside",
|
|
187
|
+
"blockquote",
|
|
188
|
+
"button",
|
|
189
|
+
"caption",
|
|
190
|
+
"center",
|
|
191
|
+
"colgroup",
|
|
192
|
+
"details",
|
|
193
|
+
"dl",
|
|
194
|
+
"fieldset",
|
|
195
|
+
"figure",
|
|
196
|
+
"footer",
|
|
197
|
+
"form",
|
|
198
|
+
"h1",
|
|
199
|
+
"h2",
|
|
200
|
+
"h3",
|
|
201
|
+
"h4",
|
|
202
|
+
"h5",
|
|
203
|
+
"h6",
|
|
204
|
+
"header",
|
|
205
|
+
"hgroup",
|
|
206
|
+
"hr",
|
|
207
|
+
"main",
|
|
208
|
+
"marquee",
|
|
209
|
+
"menu",
|
|
210
|
+
"nav",
|
|
211
|
+
"object",
|
|
212
|
+
"ol",
|
|
213
|
+
"pre",
|
|
214
|
+
"search",
|
|
215
|
+
"section",
|
|
216
|
+
"select",
|
|
217
|
+
"table",
|
|
218
|
+
"tbody",
|
|
219
|
+
"td",
|
|
220
|
+
"tfoot",
|
|
221
|
+
"th",
|
|
222
|
+
"thead",
|
|
223
|
+
"tr",
|
|
224
|
+
"ul"
|
|
225
|
+
]);
|
|
226
|
+
const HTML_PARSER_SENSITIVE_HOST_CONTEXTS = /* @__PURE__ */ new Map([
|
|
227
|
+
["p", { descendants: /* @__PURE__ */ new Set([
|
|
228
|
+
"address",
|
|
229
|
+
"article",
|
|
230
|
+
"aside",
|
|
231
|
+
"blockquote",
|
|
232
|
+
"center",
|
|
233
|
+
"dd",
|
|
234
|
+
"details",
|
|
235
|
+
"dialog",
|
|
236
|
+
"dir",
|
|
237
|
+
"div",
|
|
238
|
+
"dl",
|
|
239
|
+
"dt",
|
|
240
|
+
"fieldset",
|
|
241
|
+
"figcaption",
|
|
242
|
+
"figure",
|
|
243
|
+
"footer",
|
|
244
|
+
"form",
|
|
245
|
+
"h1",
|
|
246
|
+
"h2",
|
|
247
|
+
"h3",
|
|
248
|
+
"h4",
|
|
249
|
+
"h5",
|
|
250
|
+
"h6",
|
|
251
|
+
"header",
|
|
252
|
+
"hgroup",
|
|
253
|
+
"hr",
|
|
254
|
+
"li",
|
|
255
|
+
"listing",
|
|
256
|
+
"main",
|
|
257
|
+
"menu",
|
|
258
|
+
"nav",
|
|
259
|
+
"ol",
|
|
260
|
+
"p",
|
|
261
|
+
"plaintext",
|
|
262
|
+
"pre",
|
|
263
|
+
"search",
|
|
264
|
+
"section",
|
|
265
|
+
"summary",
|
|
266
|
+
"table",
|
|
267
|
+
"ul",
|
|
268
|
+
"xmp"
|
|
269
|
+
]) }],
|
|
270
|
+
["a", { descendants: /* @__PURE__ */ new Set(["a"]) }],
|
|
271
|
+
["button", { descendants: /* @__PURE__ */ new Set(["button"]) }],
|
|
272
|
+
["li", {
|
|
273
|
+
descendants: /* @__PURE__ */ new Set(["li"]),
|
|
274
|
+
barriers: HTML_LIST_ITEM_SCAN_BARRIERS
|
|
275
|
+
}],
|
|
276
|
+
["dt", {
|
|
277
|
+
descendants: /* @__PURE__ */ new Set(["dt", "dd"]),
|
|
278
|
+
barriers: HTML_LIST_ITEM_SCAN_BARRIERS
|
|
279
|
+
}],
|
|
280
|
+
["dd", {
|
|
281
|
+
descendants: /* @__PURE__ */ new Set(["dt", "dd"]),
|
|
282
|
+
barriers: HTML_LIST_ITEM_SCAN_BARRIERS
|
|
283
|
+
}],
|
|
284
|
+
["nobr", { descendants: /* @__PURE__ */ new Set(["nobr"]) }],
|
|
285
|
+
["form", { descendants: /* @__PURE__ */ new Set(["form"]) }],
|
|
286
|
+
["caption", {
|
|
287
|
+
descendants: /* @__PURE__ */ new Set([
|
|
288
|
+
"caption",
|
|
289
|
+
"col",
|
|
290
|
+
"colgroup",
|
|
291
|
+
"tbody",
|
|
292
|
+
"td",
|
|
293
|
+
"tfoot",
|
|
294
|
+
"th",
|
|
295
|
+
"thead",
|
|
296
|
+
"tr"
|
|
297
|
+
]),
|
|
298
|
+
barriers: /* @__PURE__ */ new Set(["table"])
|
|
299
|
+
}],
|
|
300
|
+
["td", {
|
|
301
|
+
descendants: /* @__PURE__ */ new Set([
|
|
302
|
+
"caption",
|
|
303
|
+
"col",
|
|
304
|
+
"colgroup",
|
|
305
|
+
"tbody",
|
|
306
|
+
"td",
|
|
307
|
+
"tfoot",
|
|
308
|
+
"th",
|
|
309
|
+
"thead",
|
|
310
|
+
"tr"
|
|
311
|
+
]),
|
|
312
|
+
barriers: /* @__PURE__ */ new Set(["table"])
|
|
313
|
+
}],
|
|
314
|
+
["th", {
|
|
315
|
+
descendants: /* @__PURE__ */ new Set([
|
|
316
|
+
"caption",
|
|
317
|
+
"col",
|
|
318
|
+
"colgroup",
|
|
319
|
+
"tbody",
|
|
320
|
+
"td",
|
|
321
|
+
"tfoot",
|
|
322
|
+
"th",
|
|
323
|
+
"thead",
|
|
324
|
+
"tr"
|
|
325
|
+
]),
|
|
326
|
+
barriers: /* @__PURE__ */ new Set(["table"])
|
|
327
|
+
}]
|
|
328
|
+
]);
|
|
329
|
+
const STREAM_BOUNDARY_START_PREFIX = "fict:suspense-start:";
|
|
330
|
+
const STREAM_BOUNDARY_END_PREFIX = "fict:suspense-end:";
|
|
331
|
+
const SCRIPT_SUPPORTING_ELEMENTS = ["script", "template"];
|
|
332
|
+
const HTML_STREAM_BOUNDARY_ALLOWED_CHILDREN = /* @__PURE__ */ new Map([
|
|
333
|
+
["table", /* @__PURE__ */ new Set([
|
|
334
|
+
"caption",
|
|
335
|
+
"colgroup",
|
|
336
|
+
"tbody",
|
|
337
|
+
"tfoot",
|
|
338
|
+
"thead",
|
|
339
|
+
...SCRIPT_SUPPORTING_ELEMENTS
|
|
340
|
+
])],
|
|
341
|
+
["tbody", /* @__PURE__ */ new Set(["tr", ...SCRIPT_SUPPORTING_ELEMENTS])],
|
|
342
|
+
["thead", /* @__PURE__ */ new Set(["tr", ...SCRIPT_SUPPORTING_ELEMENTS])],
|
|
343
|
+
["tfoot", /* @__PURE__ */ new Set(["tr", ...SCRIPT_SUPPORTING_ELEMENTS])],
|
|
344
|
+
["tr", /* @__PURE__ */ new Set([
|
|
345
|
+
"td",
|
|
346
|
+
"th",
|
|
347
|
+
...SCRIPT_SUPPORTING_ELEMENTS
|
|
348
|
+
])],
|
|
349
|
+
["colgroup", /* @__PURE__ */ new Set(["col", "template"])],
|
|
350
|
+
["select", /* @__PURE__ */ new Set([
|
|
351
|
+
"hr",
|
|
352
|
+
"optgroup",
|
|
353
|
+
"option",
|
|
354
|
+
...SCRIPT_SUPPORTING_ELEMENTS
|
|
355
|
+
])],
|
|
356
|
+
["optgroup", /* @__PURE__ */ new Set(["option", ...SCRIPT_SUPPORTING_ELEMENTS])],
|
|
357
|
+
["option", /* @__PURE__ */ new Set()]
|
|
358
|
+
]);
|
|
359
|
+
const HTML_STREAM_BOUNDARY_TEXT_CONTEXTS = /* @__PURE__ */ new Set([
|
|
360
|
+
"option",
|
|
361
|
+
"optgroup",
|
|
362
|
+
"select"
|
|
363
|
+
]);
|
|
364
|
+
const ELEMENT_NODE = 1;
|
|
365
|
+
const TEXT_NODE = 3;
|
|
366
|
+
const CDATA_SECTION_NODE = 4;
|
|
367
|
+
const PROCESSING_INSTRUCTION_NODE = 7;
|
|
368
|
+
const COMMENT_NODE = 8;
|
|
369
|
+
const DOCUMENT_NODE = 9;
|
|
370
|
+
const DOCUMENT_TYPE_NODE = 10;
|
|
371
|
+
const DOCUMENT_FRAGMENT_NODE = 11;
|
|
372
|
+
/**
|
|
373
|
+
* Serialize a DOM subtree as HTML without relying on the host DOM's
|
|
374
|
+
* `innerHTML`/`outerHTML` implementation. Some lightweight server DOMs do not
|
|
375
|
+
* escape ampersands in attributes or matching end tags in raw-text elements,
|
|
376
|
+
* so their output can produce a different (and unsafe) tree when a browser
|
|
377
|
+
* parses it again.
|
|
378
|
+
*/
|
|
379
|
+
function serializeHtmlNode(node, parentElement = null) {
|
|
380
|
+
switch (node.nodeType) {
|
|
381
|
+
case ELEMENT_NODE: return serializeElement(node, parentElement);
|
|
382
|
+
case TEXT_NODE:
|
|
383
|
+
case CDATA_SECTION_NODE: return serializeText(node.nodeValue ?? "", parentElement);
|
|
384
|
+
case COMMENT_NODE: return serializeComment(node.nodeValue ?? "");
|
|
385
|
+
case DOCUMENT_NODE:
|
|
386
|
+
case DOCUMENT_FRAGMENT_NODE: return serializeHtmlChildren(node);
|
|
387
|
+
case DOCUMENT_TYPE_NODE: return serializeDocumentType(node);
|
|
388
|
+
case PROCESSING_INSTRUCTION_NODE: return serializeComment(`?${node.nodeName} ${node.nodeValue ?? ""}?`);
|
|
389
|
+
default: return "";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function serializeHtmlChildren(parent) {
|
|
393
|
+
const parentElement = parent.nodeType === ELEMENT_NODE ? parent : null;
|
|
394
|
+
return serializeHtmlNodes(parent.childNodes, parentElement);
|
|
395
|
+
}
|
|
396
|
+
function serializeHtmlNodes(nodes, parentElement = null) {
|
|
397
|
+
if (parentElement && isHtmlElement(parentElement)) assertSafeStreamingBoundaryContext(parentElement, (parentElement.localName || parentElement.tagName).toLowerCase());
|
|
398
|
+
let html = "";
|
|
399
|
+
for (const node of nodes) html += serializeHtmlNode(node, parentElement);
|
|
400
|
+
const rawTextTagName = getRawTextTagName(parentElement);
|
|
401
|
+
return rawTextTagName ? escapeRawTextEndTag(html, rawTextTagName) : html;
|
|
402
|
+
}
|
|
403
|
+
function serializeElement(element, serializedParent) {
|
|
404
|
+
const localName = element.localName || element.tagName;
|
|
405
|
+
const tagName = element.prefix ? `${element.prefix}:${localName}` : localName;
|
|
406
|
+
const isHtml = isHtmlElement(element);
|
|
407
|
+
const normalizedTagName = tagName.toLowerCase();
|
|
408
|
+
assertSafeResumableHostContext(element, serializedParent);
|
|
409
|
+
if (isHtml && normalizedTagName === "plaintext") throw new Error("[fict/ssr] Cannot serialize HTML <plaintext>. The HTML syntax has no closing tag for this element, so a browser would consume every following tag, ancestor closing tag, and snapshot script as text. Use <pre> for preformatted HTML content or return a text/plain response instead.");
|
|
410
|
+
assertValidDOMElementName(tagName, !isHtml, isHtml ? void 0 : element.namespaceURI);
|
|
411
|
+
let html = `<${tagName}`;
|
|
412
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
413
|
+
assertValidDOMAttributeName(attribute.name, attribute.namespaceURI != null, attribute.namespaceURI ?? void 0);
|
|
414
|
+
html += ` ${attribute.name}="${escapeAttributeValue(attribute.value)}"`;
|
|
415
|
+
}
|
|
416
|
+
if (isHtml && HTML_VOID_ELEMENTS.has(normalizedTagName)) {
|
|
417
|
+
assertEmptyHtmlVoidElement(element, normalizedTagName);
|
|
418
|
+
return `${html}>`;
|
|
419
|
+
}
|
|
420
|
+
html += ">";
|
|
421
|
+
const childSource = isHtml && normalizedTagName === "template" && "content" in element ? element.content ?? element : element;
|
|
422
|
+
if (childSource !== element) assertSafeTemplateContent(childSource);
|
|
423
|
+
html += serializeHtmlChildren(childSource);
|
|
424
|
+
if (isHtml && (HTML_RAW_TEXT_ELEMENTS.has(normalizedTagName) || HTML_RCDATA_ELEMENTS.has(normalizedTagName))) assertTextOnlyHtmlChildren(childSource, normalizedTagName);
|
|
425
|
+
html += `</${tagName}>`;
|
|
426
|
+
return html;
|
|
427
|
+
}
|
|
428
|
+
function assertTextOnlyHtmlChildren(element, tagName) {
|
|
429
|
+
for (const child of Array.from(element.childNodes)) {
|
|
430
|
+
if (child.nodeType === TEXT_NODE || child.nodeType === CDATA_SECTION_NODE) continue;
|
|
431
|
+
const childDescription = child.nodeType === ELEMENT_NODE ? `<${(child.localName || child.tagName).toLowerCase()}>` : child.nodeType === COMMENT_NODE ? "a comment" : `a ${child.nodeName || `nodeType ${child.nodeType}`} node`;
|
|
432
|
+
throw new Error(`[fict/ssr] Cannot serialize HTML <${tagName}> with ${childDescription} child. The HTML parser treats this element's contents as text, so non-text DOM nodes cannot round-trip and would silently disappear. Provide string/textContent content instead.`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function assertEmptyHtmlVoidElement(element, tagName) {
|
|
436
|
+
const childCount = element.childNodes.length;
|
|
437
|
+
if (childCount === 0) return;
|
|
438
|
+
const discardedResumableState = describeDiscardedResumableFeature(findResumableFeature(element));
|
|
439
|
+
throw new Error(`[fict/ssr] Cannot serialize <${tagName}> with ${childCount} child node${childCount === 1 ? "" : "s"}. HTML void elements cannot contain children, and browsers omit every child when serializing or parsing <${tagName}>.${discardedResumableState} Remove all children from <${tagName}> and move the content outside the void element.`);
|
|
440
|
+
}
|
|
441
|
+
function assertSafeStreamingBoundaryContext(element, contextTag) {
|
|
442
|
+
if (HTML_TEXT_ONLY_ELEMENTS.has(contextTag)) {
|
|
443
|
+
const marker = findStreamingBoundaryMarker(element);
|
|
444
|
+
if (!marker) return;
|
|
445
|
+
throw new Error(`[fict/ssr] Cannot serialize a streaming Suspense boundary inside <${contextTag}>. The HTML parser treats its comment markers as text in this context, so the streamed patch can never find boundary ${JSON.stringify(marker.id)}. Move the streaming boundary outside <${contextTag}> and update this element through an outer component.`);
|
|
446
|
+
}
|
|
447
|
+
const allowedChildren = HTML_STREAM_BOUNDARY_ALLOWED_CHILDREN.get(contextTag);
|
|
448
|
+
if (!allowedChildren) return;
|
|
449
|
+
const children = Array.from(element.childNodes);
|
|
450
|
+
for (let index = 0; index < children.length; index++) {
|
|
451
|
+
const start = parseStreamingBoundaryMarker(children[index]);
|
|
452
|
+
if (start?.kind !== "start") continue;
|
|
453
|
+
let endIndex = index + 1;
|
|
454
|
+
while (endIndex < children.length) {
|
|
455
|
+
const end = parseStreamingBoundaryMarker(children[endIndex]);
|
|
456
|
+
if (end?.kind === "end" && end.id === start.id) break;
|
|
457
|
+
endIndex++;
|
|
458
|
+
}
|
|
459
|
+
if (endIndex === children.length) throw new Error(`[fict/ssr] Cannot serialize streaming Suspense boundary ${JSON.stringify(start.id)} inside <${contextTag}> because its sibling end marker is missing.`);
|
|
460
|
+
for (let contentIndex = index + 1; contentIndex < endIndex; contentIndex++) {
|
|
461
|
+
const invalidContent = describeInvalidBoundaryContent(children[contentIndex], allowedChildren, HTML_STREAM_BOUNDARY_TEXT_CONTEXTS.has(contextTag));
|
|
462
|
+
if (!invalidContent) continue;
|
|
463
|
+
throw new Error(`[fict/ssr] Cannot serialize a streaming Suspense boundary inside <${contextTag}> with direct ${invalidContent} content. The HTML parser reparents or discards that content, so the boundary markers no longer describe one patchable sibling range. ` + getStreamingBoundaryRewriteSuggestion(contextTag, invalidContent));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function describeInvalidBoundaryContent(node, allowedElements, allowText) {
|
|
468
|
+
if (node.nodeType === COMMENT_NODE) return null;
|
|
469
|
+
if (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) return allowText || !(node.nodeValue ?? "").trim() ? null : "non-whitespace text";
|
|
470
|
+
if (node.nodeType !== ELEMENT_NODE) return `node type ${node.nodeType}`;
|
|
471
|
+
const element = node;
|
|
472
|
+
const tagName = (element.localName || element.tagName).toLowerCase();
|
|
473
|
+
return isHtmlElement(element) && allowedElements.has(tagName) ? null : `<${tagName}>`;
|
|
474
|
+
}
|
|
475
|
+
function getStreamingBoundaryRewriteSuggestion(contextTag, invalidContent) {
|
|
476
|
+
if (contextTag === "table" && invalidContent === "<tr>") return "Wrap the boundary and rows in an explicit <tbody>.";
|
|
477
|
+
if (contextTag === "table" && invalidContent === "<col>") return "Wrap the boundary and columns in an explicit <colgroup>.";
|
|
478
|
+
if (HTML_TABLE_SECTION_ELEMENTS.has(contextTag)) return `Wrap cells in a native <tr>, or move the boundary outside <${contextTag}>.`;
|
|
479
|
+
if (contextTag === "tr") return "Render only native <td> or <th> roots inside this boundary.";
|
|
480
|
+
if (contextTag === "colgroup") return "Render only native <col> roots inside this boundary.";
|
|
481
|
+
if (contextTag === "select" || contextTag === "optgroup" || contextTag === "option") return "Use only portable native option content, or move the boundary outside <select>.";
|
|
482
|
+
return `Move the boundary outside <${contextTag}> or wrap its content in a parser-stable native container.`;
|
|
483
|
+
}
|
|
484
|
+
function assertSafeResumableHostContext(element, serializedParent) {
|
|
485
|
+
if (!isResumableFictHost(element)) return;
|
|
486
|
+
const foreignNamespace = getResumableHostForeignNamespace(element, serializedParent);
|
|
487
|
+
if (foreignNamespace) {
|
|
488
|
+
const scopeId = element.getAttribute("data-fict-s") ?? "<unknown>";
|
|
489
|
+
const namespaceDescription = foreignNamespace.kind === "other" ? `foreign namespace ${JSON.stringify(foreignNamespace.uri)}` : `the ${foreignNamespace.kind} namespace`;
|
|
490
|
+
const semanticRisk = foreignNamespace.kind === "SVG" ? "A custom element wrapper is not structurally transparent in SVG and can suppress the graphics it contains after HTML parsing." : foreignNamespace.kind === "MathML" ? "A custom element wrapper is not structurally transparent in MathML and can replace the intended operands of fixed-arity layout elements after HTML parsing." : "A custom element wrapper is not guaranteed to be structurally transparent in foreign content after HTML parsing.";
|
|
491
|
+
throw new Error(`[fict/ssr] Cannot serialize resumable <fict-host> scope ${JSON.stringify(scopeId)} in ${namespaceDescription}. ${semanticRisk} Range-based scope anchors (range-v3) are required for resumable components in foreign content; move the component boundary outside the foreign-content subtree until that protocol is available.`);
|
|
492
|
+
}
|
|
493
|
+
const parent = serializedParent ?? element.parentElement;
|
|
494
|
+
if (!parent || !isHtmlElement(parent)) return;
|
|
495
|
+
const contextTag = (parent.localName || parent.tagName).toLowerCase();
|
|
496
|
+
if (HTML_UNSAFE_RESUMABLE_HOST_PARENTS.has(contextTag)) {
|
|
497
|
+
const scopeId = element.getAttribute("data-fict-s") ?? "<unknown>";
|
|
498
|
+
throw new Error(`[fict/ssr] Cannot serialize resumable <fict-host> scope ${JSON.stringify(scopeId)} inside <${contextTag}>. The HTML parser will not preserve that host at this location, so its scope would target different DOM after parsing. ` + getResumableHostRewriteSuggestion(contextTag));
|
|
499
|
+
}
|
|
500
|
+
assertSafeHostParserStateContext(element, parent);
|
|
501
|
+
assertSafeHostSensitiveHtmlContext(element, contextTag);
|
|
502
|
+
}
|
|
503
|
+
function isResumableFictHost(element) {
|
|
504
|
+
return (element.localName || element.tagName).toLowerCase() === "fict-host" && element.hasAttribute("data-fict-host") && !!element.getAttribute("data-fict-s");
|
|
505
|
+
}
|
|
506
|
+
function assertSafeHostParserStateContext(host, parent) {
|
|
507
|
+
let ancestor = parent;
|
|
508
|
+
while (ancestor) {
|
|
509
|
+
if (isHtmlElement(ancestor)) {
|
|
510
|
+
const contextTag = (ancestor.localName || ancestor.tagName).toLowerCase();
|
|
511
|
+
const rule = HTML_PARSER_SENSITIVE_HOST_CONTEXTS.get(contextTag);
|
|
512
|
+
if (rule) {
|
|
513
|
+
const sensitiveTag = findParserSensitiveDescendant(host, rule);
|
|
514
|
+
if (sensitiveTag) {
|
|
515
|
+
const scopeId = host.getAttribute("data-fict-s") ?? "<unknown>";
|
|
516
|
+
throw new Error(`[fict/ssr] Cannot serialize resumable <fict-host> scope ${JSON.stringify(scopeId)} inside <${contextTag}> with descendant <${sensitiveTag}>. The HTML parser applies <${sensitiveTag}> start-tag rules outside the custom-element boundary, so it will close or reparent the ancestor, eject content from the host, or discard the component root. Move the component boundary outside <${contextTag}>, or make the component own that element. Range-based scope anchors (range-v3) are required to preserve this boundary without an element wrapper.`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
ancestor = ancestor.parentElement;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function findParserSensitiveDescendant(host, rule) {
|
|
524
|
+
const pending = Array.from(host.children);
|
|
525
|
+
while (pending.length > 0) {
|
|
526
|
+
const element = pending.shift();
|
|
527
|
+
if (!isHtmlElement(element)) continue;
|
|
528
|
+
const tagName = (element.localName || element.tagName).toLowerCase();
|
|
529
|
+
if (rule.descendants.has(tagName)) return tagName;
|
|
530
|
+
if (tagName === "template" || tagName === "svg" || tagName === "math" || HTML_TEXT_ONLY_ELEMENTS.has(tagName) || rule.barriers?.has(tagName)) continue;
|
|
531
|
+
pending.unshift(...Array.from(element.children));
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
function assertSafeHostSensitiveHtmlContext(host, contextTag) {
|
|
536
|
+
let sensitiveDescription;
|
|
537
|
+
if (contextTag === "picture") sensitiveDescription = "the native <source> and <img> structure";
|
|
538
|
+
else {
|
|
539
|
+
const sensitiveTags = HTML_HOST_SENSITIVE_CONTEXT_CHILDREN.get(contextTag);
|
|
540
|
+
if (!sensitiveTags) return;
|
|
541
|
+
const sensitiveTag = findTransparentDirectChildTag(host, sensitiveTags);
|
|
542
|
+
if (!sensitiveTag) return;
|
|
543
|
+
sensitiveDescription = `a transparent direct <${sensitiveTag}> child`;
|
|
544
|
+
}
|
|
545
|
+
const scopeId = host.getAttribute("data-fict-s") ?? "<unknown>";
|
|
546
|
+
throw new Error(`[fict/ssr] Cannot serialize resumable <fict-host> scope ${JSON.stringify(scopeId)} as a component boundary inside <${contextTag}> around ${sensitiveDescription}. ${getHostSensitiveContextRisk(contextTag)} CSS display: contents removes only the host's box; it does not make the host transparent to these DOM rules. Move the component boundary outside <${contextTag}> and make the component own <${contextTag}>, so its sensitive content remains native direct children. Range-based scope anchors (range-v3) are required to keep a resumable boundary at this position without an element wrapper.`);
|
|
547
|
+
}
|
|
548
|
+
function findTransparentDirectChildTag(host, sensitiveTags) {
|
|
549
|
+
for (const child of Array.from(host.children)) {
|
|
550
|
+
if (!isHtmlElement(child)) continue;
|
|
551
|
+
if (isResumableFictHost(child)) {
|
|
552
|
+
const nestedTag = findTransparentDirectChildTag(child, sensitiveTags);
|
|
553
|
+
if (nestedTag) return nestedTag;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const childTag = (child.localName || child.tagName).toLowerCase();
|
|
557
|
+
if (sensitiveTags.has(childTag)) return childTag;
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
function getHostSensitiveContextRisk(contextTag) {
|
|
562
|
+
switch (contextTag) {
|
|
563
|
+
case "picture": return "Browsers select picture candidates from its direct <source>/<img> structure, so a wrapper can silently select the fallback image.";
|
|
564
|
+
case "details": return "Only a direct <summary> child provides the native disclosure control, so wrapping it prevents activation from toggling <details>.";
|
|
565
|
+
case "fieldset": return "A direct <legend> supplies the fieldset name and its first-legend disabled-state exemption, both of which a wrapper removes.";
|
|
566
|
+
case "audio":
|
|
567
|
+
case "video": return `Browsers discover <source> and <track> from direct <${contextTag}> children, so wrapped media resources and text tracks are ignored.`;
|
|
568
|
+
case "ruby": return "Ruby annotation layout depends on direct <rt>/<rp> structure, and wrappers cause annotations to render as ordinary inline content in some browsers.";
|
|
569
|
+
case "figure": return "A direct <figcaption> provides the accessible name of <figure>, which is lost through a wrapper.";
|
|
570
|
+
case "map": return "Browser image-map and areas-collection behavior diverges when <area> is hidden behind a wrapper.";
|
|
571
|
+
default: return "This HTML context assigns semantics through native direct-child relationships that an element wrapper changes.";
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function getResumableHostForeignNamespace(element, serializedParent) {
|
|
575
|
+
const directNamespace = classifyForeignNamespace(element.namespaceURI);
|
|
576
|
+
if (directNamespace) return directNamespace;
|
|
577
|
+
let ancestor = serializedParent ?? element.parentElement;
|
|
578
|
+
let descendantLocalName = null;
|
|
579
|
+
while (ancestor) {
|
|
580
|
+
const localName = (ancestor.localName || ancestor.tagName).toLowerCase();
|
|
581
|
+
if (localName === "foreignobject" || localName === "title" || localName === "desc") return null;
|
|
582
|
+
if (localName === "mi" || localName === "mo" || localName === "mn" || localName === "ms" || localName === "mtext") {
|
|
583
|
+
if (MATHML_TEXT_INTEGRATION_EXCEPTIONS.has(descendantLocalName ?? "") || findTransparentDirectRootTag(element, MATHML_TEXT_INTEGRATION_EXCEPTIONS)) return { kind: "MathML" };
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
if (localName === "annotation-xml") {
|
|
587
|
+
const encoding = ancestor.getAttribute("encoding")?.toLowerCase();
|
|
588
|
+
if (encoding === "text/html" || encoding === "application/xhtml+xml") return null;
|
|
589
|
+
}
|
|
590
|
+
const ancestorNamespace = classifyForeignNamespace(ancestor.namespaceURI);
|
|
591
|
+
if (ancestorNamespace) return ancestorNamespace;
|
|
592
|
+
if (localName === "svg") return { kind: "SVG" };
|
|
593
|
+
if (localName === "math") return { kind: "MathML" };
|
|
594
|
+
descendantLocalName = localName;
|
|
595
|
+
ancestor = ancestor.parentElement;
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
function findTransparentDirectRootTag(host, tagNames) {
|
|
600
|
+
for (const child of Array.from(host.children)) {
|
|
601
|
+
if (isResumableFictHost(child)) {
|
|
602
|
+
const nestedTag = findTransparentDirectRootTag(child, tagNames);
|
|
603
|
+
if (nestedTag) return nestedTag;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const childTag = (child.localName || child.tagName).toLowerCase();
|
|
607
|
+
if (tagNames.has(childTag)) return childTag;
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
function classifyForeignNamespace(namespaceURI) {
|
|
612
|
+
if (namespaceURI === null || namespaceURI === HTML_NAMESPACE) return null;
|
|
613
|
+
if (namespaceURI === SVG_NAMESPACE) return { kind: "SVG" };
|
|
614
|
+
if (namespaceURI === MATHML_NAMESPACE) return { kind: "MathML" };
|
|
615
|
+
return {
|
|
616
|
+
kind: "other",
|
|
617
|
+
uri: namespaceURI
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function getResumableHostRewriteSuggestion(contextTag) {
|
|
621
|
+
if (contextTag === "table") return "Move the component outside <table>, or make it own the complete table while keeping table sections as native elements.";
|
|
622
|
+
if (HTML_TABLE_SECTION_ELEMENTS.has(contextTag)) return "Move the component into a native <td> or <th> within the row, or move its resumable boundary outside the table.";
|
|
623
|
+
if (contextTag === "tr") return "Move the component inside a native <td> or <th>, rather than using a component as a direct row child.";
|
|
624
|
+
if (contextTag === "colgroup") return "Move the component outside <colgroup>, or make it own the complete table and render <col> elements natively.";
|
|
625
|
+
if (contextTag === "select" || contextTag === "optgroup" || contextTag === "option") return "Move the component outside <select>, or make it own the complete select while rendering option structure natively.";
|
|
626
|
+
if (HTML_TEXT_ONLY_ELEMENTS.has(contextTag)) return `Move the component outside <${contextTag}> and make it own that element; bind its value or text content instead of nesting a component inside it.`;
|
|
627
|
+
return "Move the component into <body> content, outside document-structure elements.";
|
|
628
|
+
}
|
|
629
|
+
function describeDiscardedResumableFeature(feature) {
|
|
630
|
+
if (!feature) return "";
|
|
631
|
+
if (feature.kind === "scope") return ` The discarded children contain a resumable scope (${feature.detail}), which would leave orphaned snapshot state.`;
|
|
632
|
+
if (feature.kind === "event") return ` The discarded children contain a resumable event (${feature.detail}), which would disappear from the parsed document.`;
|
|
633
|
+
return ` The discarded children contain a streaming Suspense boundary (${feature.detail}), which would leave an unpatchable stream.`;
|
|
634
|
+
}
|
|
635
|
+
function assertSafeTemplateContent(content) {
|
|
636
|
+
const feature = findResumableFeature(content);
|
|
637
|
+
if (!feature) return;
|
|
638
|
+
if (feature.kind === "boundary") throw new Error(`[fict/ssr] Cannot serialize <template> content containing a streaming Suspense boundary (${feature.detail}). Native template content can be cloned more than once, which duplicates the stream boundary id and makes patch targeting ambiguous. Move the streaming boundary outside <template>.`);
|
|
639
|
+
const label = feature.kind === "scope" ? "resumable scope" : "resumable event";
|
|
640
|
+
throw new Error(`[fict/ssr] Cannot serialize <template> content containing a ${label} (${feature.detail}). Native template content can be cloned more than once, but the current resumability protocol assigns only one runtime scope to each server scope id. Move the resumable component or event outside <template>.`);
|
|
641
|
+
}
|
|
642
|
+
function findResumableFeature(root) {
|
|
643
|
+
for (const child of Array.from(root.childNodes)) {
|
|
644
|
+
const boundary = parseStreamingBoundaryMarker(child);
|
|
645
|
+
if (boundary) return {
|
|
646
|
+
kind: "boundary",
|
|
647
|
+
detail: `${boundary.kind} id ${JSON.stringify(boundary.id)}`
|
|
648
|
+
};
|
|
649
|
+
if (child.nodeType !== ELEMENT_NODE) continue;
|
|
650
|
+
const element = child;
|
|
651
|
+
const scopeId = element.getAttribute("data-fict-s");
|
|
652
|
+
if (scopeId) return {
|
|
653
|
+
kind: "scope",
|
|
654
|
+
detail: `data-fict-s=${JSON.stringify(scopeId)}`
|
|
655
|
+
};
|
|
656
|
+
const resumeQrl = element.getAttribute("data-fict-h");
|
|
657
|
+
if (resumeQrl) return {
|
|
658
|
+
kind: "scope",
|
|
659
|
+
detail: `data-fict-h=${JSON.stringify(resumeQrl)}`
|
|
660
|
+
};
|
|
661
|
+
for (const attribute of Array.from(element.attributes)) if (attribute.value && attribute.name.startsWith("on:") && attribute.name.length > 3) return {
|
|
662
|
+
kind: "event",
|
|
663
|
+
detail: attribute.name
|
|
664
|
+
};
|
|
665
|
+
const nested = findResumableFeature(isHtmlElement(element) && (element.localName || element.tagName).toLowerCase() === "template" && "content" in element ? element.content ?? element : element);
|
|
666
|
+
if (nested) return nested;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
function findStreamingBoundaryMarker(root) {
|
|
671
|
+
for (const child of Array.from(root.childNodes)) {
|
|
672
|
+
const marker = parseStreamingBoundaryMarker(child);
|
|
673
|
+
if (marker) return marker;
|
|
674
|
+
if (child.nodeType !== ELEMENT_NODE) continue;
|
|
675
|
+
const element = child;
|
|
676
|
+
const nested = findStreamingBoundaryMarker(isHtmlElement(element) && (element.localName || element.tagName).toLowerCase() === "template" && "content" in element ? element.content ?? element : element);
|
|
677
|
+
if (nested) return nested;
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
function parseStreamingBoundaryMarker(node) {
|
|
682
|
+
if (!node || node.nodeType !== COMMENT_NODE) return null;
|
|
683
|
+
const value = node.nodeValue ?? "";
|
|
684
|
+
if (value.startsWith(STREAM_BOUNDARY_START_PREFIX)) {
|
|
685
|
+
const id = value.slice(20);
|
|
686
|
+
return id ? {
|
|
687
|
+
kind: "start",
|
|
688
|
+
id
|
|
689
|
+
} : null;
|
|
690
|
+
}
|
|
691
|
+
if (value.startsWith(STREAM_BOUNDARY_END_PREFIX)) {
|
|
692
|
+
const id = value.slice(18);
|
|
693
|
+
return id ? {
|
|
694
|
+
kind: "end",
|
|
695
|
+
id
|
|
696
|
+
} : null;
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
function serializeText(value, parentElement) {
|
|
701
|
+
const rawTextTagName = getRawTextTagName(parentElement);
|
|
702
|
+
if (rawTextTagName) return escapeRawTextEndTag(value, rawTextTagName);
|
|
703
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
704
|
+
}
|
|
705
|
+
function escapeRawTextEndTag(value, tagName) {
|
|
706
|
+
const endTag = new RegExp(`</${tagName}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
707
|
+
return value.replace(endTag, (match) => `<\\/${match.slice(2)}`);
|
|
708
|
+
}
|
|
709
|
+
function escapeAttributeValue(value) {
|
|
710
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
711
|
+
}
|
|
712
|
+
function serializeComment(value) {
|
|
713
|
+
let safe = value.replace(/-(?=-)/g, "- ").replace(/-$/, "- ");
|
|
714
|
+
if (safe.startsWith(">") || safe.startsWith("->")) safe = ` ${safe}`;
|
|
715
|
+
return `<!--${safe}-->`;
|
|
716
|
+
}
|
|
717
|
+
function serializeDocumentType(doctype) {
|
|
718
|
+
const name = doctype.name || "html";
|
|
719
|
+
assertValidDOMElementName(name, true);
|
|
720
|
+
if (doctype.publicId) {
|
|
721
|
+
const system = doctype.systemId ? ` "${escapeAttributeValue(doctype.systemId)}"` : "";
|
|
722
|
+
return `<!DOCTYPE ${name} PUBLIC "${escapeAttributeValue(doctype.publicId)}"${system}>`;
|
|
723
|
+
}
|
|
724
|
+
if (doctype.systemId) return `<!DOCTYPE ${name} SYSTEM "${escapeAttributeValue(doctype.systemId)}">`;
|
|
725
|
+
return `<!DOCTYPE ${name}>`;
|
|
726
|
+
}
|
|
727
|
+
function isHtmlElement(element) {
|
|
728
|
+
return element.namespaceURI === null || element.namespaceURI === HTML_NAMESPACE;
|
|
729
|
+
}
|
|
730
|
+
function getRawTextTagName(element) {
|
|
731
|
+
if (!element || !isHtmlElement(element)) return null;
|
|
732
|
+
const tagName = (element.localName || element.tagName).toLowerCase();
|
|
733
|
+
return HTML_RAW_TEXT_ELEMENTS.has(tagName) ? tagName : null;
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/stream-bridge.ts
|
|
737
|
+
function createQueuedTextStream(options = {}) {
|
|
738
|
+
const encoder = new TextEncoder();
|
|
739
|
+
const queue = [];
|
|
740
|
+
let controller = null;
|
|
741
|
+
let closed = false;
|
|
742
|
+
let aborted;
|
|
743
|
+
const readyResolvers = [];
|
|
744
|
+
const resolveReady = () => {
|
|
745
|
+
if (!controller || (controller.desiredSize ?? 1) <= 0) return;
|
|
746
|
+
while (readyResolvers.length > 0) readyResolvers.shift()?.();
|
|
747
|
+
};
|
|
748
|
+
const drainReady = () => {
|
|
749
|
+
while (readyResolvers.length > 0) readyResolvers.shift()?.();
|
|
750
|
+
};
|
|
751
|
+
const abortQueue = (reason, notifyController = true) => {
|
|
752
|
+
if (closed || aborted !== void 0) return;
|
|
753
|
+
aborted = reason ?? /* @__PURE__ */ new Error("Stream aborted");
|
|
754
|
+
queue.length = 0;
|
|
755
|
+
drainReady();
|
|
756
|
+
if (notifyController) controller?.error(aborted);
|
|
757
|
+
};
|
|
758
|
+
return {
|
|
759
|
+
stream: new ReadableStream({
|
|
760
|
+
start(ctrl) {
|
|
761
|
+
controller = ctrl;
|
|
762
|
+
for (const chunk of queue) ctrl.enqueue(chunk);
|
|
763
|
+
queue.length = 0;
|
|
764
|
+
if (aborted !== void 0) {
|
|
765
|
+
ctrl.error(aborted);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (closed) ctrl.close();
|
|
769
|
+
},
|
|
770
|
+
pull() {
|
|
771
|
+
resolveReady();
|
|
772
|
+
},
|
|
773
|
+
cancel(reason) {
|
|
774
|
+
abortQueue(reason, false);
|
|
775
|
+
options.onCancel?.(reason);
|
|
776
|
+
}
|
|
777
|
+
}),
|
|
778
|
+
writer: {
|
|
779
|
+
write(chunk) {
|
|
780
|
+
if (closed || aborted !== void 0) return;
|
|
781
|
+
const data = encoder.encode(chunk);
|
|
782
|
+
if (controller) {
|
|
783
|
+
controller.enqueue(data);
|
|
784
|
+
if ((controller.desiredSize ?? 1) <= 0) return new Promise((resolve) => {
|
|
785
|
+
readyResolvers.push(resolve);
|
|
786
|
+
});
|
|
787
|
+
} else queue.push(data);
|
|
788
|
+
},
|
|
789
|
+
close() {
|
|
790
|
+
if (closed || aborted !== void 0) return;
|
|
791
|
+
closed = true;
|
|
792
|
+
drainReady();
|
|
793
|
+
controller?.close();
|
|
794
|
+
},
|
|
795
|
+
abort(reason) {
|
|
796
|
+
abortQueue(reason);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function createPipeBridge() {
|
|
802
|
+
const nodeBridge = createNodePipeBridge();
|
|
803
|
+
if (nodeBridge) return nodeBridge;
|
|
804
|
+
const targets = /* @__PURE__ */ new Set();
|
|
805
|
+
const buffer = [];
|
|
806
|
+
let state = "open";
|
|
807
|
+
let abortReason = null;
|
|
808
|
+
let sinkErrorHandler;
|
|
809
|
+
const safeWrite = (target, chunk) => {
|
|
810
|
+
try {
|
|
811
|
+
if (target.write(chunk) === false) return new Promise((resolve) => {
|
|
812
|
+
const withOnce = target;
|
|
813
|
+
if (typeof withOnce.once === "function") withOnce.once("drain", resolve);
|
|
814
|
+
else resolve();
|
|
815
|
+
});
|
|
816
|
+
} catch (error) {
|
|
817
|
+
sinkErrorHandler?.(error);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const safeEnd = (target) => {
|
|
821
|
+
try {
|
|
822
|
+
target.end();
|
|
823
|
+
} catch {}
|
|
824
|
+
};
|
|
825
|
+
const safeDestroy = (target, reason) => {
|
|
826
|
+
const withDestroy = target;
|
|
827
|
+
if (typeof withDestroy.destroy === "function") {
|
|
828
|
+
try {
|
|
829
|
+
withDestroy.destroy(reason);
|
|
830
|
+
} catch {}
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
safeEnd(target);
|
|
834
|
+
};
|
|
835
|
+
return {
|
|
836
|
+
pipe(writable, options) {
|
|
837
|
+
targets.add(writable);
|
|
838
|
+
if (options?.onError) {
|
|
839
|
+
sinkErrorHandler = options.onError;
|
|
840
|
+
if (typeof writable.on === "function") writable.on("error", options.onError);
|
|
841
|
+
}
|
|
842
|
+
if (buffer.length > 0) {
|
|
843
|
+
for (const chunk of buffer) safeWrite(writable, chunk);
|
|
844
|
+
buffer.length = 0;
|
|
845
|
+
}
|
|
846
|
+
if (state === "closed") safeEnd(writable);
|
|
847
|
+
else if (state === "aborted") safeDestroy(writable, abortReason ?? /* @__PURE__ */ new Error("Stream aborted"));
|
|
848
|
+
},
|
|
849
|
+
write(chunk) {
|
|
850
|
+
if (state !== "open") return;
|
|
851
|
+
if (targets.size === 0) {
|
|
852
|
+
buffer.push(chunk);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const pending = [];
|
|
856
|
+
for (const target of targets) {
|
|
857
|
+
const result = safeWrite(target, chunk);
|
|
858
|
+
if (result) pending.push(result);
|
|
859
|
+
}
|
|
860
|
+
return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
|
|
861
|
+
},
|
|
862
|
+
close() {
|
|
863
|
+
if (state !== "open") return;
|
|
864
|
+
state = "closed";
|
|
865
|
+
for (const target of targets) safeEnd(target);
|
|
866
|
+
if (targets.size > 0) buffer.length = 0;
|
|
867
|
+
},
|
|
868
|
+
abort(reason) {
|
|
869
|
+
if (state !== "open") return;
|
|
870
|
+
state = "aborted";
|
|
871
|
+
abortReason = reason instanceof Error ? reason : /* @__PURE__ */ new Error("Stream aborted");
|
|
872
|
+
for (const target of targets) safeDestroy(target, abortReason);
|
|
873
|
+
buffer.length = 0;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function createNodePipeBridge() {
|
|
878
|
+
const nodeRequire = getNodeRequire();
|
|
879
|
+
if (!nodeRequire) return null;
|
|
880
|
+
try {
|
|
881
|
+
const streamModule = nodeRequire("node:stream");
|
|
882
|
+
if (!streamModule.PassThrough) return null;
|
|
883
|
+
const passThrough = new streamModule.PassThrough();
|
|
884
|
+
passThrough.on?.("error", () => {});
|
|
885
|
+
const buffer = [];
|
|
886
|
+
let piped = false;
|
|
887
|
+
let state = "open";
|
|
888
|
+
let abortReason = null;
|
|
889
|
+
const pendingDrains = /* @__PURE__ */ new Set();
|
|
890
|
+
const flushDrains = () => {
|
|
891
|
+
for (const resolve of pendingDrains) resolve();
|
|
892
|
+
pendingDrains.clear();
|
|
893
|
+
};
|
|
894
|
+
const writeToPassThrough = (chunk) => {
|
|
895
|
+
if (passThrough.write(chunk) === false) return new Promise((resolve) => {
|
|
896
|
+
const settle = () => {
|
|
897
|
+
pendingDrains.delete(settle);
|
|
898
|
+
resolve();
|
|
899
|
+
};
|
|
900
|
+
pendingDrains.add(settle);
|
|
901
|
+
const withOnce = passThrough;
|
|
902
|
+
if (typeof withOnce.once === "function") withOnce.once("drain", settle);
|
|
903
|
+
else settle();
|
|
904
|
+
});
|
|
905
|
+
};
|
|
906
|
+
const flushBuffer = () => {
|
|
907
|
+
if (buffer.length === 0) return void 0;
|
|
908
|
+
const pending = [];
|
|
909
|
+
for (const chunk of buffer) {
|
|
910
|
+
const result = writeToPassThrough(chunk);
|
|
911
|
+
if (result) pending.push(result);
|
|
912
|
+
}
|
|
913
|
+
buffer.length = 0;
|
|
914
|
+
return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
|
|
915
|
+
};
|
|
916
|
+
const destroyPassThrough = (error) => {
|
|
917
|
+
if (typeof passThrough.destroy === "function") passThrough.destroy(error);
|
|
918
|
+
else passThrough.end();
|
|
919
|
+
};
|
|
920
|
+
return {
|
|
921
|
+
pipe(writable, options) {
|
|
922
|
+
piped = true;
|
|
923
|
+
passThrough.pipe(writable);
|
|
924
|
+
const onError = options?.onError;
|
|
925
|
+
if (onError && typeof writable.on === "function") writable.on("error", (err) => {
|
|
926
|
+
flushDrains();
|
|
927
|
+
onError(err);
|
|
928
|
+
});
|
|
929
|
+
if (state === "aborted") {
|
|
930
|
+
destroyPassThrough(abortReason ?? /* @__PURE__ */ new Error("Stream aborted"));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const flushed = flushBuffer();
|
|
934
|
+
if (state === "closed") if (flushed) flushed.then(() => passThrough.end());
|
|
935
|
+
else passThrough.end();
|
|
936
|
+
},
|
|
937
|
+
write(chunk) {
|
|
938
|
+
if (state !== "open") return;
|
|
939
|
+
if (!piped) {
|
|
940
|
+
buffer.push(chunk);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
return writeToPassThrough(chunk);
|
|
944
|
+
},
|
|
945
|
+
close() {
|
|
946
|
+
if (state !== "open") return;
|
|
947
|
+
state = "closed";
|
|
948
|
+
if (!piped) return;
|
|
949
|
+
passThrough.end();
|
|
950
|
+
},
|
|
951
|
+
abort(reason) {
|
|
952
|
+
if (state !== "open") return;
|
|
953
|
+
state = "aborted";
|
|
954
|
+
abortReason = reason instanceof Error ? reason : /* @__PURE__ */ new Error("Stream aborted");
|
|
955
|
+
buffer.length = 0;
|
|
956
|
+
flushDrains();
|
|
957
|
+
if (piped) destroyPassThrough(abortReason);
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region src/render-core.ts
|
|
966
|
+
const DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
|
|
967
|
+
const SVG_HTML_INTEGRATION_POINTS = /* @__PURE__ */ new Set([
|
|
968
|
+
"foreignobject",
|
|
969
|
+
"title",
|
|
970
|
+
"desc"
|
|
971
|
+
]);
|
|
972
|
+
const MATHML_TEXT_INTEGRATION_POINTS = /* @__PURE__ */ new Set([
|
|
973
|
+
"mi",
|
|
974
|
+
"mo",
|
|
975
|
+
"mn",
|
|
976
|
+
"ms",
|
|
977
|
+
"mtext"
|
|
978
|
+
]);
|
|
979
|
+
const IDENTIFIER_PREFIX_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
980
|
+
let streamTailMarkerId = 0;
|
|
981
|
+
let scopeIdentifierSequence = 0;
|
|
982
|
+
let streamIdentifierSequence = 0;
|
|
983
|
+
const ssrIdentifierSeed = createIdentifierSeed();
|
|
984
|
+
function createSSRDocument(html = DEFAULT_HTML) {
|
|
985
|
+
const window = parseHTML(html);
|
|
986
|
+
const document = window.document;
|
|
987
|
+
if (!window || !document) throw new Error("[fict/ssr] Failed to create DOM. Missing window or document.");
|
|
988
|
+
return {
|
|
989
|
+
window,
|
|
990
|
+
document
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function renderToDocument(view, options = {}) {
|
|
994
|
+
return __fictRunWithSSRSession(__fictCreateSSRSession(), () => renderToDocumentInSession(view, options));
|
|
995
|
+
}
|
|
996
|
+
function renderToDocumentInSession(view, options) {
|
|
997
|
+
validateScopeIdentifierPrefix(options.scopeIdentifierPrefix);
|
|
998
|
+
const scopeIdentifierPrefix = resolveScopeIdentifierPrefix(options.scopeIdentifierPrefix);
|
|
999
|
+
const includeSnapshot = options.includeSnapshot !== false;
|
|
1000
|
+
__fictEnableSSR();
|
|
1001
|
+
__fictSetSSRScopeIdentifierPrefix(scopeIdentifierPrefix);
|
|
1002
|
+
let dom;
|
|
1003
|
+
let restoreGlobals = () => {};
|
|
1004
|
+
let restoreManifest = () => {};
|
|
1005
|
+
let container;
|
|
1006
|
+
let teardown = () => {};
|
|
1007
|
+
try {
|
|
1008
|
+
dom = resolveDom(options);
|
|
1009
|
+
const { document, window } = dom;
|
|
1010
|
+
restoreGlobals = options.exposeGlobals === true ? installGlobals(window, document) : () => {};
|
|
1011
|
+
restoreManifest = installManifest(options.manifest);
|
|
1012
|
+
container = resolveContainer(document, options);
|
|
1013
|
+
teardown = render(view, container);
|
|
1014
|
+
if (includeSnapshot) {
|
|
1015
|
+
const state = __fictSerializeSSRState();
|
|
1016
|
+
injectSnapshot(document, container, state, options);
|
|
1017
|
+
}
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
__fictDisableSSR();
|
|
1020
|
+
cleanupRenderResources(teardown, restoreGlobals, restoreManifest, true);
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
__fictDisableSSR();
|
|
1024
|
+
let html;
|
|
1025
|
+
try {
|
|
1026
|
+
html = serializeOutput(dom.document, container, options);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
cleanupRenderResources(teardown, restoreGlobals, restoreManifest, true);
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
const dispose = () => cleanupRenderResources(teardown, restoreGlobals, restoreManifest, false);
|
|
1032
|
+
return {
|
|
1033
|
+
html,
|
|
1034
|
+
document: dom.document,
|
|
1035
|
+
window: dom.window,
|
|
1036
|
+
container,
|
|
1037
|
+
dispose
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
function renderToString(view, options = {}) {
|
|
1041
|
+
const result = renderToDocument(view, options);
|
|
1042
|
+
const html = result.html;
|
|
1043
|
+
result.dispose();
|
|
1044
|
+
return html;
|
|
1045
|
+
}
|
|
1046
|
+
async function renderToStringAsync(view, options = {}) {
|
|
1047
|
+
let html = "";
|
|
1048
|
+
const renderResult = startStreamingRender(view, {
|
|
1049
|
+
...options,
|
|
1050
|
+
mode: "all",
|
|
1051
|
+
fullDocument: options.fullDocument ?? false
|
|
1052
|
+
}, {
|
|
1053
|
+
write(chunk) {
|
|
1054
|
+
html += chunk;
|
|
1055
|
+
},
|
|
1056
|
+
close() {},
|
|
1057
|
+
abort() {}
|
|
1058
|
+
});
|
|
1059
|
+
await Promise.all([renderResult.shellReady, renderResult.allReady]);
|
|
1060
|
+
return html;
|
|
1061
|
+
}
|
|
1062
|
+
function renderToStream(view, options = {}) {
|
|
1063
|
+
validateScopeIdentifierPrefix(options.scopeIdentifierPrefix);
|
|
1064
|
+
validateStreamIdentifierPrefix(options.streamIdentifierPrefix);
|
|
1065
|
+
const encoder = new TextEncoder();
|
|
1066
|
+
let controller = null;
|
|
1067
|
+
let abortRender = null;
|
|
1068
|
+
const readyResolvers = [];
|
|
1069
|
+
const drainBackpressure = () => {
|
|
1070
|
+
while (readyResolvers.length > 0) readyResolvers.shift()?.();
|
|
1071
|
+
};
|
|
1072
|
+
const resolveBackpressure = () => {
|
|
1073
|
+
if (!controller || (controller.desiredSize ?? 1) <= 0) return;
|
|
1074
|
+
drainBackpressure();
|
|
1075
|
+
};
|
|
1076
|
+
const closeController = () => {
|
|
1077
|
+
abortRender = null;
|
|
1078
|
+
drainBackpressure();
|
|
1079
|
+
if (!controller) return;
|
|
1080
|
+
try {
|
|
1081
|
+
controller.close();
|
|
1082
|
+
} finally {
|
|
1083
|
+
controller = null;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
const errorController = (reason) => {
|
|
1087
|
+
abortRender = null;
|
|
1088
|
+
drainBackpressure();
|
|
1089
|
+
if (!controller) return;
|
|
1090
|
+
try {
|
|
1091
|
+
controller.error(reason);
|
|
1092
|
+
} finally {
|
|
1093
|
+
controller = null;
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
return new ReadableStream({
|
|
1097
|
+
start(ctrl) {
|
|
1098
|
+
controller = ctrl;
|
|
1099
|
+
const started = startStreamingRender(view, options, {
|
|
1100
|
+
write(chunk) {
|
|
1101
|
+
if (!controller) return;
|
|
1102
|
+
controller.enqueue(encoder.encode(chunk));
|
|
1103
|
+
if ((controller.desiredSize ?? 1) <= 0) return new Promise((resolve) => {
|
|
1104
|
+
readyResolvers.push(resolve);
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
close() {
|
|
1108
|
+
closeController();
|
|
1109
|
+
},
|
|
1110
|
+
abort(reason) {
|
|
1111
|
+
errorController(reason);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
abortRender = started.abort;
|
|
1115
|
+
started.shellReady.catch(() => void 0);
|
|
1116
|
+
started.allReady.catch(() => void 0);
|
|
1117
|
+
},
|
|
1118
|
+
pull() {
|
|
1119
|
+
resolveBackpressure();
|
|
1120
|
+
},
|
|
1121
|
+
cancel(reason) {
|
|
1122
|
+
const abort = abortRender;
|
|
1123
|
+
controller = null;
|
|
1124
|
+
drainBackpressure();
|
|
1125
|
+
abort?.(reason ?? /* @__PURE__ */ new Error("Stream canceled"));
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
function renderToPipeableStream(view, options = {}) {
|
|
1130
|
+
const bridge = createPipeBridge();
|
|
1131
|
+
const { shellReady, allReady, abort } = startStreamingRender(view, options, {
|
|
1132
|
+
write(chunk) {
|
|
1133
|
+
return bridge.write(chunk);
|
|
1134
|
+
},
|
|
1135
|
+
close() {
|
|
1136
|
+
bridge.close();
|
|
1137
|
+
},
|
|
1138
|
+
abort(reason) {
|
|
1139
|
+
bridge.abort(reason);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
return {
|
|
1143
|
+
pipe(writable) {
|
|
1144
|
+
bridge.pipe(writable, { onError: abort });
|
|
1145
|
+
},
|
|
1146
|
+
abort,
|
|
1147
|
+
shellReady,
|
|
1148
|
+
allReady
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* @experimental Preview API for v1.0; the return shape may change before this
|
|
1153
|
+
* becomes stable.
|
|
1154
|
+
*/
|
|
1155
|
+
function renderToPartial(view, options = {}) {
|
|
1156
|
+
const partialOptions = {
|
|
1157
|
+
...options,
|
|
1158
|
+
mode: "shell",
|
|
1159
|
+
fullDocument: options.fullDocument ?? true
|
|
1160
|
+
};
|
|
1161
|
+
let shell = "";
|
|
1162
|
+
let shellPhase = true;
|
|
1163
|
+
let abortPartial = null;
|
|
1164
|
+
const queued = createQueuedTextStream({ onCancel(reason) {
|
|
1165
|
+
abortPartial?.(reason ?? /* @__PURE__ */ new Error("Stream canceled"));
|
|
1166
|
+
} });
|
|
1167
|
+
const { shellReady, allReady, abort } = startStreamingRender(view, partialOptions, {
|
|
1168
|
+
write(chunk) {
|
|
1169
|
+
if (shellPhase) {
|
|
1170
|
+
shell += chunk;
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
return queued.writer.write(chunk);
|
|
1174
|
+
},
|
|
1175
|
+
close() {
|
|
1176
|
+
queued.writer.close();
|
|
1177
|
+
},
|
|
1178
|
+
abort(reason) {
|
|
1179
|
+
queued.writer.abort(reason);
|
|
1180
|
+
}
|
|
1181
|
+
}, {
|
|
1182
|
+
includeTailInShell: true,
|
|
1183
|
+
onShellFlushed() {
|
|
1184
|
+
shellPhase = false;
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
abortPartial = abort;
|
|
1188
|
+
return {
|
|
1189
|
+
shell,
|
|
1190
|
+
stream: queued.stream,
|
|
1191
|
+
shellReady,
|
|
1192
|
+
allReady,
|
|
1193
|
+
abort
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
function resolveDom(options) {
|
|
1197
|
+
if (options.dom) return options.dom;
|
|
1198
|
+
if (options.document && options.window) return {
|
|
1199
|
+
document: options.document,
|
|
1200
|
+
window: options.window
|
|
1201
|
+
};
|
|
1202
|
+
if (options.document) {
|
|
1203
|
+
const window = options.window ?? options.document.defaultView ?? options.document.defaultView ?? void 0;
|
|
1204
|
+
if (!window) throw new Error("[fict/ssr] A window is required when providing a document without defaultView.");
|
|
1205
|
+
return {
|
|
1206
|
+
document: options.document,
|
|
1207
|
+
window
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
if (options.window) return {
|
|
1211
|
+
document: options.window.document,
|
|
1212
|
+
window: options.window
|
|
1213
|
+
};
|
|
1214
|
+
return createSSRDocument(options.html);
|
|
1215
|
+
}
|
|
1216
|
+
function isPromiseLike(value) {
|
|
1217
|
+
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
1218
|
+
}
|
|
1219
|
+
function cleanupRenderResources(teardown, restoreGlobals, restoreManifest, suppressErrors) {
|
|
1220
|
+
let failed = false;
|
|
1221
|
+
let firstError;
|
|
1222
|
+
for (const cleanup of [
|
|
1223
|
+
teardown,
|
|
1224
|
+
restoreGlobals,
|
|
1225
|
+
restoreManifest
|
|
1226
|
+
]) try {
|
|
1227
|
+
cleanup();
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (!failed) {
|
|
1230
|
+
failed = true;
|
|
1231
|
+
firstError = error;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (failed && !suppressErrors) throw firstError;
|
|
1235
|
+
}
|
|
1236
|
+
function startStreamingRender(view, options, writer, control = {}) {
|
|
1237
|
+
const session = __fictCreateSSRSession();
|
|
1238
|
+
const releaseSession = __fictRetainSSRSession();
|
|
1239
|
+
try {
|
|
1240
|
+
return __fictRunWithSSRSession(session, () => startStreamingRenderInSession(session, view, options, writer, releaseSession, control));
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
releaseSession();
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function startStreamingRenderInSession(session, view, options, writer, releaseSession, control = {}) {
|
|
1247
|
+
const runInSession = (fn) => __fictRunWithSSRSession(session, fn);
|
|
1248
|
+
const resolvedOptions = {
|
|
1249
|
+
...options,
|
|
1250
|
+
fullDocument: options.fullDocument ?? true
|
|
1251
|
+
};
|
|
1252
|
+
let resolveShell;
|
|
1253
|
+
let rejectShell;
|
|
1254
|
+
let resolveAll;
|
|
1255
|
+
let rejectAll;
|
|
1256
|
+
let shellSettled = false;
|
|
1257
|
+
const shellReady = new Promise((res, rej) => {
|
|
1258
|
+
resolveShell = () => {
|
|
1259
|
+
if (shellSettled) return;
|
|
1260
|
+
shellSettled = true;
|
|
1261
|
+
res();
|
|
1262
|
+
};
|
|
1263
|
+
rejectShell = (err) => {
|
|
1264
|
+
if (shellSettled) return;
|
|
1265
|
+
shellSettled = true;
|
|
1266
|
+
rej(err);
|
|
1267
|
+
};
|
|
1268
|
+
});
|
|
1269
|
+
const allReady = new Promise((res, rej) => {
|
|
1270
|
+
resolveAll = res;
|
|
1271
|
+
rejectAll = rej;
|
|
1272
|
+
});
|
|
1273
|
+
let dom = null;
|
|
1274
|
+
let restoreGlobals = () => {};
|
|
1275
|
+
let restoreManifest = () => {};
|
|
1276
|
+
let teardown = () => {};
|
|
1277
|
+
let container = null;
|
|
1278
|
+
let closed = false;
|
|
1279
|
+
let tailHtml = "";
|
|
1280
|
+
let wroteShell = false;
|
|
1281
|
+
let shellCarriesTail = false;
|
|
1282
|
+
let writeChain = null;
|
|
1283
|
+
let writeFailed = false;
|
|
1284
|
+
let failureReported = false;
|
|
1285
|
+
let cleaned = false;
|
|
1286
|
+
let removeAbortListener = () => {};
|
|
1287
|
+
let canFinalize = false;
|
|
1288
|
+
validateScopeIdentifierPrefix(options.scopeIdentifierPrefix);
|
|
1289
|
+
validateStreamIdentifierPrefix(options.streamIdentifierPrefix);
|
|
1290
|
+
const mode = options.mode ?? "shell";
|
|
1291
|
+
const scopeIdentifierPrefix = resolveScopeIdentifierPrefix(options.scopeIdentifierPrefix);
|
|
1292
|
+
const streamIdentifierPrefix = mode === "shell" ? resolveStreamIdentifierPrefix(options.streamIdentifierPrefix) : null;
|
|
1293
|
+
const includeSnapshot = options.includeSnapshot !== false;
|
|
1294
|
+
const sentScopeSnapshots = /* @__PURE__ */ new Map();
|
|
1295
|
+
const boundaryMap = /* @__PURE__ */ new Map();
|
|
1296
|
+
let boundaryId = 0;
|
|
1297
|
+
let pendingCount = 0;
|
|
1298
|
+
const handleWriteError = (error) => {
|
|
1299
|
+
if (writeFailed) return;
|
|
1300
|
+
reportErrorAndAbort(error);
|
|
1301
|
+
};
|
|
1302
|
+
const enqueueWrite = (chunk) => {
|
|
1303
|
+
if (writeFailed) return;
|
|
1304
|
+
const trackWrite = (promise) => {
|
|
1305
|
+
const tracked = promise.then(() => {
|
|
1306
|
+
if (writeChain === tracked) writeChain = null;
|
|
1307
|
+
}, (error) => {
|
|
1308
|
+
if (writeChain === tracked) writeChain = null;
|
|
1309
|
+
handleWriteError(error);
|
|
1310
|
+
});
|
|
1311
|
+
writeChain = tracked;
|
|
1312
|
+
};
|
|
1313
|
+
const writeAsync = () => Promise.resolve(writer.write(chunk)).then(() => void 0);
|
|
1314
|
+
if (writeChain) {
|
|
1315
|
+
trackWrite(writeChain.then(writeAsync));
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
const result = writer.write(chunk);
|
|
1320
|
+
if (isPromiseLike(result)) trackWrite(result.then(() => void 0, (error) => {
|
|
1321
|
+
throw error;
|
|
1322
|
+
}));
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
handleWriteError(error);
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
const afterWrites = (fn) => {
|
|
1328
|
+
const pending = writeChain;
|
|
1329
|
+
if (!pending) {
|
|
1330
|
+
if (!writeFailed) fn();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
pending.then(() => {
|
|
1334
|
+
if (!writeFailed) fn();
|
|
1335
|
+
});
|
|
1336
|
+
};
|
|
1337
|
+
const markShellReady = () => {
|
|
1338
|
+
afterWrites(() => {
|
|
1339
|
+
try {
|
|
1340
|
+
control.onShellFlushed?.();
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
reportErrorAndAbort(error);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
resolveShell();
|
|
1346
|
+
callLifecycleCallback(options.onShellReady);
|
|
1347
|
+
});
|
|
1348
|
+
};
|
|
1349
|
+
const writeSnapshotForScopes = (scopeIds) => {
|
|
1350
|
+
runInSession(() => {
|
|
1351
|
+
if (!includeSnapshot || scopeIds.length === 0) return;
|
|
1352
|
+
const registry = __fictGetScopeRegistry();
|
|
1353
|
+
const pending = Array.from(new Set(scopeIds)).filter((id) => registry.has(id));
|
|
1354
|
+
if (pending.length === 0) return;
|
|
1355
|
+
const snapshot = __fictSerializeSSRStateForScopes(pending);
|
|
1356
|
+
const changedScopes = Object.create(null);
|
|
1357
|
+
const changedSignatures = /* @__PURE__ */ new Map();
|
|
1358
|
+
for (const [id, scope] of Object.entries(snapshot.scopes)) {
|
|
1359
|
+
const signature = JSON.stringify(scope);
|
|
1360
|
+
if (sentScopeSnapshots.get(id) === signature) continue;
|
|
1361
|
+
changedScopes[id] = scope;
|
|
1362
|
+
changedSignatures.set(id, signature);
|
|
1363
|
+
}
|
|
1364
|
+
const ids = Object.keys(changedScopes);
|
|
1365
|
+
if (ids.length === 0) return;
|
|
1366
|
+
const chunk = buildIncrementalSnapshotChunk({
|
|
1367
|
+
...snapshot,
|
|
1368
|
+
scopes: changedScopes
|
|
1369
|
+
}, resolvedOptions);
|
|
1370
|
+
if (chunk) enqueueWrite(chunk);
|
|
1371
|
+
for (const id of ids) sentScopeSnapshots.set(id, changedSignatures.get(id));
|
|
1372
|
+
});
|
|
1373
|
+
};
|
|
1374
|
+
const writeSnapshotForBoundary = (start, end) => {
|
|
1375
|
+
runInSession(() => {
|
|
1376
|
+
const scopes = collectPatchScopeIds(start, end);
|
|
1377
|
+
writeSnapshotForScopes(scopes);
|
|
1378
|
+
});
|
|
1379
|
+
};
|
|
1380
|
+
const writeRemainingSnapshots = () => {
|
|
1381
|
+
runInSession(() => {
|
|
1382
|
+
const scopes = Array.from(__fictGetScopeRegistry().keys());
|
|
1383
|
+
writeSnapshotForScopes(scopes);
|
|
1384
|
+
});
|
|
1385
|
+
};
|
|
1386
|
+
const cleanup = () => {
|
|
1387
|
+
if (cleaned) return;
|
|
1388
|
+
cleaned = true;
|
|
1389
|
+
try {
|
|
1390
|
+
removeAbortListener();
|
|
1391
|
+
} catch {}
|
|
1392
|
+
removeAbortListener = () => {};
|
|
1393
|
+
try {
|
|
1394
|
+
runInSession(() => {
|
|
1395
|
+
__fictSetSSRStreamHooks(null);
|
|
1396
|
+
__fictDisableSSR();
|
|
1397
|
+
});
|
|
1398
|
+
} catch {}
|
|
1399
|
+
try {
|
|
1400
|
+
cleanupRenderResources(teardown, restoreGlobals, restoreManifest, true);
|
|
1401
|
+
} finally {
|
|
1402
|
+
releaseSession();
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
const reportErrorAndAbort = (error) => {
|
|
1406
|
+
if (failureReported) {
|
|
1407
|
+
abort(error);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
failureReported = true;
|
|
1411
|
+
let abortReason = error;
|
|
1412
|
+
try {
|
|
1413
|
+
options.onError?.(error);
|
|
1414
|
+
} catch (onErrorFailure) {
|
|
1415
|
+
abortReason = onErrorFailure;
|
|
1416
|
+
}
|
|
1417
|
+
abort(abortReason);
|
|
1418
|
+
};
|
|
1419
|
+
const callLifecycleCallback = (callback) => {
|
|
1420
|
+
try {
|
|
1421
|
+
callback?.();
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
reportErrorAndAbort(error);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
const finalize = () => {
|
|
1427
|
+
if (closed) return;
|
|
1428
|
+
if (mode === "all" && dom && container && !wroteShell) {
|
|
1429
|
+
let fullHtml;
|
|
1430
|
+
try {
|
|
1431
|
+
if (includeSnapshot) {
|
|
1432
|
+
const snapshot = __fictSerializeSSRState();
|
|
1433
|
+
injectSnapshot(dom.document, container, snapshot, resolvedOptions);
|
|
1434
|
+
}
|
|
1435
|
+
fullHtml = serializeOutput(dom.document, container, resolvedOptions);
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
reportErrorAndAbort(error);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
closed = true;
|
|
1441
|
+
enqueueWrite(fullHtml);
|
|
1442
|
+
afterWrites(() => {
|
|
1443
|
+
try {
|
|
1444
|
+
writer.close();
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
reportErrorAndAbort(error);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
cleanup();
|
|
1450
|
+
resolveShell();
|
|
1451
|
+
resolveAll();
|
|
1452
|
+
callLifecycleCallback(options.onShellReady);
|
|
1453
|
+
callLifecycleCallback(options.onAllReady);
|
|
1454
|
+
});
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
writeRemainingSnapshots();
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
reportErrorAndAbort(error);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (writeFailed) return;
|
|
1464
|
+
closed = true;
|
|
1465
|
+
if (tailHtml) enqueueWrite(tailHtml);
|
|
1466
|
+
afterWrites(() => {
|
|
1467
|
+
try {
|
|
1468
|
+
writer.close();
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
reportErrorAndAbort(error);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
cleanup();
|
|
1474
|
+
resolveAll();
|
|
1475
|
+
callLifecycleCallback(options.onAllReady);
|
|
1476
|
+
});
|
|
1477
|
+
};
|
|
1478
|
+
const maybeFinalize = () => {
|
|
1479
|
+
if (canFinalize && pendingCount === 0) finalize();
|
|
1480
|
+
};
|
|
1481
|
+
const registerStreamBoundary = (start, end) => {
|
|
1482
|
+
const localId = `s${++boundaryId}`;
|
|
1483
|
+
const id = streamIdentifierPrefix ? `${streamIdentifierPrefix}:${localId}` : localId;
|
|
1484
|
+
boundaryMap.set(id, {
|
|
1485
|
+
start,
|
|
1486
|
+
end,
|
|
1487
|
+
pending: false
|
|
1488
|
+
});
|
|
1489
|
+
return id;
|
|
1490
|
+
};
|
|
1491
|
+
const hooks = {
|
|
1492
|
+
registerBoundary: registerStreamBoundary,
|
|
1493
|
+
registerErrorBoundary: registerStreamBoundary,
|
|
1494
|
+
boundaryPending(id) {
|
|
1495
|
+
const entry = boundaryMap.get(id);
|
|
1496
|
+
if (!entry || entry.pending) return;
|
|
1497
|
+
entry.pending = true;
|
|
1498
|
+
pendingCount++;
|
|
1499
|
+
},
|
|
1500
|
+
boundaryResolved(id) {
|
|
1501
|
+
const entry = boundaryMap.get(id);
|
|
1502
|
+
if (!entry) return;
|
|
1503
|
+
if (entry.pending) {
|
|
1504
|
+
entry.pending = false;
|
|
1505
|
+
pendingCount = Math.max(0, pendingCount - 1);
|
|
1506
|
+
}
|
|
1507
|
+
if (mode === "shell" && wroteShell) try {
|
|
1508
|
+
if (dom) {
|
|
1509
|
+
writeSnapshotForBoundary(entry.start, entry.end);
|
|
1510
|
+
const content = serializeBetween(entry.start, entry.end);
|
|
1511
|
+
enqueueWrite(buildPatchChunk(id, content, resolvedOptions));
|
|
1512
|
+
}
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
reportErrorAndAbort(error);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
maybeFinalize();
|
|
1518
|
+
},
|
|
1519
|
+
boundaryAbandoned(id) {
|
|
1520
|
+
const entry = boundaryMap.get(id);
|
|
1521
|
+
if (!entry) return;
|
|
1522
|
+
boundaryMap.delete(id);
|
|
1523
|
+
if (entry.pending) pendingCount = Math.max(0, pendingCount - 1);
|
|
1524
|
+
maybeFinalize();
|
|
1525
|
+
},
|
|
1526
|
+
onError(err) {
|
|
1527
|
+
reportErrorAndAbort(err);
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
const abort = (reason) => {
|
|
1531
|
+
const abortReason = reason ?? /* @__PURE__ */ new Error("Stream aborted");
|
|
1532
|
+
if (!cleaned) {
|
|
1533
|
+
closed = true;
|
|
1534
|
+
writeFailed = true;
|
|
1535
|
+
cleanup();
|
|
1536
|
+
try {
|
|
1537
|
+
writer.abort(abortReason);
|
|
1538
|
+
} catch {}
|
|
1539
|
+
}
|
|
1540
|
+
rejectShell(abortReason);
|
|
1541
|
+
rejectAll(abortReason);
|
|
1542
|
+
};
|
|
1543
|
+
if (options.signal) if (options.signal.aborted) {
|
|
1544
|
+
abort(options.signal.reason);
|
|
1545
|
+
return {
|
|
1546
|
+
shellReady,
|
|
1547
|
+
allReady,
|
|
1548
|
+
abort
|
|
1549
|
+
};
|
|
1550
|
+
} else {
|
|
1551
|
+
const onAbort = () => abort(options.signal?.reason);
|
|
1552
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1553
|
+
removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
__fictEnableSSR();
|
|
1557
|
+
__fictSetSSRScopeIdentifierPrefix(scopeIdentifierPrefix);
|
|
1558
|
+
__fictSetSSRStreamHooks(hooks);
|
|
1559
|
+
dom = resolveDom(resolvedOptions);
|
|
1560
|
+
restoreGlobals = resolvedOptions.exposeGlobals === true ? installGlobals(dom.window, dom.document) : () => {};
|
|
1561
|
+
restoreManifest = installManifest(resolvedOptions.manifest);
|
|
1562
|
+
container = resolveContainer(dom.document, resolvedOptions);
|
|
1563
|
+
teardown = render(view, container);
|
|
1564
|
+
if (mode === "all") {
|
|
1565
|
+
canFinalize = true;
|
|
1566
|
+
maybeFinalize();
|
|
1567
|
+
return {
|
|
1568
|
+
shellReady,
|
|
1569
|
+
allReady,
|
|
1570
|
+
abort
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript(resolvedOptions) : "";
|
|
1574
|
+
if (resolvedOptions.fullDocument) {
|
|
1575
|
+
const split = serializeStreamingDocument(dom.document, container, resolvedOptions);
|
|
1576
|
+
if (!split) throw new Error("[fict/ssr] Failed to locate the document body for streaming output.");
|
|
1577
|
+
if (control.includeTailInShell) {
|
|
1578
|
+
enqueueWrite(split.head + streamRuntime);
|
|
1579
|
+
tailHtml = split.tail;
|
|
1580
|
+
shellCarriesTail = true;
|
|
1581
|
+
} else {
|
|
1582
|
+
enqueueWrite(split.head + streamRuntime);
|
|
1583
|
+
tailHtml = split.tail;
|
|
1584
|
+
}
|
|
1585
|
+
} else enqueueWrite(serializeOutput(dom.document, container, resolvedOptions) + streamRuntime);
|
|
1586
|
+
if (writeFailed) return {
|
|
1587
|
+
shellReady,
|
|
1588
|
+
allReady,
|
|
1589
|
+
abort
|
|
1590
|
+
};
|
|
1591
|
+
wroteShell = true;
|
|
1592
|
+
writeSnapshotForScopes(Array.from(__fictGetScopeRegistry().keys()));
|
|
1593
|
+
if (shellCarriesTail && tailHtml) {
|
|
1594
|
+
enqueueWrite(tailHtml);
|
|
1595
|
+
tailHtml = "";
|
|
1596
|
+
shellCarriesTail = false;
|
|
1597
|
+
}
|
|
1598
|
+
markShellReady();
|
|
1599
|
+
canFinalize = true;
|
|
1600
|
+
maybeFinalize();
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
reportErrorAndAbort(err);
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
shellReady,
|
|
1606
|
+
allReady,
|
|
1607
|
+
abort
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function resolveContainer(document, options) {
|
|
1611
|
+
if (options.container) {
|
|
1612
|
+
if (options.container.ownerDocument && options.container.ownerDocument !== document) throw new Error("[fict/ssr] Provided container belongs to a different document.");
|
|
1613
|
+
return options.container;
|
|
1614
|
+
}
|
|
1615
|
+
const tag = options.containerTag ?? "div";
|
|
1616
|
+
assertValidDOMElementName(tag);
|
|
1617
|
+
const container = document.createElement(tag);
|
|
1618
|
+
if (options.containerId) container.setAttribute("id", options.containerId);
|
|
1619
|
+
if (options.containerAttributes) for (const [name, value] of Object.entries(options.containerAttributes)) {
|
|
1620
|
+
assertValidDOMAttributeName(name);
|
|
1621
|
+
if (value === null || value === void 0 || value === false) continue;
|
|
1622
|
+
container.setAttribute(name, value === true ? "" : String(value));
|
|
1623
|
+
}
|
|
1624
|
+
if (document.body) document.body.appendChild(container);
|
|
1625
|
+
return container;
|
|
1626
|
+
}
|
|
1627
|
+
function buildStreamRuntimeScript(options) {
|
|
1628
|
+
const nonce = renderNonceAttribute(options);
|
|
1629
|
+
if (options.streamRuntime === "external") {
|
|
1630
|
+
if (!options.streamRuntimeSrc) throw new Error("[fict/ssr] streamRuntimeSrc is required when streamRuntime is \"external\".");
|
|
1631
|
+
return `<script${nonce} src="${escapeAttribute(options.streamRuntimeSrc)}" data-fict-stream-runtime data-fict-stream-observer><\/script>`;
|
|
1632
|
+
}
|
|
1633
|
+
return `<script${nonce}>${createStreamRuntimeCode({ observerMode: resolveStreamPatchMode(options) === "observer" })}<\/script>`;
|
|
1634
|
+
}
|
|
1635
|
+
function buildPatchChunk(id, content, options) {
|
|
1636
|
+
const namespaceAttribute = content.namespace ? ` data-fict-patch-namespace="${content.namespace}"` : "";
|
|
1637
|
+
const html = content.namespace === "svg" ? `<svg>${content.html}</svg>` : content.namespace === "mathml" ? `<math>${content.html}</math>` : content.html;
|
|
1638
|
+
const template = `<template data-fict-suspense="${escapeAttribute(id)}"${namespaceAttribute}>${html}</template>`;
|
|
1639
|
+
if (resolveStreamPatchMode(options) === "observer") return template;
|
|
1640
|
+
return `${template}<script${renderNonceAttribute(options)}>__FICT_STREAM.apply("${escapeScriptString(id)}")<\/script>`;
|
|
1641
|
+
}
|
|
1642
|
+
function resolveStreamPatchMode(options) {
|
|
1643
|
+
if (options.streamPatchMode) return options.streamPatchMode;
|
|
1644
|
+
return options.streamRuntime === "external" ? "observer" : "inline";
|
|
1645
|
+
}
|
|
1646
|
+
function resolveStreamIdentifierPrefix(configured) {
|
|
1647
|
+
if (configured !== void 0) return configured;
|
|
1648
|
+
return `f${ssrIdentifierSeed}.${(++streamIdentifierSequence).toString(36)}`;
|
|
1649
|
+
}
|
|
1650
|
+
function validateStreamIdentifierPrefix(configured) {
|
|
1651
|
+
validateIdentifierPrefix("streamIdentifierPrefix", configured);
|
|
1652
|
+
}
|
|
1653
|
+
function resolveScopeIdentifierPrefix(configured) {
|
|
1654
|
+
if (configured !== void 0) return configured;
|
|
1655
|
+
return `r${ssrIdentifierSeed}.${(++scopeIdentifierSequence).toString(36)}`;
|
|
1656
|
+
}
|
|
1657
|
+
function validateScopeIdentifierPrefix(configured) {
|
|
1658
|
+
validateIdentifierPrefix("scopeIdentifierPrefix", configured);
|
|
1659
|
+
}
|
|
1660
|
+
function validateIdentifierPrefix(option, configured) {
|
|
1661
|
+
if (configured === void 0) return;
|
|
1662
|
+
if (typeof configured !== "string" || configured.length === 0 || configured.length > 128 || !IDENTIFIER_PREFIX_PATTERN.test(configured) || configured.includes("--")) throw new TypeError(`[fict/ssr] ${option} must contain 1-128 ASCII letters, digits, "_", ".", ":", or "-", and must not contain "--".`);
|
|
1663
|
+
}
|
|
1664
|
+
function createIdentifierSeed() {
|
|
1665
|
+
try {
|
|
1666
|
+
const normalized = (globalThis.crypto?.randomUUID?.())?.replace(/[^A-Za-z0-9]/g, "");
|
|
1667
|
+
if (normalized) return normalized;
|
|
1668
|
+
} catch {}
|
|
1669
|
+
const random = Math.random().toString(36).slice(2) || "0";
|
|
1670
|
+
return `${Date.now().toString(36)}${random}`;
|
|
1671
|
+
}
|
|
1672
|
+
function serializeBetween(start, end) {
|
|
1673
|
+
const parentElement = start.parentElement ?? (start.parentNode?.nodeType === 1 ? start.parentNode : null);
|
|
1674
|
+
const nodes = [];
|
|
1675
|
+
let node = start.nextSibling;
|
|
1676
|
+
while (node && node !== end) {
|
|
1677
|
+
nodes.push(node);
|
|
1678
|
+
node = node.nextSibling;
|
|
1679
|
+
}
|
|
1680
|
+
const namespace = resolveStreamPatchNamespace(parentElement);
|
|
1681
|
+
return {
|
|
1682
|
+
html: serializeHtmlNodes(nodes, parentElement),
|
|
1683
|
+
namespace
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
function collectPatchScopeIds(start, end) {
|
|
1687
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1688
|
+
const addScopeId = (element) => {
|
|
1689
|
+
const id = element.getAttribute("data-fict-s");
|
|
1690
|
+
if (id) ids.add(id);
|
|
1691
|
+
};
|
|
1692
|
+
const addVisibleSubtree = (element) => {
|
|
1693
|
+
addScopeId(element);
|
|
1694
|
+
for (const descendant of element.querySelectorAll("[data-fict-s]")) addScopeId(descendant);
|
|
1695
|
+
};
|
|
1696
|
+
let ancestor = start.parentElement ?? (start.parentNode?.nodeType === 1 ? start.parentNode : null);
|
|
1697
|
+
while (ancestor) {
|
|
1698
|
+
addScopeId(ancestor);
|
|
1699
|
+
ancestor = ancestor.parentElement;
|
|
1700
|
+
}
|
|
1701
|
+
let node = start.nextSibling;
|
|
1702
|
+
while (node && node !== end) {
|
|
1703
|
+
if (node.nodeType === 1) addVisibleSubtree(node);
|
|
1704
|
+
node = node.nextSibling;
|
|
1705
|
+
}
|
|
1706
|
+
return Array.from(ids);
|
|
1707
|
+
}
|
|
1708
|
+
function resolveStreamPatchNamespace(parentElement) {
|
|
1709
|
+
if (!parentElement) return null;
|
|
1710
|
+
let element = parentElement;
|
|
1711
|
+
let descendantLocalName = null;
|
|
1712
|
+
while (element) {
|
|
1713
|
+
const localName = element.localName.toLowerCase();
|
|
1714
|
+
if (SVG_HTML_INTEGRATION_POINTS.has(localName)) return null;
|
|
1715
|
+
if (MATHML_TEXT_INTEGRATION_POINTS.has(localName)) {
|
|
1716
|
+
if (descendantLocalName === "mglyph" || descendantLocalName === "malignmark") return "mathml";
|
|
1717
|
+
return null;
|
|
1718
|
+
}
|
|
1719
|
+
if (isHtmlAnnotationIntegrationPoint(element)) return null;
|
|
1720
|
+
if (localName === "svg") return "svg";
|
|
1721
|
+
if (localName === "math") return "mathml";
|
|
1722
|
+
descendantLocalName = localName;
|
|
1723
|
+
element = element.parentElement;
|
|
1724
|
+
}
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
function isHtmlAnnotationIntegrationPoint(element) {
|
|
1728
|
+
if (element.localName.toLowerCase() !== "annotation-xml") return false;
|
|
1729
|
+
const encoding = element.getAttribute("encoding")?.toLowerCase();
|
|
1730
|
+
return encoding === "text/html" || encoding === "application/xhtml+xml";
|
|
1731
|
+
}
|
|
1732
|
+
function serializeStreamingDocument(document, container, options) {
|
|
1733
|
+
const body = document.body;
|
|
1734
|
+
if (!body) return null;
|
|
1735
|
+
const marker = document.createComment("");
|
|
1736
|
+
body.appendChild(marker);
|
|
1737
|
+
let markerText = `fict:stream-tail:${++streamTailMarkerId}`;
|
|
1738
|
+
try {
|
|
1739
|
+
while (true) {
|
|
1740
|
+
marker.data = markerText;
|
|
1741
|
+
const html = serializeOutput(document, container, options);
|
|
1742
|
+
const serializedMarker = `<!--${markerText}-->`;
|
|
1743
|
+
const idx = html.indexOf(serializedMarker);
|
|
1744
|
+
if (idx !== -1 && html.indexOf(serializedMarker, idx + serializedMarker.length) === -1) return {
|
|
1745
|
+
head: html.slice(0, idx),
|
|
1746
|
+
tail: html.slice(idx + serializedMarker.length)
|
|
1747
|
+
};
|
|
1748
|
+
markerText += markerText;
|
|
1749
|
+
}
|
|
1750
|
+
} finally {
|
|
1751
|
+
marker.parentNode?.removeChild(marker);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
function buildIncrementalSnapshotChunk(state, options) {
|
|
1755
|
+
const json = serializeSnapshotForScript(state);
|
|
1756
|
+
const nonce = renderNonceAttribute(options);
|
|
1757
|
+
if (options.snapshotTarget === "head") {
|
|
1758
|
+
const jsonLiteral = JSON.stringify(json);
|
|
1759
|
+
return `<script${nonce}>(function(){var s=document.createElement('script');s.type='application/json';s.setAttribute('data-fict-snapshot','');${options.scriptNonce !== void 0 ? `s.setAttribute('nonce',${serializeScriptStringLiteral(options.scriptNonce)});` : ""}s.textContent=${jsonLiteral};(document.head||document.documentElement).appendChild(s);}())<\/script>`;
|
|
1760
|
+
}
|
|
1761
|
+
return `<script${nonce} type="application/json" data-fict-snapshot>${json}<\/script>`;
|
|
1762
|
+
}
|
|
1763
|
+
function serializeOutput(document, container, options) {
|
|
1764
|
+
if (options.fullDocument) {
|
|
1765
|
+
const doctype = serializeDoctype(document, options.doctype);
|
|
1766
|
+
const html = document.documentElement ? serializeHtmlNode(document.documentElement) : serializeHtmlNode(container);
|
|
1767
|
+
return doctype ? `${doctype}${html}` : html;
|
|
1768
|
+
}
|
|
1769
|
+
if (options.includeContainer) return serializeHtmlNode(container);
|
|
1770
|
+
return serializeHtmlChildren(container);
|
|
1771
|
+
}
|
|
1772
|
+
function injectSnapshot(document, container, state, options) {
|
|
1773
|
+
const script = document.createElement("script");
|
|
1774
|
+
script.type = "application/json";
|
|
1775
|
+
script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
|
|
1776
|
+
if (options.scriptNonce !== void 0) script.setAttribute("nonce", options.scriptNonce);
|
|
1777
|
+
script.textContent = serializeSnapshotForScript(state);
|
|
1778
|
+
if (options.fullDocument) {
|
|
1779
|
+
if (options.snapshotTarget === "head" && document.head) {
|
|
1780
|
+
document.head.appendChild(script);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (document.body) {
|
|
1784
|
+
document.body.appendChild(script);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const target = options.snapshotTarget ?? "container";
|
|
1789
|
+
if (target === "body" && document.body) {
|
|
1790
|
+
document.body.appendChild(script);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (target === "head" && document.head) {
|
|
1794
|
+
document.head.appendChild(script);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
container.appendChild(script);
|
|
1798
|
+
}
|
|
1799
|
+
function serializeSnapshotForScript(state) {
|
|
1800
|
+
return JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
1801
|
+
}
|
|
1802
|
+
function serializeScriptStringLiteral(value) {
|
|
1803
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
1804
|
+
}
|
|
1805
|
+
function renderNonceAttribute(options) {
|
|
1806
|
+
return options.scriptNonce === void 0 ? "" : ` nonce="${escapeAttribute(options.scriptNonce)}"`;
|
|
1807
|
+
}
|
|
1808
|
+
function escapeAttribute(value) {
|
|
1809
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1810
|
+
}
|
|
1811
|
+
function escapeScriptString(value) {
|
|
1812
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
1813
|
+
}
|
|
1814
|
+
function serializeDoctype(document, override) {
|
|
1815
|
+
if (override === null) return "";
|
|
1816
|
+
if (override !== void 0) return override;
|
|
1817
|
+
const doctype = document.doctype;
|
|
1818
|
+
if (!doctype) return "";
|
|
1819
|
+
return serializeHtmlNode(doctype);
|
|
1820
|
+
}
|
|
1821
|
+
//#endregion
|
|
1822
|
+
export { renderToStream as a, renderToPipeableStream as i, renderToDocument as n, renderToString as o, renderToPartial as r, renderToStringAsync as s, createSSRDocument as t };
|
|
1823
|
+
|
|
1824
|
+
//# sourceMappingURL=render-core-D0nOT_Pe.js.map
|