@invisibleloop/pulse 0.15.1 → 0.17.0
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/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +35 -17
- package/.claude/pulse-checklist.md +11 -11
- package/CLAUDE.md +13 -4
- package/docs/.claude/pulse-checklist.md +1 -1
- package/examples/.claude/pulse-checklist.md +6 -4
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/agent/checklist.md +1 -1
- package/src/agent/commands/verify.md +62 -32
- package/src/agent/guide-design-gallery.md +7 -5
- package/src/agent/guide-design-references.md +2 -2
- package/src/agent/guide-routing.md +91 -0
- package/src/agent/identity.md +21 -6
- package/src/agent/workflow.md +4 -3
- package/src/html.js +1 -0
- package/src/mcp/mcp.test.js +219 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/validate-worker.js +18 -0
- package/src/md/index.js +29 -2
- package/src/md/md.test.js +36 -0
- package/src/runtime/ssr.js +1 -0
- package/src/runtime/tt.js +8 -0
- package/src/server/index.js +87 -20
- package/src/server/server.test.js +106 -1
- package/src/spec/schema.js +10 -0
package/src/server/index.js
CHANGED
|
@@ -119,16 +119,25 @@ function negotiateEncoding(req) {
|
|
|
119
119
|
return null
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Brotli quality for on-the-fly (per-request) compression. The library default
|
|
123
|
+
// is 11 — the maximum, intended for build-time compression of static assets.
|
|
124
|
+
// At q11 each dynamic response costs ~10x the CPU of q5 for a marginal size gain
|
|
125
|
+
// on HTML, inflating TTFB and limiting throughput. q5 matches what CDNs/nginx use
|
|
126
|
+
// for dynamic content. Pre-built /dist/* bundles should be compressed at build time.
|
|
127
|
+
const BROTLI_DYNAMIC = {
|
|
128
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
/** Compress a Buffer using the given encoding. Returns the original if none. */
|
|
123
132
|
async function compressBuffer(buf, encoding) {
|
|
124
|
-
if (encoding === 'br') return brotliAsync(buf)
|
|
133
|
+
if (encoding === 'br') return brotliAsync(buf, BROTLI_DYNAMIC)
|
|
125
134
|
if (encoding === 'gzip') return gzipAsync(buf)
|
|
126
135
|
return buf
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
/** Create a transform stream for the given encoding, or null. */
|
|
130
139
|
function createCompressor(encoding) {
|
|
131
|
-
if (encoding === 'br') return zlib.createBrotliCompress()
|
|
140
|
+
if (encoding === 'br') return zlib.createBrotliCompress(BROTLI_DYNAMIC)
|
|
132
141
|
if (encoding === 'gzip') return zlib.createGzip()
|
|
133
142
|
return null
|
|
134
143
|
}
|
|
@@ -211,21 +220,38 @@ function parseMultipart(buf, boundary) {
|
|
|
211
220
|
// In-process TTL cache — server data memoization
|
|
212
221
|
// ---------------------------------------------------------------------------
|
|
213
222
|
|
|
214
|
-
class TtlCache {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
export class TtlCache {
|
|
224
|
+
/**
|
|
225
|
+
* @param {number} [maxEntries=1000] - hard cap on stored entries. When exceeded,
|
|
226
|
+
* the least-recently-used entry is evicted. Bounds memory so cache keys derived
|
|
227
|
+
* from attacker-controlled input (query strings, Host header) cannot grow the
|
|
228
|
+
* cache without limit.
|
|
229
|
+
*/
|
|
230
|
+
constructor(maxEntries = 1000) {
|
|
231
|
+
this._store = new Map() // Map preserves insertion order → used for LRU
|
|
232
|
+
this._interval = null
|
|
233
|
+
this._maxEntries = maxEntries
|
|
218
234
|
}
|
|
219
235
|
|
|
220
236
|
get(key) {
|
|
221
237
|
const entry = this._store.get(key)
|
|
222
238
|
if (!entry) return undefined
|
|
223
239
|
if (Date.now() > entry.expiresAt) { this._store.delete(key); return undefined }
|
|
240
|
+
// Mark as most-recently-used: re-insert to move to the end of the Map.
|
|
241
|
+
this._store.delete(key)
|
|
242
|
+
this._store.set(key, entry)
|
|
224
243
|
return entry.value
|
|
225
244
|
}
|
|
226
245
|
|
|
227
246
|
set(key, value, ttlSeconds) {
|
|
247
|
+
// Refresh position if it already exists so LRU ordering stays correct.
|
|
248
|
+
this._store.delete(key)
|
|
228
249
|
this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
|
|
250
|
+
// Evict the least-recently-used entry (first in insertion order) when over cap.
|
|
251
|
+
if (this._store.size > this._maxEntries) {
|
|
252
|
+
const oldest = this._store.keys().next().value
|
|
253
|
+
this._store.delete(oldest)
|
|
254
|
+
}
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
/** Remove all entries whose TTL has elapsed. */
|
|
@@ -308,9 +334,27 @@ function pageCacheTtl(spec, dev, defaultCache) {
|
|
|
308
334
|
return cfg?.maxAge || 0
|
|
309
335
|
}
|
|
310
336
|
|
|
311
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Build the in-process page cache key.
|
|
339
|
+
*
|
|
340
|
+
* The key is scoped to the route + path params + only the query keys the spec
|
|
341
|
+
* explicitly opts into via `spec.cacheVary`. The full query string is NOT part
|
|
342
|
+
* of the key by default: an unauthenticated client can append arbitrary query
|
|
343
|
+
* params (?n=1, ?n=2, …) that don't change a cacheable page's output, and
|
|
344
|
+
* including them would let that client mint unbounded distinct cache entries
|
|
345
|
+
* (memory-exhaustion DoS). Pages whose output genuinely depends on a query
|
|
346
|
+
* param declare it in `cacheVary: ['q']`.
|
|
347
|
+
*/
|
|
312
348
|
function buildCacheKey(spec, ctx) {
|
|
313
|
-
|
|
349
|
+
let varied = ''
|
|
350
|
+
if (Array.isArray(spec.cacheVary) && spec.cacheVary.length) {
|
|
351
|
+
const picked = {}
|
|
352
|
+
for (const k of spec.cacheVary) {
|
|
353
|
+
if (ctx.query[k] !== undefined) picked[k] = ctx.query[k]
|
|
354
|
+
}
|
|
355
|
+
varied = JSON.stringify(picked)
|
|
356
|
+
}
|
|
357
|
+
return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + varied
|
|
314
358
|
}
|
|
315
359
|
|
|
316
360
|
/**
|
|
@@ -517,8 +561,10 @@ export async function createServer(entries, options = {}) {
|
|
|
517
561
|
}
|
|
518
562
|
}
|
|
519
563
|
|
|
520
|
-
// Per-host brand cache — avoids hitting the data store on every request
|
|
521
|
-
|
|
564
|
+
// Per-host brand cache — avoids hitting the data store on every request.
|
|
565
|
+
// Capped (Host header is attacker-controlled, so unbounded distinct keys
|
|
566
|
+
// would otherwise be a memory-exhaustion vector).
|
|
567
|
+
const brandCache = new TtlCache(500)
|
|
522
568
|
brandCache.startEviction(30_000) // brand TTL is 60s, scan every 30s
|
|
523
569
|
|
|
524
570
|
// Load manifest — maps source hydrate paths to production bundle paths
|
|
@@ -554,14 +600,19 @@ export async function createServer(entries, options = {}) {
|
|
|
554
600
|
// Build route table — let so it can be swapped on hot reload
|
|
555
601
|
let router = buildRouter(specs)
|
|
556
602
|
|
|
603
|
+
// Resolve the logger module once at startup rather than dynamically importing
|
|
604
|
+
// it on every request's `finish` event. `logRequest` stays null until loaded.
|
|
605
|
+
let logRequest = null
|
|
606
|
+
if (!quiet) {
|
|
607
|
+
import('../cli/logger.js').then(m => { logRequest = m.request }).catch(() => {})
|
|
608
|
+
}
|
|
609
|
+
|
|
557
610
|
const server = http.createServer(async (req, res) => {
|
|
558
611
|
const reqStart = Date.now()
|
|
559
612
|
res.on('finish', () => {
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
564
|
-
}).catch(() => {})
|
|
613
|
+
if (logRequest) {
|
|
614
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
615
|
+
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
565
616
|
}
|
|
566
617
|
})
|
|
567
618
|
try {
|
|
@@ -691,6 +742,13 @@ export async function createServer(entries, options = {}) {
|
|
|
691
742
|
// Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
|
|
692
743
|
// spec.meta.canonical (string or function) is resolved inside each handler, where serverState
|
|
693
744
|
// is available — allowing dynamic canonicals from server fetcher results.
|
|
745
|
+
//
|
|
746
|
+
// SECURITY: proto/host come from client-controllable headers (x-forwarded-*,
|
|
747
|
+
// Host). They are HTML-escaped before being emitted into <link rel=canonical>
|
|
748
|
+
// and og:* tags, so this is not an XSS vector — but if your edge/proxy does
|
|
749
|
+
// not pin or validate these headers, an attacker can influence the canonical
|
|
750
|
+
// URL (SEO/canonical poisoning). Trust x-forwarded-* only behind a proxy you
|
|
751
|
+
// control; set spec.meta.canonical to a fixed string to opt out entirely.
|
|
694
752
|
const proto = req.headers['x-forwarded-proto'] || 'http'
|
|
695
753
|
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
|
|
696
754
|
const canonicalPath = trailingSlash === 'add' && pathname !== '/'
|
|
@@ -1112,7 +1170,7 @@ async function handleRawResponse(spec, ctx, req, res, dev) {
|
|
|
1112
1170
|
let content
|
|
1113
1171
|
|
|
1114
1172
|
if (!dev && spec.serverTtl) {
|
|
1115
|
-
const key = 'raw:' + spec
|
|
1173
|
+
const key = 'raw:' + buildCacheKey(spec, ctx)
|
|
1116
1174
|
content = serverDataCache.get(key)
|
|
1117
1175
|
if (!content) {
|
|
1118
1176
|
const serverState = await resolveServerState(spec, ctx)
|
|
@@ -1181,9 +1239,15 @@ function matchRoute(router, pathname) {
|
|
|
1181
1239
|
if (!match) continue
|
|
1182
1240
|
|
|
1183
1241
|
const params = {}
|
|
1184
|
-
|
|
1185
|
-
params
|
|
1186
|
-
|
|
1242
|
+
try {
|
|
1243
|
+
route.params.forEach((name, i) => {
|
|
1244
|
+
params[name] = decodeURIComponent(match[i + 1])
|
|
1245
|
+
})
|
|
1246
|
+
} catch {
|
|
1247
|
+
// Malformed percent-encoding (e.g. /%ZZ) — treat as no match (404)
|
|
1248
|
+
// rather than letting decodeURIComponent throw a 500.
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1187
1251
|
|
|
1188
1252
|
return { spec: route.spec, params }
|
|
1189
1253
|
}
|
|
@@ -1433,7 +1497,9 @@ function resolveSpec(spec, hydrateMap) {
|
|
|
1433
1497
|
*/
|
|
1434
1498
|
function serveStatic(req, res, staticDir, dev = false) {
|
|
1435
1499
|
const url = new URL(req.url, 'http://localhost')
|
|
1436
|
-
|
|
1500
|
+
let pathname
|
|
1501
|
+
try { pathname = decodeURIComponent(url.pathname) }
|
|
1502
|
+
catch { return false } // malformed percent-encoding → not a static file
|
|
1437
1503
|
|
|
1438
1504
|
// Prevent directory traversal
|
|
1439
1505
|
const filePath = path.join(staticDir, pathname)
|
|
@@ -1516,6 +1582,7 @@ function escHtml(str) {
|
|
|
1516
1582
|
.replace(/</g, '<')
|
|
1517
1583
|
.replace(/>/g, '>')
|
|
1518
1584
|
.replace(/"/g, '"')
|
|
1585
|
+
.replace(/'/g, ''')
|
|
1519
1586
|
}
|
|
1520
1587
|
|
|
1521
1588
|
function notFoundHtml(pathname) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'http'
|
|
7
|
-
import { createServer } from './index.js'
|
|
7
|
+
import { createServer, TtlCache } from './index.js'
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Test runner
|
|
@@ -1246,6 +1246,111 @@ await test('health check fires before route matching — shadows a user spec at
|
|
|
1246
1246
|
|
|
1247
1247
|
// ---------------------------------------------------------------------------
|
|
1248
1248
|
|
|
1249
|
+
console.log('\nMalformed request handling\n')
|
|
1250
|
+
|
|
1251
|
+
await test('malformed percent-encoding in path returns 404, not 500', async () => {
|
|
1252
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1253
|
+
const { status } = await get(port, '/%ZZ')
|
|
1254
|
+
assert(status === 404, `Expected 404 for malformed encoding, got ${status}`)
|
|
1255
|
+
})
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
await test('malformed percent-encoding in param route returns 404', async () => {
|
|
1259
|
+
await withServer([productSpec], { stream: false }, async (port) => {
|
|
1260
|
+
const { status } = await get(port, '/products/%E0%A4%A')
|
|
1261
|
+
assert(status === 404, `Expected 404 for malformed param, got ${status}`)
|
|
1262
|
+
})
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
|
|
1267
|
+
console.log('\nPage cache — query-string DoS guard\n')
|
|
1268
|
+
|
|
1269
|
+
await test('query string is NOT part of the cache key by default', async () => {
|
|
1270
|
+
// A cacheable page should serve identical cached HTML regardless of arbitrary
|
|
1271
|
+
// query params — they must not mint new cache entries.
|
|
1272
|
+
let renders = 0
|
|
1273
|
+
const spec = {
|
|
1274
|
+
route: '/cached',
|
|
1275
|
+
cache: { public: true, maxAge: 60 },
|
|
1276
|
+
view: () => `<h1>render-${++renders}</h1>`,
|
|
1277
|
+
}
|
|
1278
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
1279
|
+
const a = await get(port, '/cached?n=1')
|
|
1280
|
+
const b = await get(port, '/cached?n=2')
|
|
1281
|
+
const c = await get(port, '/cached?n=3')
|
|
1282
|
+
assert(a.body === b.body && b.body === c.body, 'Cached body should be identical across query strings')
|
|
1283
|
+
assert(a.body.includes('render-1'), `Expected first render cached, got: ${a.body}`)
|
|
1284
|
+
})
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
await test('cacheVary keys DO produce distinct cache entries', async () => {
|
|
1288
|
+
let renders = 0
|
|
1289
|
+
const spec = {
|
|
1290
|
+
route: '/varied',
|
|
1291
|
+
cache: { public: true, maxAge: 60 },
|
|
1292
|
+
cacheVary: ['q'],
|
|
1293
|
+
server: { n: async () => ++renders },
|
|
1294
|
+
view: (_s, server) => `<h1>v-${server.n}</h1>`,
|
|
1295
|
+
}
|
|
1296
|
+
// server fetcher makes this non-cacheable for HTML, so vary only affects
|
|
1297
|
+
// the in-process key; assert distinct outputs per q value.
|
|
1298
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
1299
|
+
const a = await get(port, '/varied?q=apple')
|
|
1300
|
+
const b = await get(port, '/varied?q=banana')
|
|
1301
|
+
assert(a.body !== b.body, `Expected distinct outputs per cacheVary value, got identical: ${a.body}`)
|
|
1302
|
+
})
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
console.log('\nTtlCache — bounded LRU\n')
|
|
1308
|
+
|
|
1309
|
+
await test('evicts least-recently-used entry when over capacity', () => {
|
|
1310
|
+
const c = new TtlCache(2)
|
|
1311
|
+
c.set('a', 1, 60)
|
|
1312
|
+
c.set('b', 2, 60)
|
|
1313
|
+
c.set('c', 3, 60) // exceeds cap → evicts 'a' (LRU)
|
|
1314
|
+
assert(c.get('a') === undefined, 'Expected a to be evicted')
|
|
1315
|
+
assert(c.get('b') === 2, 'Expected b to remain')
|
|
1316
|
+
assert(c.get('c') === 3, 'Expected c to remain')
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
await test('get() refreshes recency so the touched entry survives eviction', () => {
|
|
1320
|
+
const c = new TtlCache(2)
|
|
1321
|
+
c.set('a', 1, 60)
|
|
1322
|
+
c.set('b', 2, 60)
|
|
1323
|
+
c.get('a') // 'a' now most-recently-used
|
|
1324
|
+
c.set('c', 3, 60) // evicts 'b', not 'a'
|
|
1325
|
+
assert(c.get('a') === 1, 'Expected a to survive (recently used)')
|
|
1326
|
+
assert(c.get('b') === undefined, 'Expected b to be evicted')
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
await test('many distinct keys never exceed the cap', () => {
|
|
1330
|
+
const c = new TtlCache(100)
|
|
1331
|
+
for (let i = 0; i < 10_000; i++) c.set(`k${i}`, i, 60)
|
|
1332
|
+
assert(c._store.size === 100, `Expected size capped at 100, got ${c._store.size}`)
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
await test('expired entries are not returned', () => {
|
|
1336
|
+
const c = new TtlCache(10)
|
|
1337
|
+
c.set('x', 1, -1) // already expired
|
|
1338
|
+
assert(c.get('x') === undefined, 'Expected expired entry to be absent')
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// ---------------------------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
console.log('\nCompression\n')
|
|
1344
|
+
|
|
1345
|
+
await test('serves brotli-compressed HTML when br is accepted', async () => {
|
|
1346
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1347
|
+
const { headers } = await request(port, 'GET', '/hello', { 'Accept-Encoding': 'br' })
|
|
1348
|
+
assert(headers['content-encoding'] === 'br', `Expected br encoding, got ${headers['content-encoding']}`)
|
|
1349
|
+
})
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
// ---------------------------------------------------------------------------
|
|
1353
|
+
|
|
1249
1354
|
console.log('\nGraceful shutdown\n')
|
|
1250
1355
|
|
|
1251
1356
|
await test('shutdown() is returned from createServer', async () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -371,6 +371,16 @@ export function validateSpec(spec) {
|
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// cacheVary — query-string keys that affect the cached HTML for this page.
|
|
375
|
+
// By default the in-process page cache ignores the query string entirely (so a
|
|
376
|
+
// client cannot mint unbounded cache entries with arbitrary ?n=… params). Pages
|
|
377
|
+
// whose output depends on a query param must list it here, e.g. cacheVary: ['q'].
|
|
378
|
+
if (spec.cacheVary !== undefined) {
|
|
379
|
+
if (!Array.isArray(spec.cacheVary) || !spec.cacheVary.every(k => typeof k === 'string')) {
|
|
380
|
+
errors.push('spec.cacheVary must be an array of query-parameter name strings, e.g. [\'q\', \'page\']')
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
374
384
|
// hydrate — auto-derived by the framework, should never be set manually
|
|
375
385
|
if (spec.hydrate !== undefined) {
|
|
376
386
|
warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
|