@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.
- package/.github/workflows/publish.yml +3 -2
- package/CLAUDE.md +21 -1
- package/benchmark/results/2026-03-27-09-20.json +188 -0
- package/benchmark/results/2026-03-27-09-21.json +188 -0
- package/benchmark/results/2026-03-27-09-29.json +188 -0
- package/benchmark/results/2026-03-27-10-14.json +188 -0
- package/benchmark/results/2026-03-27-10-18.json +232 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/src/pages/getting-started.js +1 -0
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/bench.js +483 -0
- package/scripts/release-version.js +56 -0
- package/src/cli/index.js +30 -0
- package/src/runtime/index.js +89 -7
- package/src/runtime/morph.test.js +317 -0
- package/src/runtime/navigate.js +132 -52
- package/src/runtime/runtime.test.js +24 -0
- package/src/runtime/ssr.js +168 -23
- package/src/runtime/ssr.test.js +255 -1
- package/src/server/index.js +88 -6
- package/src/server/server.test.js +104 -0
- package/src/spec/schema.js +32 -1
- package/src/spec/schema.test.js +55 -0
package/src/server/index.js
CHANGED
|
@@ -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() {
|
|
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
|
-
|
|
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 = {
|
package/src/spec/schema.js
CHANGED
|
@@ -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
|
|
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
|
package/src/spec/schema.test.js
CHANGED
|
@@ -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' } },
|