@invisibleloop/pulse 0.20.38 → 0.20.40

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,2474 +0,0 @@
1
- /**
2
- * Pulse — HTTP Server tests
3
- * run: node src/server/server.test.js
4
- */
5
-
6
- import http from 'http'
7
- import fs from 'fs'
8
- import os from 'os'
9
- import path from 'path'
10
- import { createServer, TtlCache } from './index.js'
11
-
12
- // ---------------------------------------------------------------------------
13
- // Test runner
14
- // ---------------------------------------------------------------------------
15
-
16
- let passed = 0
17
- let failed = 0
18
-
19
- async function test(label, fn) {
20
- try {
21
- await fn()
22
- console.log(` ✓ ${label}`)
23
- passed++
24
- } catch (e) {
25
- console.log(` ✗ ${label}`)
26
- console.log(` ${e.message}`)
27
- failed++
28
- }
29
- }
30
-
31
- function assert(condition, msg) {
32
- if (!condition) throw new Error(msg || 'Assertion failed')
33
- }
34
-
35
- // Guard against a silent early exit. Production code calls process.exit() (graceful shutdown), and a
36
- // test that lets it through ends the whole run with status 0 — skipping every later test and hiding
37
- // earlier failures. The summary at the bottom sets `finished`; if the process exits without it, fail.
38
- let finished = false
39
- process.on('exit', () => {
40
- if (finished) return
41
- console.error(`\n ✗ test run ended early after ${passed + failed} tests — something called process.exit() before the summary\n`)
42
- process.exitCode = 1
43
- })
44
-
45
- /**
46
- * Replace process.exit while a test runs so graceful-shutdown code can "exit" without killing the run.
47
- * Returns { codes, waitForExit(ms), restore() } — always restore() in a finally.
48
- */
49
- function captureExit() {
50
- const real = process.exit
51
- const codes = []
52
- let notify
53
- const called = new Promise(resolve => { notify = resolve })
54
- process.exit = (code = 0) => { codes.push(code); notify() }
55
- return {
56
- codes,
57
- /** Resolves true once process.exit() has been called, false if it wasn't within `ms`. */
58
- waitForExit: (ms = 2000) => Promise.race([called.then(() => true), new Promise(r => setTimeout(() => r(false), ms))]),
59
- restore: () => { process.exit = real },
60
- }
61
- }
62
-
63
- // ---------------------------------------------------------------------------
64
- // HTTP helpers
65
- // ---------------------------------------------------------------------------
66
-
67
- function get(port, path) {
68
- return new Promise((resolve, reject) => {
69
- const req = http.get(`http://localhost:${port}${path}`, (res) => {
70
- let body = ''
71
- res.on('data', chunk => { body += chunk })
72
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
73
- })
74
- req.on('error', reject)
75
- })
76
- }
77
-
78
- function request(port, method, path, headers = {}) {
79
- return new Promise((resolve, reject) => {
80
- const req = http.request(
81
- { method, hostname: 'localhost', port, path, headers },
82
- (res) => {
83
- let body = ''
84
- res.on('data', c => { body += c })
85
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
86
- }
87
- )
88
- req.on('error', reject)
89
- req.end()
90
- })
91
- }
92
-
93
- function requestWithBody(port, method, path, body, contentType = 'application/json') {
94
- const buf = Buffer.isBuffer(body) ? body : Buffer.from(body)
95
- return new Promise((resolve, reject) => {
96
- const req = http.request(
97
- {
98
- method,
99
- hostname: 'localhost',
100
- port,
101
- path,
102
- headers: { 'Content-Type': contentType, 'Content-Length': buf.length }
103
- },
104
- (res) => {
105
- let resBody = ''
106
- res.on('data', c => { resBody += c })
107
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: resBody }))
108
- }
109
- )
110
- req.on('error', reject)
111
- req.write(buf)
112
- req.end()
113
- })
114
- }
115
-
116
- // ---------------------------------------------------------------------------
117
- // Specs used across tests
118
- // ---------------------------------------------------------------------------
119
-
120
- const helloSpec = {
121
- route: '/hello',
122
- state: {},
123
- view: () => '<h1>Hello world</h1>'
124
- }
125
-
126
- const productSpec = {
127
- route: '/products/:id',
128
- state: {},
129
- server: {
130
- product: async (ctx) => ({ id: ctx.params.id, name: 'Widget' })
131
- },
132
- view: (_s, server) => `<h1>${server.product.name} (${server.product.id})</h1>`
133
- }
134
-
135
- const querySpec = {
136
- route: '/search',
137
- state: {},
138
- server: {
139
- results: async (ctx) => [`Result for: ${ctx.query.q || 'none'}`]
140
- },
141
- view: (_s, server) => `<p>${server.results[0]}</p>`
142
- }
143
-
144
- const cookieSpec = {
145
- route: '/profile',
146
- state: {},
147
- server: {
148
- user: async (ctx) => ctx.cookies.user || 'anonymous'
149
- },
150
- view: (_s, server) => `<p>${server.user}</p>`
151
- }
152
-
153
- const metaSpec = {
154
- route: '/about',
155
- state: {},
156
- view: () => '<p>About</p>',
157
- meta: { title: 'About Us', description: 'Learn more' }
158
- }
159
-
160
- // ---------------------------------------------------------------------------
161
- // Server lifecycle helper — unique port per test to avoid TCP TIME_WAIT
162
- // ---------------------------------------------------------------------------
163
-
164
- let nextPort = 13337
165
-
166
- async function withServer(specs, options, fn) {
167
- const port = nextPort++
168
- const { server } = await createServer(specs, { ...options, port })
169
- await new Promise(resolve => server.once('listening', resolve))
170
- try {
171
- await fn(port)
172
- } finally {
173
- server.closeAllConnections?.()
174
- await new Promise(resolve => server.close(resolve))
175
- }
176
- }
177
-
178
- // ---------------------------------------------------------------------------
179
-
180
- console.log('\nRoute matching\n')
181
-
182
- await test('serves a registered route', async () => {
183
- await withServer([helloSpec], { stream: false }, async (port) => {
184
- const { status, body } = await get(port, '/hello')
185
- assert(status === 200, `Expected 200, got ${status}`)
186
- assert(body.includes('<h1>Hello world</h1>'), `Missing content: ${body}`)
187
- })
188
- })
189
-
190
- await test('returns 404 for unknown route', async () => {
191
- await withServer([helloSpec], { stream: false }, async (port) => {
192
- const { status, body } = await get(port, '/nope')
193
- assert(status === 404, `Expected 404, got ${status}`)
194
- assert(body.includes('404'), `Expected 404 page: ${body}`)
195
- })
196
- })
197
-
198
- await test('extracts route params', async () => {
199
- await withServer([productSpec], { stream: false }, async (port) => {
200
- const { status, body } = await get(port, '/products/42')
201
- assert(status === 200, `Expected 200, got ${status}`)
202
- assert(body.includes('Widget (42)'), `Expected param in output: ${body}`)
203
- })
204
- })
205
-
206
- await test('serves multiple routes', async () => {
207
- await withServer([helloSpec, productSpec], { stream: false }, async (port) => {
208
- const r1 = await get(port, '/hello')
209
- const r2 = await get(port, '/products/99')
210
- assert(r1.body.includes('Hello world'), `Route 1 failed: ${r1.body}`)
211
- assert(r2.body.includes('Widget (99)'), `Route 2 failed: ${r2.body}`)
212
- })
213
- })
214
-
215
- // ---------------------------------------------------------------------------
216
-
217
- console.log('\nRequest context\n')
218
-
219
- await test('passes query string to server fetcher', async () => {
220
- await withServer([querySpec], { stream: false }, async (port) => {
221
- const { body } = await get(port, '/search?q=pulse')
222
- assert(body.includes('Result for: pulse'), `Expected query result: ${body}`)
223
- })
224
- })
225
-
226
- await test('parses cookies from Cookie header', async () => {
227
- await withServer([cookieSpec], { stream: false }, async (port) => {
228
- const { body } = await request(port, 'GET', '/profile', { Cookie: 'user=andy' })
229
- assert(body.includes('andy'), `Expected cookie value in output: ${body}`)
230
- })
231
- })
232
-
233
- await test('returns anonymous when no cookie set', async () => {
234
- await withServer([cookieSpec], { stream: false }, async (port) => {
235
- const { body } = await get(port, '/profile')
236
- assert(body.includes('anonymous'), `Expected anonymous: ${body}`)
237
- })
238
- })
239
-
240
- // ---------------------------------------------------------------------------
241
-
242
- console.log('\nResponse — string mode\n')
243
-
244
- await test('wraps content in full HTML document', async () => {
245
- await withServer([helloSpec], { stream: false }, async (port) => {
246
- const { body } = await get(port, '/hello')
247
- assert(body.includes('<!DOCTYPE html>'), `Missing DOCTYPE: ${body}`)
248
- assert(body.includes('</html>'), `Missing closing tag: ${body}`)
249
- })
250
- })
251
-
252
- await test('sets title from spec.meta', async () => {
253
- await withServer([metaSpec], { stream: false }, async (port) => {
254
- const { body } = await get(port, '/about')
255
- assert(body.includes('<title>About Us</title>'), `Expected title: ${body}`)
256
- })
257
- })
258
-
259
- await test('resolves async meta.title function (string mode)', async () => {
260
- const spec = {
261
- route: '/async-meta',
262
- state: {},
263
- view: () => '<p>content</p>',
264
- meta: { title: async () => 'Async Title' },
265
- }
266
- await withServer([spec], { stream: false }, async (port) => {
267
- const { body } = await get(port, '/async-meta')
268
- assert(body.includes('<title>Async Title</title>'), `Expected async title: ${body}`)
269
- })
270
- })
271
-
272
- await test('resolves async meta.title function (streaming mode)', async () => {
273
- const spec = {
274
- route: '/async-meta-stream',
275
- state: {},
276
- view: () => '<p>content</p>',
277
- meta: { title: async () => 'Stream Async Title' },
278
- }
279
- await withServer([spec], { stream: true }, async (port) => {
280
- const { body } = await get(port, '/async-meta-stream')
281
- assert(body.includes('<title>Stream Async Title</title>'), `Expected async title in stream: ${body}`)
282
- })
283
- })
284
-
285
- await test('injects JSON-LD script when meta.schema is set (string mode)', async () => {
286
- const spec = {
287
- route: '/article',
288
- state: {},
289
- view: () => '<p>Article</p>',
290
- meta: {
291
- title: 'An Article',
292
- schema: { '@context': 'https://schema.org', '@type': 'Article', headline: 'An Article' }
293
- }
294
- }
295
- await withServer([spec], { stream: false }, async (port) => {
296
- const { body } = await get(port, '/article')
297
- assert(body.includes('application/ld+json'), `Expected ld+json script: ${body.slice(0, 300)}`)
298
- assert(body.includes('schema.org'), `Expected schema.org context`)
299
- assert(body.includes('"Article"'), `Expected @type Article`)
300
- })
301
- })
302
-
303
- await test('injects JSON-LD script when meta.schema is set (stream mode)', async () => {
304
- const spec = {
305
- route: '/article-stream',
306
- state: {},
307
- view: () => '<p>Article</p>',
308
- meta: {
309
- title: 'An Article',
310
- schema: { '@context': 'https://schema.org', '@type': 'Article', headline: 'An Article' }
311
- }
312
- }
313
- await withServer([spec], { stream: true }, async (port) => {
314
- const { body } = await get(port, '/article-stream')
315
- assert(body.includes('application/ld+json'), `Expected ld+json in stream: ${body.slice(0, 300)}`)
316
- assert(body.includes('schema.org'), `Expected schema.org context in stream`)
317
- })
318
- })
319
-
320
- await test('sets Content-Type to text/html', async () => {
321
- await withServer([helloSpec], { stream: false }, async (port) => {
322
- const { headers } = await get(port, '/hello')
323
- assert(headers['content-type']?.includes('text/html'), `Expected text/html: ${headers['content-type']}`)
324
- })
325
- })
326
-
327
- await test('sets Server-Timing header', async () => {
328
- await withServer([helloSpec], { stream: false }, async (port) => {
329
- const { headers } = await get(port, '/hello')
330
- assert(headers['server-timing'], `Expected Server-Timing header`)
331
- assert(headers['server-timing'].includes('total'), `Expected total in Server-Timing: ${headers['server-timing']}`)
332
- })
333
- })
334
-
335
- // ---------------------------------------------------------------------------
336
-
337
- console.log('\nResponse — stream mode\n')
338
-
339
- await test('streams content with Transfer-Encoding: chunked', async () => {
340
- await withServer([helloSpec], { stream: true }, async (port) => {
341
- const { headers, body } = await get(port, '/hello')
342
- assert(headers['transfer-encoding'] === 'chunked', `Expected chunked: ${headers['transfer-encoding']}`)
343
- assert(body.includes('<h1>Hello world</h1>'), `Missing content: ${body}`)
344
- })
345
- })
346
-
347
- await test('stream response includes pulse-root div', async () => {
348
- await withServer([helloSpec], { stream: true }, async (port) => {
349
- const { body } = await get(port, '/hello')
350
- assert(body.includes('pulse-root'), `Missing pulse-root: ${body}`)
351
- })
352
- })
353
-
354
- await test('stream response includes timing script', async () => {
355
- await withServer([helloSpec], { stream: true }, async (port) => {
356
- const { body } = await get(port, '/hello')
357
- assert(body.includes('__PULSE_TIMING__'), `Missing timing script: ${body}`)
358
- })
359
- })
360
-
361
- // ---------------------------------------------------------------------------
362
-
363
- console.log('\nMethod handling\n')
364
-
365
- await test('rejects non-GET requests with 405', async () => {
366
- await withServer([helloSpec], { stream: false }, async (port) => {
367
- const { status } = await request(port, 'POST', '/hello')
368
- assert(status === 405, `Expected 405, got ${status}`)
369
- })
370
- })
371
-
372
- // ---------------------------------------------------------------------------
373
-
374
- console.log('\nStartup validation\n')
375
-
376
- await test('throws on invalid spec at startup', async () => {
377
- let threw = false
378
- try {
379
- await createServer([{ route: '/bad' }], { port: 19999 })
380
- } catch (e) {
381
- threw = true
382
- assert(e.message.includes('/bad'), `Expected route in error: ${e.message}`)
383
- }
384
- assert(threw, 'Expected createServer to throw on invalid spec')
385
- })
386
-
387
- await test('onRequest hook can short-circuit routing', async () => {
388
- await withServer([helloSpec], {
389
- stream: false,
390
- onRequest: (_req, res) => {
391
- res.writeHead(403, { 'Content-Type': 'text/plain' })
392
- res.end('Forbidden')
393
- return false
394
- }
395
- }, async (port) => {
396
- const { status } = await get(port, '/hello')
397
- assert(status === 403, `Expected 403, got ${status}`)
398
- })
399
- })
400
-
401
- // ---------------------------------------------------------------------------
402
-
403
- console.log('\nClient-side navigation\n')
404
-
405
- const navSpec = {
406
- route: '/nav-page',
407
- hydrate: '/examples/nav-page.js',
408
- state: {},
409
- meta: { title: 'Nav Page' },
410
- server: { msg: async () => 'hello from server' },
411
- view: (_s, server) => `<p>${server.msg}</p>`
412
- }
413
-
414
- await test('X-Pulse-Navigate returns JSON', async () => {
415
- await withServer([navSpec], { stream: false }, async (port) => {
416
- const { status, headers } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
417
- assert(status === 200, `Expected 200, got ${status}`)
418
- assert(headers['content-type']?.includes('application/json'), `Expected JSON: ${headers['content-type']}`)
419
- })
420
- })
421
-
422
- await test('navigation response contains html, title, hydrate', async () => {
423
- await withServer([navSpec], { stream: false }, async (port) => {
424
- const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
425
- const payload = JSON.parse(body)
426
- assert(typeof payload.html === 'string', `Expected html string`)
427
- assert(payload.title === 'Nav Page', `Expected title: ${payload.title}`)
428
- assert(payload.hydrate === '/examples/nav-page.js', `Expected hydrate path: ${payload.hydrate}`)
429
- })
430
- })
431
-
432
- await test('navigation response html contains rendered server data', async () => {
433
- await withServer([navSpec], { stream: false }, async (port) => {
434
- const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
435
- const { html } = JSON.parse(body)
436
- assert(html.includes('hello from server'), `Expected server data in html: ${html}`)
437
- })
438
- })
439
-
440
- await test('navigation response is not a full HTML document', async () => {
441
- await withServer([navSpec], { stream: false }, async (port) => {
442
- const { body } = await request(port, 'GET', '/nav-page', { 'X-Pulse-Navigate': 'true' })
443
- const { html } = JSON.parse(body)
444
- assert(!html.includes('<!DOCTYPE'), `Nav response should not be a full document: ${html}`)
445
- })
446
- })
447
-
448
- await test('normal request still returns full HTML document', async () => {
449
- await withServer([navSpec], { stream: false }, async (port) => {
450
- const { body } = await get(port, '/nav-page')
451
- assert(body.includes('<!DOCTYPE html>'), `Expected full document: ${body}`)
452
- })
453
- })
454
-
455
- // ---------------------------------------------------------------------------
456
-
457
- const streamNavSpec = {
458
- route: '/stream-nav',
459
- hydrate: '/examples/stream-nav.js',
460
- state: {},
461
- meta: { title: 'Stream Nav', styles: ['/app.css'] },
462
- server: { msg: async () => 'streamed hello' },
463
- view: (_s, server) => `<main id="main-content">${server.msg}</main>`
464
- }
465
-
466
- const deferredNavSpec = {
467
- route: '/deferred-nav',
468
- hydrate: '/examples/deferred-nav.js',
469
- state: {},
470
- meta: { title: 'Deferred Nav' },
471
- server: {
472
- fast: async () => 'shell-data',
473
- slow: async () => { await new Promise(r => setTimeout(r, 30)); return 'deferred-data' },
474
- },
475
- stream: {
476
- shell: ['header'],
477
- deferred: ['feed'],
478
- scope: { header: ['fast'], feed: ['slow'] },
479
- },
480
- view: {
481
- header: (_s, srv) => `<header>${srv.fast}</header>`,
482
- feed: (_s, srv) => `<aside>${srv.slow}</aside>`,
483
- },
484
- }
485
-
486
- console.log('\nClient-side navigation — streaming (stream: true)\n')
487
-
488
- await test('X-Pulse-Navigate with stream:true returns application/x-ndjson for deferred specs', async () => {
489
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
490
- const { status, headers } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
491
- assert(status === 200, `Expected 200, got ${status}`)
492
- assert(headers['content-type']?.includes('application/x-ndjson'), `Expected NDJSON: ${headers['content-type']}`)
493
- })
494
- })
495
-
496
- await test('X-Pulse-Navigate with stream:true returns application/json for non-deferred specs', async () => {
497
- await withServer([streamNavSpec], { stream: true }, async (port) => {
498
- const { status, headers } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
499
- assert(status === 200, `Expected 200, got ${status}`)
500
- assert(headers['content-type']?.includes('application/json'), `Expected JSON: ${headers['content-type']}`)
501
- })
502
- })
503
-
504
- await test('streaming nav: first NDJSON line is a meta message with title and hydrate', async () => {
505
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
506
- const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
507
- const lines = body.trim().split('\n').filter(Boolean)
508
- const meta = JSON.parse(lines[0])
509
- assert(meta.type === 'meta', `First line should be meta, got ${meta.type}`)
510
- assert(meta.title === 'Deferred Nav', `Expected title 'Deferred Nav', got ${meta.title}`)
511
- assert(meta.hydrate === '/examples/deferred-nav.js', `Expected hydrate, got ${meta.hydrate}`)
512
- })
513
- })
514
-
515
- await test('streaming nav: html message contains rendered server data', async () => {
516
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
517
- const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
518
- const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
519
- const htmlMsg = messages.find(m => m.type === 'html')
520
- assert(htmlMsg, 'should have html message')
521
- assert(htmlMsg.html.includes('shell-data'), `Expected shell server data in html: ${htmlMsg.html}`)
522
- assert(!htmlMsg.html.includes('<!DOCTYPE'), 'html should not be a full document')
523
- })
524
- })
525
-
526
- await test('streaming nav: done message contains serverState', async () => {
527
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
528
- const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
529
- const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
530
- const done = messages.find(m => m.type === 'done')
531
- assert(done, 'should have done message')
532
- assert(done.serverState?.fast === 'shell-data', `Expected serverState.fast, got ${JSON.stringify(done.serverState)}`)
533
- })
534
- })
535
-
536
- await test('streaming nav: deferred spec sends shell then deferred then done', async () => {
537
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
538
- const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
539
- const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
540
- const types = messages.map(m => m.type)
541
- assert(types[0] === 'meta', `First should be meta, got ${types[0]}`)
542
- assert(types.includes('html'), 'should include html')
543
- assert(types.includes('deferred'), 'should include deferred')
544
- assert(types[types.length - 1] === 'done', 'last should be done')
545
-
546
- const deferred = messages.find(m => m.type === 'deferred')
547
- assert(deferred?.id === 'feed', `Expected deferred id 'feed', got ${deferred?.id}`)
548
- assert(deferred?.html.includes('deferred-data'), `Expected deferred content, got: ${deferred?.html}`)
549
-
550
- // Shell arrives before deferred: html index should precede deferred index
551
- const htmlIdx = types.indexOf('html')
552
- const deferredIdx = types.indexOf('deferred')
553
- assert(htmlIdx < deferredIdx, `html (${htmlIdx}) should precede deferred (${deferredIdx})`)
554
- })
555
- })
556
-
557
- await test('streaming nav: html includes <pulse-deferred> placeholder for deferred segments', async () => {
558
- await withServer([deferredNavSpec], { stream: true }, async (port) => {
559
- const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
560
- const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
561
- const html = messages.find(m => m.type === 'html')
562
- assert(html?.html.includes('pd-feed'), `Shell html should include placeholder, got: ${html?.html}`)
563
- })
564
- })
565
-
566
- // ---------------------------------------------------------------------------
567
-
568
- console.log('\nError pages\n')
569
-
570
- const throwingSpec = {
571
- route: '/boom',
572
- state: {},
573
- view: () => { throw new Error('view exploded') }
574
- }
575
-
576
- await test('dev mode returns HTML error page on render error (string mode)', async () => {
577
- // dev: true writes to the error journal at <root>/.pulse/errors.json — pass an
578
- // isolated tmp root so this doesn't leave a file behind in the package directory.
579
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
580
- await withServer([throwingSpec], { stream: false, dev: true, root: tmpRoot }, async (port) => {
581
- const { status, headers, body } = await get(port, '/boom')
582
- assert(status === 500, `Expected 500, got ${status}`)
583
- assert(headers['content-type']?.includes('text/html'), `Expected HTML, got ${headers['content-type']}`)
584
- assert(body.includes('view exploded'), `Expected error message in page: ${body.slice(0, 200)}`)
585
- assert(body.includes('Stack trace'), `Expected stack trace section in page`)
586
- })
587
- fs.rmSync(tmpRoot, { recursive: true, force: true })
588
- })
589
-
590
- await test('production mode returns generic error page (no stack trace)', async () => {
591
- await withServer([throwingSpec], { stream: false, dev: false }, async (port) => {
592
- const { status, body } = await get(port, '/boom')
593
- assert(status === 500, `Expected 500, got ${status}`)
594
- assert(!body.includes('view exploded'), `Should not expose error details in prod: ${body.slice(0, 200)}`)
595
- assert(body.includes('500'), `Expected 500 in body`)
596
- })
597
- })
598
-
599
- await test('dev mode injects error into stream when render throws (stream mode)', async () => {
600
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
601
- await withServer([throwingSpec], { stream: true, dev: true, root: tmpRoot }, async (port) => {
602
- const { status, body } = await get(port, '/boom')
603
- assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
604
- assert(body.includes('view exploded'), `Expected error message injected into stream: ${body.slice(0, 400)}`)
605
- assert(body.includes('pulse-root'), `Expected pulse-root in partial document`)
606
- })
607
- fs.rmSync(tmpRoot, { recursive: true, force: true })
608
- })
609
-
610
- await test('production stream mode hides error details', async () => {
611
- await withServer([throwingSpec], { stream: true, dev: false }, async (port) => {
612
- const { status, body } = await get(port, '/boom')
613
- assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
614
- assert(!body.includes('view exploded'), `Should not expose error details in prod stream`)
615
- assert(body.includes('Something went wrong'), `Expected generic message in prod stream`)
616
- })
617
- })
618
-
619
- // ---------------------------------------------------------------------------
620
-
621
- console.log('\nRaw content responses\n')
622
-
623
- const rssSpec = {
624
- route: '/feed.xml',
625
- contentType: 'application/rss+xml; charset=utf-8',
626
- render: () => `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>`
627
- }
628
-
629
- const jsonFeedSpec = {
630
- route: '/api/posts',
631
- contentType: 'application/json',
632
- server: { posts: async () => [{ id: 1, title: 'Hello' }] },
633
- render: (_ctx, server) => JSON.stringify(server.posts)
634
- }
635
-
636
- await test('raw spec serves correct Content-Type', async () => {
637
- await withServer([rssSpec], {}, async (port) => {
638
- const { status, headers } = await get(port, '/feed.xml')
639
- assert(status === 200, `Expected 200, got ${status}`)
640
- assert(headers['content-type'].includes('application/rss+xml'), `Wrong content-type: ${headers['content-type']}`)
641
- })
642
- })
643
-
644
- await test('raw spec serves rendered body', async () => {
645
- await withServer([rssSpec], {}, async (port) => {
646
- const { body } = await get(port, '/feed.xml')
647
- assert(body.includes('<rss'), `Missing RSS content: ${body.slice(0, 100)}`)
648
- assert(body.includes('<title>Test</title>'), `Missing title: ${body.slice(0, 200)}`)
649
- })
650
- })
651
-
652
- await test('raw spec resolves server data and passes to render', async () => {
653
- await withServer([jsonFeedSpec], {}, async (port) => {
654
- const { body, headers } = await get(port, '/api/posts')
655
- assert(headers['content-type'].includes('application/json'), `Wrong content-type: ${headers['content-type']}`)
656
- const data = JSON.parse(body)
657
- assert(Array.isArray(data) && data[0].title === 'Hello', `Unexpected data: ${body}`)
658
- })
659
- })
660
-
661
- await test('raw spec does not return an HTML document', async () => {
662
- await withServer([rssSpec], {}, async (port) => {
663
- const { body } = await get(port, '/feed.xml')
664
- assert(!body.includes('<!DOCTYPE html>'), `Should not wrap in HTML document`)
665
- assert(!body.includes('<body>'), `Should not include body tag`)
666
- })
667
- })
668
-
669
- await test('raw spec is not intercepted by X-Pulse-Navigate', async () => {
670
- await withServer([rssSpec], {}, async (port) => {
671
- const { headers, body } = await request(port, 'GET', '/feed.xml', { 'X-Pulse-Navigate': 'true' })
672
- assert(headers['content-type'].includes('application/rss+xml'), `Should still serve raw content on nav request`)
673
- assert(body.includes('<rss'), `Body should be RSS not JSON`)
674
- })
675
- })
676
-
677
- // ---------------------------------------------------------------------------
678
- // Guard
679
- // ---------------------------------------------------------------------------
680
-
681
- const guardSpec = {
682
- route: '/protected',
683
- meta: { title: 'Protected', styles: [] },
684
- state: {},
685
- guard: async (ctx) => {
686
- if (ctx.headers['x-auth'] !== 'secret') return { redirect: '/login' }
687
- },
688
- view: () => `<main id="main-content"><h1>Secret</h1></main>`,
689
- }
690
-
691
- await test('guard redirects 302 when condition fails', async () => {
692
- await withServer([guardSpec], {}, async (port) => {
693
- const { status, headers } = await request(port, 'GET', '/protected')
694
- assert(status === 302, `Expected 302, got ${status}`)
695
- assert(headers.location === '/login', `Expected Location: /login, got ${headers.location}`)
696
- })
697
- })
698
-
699
- await test('guard allows request when condition passes', async () => {
700
- await withServer([guardSpec], {}, async (port) => {
701
- const { status, body } = await request(port, 'GET', '/protected', { 'x-auth': 'secret' })
702
- assert(status === 200, `Expected 200, got ${status}`)
703
- assert(body.includes('Secret'), `Expected page content, got: ${body.slice(0, 200)}`)
704
- })
705
- })
706
-
707
- await test('guard runs before server data fetchers', async () => {
708
- let fetcherCalled = false
709
- const spec = {
710
- route: '/guarded',
711
- meta: { title: 'Guarded', styles: [] },
712
- state: {},
713
- guard: async () => ({ redirect: '/login' }),
714
- server: { data: async () => { fetcherCalled = true; return {} } },
715
- view: () => `<main id="main-content">Content</main>`,
716
- }
717
- await withServer([spec], {}, async (port) => {
718
- await request(port, 'GET', '/guarded')
719
- assert(!fetcherCalled, 'Server data fetcher should not run when guard redirects')
720
- })
721
- })
722
-
723
- await test('guard redirect includes security headers', async () => {
724
- await withServer([guardSpec], {}, async (port) => {
725
- const { headers } = await request(port, 'GET', '/protected')
726
- assert(headers['x-frame-options'] === 'DENY', `Missing security header`)
727
- })
728
- })
729
-
730
- // ---------------------------------------------------------------------------
731
- // ctx.setHeader / ctx.setCookie
732
- // ---------------------------------------------------------------------------
733
-
734
- const setCtxCookieSpec = {
735
- route: '/profile',
736
- meta: { title: 'Profile', styles: [] },
737
- state: {},
738
- guard: async (ctx) => {
739
- ctx.setCookie('visited', 'true', { httpOnly: true, maxAge: 3600 })
740
- },
741
- view: () => `<main id="main-content"><h1>Profile</h1></main>`,
742
- }
743
-
744
- await test('ctx.setCookie sets Set-Cookie header on page response', async () => {
745
- await withServer([setCtxCookieSpec], { stream: false }, async (port) => {
746
- const { status, headers } = await request(port, 'GET', '/profile')
747
- assert(status === 200, `Expected 200, got ${status}`)
748
- const cookies = [headers['set-cookie']].flat().filter(Boolean)
749
- assert(cookies.some(c => c.includes('visited=true')), `Expected visited cookie, got: ${JSON.stringify(cookies)}`)
750
- assert(cookies.some(c => c.includes('HttpOnly')), `Expected HttpOnly, got: ${JSON.stringify(cookies)}`)
751
- assert(cookies.some(c => c.includes('Max-Age=3600')), `Expected Max-Age, got: ${JSON.stringify(cookies)}`)
752
- })
753
- })
754
-
755
- await test('ctx.setCookie works on streaming response', async () => {
756
- await withServer([setCtxCookieSpec], { stream: true }, async (port) => {
757
- const { status, headers } = await request(port, 'GET', '/profile')
758
- assert(status === 200, `Expected 200, got ${status}`)
759
- const cookies = [headers['set-cookie']].flat().filter(Boolean)
760
- assert(cookies.some(c => c.includes('visited=true')), `Expected visited cookie in stream response`)
761
- })
762
- })
763
-
764
- await test('ctx.setCookie on guard redirect includes cookie in redirect response', async () => {
765
- const spec = {
766
- route: '/logout',
767
- meta: { title: 'Logout', styles: [] },
768
- state: {},
769
- guard: async (ctx) => {
770
- ctx.setCookie('session', '', { maxAge: 0 })
771
- return { redirect: '/login' }
772
- },
773
- view: () => `<main id="main-content">Logged out</main>`,
774
- }
775
- await withServer([spec], {}, async (port) => {
776
- const { status, headers } = await request(port, 'GET', '/logout')
777
- assert(status === 302, `Expected 302, got ${status}`)
778
- const cookies = [headers['set-cookie']].flat().filter(Boolean)
779
- assert(cookies.some(c => c.includes('Max-Age=0')), `Expected expired cookie on redirect`)
780
- })
781
- })
782
-
783
- await test('raw response render can return { redirect } for auth callbacks', async () => {
784
- const callbackSpec = {
785
- route: '/auth/callback',
786
- contentType: 'text/html',
787
- server: {
788
- token: async (ctx) => {
789
- ctx.setCookie('session', 'tok123', { httpOnly: true })
790
- return 'tok123'
791
- },
792
- },
793
- render: () => ({ redirect: '/dashboard' }),
794
- }
795
- await withServer([callbackSpec], {}, async (port) => {
796
- const { status, headers } = await request(port, 'GET', '/auth/callback')
797
- assert(status === 302, `Expected 302, got ${status}`)
798
- assert(headers.location === '/dashboard', `Expected /dashboard, got ${headers.location}`)
799
- const cookies = [headers['set-cookie']].flat().filter(Boolean)
800
- assert(cookies.some(c => c.includes('session=tok123')), `Expected session cookie on redirect`)
801
- })
802
- })
803
-
804
- // ---------------------------------------------------------------------------
805
- // Canonical URLs + trailing slash redirects
806
- // ---------------------------------------------------------------------------
807
-
808
- const canonicalSpec = {
809
- route: '/about',
810
- meta: { title: 'About', styles: [] },
811
- state: {},
812
- view: () => `<main id="main-content"><h1>About</h1></main>`,
813
- }
814
-
815
- const canonicalOverrideSpec = {
816
- route: '/about',
817
- meta: { title: 'About', styles: [], canonical: 'https://example.com/about' },
818
- state: {},
819
- view: () => `<main id="main-content"><h1>About</h1></main>`,
820
- }
821
-
822
- await test('trailing slash redirects 301 to path without slash', async () => {
823
- await withServer([canonicalSpec], {}, async (port) => {
824
- const { status, headers } = await request(port, 'GET', '/about/')
825
- assert(status === 301, `Expected 301, got ${status}`)
826
- assert(headers.location === '/about', `Expected Location: /about, got ${headers.location}`)
827
- })
828
- })
829
-
830
- await test('trailing slash redirect preserves query string', async () => {
831
- await withServer([canonicalSpec], {}, async (port) => {
832
- const { status, headers } = await request(port, 'GET', '/about/?foo=bar')
833
- assert(status === 301, `Expected 301, got ${status}`)
834
- assert(headers.location === '/about?foo=bar', `Expected query preserved, got ${headers.location}`)
835
- })
836
- })
837
-
838
- await test('root path does not redirect', async () => {
839
- const homeSpec = { route: '/', meta: { title: 'Home', styles: [] }, state: {}, view: () => `<main id="main-content">Home</main>` }
840
- await withServer([homeSpec], {}, async (port) => {
841
- const { status } = await request(port, 'GET', '/')
842
- assert(status === 200, `Root / should not redirect, got ${status}`)
843
- })
844
- })
845
-
846
- await test('canonical tag injected with request host and path', async () => {
847
- await withServer([canonicalSpec], { stream: false }, async (port) => {
848
- const { body } = await get(port, '/about')
849
- assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about">`),
850
- `Missing canonical tag in: ${body.slice(0, 500)}`)
851
- })
852
- })
853
-
854
- await test('meta.canonical overrides auto-generated canonical URL', async () => {
855
- await withServer([canonicalOverrideSpec], { stream: false }, async (port) => {
856
- const { body } = await get(port, '/about')
857
- assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
858
- `Expected override canonical, got: ${body.slice(0, 500)}`)
859
- })
860
- })
861
-
862
- await test('meta.canonical as a function receives ctx (string path)', async () => {
863
- const spec = {
864
- route: '/about',
865
- meta: { title: 'About', styles: [], canonical: (ctx) => `https://example.com${ctx.params.slug ?? '/about'}` },
866
- state: {},
867
- view: () => `<main id="main-content"><h1>About</h1></main>`,
868
- }
869
- await withServer([spec], { stream: false }, async (port) => {
870
- const { body } = await get(port, '/about')
871
- assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
872
- `Expected function canonical, got: ${body.slice(0, 500)}`)
873
- })
874
- })
875
-
876
- await test('meta.canonical as a function receives serverState (string path)', async () => {
877
- const spec = {
878
- route: '/product',
879
- meta: {
880
- title: 'Product',
881
- styles: [],
882
- canonical: (ctx, serverState) => `https://example.com/products/${serverState?.data?.slug ?? 'fallback'}`,
883
- },
884
- state: {},
885
- server: { data: async () => ({ slug: 'my-product-slug' }) },
886
- view: (state, server) => `<main id="main-content"><h1>${server.data.slug}</h1></main>`,
887
- }
888
- await withServer([spec], { stream: false }, async (port) => {
889
- const { body } = await get(port, '/product')
890
- assert(body.includes(`<link rel="canonical" href="https://example.com/products/my-product-slug">`),
891
- `Expected serverState-derived canonical, got: ${body.slice(0, 500)}`)
892
- })
893
- })
894
-
895
- await test('meta.canonical as a function receives ctx in streaming mode', async () => {
896
- const spec = {
897
- route: '/about',
898
- meta: { title: 'About', styles: [], canonical: (ctx) => `https://example.com/about` },
899
- state: {},
900
- view: () => `<main id="main-content"><h1>About</h1></main>`,
901
- }
902
- await withServer([spec], { stream: true }, async (port) => {
903
- const { body } = await get(port, '/about')
904
- assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
905
- `Expected function canonical in stream, got: ${body.slice(0, 500)}`)
906
- })
907
- })
908
-
909
- await test('canonical tag injected in streaming response', async () => {
910
- await withServer([canonicalSpec], { stream: true }, async (port) => {
911
- const { body } = await get(port, '/about')
912
- assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about">`),
913
- `Missing canonical tag in stream response: ${body.slice(0, 500)}`)
914
- })
915
- })
916
-
917
- // trailingSlash: 'add'
918
- await test("trailingSlash:'add' redirects /about to /about/", async () => {
919
- await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
920
- const { status, headers } = await request(port, 'GET', '/about')
921
- assert(status === 301, `Expected 301, got ${status}`)
922
- assert(headers.location === '/about/', `Expected Location: /about/, got ${headers.location}`)
923
- })
924
- })
925
-
926
- await test("trailingSlash:'add' does not redirect /about/", async () => {
927
- await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
928
- const { status } = await request(port, 'GET', '/about/')
929
- assert(status === 200, `Expected 200, got ${status}`)
930
- })
931
- })
932
-
933
- await test("trailingSlash:'add' redirect preserves query string", async () => {
934
- await withServer([canonicalSpec], { trailingSlash: 'add' }, async (port) => {
935
- const { status, headers } = await request(port, 'GET', '/about?foo=bar')
936
- assert(status === 301, `Expected 301, got ${status}`)
937
- assert(headers.location === '/about/?foo=bar', `Expected query preserved, got ${headers.location}`)
938
- })
939
- })
940
-
941
- await test("trailingSlash:'add' root does not redirect", async () => {
942
- const homeSpec = { route: '/', meta: { title: 'Home', styles: [] }, state: {}, view: () => `<main id="main-content">Home</main>` }
943
- await withServer([homeSpec], { trailingSlash: 'add' }, async (port) => {
944
- const { status } = await request(port, 'GET', '/')
945
- assert(status === 200, `Root / should not redirect, got ${status}`)
946
- })
947
- })
948
-
949
- await test("trailingSlash:'add' canonical tag uses slash form", async () => {
950
- await withServer([canonicalSpec], { trailingSlash: 'add', stream: false }, async (port) => {
951
- const { body } = await get(port, '/about/')
952
- assert(body.includes(`<link rel="canonical" href="http://localhost:${port}/about/">`),
953
- `Expected slash canonical, got: ${body.slice(0, 500)}`)
954
- })
955
- })
956
-
957
- // trailingSlash: 'allow'
958
- await test("trailingSlash:'allow' serves /about/ without redirect", async () => {
959
- await withServer([canonicalSpec], { trailingSlash: 'allow' }, async (port) => {
960
- const { status } = await request(port, 'GET', '/about/')
961
- assert(status === 200, `Expected 200, got ${status}`)
962
- })
963
- })
964
-
965
- await test("trailingSlash:'allow' serves /about without redirect", async () => {
966
- await withServer([canonicalSpec], { trailingSlash: 'allow' }, async (port) => {
967
- const { status } = await request(port, 'GET', '/about')
968
- assert(status === 200, `Expected 200, got ${status}`)
969
- })
970
- })
971
-
972
- await test("trailingSlash:'allow' canonical uses no-slash form for both paths", async () => {
973
- await withServer([canonicalSpec], { trailingSlash: 'allow', stream: false }, async (port) => {
974
- const { body: bodySlash } = await get(port, '/about/')
975
- const { body: bodyNoSlash } = await get(port, '/about')
976
- const tag = `<link rel="canonical" href="http://localhost:${port}/about">`
977
- assert(bodySlash.includes(tag), `Expected no-slash canonical for /about/, got: ${bodySlash.slice(0, 500)}`)
978
- assert(bodyNoSlash.includes(tag), `Expected no-slash canonical for /about, got: ${bodyNoSlash.slice(0, 500)}`)
979
- })
980
- })
981
-
982
- // ---------------------------------------------------------------------------
983
- // Security — CSP, HSTS, SameSite, POST support
984
- // ---------------------------------------------------------------------------
985
-
986
- // CSP
987
- await test('CSP header present on page response', async () => {
988
- await withServer([canonicalSpec], { stream: false }, async (port) => {
989
- const { headers } = await request(port, 'GET', '/about')
990
- assert(headers['content-security-policy'],
991
- 'Missing Content-Security-Policy header')
992
- assert(headers['content-security-policy'].includes("object-src 'none'"),
993
- `Expected object-src 'none' in CSP: ${headers['content-security-policy']}`)
994
- })
995
- })
996
-
997
- await test('CSP nonce is present in page response body', async () => {
998
- const hydrateSpec = {
999
- route: '/about',
1000
- meta: { title: 'About', styles: [] },
1001
- state: {},
1002
- hydrate: '/examples/about.js',
1003
- view: () => `<main id="main-content"><h1>About</h1></main>`,
1004
- }
1005
- await withServer([hydrateSpec], { stream: false }, async (port) => {
1006
- const { headers, body } = await request(port, 'GET', '/about')
1007
- const csp = headers['content-security-policy']
1008
- const match = csp && csp.match(/nonce-([A-Za-z0-9_\-]+)/)
1009
- assert(match, `Expected nonce in CSP header: ${csp}`)
1010
- const nonce = match[1]
1011
- assert(body.includes(`nonce="${nonce}"`),
1012
- `Expected nonce="${nonce}" in HTML body`)
1013
- })
1014
- })
1015
-
1016
- await test('CSP nonce differs between requests', async () => {
1017
- await withServer([canonicalSpec], { stream: false }, async (port) => {
1018
- const r1 = await request(port, 'GET', '/about')
1019
- const r2 = await request(port, 'GET', '/about')
1020
- const n1 = r1.headers['content-security-policy']?.match(/nonce-([A-Za-z0-9_\-]+)/)?.[1]
1021
- const n2 = r2.headers['content-security-policy']?.match(/nonce-([A-Za-z0-9_\-]+)/)?.[1]
1022
- assert(n1 && n2 && n1 !== n2, `Expected different nonces per request, got: ${n1} and ${n2}`)
1023
- })
1024
- })
1025
-
1026
- await test('csp option extends trusted-types, merging with the base pulse policy', async () => {
1027
- // Regression: an agent assumed trusted-types/require-trusted-types-for
1028
- // weren't extendable via createServer's csp option (unlike other
1029
- // directives like img-src/style-src) because no guide demonstrated it —
1030
- // buildCsp's merge loop is actually fully generic across all directive
1031
- // keys, this was a documentation gap, not a real framework limitation.
1032
- // Pin the real behavior so it can't silently regress.
1033
- await withServer([canonicalSpec], { stream: false, csp: { 'trusted-types': ['google'] } }, async (port) => {
1034
- const { headers } = await request(port, 'GET', '/about')
1035
- const csp = headers['content-security-policy']
1036
- assert(csp.includes('trusted-types pulse google'),
1037
- `Expected trusted-types to merge with the base 'pulse' policy, got: ${csp}`)
1038
- })
1039
- })
1040
-
1041
- await test('CSP header present on streaming page response', async () => {
1042
- await withServer([canonicalSpec], { stream: true }, async (port) => {
1043
- const { headers } = await request(port, 'GET', '/about')
1044
- assert(headers['content-security-policy'],
1045
- 'Missing Content-Security-Policy header on streaming response')
1046
- })
1047
- })
1048
-
1049
- // Cache-Control
1050
- await test('HTML with no spec.cache/defaultCache gets no-cache, not no-store', async () => {
1051
- // Regression: a real Lighthouse audit failed "Page prevented back/forward
1052
- // cache restoration" with reason "cache-control:no-store" on a page that
1053
- // had no cache config at all — the framework's old default was no-store,
1054
- // which unconditionally blocks the browser's bfcache. no-cache still
1055
- // forces revalidation on every direct load (no risk of serving stale
1056
- // content) but does not block bfcache, since bfcache restoration never
1057
- // touches the network.
1058
- await withServer([canonicalSpec], { stream: false }, async (port) => {
1059
- const { headers } = await request(port, 'GET', '/about')
1060
- assert(headers['cache-control'] === 'no-cache',
1061
- `Expected no-cache, got: ${headers['cache-control']}`)
1062
- })
1063
- })
1064
-
1065
- await test('HTML respects spec.cache / defaultCache when configured', async () => {
1066
- await withServer([helloSpec], { stream: false, defaultCache: 60 }, async (port) => {
1067
- const { headers } = await request(port, 'GET', '/hello')
1068
- assert(headers['cache-control']?.includes('max-age=60'),
1069
- `Expected max-age=60, got: ${headers['cache-control']}`)
1070
- })
1071
- })
1072
-
1073
- await test('dev mode always returns no-store regardless of cache config', async () => {
1074
- await withServer([canonicalSpec], { stream: false, dev: true, defaultCache: 3600 }, async (port) => {
1075
- const { headers } = await request(port, 'GET', '/about')
1076
- assert(headers['cache-control'] === 'no-store',
1077
- `Expected no-store in dev mode, got: ${headers['cache-control']}`)
1078
- })
1079
- })
1080
-
1081
- // HSTS
1082
- await test('HSTS header absent on plain HTTP', async () => {
1083
- await withServer([canonicalSpec], { stream: false }, async (port) => {
1084
- const { headers } = await request(port, 'GET', '/about')
1085
- assert(!headers['strict-transport-security'],
1086
- `HSTS should not be present on plain HTTP, got: ${headers['strict-transport-security']}`)
1087
- })
1088
- })
1089
-
1090
- await test('HSTS header present when x-forwarded-proto is https', async () => {
1091
- await withServer([canonicalSpec], { stream: false }, async (port) => {
1092
- const { headers } = await request(port, 'GET', '/about', { 'x-forwarded-proto': 'https' })
1093
- assert(headers['strict-transport-security'],
1094
- 'Expected Strict-Transport-Security header when x-forwarded-proto: https')
1095
- assert(headers['strict-transport-security'].includes('max-age=31536000'),
1096
- `Expected max-age=31536000 in HSTS: ${headers['strict-transport-security']}`)
1097
- })
1098
- })
1099
-
1100
- // SameSite default
1101
- await test('setCookie defaults to SameSite=Lax', async () => {
1102
- const cookieSpec = {
1103
- route: '/cookie-test',
1104
- state: {},
1105
- guard: async (ctx) => {
1106
- ctx.setCookie('test', 'value', { httpOnly: true })
1107
- },
1108
- view: () => `<main id="main-content">ok</main>`,
1109
- }
1110
- await withServer([cookieSpec], { stream: false }, async (port) => {
1111
- const { headers } = await request(port, 'GET', '/cookie-test')
1112
- const cookie = Array.isArray(headers['set-cookie'])
1113
- ? headers['set-cookie'].join('; ')
1114
- : headers['set-cookie'] || ''
1115
- assert(cookie.includes('SameSite=Lax'),
1116
- `Expected SameSite=Lax default, got: ${cookie}`)
1117
- })
1118
- })
1119
-
1120
- await test('setCookie SameSite can be overridden to Strict', async () => {
1121
- const cookieSpec2 = {
1122
- route: '/cookie-strict',
1123
- state: {},
1124
- guard: async (ctx) => {
1125
- ctx.setCookie('test', 'value', { sameSite: 'Strict' })
1126
- },
1127
- view: () => `<main id="main-content">ok</main>`,
1128
- }
1129
- await withServer([cookieSpec2], { stream: false }, async (port) => {
1130
- const { headers } = await request(port, 'GET', '/cookie-strict')
1131
- const cookie = Array.isArray(headers['set-cookie'])
1132
- ? headers['set-cookie'].join('; ')
1133
- : headers['set-cookie'] || ''
1134
- assert(cookie.includes('SameSite=Strict'),
1135
- `Expected SameSite=Strict override, got: ${cookie}`)
1136
- })
1137
- })
1138
-
1139
- // POST support for raw response specs
1140
- await test('POST request to raw response spec returns 200', async () => {
1141
- const postSpec = {
1142
- route: '/webhook',
1143
- contentType: 'application/json',
1144
- render: () => JSON.stringify({ received: true }),
1145
- }
1146
- await withServer([postSpec], {}, async (port) => {
1147
- const { status } = await request(port, 'POST', '/webhook')
1148
- assert(status === 200, `Expected 200 for POST to raw spec, got ${status}`)
1149
- })
1150
- })
1151
-
1152
- await test('POST request to page spec returns 405', async () => {
1153
- await withServer([canonicalSpec], {}, async (port) => {
1154
- const { status } = await request(port, 'POST', '/about')
1155
- assert(status === 405, `Expected 405 for POST to page spec, got ${status}`)
1156
- })
1157
- })
1158
-
1159
- // Body parsing
1160
-
1161
- await test('ctx.json() parses JSON POST body', async () => {
1162
- const spec = {
1163
- route: '/api/items',
1164
- methods: ['GET', 'POST'],
1165
- state: {},
1166
- guard: async (ctx) => {
1167
- if (ctx.method === 'POST') {
1168
- const data = await ctx.json()
1169
- return { status: 201, json: { created: data.name } }
1170
- }
1171
- },
1172
- view: () => '<p>items</p>',
1173
- }
1174
- await withServer([spec], { stream: false }, async (port) => {
1175
- const { status, body } = await requestWithBody(port, 'POST', '/api/items', '{"name":"widget"}', 'application/json')
1176
- assert(status === 201, `Expected 201, got ${status}`)
1177
- assert(JSON.parse(body).created === 'widget', `Expected created:widget, got ${body}`)
1178
- })
1179
- })
1180
-
1181
- await test('ctx.formData() parses URL-encoded POST body', async () => {
1182
- const spec = {
1183
- route: '/contact',
1184
- methods: ['GET', 'POST'],
1185
- state: {},
1186
- guard: async (ctx) => {
1187
- if (ctx.method === 'POST') {
1188
- const data = await ctx.formData()
1189
- return { redirect: `/thanks?name=${encodeURIComponent(data.name)}` }
1190
- }
1191
- },
1192
- view: () => '<form method="POST"><input name="name"><button>Send</button></form>',
1193
- }
1194
- await withServer([spec], { stream: false }, async (port) => {
1195
- const { status, headers } = await requestWithBody(port, 'POST', '/contact', 'name=Alice', 'application/x-www-form-urlencoded')
1196
- assert(status === 302, `Expected 302, got ${status}`)
1197
- assert(headers.location?.includes('Alice'), `Expected redirect with Alice, got ${headers.location}`)
1198
- })
1199
- })
1200
-
1201
- await test('ctx.text() returns raw POST body string', async () => {
1202
- const spec = {
1203
- route: '/webhook',
1204
- contentType: 'application/json',
1205
- render: async (ctx) => {
1206
- const raw = await ctx.text()
1207
- return JSON.stringify({ received: raw })
1208
- },
1209
- }
1210
- await withServer([spec], { stream: false }, async (port) => {
1211
- const { status, body } = await requestWithBody(port, 'POST', '/webhook', 'hello world', 'text/plain')
1212
- assert(status === 200, `Expected 200, got ${status}`)
1213
- assert(JSON.parse(body).received === 'hello world', `Expected received body, got ${body}`)
1214
- })
1215
- })
1216
-
1217
- await test('POST to page spec without methods returns 405', async () => {
1218
- await withServer([helloSpec], { stream: false }, async (port) => {
1219
- const { status } = await request(port, 'POST', '/hello')
1220
- assert(status === 405, `Expected 405, got ${status}`)
1221
- })
1222
- })
1223
-
1224
- await test('spec.methods allows POST on page spec', async () => {
1225
- const spec = {
1226
- route: '/form',
1227
- methods: ['GET', 'POST'],
1228
- state: {},
1229
- guard: async (ctx) => {
1230
- if (ctx.method === 'POST') return { redirect: '/done' }
1231
- },
1232
- view: () => '<form method="POST"><button>Go</button></form>',
1233
- }
1234
- await withServer([spec], { stream: false }, async (port) => {
1235
- const { status } = await request(port, 'POST', '/form')
1236
- assert(status === 302, `Expected 302, got ${status}`)
1237
- })
1238
- })
1239
-
1240
- await test('guard can return { status, json } for custom error responses', async () => {
1241
- const spec = {
1242
- route: '/api/secure',
1243
- methods: ['GET', 'POST'],
1244
- state: {},
1245
- guard: async (ctx) => {
1246
- if (ctx.method === 'POST') {
1247
- const data = await ctx.json()
1248
- if (!data?.token) return { status: 401, json: { error: 'Unauthorized' } }
1249
- }
1250
- },
1251
- view: () => '<p>secure</p>',
1252
- }
1253
- await withServer([spec], { stream: false }, async (port) => {
1254
- const { status, body } = await requestWithBody(port, 'POST', '/api/secure', '{}', 'application/json')
1255
- assert(status === 401, `Expected 401, got ${status}`)
1256
- assert(JSON.parse(body).error === 'Unauthorized', `Expected error message, got ${body}`)
1257
- })
1258
- })
1259
-
1260
- await test('body exceeding maxBody returns 413', async () => {
1261
- const spec = {
1262
- route: '/upload',
1263
- contentType: 'application/json',
1264
- render: async (ctx) => { await ctx.text(); return '{}' },
1265
- }
1266
- await withServer([spec], { stream: false, maxBody: 10 }, async (port) => {
1267
- const { status } = await requestWithBody(port, 'POST', '/upload', 'x'.repeat(100), 'text/plain')
1268
- assert(status === 413, `Expected 413, got ${status}`)
1269
- })
1270
- })
1271
-
1272
- // ---------------------------------------------------------------------------
1273
-
1274
- console.log('\nHealth check\n')
1275
-
1276
- await test('GET /healthz returns 200 with JSON status by default', async () => {
1277
- await withServer([helloSpec], { stream: false }, async (port) => {
1278
- const { status, headers, body } = await get(port, '/healthz')
1279
- assert(status === 200, `Expected 200, got ${status}`)
1280
- assert(headers['content-type']?.includes('application/json'), `Expected JSON content-type, got ${headers['content-type']}`)
1281
- const json = JSON.parse(body)
1282
- assert(json.status === 'ok', `Expected status ok, got ${JSON.stringify(json)}`)
1283
- assert(typeof json.uptime === 'number', `Expected uptime number, got ${JSON.stringify(json)}`)
1284
- })
1285
- })
1286
-
1287
- await test('HEAD /healthz returns 200 with no body', async () => {
1288
- await withServer([helloSpec], { stream: false }, async (port) => {
1289
- const { status, body } = await request(port, 'HEAD', '/healthz')
1290
- assert(status === 200, `Expected 200, got ${status}`)
1291
- assert(body === '', `Expected empty body for HEAD, got: ${body}`)
1292
- })
1293
- })
1294
-
1295
- await test('health check uses a custom path when set', async () => {
1296
- await withServer([helloSpec], { stream: false, healthCheck: '/ping' }, async (port) => {
1297
- const { status } = await get(port, '/ping')
1298
- assert(status === 200, `Expected 200 at /ping, got ${status}`)
1299
- const { status: defaultStatus } = await get(port, '/healthz')
1300
- assert(defaultStatus === 404, `Expected 404 at /healthz when overridden, got ${defaultStatus}`)
1301
- })
1302
- })
1303
-
1304
- await test('health check can be disabled with healthCheck: false', async () => {
1305
- await withServer([helloSpec], { stream: false, healthCheck: false }, async (port) => {
1306
- const { status } = await get(port, '/healthz')
1307
- assert(status === 404, `Expected 404 when healthCheck disabled, got ${status}`)
1308
- })
1309
- })
1310
-
1311
- await test('health check bypasses onRequest hook', async () => {
1312
- let hookCalled = false
1313
- await withServer([helloSpec], {
1314
- stream: false,
1315
- onRequest: () => { hookCalled = true }
1316
- }, async (port) => {
1317
- await get(port, '/healthz')
1318
- assert(!hookCalled, 'Expected onRequest to NOT be called for health check')
1319
- })
1320
- })
1321
-
1322
- await test('health check fires before route matching — shadows a user spec at the same path', async () => {
1323
- // Built-in handler is checked before route matching, so a spec at /healthz never runs
1324
- const customSpec = { route: '/healthz', state: {}, view: () => '<p>custom</p>' }
1325
- await withServer([customSpec], { stream: false }, async (port) => {
1326
- const { body } = await get(port, '/healthz')
1327
- assert(body.includes('"status"'), `Expected health JSON, got: ${body}`)
1328
- })
1329
- })
1330
-
1331
- // ---------------------------------------------------------------------------
1332
-
1333
- console.log('\nCustom 404 page (route: "*")\n')
1334
-
1335
- const notFoundPageSpec = {
1336
- route: '*',
1337
- meta: { title: 'Lost?' },
1338
- view: () => '<main id="main-content"><h1>Custom not found</h1><a href="/">Home</a></main>',
1339
- }
1340
-
1341
- await test('unknown route renders the "*" spec with status 404', async () => {
1342
- await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
1343
- const { status, body } = await get(port, '/nope')
1344
- assert(status === 404, `Expected 404, got ${status}`)
1345
- assert(body.includes('Custom not found'), `Expected custom 404 content, got: ${body.slice(0, 200)}`)
1346
- assert(body.includes('<title>Lost?</title>'), `Expected custom 404 title, got: ${body.slice(0, 300)}`)
1347
- })
1348
- })
1349
-
1350
- await test('custom 404 renders in streaming mode too (forced string path)', async () => {
1351
- await withServer([helloSpec, notFoundPageSpec], { stream: true }, async (port) => {
1352
- const { status, body } = await get(port, '/still-nope')
1353
- assert(status === 404, `Expected 404, got ${status}`)
1354
- assert(body.includes('Custom not found'), `Expected custom 404 content`)
1355
- })
1356
- })
1357
-
1358
- await test('known routes are unaffected by the "*" spec', async () => {
1359
- await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
1360
- const { status, body } = await get(port, '/hello')
1361
- assert(status === 200, `Expected 200, got ${status}`)
1362
- assert(body.includes('Hello world'), 'Expected normal page content')
1363
- })
1364
- })
1365
-
1366
- await test('custom 404 responses are not cached as 200s', async () => {
1367
- await withServer([helloSpec, notFoundPageSpec], { stream: false, defaultCache: 3600, dev: false }, async (port) => {
1368
- const a = await get(port, '/missing')
1369
- const b = await get(port, '/missing')
1370
- assert(a.status === 404 && b.status === 404, `Expected both 404, got ${a.status}/${b.status}`)
1371
- })
1372
- })
1373
-
1374
- await test('without a "*" spec the default 404 still works', async () => {
1375
- await withServer([helloSpec], { stream: false }, async (port) => {
1376
- const { status, body } = await get(port, '/nope')
1377
- assert(status === 404 && body.includes('404'), 'Expected default 404 page')
1378
- })
1379
- })
1380
-
1381
- await test('custom 404 works in production mode (manifest with runtime bundle present)', async () => {
1382
- // Regression: in production the manifest sets runtimeBundle, which made
1383
- // wrapDocument call isBundle(spec.hydrate) — undefined for non-hydrated pages
1384
- // like a typical 404 spec → TypeError → 500 instead of the custom 404.
1385
- // Dev never hit it because runtimeBundle is '' there.
1386
- const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
1387
- await withServer([helloSpec, notFoundPageSpec], { stream: true, manifest: prodManifest }, async (port) => {
1388
- const { status, body } = await get(port, '/nope')
1389
- assert(status === 404, `Expected 404 in production mode, got ${status}`)
1390
- assert(body.includes('Custom not found'), `Expected custom 404 content: ${body.slice(0, 200)}`)
1391
- })
1392
- })
1393
-
1394
- await test('non-hydrated pages render on the buffered path in production mode', async () => {
1395
- // Same root cause, different surface: stream: false + production manifest
1396
- // crashed EVERY zero-JS page, not just the 404.
1397
- const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
1398
- await withServer([helloSpec], { stream: false, manifest: prodManifest }, async (port) => {
1399
- const { status, body } = await get(port, '/hello')
1400
- assert(status === 200, `Expected 200, got ${status}`)
1401
- assert(body.includes('Hello world'), 'Expected page content')
1402
- })
1403
- })
1404
-
1405
- // ---------------------------------------------------------------------------
1406
-
1407
- console.log('\nServer-side forms (spec.submit) + CSRF\n')
1408
-
1409
- function requestFull(port, method, path, { headers = {}, body = null } = {}) {
1410
- return new Promise((resolve, reject) => {
1411
- const req = http.request({ method, hostname: 'localhost', port, path, headers }, (res) => {
1412
- let data = ''
1413
- res.on('data', c => { data += c })
1414
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
1415
- })
1416
- req.on('error', reject)
1417
- if (body) req.write(body)
1418
- req.end()
1419
- })
1420
- }
1421
-
1422
- function postForm(port, path, fields, cookie = '') {
1423
- const body = new URLSearchParams(fields).toString()
1424
- return requestFull(port, 'POST', path, {
1425
- headers: {
1426
- 'Content-Type': 'application/x-www-form-urlencoded',
1427
- 'Content-Length': Buffer.byteLength(body),
1428
- ...(cookie ? { Cookie: cookie } : {}),
1429
- },
1430
- body,
1431
- })
1432
- }
1433
-
1434
- /** GET the form page and extract the CSRF cookie + token for a valid POST. */
1435
- async function getCsrfPair(port, path) {
1436
- const res = await get(port, path)
1437
- const setCookie = [].concat(res.headers['set-cookie'] || []).find(c => c.startsWith('pulse_csrf='))
1438
- const cookie = setCookie ? setCookie.split(';')[0] : ''
1439
- const token = res.body.match(/name="_csrf" value="([^"]+)"/)?.[1] || ''
1440
- return { cookie, token, body: res.body }
1441
- }
1442
-
1443
- const formSpec = {
1444
- route: '/signup',
1445
- meta: { title: 'Sign up' },
1446
- submit: async (ctx) => {
1447
- const data = await ctx.formData()
1448
- if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
1449
- if (data.email === 'taken@example.com') return { status: 422, errors: { email: 'Already registered' }, values: data }
1450
- return { redirect: '/signup?done=1' }
1451
- },
1452
- view: (state, server) => `
1453
- <main id="main-content">
1454
- <h1>Sign up</h1>
1455
- ${server.form?.errors?.email ? `<p class="error">${server.form.errors.email}</p>` : ''}
1456
- <form method="POST">
1457
- ${server.csrf ?? ''}
1458
- <label for="email">Email</label>
1459
- <input id="email" name="email" value="${server.form?.values?.email ?? ''}">
1460
- <button type="submit">Go</button>
1461
- </form>
1462
- </main>`,
1463
- }
1464
-
1465
- await test('GET on a submit page sets the CSRF cookie and embeds the hidden field', async () => {
1466
- await withServer([formSpec], { stream: false }, async (port) => {
1467
- const { cookie, token, body } = await getCsrfPair(port, '/signup')
1468
- assert(cookie.startsWith('pulse_csrf='), `Expected pulse_csrf cookie, got: ${cookie}`)
1469
- assert(token.length > 20, `Expected a CSRF token in the form, got: ${token}`)
1470
- assert(body.includes('name="_csrf"'), 'Expected hidden _csrf input in the form')
1471
- })
1472
- })
1473
-
1474
- await test('CSRF field is present in streaming mode too', async () => {
1475
- await withServer([formSpec], { stream: true }, async (port) => {
1476
- const { token } = await getCsrfPair(port, '/signup')
1477
- assert(token.length > 20, 'Expected CSRF token in streamed HTML')
1478
- })
1479
- })
1480
-
1481
- await test('POST without a CSRF token is rejected with 403', async () => {
1482
- await withServer([formSpec], { stream: false }, async (port) => {
1483
- const { status } = await postForm(port, '/signup', { email: 'a@b.com' })
1484
- assert(status === 403, `Expected 403, got ${status}`)
1485
- })
1486
- })
1487
-
1488
- await test('POST with a forged token is rejected with 403', async () => {
1489
- await withServer([formSpec], { stream: false }, async (port) => {
1490
- const { cookie } = await getCsrfPair(port, '/signup')
1491
- const { status } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: 'forged-token-value' }, cookie)
1492
- assert(status === 403, `Expected 403, got ${status}`)
1493
- })
1494
- })
1495
-
1496
- await test('valid POST follows POST-redirect-GET with 303', async () => {
1497
- await withServer([formSpec], { stream: false }, async (port) => {
1498
- const { cookie, token } = await getCsrfPair(port, '/signup')
1499
- const { status, headers } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: token }, cookie)
1500
- assert(status === 303, `Expected 303, got ${status}`)
1501
- assert(headers.location === '/signup?done=1', `Expected PRG redirect, got ${headers.location}`)
1502
- })
1503
- })
1504
-
1505
- await test('validation failure re-renders the page with server.form (errors + values)', async () => {
1506
- await withServer([formSpec], { stream: false }, async (port) => {
1507
- const { cookie, token } = await getCsrfPair(port, '/signup')
1508
- const { status, body } = await postForm(port, '/signup', { email: '', _csrf: token }, cookie)
1509
- assert(status === 200, `Expected 200 re-render, got ${status}`)
1510
- assert(body.includes('Email is required'), `Expected error message in re-render, got: ${body.slice(0, 400)}`)
1511
- })
1512
- })
1513
-
1514
- await test('submit can set a custom response status (422) and values are echoed back', async () => {
1515
- await withServer([formSpec], { stream: false }, async (port) => {
1516
- const { cookie, token } = await getCsrfPair(port, '/signup')
1517
- const { status, body } = await postForm(port, '/signup', { email: 'taken@example.com', _csrf: token }, cookie)
1518
- assert(status === 422, `Expected 422, got ${status}`)
1519
- assert(body.includes('Already registered'), 'Expected error in re-render')
1520
- assert(body.includes('value="taken@example.com"'), 'Expected submitted value echoed into the input')
1521
- })
1522
- })
1523
-
1524
- await test('csrf: false opts a submit route out of CSRF protection', async () => {
1525
- const webhookish = {
1526
- route: '/hook',
1527
- csrf: false,
1528
- submit: async () => ({ redirect: '/hook?ok=1' }),
1529
- view: () => '<main id="main-content">hook</main>',
1530
- }
1531
- await withServer([webhookish], { stream: false }, async (port) => {
1532
- const { status, headers } = await postForm(port, '/hook', { x: '1' })
1533
- assert(status === 303 && headers.location === '/hook?ok=1', `Expected 303 without CSRF, got ${status}`)
1534
- })
1535
- })
1536
-
1537
- await test('POST to a page without submit is still 405', async () => {
1538
- await withServer([helloSpec], { stream: false }, async (port) => {
1539
- const { status } = await postForm(port, '/hello', { x: '1' })
1540
- assert(status === 405, `Expected 405, got ${status}`)
1541
- })
1542
- })
1543
-
1544
- await test('submit pages are excluded from the in-process page cache', async () => {
1545
- // Two different clients must get different CSRF tokens — a cached page would
1546
- // serve client A's token (and cookie pairing) to client B.
1547
- await withServer([formSpec], { stream: false, defaultCache: 3600 }, async (port) => {
1548
- const a = await getCsrfPair(port, '/signup')
1549
- const b = await getCsrfPair(port, '/signup')
1550
- assert(a.token && b.token && a.token !== b.token,
1551
- `Expected per-visitor CSRF tokens, got identical values (page was cached)`)
1552
- })
1553
- })
1554
-
1555
- // Regression: the client router fetches pages with X-Pulse-Navigate and the JSON / NDJSON
1556
- // responses used to be written without ctx's queued cookies and headers. A first-time visitor
1557
- // who reached a form via a link got a CSRF token but never the pulse_csrf cookie it is bound
1558
- // to, so every submit failed with "Invalid or missing CSRF token".
1559
- console.log('\nClient-side navigation — cookies and headers set during the request\n')
1560
-
1561
- await test('JSON navigation response sets the CSRF cookie for a submit page', async () => {
1562
- await withServer([formSpec], { stream: false }, async (port) => {
1563
- const res = await request(port, 'GET', '/signup', { 'X-Pulse-Navigate': 'true' })
1564
- assert(res.headers['content-type'].includes('application/json'), `Expected JSON, got ${res.headers['content-type']}`)
1565
- const setCookie = [].concat(res.headers['set-cookie'] || []).find(c => c.startsWith('pulse_csrf='))
1566
- assert(setCookie, `Expected a pulse_csrf Set-Cookie on the navigation response, got: ${JSON.stringify(res.headers['set-cookie'])}`)
1567
- assert(/HttpOnly/i.test(setCookie), 'CSRF cookie must stay HttpOnly')
1568
- })
1569
- })
1570
-
1571
- await test('a form reached via client navigation can be submitted (cookie and token pair up)', async () => {
1572
- await withServer([formSpec], { stream: false }, async (port) => {
1573
- const res = await request(port, 'GET', '/signup', { 'X-Pulse-Navigate': 'true' })
1574
- const cookie = [].concat(res.headers['set-cookie'] || []).find(c => c.startsWith('pulse_csrf='))?.split(';')[0] || ''
1575
- const token = JSON.parse(res.body).html.match(/name="_csrf" value="([^"]+)"/)?.[1] || ''
1576
- assert(cookie && token, `Expected both a cookie and a token, got cookie="${cookie}" token="${token}"`)
1577
- const { status, headers } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: token }, cookie)
1578
- assert(status === 303 && headers.location === '/signup?done=1', `Expected 303 for a valid POST, got ${status}`)
1579
- })
1580
- })
1581
-
1582
- await test('JSON navigation response carries cookies and headers set by a server fetcher', async () => {
1583
- const spec = {
1584
- route: '/nav-ctx',
1585
- meta: { title: 'Nav ctx' },
1586
- server: {
1587
- flag: async (ctx) => {
1588
- ctx.setCookie('seen', '1', { httpOnly: true })
1589
- ctx.setHeader('X-Custom', 'yes')
1590
- return 'ok'
1591
- },
1592
- },
1593
- view: (_s, srv) => `<main id="main-content">${srv.flag}</main>`,
1594
- }
1595
- await withServer([spec], { stream: false }, async (port) => {
1596
- const res = await request(port, 'GET', '/nav-ctx', { 'X-Pulse-Navigate': 'true' })
1597
- assert([].concat(res.headers['set-cookie'] || []).some(c => c.startsWith('seen=1')),
1598
- `Expected seen=1 cookie, got: ${JSON.stringify(res.headers['set-cookie'])}`)
1599
- assert(res.headers['x-custom'] === 'yes', `Expected X-Custom header, got: ${res.headers['x-custom']}`)
1600
- assert(res.headers['x-content-type-options'] === 'nosniff', 'Security headers must still be present')
1601
- })
1602
- })
1603
-
1604
- await test('NDJSON (streaming) navigation response carries cookies and headers set in a guard', async () => {
1605
- const spec = {
1606
- ...deferredNavSpec,
1607
- route: '/deferred-nav-ctx',
1608
- guard: async (ctx) => {
1609
- ctx.setCookie('seen', '1', { httpOnly: true })
1610
- ctx.setHeader('X-Custom', 'yes')
1611
- },
1612
- }
1613
- await withServer([spec], { stream: true }, async (port) => {
1614
- const res = await request(port, 'GET', '/deferred-nav-ctx', { 'X-Pulse-Navigate': 'true' })
1615
- assert(res.headers['content-type'].includes('application/x-ndjson'), `Expected NDJSON, got ${res.headers['content-type']}`)
1616
- assert([].concat(res.headers['set-cookie'] || []).some(c => c.startsWith('seen=1')),
1617
- `Expected seen=1 cookie, got: ${JSON.stringify(res.headers['set-cookie'])}`)
1618
- assert(res.headers['x-custom'] === 'yes', `Expected X-Custom header, got: ${res.headers['x-custom']}`)
1619
- assert(res.headers['x-accel-buffering'] === 'no', 'Streaming headers must be preserved')
1620
- })
1621
- })
1622
-
1623
- await test('navigation responses without queued cookies/headers are unchanged', async () => {
1624
- await withServer([helloSpec], { stream: false }, async (port) => {
1625
- const res = await request(port, 'GET', '/hello', { 'X-Pulse-Navigate': 'true' })
1626
- assert(res.status === 200 && res.headers['set-cookie'] === undefined, `Unexpected Set-Cookie: ${res.headers['set-cookie']}`)
1627
- })
1628
- })
1629
-
1630
- // ---------------------------------------------------------------------------
1631
-
1632
- console.log('\nDeclarative redirects\n')
1633
-
1634
- await test('plain redirect responds 301 with Location', async () => {
1635
- await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1636
- const { status, headers } = await get(port, '/old-hello')
1637
- assert(status === 301, `Expected 301, got ${status}`)
1638
- assert(headers.location === '/hello', `Expected /hello, got ${headers.location}`)
1639
- })
1640
- })
1641
-
1642
- await test(':params are substituted into the target', async () => {
1643
- await withServer([helloSpec], { stream: false, redirects: { '/old-blog/:slug': '/blog/:slug' } }, async (port) => {
1644
- const { status, headers } = await get(port, '/old-blog/my-post')
1645
- assert(status === 301)
1646
- assert(headers.location === '/blog/my-post', `Expected /blog/my-post, got ${headers.location}`)
1647
- })
1648
- })
1649
-
1650
- await test('percent-encoded params keep their encoding through substitution', async () => {
1651
- await withServer([helloSpec], { stream: false, redirects: { '/old/:slug': '/new/:slug' } }, async (port) => {
1652
- const { headers } = await get(port, '/old/caf%C3%A9')
1653
- assert(headers.location === '/new/caf%C3%A9', `Encoding mangled: ${headers.location}`)
1654
- })
1655
- })
1656
-
1657
- await test('query string is preserved on redirect', async () => {
1658
- await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1659
- const { headers } = await get(port, '/old-hello?utm=x&page=2')
1660
- assert(headers.location === '/hello?utm=x&page=2', `Query lost: ${headers.location}`)
1661
- })
1662
- })
1663
-
1664
- await test('custom status via { to, status }', async () => {
1665
- await withServer([helloSpec], { stream: false, redirects: { '/promo': { to: '/hello', status: 302 } } }, async (port) => {
1666
- const { status, headers } = await get(port, '/promo')
1667
- assert(status === 302, `Expected 302, got ${status}`)
1668
- assert(headers.location === '/hello')
1669
- })
1670
- })
1671
-
1672
- await test('absolute URL targets work (domain moves)', async () => {
1673
- await withServer([helloSpec], { stream: false, redirects: { '/moved/:slug': 'https://newsite.com/blog/:slug' } }, async (port) => {
1674
- const { headers } = await get(port, '/moved/post-1')
1675
- assert(headers.location === 'https://newsite.com/blog/post-1', `Got ${headers.location}`)
1676
- })
1677
- })
1678
-
1679
- await test('redirect wins over a registered route at the same path', async () => {
1680
- await withServer([helloSpec], { stream: false, quiet: true, redirects: { '/hello': '/elsewhere' } }, async (port) => {
1681
- const { status, headers } = await get(port, '/hello')
1682
- assert(status === 301 && headers.location === '/elsewhere', `Expected redirect to win, got ${status}`)
1683
- })
1684
- })
1685
-
1686
- await test('POST is not redirected', async () => {
1687
- await withServer([helloSpec], { stream: false, redirects: { '/old-api': '/hello' } }, async (port) => {
1688
- const { status } = await requestFull(port, 'POST', '/old-api', {})
1689
- assert(status === 404, `Expected POST to skip redirects (404), got ${status}`)
1690
- })
1691
- })
1692
-
1693
- await test('self-redirects fall through to normal routing instead of looping', async () => {
1694
- await withServer([helloSpec], { stream: false, redirects: { '/hello/:x': '/hello/:x' } }, async (port) => {
1695
- const { status } = await get(port, '/hello/abc')
1696
- assert(status === 404, `Expected fall-through to routing (404 here), got ${status}`)
1697
- const ok = await get(port, '/hello')
1698
- assert(ok.status === 200, 'Unrelated routes unaffected')
1699
- })
1700
- })
1701
-
1702
- await test('invalid redirect config throws at startup', async () => {
1703
- const cases = [
1704
- { redirects: { 'no-slash': '/x' } },
1705
- { redirects: { '/x': 'no-slash-target' } },
1706
- { redirects: { '/x': { to: '/y', status: 418 } } },
1707
- { redirects: { '/x': '/y/:missing' } },
1708
- ]
1709
- for (const opts of cases) {
1710
- let threw = false
1711
- try { await createServer([helloSpec], { ...opts, port: nextPort++, quiet: true }) } catch { threw = true }
1712
- assert(threw, `Expected startup throw for ${JSON.stringify(opts.redirects)}`)
1713
- }
1714
- })
1715
-
1716
- // ---------------------------------------------------------------------------
1717
-
1718
- console.log('\nSitemap + robots.txt\n')
1719
-
1720
- const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
1721
- const blogSpec = {
1722
- route: '/blog/:slug',
1723
- sitemap: async () => ['/blog/first-post', { path: '/blog/second-post', lastmod: '2026-01-15' }],
1724
- server: { post: async (ctx) => ({ slug: ctx.params.slug }) },
1725
- view: (_s, server) => `<main id="main-content">${server.post.slug}</main>`,
1726
- }
1727
- const secretSpec = { route: '/admin', guard: async () => {}, view: () => '<main id="main-content">admin</main>' }
1728
- const hiddenSpec = { route: '/internal', sitemap: false, view: () => '<main id="main-content">internal</main>' }
1729
-
1730
- await test('sitemap: true serves /sitemap.xml with static routes', async () => {
1731
- await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true }, async (port) => {
1732
- const { status, headers, body } = await get(port, '/sitemap.xml')
1733
- assert(status === 200, `Expected 200, got ${status}`)
1734
- assert(headers['content-type'].includes('application/xml'), `Expected XML, got ${headers['content-type']}`)
1735
- assert(body.includes(`<loc>http://localhost:${port}/hello</loc>`), `Missing /hello: ${body}`)
1736
- assert(body.includes(`<loc>http://localhost:${port}/about</loc>`), `Missing /about: ${body}`)
1737
- })
1738
- })
1739
-
1740
- await test('dynamic routes contribute via spec.sitemap enumerator (with lastmod)', async () => {
1741
- await withServer([blogSpec], { stream: false, sitemap: true, quiet: true }, async (port) => {
1742
- const { body } = await get(port, '/sitemap.xml')
1743
- assert(body.includes('/blog/first-post</loc>'), `Missing enumerated path: ${body}`)
1744
- assert(body.includes('/blog/second-post</loc>'), 'Missing second enumerated path')
1745
- assert(body.includes('<lastmod>2026-01-15</lastmod>'), 'Missing lastmod')
1746
- })
1747
- })
1748
-
1749
- await test('dynamic routes without an enumerator are skipped, not broken', async () => {
1750
- const bare = { route: '/items/:id', server: { i: async (ctx) => ctx.params.id }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
1751
- await withServer([helloSpec, bare], { stream: false, sitemap: true, quiet: true }, async (port) => {
1752
- const { status, body } = await get(port, '/sitemap.xml')
1753
- assert(status === 200)
1754
- assert(!body.includes(':id'), `Raw :param leaked into sitemap: ${body}`)
1755
- assert(body.includes('/hello</loc>'), 'Static route should still be present')
1756
- })
1757
- })
1758
-
1759
- await test('guarded pages, route "*", and sitemap:false are excluded', async () => {
1760
- const nf = { route: '*', view: () => '<main id="main-content">404</main>' }
1761
- await withServer([helloSpec, secretSpec, hiddenSpec, nf], { stream: false, sitemap: true }, async (port) => {
1762
- const { body } = await get(port, '/sitemap.xml')
1763
- assert(!body.includes('/admin'), 'Guarded page must be excluded by default')
1764
- assert(!body.includes('/internal'), 'sitemap:false page must be excluded')
1765
- assert(!body.includes('<loc>http://localhost:' + port + '/*'), 'route * must be excluded')
1766
- assert(body.includes('/hello</loc>'))
1767
- })
1768
- })
1769
-
1770
- await test('a guarded page opts in with sitemap: true', async () => {
1771
- const publicGuarded = { route: '/landing', guard: async () => {}, sitemap: true, view: () => '<main id="main-content">x</main>' }
1772
- await withServer([publicGuarded], { stream: false, sitemap: true }, async (port) => {
1773
- const { body } = await get(port, '/sitemap.xml')
1774
- assert(body.includes('/landing</loc>'), `Expected opted-in guarded page: ${body}`)
1775
- })
1776
- })
1777
-
1778
- await test('robots.txt is generated and points at the sitemap', async () => {
1779
- await withServer([helloSpec], { stream: false, sitemap: true }, async (port) => {
1780
- const { status, body } = await get(port, '/robots.txt')
1781
- assert(status === 200)
1782
- assert(body.includes('User-agent: *'), `Missing user-agent: ${body}`)
1783
- assert(body.includes(`Sitemap: http://localhost:${port}/sitemap.xml`), `Missing sitemap line: ${body}`)
1784
- })
1785
- })
1786
-
1787
- await test('robots: false disables robots.txt; custom string is served verbatim', async () => {
1788
- await withServer([helloSpec], { stream: false, sitemap: true, robots: false }, async (port) => {
1789
- const { status } = await get(port, '/robots.txt')
1790
- assert(status === 404, `Expected 404 with robots:false, got ${status}`)
1791
- })
1792
- await withServer([helloSpec], { stream: false, sitemap: true, robots: 'User-agent: *\nDisallow: /private\n' }, async (port) => {
1793
- const { body } = await get(port, '/robots.txt')
1794
- assert(body.includes('Disallow: /private'), 'Expected custom robots content')
1795
- })
1796
- })
1797
-
1798
- await test('sitemap origin can be pinned via { origin }', async () => {
1799
- await withServer([helloSpec], { stream: false, sitemap: { origin: 'https://mysite.com' } }, async (port) => {
1800
- const { body } = await get(port, '/sitemap.xml')
1801
- assert(body.includes('<loc>https://mysite.com/hello</loc>'), `Expected pinned origin: ${body}`)
1802
- const robotsRes = await get(port, '/robots.txt')
1803
- assert(robotsRes.body.includes('Sitemap: https://mysite.com/sitemap.xml'), 'robots should use pinned origin')
1804
- })
1805
- })
1806
-
1807
- await test('trailingSlash "add" is reflected in sitemap URLs', async () => {
1808
- await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true, trailingSlash: 'add' }, async (port) => {
1809
- const { body } = await get(port, '/sitemap.xml')
1810
- assert(body.includes('/about/</loc>'), `Expected trailing slash on URLs: ${body}`)
1811
- })
1812
- })
1813
-
1814
- await test('sitemap disabled by default — /sitemap.xml is 404', async () => {
1815
- await withServer([helloSpec], { stream: false }, async (port) => {
1816
- const { status } = await get(port, '/sitemap.xml')
1817
- assert(status === 404, `Expected 404 when sitemap not enabled, got ${status}`)
1818
- })
1819
- })
1820
-
1821
- await test('a failing enumerator does not take down the sitemap', async () => {
1822
- const broken = { route: '/x/:id', sitemap: async () => { throw new Error('db down') }, server: { i: async () => 1 }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
1823
- await withServer([helloSpec, broken], { stream: false, sitemap: true, quiet: true }, async (port) => {
1824
- const { status, body } = await get(port, '/sitemap.xml')
1825
- assert(status === 200, `Expected 200 despite enumerator failure, got ${status}`)
1826
- assert(body.includes('/hello</loc>'), 'Other routes should still be listed')
1827
- })
1828
- })
1829
-
1830
- // ---------------------------------------------------------------------------
1831
-
1832
- console.log('\nLive store push (SSE)\n')
1833
-
1834
- /** Open an SSE connection and return a handle that accumulates received text. */
1835
- function openSse(port, path) {
1836
- return new Promise((resolve, reject) => {
1837
- const req = http.get({ hostname: 'localhost', port, path, headers: { Accept: 'text/event-stream' } }, (res) => {
1838
- let buf = ''
1839
- res.on('data', c => { buf += c.toString() })
1840
- resolve({ status: res.statusCode, headers: res.headers, read: () => buf, destroy: () => req.destroy() })
1841
- })
1842
- req.on('error', reject)
1843
- })
1844
- }
1845
-
1846
- const wait = (ms) => new Promise(r => setTimeout(r, ms))
1847
-
1848
- await test('live: true serves the SSE endpoint', async () => {
1849
- const port = nextPort++
1850
- const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1851
- try {
1852
- const sse = await openSse(port, '/__pulse/live')
1853
- assert(sse.status === 200, `Expected 200, got ${sse.status}`)
1854
- assert(sse.headers['content-type'].includes('text/event-stream'), `Got ${sse.headers['content-type']}`)
1855
- await wait(50)
1856
- assert(sse.read().includes(':connected'), 'Expected connection comment')
1857
- sse.destroy()
1858
- } finally {
1859
- server.closeAllConnections?.()
1860
- await new Promise(r => server.close(r))
1861
- }
1862
- })
1863
-
1864
- await test('pushStore broadcasts a patch to all connected clients', async () => {
1865
- const port = nextPort++
1866
- const { server, pushStore } = await createServer([helloSpec], { port, quiet: true, live: true })
1867
- try {
1868
- const a = await openSse(port, '/__pulse/live')
1869
- const b = await openSse(port, '/__pulse/live')
1870
- await wait(50)
1871
- const sent = pushStore({ stock: 5, banner: 'Sale on now' })
1872
- assert(sent === 2, `Expected 2 clients, got ${sent}`)
1873
- await wait(50)
1874
- for (const sse of [a, b]) {
1875
- assert(sse.read().includes('event: store'), 'Missing event name')
1876
- assert(sse.read().includes('"stock":5'), `Missing payload: ${sse.read()}`)
1877
- }
1878
- a.destroy(); b.destroy()
1879
- } finally {
1880
- server.closeAllConnections?.()
1881
- await new Promise(r => server.close(r))
1882
- }
1883
- })
1884
-
1885
- await test('pushStore requires live: true and a plain-object patch', async () => {
1886
- const port = nextPort++
1887
- const off = await createServer([helloSpec], { port, quiet: true })
1888
- try {
1889
- let threw = false
1890
- try { off.pushStore({ x: 1 }) } catch { threw = true }
1891
- assert(threw, 'Expected throw without live: true')
1892
- } finally {
1893
- off.server.closeAllConnections?.()
1894
- await new Promise(r => off.server.close(r))
1895
- }
1896
- const port2 = nextPort++
1897
- const on = await createServer([helloSpec], { port: port2, quiet: true, live: true })
1898
- try {
1899
- let threw = false
1900
- try { on.pushStore('not-an-object') } catch { threw = true }
1901
- assert(threw, 'Expected throw for non-object patch')
1902
- } finally {
1903
- on.server.closeAllConnections?.()
1904
- await new Promise(r => on.server.close(r))
1905
- }
1906
- })
1907
-
1908
- await test('module-level pushStore export reaches the live server (spec-file usage)', async () => {
1909
- const { pushStore: globalPush } = await import('./index.js')
1910
- const port = nextPort++
1911
- const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1912
- try {
1913
- const sse = await openSse(port, '/__pulse/live')
1914
- await wait(50)
1915
- const sent = globalPush({ score: 99 })
1916
- assert(sent === 1, `Expected module-level pushStore to reach 1 client, got ${sent}`)
1917
- await wait(50)
1918
- assert(sse.read().includes('"score":99'), `Missing payload via module export: ${sse.read()}`)
1919
- sse.destroy()
1920
- } finally {
1921
- server.closeAllConnections?.()
1922
- await new Promise(r => server.close(r))
1923
- }
1924
- })
1925
-
1926
- await test('endpoint is 404 when live is not enabled', async () => {
1927
- await withServer([helloSpec], { stream: false }, async (port) => {
1928
- const { status } = await get(port, '/__pulse/live')
1929
- assert(status === 404, `Expected 404, got ${status}`)
1930
- })
1931
- })
1932
-
1933
- await test('custom endpoint path via { path }', async () => {
1934
- const port = nextPort++
1935
- const { server } = await createServer([helloSpec], { port, quiet: true, live: { path: '/_realtime' } })
1936
- try {
1937
- const sse = await openSse(port, '/_realtime')
1938
- assert(sse.status === 200, `Expected 200 on custom path, got ${sse.status}`)
1939
- sse.destroy()
1940
- } finally {
1941
- server.closeAllConnections?.()
1942
- await new Promise(r => server.close(r))
1943
- }
1944
- })
1945
-
1946
- await test('hydrated pages receive the __PULSE_LIVE__ flag; disabled servers do not', async () => {
1947
- const hydrated = {
1948
- route: '/live-page', hydrate: '/src/pages/live-page.js', store: ['stock'],
1949
- state: { n: 0 }, mutations: { inc: (s) => ({ n: s.n + 1 }) },
1950
- view: (state, server) => `<main id="main-content">${server.stock ?? '—'}</main>`,
1951
- }
1952
- await withServer([hydrated], { stream: false, live: true }, async (port) => {
1953
- const { body } = await get(port, '/live-page')
1954
- assert(body.includes(`window.__PULSE_LIVE__='/__pulse/live'`), `Flag missing: ${body.slice(0, 600)}`)
1955
- })
1956
- await withServer([hydrated], { stream: false }, async (port) => {
1957
- const { body } = await get(port, '/live-page')
1958
- assert(!body.includes('__PULSE_LIVE__'), 'Flag must be absent when live is disabled')
1959
- })
1960
- })
1961
-
1962
- await test('a store-only URL-entry spec (no local mutations/actions/persist, no explicit hydrate) still auto-derives a hydrate URL — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; without this a store-only page silently ships zero JS and never receives pushStore() broadcasts', async () => {
1963
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-needshydration-store-test-'))
1964
- const specPath = path.join(dir, 'storey.js')
1965
- fs.writeFileSync(
1966
- specPath,
1967
- `export default {\n route: '/storey',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count ?? 0}\`,\n}\n`
1968
- )
1969
- try {
1970
- const port = nextPort++
1971
- const { server } = await createServer([new URL('file://' + specPath)], { stream: false, live: true, port, root: new URL('file://' + dir + '/') })
1972
- await new Promise(resolve => server.once('listening', resolve))
1973
- try {
1974
- const { body } = await get(port, '/storey')
1975
- // A store-only page with no auto-derived hydrate would render with no
1976
- // module script and no __PULSE_LIVE__ flag at all — both must be present.
1977
- assert(body.includes('<script type="module"'), `Expected a hydration script tag, got: ${body.slice(0, 800)}`)
1978
- assert(body.includes('__PULSE_LIVE__'), `Expected the live-store flag (only emitted for hydrated pages), got: ${body.slice(0, 800)}`)
1979
- } finally {
1980
- server.closeAllConnections?.()
1981
- await new Promise(resolve => server.close(resolve))
1982
- }
1983
- } finally {
1984
- fs.rmSync(dir, { recursive: true, force: true })
1985
- }
1986
- })
1987
-
1988
- // ---------------------------------------------------------------------------
1989
-
1990
- console.log('\nMalformed request handling\n')
1991
-
1992
- await test('malformed percent-encoding in path returns 404, not 500', async () => {
1993
- await withServer([helloSpec], { stream: false }, async (port) => {
1994
- const { status } = await get(port, '/%ZZ')
1995
- assert(status === 404, `Expected 404 for malformed encoding, got ${status}`)
1996
- })
1997
- })
1998
-
1999
- await test('malformed percent-encoding in param route returns 404', async () => {
2000
- await withServer([productSpec], { stream: false }, async (port) => {
2001
- const { status } = await get(port, '/products/%E0%A4%A')
2002
- assert(status === 404, `Expected 404 for malformed param, got ${status}`)
2003
- })
2004
- })
2005
-
2006
- // ---------------------------------------------------------------------------
2007
-
2008
- console.log('\nPage cache — query-string DoS guard\n')
2009
-
2010
- await test('query string is NOT part of the cache key by default', async () => {
2011
- // A cacheable page should serve identical cached HTML regardless of arbitrary
2012
- // query params — they must not mint new cache entries.
2013
- let renders = 0
2014
- const spec = {
2015
- route: '/cached',
2016
- cache: { public: true, maxAge: 60 },
2017
- view: () => `<h1>render-${++renders}</h1>`,
2018
- }
2019
- await withServer([spec], { stream: false }, async (port) => {
2020
- const a = await get(port, '/cached?n=1')
2021
- const b = await get(port, '/cached?n=2')
2022
- const c = await get(port, '/cached?n=3')
2023
- assert(a.body === b.body && b.body === c.body, 'Cached body should be identical across query strings')
2024
- assert(a.body.includes('render-1'), `Expected first render cached, got: ${a.body}`)
2025
- })
2026
- })
2027
-
2028
- await test('cacheVary keys DO produce distinct cache entries', async () => {
2029
- let renders = 0
2030
- const spec = {
2031
- route: '/varied',
2032
- cache: { public: true, maxAge: 60 },
2033
- cacheVary: ['q'],
2034
- server: { n: async () => ++renders },
2035
- view: (_s, server) => `<h1>v-${server.n}</h1>`,
2036
- }
2037
- // server fetcher makes this non-cacheable for HTML, so vary only affects
2038
- // the in-process key; assert distinct outputs per q value.
2039
- await withServer([spec], { stream: false }, async (port) => {
2040
- const a = await get(port, '/varied?q=apple')
2041
- const b = await get(port, '/varied?q=banana')
2042
- assert(a.body !== b.body, `Expected distinct outputs per cacheVary value, got identical: ${a.body}`)
2043
- })
2044
- })
2045
-
2046
- // ---------------------------------------------------------------------------
2047
-
2048
- console.log('\nTtlCache — bounded LRU\n')
2049
-
2050
- await test('evicts least-recently-used entry when over capacity', () => {
2051
- const c = new TtlCache(2)
2052
- c.set('a', 1, 60)
2053
- c.set('b', 2, 60)
2054
- c.set('c', 3, 60) // exceeds cap → evicts 'a' (LRU)
2055
- assert(c.get('a') === undefined, 'Expected a to be evicted')
2056
- assert(c.get('b') === 2, 'Expected b to remain')
2057
- assert(c.get('c') === 3, 'Expected c to remain')
2058
- })
2059
-
2060
- await test('get() refreshes recency so the touched entry survives eviction', () => {
2061
- const c = new TtlCache(2)
2062
- c.set('a', 1, 60)
2063
- c.set('b', 2, 60)
2064
- c.get('a') // 'a' now most-recently-used
2065
- c.set('c', 3, 60) // evicts 'b', not 'a'
2066
- assert(c.get('a') === 1, 'Expected a to survive (recently used)')
2067
- assert(c.get('b') === undefined, 'Expected b to be evicted')
2068
- })
2069
-
2070
- await test('many distinct keys never exceed the cap', () => {
2071
- const c = new TtlCache(100)
2072
- for (let i = 0; i < 10_000; i++) c.set(`k${i}`, i, 60)
2073
- assert(c._store.size === 100, `Expected size capped at 100, got ${c._store.size}`)
2074
- })
2075
-
2076
- await test('expired entries are not returned', () => {
2077
- const c = new TtlCache(10)
2078
- c.set('x', 1, -1) // already expired
2079
- assert(c.get('x') === undefined, 'Expected expired entry to be absent')
2080
- })
2081
-
2082
- // ---------------------------------------------------------------------------
2083
-
2084
- console.log('\nCompression\n')
2085
-
2086
- await test('serves brotli-compressed HTML when br is accepted', async () => {
2087
- await withServer([helloSpec], { stream: false }, async (port) => {
2088
- const { headers } = await request(port, 'GET', '/hello', { 'Accept-Encoding': 'br' })
2089
- assert(headers['content-encoding'] === 'br', `Expected br encoding, got ${headers['content-encoding']}`)
2090
- })
2091
- })
2092
-
2093
- // ---------------------------------------------------------------------------
2094
- // Route shadowing warning
2095
- // ---------------------------------------------------------------------------
2096
- // Regression: found via dogfooding. Route matching is first-match-wins in
2097
- // registration order with no specificity ranking (see matchRoute) — a dynamic
2098
- // route registered before a same-depth static route silently swallows every
2099
- // request to that static route, with no error and no warning. discoverPages'
2100
- // file-order is not guaranteed alphabetical across platforms, so this is a
2101
- // real, easy-to-hit trap for any project mixing a catch-all-ish :param route
2102
- // with static pages at the same path depth.
2103
-
2104
- console.log('\nRoute shadowing\n')
2105
-
2106
- const dynamicFirstSpec = { route: '/:code', state: {}, view: () => '' }
2107
- const staticShadowedSpec = { route: '/settings', state: {}, view: () => '' }
2108
- const unrelatedSpec = { route: '/about', state: {}, view: () => '' }
2109
-
2110
- // The warning fires synchronously inside createServer, before it starts
2111
- // listening — capture around the whole createServer call, then tear the
2112
- // server down immediately (nothing needs to actually run against it).
2113
- async function warningsFromCreateServer(specs) {
2114
- const original = console.warn
2115
- const messages = []
2116
- console.warn = (msg) => messages.push(msg)
2117
- const port = nextPort++
2118
- let server
2119
- try {
2120
- ;({ server } = await createServer(specs, { port, stream: false, quiet: true }))
2121
- await new Promise(resolve => server.once('listening', resolve))
2122
- } finally {
2123
- console.warn = original
2124
- if (server) {
2125
- server.closeAllConnections?.()
2126
- await new Promise(resolve => server.close(resolve))
2127
- }
2128
- }
2129
- return messages
2130
- }
2131
-
2132
- await test('warns when a dynamic route is registered before a static route it shadows', async () => {
2133
- const warnings = await warningsFromCreateServer([dynamicFirstSpec, staticShadowedSpec])
2134
- assert(warnings.some(w => w.includes('/:code') && w.includes('/settings') && w.includes('never be reached')),
2135
- `Expected a shadowing warning naming both routes, got: ${JSON.stringify(warnings)}`)
2136
- })
2137
-
2138
- await test('no warning when the static route is registered first (correct order)', async () => {
2139
- const warnings = await warningsFromCreateServer([staticShadowedSpec, dynamicFirstSpec])
2140
- assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
2141
- })
2142
-
2143
- await test('no warning between two unrelated static routes', async () => {
2144
- const warnings = await warningsFromCreateServer([staticShadowedSpec, unrelatedSpec])
2145
- assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
2146
- })
2147
-
2148
- // ---------------------------------------------------------------------------
2149
-
2150
- console.log('\nError journal\n')
2151
-
2152
- function makeTmpRoot() {
2153
- return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-error-journal-'))
2154
- }
2155
-
2156
- function readJournal(root) {
2157
- const p = path.join(root, '.pulse', 'errors.json')
2158
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null
2159
- }
2160
-
2161
- const journalThrowingSpec = {
2162
- route: '/boom-journal',
2163
- state: {},
2164
- view: () => { throw new Error('deliberate view failure') },
2165
- }
2166
-
2167
- await test('server error in dev mode writes an entry to the journal', async () => {
2168
- const root = makeTmpRoot()
2169
- await withServer([journalThrowingSpec], { stream: false, dev: true, root }, async (port) => {
2170
- await get(port, '/boom-journal')
2171
- const entries = readJournal(root)
2172
- assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
2173
- assert(entries[0].phase === 'server', `Expected phase "server", got ${entries[0].phase}`)
2174
- assert(entries[0].message.includes('deliberate view failure'), `Message missing detail: ${entries[0].message}`)
2175
- assert(entries[0].resolved === false, 'New entry should be unresolved')
2176
- })
2177
- fs.rmSync(root, { recursive: true, force: true })
2178
- })
2179
-
2180
- await test('server error NOT in dev mode does not write a journal entry', async () => {
2181
- const root = makeTmpRoot()
2182
- await withServer([journalThrowingSpec], { stream: false, dev: false, root }, async (port) => {
2183
- await get(port, '/boom-journal')
2184
- const entries = readJournal(root)
2185
- assert(entries === null, `Expected no journal file in prod mode, got ${JSON.stringify(entries)}`)
2186
- })
2187
- fs.rmSync(root, { recursive: true, force: true })
2188
- })
2189
-
2190
- await test('POST /__pulse/error in dev mode writes a client-reported entry', async () => {
2191
- const root = makeTmpRoot()
2192
- await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
2193
- const { status } = await requestWithBody(
2194
- port, 'POST', '/__pulse/error',
2195
- JSON.stringify({ route: '/dashboard', phase: 'action', message: 'action "save" failed: boom', stack: 'Error: boom\n at save' }),
2196
- 'application/json'
2197
- )
2198
- assert(status === 204, `Expected 204, got ${status}`)
2199
- const entries = readJournal(root)
2200
- assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
2201
- assert(entries[0].route === '/dashboard', `Expected route /dashboard, got ${entries[0].route}`)
2202
- assert(entries[0].phase === 'action', `Expected phase action, got ${entries[0].phase}`)
2203
- })
2204
- fs.rmSync(root, { recursive: true, force: true })
2205
- })
2206
-
2207
- await test('POST /__pulse/error in production returns 404 — no dev-only endpoint exposed', async () => {
2208
- const root = makeTmpRoot()
2209
- await withServer([helloSpec], { stream: false, dev: false, root }, async (port) => {
2210
- const { status } = await requestWithBody(port, 'POST', '/__pulse/error', JSON.stringify({}), 'application/json')
2211
- assert(status === 404, `Expected 404 in production, got ${status}`)
2212
- assert(readJournal(root) === null, 'No journal file should exist in production')
2213
- })
2214
- fs.rmSync(root, { recursive: true, force: true })
2215
- })
2216
-
2217
- await test('error journal is capped at 50 entries (ring buffer)', async () => {
2218
- const root = makeTmpRoot()
2219
- await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
2220
- for (let i = 0; i < 55; i++) {
2221
- await requestWithBody(
2222
- port, 'POST', '/__pulse/error',
2223
- JSON.stringify({ route: '/x', phase: 'view', message: `error ${i}`, stack: '' }),
2224
- 'application/json'
2225
- )
2226
- }
2227
- const entries = readJournal(root)
2228
- assert(entries.length === 50, `Expected 50 entries (capped), got ${entries.length}`)
2229
- // Oldest entries should have been dropped — the most recent should remain
2230
- assert(entries[entries.length - 1].message === 'error 54', `Expected newest entry to survive, got ${entries[entries.length - 1].message}`)
2231
- assert(entries[0].message === 'error 5', `Expected oldest surviving entry to be "error 5", got ${entries[0].message}`)
2232
- })
2233
- fs.rmSync(root, { recursive: true, force: true })
2234
- })
2235
-
2236
- console.log('\nGraceful shutdown\n')
2237
-
2238
- await test('shutdown() is returned from createServer', async () => {
2239
- const port = nextPort++
2240
- const result = await createServer([helloSpec], { port, stream: false })
2241
- assert(typeof result.shutdown === 'function', 'Expected shutdown to be a function')
2242
- result.server.closeAllConnections?.()
2243
- await new Promise(resolve => result.server.close(resolve))
2244
- })
2245
-
2246
- // shutdown() ends with server.close(() => process.exit(0)). That is right for production, but in a
2247
- // test it would terminate the whole run, so each of these tests stubs process.exit (captureExit) and
2248
- // waits for the stub to be hit before restoring it.
2249
- await test('shutdown() stops the server from accepting new connections', async () => {
2250
- const exit = captureExit()
2251
- try {
2252
- const port = nextPort++
2253
- const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
2254
- await new Promise(resolve => server.once('listening', resolve))
2255
-
2256
- // Confirm server is up
2257
- const before = await get(port, '/hello')
2258
- assert(before.status === 200, `Expected 200 before shutdown, got ${before.status}`)
2259
-
2260
- shutdown()
2261
-
2262
- // Server should no longer accept connections
2263
- await new Promise(resolve => setTimeout(resolve, 20))
2264
- let refused = false
2265
- try { await get(port, '/hello') } catch { refused = true }
2266
- assert(refused, 'Expected connection to be refused after shutdown()')
2267
-
2268
- assert(await exit.waitForExit(), 'Expected process.exit() once all connections closed')
2269
- assert(exit.codes.length === 1 && exit.codes[0] === 0, `Expected a single clean exit(0), got ${JSON.stringify(exit.codes)}`)
2270
- } finally {
2271
- exit.restore()
2272
- }
2273
- })
2274
-
2275
- await test('shutdown() is idempotent — calling twice does not throw', async () => {
2276
- const exit = captureExit()
2277
- try {
2278
- const port = nextPort++
2279
- const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
2280
- await new Promise(resolve => server.once('listening', resolve))
2281
- shutdown()
2282
- shutdown() // second call is a no-op
2283
- assert(await exit.waitForExit(), 'Expected process.exit() once the server closed')
2284
- await new Promise(resolve => setTimeout(resolve, 20))
2285
- assert(exit.codes.length === 1, `Expected exactly one exit from two shutdown() calls, got ${JSON.stringify(exit.codes)}`)
2286
- } finally {
2287
- exit.restore()
2288
- }
2289
- })
2290
-
2291
- await test('in-flight request completes after shutdown() is called', async () => {
2292
- const port = nextPort++
2293
- let resolveSlowFetch
2294
- const slowSpec = {
2295
- route: '/slow',
2296
- state: {},
2297
- server: {
2298
- data: () => new Promise(resolve => { resolveSlowFetch = () => resolve('done') })
2299
- },
2300
- view: (_s, server) => `<p>${server.data}</p>`
2301
- }
2302
-
2303
- const exit = captureExit()
2304
- try {
2305
- const { server, shutdown } = await createServer([slowSpec], { port, stream: false })
2306
- await new Promise(resolve => server.once('listening', resolve))
2307
-
2308
- // Start a request but don't resolve its server fetch yet
2309
- const pending = get(port, '/slow')
2310
-
2311
- // Small delay to ensure the request is in flight before shutdown
2312
- await new Promise(resolve => setTimeout(resolve, 20))
2313
-
2314
- shutdown()
2315
-
2316
- // The process must wait for the in-flight request before exiting
2317
- assert(exit.codes.length === 0, 'Must not exit while a request is still in flight')
2318
-
2319
- // Now resolve the slow fetch — the response should still complete
2320
- resolveSlowFetch()
2321
- const { status, body } = await pending
2322
- assert(status === 200, `Expected 200, got ${status}`)
2323
- assert(body.includes('<p>done</p>'), `Expected response body, got: ${body}`)
2324
-
2325
- assert(await exit.waitForExit(), 'Expected process.exit() once the in-flight request finished')
2326
- assert(exit.codes[0] === 0, `Expected exit(0), got ${JSON.stringify(exit.codes)}`)
2327
- } finally {
2328
- exit.restore()
2329
- }
2330
- })
2331
-
2332
- // ---------------------------------------------------------------------------
2333
- // Multipart form data — ctx.formData()
2334
- // ---------------------------------------------------------------------------
2335
-
2336
- /**
2337
- * Build a multipart/form-data body Buffer from a fields map.
2338
- * Each field can be a string (text field) or
2339
- * { filename, type, content: string|Buffer } (file field).
2340
- */
2341
- function buildMultipart(fields, boundary = 'TestBoundary123') {
2342
- const chunks = []
2343
- for (const [name, value] of Object.entries(fields)) {
2344
- chunks.push(Buffer.from(`--${boundary}\r\n`))
2345
- if (value !== null && typeof value === 'object' && 'filename' in value) {
2346
- const ct = value.type || 'application/octet-stream'
2347
- chunks.push(Buffer.from(`Content-Disposition: form-data; name="${name}"; filename="${value.filename}"\r\nContent-Type: ${ct}\r\n\r\n`))
2348
- chunks.push(Buffer.isBuffer(value.content) ? value.content : Buffer.from(String(value.content)))
2349
- } else {
2350
- chunks.push(Buffer.from(`Content-Disposition: form-data; name="${name}"\r\n\r\n`))
2351
- chunks.push(Buffer.from(String(value)))
2352
- }
2353
- chunks.push(Buffer.from('\r\n'))
2354
- }
2355
- chunks.push(Buffer.from(`--${boundary}--\r\n`))
2356
- return Buffer.concat(chunks)
2357
- }
2358
-
2359
- console.log('\nMultipart form data (ctx.formData)\n')
2360
-
2361
- const multipartBaseSpec = {
2362
- route: '/upload',
2363
- contentType: 'application/json',
2364
- render: async (ctx) => {
2365
- const data = await ctx.formData()
2366
- return JSON.stringify(data ?? null)
2367
- },
2368
- }
2369
-
2370
- await test('ctx.formData() parses multipart text fields', async () => {
2371
- const body = buildMultipart({ name: 'Alice', email: 'alice@example.com' })
2372
- await withServer([multipartBaseSpec], {}, async (port) => {
2373
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
2374
- assert(status === 200, `Expected 200, got ${status}`)
2375
- const data = JSON.parse(resBody)
2376
- assert(data.name === 'Alice', `Expected name Alice, got ${JSON.stringify(data)}`)
2377
- assert(data.email === 'alice@example.com', `Expected email, got ${JSON.stringify(data)}`)
2378
- })
2379
- })
2380
-
2381
- await test('ctx.formData() parses multipart file field', async () => {
2382
- const body = buildMultipart({
2383
- avatar: { filename: 'photo.png', type: 'image/png', content: 'PNG_BYTES_HERE' },
2384
- })
2385
- await withServer([multipartBaseSpec], {}, async (port) => {
2386
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
2387
- assert(status === 200, `Expected 200, got ${status}`)
2388
- const data = JSON.parse(resBody)
2389
- assert(data.avatar?.filename === 'photo.png', `Expected filename photo.png, got ${JSON.stringify(data.avatar)}`)
2390
- assert(data.avatar?.type === 'image/png', `Expected type image/png, got ${JSON.stringify(data.avatar)}`)
2391
- assert(typeof data.avatar?.size === 'number', `Expected numeric size, got ${JSON.stringify(data.avatar)}`)
2392
- })
2393
- })
2394
-
2395
- await test('ctx.formData() handles mixed text and file fields', async () => {
2396
- const body = buildMultipart({
2397
- title: 'My Upload',
2398
- file: { filename: 'doc.txt', type: 'text/plain', content: 'Hello world' },
2399
- })
2400
- await withServer([multipartBaseSpec], {}, async (port) => {
2401
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', body, 'multipart/form-data; boundary=TestBoundary123')
2402
- assert(status === 200, `Expected 200, got ${status}`)
2403
- const data = JSON.parse(resBody)
2404
- assert(data.title === 'My Upload', `Expected title, got ${JSON.stringify(data)}`)
2405
- assert(data.file?.filename === 'doc.txt', `Expected filename doc.txt, got ${JSON.stringify(data.file)}`)
2406
- assert(data.file?.size === 11, `Expected size 11, got ${JSON.stringify(data.file)}`)
2407
- })
2408
- })
2409
-
2410
- await test('ctx.formData() handles repeated field name as array', async () => {
2411
- // Build raw multipart with two fields sharing the same name
2412
- const boundary = 'MultiRepeat'
2413
- const raw = Buffer.concat([
2414
- Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="tag"\r\n\r\none\r\n`),
2415
- Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="tag"\r\n\r\ntwo\r\n`),
2416
- Buffer.from(`--${boundary}--\r\n`),
2417
- ])
2418
- await withServer([multipartBaseSpec], {}, async (port) => {
2419
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', raw, `multipart/form-data; boundary=${boundary}`)
2420
- assert(status === 200, `Expected 200, got ${status}`)
2421
- const data = JSON.parse(resBody)
2422
- assert(Array.isArray(data.tag), `Expected array for repeated name, got ${JSON.stringify(data.tag)}`)
2423
- assert(data.tag.includes('one') && data.tag.includes('two'), `Expected both values, got ${JSON.stringify(data.tag)}`)
2424
- })
2425
- })
2426
-
2427
- await test('ctx.formData() returns null for empty body', async () => {
2428
- await withServer([multipartBaseSpec], {}, async (port) => {
2429
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', '', 'multipart/form-data; boundary=TestBoundary123')
2430
- assert(status === 200, `Expected 200, got ${status}`)
2431
- assert(resBody === 'null', `Expected null for empty body, got ${resBody}`)
2432
- })
2433
- })
2434
-
2435
- await test('ctx.formData() still handles URL-encoded body correctly', async () => {
2436
- await withServer([multipartBaseSpec], {}, async (port) => {
2437
- const { status, body: resBody } = await requestWithBody(port, 'POST', '/upload', 'name=Bob&role=admin', 'application/x-www-form-urlencoded')
2438
- assert(status === 200, `Expected 200, got ${status}`)
2439
- const data = JSON.parse(resBody)
2440
- assert(data.name === 'Bob', `Expected name Bob, got ${JSON.stringify(data)}`)
2441
- assert(data.role === 'admin', `Expected role admin, got ${JSON.stringify(data)}`)
2442
- })
2443
- })
2444
-
2445
- // ---------------------------------------------------------------------------
2446
-
2447
- console.log('\nAnalytics (opt-in)\n')
2448
-
2449
- const TRACKER = 'https://example-analytics.test/tracker.js'
2450
-
2451
- for (const stream of [false, true]) {
2452
- await test(`no third-party script is injected by default (stream: ${stream})`, async () => {
2453
- await withServer([helloSpec], { stream }, async (port) => {
2454
- const { body } = await get(port, '/hello')
2455
- assert(!body.includes('<script defer'), 'default pages must not carry an analytics script')
2456
- })
2457
- })
2458
-
2459
- await test(`analytics option injects the script and widens CSP (stream: ${stream})`, async () => {
2460
- await withServer([helloSpec], { stream, analytics: TRACKER }, async (port) => {
2461
- const res = await get(port, '/hello')
2462
- assert(res.body.includes(`<script defer src="${TRACKER}"></script>`), 'script tag missing from head')
2463
- const csp = String(res.headers['content-security-policy'])
2464
- assert(/script-src[^;]*https:\/\/example-analytics\.test/.test(csp), 'script-src should allow the origin')
2465
- assert(/connect-src[^;]*https:\/\/example-analytics\.test/.test(csp), 'connect-src should allow the origin')
2466
- })
2467
- })
2468
- }
2469
-
2470
- // ---------------------------------------------------------------------------
2471
-
2472
- console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
2473
- finished = true
2474
- if (failed > 0) process.exit(1)