@invisibleloop/pulse 0.1.39 → 0.2.1

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.
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -63,6 +63,7 @@ const BASE_CSP = {
63
63
  'img-src': ["'self'", 'data:'],
64
64
  'font-src': ["'self'"],
65
65
  'connect-src': ["'self'"],
66
+ 'object-src': ["'none'"],
66
67
  'frame-ancestors':["'none'"],
67
68
  'base-uri': ["'self'"],
68
69
  'form-action': ["'self'"],
@@ -203,7 +204,10 @@ function parseMultipart(buf, boundary) {
203
204
  // ---------------------------------------------------------------------------
204
205
 
205
206
  class TtlCache {
206
- constructor() { this._store = new Map() }
207
+ constructor() {
208
+ this._store = new Map()
209
+ this._interval = null
210
+ }
207
211
 
208
212
  get(key) {
209
213
  const entry = this._store.get(key)
@@ -215,11 +219,38 @@ class TtlCache {
215
219
  set(key, value, ttlSeconds) {
216
220
  this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
217
221
  }
222
+
223
+ /** Remove all entries whose TTL has elapsed. */
224
+ purgeExpired() {
225
+ const now = Date.now()
226
+ for (const [key, entry] of this._store) {
227
+ if (now > entry.expiresAt) this._store.delete(key)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Start a background eviction timer.
233
+ * The interval is unref()'d so it never prevents the Node.js process from exiting.
234
+ *
235
+ * @param {number} intervalMs - how often to purge (default 60 000 ms)
236
+ */
237
+ startEviction(intervalMs = 60_000) {
238
+ if (this._interval) return // already running
239
+ this._interval = setInterval(() => this.purgeExpired(), intervalMs).unref()
240
+ }
241
+
242
+ /** Stop the background eviction timer (used for graceful shutdown and tests). */
243
+ stopEviction() {
244
+ if (this._interval) { clearInterval(this._interval); this._interval = null }
245
+ }
218
246
  }
219
247
 
220
248
  const serverDataCache = new TtlCache()
221
249
  const pageHtmlCache = new TtlCache()
222
250
 
251
+ serverDataCache.startEviction()
252
+ pageHtmlCache.startEviction()
253
+
223
254
  // ---------------------------------------------------------------------------
224
255
  // Cache-Control builder
225
256
  // ---------------------------------------------------------------------------
@@ -395,6 +426,7 @@ export function createServer(specs, options = {}) {
395
426
 
396
427
  // Per-host brand cache — avoids hitting the data store on every request
397
428
  const brandCache = new TtlCache()
429
+ brandCache.startEviction(30_000) // brand TTL is 60s, scan every 30s
398
430
 
399
431
  // Load manifest — maps source hydrate paths to production bundle paths
400
432
  const hydrateMap = loadManifest(manifest, staticDir)
@@ -567,16 +599,20 @@ export function createServer(specs, options = {}) {
567
599
  return
568
600
  }
569
601
 
570
- // Client-side navigation request — return JSON fragment, not a full document
602
+ // Client-side navigation request — return JSON fragment (or NDJSON stream), not a full document
571
603
  if (req.headers['x-pulse-navigate'] === 'true') {
572
- await handleNavResponse(spec, ctx, res, dev)
604
+ if (stream) {
605
+ await handleNavStreamResponse(spec, ctx, res)
606
+ } else {
607
+ await handleNavResponse(spec, ctx, res, dev)
608
+ }
573
609
  return
574
610
  }
575
611
 
576
612
  if (stream) {
577
613
  await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
578
614
  } else {
579
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp)
615
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
580
616
  }
581
617
 
582
618
  } catch (err) {
@@ -622,6 +658,11 @@ export function createServer(specs, options = {}) {
622
658
 
623
659
  console.log('⚡ Pulse shutting down gracefully…')
624
660
 
661
+ // Stop background cache eviction timers
662
+ serverDataCache.stopEviction()
663
+ pageHtmlCache.stopEviction()
664
+ brandCache.stopEviction()
665
+
625
666
  // Stop accepting new connections; exit once all connections are closed
626
667
  server.close(() => process.exit(0))
627
668
 
@@ -683,11 +724,43 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
683
724
  res.end(payload)
684
725
  }
685
726
 
727
+ /**
728
+ * Client-side navigation — streaming NDJSON variant.
729
+ * Sends meta immediately, then streams shell HTML and deferred segments
730
+ * as their server data resolves. The browser applies chunks progressively,
731
+ * showing shell content without waiting for slower deferred fetchers.
732
+ */
733
+ async function handleNavStreamResponse(spec, ctx, res) {
734
+ const meta = resolveMeta(spec.meta, ctx)
735
+
736
+ res.writeHead(200, {
737
+ 'Content-Type': 'application/x-ndjson',
738
+ 'Cache-Control': 'no-store',
739
+ 'X-Accel-Buffering': 'no',
740
+ ...SECURITY_HEADERS,
741
+ })
742
+
743
+ const navStream = renderToNavStream(spec, ctx, meta)
744
+ const reader = navStream.getReader()
745
+
746
+ try {
747
+ while (true) {
748
+ const { done, value } = await reader.read()
749
+ if (done) break
750
+ res.write(value)
751
+ }
752
+ } catch {
753
+ // Headers already sent — cannot change status, just end cleanly
754
+ } finally {
755
+ res.end()
756
+ }
757
+ }
758
+
686
759
  /**
687
760
  * Render to a complete string then send — simpler, easier to cache.
688
761
  * Checks the in-process page cache before rendering; stores result after.
689
762
  */
690
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
763
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
691
764
  const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
692
765
  // Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
693
766
  const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
@@ -773,6 +846,15 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
773
846
  ? (canonicalRaw(ctx) || canonicalBase)
774
847
  : (canonicalRaw || canonicalBase)
775
848
 
849
+ // 103 Early Hints — browser starts fetching CSS/JS while server resolves data
850
+ const earlyLinks = [
851
+ ...(meta.styles || []).map(href => `<${href}>; rel=preload; as=style`),
852
+ ...(runtimeBundle && spec.hydrate?.startsWith('/dist/') ? [`<${runtimeBundle}>; rel=modulepreload; as=script`] : []),
853
+ ]
854
+ if (earlyLinks.length > 0) {
855
+ try { res.writeEarlyHints({ link: earlyLinks }) } catch {}
856
+ }
857
+
776
858
  const stylePreloads = (meta.styles || [])
777
859
  .map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
778
860
  .join('\n')
@@ -397,6 +397,110 @@ await test('normal request still returns full HTML document', async () => {
397
397
 
398
398
  // ---------------------------------------------------------------------------
399
399
 
400
+ const streamNavSpec = {
401
+ route: '/stream-nav',
402
+ hydrate: '/examples/stream-nav.js',
403
+ state: {},
404
+ meta: { title: 'Stream Nav', styles: ['/app.css'] },
405
+ server: { msg: async () => 'streamed hello' },
406
+ view: (_s, server) => `<main id="main-content">${server.msg}</main>`
407
+ }
408
+
409
+ const deferredNavSpec = {
410
+ route: '/deferred-nav',
411
+ hydrate: '/examples/deferred-nav.js',
412
+ state: {},
413
+ meta: { title: 'Deferred Nav' },
414
+ server: {
415
+ fast: async () => 'shell-data',
416
+ slow: async () => { await new Promise(r => setTimeout(r, 30)); return 'deferred-data' },
417
+ },
418
+ stream: {
419
+ shell: ['header'],
420
+ deferred: ['feed'],
421
+ scope: { header: ['fast'], feed: ['slow'] },
422
+ },
423
+ view: {
424
+ header: (_s, srv) => `<header>${srv.fast}</header>`,
425
+ feed: (_s, srv) => `<aside>${srv.slow}</aside>`,
426
+ },
427
+ }
428
+
429
+ console.log('\nClient-side navigation — streaming (stream: true)\n')
430
+
431
+ await test('X-Pulse-Navigate with stream:true returns application/x-ndjson', async () => {
432
+ await withServer([streamNavSpec], {}, async (port) => {
433
+ const { status, headers } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
434
+ assert(status === 200, `Expected 200, got ${status}`)
435
+ assert(headers['content-type']?.includes('application/x-ndjson'), `Expected NDJSON: ${headers['content-type']}`)
436
+ })
437
+ })
438
+
439
+ await test('streaming nav: first NDJSON line is a meta message with title and hydrate', async () => {
440
+ await withServer([streamNavSpec], {}, async (port) => {
441
+ const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
442
+ const lines = body.trim().split('\n').filter(Boolean)
443
+ const meta = JSON.parse(lines[0])
444
+ assert(meta.type === 'meta', `First line should be meta, got ${meta.type}`)
445
+ assert(meta.title === 'Stream Nav', `Expected title 'Stream Nav', got ${meta.title}`)
446
+ assert(meta.hydrate === '/examples/stream-nav.js', `Expected hydrate, got ${meta.hydrate}`)
447
+ assert(meta.styles[0] === '/app.css', `Expected styles, got ${JSON.stringify(meta.styles)}`)
448
+ })
449
+ })
450
+
451
+ await test('streaming nav: html message contains rendered server data', async () => {
452
+ await withServer([streamNavSpec], {}, async (port) => {
453
+ const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
454
+ const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
455
+ const htmlMsg = messages.find(m => m.type === 'html')
456
+ assert(htmlMsg, 'should have html message')
457
+ assert(htmlMsg.html.includes('streamed hello'), `Expected server data in html: ${htmlMsg.html}`)
458
+ assert(!htmlMsg.html.includes('<!DOCTYPE'), 'html should not be a full document')
459
+ })
460
+ })
461
+
462
+ await test('streaming nav: done message contains serverState', async () => {
463
+ await withServer([streamNavSpec], {}, async (port) => {
464
+ const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
465
+ const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
466
+ const done = messages.find(m => m.type === 'done')
467
+ assert(done, 'should have done message')
468
+ assert(done.serverState?.msg === 'streamed hello', `Expected serverState.msg, got ${JSON.stringify(done.serverState)}`)
469
+ })
470
+ })
471
+
472
+ await test('streaming nav: deferred spec sends shell then deferred then done', async () => {
473
+ await withServer([deferredNavSpec], {}, async (port) => {
474
+ const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
475
+ const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
476
+ const types = messages.map(m => m.type)
477
+ assert(types[0] === 'meta', `First should be meta, got ${types[0]}`)
478
+ assert(types.includes('html'), 'should include html')
479
+ assert(types.includes('deferred'), 'should include deferred')
480
+ assert(types[types.length - 1] === 'done', 'last should be done')
481
+
482
+ const deferred = messages.find(m => m.type === 'deferred')
483
+ assert(deferred?.id === 'feed', `Expected deferred id 'feed', got ${deferred?.id}`)
484
+ assert(deferred?.html.includes('deferred-data'), `Expected deferred content, got: ${deferred?.html}`)
485
+
486
+ // Shell arrives before deferred: html index should precede deferred index
487
+ const htmlIdx = types.indexOf('html')
488
+ const deferredIdx = types.indexOf('deferred')
489
+ assert(htmlIdx < deferredIdx, `html (${htmlIdx}) should precede deferred (${deferredIdx})`)
490
+ })
491
+ })
492
+
493
+ await test('streaming nav: html includes <pulse-deferred> placeholder for deferred segments', async () => {
494
+ await withServer([deferredNavSpec], {}, async (port) => {
495
+ const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
496
+ const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
497
+ const html = messages.find(m => m.type === 'html')
498
+ assert(html?.html.includes('pd-feed'), `Shell html should include placeholder, got: ${html?.html}`)
499
+ })
500
+ })
501
+
502
+ // ---------------------------------------------------------------------------
503
+
400
504
  console.log('\nError pages\n')
401
505
 
402
506
  const throwingSpec = {
@@ -18,8 +18,16 @@
18
18
  * Shell segments render immediately on first flush.
19
19
  * Deferred segments render after their server data resolves.
20
20
  *
21
- * @property {string[]} shell - Segment keys to render in the first flush
21
+ * @property {string[]} shell - Segment keys to render in the first flush
22
22
  * @property {string[]} [deferred] - Segment keys to render after async data
23
+ * @property {Object.<string, string[]>} [scope]
24
+ * Optional per-segment fetcher scoping. Maps each segment name to the server
25
+ * keys it needs. When declared, the shell only awaits its own fetchers and
26
+ * each deferred segment streams as soon as its own fetchers resolve — fetchers
27
+ * scoped to deferred segments never block the shell.
28
+ *
29
+ * Example: { header: ['user'], feed: ['posts', 'ads'] }
30
+ * Segments not listed in scope receive all server state (safe default).
23
31
  */
24
32
 
25
33
  /**
@@ -190,6 +198,29 @@ export function validateSpec(spec) {
190
198
  }
191
199
  }
192
200
  }
201
+ // validate scope annotations
202
+ if (spec.stream.scope !== undefined) {
203
+ if (typeof spec.stream.scope !== 'object' || Array.isArray(spec.stream.scope)) {
204
+ errors.push('spec.stream.scope must be a plain object mapping segment names to fetcher key arrays')
205
+ } else {
206
+ const allSegments = new Set([...(spec.stream.shell || []), ...(spec.stream.deferred || [])])
207
+ const serverKeys = new Set(Object.keys(spec.server || {}))
208
+ for (const [seg, keys] of Object.entries(spec.stream.scope)) {
209
+ if (!allSegments.has(seg)) {
210
+ errors.push(`spec.stream.scope references unknown segment "${seg}"`)
211
+ }
212
+ if (!Array.isArray(keys)) {
213
+ errors.push(`spec.stream.scope["${seg}"] must be an array of server fetcher key strings`)
214
+ } else {
215
+ for (const key of keys) {
216
+ if (!serverKeys.has(key)) {
217
+ errors.push(`spec.stream.scope["${seg}"] references unknown server key "${key}"`)
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
193
224
  }
194
225
 
195
226
  // server
@@ -103,6 +103,61 @@ test('rejects stream.shell that is not an array', () => {
103
103
  )
104
104
  })
105
105
 
106
+ test('accepts valid stream.scope', () => {
107
+ const spec = {
108
+ route: '/s',
109
+ state: {},
110
+ server: { user: async () => {}, posts: async () => {} },
111
+ stream: {
112
+ shell: ['header'],
113
+ deferred: ['feed'],
114
+ scope: { header: ['user'], feed: ['posts'] },
115
+ },
116
+ view: { header: () => '', feed: () => '' },
117
+ }
118
+ const { valid } = validateSpec(spec)
119
+ assert(valid, 'Expected valid spec with stream.scope')
120
+ })
121
+
122
+ test('rejects stream.scope referencing unknown segment', () => {
123
+ assertErrors(
124
+ {
125
+ route: '/s',
126
+ state: {},
127
+ server: { user: async () => {} },
128
+ stream: { shell: ['header'], scope: { unknown: ['user'] } },
129
+ view: { header: () => '' },
130
+ },
131
+ 'spec.stream.scope references unknown segment "unknown"'
132
+ )
133
+ })
134
+
135
+ test('rejects stream.scope referencing unknown server key', () => {
136
+ assertErrors(
137
+ {
138
+ route: '/s',
139
+ state: {},
140
+ server: { user: async () => {} },
141
+ stream: { shell: ['header'], scope: { header: ['missing'] } },
142
+ view: { header: () => '' },
143
+ },
144
+ 'spec.stream.scope["header"] references unknown server key "missing"'
145
+ )
146
+ })
147
+
148
+ test('rejects stream.scope with non-array value', () => {
149
+ assertErrors(
150
+ {
151
+ route: '/s',
152
+ state: {},
153
+ server: { user: async () => {} },
154
+ stream: { shell: ['header'], scope: { header: 'user' } },
155
+ view: { header: () => '' },
156
+ },
157
+ 'spec.stream.scope["header"] must be an array'
158
+ )
159
+ })
160
+
106
161
  test('rejects mutations with non-function values', () => {
107
162
  assertErrors(
108
163
  { route: '/x', state: {}, view: () => '', mutations: { inc: 'not a function' } },