@pyreon/runtime-server 0.24.5 → 0.24.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/src/index.ts DELETED
@@ -1,885 +0,0 @@
1
- /**
2
- * @pyreon/runtime-server — SSR/SSG renderer for Pyreon.
3
- *
4
- * Walks a VNode tree and produces HTML strings.
5
- * Signal accessors (reactive getters `() => value`) are called synchronously
6
- * to snapshot their current value — no effects are set up on the server.
7
- *
8
- * Async components (`async function Component()`) are fully supported:
9
- * renderToString will await them before continuing the tree walk.
10
- *
11
- * API:
12
- * renderToString(vnode) → Promise<string>
13
- * renderToStream(vnode) → ReadableStream<string>
14
- */
15
-
16
- import { AsyncLocalStorage } from 'node:async_hooks'
17
- import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'
18
- import {
19
- cx,
20
- ForSymbol,
21
- Fragment,
22
- getContextStackLength,
23
- makeReactiveProps,
24
- normalizeStyleValue,
25
- popContext,
26
- runWithHooks,
27
- Suspense,
28
- setContextStackProvider,
29
- } from '@pyreon/core'
30
-
31
- const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
32
-
33
- // Dev-mode perf counter sink. Zero coupling to @pyreon/perf-harness — we just
34
- // call the global if it's installed. Guarded on __DEV__ so NODE_ENV=production
35
- // short-circuits at runtime; @pyreon/runtime-server is a server package, so the
36
- // `typeof process` gate is correct here (not `import.meta.env.DEV`, which is a
37
- // browser-bundler concern). See .claude/rules/test-environment-parity.md.
38
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
39
-
40
- // ─── Streaming Suspense context ───────────────────────────────────────────────
41
- // Tracks in-flight async Suspense boundary resolutions within a single stream.
42
-
43
- interface StreamCtx {
44
- pending: Promise<void>[]
45
- nextId: () => number
46
- mainEnqueue: (s: string) => void
47
- /** Depth counter — non-zero when rendering inside a Suspense child resolution. */
48
- suspenseDepth: number
49
- /**
50
- * Abort signal fired when EITHER the upstream caller's signal aborts
51
- * OR the stream consumer (`ReadableStream.cancel()`) closes. Boundary
52
- * resolvers check this before enqueuing post-resolve HTML so they
53
- * stop streaming once the client has hung up.
54
- */
55
- signal?: AbortSignal
56
- /**
57
- * Per-boundary Suspense timeout (ms). When an async Suspense child
58
- * doesn't resolve within this window, the fallback stays visible and
59
- * the resolved content is dropped. Set to `Infinity` to disable the
60
- * timeout entirely (apps that prefer waiting indefinitely over showing
61
- * the fallback). Defaults to 30_000 (30s) — matches the pre-config
62
- * hard-coded value, so unset is byte-identical to prior behavior.
63
- */
64
- suspenseTimeoutMs: number
65
- }
66
-
67
- const _streamCtxAls = new AsyncLocalStorage<StreamCtx>()
68
-
69
- // ─── Concurrent SSR context isolation ────────────────────────────────────────
70
- // Each renderToString call runs in its own ALS store (a fresh empty stack[]).
71
- // Concurrent requests never share context frames.
72
-
73
- const _contextAls = new AsyncLocalStorage<Map<symbol, unknown>[]>()
74
- const _fallbackStack: Map<symbol, unknown>[] = []
75
-
76
- setContextStackProvider(() => _contextAls.getStore() ?? _fallbackStack)
77
-
78
- // ─── Store isolation (optional) ───────────────────────────────────────────────
79
- // A second ALS isolates store registries between concurrent requests.
80
- // Activated only when the user calls configureStoreIsolation().
81
-
82
- const _storeAls = new AsyncLocalStorage<Map<string, unknown>>()
83
- let _storeIsolationActive = false
84
-
85
- /**
86
- * Wire up per-request store isolation.
87
- * Call once at server startup, passing a `setStoreRegistryProvider` function.
88
- *
89
- * @example
90
- * import { configureStoreIsolation } from "@pyreon/runtime-server"
91
- * configureStoreIsolation(setStoreRegistryProvider)
92
- */
93
- export function configureStoreIsolation(
94
- setStoreRegistryProvider: (fn: () => Map<string, unknown>) => void,
95
- ): void {
96
- setStoreRegistryProvider(() => _storeAls.getStore() ?? new Map())
97
- _storeIsolationActive = true
98
- }
99
-
100
- /** Wrap a function call in a fresh store registry (no-op if isolation not configured). */
101
- function withStoreContext<T>(fn: () => T): T {
102
- if (!_storeIsolationActive) return fn()
103
- return _storeAls.run(new Map(), fn)
104
- }
105
-
106
- // ─── Public API ───────────────────────────────────────────────────────────────
107
-
108
- /** Render a VNode tree to an HTML string. Supports async component functions. */
109
- export async function renderToString(root: VNode | null): Promise<string> {
110
- if (root === null) return ''
111
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.render')
112
- // Each call gets a fresh isolated context stack and (optionally) store registry
113
- return withStoreContext(() => _contextAls.run([], () => renderNode(root)))
114
- }
115
-
116
- /**
117
- * Run an async function with a fresh, isolated context stack and store registry.
118
- * Useful when you need to call Pyreon APIs (e.g. useHead, prefetchLoaderData)
119
- * outside of renderToString but still want per-request isolation.
120
- */
121
- export function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T> {
122
- return withStoreContext(() => _contextAls.run([], fn))
123
- }
124
-
125
- /**
126
- * Render a VNode tree to a Web-standard ReadableStream of HTML chunks.
127
- *
128
- * True progressive streaming: HTML is flushed to the client as soon as each
129
- * node is ready. Synchronous subtrees are enqueued immediately; async component
130
- * boundaries are awaited in-order and their output is enqueued as it resolves.
131
- *
132
- * Suspense boundaries are streamed out-of-order: the fallback is emitted
133
- * immediately, and the resolved children are sent as a `<template>` + inline
134
- * swap script once ready — without blocking the rest of the page.
135
- *
136
- * Each renderToStream call gets its own isolated ALS context stack.
137
- */
138
- export interface RenderToStreamOptions {
139
- /**
140
- * AbortSignal to cancel in-flight Suspense work when the client
141
- * disconnects (browser back-button, navigation, fetch reader closes,
142
- * etc.). On abort, the stream's `cancel()` fires the signal —
143
- * pending Suspense boundaries are abandoned (the fallback HTML they
144
- * already wrote stays in the buffer that's now closed) and the
145
- * background async work they spawned is no longer awaited. The work
146
- * itself isn't forcibly killed (JS has no async cancellation
147
- * primitive at the language level), but the framework stops blocking
148
- * on it. Pass your own signal from upstream (e.g. a `Request.signal`)
149
- * to chain abort propagation.
150
- */
151
- signal?: AbortSignal
152
- /**
153
- * Per-boundary Suspense timeout in milliseconds. When an async
154
- * Suspense child doesn't resolve within this window, its fallback
155
- * stays visible (the resolved content is dropped — no `<template>`
156
- * + swap script is emitted) and a dev-mode warning fires. Defaults
157
- * to 30_000 (30s); unset behavior is byte-identical to the
158
- * pre-config implementation.
159
- *
160
- * Ops control: tighten this for tight-SLA deploys (5_000–10_000 is
161
- * typical for user-facing apps where a fallback is preferable to a
162
- * delayed full render). Loosen it (or pass `Infinity` to disable)
163
- * for renders that legitimately need long async work — exports,
164
- * reports, scheduled jobs, etc.
165
- *
166
- * Values ≤0 or `NaN` fall back to the default. `Infinity` is honored
167
- * verbatim — the timeout race is skipped entirely so a hung boundary
168
- * keeps the stream open until the AbortSignal fires or the consumer
169
- * cancels.
170
- */
171
- suspenseTimeoutMs?: number
172
- }
173
-
174
- export function renderToStream(
175
- root: VNode | null,
176
- options: RenderToStreamOptions = {},
177
- ): ReadableStream<string> {
178
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.stream')
179
- // Internal AbortController — fires when EITHER the caller's signal
180
- // aborts (upstream cancellation, e.g. `Request.signal`) OR the consumer
181
- // of the stream calls `.cancel()` (client closed the fetch reader).
182
- const ac = new AbortController()
183
- const signal = ac.signal
184
- if (options.signal) {
185
- if (options.signal.aborted) ac.abort(options.signal.reason)
186
- else options.signal.addEventListener('abort', () => ac.abort(options.signal!.reason), { once: true })
187
- }
188
- // Resolve the Suspense timeout. Invalid input (≤0, NaN) falls back to
189
- // 30_000 — same as pre-config behavior. `Infinity` is preserved so the
190
- // boundary code can detect it and skip the race entirely.
191
- const userTimeout = options.suspenseTimeoutMs
192
- const suspenseTimeoutMs
193
- = userTimeout === Infinity
194
- ? Infinity
195
- : userTimeout !== undefined && Number.isFinite(userTimeout) && userTimeout > 0
196
- ? userTimeout
197
- : 30_000
198
-
199
- return new ReadableStream<string>({
200
- start(controller) {
201
- const enqueue = (chunk: string) => {
202
- if (signal.aborted) return // stop appending after abort
203
- controller.enqueue(chunk)
204
- }
205
- let bid = 0
206
- const ctx: StreamCtx = {
207
- pending: [],
208
- nextId: () => bid++,
209
- mainEnqueue: enqueue,
210
- suspenseDepth: 0,
211
- signal,
212
- suspenseTimeoutMs,
213
- }
214
- // One shared abort-promise — registered ONCE, resolved on signal
215
- // abort. Racing each pending batch against this lets the drain
216
- // loop exit promptly when the consumer hangs up, without accruing
217
- // one abort listener per loop iteration.
218
- const abortPromise: Promise<void> = signal.aborted
219
- ? Promise.resolve()
220
- : new Promise<void>((resolve) => {
221
- signal.addEventListener('abort', () => resolve(), { once: true })
222
- })
223
-
224
- return withStoreContext(() =>
225
- _contextAls.run([], () =>
226
- _streamCtxAls
227
- .run(ctx, async () => {
228
- await streamNode(root, enqueue)
229
- // Drain all pending Suspense resolutions (may spawn nested
230
- // ones). Each batch is RACED against the abort signal so a
231
- // mid-flight Suspense child doesn't keep us blocked after
232
- // the consumer hung up. Per-boundary work also checks
233
- // `ctx.signal.aborted` to skip its post-resolve enqueue.
234
- while (ctx.pending.length > 0) {
235
- if (signal.aborted) break
236
- const batch = Promise.all(ctx.pending.splice(0))
237
- await Promise.race([batch, abortPromise])
238
- }
239
- // ALWAYS close — gracefully on natural completion AND on
240
- // abort. (Pre-fix: `if (!aborted) close()` left the stream
241
- // open forever on cancel, hanging the reader.) Wrap in
242
- // try/catch because the stream may have already been
243
- // closed by `cancel()` upstream.
244
- try {
245
- controller.close()
246
- } catch {
247
- /* already closed (e.g. cancel raced ahead) */
248
- }
249
- })
250
- .catch((err) => {
251
- // Aborts are expected, not errors — close silently to mirror
252
- // a normal end-of-stream when the consumer hung up.
253
- if (signal.aborted) {
254
- try {
255
- controller.close()
256
- } catch {
257
- /* already closed */
258
- }
259
- return
260
- }
261
- controller.error(err)
262
- }),
263
- ),
264
- )
265
- },
266
- cancel(reason) {
267
- // Consumer (browser fetch reader) closed the stream — propagate to
268
- // the internal controller so in-flight Suspense work stops being
269
- // awaited.
270
- ac.abort(reason)
271
- },
272
- })
273
- }
274
-
275
- // ─── Streaming renderer ───────────────────────────────────────────────────────
276
-
277
- async function streamVNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {
278
- if (vnode.type === Fragment) {
279
- for (const child of vnode.children) await streamNode(child, enqueue)
280
- return
281
- }
282
-
283
- if (vnode.type === (ForSymbol as unknown as string)) {
284
- const { each, children, by } = vnode.props as unknown as ForProps<unknown>
285
- enqueue('<!--pyreon-for-->')
286
- // Defensive: `each` is normally `_rp(() => arr)` (a function). PR #410's
287
- // `makeReactiveProps` in `mergeChildrenIntoProps` invokes `_rp` getters
288
- // when the For COMPONENT runs, which flips `props.each` to the resolved
289
- // array on the re-emitted ForSymbol vnode. Accept both forms.
290
- const items = typeof each === 'function' ? each() : (each as Iterable<unknown>)
291
- for (const item of items) {
292
- const key = by(item)
293
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.for.keyMarker')
294
- enqueue(`<!--k:${safeKeyForMarker(key)}-->`)
295
- await streamNode(children(item) as VNodeChild, enqueue)
296
- }
297
- enqueue('<!--/pyreon-for-->')
298
- return
299
- }
300
-
301
- if (typeof vnode.type === 'function') {
302
- await streamComponentNode(vnode, enqueue)
303
- return
304
- }
305
-
306
- await streamElementNode(vnode, enqueue)
307
- }
308
-
309
- async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {
310
- if (vnode.type === Suspense) {
311
- await streamSuspenseBoundary(vnode, enqueue)
312
- return
313
- }
314
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.component')
315
- // Snapshot the context stack BEFORE the component renders so we can pop
316
- // any frames pushed via `provide()` after children stream. We do NOT run
317
- // user-registered unmount hooks during SSR — that would clear state still
318
- // needed by post-render extraction (e.g. `useHead` uses `onUnmount` to
319
- // remove its registered tags from the head store; running it during SSR
320
- // wipes the entries before `renderWithHead` reads them). See `renderComponent`
321
- // for the full architectural rationale.
322
- const stackLenBefore = getContextStackLength()
323
- try {
324
- const { vnode: output } = runWithHooks(
325
- vnode.type as ComponentFn,
326
- mergeChildrenIntoProps(vnode),
327
- )
328
- const resolved = output instanceof Promise ? await output : output
329
- if (resolved !== null) await streamNode(resolved, enqueue)
330
- } catch (err) {
331
- if (__DEV__) {
332
- const name = (vnode.type as ComponentFn).name || 'Anonymous'
333
- console.error(`[Pyreon SSR] Error rendering <${name}>:`, err)
334
- }
335
- // Inside a Suspense child resolution, re-throw so the boundary can catch and
336
- // suppress the swap (fallback stays visible). Outside Suspense, swallow the
337
- // error and emit a marker so the stream can continue.
338
- const ctx = _streamCtxAls.getStore()
339
- if (ctx && ctx.suspenseDepth > 0) throw err
340
- enqueue('<!--pyreon-error-->')
341
- } finally {
342
- trimContextStack(stackLenBefore)
343
- }
344
- }
345
-
346
- async function streamElementNode(vnode: VNode, enqueue: (s: string) => void): Promise<void> {
347
- const tag = vnode.type as string
348
- warnIfUnsafeTag(tag)
349
- let open = `<${tag}`
350
- const props = vnode.props as Record<string, unknown>
351
- for (const key in props) {
352
- const attr = renderProp(key, props[key])
353
- if (attr) open += ` ${attr}`
354
- }
355
- if (isVoidElement(tag)) {
356
- enqueue(`${open} />`)
357
- return
358
- }
359
- enqueue(`${open}>`)
360
- // `dangerouslySetInnerHTML` and `innerHTML` both become inner content
361
- // of the element (NOT attributes). Skipped in `renderPropSkipped` to
362
- // keep them out of the open-tag attribute list. Function values —
363
- // emitted by the compiler for signal-derived prop expressions — are
364
- // called once at render time (SSR is one-shot; any reactivity happens
365
- // post-hydration on the client).
366
- const dangerous = props.dangerouslySetInnerHTML as { __html: string } | (() => { __html: string }) | undefined
367
- const innerHtml = props.innerHTML as string | (() => string) | undefined
368
- const dangerousHtml =
369
- typeof dangerous === 'function' ? (dangerous as () => { __html: string })()?.__html : dangerous?.__html
370
- const plainInnerHtml =
371
- typeof innerHtml === 'function' ? (innerHtml as () => string)() : innerHtml
372
- if (dangerousHtml) {
373
- enqueue(dangerousHtml)
374
- } else if (plainInnerHtml != null && plainInnerHtml !== '') {
375
- enqueue(String(plainInnerHtml))
376
- } else {
377
- for (const child of vnode.children) await streamNode(child, enqueue)
378
- }
379
- enqueue(`</${tag}>`)
380
- }
381
-
382
- async function streamNode(
383
- node: VNodeChild | null | (() => VNodeChild),
384
- enqueue: (s: string) => void,
385
- ): Promise<void> {
386
- if (typeof node === 'function') {
387
- return streamNode((node as () => VNodeChild)(), enqueue)
388
- }
389
- if (node == null || node === false) return
390
- if (typeof node === 'string') {
391
- enqueue(escapeHtml(node))
392
- return
393
- }
394
- if (typeof node === 'number' || typeof node === 'boolean') {
395
- enqueue(String(node))
396
- return
397
- }
398
- if (Array.isArray(node)) {
399
- for (const child of node) await streamNode(child, enqueue)
400
- return
401
- }
402
-
403
- await streamVNode(node as VNode, enqueue)
404
- }
405
-
406
- // Inline swap helper emitted once per stream, before the first <template>
407
- const SUSPENSE_SWAP_FN =
408
- '<script>function __NS(s,t){var e=document.getElementById(s),l=document.getElementById(t);' +
409
- 'if(e&&l){e.replaceWith(l.content.cloneNode(!0));l.remove()}}</script>'
410
-
411
- /**
412
- * Stream a Suspense boundary: emit fallback immediately, then resolve children
413
- * asynchronously and emit them as a `<template>` + client-side swap.
414
- *
415
- * The actual children HTML is buffered until fully resolved, then emitted to the
416
- * main stream enqueue so it always arrives after the fallback placeholder.
417
- */
418
- async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void): Promise<void> {
419
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.suspense.boundary')
420
- const ctx = _streamCtxAls.getStore()
421
- const { fallback, children } = vnode.props as { fallback: VNodeChild; children?: VNodeChild }
422
-
423
- // Defensive: the streaming pipeline only enters this function via
424
- // `_streamCtxAls.run(ctx, ...)` (set up in `renderToStream`), so `ctx`
425
- // is always defined when `streamSuspenseBoundary` runs. Kept as a safety
426
- // net in case a future entry point bypasses the streaming context — the
427
- // block performs the same context-stack hygiene as `renderComponent` /
428
- // `streamComponentNode` so a Suspense `provide()` wouldn't leak into
429
- // siblings if it ever fires. Excluded from coverage because no public-API
430
- // path reaches it; including a unit test would either require exporting
431
- // `streamSuspenseBoundary` (leaks an internal) or stubbing the ALS (false
432
- // signal).
433
- /* c8 ignore start */
434
- if (!ctx) {
435
- const stackLenBefore = getContextStackLength()
436
- const { vnode: output } = runWithHooks(Suspense as ComponentFn, vnode.props)
437
- try {
438
- if (output !== null) await streamNode(output, enqueue)
439
- } finally {
440
- trimContextStack(stackLenBefore)
441
- }
442
- return
443
- }
444
- /* c8 ignore stop */
445
-
446
- const id = ctx.nextId()
447
- const { mainEnqueue } = ctx
448
-
449
- // Emit the swap helper function once (before first use)
450
- if (id === 0) mainEnqueue(SUSPENSE_SWAP_FN)
451
-
452
- // Stream the fallback synchronously (no await on children)
453
- mainEnqueue(`<div id="pyreon-s-${id}">`)
454
- await streamNode(fallback ?? null, enqueue)
455
- mainEnqueue('</div>')
456
-
457
- // Capture the context store for the async resolution so it inherits context
458
- const ctxStore = _contextAls.getStore() ?? []
459
-
460
- // Queue async resolution — runs in parallel, emits to main stream when done.
461
- // Errors are caught per-boundary so one failing Suspense doesn't abort the stream.
462
- // Timeout prevents hung async children from keeping the stream open forever.
463
- // Configurable via `RenderToStreamOptions.suspenseTimeoutMs` (default 30_000;
464
- // `Infinity` disables the race so a hung boundary waits indefinitely until
465
- // the upstream AbortSignal or consumer cancel fires).
466
- const suspenseTimeoutMs = ctx.suspenseTimeoutMs
467
-
468
- ctx.pending.push(
469
- _contextAls.run(ctxStore, async () => {
470
- try {
471
- ctx.suspenseDepth++
472
- const buf: string[] = []
473
-
474
- // Race the async children against a timeout (skipped when the
475
- // user passed `Infinity` to opt out). Class I — capture the
476
- // timer id and clear on the success path; without this, every
477
- // successful Suspense boundary leaks a pending timer + resolve
478
- // callback until it fires. Caught by the `audit-leak-classes`
479
- // script's promise-race-no-clear detector.
480
- let result: 'resolved' | 'timeout'
481
- if (suspenseTimeoutMs === Infinity) {
482
- // No-timeout mode: just await children. AbortSignal still
483
- // applies via the outer drain-loop race in renderToStream.
484
- await streamNode(children ?? null, (s) => buf.push(s))
485
- result = 'resolved'
486
- } else {
487
- let timeoutId: ReturnType<typeof setTimeout> | undefined
488
- try {
489
- result = await Promise.race([
490
- streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),
491
- new Promise<'timeout'>((resolve) => {
492
- timeoutId = setTimeout(() => resolve('timeout'), suspenseTimeoutMs)
493
- }),
494
- ])
495
- }
496
- finally {
497
- if (timeoutId !== undefined) clearTimeout(timeoutId)
498
- }
499
- }
500
-
501
- if (result === 'timeout') {
502
- if (__DEV__) {
503
- _countSink.__pyreon_count__?.('runtime-server.suspense.fallback')
504
- console.warn(
505
- `[Pyreon SSR] Suspense boundary timed out after ${suspenseTimeoutMs}ms — fallback will remain.`,
506
- )
507
- }
508
- // Fallback stays visible — no swap
509
- return
510
- }
511
-
512
- // Client disconnected (or upstream aborted) while we were
513
- // resolving — don't bother enqueueing the post-resolve content.
514
- // The drain loop also checks `signal.aborted` so the stream
515
- // closes promptly without us racing it.
516
- if (ctx.signal?.aborted) return
517
-
518
- // Escape </template> in buffered content to prevent early close + XSS
519
- const content = buf.join('').replace(/<\/template/gi, '<\\/template')
520
- mainEnqueue(`<template id="pyreon-t-${id}">${content}</template>`)
521
- mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")</script>`)
522
- } catch (err) {
523
- if (__DEV__) {
524
- console.error(
525
- `[Pyreon SSR] Suspense boundary caught an error — fallback will remain:`,
526
- err,
527
- )
528
- }
529
- // Fallback stays visible — no swap script emitted
530
- } finally {
531
- ctx.suspenseDepth--
532
- }
533
- }),
534
- )
535
- }
536
-
537
- // ─── Core renderer ───────────────────────────────────────────────────────────
538
-
539
- async function renderNode(node: VNodeChild | (() => VNodeChild)): Promise<string> {
540
- // Reactive accessor — call it synchronously (snapshot)
541
- if (typeof node === 'function') {
542
- return renderNode((node as () => VNodeChild)())
543
- }
544
-
545
- if (node == null || node === false) return ''
546
-
547
- if (typeof node === 'string') return escapeHtml(node)
548
- if (typeof node === 'number' || typeof node === 'boolean') return String(node)
549
-
550
- if (Array.isArray(node)) {
551
- let html = ''
552
- for (const child of node) html += await renderNode(child)
553
- return html
554
- }
555
-
556
- const vnode = node as VNode
557
-
558
- if (vnode.type === Fragment) {
559
- return renderChildren(vnode.children)
560
- }
561
-
562
- if (vnode.type === (ForSymbol as unknown as string)) {
563
- const { each, children, by } = vnode.props as unknown as ForProps<unknown>
564
- let forHtml = '<!--pyreon-for-->'
565
- // Defensive: `each` is normally `_rp(() => arr)` (a function). PR #410's
566
- // `makeReactiveProps` in `mergeChildrenIntoProps` invokes `_rp` getters
567
- // when the For COMPONENT runs, which flips `props.each` to the resolved
568
- // array on the re-emitted ForSymbol vnode. Accept both forms.
569
- const items = typeof each === 'function' ? each() : (each as Iterable<unknown>)
570
- for (const item of items) {
571
- const key = by(item)
572
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.for.keyMarker')
573
- forHtml += `<!--k:${safeKeyForMarker(key)}-->`
574
- forHtml += await renderNode(children(item) as VNodeChild)
575
- }
576
- forHtml += '<!--/pyreon-for-->'
577
- return forHtml
578
- }
579
-
580
- if (typeof vnode.type === 'function') {
581
- return renderComponent(vnode as VNode & { type: ComponentFn })
582
- }
583
-
584
- return renderElement(vnode)
585
- }
586
-
587
- async function renderChildren(children: VNodeChild[]): Promise<string> {
588
- let html = ''
589
- for (const child of children) html += await renderNode(child)
590
- return html
591
- }
592
-
593
- async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<string> {
594
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.component')
595
- // Snapshot the context stack length BEFORE the component renders. After
596
- // children render, trim back to this length — that pops every frame the
597
- // component pushed via `provide(ctx, value)` (which calls `pushContext` +
598
- // registers `onUnmount(popContext)`). Without this, every provider during
599
- // SSR leaks its frame onto the global stack and subsequent siblings see
600
- // the wrong context value (Bug 4 — bokisch.com `<PyreonUI inversed>`
601
- // inside `<Intro>` flipped every later section to dark).
602
- //
603
- // We trim the stack DIRECTLY instead of running the component's unmount
604
- // hooks because users register `onUnmount` for things still load-bearing
605
- // at post-render time during SSR — `useHead({ title })` uses `onUnmount`
606
- // to remove its head entries, and running it here wipes the head store
607
- // before `renderWithHead` extracts it. SSR has no real "unmount" phase
608
- // (the response ships, the process moves on); user-registered cleanup
609
- // is for the CSR lifecycle. `provide()`'s frame cleanup is the only
610
- // SSR-visible side effect and we handle it structurally below.
611
- const stackLenBefore = getContextStackLength()
612
- const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode))
613
-
614
- // Async component function (async function Component()) — await the promise
615
- let html: string
616
- try {
617
- if (output instanceof Promise) {
618
- const resolved = await output
619
- html = resolved === null ? '' : await renderNode(resolved)
620
- } else if (output === null) {
621
- html = ''
622
- } else {
623
- html = await renderNode(output)
624
- }
625
- } finally {
626
- trimContextStack(stackLenBefore)
627
- }
628
- return html
629
- }
630
-
631
- /**
632
- * Pop context frames pushed during a component's render. Trims the global
633
- * context stack back to the snapshot length captured before the component
634
- * ran. Each iteration calls `popContext` once — symmetric with `provide()`'s
635
- * `pushContext()` so frames pop in LIFO order even when a single component
636
- * called `provide()` multiple times.
637
- *
638
- * Why not run user unmount hooks here? See `renderComponent` for the full
639
- * architectural rationale — TL;DR: SSR has no unmount phase, `useHead` uses
640
- * `onUnmount` to clear head entries that the post-render extraction still
641
- * needs, etc. The structural fix is to clean up the ONE SSR-visible side
642
- * effect of `provide()` (its context frame) without firing other hooks.
643
- */
644
- function trimContextStack(targetLen: number): void {
645
- let current = getContextStackLength()
646
- while (current > targetLen) {
647
- popContext()
648
- current--
649
- }
650
- }
651
-
652
- async function renderElement(vnode: VNode): Promise<string> {
653
- const tag = vnode.type as string
654
- warnIfUnsafeTag(tag)
655
- let html = `<${tag}`
656
-
657
- const props = vnode.props as Record<string, unknown>
658
- for (const key in props) {
659
- const attr = renderProp(key, props[key])
660
- if (attr) html += ` ${attr}`
661
- }
662
-
663
- if (isVoidElement(tag)) {
664
- html += ' />'
665
- return html
666
- }
667
-
668
- html += '>'
669
-
670
- // `dangerouslySetInnerHTML` and `innerHTML` become inner content of the
671
- // element (NOT attributes — skipped in `renderPropSkipped`). Function
672
- // values — emitted by the compiler for signal-derived prop expressions —
673
- // are called once at render time (SSR is one-shot; reactivity happens
674
- // post-hydration on the client). Kept in sync with `streamElementNode`.
675
- const dangerous = props.dangerouslySetInnerHTML as
676
- | { __html: string }
677
- | (() => { __html: string })
678
- | undefined
679
- const innerHtml = props.innerHTML as string | (() => string) | undefined
680
- const dangerousHtml =
681
- typeof dangerous === 'function'
682
- ? (dangerous as () => { __html: string })()?.__html
683
- : dangerous?.__html
684
- const plainInnerHtml =
685
- typeof innerHtml === 'function' ? (innerHtml as () => string)() : innerHtml
686
- if (dangerousHtml) {
687
- html += dangerousHtml
688
- } else if (plainInnerHtml != null && plainInnerHtml !== '') {
689
- html += String(plainInnerHtml)
690
- } else {
691
- for (const child of vnode.children) {
692
- html += await renderNode(child)
693
- }
694
- }
695
-
696
- html += `</${tag}>`
697
- return html
698
- }
699
-
700
- const SSR_URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
701
- const SSR_UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
702
-
703
- function renderPropSkipped(key: string): boolean {
704
- // `innerHTML` and `dangerouslySetInnerHTML` are NOT attributes — they
705
- // get written as inner content in `streamElementNode`. Without this
706
- // skip, `innerHTML` would be emitted as a literal HTML attribute
707
- // (`<span innerHTML="&lt;svg&gt;…">`) and the client hydration would
708
- // fix it up — wasted bytes, hydration mismatch, and (with the recent
709
- // client-side `innerHTML` bug) literal closure text visible before
710
- // hydration completed.
711
- if (key === 'key' || key === 'ref') return true
712
- if (key === 'innerHTML' || key === 'dangerouslySetInnerHTML') return true
713
- if (/^on[A-Z]/.test(key)) return true
714
- return false
715
- }
716
-
717
- function renderPropValue(key: string, value: unknown): string | null {
718
- if (value === null || value === undefined || value === false) return null
719
- if (value === true) return escapeHtml(toAttrName(key))
720
-
721
- if (key === 'class') {
722
- const cls = cx(value as ClassValue)
723
- return cls ? `class="${escapeHtml(cls)}"` : null
724
- }
725
-
726
- if (key === 'style') {
727
- const style = normalizeStyle(value)
728
- return style ? `style="${escapeHtml(style)}"` : null
729
- }
730
-
731
- return `${escapeHtml(toAttrName(key))}="${escapeHtml(String(value))}"`
732
- }
733
-
734
- function renderProp(key: string, value: unknown): string | null {
735
- if (renderPropSkipped(key)) return null
736
-
737
- if (typeof value === 'function') {
738
- return renderProp(key, (value as () => unknown)())
739
- }
740
-
741
- if (SSR_URL_ATTRS.has(key) && typeof value === 'string' && SSR_UNSAFE_URL_RE.test(value)) {
742
- return null
743
- }
744
-
745
- return renderPropValue(key, value)
746
- }
747
-
748
- // ─── Helpers ─────────────────────────────────────────────────────────────────
749
-
750
- const VOID_ELEMENTS = new Set([
751
- 'area',
752
- 'base',
753
- 'br',
754
- 'col',
755
- 'embed',
756
- 'hr',
757
- 'img',
758
- 'input',
759
- 'link',
760
- 'meta',
761
- 'param',
762
- 'source',
763
- 'track',
764
- 'wbr',
765
- ])
766
-
767
- function isVoidElement(tag: string): boolean {
768
- return VOID_ELEMENTS.has(tag.toLowerCase())
769
- }
770
-
771
- /** camelCase prop → kebab-case HTML attribute (e.g. className → class, htmlFor → for) */
772
- function toAttrName(key: string): string {
773
- if (key === 'className') return 'class'
774
- if (key === 'htmlFor') return 'for'
775
- return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
776
- }
777
-
778
- function isStyleObject(value: unknown): value is Record<string, unknown> {
779
- if (!value) return false
780
- return typeof value === 'object'
781
- }
782
-
783
- function normalizeStyle(value: unknown): string {
784
- if (typeof value === 'string') return value
785
- if (isStyleObject(value)) {
786
- const proto = Object.getPrototypeOf(value)
787
- if (proto === Object.prototype || proto === null) {
788
- return Object.entries(value)
789
- .map(([k, v]) => `${toKebab(k)}: ${normalizeStyleValue(k, v)}`)
790
- .join('; ')
791
- }
792
- }
793
- return ''
794
- }
795
-
796
- function toKebab(str: string): string {
797
- return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
798
- }
799
-
800
- const ESCAPE_MAP: Record<string, string> = {
801
- '&': '&amp;',
802
- '<': '&lt;',
803
- '>': '&gt;',
804
- '"': '&quot;',
805
- "'": '&#39;',
806
- }
807
-
808
- /**
809
- * Encode a For-list key so it's safe to inline inside an HTML comment
810
- * marker `<!--k:KEY-->`. If a user-supplied key contains `-->` the naive
811
- * form breaks out of the comment and may inject markup. Per-byte URL
812
- * encoding with an extra `-` substitution makes `-->` impossible in the
813
- * output: `%2D%2D>` no longer terminates the comment. Client-side
814
- * hydration does not read the marker body today, so any reversible-or-
815
- * irreversible encoding works; this one is predictable enough for a
816
- * future consumer to decode if needed.
817
- */
818
- function safeKeyForMarker(key: unknown): string {
819
- return encodeURIComponent(String(key)).replace(/-/g, '%2D')
820
- }
821
-
822
- /**
823
- * Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
824
- * original string. Not used by runtime today (hydration does not read
825
- * per-item `<!--k:KEY-->` markers) but shipped alongside the encoder so
826
- * future hydration or devtools consumers decode symmetrically without
827
- * having to re-derive the encoding from source.
828
- */
829
- export function decodeKeyFromMarker(encoded: string): string {
830
- return decodeURIComponent(encoded.replace(/%2D/gi, '-'))
831
- }
832
-
833
- // Detect tag names that would break out of the `<TAG>` or `</TAG>` form
834
- // and inject HTML. If user data ever feeds `h(userTag, ...)` the attack
835
- // `userTag = 'div><script>alert(1)</script><div'` yields executable
836
- // markup. Framework doesn't HTML-escape tag names (React/Vue/Solid
837
- // match) — responsibility is on the caller — but a dev-mode warning
838
- // catches the mistake before it reaches prod. Safe tag pattern covers
839
- // HTML element names and custom elements (letter start, then
840
- // alphanumerics + hyphens).
841
- const SAFE_TAG_RE = /^[a-zA-Z][a-zA-Z0-9-]*$/
842
- function warnIfUnsafeTag(tag: string): void {
843
- if (!__DEV__) return
844
- if (SAFE_TAG_RE.test(tag)) return
845
- // oxlint-disable-next-line no-console
846
- console.warn(
847
- `[Pyreon SSR] Tag name "${tag}" contains characters that could break HTML structure. ` +
848
- `Tag names must match /^[a-zA-Z][a-zA-Z0-9-]*$/. ` +
849
- `If user-supplied data drives a tag name, validate it against an allowlist before passing to h().`,
850
- )
851
- }
852
-
853
- // Fast test — most strings in SSR have no special chars (tag names, class names, etc.)
854
- const NEEDS_ESCAPE_RE = /[&<>"']/
855
-
856
- function escapeHtml(str: string): string {
857
- if (!NEEDS_ESCAPE_RE.test(str)) return str
858
- if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.escape')
859
- return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c)
860
- }
861
-
862
- /**
863
- * Merge vnode.children into props.children for component rendering, AND
864
- * convert compiler-emitted `_rp(() => expr)` reactive-prop wrappers into
865
- * getter properties via `makeReactiveProps`.
866
- *
867
- * mount.ts (CSR) does the same dance — without it, components reading
868
- * `props.x` get the raw `_rp` function instead of the resolved value.
869
- * Pre-fix, SSR (and hydrate.ts — same bug, fixed alongside) skipped the
870
- * `makeReactiveProps` step, so any `<Comp prop={signal()}>` shape rendered
871
- * the function source as the attribute value (e.g. `<a href="() => …">`).
872
- * Visible end-to-end through the fundamentals NavItem layout — see
873
- * `e2e/fundamentals/playground.spec.ts`.
874
- */
875
- function mergeChildrenIntoProps(vnode: VNode): Record<string, unknown> {
876
- const raw =
877
- vnode.children.length > 0 &&
878
- (vnode.props as Record<string, unknown>).children === undefined
879
- ? {
880
- ...vnode.props,
881
- children: vnode.children.length === 1 ? vnode.children[0] : vnode.children,
882
- }
883
- : (vnode.props as Record<string, unknown>)
884
- return makeReactiveProps(raw)
885
- }