@pyreon/runtime-server 0.12.7 → 0.12.8
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 -3
- package/src/tests/integration.test.ts +225 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-server",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
4
4
|
"description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"prepublishOnly": "bun run build"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@pyreon/core": "^0.12.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
45
|
+
"@pyreon/core": "^0.12.8",
|
|
46
|
+
"@pyreon/reactivity": "^0.12.8"
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
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('collecting all chunks produces valid complete HTML', async () => {
|
|
124
|
+
const Header = () => h('header', null, 'Header')
|
|
125
|
+
const Main = () => h('main', null, 'Content')
|
|
126
|
+
const Footer = () => h('footer', null, 'Footer')
|
|
127
|
+
|
|
128
|
+
const App = () =>
|
|
129
|
+
h(
|
|
130
|
+
'div',
|
|
131
|
+
{ id: 'app' },
|
|
132
|
+
h(Header, null),
|
|
133
|
+
h(Main, null),
|
|
134
|
+
h(Footer, null),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const stream = renderToStream(h(App, null))
|
|
138
|
+
const reader = stream.getReader()
|
|
139
|
+
const chunks: string[] = []
|
|
140
|
+
while (true) {
|
|
141
|
+
const { done, value } = await reader.read()
|
|
142
|
+
if (done) break
|
|
143
|
+
chunks.push(value)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const html = chunks.join('')
|
|
147
|
+
expect(html).toContain('<header>Header</header>')
|
|
148
|
+
expect(html).toContain('<main>Content</main>')
|
|
149
|
+
expect(html).toContain('<footer>Footer</footer>')
|
|
150
|
+
// Overall structure is valid
|
|
151
|
+
expect(html).toContain('<div id="app">')
|
|
152
|
+
expect(html).toContain('</div>')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ─── SSR integration — context isolation ──────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('SSR integration — context isolation', () => {
|
|
159
|
+
test('two concurrent renderToString calls do not leak context', async () => {
|
|
160
|
+
const Ctx = createContext('default')
|
|
161
|
+
|
|
162
|
+
function makeApp(value: string): ComponentFn {
|
|
163
|
+
return function App() {
|
|
164
|
+
pushContext(new Map([[Ctx.id, value]]))
|
|
165
|
+
return h('span', null, () => useContext(Ctx))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [html1, html2] = await Promise.all([
|
|
170
|
+
renderToString(h(makeApp('request-1'), null)),
|
|
171
|
+
renderToString(h(makeApp('request-2'), null)),
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
expect(html1).toBe('<span>request-1</span>')
|
|
175
|
+
expect(html2).toBe('<span>request-2</span>')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('concurrent renders with async components stay isolated', async () => {
|
|
179
|
+
const Ctx = createContext('none')
|
|
180
|
+
|
|
181
|
+
async function AsyncReader(props: { delay: number }): Promise<VNode> {
|
|
182
|
+
await new Promise<void>((r) => setTimeout(r, props.delay))
|
|
183
|
+
return h('span', null, useContext(Ctx))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function RequestApp(props: { reqId: string; delay: number }): VNode {
|
|
187
|
+
pushContext(new Map([[Ctx.id, props.reqId]]))
|
|
188
|
+
return h(AsyncReader as unknown as ComponentFn, { delay: props.delay })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const N = 10
|
|
192
|
+
const results = await Promise.all(
|
|
193
|
+
Array.from({ length: N }, (_, i) =>
|
|
194
|
+
renderToString(
|
|
195
|
+
h(RequestApp as unknown as ComponentFn, {
|
|
196
|
+
reqId: `req-${i}`,
|
|
197
|
+
delay: Math.floor(Math.random() * 15),
|
|
198
|
+
}),
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
results.forEach((html, i) => {
|
|
204
|
+
expect(html).toBe(`<span>req-${i}</span>`)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('runWithRequestContext isolates two concurrent calls', async () => {
|
|
209
|
+
const Ctx = createContext('none')
|
|
210
|
+
const [r1, r2] = await Promise.all([
|
|
211
|
+
runWithRequestContext(async () => {
|
|
212
|
+
pushContext(new Map([[Ctx.id, 'isolated-A']]))
|
|
213
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
214
|
+
return useContext(Ctx)
|
|
215
|
+
}),
|
|
216
|
+
runWithRequestContext(async () => {
|
|
217
|
+
pushContext(new Map([[Ctx.id, 'isolated-B']]))
|
|
218
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
219
|
+
return useContext(Ctx)
|
|
220
|
+
}),
|
|
221
|
+
])
|
|
222
|
+
expect(r1).toBe('isolated-A')
|
|
223
|
+
expect(r2).toBe('isolated-B')
|
|
224
|
+
})
|
|
225
|
+
})
|