@pyreon/runtime-server 0.24.4 → 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/manifest.ts DELETED
@@ -1,126 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- export default defineManifest({
4
- name: '@pyreon/runtime-server',
5
- title: 'SSR Renderer',
6
- tagline:
7
- 'SSR/SSG VNode→HTML renderer — renderToString, renderToStream (out-of-order Suspense), per-request ALS context/store isolation',
8
- description:
9
- "Pyreon's server-side renderer: walks a VNode tree and produces HTML. Signal accessors are called synchronously to SNAPSHOT their current value — no effects, no reactivity on the server (reactivity resumes post-hydration on the client). Async component functions are awaited. `renderToStream` flushes progressively and resolves Suspense boundaries out-of-order (fallback first, then a `<template>` + inline swap script). Concurrency-safe: every `renderToString` / `renderToStream` / `runWithRequestContext` call runs in its own `AsyncLocalStorage` store so concurrent requests never share context frames; `configureStoreIsolation()` extends the same isolation to the `@pyreon/store` registry. Most apps consume this transitively through `@pyreon/server` (`createHandler` / `prerender`) rather than calling it directly.",
10
- category: 'server',
11
- features: [
12
- 'renderToString(vnode) → Promise<string> — one-shot HTML, awaits async components',
13
- 'renderToStream(vnode, { signal?, suspenseTimeoutMs? }) → ReadableStream<string> — progressive, out-of-order Suspense (default 30s per-boundary timeout, configurable; signal threads AbortSignal end-to-end)',
14
- 'Per-request ALS context isolation — concurrent requests never share provide() frames',
15
- 'runWithRequestContext(fn) — isolated context+store for Pyreon APIs called outside renderToString',
16
- 'configureStoreIsolation(setStoreRegistryProvider) — opt-in per-request @pyreon/store isolation',
17
- 'Compiler-emitted reactive props resolved via makeReactiveProps (parity with CSR mount.ts)',
18
- 'For-list key markers URL-encoded so user keys can never break out of the HTML comment',
19
- 'decodeKeyFromMarker — symmetric inverse of the For-key marker encoder (devtools/future hydration)',
20
- ],
21
- api: [
22
- {
23
- name: 'renderToString',
24
- kind: 'function',
25
- signature: 'renderToString(root: VNode | null): Promise<string>',
26
- summary:
27
- 'Render a VNode tree to a single HTML string. Each call runs in a fresh isolated ALS context stack (and store registry if `configureStoreIsolation` was called) so concurrent requests never bleed `provide()` frames into each other. Signal accessors are invoked synchronously to snapshot their CURRENT value — there is no reactivity on the server, so a `<div>{count()}</div>` renders the value at render time and only becomes live after client hydration. Async component functions (`async function C()`) are awaited before the walk continues. Returns the empty string for a `null` root.',
28
- example: `import { renderToString } from "@pyreon/runtime-server"
29
-
30
- const html = await renderToString(<App />)`,
31
- mistakes: [
32
- 'Expecting signal writes after `renderToString` to change the output — SSR is one-shot; the string is already produced. Reactivity is a post-hydration (client) concern',
33
- "Calling Pyreon context APIs (`useHead`, loaders) OUTSIDE `renderToString` and expecting per-request isolation — use `runWithRequestContext` for that; bare calls share the fallback stack across concurrent requests",
34
- 'Reaching for `renderToString` directly when you have an HTTP handler — the `createHandler` in `@pyreon/server` wraps it with template precompilation, middleware, and loader-data injection; prefer that for request handling',
35
- ],
36
- seeAlso: ['renderToStream', 'runWithRequestContext'],
37
- },
38
- {
39
- name: 'renderToStream',
40
- kind: 'function',
41
- signature: 'renderToStream(root: VNode | null, options?: { signal?: AbortSignal; suspenseTimeoutMs?: number }): ReadableStream<string>',
42
- summary:
43
- 'Render to a Web-standard `ReadableStream<string>` with true progressive flushing — synchronous subtrees enqueue immediately, async component boundaries are awaited in order. Suspense boundaries stream OUT OF ORDER: the fallback is emitted inline at once, and the resolved children arrive later as a `<template>` + a tiny inline swap `<script>` that replaces the placeholder client-side — without blocking the rest of the page. Each call gets its own isolated ALS context stack. A Suspense boundary that does not resolve within the per-boundary timeout (default 30_000 ms, configurable via `options.suspenseTimeoutMs`; pass `Infinity` to disable) leaves its fallback in place and a dev-mode warning fires; a boundary that throws also leaves the fallback (no swap script emitted). Pass `options.signal` (e.g. `Request.signal`) to abort pending Suspense work when the consumer disconnects.',
44
- example: `import { renderToStream } from "@pyreon/runtime-server"
45
-
46
- return new Response(renderToStream(<App />, {
47
- signal: req.signal,
48
- suspenseTimeoutMs: 5_000, // ops-controlled per-boundary cap
49
- }), {
50
- headers: { "content-type": "text/html" },
51
- })`,
52
- mistakes: [
53
- 'Assuming Suspense children arrive in source order — they are swapped in as each boundary resolves; the fallback ships first, resolved content can arrive in any order',
54
- 'Expecting `@pyreon/head` tags registered inside a Suspense child to reach the document `<head>` — the head is flushed in the shell BEFORE any boundary resolves, so async-loaded data does not contribute to it',
55
- 'Treating a timed-out boundary as an error — by design the fallback simply stays; only a dev-mode `console.warn` signals it. Tune `options.suspenseTimeoutMs` to match your SLA (5_000–10_000 typical for user-facing apps; `Infinity` to disable entirely for export jobs / reports)',
56
- 'Buffering the whole stream before responding — that throws away the progressive-flush benefit; pass the stream straight into the `Response`',
57
- 'Forgetting `signal: req.signal` — without it, in-flight Suspense work keeps running (and tries to write to a closed stream) after the consumer disconnects',
58
- ],
59
- seeAlso: ['renderToString'],
60
- },
61
- {
62
- name: 'runWithRequestContext',
63
- kind: 'function',
64
- signature: 'runWithRequestContext<T>(fn: () => Promise<T>): Promise<T>',
65
- summary:
66
- 'Run an async function inside a fresh, isolated ALS context stack (and store registry, if `configureStoreIsolation` was called). Use this when you need to call Pyreon context-aware APIs — `useHead`, `prefetchLoaderData`, router resolution — OUTSIDE a `renderToString` / `renderToStream` call but still want per-request isolation. Without it those calls land on a process-global fallback stack shared by every concurrent request.',
67
- example: `import { runWithRequestContext } from "@pyreon/runtime-server"
68
-
69
- const data = await runWithRequestContext(async () => {
70
- await prefetchLoaderData(router, url.pathname, request)
71
- return renderToString(<App />)
72
- })`,
73
- mistakes: [
74
- 'Calling `prefetchLoaderData` / `useHead` before `renderToString` WITHOUT wrapping the whole sequence in one `runWithRequestContext` — the prefetch lands in a different (or the shared fallback) context than the render, so the render sees no loader data',
75
- 'Wrapping a synchronous function — the signature is `() => Promise<T>`; return the promise (or make the fn `async`) so the ALS scope spans the awaited work',
76
- ],
77
- seeAlso: ['renderToString', 'configureStoreIsolation'],
78
- },
79
- {
80
- name: 'configureStoreIsolation',
81
- kind: 'function',
82
- signature:
83
- 'configureStoreIsolation(setStoreRegistryProvider: (fn: () => Map<string, unknown>) => void): void',
84
- summary:
85
- "Opt in to per-request `@pyreon/store` isolation. Call ONCE at server startup, passing `@pyreon/store`'s `setStoreRegistryProvider`. After this, every `renderToString` / `renderToStream` / `runWithRequestContext` call gets its own fresh store registry via ALS. WITHOUT calling it, store isolation is a no-op and all concurrent requests share ONE process-global store registry — request A's `defineStore` state is visible to request B (SSR state bleed across users).",
86
- example: `import { configureStoreIsolation } from "@pyreon/runtime-server"
87
- import { setStoreRegistryProvider } from "@pyreon/store"
88
-
89
- // once, at server startup:
90
- configureStoreIsolation(setStoreRegistryProvider)`,
91
- mistakes: [
92
- 'Not calling it at all in an SSR app that uses `@pyreon/store` — concurrent requests share one global registry, so one request’s store state leaks into another request’s render',
93
- 'Calling it per request instead of once at startup — it only needs to wire the provider once; the per-request fresh `Map` is handled internally by the ALS run',
94
- 'Passing something other than the `setStoreRegistryProvider` exported by `@pyreon/store` — the contract is specifically that provider-setter shape',
95
- ],
96
- seeAlso: ['runWithRequestContext', 'renderToString'],
97
- },
98
- {
99
- name: 'decodeKeyFromMarker',
100
- kind: 'function',
101
- signature: 'decodeKeyFromMarker(encoded: string): string',
102
- summary:
103
- "Inverse of the internal For-list key encoder. `<For>` SSR emits per-item `<!--k:KEY-->` markers; the encoder URL-encodes the key and replaces every `-` with `%2D` so a user-supplied key can never form `-->` and break out of the HTML comment (an injection vector). `decodeKeyFromMarker` reverses that. Not used by the runtime today (hydration does not read per-item markers) — shipped alongside the encoder so future hydration or devtools consumers decode symmetrically without re-deriving the scheme.",
104
- example: `import { decodeKeyFromMarker } from "@pyreon/runtime-server"
105
-
106
- decodeKeyFromMarker("a%2Db") // "a-b"`,
107
- mistakes: [
108
- 'Assuming the runtime consumes this — it does not yet; it exists for forward-compat / devtools symmetry with the marker encoder',
109
- ],
110
- },
111
- ],
112
- gotchas: [
113
- {
114
- label: 'No reactivity on the server',
115
- note: 'Signal accessors are called synchronously to snapshot their value — no effects are created. A `{count()}` expression renders the value at render time; it only becomes live after client hydration. SSR is one-shot.',
116
- },
117
- {
118
- label: 'Usually consumed via @pyreon/server',
119
- note: '`createHandler` (SSR HTTP) and `prerender` (SSG) in `@pyreon/server` wrap this renderer with template precompilation, middleware, and loader-data injection. Call `renderToString` / `renderToStream` directly only for custom integrations; for request handling prefer `@pyreon/server`.',
120
- },
121
- {
122
- label: 'Server dev-gate convention',
123
- note: 'As a server package, `@pyreon/runtime-server` correctly uses `typeof process !== "undefined" && process.env.NODE_ENV !== "production"` for its dev-mode perf-counter sink — NOT the browser `import.meta.env.DEV` form. It always runs in Node/Bun where `process` is real.',
124
- },
125
- ],
126
- })
@@ -1,86 +0,0 @@
1
- import { For, h } from '@pyreon/core'
2
- import { signal } from '@pyreon/reactivity'
3
- import { decodeKeyFromMarker, renderToString } from '../index'
4
-
5
- // For list SSR emits <!--k:KEY--> markers between items. The key is
6
- // user-supplied (derived from `by`) and must not be able to break out
7
- // of the HTML comment. A naive inline of `-->` would terminate the
8
- // comment and inject arbitrary markup. This suite locks the fix.
9
-
10
- describe('For SSR — key marker safety', () => {
11
- it('emits one marker per item with the expected key for normal ids', async () => {
12
- type Row = { id: number; label: string }
13
- const items = signal<Row[]>([
14
- { id: 1, label: 'a' },
15
- { id: 2, label: 'b' },
16
- { id: 3, label: 'c' },
17
- ])
18
- const html = await renderToString(
19
- h(For, {
20
- each: () => items(),
21
- by: (r: Row) => r.id,
22
- children: (r: Row) => h('li', null, r.label),
23
- }),
24
- )
25
-
26
- const markers = html.match(/<!--k:[^>]*-->/g) ?? []
27
- expect(markers).toHaveLength(3)
28
- // Numeric keys are URL-encoded predictably (digits survive).
29
- expect(markers[0]).toBe('<!--k:1-->')
30
- expect(markers[1]).toBe('<!--k:2-->')
31
- expect(markers[2]).toBe('<!--k:3-->')
32
- })
33
-
34
- it('prevents comment-breakout via `-->` in the key (XSS guard)', async () => {
35
- // Adversarial: a key that would, unsanitized, close the comment and
36
- // inject a <script> tag.
37
- const attackKey = '--><script>alert(1)</script><!--'
38
- type Row = { id: string }
39
- const items = signal<Row[]>([{ id: attackKey }])
40
- const html = await renderToString(
41
- h(For, {
42
- each: () => items(),
43
- by: (r: Row) => r.id,
44
- children: () => h('li', null, 'x'),
45
- }),
46
- )
47
-
48
- // The raw attacker string must NOT appear verbatim.
49
- expect(html).not.toContain('<script>alert(1)</script>')
50
- // The `-->` terminator must not appear anywhere but at the very end
51
- // of the marker (after the encoded key).
52
- const markerMatch = html.match(/<!--k:([^-]*)-->/)
53
- expect(markerMatch).not.toBeNull()
54
- // No literal `-` survives inside the encoded key (defense-in-depth
55
- // against any future HTML-comment parsing quirk).
56
- expect(markerMatch![1]).not.toMatch(/-/)
57
- })
58
-
59
- it('URL-encodes keys with special chars safely', async () => {
60
- type Row = { id: string }
61
- const items = signal<Row[]>([{ id: 'a&b=c d' }])
62
- const html = await renderToString(
63
- h(For, {
64
- each: () => items(),
65
- by: (r: Row) => r.id,
66
- children: () => h('li', null, 'x'),
67
- }),
68
- )
69
-
70
- const marker = html.match(/<!--k:([^>]*)-->/)
71
- expect(marker).not.toBeNull()
72
- // `&`, `=`, ` `, and `-` all URL-encoded.
73
- expect(marker![1]).toBe('a%26b%3Dc%20d')
74
- // decodeKeyFromMarker round-trips back to the original string.
75
- expect(decodeKeyFromMarker(marker![1]!)).toBe('a&b=c d')
76
- })
77
-
78
- it('decodeKeyFromMarker round-trips adversarial keys', () => {
79
- // Directly round-trip the adversarial case that motivated the fix.
80
- const attackKey = '--><script>alert(1)</script><!--'
81
- // Produce what SSR would emit (safeKeyForMarker is private; use
82
- // the inverse on a value we know was encoded from it).
83
- const encoded = encodeURIComponent(attackKey).replace(/-/g, '%2D')
84
- expect(decodeKeyFromMarker(encoded)).toBe(attackKey)
85
- })
86
- })
@@ -1,310 +0,0 @@
1
- import type { ComponentFn, VNode } from '@pyreon/core'
2
- import {
3
- createContext,
4
- For,
5
- h,
6
- pushContext,
7
- Show,
8
- Suspense,
9
- useContext,
10
- } from '@pyreon/core'
11
- import { signal } from '@pyreon/reactivity'
12
- import { renderToStream, renderToString, runWithRequestContext } from '../index'
13
-
14
- async function collectStream(stream: ReadableStream<string>): Promise<string> {
15
- const reader = stream.getReader()
16
- const chunks: string[] = []
17
- while (true) {
18
- const { done, value } = await reader.read()
19
- if (done) break
20
- chunks.push(value)
21
- }
22
- return chunks.join('')
23
- }
24
-
25
- // ─── SSR integration — renderToString ─────────────────────────────────────────
26
-
27
- describe('SSR integration — renderToString', () => {
28
- test('simple component renders valid HTML string', async () => {
29
- const Greeting = () => h('div', { class: 'greeting' }, 'Hello world')
30
- const html = await renderToString(h(Greeting, null))
31
- expect(html).toBe('<div class="greeting">Hello world</div>')
32
- })
33
-
34
- test('component with signal initial value renders correct value in HTML', async () => {
35
- const count = signal(42)
36
- const Counter = () => h('span', null, () => count())
37
- const html = await renderToString(h(Counter, null))
38
- expect(html).toBe('<span>42</span>')
39
- })
40
-
41
- test('nested components render correct nesting in output', async () => {
42
- const Inner = (props: { label: string }) => h('span', null, props.label)
43
- const Outer = () =>
44
- h('div', { class: 'outer' }, h(Inner, { label: 'A' }), h(Inner, { label: 'B' }))
45
- const html = await renderToString(h(Outer, null))
46
- expect(html).toBe('<div class="outer"><span>A</span><span>B</span></div>')
47
- })
48
-
49
- test('Show when=true renders children', async () => {
50
- const vnode = h(Show, {
51
- when: () => true,
52
- children: h('p', null, 'visible'),
53
- })
54
- const html = await renderToString(vnode)
55
- expect(html).toContain('visible')
56
- })
57
-
58
- test('Show when=false renders nothing', async () => {
59
- const vnode = h(Show, {
60
- when: () => false,
61
- children: h('p', null, 'hidden'),
62
- })
63
- const html = await renderToString(vnode)
64
- expect(html).not.toContain('hidden')
65
- })
66
-
67
- test('For list renders all items with key markers', async () => {
68
- const items = signal([
69
- { id: 1, name: 'A' },
70
- { id: 2, name: 'B' },
71
- { id: 3, name: 'C' },
72
- ])
73
- const vnode = For({
74
- each: items,
75
- by: (r: { id: number }) => r.id,
76
- children: (r: { id: number; name: string }) => h('li', null, r.name),
77
- })
78
- const html = await renderToString(vnode)
79
- expect(html).toContain('<!--pyreon-for-->')
80
- expect(html).toContain('<!--/pyreon-for-->')
81
- expect(html).toContain('<li>A</li>')
82
- expect(html).toContain('<li>B</li>')
83
- expect(html).toContain('<li>C</li>')
84
- })
85
-
86
- test('component that throws rejects the renderToString promise', async () => {
87
- const Broken = () => {
88
- throw new Error('test error')
89
- }
90
- await expect(
91
- renderToString(h(Broken as unknown as ComponentFn, null)),
92
- ).rejects.toThrow('test error')
93
- })
94
- })
95
-
96
- // ─── SSR integration — renderToStream ─────────────────────────────────────────
97
-
98
- describe('SSR integration — renderToStream', () => {
99
- test('simple component streams correct HTML', async () => {
100
- const Comp = () => h('div', { id: 'streamed' }, 'hello stream')
101
- const html = await collectStream(renderToStream(h(Comp, null)))
102
- expect(html).toContain('<div id="streamed">hello stream</div>')
103
- })
104
-
105
- test('Suspense with async component streams fallback first, then content', async () => {
106
- async function AsyncComp(): Promise<ReturnType<typeof h>> {
107
- await new Promise<void>((r) => setTimeout(r, 10))
108
- return h('div', null, 'loaded')
109
- }
110
-
111
- const vnode = h(Suspense, {
112
- fallback: h('span', null, 'loading...'),
113
- children: h(AsyncComp as unknown as ComponentFn, null),
114
- })
115
-
116
- const html = await collectStream(renderToStream(vnode))
117
- // Fallback was emitted
118
- expect(html).toContain('loading...')
119
- // Resolved content was emitted
120
- expect(html).toContain('loaded')
121
- })
122
-
123
- test('AbortSignal: upstream abort skips post-resolve enqueue (client disconnected)', async () => {
124
- // 50ms-deferred async component; we abort after 5ms, well before
125
- // resolution. The fallback IS emitted (it runs synchronously during
126
- // the initial stream pass, BEFORE the signal aborts). The
127
- // post-resolve swap (`<template>` + `__NS()` script) MUST be skipped
128
- // because the consumer (browser fetch reader) hung up.
129
- async function SlowComp(): Promise<ReturnType<typeof h>> {
130
- await new Promise<void>((r) => setTimeout(r, 50))
131
- return h('div', null, 'loaded-too-late')
132
- }
133
- const vnode = h(Suspense, {
134
- fallback: h('span', null, 'loading-shown'),
135
- children: h(SlowComp as unknown as ComponentFn, null),
136
- })
137
-
138
- const ac = new AbortController()
139
- const stream = renderToStream(vnode, { signal: ac.signal })
140
- setTimeout(() => ac.abort(), 5)
141
-
142
- const html = await collectStream(stream)
143
- // Fallback streamed before the abort fired
144
- expect(html).toContain('loading-shown')
145
- // Post-resolve enqueue was skipped — the template + swap script never
146
- // landed. (`__NS` FUNCTION DEFINITION ships at the head of every
147
- // stream as the swap-script preamble; the per-boundary swap CALLS
148
- // it as `__NS("pyreon-s-<id>",...)`. We check for the CALL, not the
149
- // definition.)
150
- expect(html).not.toContain('loaded-too-late')
151
- expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
152
- })
153
-
154
- test('AbortSignal: pre-aborted signal still emits the synchronous portion', async () => {
155
- // Edge case — signal already aborted at renderToStream call time.
156
- // Synchronous portion still emits (the abort doesn't STOP rendering,
157
- // it only suppresses post-resolve enqueues), but the stream closes
158
- // promptly without waiting for any pending boundaries.
159
- const ac = new AbortController()
160
- ac.abort()
161
- const html = await collectStream(
162
- renderToStream(h('div', { id: 'sync' }, 'sync-content'), { signal: ac.signal }),
163
- )
164
- // Sync output was emitted before the abort propagated through the
165
- // first enqueue check.
166
- expect(html.length).toBeGreaterThanOrEqual(0)
167
- })
168
-
169
- test('ReadableStream.cancel() aborts in-flight Suspense work', async () => {
170
- async function SlowComp(): Promise<ReturnType<typeof h>> {
171
- await new Promise<void>((r) => setTimeout(r, 200))
172
- return h('div', null, 'never-streamed')
173
- }
174
- const vnode = h(Suspense, {
175
- fallback: h('span', null, 'fallback-shown'),
176
- children: h(SlowComp as unknown as ComponentFn, null),
177
- })
178
-
179
- const stream = renderToStream(vnode)
180
- const reader = stream.getReader()
181
-
182
- // Drain chunks until we see the fallback (the `__NS` setup script
183
- // is emitted first, then the per-boundary fallback HTML). Then
184
- // cancel — this MUST propagate to the internal abort signal so the
185
- // drain loop exits without waiting for SlowComp's 200ms timer.
186
- let collected = ''
187
- const start = Date.now()
188
- while (!collected.includes('fallback-shown') && Date.now() - start < 1000) {
189
- const chunk = await reader.read()
190
- if (chunk.done) break
191
- collected += String(chunk.value)
192
- }
193
- expect(collected).toContain('fallback-shown')
194
-
195
- await reader.cancel('client-disconnect')
196
- // After cancellation, the stream MUST close promptly (well before
197
- // SlowComp's 200ms timer). The next read returns done=true.
198
- const beforeRead = Date.now()
199
- const done = await reader.read()
200
- const elapsed = Date.now() - beforeRead
201
- expect(done.done).toBe(true)
202
- // Generous bound: 100ms is plenty to detect "promptly" vs "waited
203
- // for the 200ms timer". On a slow CI box even 100ms is well below
204
- // the cancelled boundary's pending work.
205
- expect(elapsed).toBeLessThan(100)
206
- })
207
-
208
- test('collecting all chunks produces valid complete HTML', async () => {
209
- const Header = () => h('header', null, 'Header')
210
- const Main = () => h('main', null, 'Content')
211
- const Footer = () => h('footer', null, 'Footer')
212
-
213
- const App = () =>
214
- h(
215
- 'div',
216
- { id: 'app' },
217
- h(Header, null),
218
- h(Main, null),
219
- h(Footer, null),
220
- )
221
-
222
- const stream = renderToStream(h(App, null))
223
- const reader = stream.getReader()
224
- const chunks: string[] = []
225
- while (true) {
226
- const { done, value } = await reader.read()
227
- if (done) break
228
- chunks.push(value)
229
- }
230
-
231
- const html = chunks.join('')
232
- expect(html).toContain('<header>Header</header>')
233
- expect(html).toContain('<main>Content</main>')
234
- expect(html).toContain('<footer>Footer</footer>')
235
- // Overall structure is valid
236
- expect(html).toContain('<div id="app">')
237
- expect(html).toContain('</div>')
238
- })
239
- })
240
-
241
- // ─── SSR integration — context isolation ──────────────────────────────────────
242
-
243
- describe('SSR integration — context isolation', () => {
244
- test('two concurrent renderToString calls do not leak context', async () => {
245
- const Ctx = createContext('default')
246
-
247
- function makeApp(value: string): ComponentFn {
248
- return function App() {
249
- pushContext(new Map([[Ctx.id, value]]))
250
- return h('span', null, () => useContext(Ctx))
251
- }
252
- }
253
-
254
- const [html1, html2] = await Promise.all([
255
- renderToString(h(makeApp('request-1'), null)),
256
- renderToString(h(makeApp('request-2'), null)),
257
- ])
258
-
259
- expect(html1).toBe('<span>request-1</span>')
260
- expect(html2).toBe('<span>request-2</span>')
261
- })
262
-
263
- test('concurrent renders with async components stay isolated', async () => {
264
- const Ctx = createContext('none')
265
-
266
- async function AsyncReader(props: { delay: number }): Promise<VNode> {
267
- await new Promise<void>((r) => setTimeout(r, props.delay))
268
- return h('span', null, useContext(Ctx))
269
- }
270
-
271
- function RequestApp(props: { reqId: string; delay: number }): VNode {
272
- pushContext(new Map([[Ctx.id, props.reqId]]))
273
- return h(AsyncReader as unknown as ComponentFn, { delay: props.delay })
274
- }
275
-
276
- const N = 10
277
- const results = await Promise.all(
278
- Array.from({ length: N }, (_, i) =>
279
- renderToString(
280
- h(RequestApp as unknown as ComponentFn, {
281
- reqId: `req-${i}`,
282
- delay: Math.floor(Math.random() * 15),
283
- }),
284
- ),
285
- ),
286
- )
287
-
288
- results.forEach((html, i) => {
289
- expect(html).toBe(`<span>req-${i}</span>`)
290
- })
291
- })
292
-
293
- test('runWithRequestContext isolates two concurrent calls', async () => {
294
- const Ctx = createContext('none')
295
- const [r1, r2] = await Promise.all([
296
- runWithRequestContext(async () => {
297
- pushContext(new Map([[Ctx.id, 'isolated-A']]))
298
- await new Promise<void>((r) => setTimeout(r, 10))
299
- return useContext(Ctx)
300
- }),
301
- runWithRequestContext(async () => {
302
- pushContext(new Map([[Ctx.id, 'isolated-B']]))
303
- await new Promise<void>((r) => setTimeout(r, 10))
304
- return useContext(Ctx)
305
- }),
306
- ])
307
- expect(r1).toBe('isolated-A')
308
- expect(r2).toBe('isolated-B')
309
- })
310
- })
@@ -1,39 +0,0 @@
1
- import {
2
- renderApiReferenceEntries,
3
- renderLlmsFullSection,
4
- renderLlmsTxtLine,
5
- } from '@pyreon/manifest'
6
- import manifest from '../manifest'
7
-
8
- describe('gen-docs — runtime-server snapshot', () => {
9
- it('renders a llms.txt bullet starting with the package prefix', () => {
10
- const line = renderLlmsTxtLine(manifest)
11
- expect(line.startsWith('- @pyreon/runtime-server —')).toBe(true)
12
- })
13
-
14
- it('renders a llms-full.txt section with the right header', () => {
15
- const section = renderLlmsFullSection(manifest)
16
- expect(section.startsWith('## @pyreon/runtime-server —')).toBe(true)
17
- expect(section).toContain('```typescript')
18
- })
19
-
20
- it('renders MCP api-reference entries for every api[] item', () => {
21
- const record = renderApiReferenceEntries(manifest)
22
- expect(Object.keys(record).sort()).toEqual([
23
- 'runtime-server/configureStoreIsolation',
24
- 'runtime-server/decodeKeyFromMarker',
25
- 'runtime-server/renderToStream',
26
- 'runtime-server/renderToString',
27
- 'runtime-server/runWithRequestContext',
28
- ])
29
- })
30
-
31
- it('carries the SSR foot-gun catalog into MCP mistakes for the flagship APIs', () => {
32
- const r = renderApiReferenceEntries(manifest)
33
- expect(r['runtime-server/renderToString']?.mistakes).toContain('one-shot')
34
- expect(r['runtime-server/renderToStream']?.mistakes).toContain('Suspense')
35
- expect(r['runtime-server/configureStoreIsolation']?.mistakes).toContain(
36
- 'global registry',
37
- )
38
- })
39
- })
@@ -1,95 +0,0 @@
1
- import type { ComponentFn, Props } from '@pyreon/core'
2
- import { _rp, For, h } from '@pyreon/core'
3
- import { describe, expect, it } from 'vitest'
4
- import { renderToString } from '..'
5
-
6
- // Regression for the SSR `_rp`/`makeReactiveProps` gap.
7
- //
8
- // The compiler wraps `<Comp prop={signalRead}>` as
9
- // `h(Comp, { prop: _rp(() => signalRead) })`. mount.ts (CSR) calls
10
- // `makeReactiveProps` on the raw vnode props before invoking the component, so
11
- // inside the body `props.prop` invokes the getter and returns the resolved
12
- // value. Pre-fix, `runtime-server` skipped that step — components received raw
13
- // `_rp` functions, so `${props.prop}` stringified the function source and any
14
- // downstream attribute / template-literal interpolation embedded code into the
15
- // HTML (e.g. `<a href="() => props.path">` from the fundamentals layout).
16
- //
17
- // hydrate.ts had the same gap; lock-in lives in the runtime-dom suite.
18
-
19
- describe('SSR — _rp-wrapped component props are resolved (makeReactiveProps wired into runtime-server)', () => {
20
- it('resolves `_rp(() => string)` to the string when interpolated in component-emitted HTML', async () => {
21
- const Link = (props: { to: string }) => h('a', { href: `#${props.to}` }, 'go')
22
- const html = await renderToString(
23
- h(Link, { to: _rp(() => '/about') as unknown as string }),
24
- )
25
- expect(html).toBe('<a href="#/about">go</a>')
26
- expect(html).not.toContain('=>')
27
- })
28
-
29
- it('resolves `_rp` chain when a parent reads its own getter and forwards through another `_rp`', async () => {
30
- // Layout-shape: outer passes `_rp(() => '/store')` as `path`; NavItem
31
- // reads `props.path` (which is now a getter) and re-wraps as
32
- // `_rp(() => props.path)` for the child. SSR must traverse BOTH layers.
33
- const Inner = (props: { to: string }) => h('a', { href: props.to }, 'x')
34
- const Outer = (props: { path: string }) =>
35
- h(Inner, { to: _rp(() => props.path) as unknown as string })
36
- const html = await renderToString(
37
- h(Outer, { path: _rp(() => '/store') as unknown as string }),
38
- )
39
- expect(html).toBe('<a href="/store">x</a>')
40
- })
41
-
42
- it('`<For each={items()}>` from a component renders correctly under SSR', async () => {
43
- // Regression: PR #410's `makeReactiveProps` in `mergeChildrenIntoProps`
44
- // converts `_rp(() => arr)` props to getters that RESOLVE the array. The
45
- // `<For>` function is a component (its body returns a ForSymbol vnode),
46
- // so it goes through that path. Result: the re-emitted ForSymbol vnode
47
- // has `props.each` as the resolved array, not the function. SSR's For
48
- // handler used to call `each()` unconditionally → TypeError. Defensive
49
- // normalization (typeof === 'function' ? each() : each) fixes both shapes.
50
- type Item = { id: number; name: string }
51
- const Page = () => {
52
- const items = () => [
53
- { id: 1, name: 'a' },
54
- { id: 2, name: 'b' },
55
- ] as Item[]
56
- const forProps = {
57
- each: _rp(items) as unknown as () => Item[],
58
- by: (r: Item) => r.id,
59
- children: (r: Item) => h('span', null, r.name),
60
- }
61
- return h(For as unknown as ComponentFn, forProps as unknown as Props)
62
- }
63
- const html = await renderToString(h(Page, null))
64
- expect(html).toContain('a')
65
- expect(html).toContain('b')
66
- expect(html).not.toContain('SSR Error')
67
- })
68
-
69
- it('`<For each={arr}>` (already-array form) still renders correctly under SSR', async () => {
70
- // When `each` is a plain array (not a function), the defensive shape must
71
- // still iterate. `<For each={[1,2,3]}>` is the typical hand-coded form.
72
- type Item = { id: number; name: string }
73
- const forProps = {
74
- each: [{ id: 1, name: 'plain' }] as unknown as () => Item[],
75
- by: (r: Item) => r.id,
76
- children: (r: Item) => h('span', null, r.name),
77
- }
78
- const html = await renderToString(
79
- h(For as unknown as ComponentFn, forProps as unknown as Props),
80
- )
81
- expect(html).toContain('plain')
82
- })
83
-
84
- it('non-`_rp` function props (user-written accessors) still pass through to elements', async () => {
85
- // `class={() => 'foo'}` is NOT `_rp`-wrapped (it's a user-written
86
- // accessor, not a compiler emission). makeReactiveProps must leave it as
87
- // a plain function on `props.class` so the runtime can call it. The SSR
88
- // attribute renderer already invokes function-typed attribute values, so
89
- // the result still hits the rendered HTML — but we lock the contract in.
90
- const Wrapper = (props: { class: () => string }) =>
91
- h('div', { class: props.class }, 'x')
92
- const html = await renderToString(h(Wrapper, { class: () => 'foo' }))
93
- expect(html).toBe('<div class="foo">x</div>')
94
- })
95
- })