@pyreon/runtime-server 0.12.12 → 0.12.14
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +34 -3
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +9 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +49 -2
- package/src/tests/for-key-marker.test.ts +86 -0
- package/src/tests/suspense-stream-error.test.ts +83 -0
- package/src/tests/unsafe-tag-warning.test.ts +55 -0
- package/src/tests/xss-attribute-escape.test.ts +40 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"ed1e39d6-1"}]}],"isRoot":true},"nodeParts":{"ed1e39d6-1":{"renderedLength":13660,"gzipLength":4634,"brotliLength":0,"metaUid":"ed1e39d6-0"}},"nodeMetas":{"ed1e39d6-0":{"id":"/src/index.ts","moduleParts":{"index.js":"ed1e39d6-1"},"imported":[{"uid":"ed1e39d6-2"},{"uid":"ed1e39d6-3"}],"importedBy":[],"isEntry":true},"ed1e39d6-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"ed1e39d6-0"}]},"ed1e39d6-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"ed1e39d6-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -92,7 +92,7 @@ async function streamVNode(vnode, enqueue) {
|
|
|
92
92
|
const { each, children, by } = vnode.props;
|
|
93
93
|
enqueue("<!--pyreon-for-->");
|
|
94
94
|
for (const item of each()) {
|
|
95
|
-
enqueue(`<!--k:${by(item)}-->`);
|
|
95
|
+
enqueue(`<!--k:${safeKeyForMarker(by(item))}-->`);
|
|
96
96
|
await streamNode(children(item), enqueue);
|
|
97
97
|
}
|
|
98
98
|
enqueue("<!--/pyreon-for-->");
|
|
@@ -125,6 +125,7 @@ async function streamComponentNode(vnode, enqueue) {
|
|
|
125
125
|
}
|
|
126
126
|
async function streamElementNode(vnode, enqueue) {
|
|
127
127
|
const tag = vnode.type;
|
|
128
|
+
warnIfUnsafeTag(tag);
|
|
128
129
|
let open = `<${tag}`;
|
|
129
130
|
const props = vnode.props;
|
|
130
131
|
for (const key in props) {
|
|
@@ -216,7 +217,7 @@ async function renderNode(node) {
|
|
|
216
217
|
let forHtml = "<!--pyreon-for-->";
|
|
217
218
|
for (const item of each()) {
|
|
218
219
|
const key = by(item);
|
|
219
|
-
forHtml += `<!--k:${key}-->`;
|
|
220
|
+
forHtml += `<!--k:${safeKeyForMarker(key)}-->`;
|
|
220
221
|
forHtml += await renderNode(children(item));
|
|
221
222
|
}
|
|
222
223
|
forHtml += "<!--/pyreon-for-->";
|
|
@@ -242,6 +243,7 @@ async function renderComponent(vnode) {
|
|
|
242
243
|
}
|
|
243
244
|
async function renderElement(vnode) {
|
|
244
245
|
const tag = vnode.type;
|
|
246
|
+
warnIfUnsafeTag(tag);
|
|
245
247
|
let html = `<${tag}`;
|
|
246
248
|
const props = vnode.props;
|
|
247
249
|
for (const key in props) {
|
|
@@ -340,6 +342,35 @@ const ESCAPE_MAP = {
|
|
|
340
342
|
"\"": """,
|
|
341
343
|
"'": "'"
|
|
342
344
|
};
|
|
345
|
+
/**
|
|
346
|
+
* Encode a For-list key so it's safe to inline inside an HTML comment
|
|
347
|
+
* marker `<!--k:KEY-->`. If a user-supplied key contains `-->` the naive
|
|
348
|
+
* form breaks out of the comment and may inject markup. Per-byte URL
|
|
349
|
+
* encoding with an extra `-` substitution makes `-->` impossible in the
|
|
350
|
+
* output: `%2D%2D>` no longer terminates the comment. Client-side
|
|
351
|
+
* hydration does not read the marker body today, so any reversible-or-
|
|
352
|
+
* irreversible encoding works; this one is predictable enough for a
|
|
353
|
+
* future consumer to decode if needed.
|
|
354
|
+
*/
|
|
355
|
+
function safeKeyForMarker(key) {
|
|
356
|
+
return encodeURIComponent(String(key)).replace(/-/g, "%2D");
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
|
|
360
|
+
* original string. Not used by runtime today (hydration does not read
|
|
361
|
+
* per-item `<!--k:KEY-->` markers) but shipped alongside the encoder so
|
|
362
|
+
* future hydration or devtools consumers decode symmetrically without
|
|
363
|
+
* having to re-derive the encoding from source.
|
|
364
|
+
*/
|
|
365
|
+
function decodeKeyFromMarker(encoded) {
|
|
366
|
+
return decodeURIComponent(encoded.replace(/%2D/gi, "-"));
|
|
367
|
+
}
|
|
368
|
+
const SAFE_TAG_RE = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
369
|
+
function warnIfUnsafeTag(tag) {
|
|
370
|
+
if (!__DEV__) return;
|
|
371
|
+
if (SAFE_TAG_RE.test(tag)) return;
|
|
372
|
+
console.warn(`[Pyreon SSR] Tag name "${tag}" contains characters that could break HTML structure. Tag names must match /^[a-zA-Z][a-zA-Z0-9-]*$/. If user-supplied data drives a tag name, validate it against an allowlist before passing to h().`);
|
|
373
|
+
}
|
|
343
374
|
const NEEDS_ESCAPE_RE = /[&<>"']/;
|
|
344
375
|
function escapeHtml(str) {
|
|
345
376
|
if (!NEEDS_ESCAPE_RE.test(str)) return str;
|
|
@@ -359,5 +390,5 @@ function mergeChildrenIntoProps(vnode) {
|
|
|
359
390
|
}
|
|
360
391
|
|
|
361
392
|
//#endregion
|
|
362
|
-
export { configureStoreIsolation, renderToStream, renderToString, runWithRequestContext };
|
|
393
|
+
export { configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
|
|
363
394
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1 +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 { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'\nimport {\n cx,\n ForSymbol,\n Fragment,\n normalizeStyleValue,\n runWithHooks,\n Suspense,\n setContextStackProvider,\n} from '@pyreon/core'\n\nconst __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'\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 /** Depth counter — non-zero when rendering inside a Suspense child resolution. */\n suspenseDepth: number\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 suspenseDepth: 0,\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, by } = vnode.props as unknown as ForProps<unknown>\n enqueue('<!--pyreon-for-->')\n for (const item of each()) {\n const key = by(item)\n enqueue(`<!--k:${key}-->`)\n await streamNode(children(item) as VNodeChild, enqueue)\n }\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 try {\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 } catch (err) {\n if (__DEV__) {\n const name = (vnode.type as ComponentFn).name || 'Anonymous'\n console.error(`[Pyreon SSR] Error rendering <${name}>:`, err)\n }\n // Inside a Suspense child resolution, re-throw so the boundary can catch and\n // suppress the swap (fallback stays visible). Outside Suspense, swallow the\n // error and emit a marker so the stream can continue.\n const ctx = _streamCtxAls.getStore()\n if (ctx && ctx.suspenseDepth > 0) throw err\n enqueue('<!--pyreon-error-->')\n }\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 const props = vnode.props as Record<string, unknown>\n for (const key in props) {\n const attr = renderProp(key, props[key])\n if (attr) open += ` ${attr}`\n }\n if (isVoidElement(tag)) {\n enqueue(`${open} />`)\n return\n }\n enqueue(`${open}>`)\n const dangerous = props.dangerouslySetInnerHTML as { __html: string } | undefined\n if (dangerous?.__html) {\n enqueue(dangerous.__html)\n } else {\n for (const child of vnode.children) await streamNode(child, enqueue)\n }\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 // Timeout prevents hung async children from keeping the stream open forever.\n const SUSPENSE_TIMEOUT_MS = 30_000\n\n ctx.pending.push(\n _contextAls.run(ctxStore, async () => {\n try {\n ctx.suspenseDepth++\n const buf: string[] = []\n\n // Race the async children against a timeout\n const result = await Promise.race([\n streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),\n new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), SUSPENSE_TIMEOUT_MS)),\n ])\n\n if (result === 'timeout') {\n if (__DEV__) {\n console.warn(\n `[Pyreon SSR] Suspense boundary timed out after ${SUSPENSE_TIMEOUT_MS}ms — fallback will remain.`,\n )\n }\n // Fallback stays visible — no swap\n return\n }\n\n // Escape </template> in buffered content to prevent early close + XSS\n const content = buf.join('').replace(/<\\/template/gi, '<\\\\/template')\n mainEnqueue(`<template id=\"pyreon-t-${id}\">${content}</template>`)\n mainEnqueue(`<script>__NS(\"pyreon-s-${id}\",\"pyreon-t-${id}\")</script>`)\n } catch (err) {\n if (__DEV__) {\n console.error(\n `[Pyreon SSR] Suspense boundary caught an error — fallback will remain:`,\n err,\n )\n }\n // Fallback stays visible — no swap script emitted\n } finally {\n ctx.suspenseDepth--\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 let html = ''\n for (const child of node) html += await renderNode(child)\n return html\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, by } = vnode.props as unknown as ForProps<unknown>\n let forHtml = '<!--pyreon-for-->'\n for (const item of each()) {\n const key = by(item)\n forHtml += `<!--k:${key}-->`\n forHtml += await renderNode(children(item) as VNodeChild)\n }\n forHtml += '<!--/pyreon-for-->'\n return forHtml\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 let html = ''\n for (const child of children) html += await renderNode(child)\n return html\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 const props = vnode.props as Record<string, unknown>\n for (const key in props) {\n const attr = renderProp(key, props[key])\n if (attr) html += ` ${attr}`\n }\n\n if (isVoidElement(tag)) {\n html += ' />'\n return html\n }\n\n html += '>'\n\n // dangerouslySetInnerHTML — inject raw HTML, skip children\n const dangerous = props.dangerouslySetInnerHTML as { __html: string } | undefined\n if (dangerous?.__html) {\n html += dangerous.__html\n } else {\n for (const child of vnode.children) {\n html += await renderNode(child)\n }\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 === 'dangerouslySetInnerHTML') 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 = cx(value as ClassValue)\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 isStyleObject(value: unknown): value is Record<string, unknown> {\n if (!value) return false\n return typeof value === 'object'\n}\n\nfunction normalizeStyle(value: unknown): string {\n if (typeof value === 'string') return value\n if (isStyleObject(value)) {\n const proto = Object.getPrototypeOf(value)\n if (proto === Object.prototype || proto === null) {\n return Object.entries(value)\n .map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`)\n .join('; ')\n }\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\n// Fast test — most strings in SSR have no special chars (tag names, class names, etc.)\nconst NEEDS_ESCAPE_RE = /[&<>\"']/\n\nfunction escapeHtml(str: string): string {\n if (!NEEDS_ESCAPE_RE.test(str)) return str\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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"}
|
|
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 { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'\nimport {\n cx,\n ForSymbol,\n Fragment,\n normalizeStyleValue,\n runWithHooks,\n Suspense,\n setContextStackProvider,\n} from '@pyreon/core'\n\nconst __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'\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 /** Depth counter — non-zero when rendering inside a Suspense child resolution. */\n suspenseDepth: number\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 suspenseDepth: 0,\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, by } = vnode.props as unknown as ForProps<unknown>\n enqueue('<!--pyreon-for-->')\n for (const item of each()) {\n const key = by(item)\n enqueue(`<!--k:${safeKeyForMarker(key)}-->`)\n await streamNode(children(item) as VNodeChild, enqueue)\n }\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 try {\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 } catch (err) {\n if (__DEV__) {\n const name = (vnode.type as ComponentFn).name || 'Anonymous'\n console.error(`[Pyreon SSR] Error rendering <${name}>:`, err)\n }\n // Inside a Suspense child resolution, re-throw so the boundary can catch and\n // suppress the swap (fallback stays visible). Outside Suspense, swallow the\n // error and emit a marker so the stream can continue.\n const ctx = _streamCtxAls.getStore()\n if (ctx && ctx.suspenseDepth > 0) throw err\n enqueue('<!--pyreon-error-->')\n }\n}\n\nasync function streamElementNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {\n const tag = vnode.type as string\n warnIfUnsafeTag(tag)\n let open = `<${tag}`\n const props = vnode.props as Record<string, unknown>\n for (const key in props) {\n const attr = renderProp(key, props[key])\n if (attr) open += ` ${attr}`\n }\n if (isVoidElement(tag)) {\n enqueue(`${open} />`)\n return\n }\n enqueue(`${open}>`)\n const dangerous = props.dangerouslySetInnerHTML as { __html: string } | undefined\n if (dangerous?.__html) {\n enqueue(dangerous.__html)\n } else {\n for (const child of vnode.children) await streamNode(child, enqueue)\n }\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 // Timeout prevents hung async children from keeping the stream open forever.\n const SUSPENSE_TIMEOUT_MS = 30_000\n\n ctx.pending.push(\n _contextAls.run(ctxStore, async () => {\n try {\n ctx.suspenseDepth++\n const buf: string[] = []\n\n // Race the async children against a timeout\n const result = await Promise.race([\n streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),\n new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), SUSPENSE_TIMEOUT_MS)),\n ])\n\n if (result === 'timeout') {\n if (__DEV__) {\n console.warn(\n `[Pyreon SSR] Suspense boundary timed out after ${SUSPENSE_TIMEOUT_MS}ms — fallback will remain.`,\n )\n }\n // Fallback stays visible — no swap\n return\n }\n\n // Escape </template> in buffered content to prevent early close + XSS\n const content = buf.join('').replace(/<\\/template/gi, '<\\\\/template')\n mainEnqueue(`<template id=\"pyreon-t-${id}\">${content}</template>`)\n mainEnqueue(`<script>__NS(\"pyreon-s-${id}\",\"pyreon-t-${id}\")</script>`)\n } catch (err) {\n if (__DEV__) {\n console.error(\n `[Pyreon SSR] Suspense boundary caught an error — fallback will remain:`,\n err,\n )\n }\n // Fallback stays visible — no swap script emitted\n } finally {\n ctx.suspenseDepth--\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 let html = ''\n for (const child of node) html += await renderNode(child)\n return html\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, by } = vnode.props as unknown as ForProps<unknown>\n let forHtml = '<!--pyreon-for-->'\n for (const item of each()) {\n const key = by(item)\n forHtml += `<!--k:${safeKeyForMarker(key)}-->`\n forHtml += await renderNode(children(item) as VNodeChild)\n }\n forHtml += '<!--/pyreon-for-->'\n return forHtml\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 let html = ''\n for (const child of children) html += await renderNode(child)\n return html\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 warnIfUnsafeTag(tag)\n let html = `<${tag}`\n\n const props = vnode.props as Record<string, unknown>\n for (const key in props) {\n const attr = renderProp(key, props[key])\n if (attr) html += ` ${attr}`\n }\n\n if (isVoidElement(tag)) {\n html += ' />'\n return html\n }\n\n html += '>'\n\n // dangerouslySetInnerHTML — inject raw HTML, skip children\n const dangerous = props.dangerouslySetInnerHTML as { __html: string } | undefined\n if (dangerous?.__html) {\n html += dangerous.__html\n } else {\n for (const child of vnode.children) {\n html += await renderNode(child)\n }\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 === 'dangerouslySetInnerHTML') 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 = cx(value as ClassValue)\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 isStyleObject(value: unknown): value is Record<string, unknown> {\n if (!value) return false\n return typeof value === 'object'\n}\n\nfunction normalizeStyle(value: unknown): string {\n if (typeof value === 'string') return value\n if (isStyleObject(value)) {\n const proto = Object.getPrototypeOf(value)\n if (proto === Object.prototype || proto === null) {\n return Object.entries(value)\n .map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`)\n .join('; ')\n }\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\n/**\n * Encode a For-list key so it's safe to inline inside an HTML comment\n * marker `<!--k:KEY-->`. If a user-supplied key contains `-->` the naive\n * form breaks out of the comment and may inject markup. Per-byte URL\n * encoding with an extra `-` substitution makes `-->` impossible in the\n * output: `%2D%2D>` no longer terminates the comment. Client-side\n * hydration does not read the marker body today, so any reversible-or-\n * irreversible encoding works; this one is predictable enough for a\n * future consumer to decode if needed.\n */\nfunction safeKeyForMarker(key: unknown): string {\n return encodeURIComponent(String(key)).replace(/-/g, '%2D')\n}\n\n/**\n * Inverse of `safeKeyForMarker` — decode a marker-safe key back to the\n * original string. Not used by runtime today (hydration does not read\n * per-item `<!--k:KEY-->` markers) but shipped alongside the encoder so\n * future hydration or devtools consumers decode symmetrically without\n * having to re-derive the encoding from source.\n */\nexport function decodeKeyFromMarker(encoded: string): string {\n return decodeURIComponent(encoded.replace(/%2D/gi, '-'))\n}\n\n// Detect tag names that would break out of the `<TAG>` or `</TAG>` form\n// and inject HTML. If user data ever feeds `h(userTag, ...)` the attack\n// `userTag = 'div><script>alert(1)</script><div'` yields executable\n// markup. Framework doesn't HTML-escape tag names (React/Vue/Solid\n// match) — responsibility is on the caller — but a dev-mode warning\n// catches the mistake before it reaches prod. Safe tag pattern covers\n// HTML element names and custom elements (letter start, then\n// alphanumerics + hyphens).\nconst SAFE_TAG_RE = /^[a-zA-Z][a-zA-Z0-9-]*$/\nfunction warnIfUnsafeTag(tag: string): void {\n if (!__DEV__) return\n if (SAFE_TAG_RE.test(tag)) return\n // oxlint-disable-next-line no-console\n console.warn(\n `[Pyreon SSR] Tag name \"${tag}\" contains characters that could break HTML structure. ` +\n `Tag names must match /^[a-zA-Z][a-zA-Z0-9-]*$/. ` +\n `If user-supplied data drives a tag name, validate it against an allowlist before passing to h().`,\n )\n}\n\n// Fast test — most strings in SSR have no special chars (tag names, class names, etc.)\nconst NEEDS_ESCAPE_RE = /[&<>\"']/\n\nfunction escapeHtml(str: string): string {\n if (!NEEDS_ESCAPE_RE.test(str)) return str\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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"}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ declare function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T>;
|
|
|
32
32
|
* Each renderToStream call gets its own isolated ALS context stack.
|
|
33
33
|
*/
|
|
34
34
|
declare function renderToStream(root: VNode | null): ReadableStream<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
|
|
37
|
+
* original string. Not used by runtime today (hydration does not read
|
|
38
|
+
* per-item `<!--k:KEY-->` markers) but shipped alongside the encoder so
|
|
39
|
+
* future hydration or devtools consumers decode symmetrically without
|
|
40
|
+
* having to re-derive the encoding from source.
|
|
41
|
+
*/
|
|
42
|
+
declare function decodeKeyFromMarker(encoded: string): string;
|
|
35
43
|
//#endregion
|
|
36
|
-
export { configureStoreIsolation, renderToStream, renderToString, runWithRequestContext };
|
|
44
|
+
export { configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
|
|
37
45
|
//# sourceMappingURL=index2.d.ts.map
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/index.ts"],"mappings":";;;;;;;;;AA6FA;;iBA3BgB,uBAAA,CACd,wBAAA,GAA2B,EAAA,QAAU,GAAA;;iBAejB,cAAA,CAAe,IAAA,EAAM,KAAA,UAAe,OAAA;;;;;;iBAW1C,qBAAA,GAAA,CAAyB,EAAA,QAAU,OAAA,CAAQ,CAAA,IAAK,OAAA,CAAQ,CAAA;;;;;;;AAiBxE;;;;;;;iBAAgB,cAAA,CAAe,IAAA,EAAM,KAAA,UAAe,cAAA"}
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/index.ts"],"mappings":";;;;;;;;;AA6FA;;iBA3BgB,uBAAA,CACd,wBAAA,GAA2B,EAAA,QAAU,GAAA;;iBAejB,cAAA,CAAe,IAAA,EAAM,KAAA,UAAe,OAAA;;;;;;iBAW1C,qBAAA,GAAA,CAAyB,EAAA,QAAU,OAAA,CAAQ,CAAA,IAAK,OAAA,CAAQ,CAAA;;;;;;;AAiBxE;;;;;;;iBAAgB,cAAA,CAAe,IAAA,EAAM,KAAA,UAAe,cAAA;;AA2apD;;;;;;iBAAgB,mBAAA,CAAoB,OAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-server",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
4
4
|
"description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"prepublishOnly": "bun run build"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@pyreon/core": "^0.12.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
45
|
+
"@pyreon/core": "^0.12.14",
|
|
46
|
+
"@pyreon/reactivity": "^0.12.14"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -150,7 +150,7 @@ async function streamVNode(vnode: VNode, enqueue: (s: string) => void): Promise<
|
|
|
150
150
|
enqueue('<!--pyreon-for-->')
|
|
151
151
|
for (const item of each()) {
|
|
152
152
|
const key = by(item)
|
|
153
|
-
enqueue(`<!--k:${key}-->`)
|
|
153
|
+
enqueue(`<!--k:${safeKeyForMarker(key)}-->`)
|
|
154
154
|
await streamNode(children(item) as VNodeChild, enqueue)
|
|
155
155
|
}
|
|
156
156
|
enqueue('<!--/pyreon-for-->')
|
|
@@ -190,6 +190,7 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
|
|
|
190
190
|
|
|
191
191
|
async function streamElementNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {
|
|
192
192
|
const tag = vnode.type as string
|
|
193
|
+
warnIfUnsafeTag(tag)
|
|
193
194
|
let open = `<${tag}`
|
|
194
195
|
const props = vnode.props as Record<string, unknown>
|
|
195
196
|
for (const key in props) {
|
|
@@ -347,7 +348,7 @@ async function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string
|
|
|
347
348
|
let forHtml = '<!--pyreon-for-->'
|
|
348
349
|
for (const item of each()) {
|
|
349
350
|
const key = by(item)
|
|
350
|
-
forHtml += `<!--k:${key}-->`
|
|
351
|
+
forHtml += `<!--k:${safeKeyForMarker(key)}-->`
|
|
351
352
|
forHtml += await renderNode(children(item) as VNodeChild)
|
|
352
353
|
}
|
|
353
354
|
forHtml += '<!--/pyreon-for-->'
|
|
@@ -383,6 +384,7 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
|
|
|
383
384
|
|
|
384
385
|
async function renderElement(vnode: VNode): Promise<string> {
|
|
385
386
|
const tag = vnode.type as string
|
|
387
|
+
warnIfUnsafeTag(tag)
|
|
386
388
|
let html = `<${tag}`
|
|
387
389
|
|
|
388
390
|
const props = vnode.props as Record<string, unknown>
|
|
@@ -512,6 +514,51 @@ const ESCAPE_MAP: Record<string, string> = {
|
|
|
512
514
|
"'": ''',
|
|
513
515
|
}
|
|
514
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Encode a For-list key so it's safe to inline inside an HTML comment
|
|
519
|
+
* marker `<!--k:KEY-->`. If a user-supplied key contains `-->` the naive
|
|
520
|
+
* form breaks out of the comment and may inject markup. Per-byte URL
|
|
521
|
+
* encoding with an extra `-` substitution makes `-->` impossible in the
|
|
522
|
+
* output: `%2D%2D>` no longer terminates the comment. Client-side
|
|
523
|
+
* hydration does not read the marker body today, so any reversible-or-
|
|
524
|
+
* irreversible encoding works; this one is predictable enough for a
|
|
525
|
+
* future consumer to decode if needed.
|
|
526
|
+
*/
|
|
527
|
+
function safeKeyForMarker(key: unknown): string {
|
|
528
|
+
return encodeURIComponent(String(key)).replace(/-/g, '%2D')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
|
|
533
|
+
* original string. Not used by runtime today (hydration does not read
|
|
534
|
+
* per-item `<!--k:KEY-->` markers) but shipped alongside the encoder so
|
|
535
|
+
* future hydration or devtools consumers decode symmetrically without
|
|
536
|
+
* having to re-derive the encoding from source.
|
|
537
|
+
*/
|
|
538
|
+
export function decodeKeyFromMarker(encoded: string): string {
|
|
539
|
+
return decodeURIComponent(encoded.replace(/%2D/gi, '-'))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Detect tag names that would break out of the `<TAG>` or `</TAG>` form
|
|
543
|
+
// and inject HTML. If user data ever feeds `h(userTag, ...)` the attack
|
|
544
|
+
// `userTag = 'div><script>alert(1)</script><div'` yields executable
|
|
545
|
+
// markup. Framework doesn't HTML-escape tag names (React/Vue/Solid
|
|
546
|
+
// match) — responsibility is on the caller — but a dev-mode warning
|
|
547
|
+
// catches the mistake before it reaches prod. Safe tag pattern covers
|
|
548
|
+
// HTML element names and custom elements (letter start, then
|
|
549
|
+
// alphanumerics + hyphens).
|
|
550
|
+
const SAFE_TAG_RE = /^[a-zA-Z][a-zA-Z0-9-]*$/
|
|
551
|
+
function warnIfUnsafeTag(tag: string): void {
|
|
552
|
+
if (!__DEV__) return
|
|
553
|
+
if (SAFE_TAG_RE.test(tag)) return
|
|
554
|
+
// oxlint-disable-next-line no-console
|
|
555
|
+
console.warn(
|
|
556
|
+
`[Pyreon SSR] Tag name "${tag}" contains characters that could break HTML structure. ` +
|
|
557
|
+
`Tag names must match /^[a-zA-Z][a-zA-Z0-9-]*$/. ` +
|
|
558
|
+
`If user-supplied data drives a tag name, validate it against an allowlist before passing to h().`,
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
515
562
|
// Fast test — most strings in SSR have no special chars (tag names, class names, etc.)
|
|
516
563
|
const NEEDS_ESCAPE_RE = /[&<>"']/
|
|
517
564
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { For, h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { decodeKeyFromMarker, renderToString } from '../index'
|
|
4
|
+
|
|
5
|
+
// For list SSR emits <!--k:KEY--> markers between items. The key is
|
|
6
|
+
// user-supplied (derived from `by`) and must not be able to break out
|
|
7
|
+
// of the HTML comment. A naive inline of `-->` would terminate the
|
|
8
|
+
// comment and inject arbitrary markup. This suite locks the fix.
|
|
9
|
+
|
|
10
|
+
describe('For SSR — key marker safety', () => {
|
|
11
|
+
it('emits one marker per item with the expected key for normal ids', async () => {
|
|
12
|
+
type Row = { id: number; label: string }
|
|
13
|
+
const items = signal<Row[]>([
|
|
14
|
+
{ id: 1, label: 'a' },
|
|
15
|
+
{ id: 2, label: 'b' },
|
|
16
|
+
{ id: 3, label: 'c' },
|
|
17
|
+
])
|
|
18
|
+
const html = await renderToString(
|
|
19
|
+
h(For, {
|
|
20
|
+
each: () => items(),
|
|
21
|
+
by: (r: Row) => r.id,
|
|
22
|
+
children: (r: Row) => h('li', null, r.label),
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const markers = html.match(/<!--k:[^>]*-->/g) ?? []
|
|
27
|
+
expect(markers).toHaveLength(3)
|
|
28
|
+
// Numeric keys are URL-encoded predictably (digits survive).
|
|
29
|
+
expect(markers[0]).toBe('<!--k:1-->')
|
|
30
|
+
expect(markers[1]).toBe('<!--k:2-->')
|
|
31
|
+
expect(markers[2]).toBe('<!--k:3-->')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('prevents comment-breakout via `-->` in the key (XSS guard)', async () => {
|
|
35
|
+
// Adversarial: a key that would, unsanitized, close the comment and
|
|
36
|
+
// inject a <script> tag.
|
|
37
|
+
const attackKey = '--><script>alert(1)</script><!--'
|
|
38
|
+
type Row = { id: string }
|
|
39
|
+
const items = signal<Row[]>([{ id: attackKey }])
|
|
40
|
+
const html = await renderToString(
|
|
41
|
+
h(For, {
|
|
42
|
+
each: () => items(),
|
|
43
|
+
by: (r: Row) => r.id,
|
|
44
|
+
children: () => h('li', null, 'x'),
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// The raw attacker string must NOT appear verbatim.
|
|
49
|
+
expect(html).not.toContain('<script>alert(1)</script>')
|
|
50
|
+
// The `-->` terminator must not appear anywhere but at the very end
|
|
51
|
+
// of the marker (after the encoded key).
|
|
52
|
+
const markerMatch = html.match(/<!--k:([^-]*)-->/)
|
|
53
|
+
expect(markerMatch).not.toBeNull()
|
|
54
|
+
// No literal `-` survives inside the encoded key (defense-in-depth
|
|
55
|
+
// against any future HTML-comment parsing quirk).
|
|
56
|
+
expect(markerMatch![1]).not.toMatch(/-/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('URL-encodes keys with special chars safely', async () => {
|
|
60
|
+
type Row = { id: string }
|
|
61
|
+
const items = signal<Row[]>([{ id: 'a&b=c d' }])
|
|
62
|
+
const html = await renderToString(
|
|
63
|
+
h(For, {
|
|
64
|
+
each: () => items(),
|
|
65
|
+
by: (r: Row) => r.id,
|
|
66
|
+
children: () => h('li', null, 'x'),
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const marker = html.match(/<!--k:([^>]*)-->/)
|
|
71
|
+
expect(marker).not.toBeNull()
|
|
72
|
+
// `&`, `=`, ` `, and `-` all URL-encoded.
|
|
73
|
+
expect(marker![1]).toBe('a%26b%3Dc%20d')
|
|
74
|
+
// decodeKeyFromMarker round-trips back to the original string.
|
|
75
|
+
expect(decodeKeyFromMarker(marker![1]!)).toBe('a&b=c d')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('decodeKeyFromMarker round-trips adversarial keys', () => {
|
|
79
|
+
// Directly round-trip the adversarial case that motivated the fix.
|
|
80
|
+
const attackKey = '--><script>alert(1)</script><!--'
|
|
81
|
+
// Produce what SSR would emit (safeKeyForMarker is private; use
|
|
82
|
+
// the inverse on a value we know was encoded from it).
|
|
83
|
+
const encoded = encodeURIComponent(attackKey).replace(/-/g, '%2D')
|
|
84
|
+
expect(decodeKeyFromMarker(encoded)).toBe(attackKey)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { h, Suspense } from '@pyreon/core'
|
|
2
|
+
import type { ComponentFn } from '@pyreon/core'
|
|
3
|
+
import { renderToStream } from '../index'
|
|
4
|
+
|
|
5
|
+
async function collectStream(stream: ReadableStream<string>): Promise<string> {
|
|
6
|
+
const reader = stream.getReader()
|
|
7
|
+
let out = ''
|
|
8
|
+
while (true) {
|
|
9
|
+
const { value, done } = await reader.read()
|
|
10
|
+
if (done) break
|
|
11
|
+
out += value
|
|
12
|
+
}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// PR #233 follow-up: when an async child inside a Suspense boundary
|
|
17
|
+
// rejects mid-stream, what happens? The fallback should stay visible,
|
|
18
|
+
// the swap must NOT be emitted, and the stream must close (not hang
|
|
19
|
+
// waiting for a resolution that will never come).
|
|
20
|
+
|
|
21
|
+
describe('renderToStream — Suspense boundary rejection', () => {
|
|
22
|
+
test('keeps fallback visible and closes stream when async child rejects', async () => {
|
|
23
|
+
async function Rejects(): Promise<ReturnType<typeof h>> {
|
|
24
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
25
|
+
throw new Error('deliberate test failure')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const vnode = h(Suspense, {
|
|
29
|
+
fallback: h('p', { id: 'fallback' }, 'loading...'),
|
|
30
|
+
children: h(Rejects as unknown as ComponentFn, null),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// If the stream hangs, this test will time out. Passing means it
|
|
34
|
+
// closed cleanly via controller.close().
|
|
35
|
+
const html = await collectStream(renderToStream(vnode))
|
|
36
|
+
|
|
37
|
+
// Fallback placeholder + content are present
|
|
38
|
+
expect(html).toContain('id="pyreon-s-0"')
|
|
39
|
+
expect(html).toContain('loading...')
|
|
40
|
+
|
|
41
|
+
// NO swap template or __NS invocation for this boundary — those only
|
|
42
|
+
// emit on successful resolution. The __NS helper FUNCTION is always
|
|
43
|
+
// inlined once per stream; distinguish definition vs call.
|
|
44
|
+
expect(html).not.toContain('id="pyreon-t-0"')
|
|
45
|
+
expect(html).not.toContain('__NS("pyreon-s-0"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('one rejecting boundary does not abort siblings — other content still streams', async () => {
|
|
49
|
+
async function Rejects(): Promise<ReturnType<typeof h>> {
|
|
50
|
+
await new Promise<void>((r) => setTimeout(r, 5))
|
|
51
|
+
throw new Error('sibling rejection')
|
|
52
|
+
}
|
|
53
|
+
async function Resolves(): Promise<ReturnType<typeof h>> {
|
|
54
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
55
|
+
return h('span', { id: 'ok' }, 'ok')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const vnode = h(
|
|
59
|
+
'div',
|
|
60
|
+
null,
|
|
61
|
+
h(Suspense, {
|
|
62
|
+
fallback: h('span', { id: 'fb-a' }, 'fb-a'),
|
|
63
|
+
children: h(Rejects as unknown as ComponentFn, null),
|
|
64
|
+
}),
|
|
65
|
+
h(Suspense, {
|
|
66
|
+
fallback: h('span', { id: 'fb-b' }, 'fb-b'),
|
|
67
|
+
children: h(Resolves as unknown as ComponentFn, null),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const html = await collectStream(renderToStream(vnode))
|
|
72
|
+
|
|
73
|
+
// Both fallbacks shipped
|
|
74
|
+
expect(html).toContain('id="fb-a"')
|
|
75
|
+
expect(html).toContain('id="fb-b"')
|
|
76
|
+
// The resolving sibling's swap still went through. The rejecting
|
|
77
|
+
// sibling (boundary id 0) must NOT swap; the resolving one (id 1)
|
|
78
|
+
// must. Assert the specific invocations.
|
|
79
|
+
expect(html).toContain('id="ok"')
|
|
80
|
+
expect(html).not.toContain('__NS("pyreon-s-0"')
|
|
81
|
+
expect(html).toContain('__NS("pyreon-s-1"')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { renderToString } from '../index'
|
|
3
|
+
|
|
4
|
+
// Security sweep follow-up to #233/#235. `vnode.type` is interpolated
|
|
5
|
+
// into `<TAG>` and `</TAG>` unescaped (matching React/Vue/Solid — the
|
|
6
|
+
// framework trusts callers not to feed user-controlled strings as tag
|
|
7
|
+
// names). Defense-in-depth: dev-mode warning when the tag contains
|
|
8
|
+
// characters that would break HTML structure, so the mistake surfaces
|
|
9
|
+
// before it ships.
|
|
10
|
+
|
|
11
|
+
describe('SSR — dev warning for unsafe tag names', () => {
|
|
12
|
+
let originalNodeEnv: string | undefined
|
|
13
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
originalNodeEnv = process.env.NODE_ENV
|
|
17
|
+
// Ensure __DEV__ is true for the duration of these tests
|
|
18
|
+
process.env.NODE_ENV = 'development'
|
|
19
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV
|
|
24
|
+
else process.env.NODE_ENV = originalNodeEnv
|
|
25
|
+
warnSpy.mockRestore()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('does not warn for a normal HTML tag', async () => {
|
|
29
|
+
await renderToString(h('div', null, 'ok'))
|
|
30
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does not warn for custom elements with hyphens', async () => {
|
|
34
|
+
await renderToString(h('my-element', null, 'ok'))
|
|
35
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('warns when the tag contains > (HTML breakout attempt)', async () => {
|
|
39
|
+
await renderToString(h('div><script>alert(1)</script><div', null, 'x'))
|
|
40
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
41
|
+
const msg = warnSpy.mock.calls[0]?.[0] as string
|
|
42
|
+
expect(msg).toContain('[Pyreon SSR]')
|
|
43
|
+
expect(msg).toContain('break HTML structure')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('warns when the tag contains a space (attribute-smuggling attempt)', async () => {
|
|
47
|
+
await renderToString(h('div onerror=alert(1)', null, 'x'))
|
|
48
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('warns when the tag starts with a non-letter', async () => {
|
|
52
|
+
await renderToString(h('123-bad', null, 'x'))
|
|
53
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { renderToString } from '../index'
|
|
3
|
+
|
|
4
|
+
// Lock-in regression suite for attribute-value escaping. Flagged as
|
|
5
|
+
// PR #233 follow-up: would a user-supplied string with embedded
|
|
6
|
+
// HTML-significant characters break out of an attribute value and
|
|
7
|
+
// inject arbitrary markup? Verified: escapeHtml covers all five
|
|
8
|
+
// critical characters (& < > " '); this suite locks the invariant.
|
|
9
|
+
|
|
10
|
+
describe('renderToString — attribute value escaping', () => {
|
|
11
|
+
it('escapes double quotes in attribute values (breakout prevention)', async () => {
|
|
12
|
+
const html = await renderToString(h('div', { 'data-x': 'he said "hi"' }))
|
|
13
|
+
expect(html).toContain('data-x="he said "hi""')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('escapes single quotes in attribute values', async () => {
|
|
17
|
+
const html = await renderToString(h('div', { 'data-x': "it's fine" }))
|
|
18
|
+
expect(html).toContain('data-x="it's fine"')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('escapes ampersands (prevent entity confusion)', async () => {
|
|
22
|
+
const html = await renderToString(h('a', { href: 'https://x.test?a=1&b=2' }))
|
|
23
|
+
expect(html).toContain('href="https://x.test?a=1&b=2"')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('escapes < and > (prevent tag injection via attribute)', async () => {
|
|
27
|
+
const html = await renderToString(h('div', { 'data-x': '<script>bad</script>' }))
|
|
28
|
+
expect(html).toContain('data-x="<script>bad</script>"')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('escapes all five critical characters in a single value', async () => {
|
|
32
|
+
const html = await renderToString(h('div', { title: `&<>"'` }))
|
|
33
|
+
expect(html).toContain('title="&<>"'"')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('escapes text children containing HTML-significant chars', async () => {
|
|
37
|
+
const html = await renderToString(h('p', null, 'a & b < c > "d"'))
|
|
38
|
+
expect(html).toContain('a & b < c > "d"')
|
|
39
|
+
})
|
|
40
|
+
})
|