@pyreon/runtime-server 0.11.5 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ bun add @pyreon/runtime-server
11
11
  ## Quick Start
12
12
 
13
13
  ```tsx
14
- import { renderToString } from "@pyreon/runtime-server"
14
+ import { renderToString } from '@pyreon/runtime-server'
15
15
 
16
16
  function App() {
17
17
  return <h1>Hello from SSR</h1>
@@ -32,11 +32,11 @@ Render a VNode tree to a complete HTML string. Returns `Promise<string>`. Async
32
32
  Render a VNode tree to a `ReadableStream<string>` for progressive HTML streaming. Synchronous subtrees are flushed immediately. Suspense boundaries are streamed out-of-order: the fallback is emitted first, then resolved children are sent as `<template>` elements with inline swap scripts.
33
33
 
34
34
  ```tsx
35
- import { renderToStream } from "@pyreon/runtime-server"
35
+ import { renderToStream } from '@pyreon/runtime-server'
36
36
 
37
37
  const stream = renderToStream(<App />)
38
38
  return new Response(stream, {
39
- headers: { "Content-Type": "text/html" },
39
+ headers: { 'Content-Type': 'text/html' },
40
40
  })
41
41
  ```
42
42
 
@@ -45,7 +45,7 @@ return new Response(stream, {
45
45
  Run an async function with a fresh, isolated context stack and store registry. Useful for calling Pyreon APIs (e.g. `useHead`, route loader prefetching) outside of `renderToString` while maintaining per-request isolation.
46
46
 
47
47
  ```ts
48
- import { runWithRequestContext } from "@pyreon/runtime-server"
48
+ import { runWithRequestContext } from '@pyreon/runtime-server'
49
49
 
50
50
  const result = await runWithRequestContext(async () => {
51
51
  // Pyreon context and stores are isolated to this call
@@ -58,9 +58,9 @@ const result = await runWithRequestContext(async () => {
58
58
  Wire up per-request store isolation for concurrent SSR. Call once at server startup with a function that hooks your store registry into the request-scoped `AsyncLocalStorage`. Prevents store state from leaking between requests.
59
59
 
60
60
  ```ts
61
- import { configureStoreIsolation } from "@pyreon/runtime-server"
61
+ import { configureStoreIsolation } from '@pyreon/runtime-server'
62
62
 
63
- configureStoreIsolation(provider => {
63
+ configureStoreIsolation((provider) => {
64
64
  // Wire your store registry to use the per-request provider
65
65
  })
66
66
  ```
@@ -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":"b6b89270-1"}]}],"isRoot":true},"nodeParts":{"b6b89270-1":{"renderedLength":11206,"gzipLength":3685,"brotliLength":0,"metaUid":"b6b89270-0"}},"nodeMetas":{"b6b89270-0":{"id":"/src/index.ts","moduleParts":{"index.js":"b6b89270-1"},"imported":[{"uid":"b6b89270-2"},{"uid":"b6b89270-3"}],"importedBy":[],"isEntry":true},"b6b89270-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"b6b89270-0"}]},"b6b89270-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"b6b89270-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"1c48f70e-1"}]}],"isRoot":true},"nodeParts":{"1c48f70e-1":{"renderedLength":11382,"gzipLength":3735,"brotliLength":0,"metaUid":"1c48f70e-0"}},"nodeMetas":{"1c48f70e-0":{"id":"/src/index.ts","moduleParts":{"index.js":"1c48f70e-1"},"imported":[{"uid":"1c48f70e-2"},{"uid":"1c48f70e-3"}],"importedBy":[],"isEntry":true},"1c48f70e-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"1c48f70e-0"}]},"1c48f70e-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"1c48f70e-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
@@ -303,9 +303,16 @@ function toAttrName(key) {
303
303
  if (key === "htmlFor") return "for";
304
304
  return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
305
305
  }
306
+ function isStyleObject(value) {
307
+ if (!value) return false;
308
+ return typeof value === "object";
309
+ }
306
310
  function normalizeStyle(value) {
307
311
  if (typeof value === "string") return value;
308
- if (typeof value === "object" && value !== null) return Object.entries(value).map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`).join("; ");
312
+ if (isStyleObject(value)) {
313
+ const proto = Object.getPrototypeOf(value);
314
+ if (proto === Object.prototype || proto === null) return Object.entries(value).map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`).join("; ");
315
+ }
309
316
  return "";
