@invisibleloop/pulse 0.16.0 → 0.17.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.
@@ -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
- constructor() {
216
- this._store = new Map()
217
- this._interval = null
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
- /** Build the in-process page cache key scoped to route + request parameters. */
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
- return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
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
- const brandCache = new TtlCache()
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 (!quiet) {
561
- import('../cli/logger.js').then(({ request: logRequest }) => {
562
- const url = new URL(req.url, `http://localhost:${port}`)
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.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
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
- route.params.forEach((name, i) => {
1185
- params[name] = decodeURIComponent(match[i + 1])
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
- const pathname = decodeURIComponent(url.pathname)
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, '&lt;')
1517
1583
  .replace(/>/g, '&gt;')
1518
1584
  .replace(/"/g, '&quot;')
1585
+ .replace(/'/g, '&#39;')
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 () => {
@@ -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.')