@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/package.json +3 -5
- package/src/index.ts +0 -885
- package/src/manifest.ts +0 -126
- package/src/tests/for-key-marker.test.ts +0 -86
- package/src/tests/integration.test.ts +0 -310
- package/src/tests/manifest-snapshot.test.ts +0 -39
- package/src/tests/reactive-props.test.ts +0 -95
- package/src/tests/ssr.test.ts +0 -1365
- package/src/tests/suspense-stream-error.test.ts +0 -83
- package/src/tests/unsafe-tag-warning.test.ts +0 -55
- package/src/tests/xss-attribute-escape.test.ts +0 -40
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="<svg>…">`) 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
|
-
'&': '&',
|
|
802
|
-
'<': '<',
|
|
803
|
-
'>': '>',
|
|
804
|
-
'"': '"',
|
|
805
|
-
"'": ''',
|
|
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
|
-
}
|