310
317
  }
311
318
  function toKebab(str) {
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 } = 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 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 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 ctx.suspenseDepth++\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 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 } = vnode.props as unknown as ForProps<unknown>\n let forHtml = \"<!--pyreon-for-->\"\n for (const item of each()) forHtml += await renderNode(children(item) as VNodeChild)\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 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\") 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 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)}: ${normalizeStyleValue(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 \"&\": \"&amp;\",\n \"<\": \"&lt;\",\n \">\": \"&gt;\",\n '\"': \"&quot;\",\n \"'\": \"&#39;\",\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 } = 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 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 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 ctx.suspenseDepth++\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 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 } = vnode.props as unknown as ForProps<unknown>\n let forHtml = '<!--pyreon-for-->'\n for (const item of each()) forHtml += await renderNode(children(item) as VNodeChild)\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 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') 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 '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&#39;',\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/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-server",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
5
+ "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/pyreon/pyreon/issues"
8
+ },
5
9
  "license": "MIT",
6
10
  "repository": {
7
11
  "type": "git",
8
12
  "url": "https://github.com/pyreon/pyreon.git",
9
13
  "directory": "packages/core/runtime-server"
10
14
  },
11
- "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
12
- "bugs": {
13
- "url": "https://github.com/pyreon/pyreon/issues"
14
- },
15
15
  "files": [
16
16
  "lib",
17
17
  "src",
18
18
  "README.md",
19
19
  "LICENSE"
20
20
  ],
21
- "sideEffects": false,
22
21
  "type": "module",
22
+ "sideEffects": false,
23
23
  "main": "./lib/index.js",
24
24
  "module": "./lib/index.js",
25
25
  "types": "./lib/types/index.d.ts",
@@ -30,19 +30,19 @@
30
30
  "types": "./lib/types/index.d.ts"
31
31
  }
32
32
  },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
33
36
  "scripts": {
34
37
  "build": "vl_rolldown_build",
35
38
  "dev": "vl_rolldown_build-watch",
36
39
  "test": "vitest run",
37
40
  "typecheck": "tsc --noEmit",
38
- "lint": "biome check .",
41
+ "lint": "oxlint .",
39
42
  "prepublishOnly": "bun run build"
40
43
  },
41
44
  "dependencies": {
42
- "@pyreon/core": "^0.11.5",
43
- "@pyreon/reactivity": "^0.11.5"
44
- },
45
- "publishConfig": {
46
- "access": "public"
45
+ "@pyreon/core": "^0.11.6",
46
+ "@pyreon/reactivity": "^0.11.6"
47
47
  }
48
48
  }
package/src/index.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * renderToStream(vnode) → ReadableStream<string>
14
14
  */
15
15
 
16
- import { AsyncLocalStorage } from "node:async_hooks"
17
- import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from "@pyreon/core"
16
+ import { AsyncLocalStorage } from 'node:async_hooks'
17
+ import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'
18
18
  import {
19
19
  cx,
20
20
  ForSymbol,
@@ -23,9 +23,9 @@ import {
23
23
  runWithHooks,
24
24
  Suspense,
25
25
  setContextStackProvider,
26
- } from "@pyreon/core"
26
+ } from '@pyreon/core'
27
27
 
28
- const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
28
+ const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
29
29
 
30
30
  // ─── Streaming Suspense context ───────────────────────────────────────────────
31
31
  // Tracks in-flight async Suspense boundary resolutions within a single stream.
