@pyreon/runtime-server 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +318 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +298 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +37 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +44 -0
- package/src/index.ts +460 -0
- package/src/tests/ssr.test.ts +916 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { ForSymbol, Fragment, Suspense, runWithHooks, setContextStackProvider } from "@pyreon/core";
|
|
3
|
+
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* @pyreon/runtime-server — SSR/SSG renderer for Pyreon.
|
|
7
|
+
*
|
|
8
|
+
* Walks a VNode tree and produces HTML strings.
|
|
9
|
+
* Signal accessors (reactive getters `() => value`) are called synchronously
|
|
10
|
+
* to snapshot their current value — no effects are set up on the server.
|
|
11
|
+
*
|
|
12
|
+
* Async components (`async function Component()`) are fully supported:
|
|
13
|
+
* renderToString will await them before continuing the tree walk.
|
|
14
|
+
*
|
|
15
|
+
* API:
|
|
16
|
+
* renderToString(vnode) → Promise<string>
|
|
17
|
+
* renderToStream(vnode) → ReadableStream<string>
|
|
18
|
+
*/
|
|
19
|
+
const _streamCtxAls = new AsyncLocalStorage();
|
|
20
|
+
const _contextAls = new AsyncLocalStorage();
|
|
21
|
+
const _fallbackStack = [];
|
|
22
|
+
setContextStackProvider(() => _contextAls.getStore() ?? _fallbackStack);
|
|
23
|
+
const _storeAls = new AsyncLocalStorage();
|
|
24
|
+
let _storeIsolationActive = false;
|
|
25
|
+
/**
|
|
26
|
+
* Wire up per-request store isolation.
|
|
27
|
+
* Call once at server startup, passing a `setStoreRegistryProvider` function.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* import { configureStoreIsolation } from "@pyreon/runtime-server"
|
|
31
|
+
* configureStoreIsolation(setStoreRegistryProvider)
|
|
32
|
+
*/
|
|
33
|
+
function configureStoreIsolation(setStoreRegistryProvider) {
|
|
34
|
+
setStoreRegistryProvider(() => _storeAls.getStore() ?? /* @__PURE__ */ new Map());
|
|
35
|
+
_storeIsolationActive = true;
|
|
36
|
+
}
|
|
37
|
+
/** Wrap a function call in a fresh store registry (no-op if isolation not configured). */
|
|
38
|
+
function withStoreContext(fn) {
|
|
39
|
+
if (!_storeIsolationActive) return fn();
|
|
40
|
+
return _storeAls.run(/* @__PURE__ */ new Map(), fn);
|
|
41
|
+
}
|
|
42
|
+
/** Render a VNode tree to an HTML string. Supports async component functions. */
|
|
43
|
+
async function renderToString(root) {
|
|
44
|
+
if (root === null) return "";
|
|
45
|
+
return withStoreContext(() => _contextAls.run([], () => renderNode(root)));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run an async function with a fresh, isolated context stack and store registry.
|
|
49
|
+
* Useful when you need to call Pyreon APIs (e.g. useHead, prefetchLoaderData)
|
|
50
|
+
* outside of renderToString but still want per-request isolation.
|
|
51
|
+
*/
|
|
52
|
+
function runWithRequestContext(fn) {
|
|
53
|
+
return withStoreContext(() => _contextAls.run([], fn));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Render a VNode tree to a Web-standard ReadableStream of HTML chunks.
|
|
57
|
+
*
|
|
58
|
+
* True progressive streaming: HTML is flushed to the client as soon as each
|
|
59
|
+
* node is ready. Synchronous subtrees are enqueued immediately; async component
|
|
60
|
+
* boundaries are awaited in-order and their output is enqueued as it resolves.
|
|
61
|
+
*
|
|
62
|
+
* Suspense boundaries are streamed out-of-order: the fallback is emitted
|
|
63
|
+
* immediately, and the resolved children are sent as a `<template>` + inline
|
|
64
|
+
* swap script once ready — without blocking the rest of the page.
|
|
65
|
+
*
|
|
66
|
+
* Each renderToStream call gets its own isolated ALS context stack.
|
|
67
|
+
*/
|
|
68
|
+
function renderToStream(root) {
|
|
69
|
+
return new ReadableStream({ start(controller) {
|
|
70
|
+
const enqueue = (chunk) => controller.enqueue(chunk);
|
|
71
|
+
let bid = 0;
|
|
72
|
+
const ctx = {
|
|
73
|
+
pending: [],
|
|
74
|
+
nextId: () => bid++,
|
|
75
|
+
mainEnqueue: enqueue
|
|
76
|
+
};
|
|
77
|
+
return withStoreContext(() => _contextAls.run([], () => _streamCtxAls.run(ctx, async () => {
|
|
78
|
+
await streamNode(root, enqueue);
|
|
79
|
+
while (ctx.pending.length > 0) await Promise.all(ctx.pending.splice(0));
|
|
80
|
+
controller.close();
|
|
81
|
+
}).catch((err) => controller.error(err))));
|
|
82
|
+
} });
|
|
83
|
+
}
|
|
84
|
+
async function streamVNode(vnode, enqueue) {
|
|
85
|
+
if (vnode.type === Fragment) {
|
|
86
|
+
for (const child of vnode.children) await streamNode(child, enqueue);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (vnode.type === ForSymbol) {
|
|
90
|
+
const { each, children } = vnode.props;
|
|
91
|
+
enqueue("<!--pyreon-for-->");
|
|
92
|
+
for (const item of each()) await streamNode(children(item), enqueue);
|
|
93
|
+
enqueue("<!--/pyreon-for-->");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof vnode.type === "function") {
|
|
97
|
+
await streamComponentNode(vnode, enqueue);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await streamElementNode(vnode, enqueue);
|
|
101
|
+
}
|
|
102
|
+
async function streamComponentNode(vnode, enqueue) {
|
|
103
|
+
if (vnode.type === Suspense) {
|
|
104
|
+
await streamSuspenseBoundary(vnode, enqueue);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
108
|
+
const resolved = output instanceof Promise ? await output : output;
|
|
109
|
+
if (resolved !== null) await streamNode(resolved, enqueue);
|
|
110
|
+
}
|
|
111
|
+
async function streamElementNode(vnode, enqueue) {
|
|
112
|
+
const tag = vnode.type;
|
|
113
|
+
let open = `<${tag}`;
|
|
114
|
+
for (const [key, value] of Object.entries(vnode.props)) {
|
|
115
|
+
const attr = renderProp(key, value);
|
|
116
|
+
if (attr) open += ` ${attr}`;
|
|
117
|
+
}
|
|
118
|
+
if (isVoidElement(tag)) {
|
|
119
|
+
enqueue(`${open} />`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
enqueue(`${open}>`);
|
|
123
|
+
for (const child of vnode.children) await streamNode(child, enqueue);
|
|
124
|
+
enqueue(`</${tag}>`);
|
|
125
|
+
}
|
|
126
|
+
async function streamNode(node, enqueue) {
|
|
127
|
+
if (typeof node === "function") return streamNode(node(), enqueue);
|
|
128
|
+
if (node == null || node === false) return;
|
|
129
|
+
if (typeof node === "string") {
|
|
130
|
+
enqueue(escapeHtml(node));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (typeof node === "number" || typeof node === "boolean") {
|
|
134
|
+
enqueue(String(node));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(node)) {
|
|
138
|
+
for (const child of node) await streamNode(child, enqueue);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await streamVNode(node, enqueue);
|
|
142
|
+
}
|
|
143
|
+
const SUSPENSE_SWAP_FN = "<script>function __NS(s,t){var e=document.getElementById(s),l=document.getElementById(t);if(e&&l){e.replaceWith(l.content.cloneNode(!0));l.remove()}}<\/script>";
|
|
144
|
+
/**
|
|
145
|
+
* Stream a Suspense boundary: emit fallback immediately, then resolve children
|
|
146
|
+
* asynchronously and emit them as a `<template>` + client-side swap.
|
|
147
|
+
*
|
|
148
|
+
* The actual children HTML is buffered until fully resolved, then emitted to the
|
|
149
|
+
* main stream enqueue so it always arrives after the fallback placeholder.
|
|
150
|
+
*/
|
|
151
|
+
async function streamSuspenseBoundary(vnode, enqueue) {
|
|
152
|
+
const ctx = _streamCtxAls.getStore();
|
|
153
|
+
const { fallback, children } = vnode.props;
|
|
154
|
+
if (!ctx) {
|
|
155
|
+
const { vnode: output } = runWithHooks(Suspense, vnode.props);
|
|
156
|
+
if (output !== null) await streamNode(output, enqueue);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const id = ctx.nextId();
|
|
160
|
+
const { mainEnqueue } = ctx;
|
|
161
|
+
if (id === 0) mainEnqueue(SUSPENSE_SWAP_FN);
|
|
162
|
+
mainEnqueue(`<div id="pyreon-s-${id}">`);
|
|
163
|
+
await streamNode(fallback ?? null, enqueue);
|
|
164
|
+
mainEnqueue("</div>");
|
|
165
|
+
const ctxStore = _contextAls.getStore() ?? [];
|
|
166
|
+
ctx.pending.push(_contextAls.run(ctxStore, async () => {
|
|
167
|
+
try {
|
|
168
|
+
const buf = [];
|
|
169
|
+
await streamNode(children ?? null, (s) => buf.push(s));
|
|
170
|
+
mainEnqueue(`<template id="pyreon-t-${id}">${buf.join("")}</template>`);
|
|
171
|
+
mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")<\/script>`);
|
|
172
|
+
} catch (_err) {}
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
async function renderNode(node) {
|
|
176
|
+
if (typeof node === "function") return renderNode(node());
|
|
177
|
+
if (node == null || node === false) return "";
|
|
178
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
179
|
+
if (typeof node === "number" || typeof node === "boolean") return String(node);
|
|
180
|
+
if (Array.isArray(node)) return (await Promise.all(node.map((n) => renderNode(n)))).join("");
|
|
181
|
+
const vnode = node;
|
|
182
|
+
if (vnode.type === Fragment) return renderChildren(vnode.children);
|
|
183
|
+
if (vnode.type === ForSymbol) {
|
|
184
|
+
const { each, children } = vnode.props;
|
|
185
|
+
return `<!--pyreon-for-->${(await Promise.all(each().map((item) => renderNode(children(item))))).join("")}<!--/pyreon-for-->`;
|
|
186
|
+
}
|
|
187
|
+
if (typeof vnode.type === "function") return renderComponent(vnode);
|
|
188
|
+
return renderElement(vnode);
|
|
189
|
+
}
|
|
190
|
+
async function renderChildren(children) {
|
|
191
|
+
return (await Promise.all(children.map((c) => renderNode(c)))).join("");
|
|
192
|
+
}
|
|
193
|
+
async function renderComponent(vnode) {
|
|
194
|
+
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
195
|
+
if (output instanceof Promise) {
|
|
196
|
+
const resolved = await output;
|
|
197
|
+
if (resolved === null) return "";
|
|
198
|
+
return renderNode(resolved);
|
|
199
|
+
}
|
|
200
|
+
if (output === null) return "";
|
|
201
|
+
return renderNode(output);
|
|
202
|
+
}
|
|
203
|
+
async function renderElement(vnode) {
|
|
204
|
+
const tag = vnode.type;
|
|
205
|
+
let html = `<${tag}`;
|
|
206
|
+
for (const [key, value] of Object.entries(vnode.props)) {
|
|
207
|
+
const attr = renderProp(key, value);
|
|
208
|
+
if (attr) html += ` ${attr}`;
|
|
209
|
+
}
|
|
210
|
+
if (isVoidElement(tag)) {
|
|
211
|
+
html += " />";
|
|
212
|
+
return html;
|
|
213
|
+
}
|
|
214
|
+
html += ">";
|
|
215
|
+
for (const child of vnode.children) html += await renderNode(child);
|
|
216
|
+
html += `</${tag}>`;
|
|
217
|
+
return html;
|
|
218
|
+
}
|
|
219
|
+
const SSR_URL_ATTRS = new Set([
|
|
220
|
+
"href",
|
|
221
|
+
"src",
|
|
222
|
+
"action",
|
|
223
|
+
"formaction",
|
|
224
|
+
"poster",
|
|
225
|
+
"cite",
|
|
226
|
+
"data"
|
|
227
|
+
]);
|
|
228
|
+
const SSR_UNSAFE_URL_RE = /^\s*(?:javascript|data):/i;
|
|
229
|
+
function renderPropSkipped(key) {
|
|
230
|
+
if (key === "key" || key === "ref" || key === "n-show") return true;
|
|
231
|
+
if (key.startsWith("n-")) return true;
|
|
232
|
+
if (/^on[A-Z]/.test(key)) return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
function renderPropValue(key, value) {
|
|
236
|
+
if (value === null || value === void 0 || value === false) return null;
|
|
237
|
+
if (value === true) return escapeHtml(toAttrName(key));
|
|
238
|
+
if (key === "class") {
|
|
239
|
+
const cls = normalizeClass(value);
|
|
240
|
+
return cls ? `class="${escapeHtml(cls)}"` : null;
|
|
241
|
+
}
|
|
242
|
+
if (key === "style") {
|
|
243
|
+
const style = normalizeStyle(value);
|
|
244
|
+
return style ? `style="${escapeHtml(style)}"` : null;
|
|
245
|
+
}
|
|
246
|
+
return `${escapeHtml(toAttrName(key))}="${escapeHtml(String(value))}"`;
|
|
247
|
+
}
|
|
248
|
+
function renderProp(key, value) {
|
|
249
|
+
if (renderPropSkipped(key)) return null;
|
|
250
|
+
if (typeof value === "function") return renderProp(key, value());
|
|
251
|
+
if (SSR_URL_ATTRS.has(key) && typeof value === "string" && SSR_UNSAFE_URL_RE.test(value)) return null;
|
|
252
|
+
return renderPropValue(key, value);
|
|
253
|
+
}
|
|
254
|
+
const VOID_ELEMENTS = new Set([
|
|
255
|
+
"area",
|
|
256
|
+
"base",
|
|
257
|
+
"br",
|
|
258
|
+
"col",
|
|
259
|
+
"embed",
|
|
260
|
+
"hr",
|
|
261
|
+
"img",
|
|
262
|
+
"input",
|
|
263
|
+
"link",
|
|
264
|
+
"meta",
|
|
265
|
+
"param",
|
|
266
|
+
"source",
|
|
267
|
+
"track",
|
|
268
|
+
"wbr"
|
|
269
|
+
]);
|
|
270
|
+
function isVoidElement(tag) {
|
|
271
|
+
return VOID_ELEMENTS.has(tag.toLowerCase());
|
|
272
|
+
}
|
|
273
|
+
/** camelCase prop → kebab-case HTML attribute (e.g. className → class, htmlFor → for) */
|
|
274
|
+
function toAttrName(key) {
|
|
275
|
+
if (key === "className") return "class";
|
|
276
|
+
if (key === "htmlFor") return "for";
|
|
277
|
+
return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
278
|
+
}
|
|
279
|
+
function normalizeClass(value) {
|
|
280
|
+
if (typeof value === "string") return value;
|
|
281
|
+
if (Array.isArray(value)) return value.filter(Boolean).join(" ");
|
|
282
|
+
if (typeof value === "object" && value !== null) return Object.entries(value).filter(([, v]) => v).map(([k]) => k).join(" ");
|
|
283
|
+
return "";
|
|
284
|
+
}
|
|
285
|
+
function normalizeStyle(value) {
|
|
286
|
+
if (typeof value === "string") return value;
|
|
287
|
+
if (typeof value === "object" && value !== null) return Object.entries(value).map(([k, v]) => `${toKebab(k)}: ${v}`).join("; ");
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
function toKebab(str) {
|
|
291
|
+
return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
292
|
+
}
|
|
293
|
+
const ESCAPE_MAP = {
|
|
294
|
+
"&": "&",
|
|
295
|
+
"<": "<",
|
|
296
|
+
">": ">",
|
|
297
|
+
"\"": """,
|
|
298
|
+
"'": "'"
|
|
299
|
+
};
|
|
300
|
+
function escapeHtml(str) {
|
|
301
|
+
return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Merge vnode.children into props.children for component rendering.
|
|
305
|
+
* Matches the behavior of mount.ts and hydrate.ts so components can
|
|
306
|
+
* access children passed via h(Comp, props, child1, child2).
|
|
307
|
+
*/
|
|
308
|
+
function mergeChildrenIntoProps(vnode) {
|
|
309
|
+
if (vnode.children.length > 0 && vnode.props.children === void 0) return {
|
|
310
|
+
...vnode.props,
|
|
311
|
+
children: vnode.children.length === 1 ? vnode.children[0] : vnode.children
|
|
312
|
+
};
|
|
313
|
+
return vnode.props;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
//#endregion
|
|
317
|
+
export { configureStoreIsolation, renderToStream, renderToString, runWithRequestContext };
|
|
318
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @pyreon/runtime-server — SSR/SSG renderer for Pyreon.\n *\n * Walks a VNode tree and produces HTML strings.\n * Signal accessors (reactive getters `() => value`) are called synchronously\n * to snapshot their current value — no effects are set up on the server.\n *\n * Async components (`async function Component()`) are fully supported:\n * renderToString will await them before continuing the tree walk.\n *\n * API:\n * renderToString(vnode) → Promise<string>\n * renderToStream(vnode) → ReadableStream<string>\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\"\nimport type { ComponentFn, ForProps, VNode, VNodeChild } from \"@pyreon/core\"\nimport { ForSymbol, Fragment, runWithHooks, Suspense, setContextStackProvider } from \"@pyreon/core\"\n\n// ─── Streaming Suspense context ───────────────────────────────────────────────\n// Tracks in-flight async Suspense boundary resolutions within a single stream.\n\ninterface StreamCtx {\n pending: Promise<void>[]\n nextId: () => number\n mainEnqueue: (s: string) => void\n}\n\nconst _streamCtxAls = new AsyncLocalStorage<StreamCtx>()\n\n// ─── Concurrent SSR context isolation ────────────────────────────────────────\n// Each renderToString call runs in its own ALS store (a fresh empty stack[]).\n// Concurrent requests never share context frames.\n\nconst _contextAls = new AsyncLocalStorage<Map<symbol, unknown>[]>()\nconst _fallbackStack: Map<symbol, unknown>[] = []\n\nsetContextStackProvider(() => _contextAls.getStore() ?? _fallbackStack)\n\n// ─── Store isolation (optional) ───────────────────────────────────────────────\n// A second ALS isolates store registries between concurrent requests.\n// Activated only when the user calls configureStoreIsolation().\n\nconst _storeAls = new AsyncLocalStorage<Map<string, unknown>>()\nlet _storeIsolationActive = false\n\n/**\n * Wire up per-request store isolation.\n * Call once at server startup, passing a `setStoreRegistryProvider` function.\n *\n * @example\n * import { configureStoreIsolation } from \"@pyreon/runtime-server\"\n * configureStoreIsolation(setStoreRegistryProvider)\n */\nexport function configureStoreIsolation(\n setStoreRegistryProvider: (fn: () => Map<string, unknown>) => void,\n): void {\n setStoreRegistryProvider(() => _storeAls.getStore() ?? new Map())\n _storeIsolationActive = true\n}\n\n/** Wrap a function call in a fresh store registry (no-op if isolation not configured). */\nfunction withStoreContext<T>(fn: () => T): T {\n if (!_storeIsolationActive) return fn()\n return _storeAls.run(new Map(), fn)\n}\n\n// ─── Public API ───────────────────────────────────────────────────────────────\n\n/** Render a VNode tree to an HTML string. Supports async component functions. */\nexport async function renderToString(root: VNode | null): Promise<string> {\n if (root === null) return \"\"\n // Each call gets a fresh isolated context stack and (optionally) store registry\n return withStoreContext(() => _contextAls.run([], () => renderNode(root)))\n}\n\n/**\n * Run an async function with a fresh, isolated context stack and store registry.\n * Useful when you need to call Pyreon APIs (e.g. useHead, prefetchLoaderData)\n * outside of renderToString but still want per-request isolation.\n */\nexport function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T> {\n return withStoreContext(() => _contextAls.run([], fn))\n}\n\n/**\n * Render a VNode tree to a Web-standard ReadableStream of HTML chunks.\n *\n * True progressive streaming: HTML is flushed to the client as soon as each\n * node is ready. Synchronous subtrees are enqueued immediately; async component\n * boundaries are awaited in-order and their output is enqueued as it resolves.\n *\n * Suspense boundaries are streamed out-of-order: the fallback is emitted\n * immediately, and the resolved children are sent as a `<template>` + inline\n * swap script once ready — without blocking the rest of the page.\n *\n * Each renderToStream call gets its own isolated ALS context stack.\n */\nexport function renderToStream(root: VNode | null): ReadableStream<string> {\n return new ReadableStream<string>({\n start(controller) {\n const enqueue = (chunk: string) => controller.enqueue(chunk)\n let bid = 0\n const ctx: StreamCtx = {\n pending: [],\n nextId: () => bid++,\n mainEnqueue: enqueue,\n }\n return withStoreContext(() =>\n _contextAls.run([], () =>\n _streamCtxAls\n .run(ctx, async () => {\n await streamNode(root, enqueue)\n // Drain all pending Suspense resolutions (may spawn nested ones)\n while (ctx.pending.length > 0) {\n await Promise.all(ctx.pending.splice(0))\n }\n controller.close()\n })\n .catch((err) => controller.error(err)),\n ),\n )\n },\n })\n}\n\n// ─── Streaming renderer ───────────────────────────────────────────────────────\n\nasync function streamVNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {\n if (vnode.type === Fragment) {\n for (const child of vnode.children) await streamNode(child, enqueue)\n return\n }\n\n if (vnode.type === (ForSymbol as unknown as string)) {\n const { each, children } = vnode.props as unknown as ForProps<unknown>\n enqueue(\"<!--pyreon-for-->\")\n for (const item of each()) await streamNode(children(item) as VNodeChild, enqueue)\n enqueue(\"<!--/pyreon-for-->\")\n return\n }\n\n if (typeof vnode.type === \"function\") {\n await streamComponentNode(vnode, enqueue)\n return\n }\n\n await streamElementNode(vnode, enqueue)\n}\n\nasync function streamComponentNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {\n if (vnode.type === Suspense) {\n await streamSuspenseBoundary(vnode, enqueue)\n return\n }\n const { vnode: output } = runWithHooks(vnode.type as ComponentFn, mergeChildrenIntoProps(vnode))\n const resolved = output instanceof Promise ? await output : output\n if (resolved !== null) await streamNode(resolved, enqueue)\n}\n\nasync function streamElementNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {\n const tag = vnode.type as string\n let open = `<${tag}`\n for (const [key, value] of Object.entries(vnode.props)) {\n const attr = renderProp(key, value)\n if (attr) open += ` ${attr}`\n }\n if (isVoidElement(tag)) {\n enqueue(`${open} />`)\n return\n }\n enqueue(`${open}>`)\n for (const child of vnode.children) await streamNode(child, enqueue)\n enqueue(`</${tag}>`)\n}\n\nasync function streamNode(\n node: VNodeChild | null | (() => VNodeChild),\n enqueue: (s: string) => void,\n): Promise<void> {\n if (typeof node === \"function\") {\n return streamNode((node as () => VNodeChild)(), enqueue)\n }\n if (node == null || node === false) return\n if (typeof node === \"string\") {\n enqueue(escapeHtml(node))\n return\n }\n if (typeof node === \"number\" || typeof node === \"boolean\") {\n enqueue(String(node))\n return\n }\n if (Array.isArray(node)) {\n for (const child of node) await streamNode(child, enqueue)\n return\n }\n\n await streamVNode(node as VNode, enqueue)\n}\n\n// Inline swap helper emitted once per stream, before the first <template>\nconst SUSPENSE_SWAP_FN =\n \"<script>function __NS(s,t){var e=document.getElementById(s),l=document.getElementById(t);\" +\n \"if(e&&l){e.replaceWith(l.content.cloneNode(!0));l.remove()}}</script>\"\n\n/**\n * Stream a Suspense boundary: emit fallback immediately, then resolve children\n * asynchronously and emit them as a `<template>` + client-side swap.\n *\n * The actual children HTML is buffered until fully resolved, then emitted to the\n * main stream enqueue so it always arrives after the fallback placeholder.\n */\nasync function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void): Promise<void> {\n const ctx = _streamCtxAls.getStore()\n const { fallback, children } = vnode.props as { fallback: VNodeChild; children?: VNodeChild }\n\n // No streaming context (e.g. called from renderToString) — render children inline\n if (!ctx) {\n const { vnode: output } = runWithHooks(Suspense as ComponentFn, vnode.props)\n if (output !== null) await streamNode(output, enqueue)\n return\n }\n\n const id = ctx.nextId()\n const { mainEnqueue } = ctx\n\n // Emit the swap helper function once (before first use)\n if (id === 0) mainEnqueue(SUSPENSE_SWAP_FN)\n\n // Stream the fallback synchronously (no await on children)\n mainEnqueue(`<div id=\"pyreon-s-${id}\">`)\n await streamNode(fallback ?? null, enqueue)\n mainEnqueue(\"</div>\")\n\n // Capture the context store for the async resolution so it inherits context\n const ctxStore = _contextAls.getStore() ?? []\n\n // Queue async resolution — runs in parallel, emits to main stream when done\n // Errors are caught per-boundary so one failing Suspense doesn't abort the stream.\n ctx.pending.push(\n _contextAls.run(ctxStore, async () => {\n try {\n const buf: string[] = []\n await streamNode(children ?? null, (s) => buf.push(s))\n mainEnqueue(`<template id=\"pyreon-t-${id}\">${buf.join(\"\")}</template>`)\n mainEnqueue(`<script>__NS(\"pyreon-s-${id}\",\"pyreon-t-${id}\")</script>`)\n } catch (_err) {\n // Fallback stays visible — no swap script emitted\n }\n }),\n )\n}\n\n// ─── Core renderer ───────────────────────────────────────────────────────────\n\nasync function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string> {\n // Reactive accessor — call it synchronously (snapshot)\n if (typeof node === \"function\") {\n return renderNode((node as () => VNodeChild)())\n }\n\n if (node == null || node === false) return \"\"\n\n if (typeof node === \"string\") return escapeHtml(node)\n if (typeof node === \"number\" || typeof node === \"boolean\") return String(node)\n\n if (Array.isArray(node)) {\n const parts = await Promise.all(node.map((n) => renderNode(n)))\n return parts.join(\"\")\n }\n\n const vnode = node as VNode\n\n if (vnode.type === Fragment) {\n return renderChildren(vnode.children)\n }\n\n if (vnode.type === (ForSymbol as unknown as string)) {\n const { each, children } = vnode.props as unknown as ForProps<unknown>\n const parts = await Promise.all(each().map((item) => renderNode(children(item) as VNodeChild)))\n // Hydration markers so the client can claim existing For-rendered children\n return `<!--pyreon-for-->${parts.join(\"\")}<!--/pyreon-for-->`\n }\n\n if (typeof vnode.type === \"function\") {\n return renderComponent(vnode as VNode & { type: ComponentFn })\n }\n\n return renderElement(vnode)\n}\n\nasync function renderChildren(children: VNodeChild[]): Promise<string> {\n const parts = await Promise.all(children.map((c) => renderNode(c)))\n return parts.join(\"\")\n}\n\nasync function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<string> {\n const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode))\n\n // Async component function (async function Component()) — await the promise\n if (output instanceof Promise) {\n const resolved = await output\n if (resolved === null) return \"\"\n return renderNode(resolved)\n }\n\n if (output === null) return \"\"\n return renderNode(output)\n}\n\nasync function renderElement(vnode: VNode): Promise<string> {\n const tag = vnode.type as string\n let html = `<${tag}`\n\n for (const [key, value] of Object.entries(vnode.props)) {\n const attr = renderProp(key, value)\n if (attr) html += ` ${attr}`\n }\n\n if (isVoidElement(tag)) {\n html += \" />\"\n return html\n }\n\n html += \">\"\n\n for (const child of vnode.children) {\n html += await renderNode(child)\n }\n\n html += `</${tag}>`\n return html\n}\n\nconst SSR_URL_ATTRS = new Set([\"href\", \"src\", \"action\", \"formaction\", \"poster\", \"cite\", \"data\"])\nconst SSR_UNSAFE_URL_RE = /^\\s*(?:javascript|data):/i\n\nfunction renderPropSkipped(key: string): boolean {\n if (key === \"key\" || key === \"ref\" || key === \"n-show\") return true\n if (key.startsWith(\"n-\")) return true\n if (/^on[A-Z]/.test(key)) return true\n return false\n}\n\nfunction renderPropValue(key: string, value: unknown): string | null {\n if (value === null || value === undefined || value === false) return null\n if (value === true) return escapeHtml(toAttrName(key))\n\n if (key === \"class\") {\n const cls = normalizeClass(value)\n return cls ? `class=\"${escapeHtml(cls)}\"` : null\n }\n\n if (key === \"style\") {\n const style = normalizeStyle(value)\n return style ? `style=\"${escapeHtml(style)}\"` : null\n }\n\n return `${escapeHtml(toAttrName(key))}=\"${escapeHtml(String(value))}\"`\n}\n\nfunction renderProp(key: string, value: unknown): string | null {\n if (renderPropSkipped(key)) return null\n\n if (typeof value === \"function\") {\n return renderProp(key, (value as () => unknown)())\n }\n\n if (SSR_URL_ATTRS.has(key) && typeof value === \"string\" && SSR_UNSAFE_URL_RE.test(value)) {\n return null\n }\n\n return renderPropValue(key, value)\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nconst VOID_ELEMENTS = new Set([\n \"area\",\n \"base\",\n \"br\",\n \"col\",\n \"embed\",\n \"hr\",\n \"img\",\n \"input\",\n \"link\",\n \"meta\",\n \"param\",\n \"source\",\n \"track\",\n \"wbr\",\n])\n\nfunction isVoidElement(tag: string): boolean {\n return VOID_ELEMENTS.has(tag.toLowerCase())\n}\n\n/** camelCase prop → kebab-case HTML attribute (e.g. className → class, htmlFor → for) */\nfunction toAttrName(key: string): string {\n if (key === \"className\") return \"class\"\n if (key === \"htmlFor\") return \"for\"\n return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)\n}\n\nfunction normalizeClass(value: unknown): string {\n if (typeof value === \"string\") return value\n if (Array.isArray(value)) return value.filter(Boolean).join(\" \")\n if (typeof value === \"object\" && value !== null) {\n return Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v)\n .map(([k]) => k)\n .join(\" \")\n }\n return \"\"\n}\n\nfunction normalizeStyle(value: unknown): string {\n if (typeof value === \"string\") return value\n if (typeof value === \"object\" && value !== null) {\n return Object.entries(value as Record<string, unknown>)\n .map(([k, v]) => `${toKebab(k)}: ${v}`)\n .join(\"; \")\n }\n return \"\"\n}\n\nfunction toKebab(str: string): string {\n return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)\n}\n\nconst ESCAPE_MAP: Record<string, string> = {\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n}\n\nfunction escapeHtml(str: string): string {\n return str.replace(/[&<>\"']/g, (c) => ESCAPE_MAP[c] ?? c)\n}\n\n/**\n * Merge vnode.children into props.children for component rendering.\n * Matches the behavior of mount.ts and hydrate.ts so components can\n * access children passed via h(Comp, props, child1, child2).\n */\nfunction mergeChildrenIntoProps(vnode: VNode): Record<string, unknown> {\n if (\n vnode.children.length > 0 &&\n (vnode.props as Record<string, unknown>).children === undefined\n ) {\n return {\n ...vnode.props,\n children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,\n }\n }\n return vnode.props as Record<string, unknown>\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { ForSymbol, Fragment, Suspense, runWithHooks, setContextStackProvider } from "@pyreon/core";
|
|
3
|
+
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* @pyreon/runtime-server — SSR/SSG renderer for Pyreon.
|
|
7
|
+
*
|
|
8
|
+
* Walks a VNode tree and produces HTML strings.
|
|
9
|
+
* Signal accessors (reactive getters `() => value`) are called synchronously
|
|
10
|
+
* to snapshot their current value — no effects are set up on the server.
|
|
11
|
+
*
|
|
12
|
+
* Async components (`async function Component()`) are fully supported:
|
|
13
|
+
* renderToString will await them before continuing the tree walk.
|
|
14
|
+
*
|
|
15
|
+
* API:
|
|
16
|
+
* renderToString(vnode) → Promise<string>
|
|
17
|
+
* renderToStream(vnode) → ReadableStream<string>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wire up per-request store isolation.
|
|
22
|
+
* Call once at server startup, passing a `setStoreRegistryProvider` function.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* import { configureStoreIsolation } from "@pyreon/runtime-server"
|
|
26
|
+
* configureStoreIsolation(setStoreRegistryProvider)
|
|
27
|
+
*/
|
|
28
|
+
function configureStoreIsolation(setStoreRegistryProvider) {
|
|
29
|
+
setStoreRegistryProvider(() => _storeAls.getStore() ?? /* @__PURE__ */new Map());
|
|
30
|
+
_storeIsolationActive = true;
|
|
31
|
+
}
|
|
32
|
+
/** Wrap a function call in a fresh store registry (no-op if isolation not configured). */
|
|
33
|
+
function withStoreContext(fn) {
|
|
34
|
+
if (!_storeIsolationActive) return fn();
|
|
35
|
+
return _storeAls.run(/* @__PURE__ */new Map(), fn);
|
|
36
|
+
}
|
|
37
|
+
/** Render a VNode tree to an HTML string. Supports async component functions. */
|
|
38
|
+
async function renderToString(root) {
|
|
39
|
+
if (root === null) return "";
|
|
40
|
+
return withStoreContext(() => _contextAls.run([], () => renderNode(root)));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run an async function with a fresh, isolated context stack and store registry.
|
|
44
|
+
* Useful when you need to call Pyreon APIs (e.g. useHead, prefetchLoaderData)
|
|
45
|
+
* outside of renderToString but still want per-request isolation.
|
|
46
|
+
*/
|
|
47
|
+
function runWithRequestContext(fn) {
|
|
48
|
+
return withStoreContext(() => _contextAls.run([], fn));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render a VNode tree to a Web-standard ReadableStream of HTML chunks.
|
|
52
|
+
*
|
|
53
|
+
* True progressive streaming: HTML is flushed to the client as soon as each
|
|
54
|
+
* node is ready. Synchronous subtrees are enqueued immediately; async component
|
|
55
|
+
* boundaries are awaited in-order and their output is enqueued as it resolves.
|
|
56
|
+
*
|
|
57
|
+
* Suspense boundaries are streamed out-of-order: the fallback is emitted
|
|
58
|
+
* immediately, and the resolved children are sent as a `<template>` + inline
|
|
59
|
+
* swap script once ready — without blocking the rest of the page.
|
|
60
|
+
*
|
|
61
|
+
* Each renderToStream call gets its own isolated ALS context stack.
|
|
62
|
+
*/
|
|
63
|
+
function renderToStream(root) {
|
|
64
|
+
return new ReadableStream({
|
|
65
|
+
start(controller) {
|
|
66
|
+
const enqueue = chunk => controller.enqueue(chunk);
|
|
67
|
+
let bid = 0;
|
|
68
|
+
const ctx = {
|
|
69
|
+
pending: [],
|
|
70
|
+
nextId: () => bid++,
|
|
71
|
+
mainEnqueue: enqueue
|
|
72
|
+
};
|
|
73
|
+
return withStoreContext(() => _contextAls.run([], () => _streamCtxAls.run(ctx, async () => {
|
|
74
|
+
await streamNode(root, enqueue);
|
|
75
|
+
while (ctx.pending.length > 0) await Promise.all(ctx.pending.splice(0));
|
|
76
|
+
controller.close();
|
|
77
|
+
}).catch(err => controller.error(err))));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function streamVNode(vnode, enqueue) {
|
|
82
|
+
if (vnode.type === Fragment) {
|
|
83
|
+
for (const child of vnode.children) await streamNode(child, enqueue);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (vnode.type === ForSymbol) {
|
|
87
|
+
const {
|
|
88
|
+
each,
|
|
89
|
+
children
|
|
90
|
+
} = vnode.props;
|
|
91
|
+
enqueue("<!--pyreon-for-->");
|
|
92
|
+
for (const item of each()) await streamNode(children(item), enqueue);
|
|
93
|
+
enqueue("<!--/pyreon-for-->");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof vnode.type === "function") {
|
|
97
|
+
await streamComponentNode(vnode, enqueue);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await streamElementNode(vnode, enqueue);
|
|
101
|
+
}
|
|
102
|
+
async function streamComponentNode(vnode, enqueue) {
|
|
103
|
+
if (vnode.type === Suspense) {
|
|
104
|
+
await streamSuspenseBoundary(vnode, enqueue);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const {
|
|
108
|
+
vnode: output
|
|
109
|
+
} = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
110
|
+
const resolved = output instanceof Promise ? await output : output;
|
|
111
|
+
if (resolved !== null) await streamNode(resolved, enqueue);
|
|
112
|
+
}
|
|
113
|
+
async function streamElementNode(vnode, enqueue) {
|
|
114
|
+
const tag = vnode.type;
|
|
115
|
+
let open = `<${tag}`;
|
|
116
|
+
for (const [key, value] of Object.entries(vnode.props)) {
|
|
117
|
+
const attr = renderProp(key, value);
|
|
118
|
+
if (attr) open += ` ${attr}`;
|
|
119
|
+
}
|
|
120
|
+
if (isVoidElement(tag)) {
|
|
121
|
+
enqueue(`${open} />`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
enqueue(`${open}>`);
|
|
125
|
+
for (const child of vnode.children) await streamNode(child, enqueue);
|
|
126
|
+
enqueue(`</${tag}>`);
|
|
127
|
+
}
|
|
128
|
+
async function streamNode(node, enqueue) {
|
|
129
|
+
if (typeof node === "function") return streamNode(node(), enqueue);
|
|
130
|
+
if (node == null || node === false) return;
|
|
131
|
+
if (typeof node === "string") {
|
|
132
|
+
enqueue(escapeHtml(node));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (typeof node === "number" || typeof node === "boolean") {
|
|
136
|
+
enqueue(String(node));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(node)) {
|
|
140
|
+
for (const child of node) await streamNode(child, enqueue);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
await streamVNode(node, enqueue);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Stream a Suspense boundary: emit fallback immediately, then resolve children
|
|
147
|
+
* asynchronously and emit them as a `<template>` + client-side swap.
|
|
148
|
+
*
|
|
149
|
+
* The actual children HTML is buffered until fully resolved, then emitted to the
|
|
150
|
+
* main stream enqueue so it always arrives after the fallback placeholder.
|
|
151
|
+
*/
|
|
152
|
+
async function streamSuspenseBoundary(vnode, enqueue) {
|
|
153
|
+
const ctx = _streamCtxAls.getStore();
|
|
154
|
+
const {
|
|
155
|
+
fallback,
|
|
156
|
+
children
|
|
157
|
+
} = vnode.props;
|
|
158
|
+
if (!ctx) {
|
|
159
|
+
const {
|
|
160
|
+
vnode: output
|
|
161
|
+
} = runWithHooks(Suspense, vnode.props);
|
|
162
|
+
if (output !== null) await streamNode(output, enqueue);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const id = ctx.nextId();
|
|
166
|
+
const {
|
|
167
|
+
mainEnqueue
|
|
168
|
+
} = ctx;
|
|
169
|
+
if (id === 0) mainEnqueue(SUSPENSE_SWAP_FN);
|
|
170
|
+
mainEnqueue(`<div id="pyreon-s-${id}">`);
|
|
171
|
+
await streamNode(fallback ?? null, enqueue);
|
|
172
|
+
mainEnqueue("</div>");
|
|
173
|
+
const ctxStore = _contextAls.getStore() ?? [];
|
|
174
|
+
ctx.pending.push(_contextAls.run(ctxStore, async () => {
|
|
175
|
+
try {
|
|
176
|
+
const buf = [];
|
|
177
|
+
await streamNode(children ?? null, s => buf.push(s));
|
|
178
|
+
mainEnqueue(`<template id="pyreon-t-${id}">${buf.join("")}</template>`);
|
|
179
|
+
mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")<\/script>`);
|
|
180
|
+
} catch (_err) {}
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
async function renderNode(node) {
|
|
184
|
+
if (typeof node === "function") return renderNode(node());
|
|
185
|
+
if (node == null || node === false) return "";
|
|
186
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
187
|
+
if (typeof node === "number" || typeof node === "boolean") return String(node);
|
|
188
|
+
if (Array.isArray(node)) return (await Promise.all(node.map(n => renderNode(n)))).join("");
|
|
189
|
+
const vnode = node;
|
|
190
|
+
if (vnode.type === Fragment) return renderChildren(vnode.children);
|
|
191
|
+
if (vnode.type === ForSymbol) {
|
|
192
|
+
const {
|
|
193
|
+
each,
|
|
194
|
+
children
|
|
195
|
+
} = vnode.props;
|
|
196
|
+
return `<!--pyreon-for-->${(await Promise.all(each().map(item => renderNode(children(item))))).join("")}<!--/pyreon-for-->`;
|
|
197
|
+
}
|
|
198
|
+
if (typeof vnode.type === "function") return renderComponent(vnode);
|
|
199
|
+
return renderElement(vnode);
|
|
200
|
+
}
|
|
201
|
+
async function renderChildren(children) {
|
|
202
|
+
return (await Promise.all(children.map(c => renderNode(c)))).join("");
|
|
203
|
+
}
|
|
204
|
+
async function renderComponent(vnode) {
|
|
205
|
+
const {
|
|
206
|
+
vnode: output
|
|
207
|
+
} = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
208
|
+
if (output instanceof Promise) {
|
|
209
|
+
const resolved = await output;
|
|
210
|
+
if (resolved === null) return "";
|
|
211
|
+
return renderNode(resolved);
|
|
212
|
+
}
|
|
213
|
+
if (output === null) return "";
|
|
214
|
+
return renderNode(output);
|
|
215
|
+
}
|
|
216
|
+
async function renderElement(vnode) {
|
|
217
|
+
const tag = vnode.type;
|
|
218
|
+
let html = `<${tag}`;
|
|
219
|
+
for (const [key, value] of Object.entries(vnode.props)) {
|
|
220
|
+
const attr = renderProp(key, value);
|
|
221
|
+
if (attr) html += ` ${attr}`;
|
|
222
|
+
}
|
|
223
|
+
if (isVoidElement(tag)) {
|
|
224
|
+
html += " />";
|
|
225
|
+
return html;
|
|
226
|
+
}
|
|
227
|
+
html += ">";
|
|
228
|
+
for (const child of vnode.children) html += await renderNode(child);
|
|
229
|
+
html += `</${tag}>`;
|
|
230
|
+
return html;
|
|
231
|
+
}
|
|
232
|
+
function renderPropSkipped(key) {
|
|
233
|
+
if (key === "key" || key === "ref" || key === "n-show") return true;
|
|
234
|
+
if (key.startsWith("n-")) return true;
|
|
235
|
+
if (/^on[A-Z]/.test(key)) return true;
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
function renderPropValue(key, value) {
|
|
239
|
+
if (value === null || value === void 0 || value === false) return null;
|
|
240
|
+
if (value === true) return escapeHtml(toAttrName(key));
|
|
241
|
+
if (key === "class") {
|
|
242
|
+
const cls = normalizeClass(value);
|
|
243
|
+
return cls ? `class="${escapeHtml(cls)}"` : null;
|
|
244
|
+
}
|
|
245
|
+
if (key === "style") {
|
|
246
|
+
const style = normalizeStyle(value);
|
|
247
|
+
return style ? `style="${escapeHtml(style)}"` : null;
|
|
248
|
+
}
|
|
249
|
+
return `${escapeHtml(toAttrName(key))}="${escapeHtml(String(value))}"`;
|
|
250
|
+
}
|
|
251
|
+
function renderProp(key, value) {
|
|
252
|
+
if (renderPropSkipped(key)) return null;
|
|
253
|
+
if (typeof value === "function") return renderProp(key, value());
|
|
254
|
+
if (SSR_URL_ATTRS.has(key) && typeof value === "string" && SSR_UNSAFE_URL_RE.test(value)) return null;
|
|
255
|
+
return renderPropValue(key, value);
|
|
256
|
+
}
|
|
257
|
+
function isVoidElement(tag) {
|
|
258
|
+
return VOID_ELEMENTS.has(tag.toLowerCase());
|
|
259
|
+
}
|
|
260
|
+
/** camelCase prop → kebab-case HTML attribute (e.g. className → class, htmlFor → for) */
|
|
261
|
+
function toAttrName(key) {
|
|
262
|
+
if (key === "className") return "class";
|
|
263
|
+
if (key === "htmlFor") return "for";
|
|
264
|
+
return key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
|
|
265
|
+
}
|
|
266
|
+
function normalizeClass(value) {
|
|
267
|
+
if (typeof value === "string") return value;
|
|
268
|
+
if (Array.isArray(value)) return value.filter(Boolean).join(" ");
|
|
269
|
+
if (typeof value === "object" && value !== null) return Object.entries(value).filter(([, v]) => v).map(([k]) => k).join(" ");
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
function normalizeStyle(value) {
|
|
273
|
+
if (typeof value === "string") return value;
|
|
274
|
+
if (typeof value === "object" && value !== null) return Object.entries(value).map(([k, v]) => `${toKebab(k)}: ${v}`).join("; ");
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
function toKebab(str) {
|
|
278
|
+
return str.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
|
|
279
|
+
}
|
|
280
|
+
function escapeHtml(str) {
|
|
281
|
+
return str.replace(/[&<>"']/g, c => ESCAPE_MAP[c] ?? c);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Merge vnode.children into props.children for component rendering.
|
|
285
|
+
* Matches the behavior of mount.ts and hydrate.ts so components can
|
|
286
|
+
* access children passed via h(Comp, props, child1, child2).
|
|
287
|
+
*/
|
|
288
|
+
function mergeChildrenIntoProps(vnode) {
|
|
289
|
+
if (vnode.children.length > 0 && vnode.props.children === void 0) return {
|
|
290
|
+
...vnode.props,
|
|
291
|
+
children: vnode.children.length === 1 ? vnode.children[0] : vnode.children
|
|
292
|
+
};
|
|
293
|
+
return vnode.props;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
//#endregion
|
|
297
|
+
export { configureStoreIsolation, renderToStream, renderToString, runWithRequestContext };
|
|
298
|
+
//# sourceMappingURL=index.d.ts.map
|