@invisibleloop/pulse 0.20.38 → 0.20.39

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.
@@ -1,750 +0,0 @@
1
- /**
2
- * Pulse — SSR renderer tests
3
- * run: node src/runtime/ssr.test.js
4
- */
5
-
6
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, withTimeout, safeJson } from './ssr.js'
7
-
8
- // ---------------------------------------------------------------------------
9
- // Test runner
10
- // ---------------------------------------------------------------------------
11
-
12
- let passed = 0
13
- let failed = 0
14
-
15
- async function test(label, fn) {
16
- try {
17
- await fn()
18
- console.log(` ✓ ${label}`)
19
- passed++
20
- } catch (e) {
21
- console.log(` ✗ ${label}`)
22
- console.log(` ${e.message}`)
23
- failed++
24
- }
25
- }
26
-
27
- function assert(condition, msg) {
28
- if (!condition) throw new Error(msg || 'Assertion failed')
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // Helpers
33
- // ---------------------------------------------------------------------------
34
-
35
- async function streamToString(stream) {
36
- const reader = stream.getReader()
37
- const decoder = new TextDecoder()
38
- let result = ''
39
- while (true) {
40
- const { done, value } = await reader.read()
41
- if (done) break
42
- result += decoder.decode(value)
43
- }
44
- return result
45
- }
46
-
47
- // ---------------------------------------------------------------------------
48
- // Specs used across tests
49
- // ---------------------------------------------------------------------------
50
-
51
- const helloSpec = {
52
- route: '/hello',
53
- state: {},
54
- view: () => '<h1>Hello world</h1>'
55
- }
56
-
57
- const counterSpec = {
58
- route: '/counter',
59
- state: { count: 5 },
60
- view: (state) => `<span id="count">${state.count}</span>`
61
- }
62
-
63
- const serverDataSpec = {
64
- route: '/products',
65
- hydrate: '/src/pages/products.js',
66
- state: {},
67
- server: {
68
- products: async (ctx) => [
69
- { id: 1, name: 'Widget' },
70
- { id: 2, name: 'Gadget' }
71
- ]
72
- },
73
- view: (state, server) =>
74
- `<ul>${server.products.map(p => `<li>${p.name}</li>`).join('')}</ul>`
75
- }
76
-
77
- const streamSpec = {
78
- route: '/stream',
79
- stream: {
80
- shell: ['header', 'nav'],
81
- deferred: ['feed']
82
- },
83
- server: {
84
- feed: async () => {
85
- // Simulate async data
86
- await new Promise(r => setTimeout(r, 10))
87
- return [{ title: 'Post 1' }, { title: 'Post 2' }]
88
- }
89
- },
90
- state: {},
91
- view: {
92
- header: () => '<header>Pulse</header>',
93
- nav: () => '<nav>Home</nav>',
94
- feed: (s, server) => `<main>${server.feed.map(p => `<h2>${p.title}</h2>`).join('')}</main>`
95
- }
96
- }
97
-
98
- const metaSpec = {
99
- route: '/about',
100
- state: {},
101
- view: () => '<p>About</p>',
102
- meta: {
103
- title: 'About Us',
104
- description: 'Learn more about us',
105
- ogTitle: 'About Us — Pulse'
106
- }
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
-
111
- console.log('\nrenderToString\n')
112
-
113
- await test('renders simple function view', async () => {
114
- const { html } = await renderToString(helloSpec)
115
- assert(html.includes('<h1>Hello world</h1>'), `Got: ${html}`)
116
- })
117
-
118
- await test('renders initial client state into view', async () => {
119
- const { html } = await renderToString(counterSpec)
120
- assert(html.includes('>5<'), `Expected count 5, got: ${html}`)
121
- })
122
-
123
- await test('resolves server data and passes to view', async () => {
124
- const { html } = await renderToString(serverDataSpec)
125
- assert(html.includes('<li>Widget</li>'), `Expected Widget, got: ${html}`)
126
- assert(html.includes('<li>Gadget</li>'), `Expected Gadget, got: ${html}`)
127
- })
128
-
129
- await test('passes ctx to server data fetchers', async () => {
130
- const spec = {
131
- route: '/user',
132
- state: {},
133
- server: {
134
- greeting: async (ctx) => `Hello ${ctx.user}`
135
- },
136
- view: (s, server) => `<p>${server.greeting}</p>`
137
- }
138
- const { html } = await renderToString(spec, { user: 'Andy' })
139
- assert(html.includes('Hello Andy'), `Got: ${html}`)
140
- })
141
-
142
- await test('exposes ctx.nonce to the view as server.nonce', async () => {
143
- // Regression: ctx.nonce was already available to guard/server fetchers
144
- // (buildContext sets it directly on ctx), but never reached the view
145
- // itself — there was no documented way for a hand-written <script> tag
146
- // (e.g. third-party scripts like AdSense that require an inline snippet
147
- // the framework doesn't generate) to receive the per-request CSP nonce.
148
- const spec = {
149
- route: '/ads',
150
- state: {},
151
- view: (s, server) => `<script nonce="${server.nonce}">/* ad init */</script>`,
152
- }
153
- const { html } = await renderToString(spec, { nonce: 'abc123' })
154
- assert(html.includes('nonce="abc123"'), `Got: ${html}`)
155
- })
156
-
157
- await test('server.nonce is absent (not empty string) when ctx.nonce is unset', async () => {
158
- const { serverState } = await renderToString(helloSpec, {})
159
- assert(serverState.nonce === undefined, `Expected no nonce key, got: ${serverState.nonce}`)
160
- })
161
-
162
- await test('an explicit server.nonce fetcher wins over ctx.nonce', async () => {
163
- const spec = {
164
- route: '/custom-nonce',
165
- state: {},
166
- server: { nonce: async () => 'explicit-value' },
167
- view: (s, server) => `<p>${server.nonce}</p>`,
168
- }
169
- const { serverState } = await renderToString(spec, { nonce: 'from-ctx' })
170
- assert(serverState.nonce === 'explicit-value', `Got: ${serverState.nonce}`)
171
- })
172
-
173
- await test('returns serverState from resolved fetchers', async () => {
174
- const { serverState } = await renderToString(serverDataSpec)
175
- assert(Array.isArray(serverState.products), 'Expected products array')
176
- assert(serverState.products.length === 2, `Expected 2 products, got ${serverState.products.length}`)
177
- })
178
-
179
- await test('returns timing object', async () => {
180
- const { timing } = await renderToString(helloSpec)
181
- assert(typeof timing.data === 'number', 'timing.data should be a number')
182
- assert(typeof timing.render === 'number', 'timing.render should be a number')
183
- assert(typeof timing.total === 'number', 'timing.total should be a number')
184
- assert(timing.total >= 0, 'timing.total should be non-negative')
185
- })
186
-
187
- await test('resolves multiple server fetchers in parallel', async () => {
188
- const spec = {
189
- route: '/multi',
190
- state: {},
191
- server: {
192
- a: async () => { await new Promise(r => setTimeout(r, 20)); return 'A' },
193
- b: async () => { await new Promise(r => setTimeout(r, 20)); return 'B' }
194
- },
195
- view: (s, server) => `${server.a}${server.b}`
196
- }
197
-
198
- const t0 = Date.now()
199
- const { html } = await renderToString(spec)
200
- const elapsed = Date.now() - t0
201
-
202
- assert(html === 'AB', `Got: ${html}`)
203
- // Parallel: should be ~20ms, not ~40ms
204
- assert(elapsed < 35, `Expected parallel resolution ~20ms, took ${elapsed}ms`)
205
- })
206
-
207
- await test('renders segmented view in order', async () => {
208
- const spec = {
209
- route: '/seg',
210
- state: {},
211
- view: {
212
- header: () => '<header/>',
213
- body: () => '<main/>',
214
- footer: () => '<footer/>'
215
- }
216
- }
217
- const { html } = await renderToString(spec)
218
- const headerPos = html.indexOf('<header/>')
219
- const bodyPos = html.indexOf('<main/>')
220
- const footerPos = html.indexOf('<footer/>')
221
- assert(headerPos < bodyPos, 'header should come before body')
222
- assert(bodyPos < footerPos, 'body should come before footer')
223
- })
224
-
225
- await test('throws on invalid spec', async () => {
226
- let threw = false
227
- try { await renderToString({ route: '/bad' }) } catch { threw = true }
228
- assert(threw, 'Expected renderToString to throw on invalid spec')
229
- })
230
-
231
- // ---------------------------------------------------------------------------
232
-
233
- console.log('\nrenderToStream\n')
234
-
235
- await test('streams shell segments immediately', async () => {
236
- const html = await streamToString(renderToStream(streamSpec))
237
- assert(html.includes('<header>Pulse</header>'), `Shell header missing: ${html}`)
238
- assert(html.includes('<nav>Home</nav>'), `Shell nav missing: ${html}`)
239
- })
240
-
241
- await test('streams deferred content after shell', async () => {
242
- const html = await streamToString(renderToStream(streamSpec))
243
- assert(html.includes('Post 1'), `Deferred feed missing Post 1: ${html}`)
244
- assert(html.includes('Post 2'), `Deferred feed missing Post 2: ${html}`)
245
- })
246
-
247
- await test('deferred content comes after shell in stream order', async () => {
248
- const html = await streamToString(renderToStream(streamSpec))
249
- const shellPos = html.indexOf('<header>')
250
- const deferredPos = html.indexOf('Post 1')
251
- assert(shellPos < deferredPos, 'Shell should appear before deferred content')
252
- })
253
-
254
- await test('includes placeholder for deferred segment', async () => {
255
- const html = await streamToString(renderToStream(streamSpec))
256
- assert(html.includes('pulse-deferred'), `Expected pulse-deferred placeholder: ${html}`)
257
- })
258
-
259
- await test('includes replacement script for deferred segment', async () => {
260
- const html = await streamToString(renderToStream(streamSpec))
261
- assert(html.includes('replaceWith'), `Expected inline replacement script: ${html}`)
262
- })
263
-
264
- await test('streams simple function view', async () => {
265
- const html = await streamToString(renderToStream(helloSpec))
266
- assert(html.includes('<h1>Hello world</h1>'), `Got: ${html}`)
267
- })
268
-
269
- await test('stream injects __PULSE_SERVER__ when spec has server data', async () => {
270
- const html = await streamToString(renderToStream(serverDataSpec))
271
- assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
272
- assert(html.includes('"Widget"'), `Expected serialised server data: ${html}`)
273
- })
274
-
275
- await test('stream does not inject __PULSE_SERVER__ when no server data', async () => {
276
- const html = await streamToString(renderToStream(helloSpec))
277
- assert(!html.includes('__PULSE_SERVER__'), `Should not include empty server state: ${html}`)
278
- })
279
-
280
- await test('each server fetcher runs at most once across all segments', async () => {
281
- let callCount = 0
282
- const spec = {
283
- route: '/dedup',
284
- stream: {
285
- shell: ['header'],
286
- deferred: ['feed', 'sidebar'],
287
- },
288
- server: {
289
- user: async () => { callCount++; return 'user-data' },
290
- },
291
- state: {},
292
- view: {
293
- header: (s, { user }) => `<header>${user}</header>`,
294
- feed: (s, { user }) => `<feed>${user}</feed>`,
295
- sidebar: (s, { user }) => `<aside>${user}</aside>`,
296
- }
297
- }
298
-
299
- await streamToString(renderToStream(spec))
300
- assert(callCount === 1, `Expected fetcher called once, got ${callCount}`)
301
- })
302
-
303
- await test('stream.scope: shell does not wait for deferred-only fetchers', async () => {
304
- const spec = {
305
- route: '/scoped',
306
- stream: {
307
- shell: ['header'],
308
- deferred: ['feed'],
309
- scope: {
310
- header: ['user'], // fast (10ms)
311
- feed: ['posts'], // slow (80ms)
312
- }
313
- },
314
- server: {
315
- user: async () => { await new Promise(r => setTimeout(r, 10)); return 'user-data' },
316
- posts: async () => { await new Promise(r => setTimeout(r, 80)); return 'posts-data' },
317
- },
318
- state: {},
319
- view: {
320
- header: (s, { user }) => `<header>${user}</header>`,
321
- feed: (s, { posts }) => `<main>${posts}</main>`,
322
- }
323
- }
324
-
325
- const chunks = []
326
- const stream = renderToStream(spec)
327
- const reader = stream.getReader()
328
- const decoder = new TextDecoder()
329
- let shellTime = null
330
- const t0 = Date.now()
331
-
332
- while (true) {
333
- const { done, value } = await reader.read()
334
- if (done) break
335
- const chunk = decoder.decode(value)
336
- if (shellTime === null && chunk.includes('<header>')) shellTime = Date.now() - t0
337
- chunks.push(chunk)
338
- }
339
-
340
- const fullHtml = chunks.join('')
341
- assert(fullHtml.includes('user-data'), 'header should contain user-data')
342
- assert(fullHtml.includes('posts-data'), 'feed should contain posts-data')
343
- // Shell should arrive ~10ms (user fetcher), not ~80ms (posts fetcher)
344
- assert(shellTime !== null && shellTime < 40, `Shell arrived too late: ${shellTime}ms (expected ~10ms)`)
345
- })
346
-
347
- await test('stream.scope: __PULSE_SERVER__ includes state from all scoped fetchers', async () => {
348
- const spec = {
349
- route: '/scoped-state',
350
- hydrate: '/src/pages/scoped-state.js',
351
- stream: {
352
- shell: ['header'],
353
- deferred: ['feed'],
354
- scope: { header: ['user'], feed: ['posts'] }
355
- },
356
- server: {
357
- user: async () => ({ name: 'Andy' }),
358
- posts: async () => [{ title: 'Hello' }],
359
- },
360
- state: {},
361
- view: {
362
- header: (s, { user }) => `<header>${user.name}</header>`,
363
- feed: (s, { posts }) => `<main>${posts[0].title}</main>`,
364
- }
365
- }
366
-
367
- const html = await streamToString(renderToStream(spec))
368
- assert(html.includes('"name":"Andy"'), `Expected user state serialised: ${html}`)
369
- assert(html.includes('"title":"Hello"'), `Expected posts state serialised: ${html}`)
370
- })
371
-
372
- // ---------------------------------------------------------------------------
373
-
374
- console.log('\nwrapDocument\n')
375
-
376
- await test('wraps content in full HTML document', async () => {
377
- const { html } = wrapDocument({ content: '<p>Hello</p>' })
378
- assert(html.includes('<!DOCTYPE html>'), 'Missing DOCTYPE')
379
- assert(html.includes('<p>Hello</p>'), 'Missing content')
380
- assert(html.includes('</html>'), 'Missing closing tag')
381
- })
382
-
383
- await test('sets title from spec.meta', async () => {
384
- const { html } = wrapDocument({ content: '', spec: metaSpec })
385
- assert(html.includes('<title>About Us</title>'), `Got: ${html}`)
386
- })
387
-
388
- await test('sets description meta tag', async () => {
389
- const { html } = wrapDocument({ content: '', spec: metaSpec })
390
- assert(html.includes('name="description"'), `Got: ${html}`)
391
- assert(html.includes('Learn more about us'), `Got: ${html}`)
392
- })
393
-
394
- await test('sets og:title meta tag', async () => {
395
- const { html } = wrapDocument({ content: '', spec: metaSpec })
396
- assert(html.includes('og:title'), `Got: ${html}`)
397
- })
398
-
399
- await test('serialises server state into page', async () => {
400
- const { html } = wrapDocument({
401
- content: '',
402
- spec: { hydrate: '/src/pages/products.js' },
403
- serverState: { products: [{ id: 1 }] }
404
- })
405
- assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
406
- assert(html.includes('"id":1'), `Expected serialised data: ${html}`)
407
- })
408
-
409
- await test('omits server state script when empty', async () => {
410
- const { html } = wrapDocument({ content: '' })
411
- assert(!html.includes('__PULSE_SERVER__'), 'Should not include empty server state script')
412
- })
413
-
414
- await test('returns serverTimingValue when timing provided', async () => {
415
- const { serverTimingValue } = wrapDocument({
416
- content: '',
417
- timing: { data: 5.2, render: 1.1, total: 6.3 }
418
- })
419
- assert(serverTimingValue !== null, 'Expected timing value')
420
- assert(serverTimingValue.includes('total'), `Got: ${serverTimingValue}`)
421
- })
422
-
423
- await test('escapes special characters in title', async () => {
424
- const { html } = wrapDocument({
425
- content: '',
426
- spec: { meta: { title: '<script>bad</script>' } }
427
- })
428
- assert(!html.includes('<script>bad'), `Title should be escaped: ${html}`)
429
- assert(html.includes('&lt;script&gt;'), `Expected escaped title: ${html}`)
430
- })
431
-
432
- await test('includes hydration script when spec.hydrate is set', async () => {
433
- const { html } = wrapDocument({
434
- content: '',
435
- spec: { hydrate: '/examples/counter.js' }
436
- })
437
- assert(html.includes('type="module"'), `Expected module script: ${html}`)
438
- assert(html.includes('/examples/counter.js'), `Expected spec path: ${html}`)
439
- assert(html.includes('/@pulse/runtime/index.js'), `Expected runtime path: ${html}`)
440
- assert(html.includes('mount('), `Expected mount() call: ${html}`)
441
- })
442
-
443
- await test('uses external script tag for bundle hydrate paths', async () => {
444
- const { html } = wrapDocument({
445
- content: '',
446
- spec: { hydrate: '/dist/counter.boot-ABC123.js' }
447
- })
448
- assert(html.includes('type="module" src="/dist/counter.boot-ABC123.js"'), `Expected external script: ${html}`)
449
- assert(!html.includes('import spec from'), `Should not include inline import: ${html}`)
450
- })
451
-
452
- await test('omits hydration script when spec.hydrate is not set', async () => {
453
- const { html } = wrapDocument({ content: '' })
454
- assert(!html.includes("import spec from"), `Should not include import: ${html}`)
455
- })
456
-
457
- // ---------------------------------------------------------------------------
458
-
459
- console.log('\nError boundaries (SSR)\n')
460
-
461
- await test('renderToString: rethrows when view throws and no onViewError — server handles it', async () => {
462
- const spec = {
463
- route: '/err',
464
- state: {},
465
- view: () => { throw new Error('ssr boom') }
466
- }
467
- let threw = false
468
- try { await renderToString(spec) } catch (e) {
469
- threw = true
470
- assert(e.message === 'ssr boom', `Expected original error, got: ${e.message}`)
471
- }
472
- assert(threw, 'Expected renderToString to rethrow when no onViewError is defined')
473
- })
474
-
475
- await test('renderToString: calls spec.onViewError and returns fallback when view throws', async () => {
476
- const spec = {
477
- route: '/err',
478
- state: { val: 99 },
479
- view: () => { throw new Error('ssr oops') },
480
- onViewError: (err, state) => `<p data-testid="fallback">${err.message}:${state.val}</p>`
481
- }
482
- const { html } = await renderToString(spec)
483
- assert(html.includes('data-testid="fallback"'), `Expected custom fallback, got: ${html}`)
484
- assert(html.includes('ssr oops:99'), `Expected err + state in fallback, got: ${html}`)
485
- })
486
-
487
- // ---------------------------------------------------------------------------
488
-
489
- console.log('\nRequest timeouts\n')
490
-
491
- await test('withTimeout: resolves normally when promise completes in time', async () => {
492
- const result = await withTimeout(Promise.resolve('ok'), 100, 'test')
493
- assert(result === 'ok', `Expected "ok", got "${result}"`)
494
- })
495
-
496
- await test('withTimeout: rejects with timeout error when promise is too slow', async () => {
497
- const slow = new Promise(r => setTimeout(r, 200))
498
- let err
499
- try { await withTimeout(slow, 30, 'myFetcher') } catch (e) { err = e }
500
- assert(err?.message?.includes('myFetcher'), `Expected fetcher name in error: ${err?.message}`)
501
- assert(err?.message?.includes('30ms'), `Expected timeout duration in error: ${err?.message}`)
502
- })
503
-
504
- await test('withTimeout: no-op when ms is null', async () => {
505
- const result = await withTimeout(Promise.resolve(42), null, 'test')
506
- assert(result === 42, `Expected 42, got ${result}`)
507
- })
508
-
509
- await test('withTimeout: no-op when ms is 0', async () => {
510
- const result = await withTimeout(Promise.resolve(42), 0, 'test')
511
- assert(result === 42, `Expected 42, got ${result}`)
512
- })
513
-
514
- await test('renderToString: ctx.fetcherTimeout is applied to server fetchers', async () => {
515
- const spec = {
516
- route: '/slow',
517
- state: {},
518
- server: {
519
- data: () => new Promise(r => setTimeout(() => r('done'), 200))
520
- },
521
- view: (state, server) => `<p>${server.data}</p>`
522
- }
523
- let err
524
- try {
525
- await renderToString(spec, { fetcherTimeout: 30 })
526
- } catch (e) { err = e }
527
- assert(err?.message?.includes('timed out'), `Expected timeout error, got: ${err?.message}`)
528
- })
529
-
530
- await test('renderToString: resolves normally when fetcher completes within timeout', async () => {
531
- const spec = {
532
- route: '/fast',
533
- state: {},
534
- server: {
535
- data: () => Promise.resolve('quick')
536
- },
537
- view: (state, server) => `<p>${server.data}</p>`
538
- }
539
- const { html } = await renderToString(spec, { fetcherTimeout: 500 })
540
- assert(html.includes('quick'), `Expected rendered data, got: ${html}`)
541
- })
542
-
543
- // ---------------------------------------------------------------------------
544
- // renderToNavStream
545
- // ---------------------------------------------------------------------------
546
-
547
- /**
548
- * Collect all NDJSON messages from a renderToNavStream response.
549
- * Returns them as an array of parsed objects in arrival order.
550
- */
551
- async function collectNavMessages(stream) {
552
- const reader = stream.getReader()
553
- const decoder = new TextDecoder()
554
- let buf = ''
555
- const messages = []
556
-
557
- while (true) {
558
- const { done, value } = await reader.read()
559
- if (done) break
560
- buf += decoder.decode(value, { stream: true })
561
- const lines = buf.split('\n')
562
- buf = lines.pop()
563
- for (const line of lines) {
564
- if (line.trim()) messages.push(JSON.parse(line))
565
- }
566
- }
567
- if (buf.trim()) messages.push(JSON.parse(buf))
568
- return messages
569
- }
570
-
571
- console.log('\nrenderToNavStream\n')
572
-
573
- await test('sends meta as the first message with no data fetching', async () => {
574
- const spec = {
575
- route: '/nav',
576
- state: {},
577
- view: () => '<main id="main-content">hello</main>',
578
- hydrate: '/nav.js',
579
- }
580
- const stream = renderToNavStream(spec, {}, { title: 'Nav Page', styles: ['/app.css'] })
581
- const messages = await collectNavMessages(stream)
582
- const meta = messages[0]
583
- assert(meta.type === 'meta', `First message should be meta, got ${meta.type}`)
584
- assert(meta.title === 'Nav Page', `Expected title 'Nav Page', got ${meta.title}`)
585
- assert(meta.hydrate === '/nav.js', `Expected hydrate '/nav.js', got ${meta.hydrate}`)
586
- assert(meta.styles[0] === '/app.css', 'styles should be forwarded')
587
- })
588
-
589
- await test('sends html message with shell content', async () => {
590
- const spec = {
591
- route: '/nav',
592
- state: {},
593
- view: () => '<main id="main-content">shell</main>',
594
- }
595
- const messages = await collectNavMessages(renderToNavStream(spec))
596
- const html = messages.find(m => m.type === 'html')
597
- assert(html, 'should have html message')
598
- assert(html.html.includes('shell'), 'html should contain view output')
599
- })
600
-
601
- await test('sends done message with serverState', async () => {
602
- const spec = {
603
- route: '/nav',
604
- state: {},
605
- server: { items: async () => [1, 2, 3] },
606
- view: (s, srv) => `<main id="main-content">${srv.items.length}</main>`,
607
- }
608
- const messages = await collectNavMessages(renderToNavStream(spec))
609
- const done = messages.find(m => m.type === 'done')
610
- assert(done, 'should have done message')
611
- assert(Array.isArray(done.serverState?.items), 'serverState.items should be present')
612
- assert(done.serverState.items.length === 3, 'serverState.items should have 3 entries')
613
- })
614
-
615
- await test('sends deferred messages for each deferred segment', async () => {
616
- const spec = {
617
- route: '/nav',
618
- state: {},
619
- server: { user: async () => ({ name: 'Alice' }), posts: async () => ['a', 'b'] },
620
- stream: {
621
- shell: ['header'],
622
- deferred: ['feed'],
623
- },
624
- view: {
625
- header: () => '<header>nav</header>',
626
- feed: (s, srv) => `<aside>${srv.posts.length} posts</aside>`,
627
- },
628
- }
629
- const messages = await collectNavMessages(renderToNavStream(spec))
630
- const types = messages.map(m => m.type)
631
- assert(types[0] === 'meta', `First should be meta, got ${types[0]}`)
632
- assert(types.includes('html'), 'should include html')
633
- assert(types.includes('deferred'), 'should include deferred')
634
- assert(types[types.length - 1] === 'done', 'last should be done')
635
-
636
- const deferred = messages.find(m => m.type === 'deferred')
637
- assert(deferred.id === 'feed', `Expected deferred id 'feed', got ${deferred.id}`)
638
- assert(deferred.html.includes('2 posts'), `Expected feed content, got: ${deferred.html}`)
639
- })
640
-
641
- await test('shell html includes <pulse-deferred> placeholders for deferred segments', async () => {
642
- const spec = {
643
- route: '/nav',
644
- state: {},
645
- server: { posts: async () => [] },
646
- stream: { shell: ['header'], deferred: ['feed'] },
647
- view: {
648
- header: () => '<header>top</header>',
649
- feed: () => '<aside>feed</aside>',
650
- },
651
- }
652
- const messages = await collectNavMessages(renderToNavStream(spec))
653
- const html = messages.find(m => m.type === 'html')
654
- assert(html.html.includes('pd-feed'), 'shell html should include <pulse-deferred id="pd-feed"> placeholder')
655
- assert(Array.isArray(html.deferred), 'html message should list deferred key names')
656
- assert(html.deferred[0] === 'feed', `Expected deferred[0]='feed', got ${html.deferred[0]}`)
657
- })
658
-
659
- await test('stream.scope: meta arrives before slow deferred fetcher resolves', async () => {
660
- const spec = {
661
- route: '/nav',
662
- state: {},
663
- server: {
664
- fast: async () => 'quick',
665
- slow: async () => { await new Promise(r => setTimeout(r, 80)); return 'late' },
666
- },
667
- stream: {
668
- shell: ['header'],
669
- deferred: ['feed'],
670
- scope: { header: ['fast'], feed: ['slow'] },
671
- },
672
- view: {
673
- header: (s, srv) => `<header>${srv.fast}</header>`,
674
- feed: (s, srv) => `<aside>${srv.slow}</aside>`,
675
- },
676
- }
677
- const t0 = Date.now()
678
- const stream = renderToNavStream(spec)
679
- const reader = stream.getReader()
680
- const decoder = new TextDecoder()
681
- let buf = ''
682
- let metaTime = null
683
- let shellTime = null
684
-
685
- outer: while (true) {
686
- const { done, value } = await reader.read()
687
- if (done) break
688
- buf += decoder.decode(value, { stream: true })
689
- const lines = buf.split('\n')
690
- buf = lines.pop()
691
- for (const line of lines) {
692
- if (!line.trim()) continue
693
- const msg = JSON.parse(line)
694
- if (msg.type === 'meta') metaTime = Date.now() - t0
695
- if (msg.type === 'html') { shellTime = Date.now() - t0; break outer }
696
- }
697
- }
698
- reader.cancel()
699
-
700
- assert(metaTime !== null, 'meta should have arrived')
701
- assert(shellTime !== null, 'shell should have arrived')
702
- assert(metaTime < 20, `meta should arrive <20ms (fast), got ${metaTime}ms`)
703
- assert(shellTime < 40, `shell should arrive <40ms (only awaits 'fast'), got ${shellTime}ms`)
704
- })
705
-
706
- // ---------------------------------------------------------------------------
707
-
708
- console.log('\nScript-context serialisation\n')
709
-
710
- const HOSTILE = '</script><img src=x onerror=alert(1)><!-- \u2028 & '
711
-
712
- await test('safeJson never emits < > & or line separators, and round-trips the value', () => {
713
- const value = { a: HOSTILE, nested: [HOSTILE], n: 1 }
714
- const out = safeJson(value)
715
- assert(!/[<>&\u2028\u2029]/.test(out), `unsafe character left in: ${out}`)
716
- assert(JSON.stringify(JSON.parse(out)) === JSON.stringify(value), 'value changed in round trip')
717
- })
718
-
719
- await test('safeJson(undefined) stays undefined like JSON.stringify', () => {
720
- assert(safeJson(undefined) === undefined)
721
- })
722
-
723
- await test('wrapDocument cannot be broken out of via server state, store state or schema', () => {
724
- const { html } = wrapDocument({
725
- content: '<p>x</p>',
726
- spec: { hydrate: '/dist/x.boot-1.js', meta: { title: 't', schema: { name: HOSTILE } } },
727
- serverState: { comment: HOSTILE },
728
- storeState: { note: HOSTILE },
729
- nonce: 'abc',
730
- })
731
- assert(!html.includes('</script><img'), 'script element was closed early by data')
732
- assert(!html.includes('onerror=alert(1)><!--'), 'raw hostile payload reached the document')
733
- const state = html.match(/window\.__PULSE_SERVER__ = (.*?);<\/script>/s)[1]
734
- assert(JSON.stringify(JSON.parse(state)) === JSON.stringify({ comment: HOSTILE }), 'state changed in round trip')
735
- })
736
-
737
- await test('wrapDocument deferred style hrefs are emitted as safe JS strings', () => {
738
- const { html } = wrapDocument({
739
- content: '',
740
- spec: { meta: { title: 't', deferredStyles: ["/a.css';alert(1);//", '/b\\'] } },
741
- nonce: 'abc',
742
- })
743
- assert(html.includes(`l.href="/a.css';alert(1);//"`), 'href must be a JSON string literal')
744
- assert(html.includes('l.href="/b\\\\"'), 'trailing backslash must be escaped')
745
- })
746
-
747
- // ---------------------------------------------------------------------------
748
-
749
- console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
750
- if (failed > 0) process.exit(1)