@@ -81,7 +81,7 @@ function withStoreContext<T>(fn: () => T): T {
81
81
 
82
82
  /** Render a VNode tree to an HTML string. Supports async component functions. */
83
83
  export async function renderToString(root: VNode | null): Promise<string> {
84
- if (root === null) return ""
84
+ if (root === null) return ''
85
85
  // Each call gets a fresh isolated context stack and (optionally) store registry
86
86
  return withStoreContext(() => _contextAls.run([], () => renderNode(root)))
87
87
  }
@@ -147,13 +147,13 @@ async function streamVNode(vnode: VNode, enqueue: (s: string) => void): Promise<
147
147
 
148
148
  if (vnode.type === (ForSymbol as unknown as string)) {
149
149
  const { each, children } = vnode.props as unknown as ForProps<unknown>
150
- enqueue("<!--pyreon-for-->")
150
+ enqueue('<!--pyreon-for-->')
151
151
  for (const item of each()) await streamNode(children(item) as VNodeChild, enqueue)
152
- enqueue("<!--/pyreon-for-->")
152
+ enqueue('<!--/pyreon-for-->')
153
153
  return
154
154
  }
155
155
 
156
- if (typeof vnode.type === "function") {
156
+ if (typeof vnode.type === 'function') {
157
157
  await streamComponentNode(vnode, enqueue)
158
158
  return
159
159
  }
@@ -172,7 +172,7 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
172
172
  if (resolved !== null) await streamNode(resolved, enqueue)
173
173
  } catch (err) {
174
174
  if (__DEV__) {
175
- const name = (vnode.type as ComponentFn).name || "Anonymous"
175
+ const name = (vnode.type as ComponentFn).name || 'Anonymous'
176
176
  console.error(`[Pyreon SSR] Error rendering <${name}>:`, err)
177
177
  }
178
178
  // Inside a Suspense child resolution, re-throw so the boundary can catch and
@@ -180,7 +180,7 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
180
180
  // error and emit a marker so the stream can continue.
181
181
  const ctx = _streamCtxAls.getStore()
182
182
  if (ctx && ctx.suspenseDepth > 0) throw err
183
- enqueue("<!--pyreon-error-->")
183
+ enqueue('<!--pyreon-error-->')
184
184
  }
185
185
  }
186
186
 
@@ -205,15 +205,15 @@ async function streamNode(
205
205
  node: VNodeChild | null | (() => VNodeChild),
206
206
  enqueue: (s: string) => void,
207
207
  ): Promise<void> {
208
- if (typeof node === "function") {
208
+ if (typeof node === 'function') {
209
209
  return streamNode((node as () => VNodeChild)(), enqueue)
210
210
  }
211
211
  if (node == null || node === false) return
212
- if (typeof node === "string") {
212
+ if (typeof node === 'string') {
213
213
  enqueue(escapeHtml(node))
214
214
  return
215
215
  }
216
- if (typeof node === "number" || typeof node === "boolean") {
216
+ if (typeof node === 'number' || typeof node === 'boolean') {
217
217
  enqueue(String(node))
218
218
  return
219
219
  }
@@ -227,8 +227,8 @@ async function streamNode(
227
227
 
228
228
  // Inline swap helper emitted once per stream, before the first <template>
229
229
  const SUSPENSE_SWAP_FN =
230
- "<script>function __NS(s,t){var e=document.getElementById(s),l=document.getElementById(t);" +
231
- "if(e&&l){e.replaceWith(l.content.cloneNode(!0));l.remove()}}</script>"
230
+ '<script>function __NS(s,t){var e=document.getElementById(s),l=document.getElementById(t);' +
231
+ 'if(e&&l){e.replaceWith(l.content.cloneNode(!0));l.remove()}}</script>'
232
232
 
233
233
  /**
234
234
  * Stream a Suspense boundary: emit fallback immediately, then resolve children
@@ -257,7 +257,7 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
257
257
  // Stream the fallback synchronously (no await on children)
258
258
  mainEnqueue(`<div id="pyreon-s-${id}">`)
259
259
  await streamNode(fallback ?? null, enqueue)
260
- mainEnqueue("</div>")
260
+ mainEnqueue('</div>')
261
261
 
262
262
  // Capture the context store for the async resolution so it inherits context
263
263
  const ctxStore = _contextAls.getStore() ?? []
@@ -270,7 +270,7 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
270
270
  ctx.suspenseDepth++
271
271
  const buf: string[] = []
272
272
  await streamNode(children ?? null, (s) => buf.push(s))
273
- mainEnqueue(`<template id="pyreon-t-${id}">${buf.join("")}</template>`)
273
+ mainEnqueue(`<template id="pyreon-t-${id}">${buf.join('')}</template>`)
274
274
  mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")</script>`)
275
275
  } catch (err) {
276
276
  if (__DEV__) {
@@ -291,17 +291,17 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
291
291
 
292
292
  async function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string> {
293
293
  // Reactive accessor — call it synchronously (snapshot)
294
- if (typeof node === "function") {
294
+ if (typeof node === 'function') {
295
295
  return renderNode((node as () => VNodeChild)())
296
296
  }
297
297
 
298
- if (node == null || node === false) return ""
298
+ if (node == null || node === false) return ''
299
299
 
300
- if (typeof node === "string") return escapeHtml(node)
301
- if (typeof node === "number" || typeof node === "boolean") return String(node)
300
+ if (typeof node === 'string') return escapeHtml(node)
301
+ if (typeof node === 'number' || typeof node === 'boolean') return String(node)
302
302
 
303
303
  if (Array.isArray(node)) {
304
- let html = ""
304
+ let html = ''
305
305
  for (const child of node) html += await renderNode(child)
306
306
  return html
307
307
  }
@@ -314,13 +314,13 @@ async function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string
314
314
 
315
315
  if (vnode.type === (ForSymbol as unknown as string)) {
316
316
  const { each, children } = vnode.props as unknown as ForProps<unknown>
317
- let forHtml = "<!--pyreon-for-->"
317
+ let forHtml = '<!--pyreon-for-->'
318
318
  for (const item of each()) forHtml += await renderNode(children(item) as VNodeChild)
319
- forHtml += "<!--/pyreon-for-->"
319
+ forHtml += '<!--/pyreon-for-->'
320
320
  return forHtml
321
321
  }
322
322
 
323
- if (typeof vnode.type === "function") {
323
+ if (typeof vnode.type === 'function') {
324
324
  return renderComponent(vnode as VNode & { type: ComponentFn })
325
325
  }
326
326
 
@@ -328,7 +328,7 @@ async function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string
328
328
  }
329
329
 
330
330
  async function renderChildren(children: VNodeChild[]): Promise<string> {
331
- let html = ""
331
+ let html = ''
332
332
  for (const child of children) html += await renderNode(child)
333
333
  return html
334
334
  }
@@ -339,11 +339,11 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
339
339
  // Async component function (async function Component()) — await the promise
340
340
  if (output instanceof Promise) {
341
341
  const resolved = await output
342
- if (resolved === null) return ""
342
+ if (resolved === null) return ''
343
343
  return renderNode(resolved)
344
344
  }
345
345
 
346
- if (output === null) return ""
346
+ if (output === null) return ''
347
347
  return renderNode(output)
348
348
  }
349
349
 
@@ -358,11 +358,11 @@ async function renderElement(vnode: VNode): Promise<string> {
358
358
  }
359
359
 
360
360
  if (isVoidElement(tag)) {
361
- html += " />"
361
+ html += ' />'
362
362
  return html
363
363
  }
364
364
 
365
- html += ">"
365
+ html += '>'
366
366
 
367
367
  for (const child of vnode.children) {
368
368
  html += await renderNode(child)
@@ -372,11 +372,11 @@ async function renderElement(vnode: VNode): Promise<string> {
372
372
  return html
373
373
  }
374
374
 
375
- const SSR_URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "cite", "data"])
375
+ const SSR_URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
376
376
  const SSR_UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
377
377
 
378
378
  function renderPropSkipped(key: string): boolean {
379
- if (key === "key" || key === "ref") return true
379
+ if (key === 'key' || key === 'ref') return true
380
380
  if (/^on[A-Z]/.test(key)) return true
381
381
  return false
382
382
  }
@@ -385,12 +385,12 @@ function renderPropValue(key: string, value: unknown): string | null {
385
385
  if (value === null || value === undefined || value === false) return null
386
386
  if (value === true) return escapeHtml(toAttrName(key))
387
387
 
388
- if (key === "class") {
388
+ if (key === 'class') {
389
389
  const cls = cx(value as ClassValue)
390
390
  return cls ? `class="${escapeHtml(cls)}"` : null
391
391
  }
392
392
 
393
- if (key === "style") {
393
+ if (key === 'style') {
394
394
  const style = normalizeStyle(value)
395
395
  return style ? `style="${escapeHtml(style)}"` : null
396
396
  }
@@ -401,11 +401,11 @@ function renderPropValue(key: string, value: unknown): string | null {
401
401
  function renderProp(key: string, value: unknown): string | null {
402
402
  if (renderPropSkipped(key)) return null
403
403
 
404
- if (typeof value === "function") {
404
+ if (typeof value === 'function') {
405
405
  return renderProp(key, (value as () => unknown)())
406
406
  }
407
407
 
408
- if (SSR_URL_ATTRS.has(key) && typeof value === "string" && SSR_UNSAFE_URL_RE.test(value)) {
408
+ if (SSR_URL_ATTRS.has(key) && typeof value === 'string' && SSR_UNSAFE_URL_RE.test(value)) {
409
409
  return null
410
410
  }
411
411
 
@@ -415,20 +415,20 @@ function renderProp(key: string, value: unknown): string | null {
415
415
  // ─── Helpers ─────────────────────────────────────────────────────────────────
416
416
 
417
417
  const VOID_ELEMENTS = new Set([
418
- "area",
419
- "base",
420
- "br",
421
- "col",
422
- "embed",
423
- "hr",
424
- "img",
425
- "input",
426
- "link",
427
- "meta",
428
- "param",
429
- "source",
430
- "track",
431
- "wbr",
418
+ 'area',
419
+ 'base',
420
+ 'br',
421
+ 'col',
422
+ 'embed',
423
+ 'hr',
424
+ 'img',
425
+ 'input',
426
+ 'link',
427
+ 'meta',
428
+ 'param',
429
+ 'source',
430
+ 'track',
431
+ 'wbr',
432
432
  ])
433
433
 
434
434
  function isVoidElement(tag: string): boolean {
@@ -437,19 +437,27 @@ function isVoidElement(tag: string): boolean {
437
437
 
438
438
  /** camelCase prop → kebab-case HTML attribute (e.g. className → class, htmlFor → for) */
439
439
  function toAttrName(key: string): string {
440
- if (key === "className") return "class"
441
- if (key === "htmlFor") return "for"
440
+ if (key === 'className') return 'class'
441
+ if (key === 'htmlFor') return 'for'
442
442
  return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
443
443
  }
444
444
 
445
+ function isStyleObject(value: unknown): value is Record<string, unknown> {
446
+ if (!value) return false
447
+ return typeof value === 'object'
448
+ }
449
+
445
450
  function normalizeStyle(value: unknown): string {
446
- if (typeof value === "string") return value
447
- if (typeof value === "object" && value !== null) {
448
- return Object.entries(value as Record<string, unknown>)
449
- .map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`)
450
- .join("; ")
451
+ if (typeof value === 'string') return value
452
+ if (isStyleObject(value)) {
453
+ const proto = Object.getPrototypeOf(value)
454
+ if (proto === Object.prototype || proto === null) {
455
+ return Object.entries(value)
456
+ .map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`)
457
+ .join('; ')
458
+ }
451
459
  }
452
- return ""
460
+ return ''
453
461
  }
454
462
 
455
463
  function toKebab(str: string): string {
@@ -457,11 +465,11 @@ function toKebab(str: string): string {
457
465
  }
458
466
 
459
467
  const ESCAPE_MAP: Record<string, string> = {
460
- "&": "&amp;",
461
- "<": "&lt;",
462
- ">": "&gt;",
463
- '"': "&quot;",
464
- "'": "&#39;",
468
+ '&': '&amp;',
469
+ '<': '&lt;',
470
+ '>': '&gt;',
471
+ '"': '&quot;',
472
+ "'": '&#39;',
465
473
  }
466
474
 
467
475
  // Fast test — most strings in SSR have no special chars (tag names, class names, etc.)