@invisibleloop/pulse 0.20.30 → 0.20.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.30",
3
+ "version": "0.20.32",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.30
1
+ 0.20.32
@@ -1235,11 +1235,13 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
1235
1235
  storeState: ctx.store && Object.keys(ctx.store).length > 0 ? ctx.store : undefined,
1236
1236
  })
1237
1237
 
1238
- res.writeHead(200, {
1238
+ // mergeCtxHeaders so cookies/headers queued on ctx during the request (the CSRF cookie, anything a
1239
+ // guard or fetcher sets) reach the browser on client-side navigations, not just on full page loads.
1240
+ res.writeHead(200, mergeCtxHeaders(ctx, {
1239
1241
  'Content-Type': 'application/json',
1240
1242
  'Cache-Control': buildCacheControl(spec, dev),
1241
1243
  ...SECURITY_HEADERS,
1242
- })
1244
+ }))
1243
1245
  res.end(payload)
1244
1246
  }
1245
1247
 
@@ -1252,12 +1254,15 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
1252
1254
  async function handleNavStreamResponse(spec, ctx, req, res) {
1253
1255
  const meta = await resolveMeta(spec.meta, ctx)
1254
1256
 
1255
- res.writeHead(200, {
1257
+ // Headers go out before the stream renders, so this merges what is already queued on ctx — the CSRF
1258
+ // cookie, guard output, meta functions. A cookie set by a `server` fetcher runs later, inside the
1259
+ // stream, and can no longer be sent; set cookies in `guard` for streamed pages.
1260
+ res.writeHead(200, mergeCtxHeaders(ctx, {
1256
1261
  'Content-Type': 'application/x-ndjson',
1257
1262
  'Cache-Control': 'no-store',
1258
1263
  'X-Accel-Buffering': 'no',
1259
1264
  ...SECURITY_HEADERS,
1260
- })
1265
+ }))
1261
1266
 
1262
1267
  const navStream = renderToNavStream(spec, ctx, meta)
1263
1268
  const reader = navStream.getReader()
@@ -32,6 +32,34 @@ function assert(condition, msg) {
32
32
  if (!condition) throw new Error(msg || 'Assertion failed')
33
33
  }
34
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
+
35
63
  // ---------------------------------------------------------------------------
36
64
  // HTTP helpers
37
65
  // ---------------------------------------------------------------------------
@@ -1524,6 +1552,81 @@ await test('submit pages are excluded from the in-process page cache', async ()
1524
1552
  })
1525
1553
  })
1526
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
+
1527
1630
  // ---------------------------------------------------------------------------
1528
1631
 
1529
1632
  console.log('\nDeclarative redirects\n')
@@ -2140,31 +2243,49 @@ await test('shutdown() is returned from createServer', async () => {
2140
2243
  await new Promise(resolve => result.server.close(resolve))
2141
2244
  })
2142
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.
2143
2249
  await test('shutdown() stops the server from accepting new connections', async () => {
2144
- const port = nextPort++
2145
- const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
2146
- await new Promise(resolve => server.once('listening', resolve))
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))
2147
2255
 
2148
- // Confirm server is up
2149
- const before = await get(port, '/hello')
2150
- assert(before.status === 200, `Expected 200 before shutdown, got ${before.status}`)
2256
+ // Confirm server is up
2257
+ const before = await get(port, '/hello')
2258
+ assert(before.status === 200, `Expected 200 before shutdown, got ${before.status}`)
2151
2259
 
2152
- shutdown()
2260
+ shutdown()
2153
2261
 
2154
- // Server should no longer accept connections
2155
- await new Promise(resolve => setTimeout(resolve, 20))
2156
- let refused = false
2157
- try { await get(port, '/hello') } catch { refused = true }
2158
- assert(refused, 'Expected connection to be refused after shutdown()')
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
+ }
2159
2273
  })
2160
2274
 
2161
2275
  await test('shutdown() is idempotent — calling twice does not throw', async () => {
2162
- const port = nextPort++
2163
- const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
2164
- await new Promise(resolve => server.once('listening', resolve))
2165
- shutdown()
2166
- shutdown() // second call is a no-op
2167
- await new Promise(resolve => setTimeout(resolve, 20))
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
+ }
2168
2289
  })
2169
2290
 
2170
2291
  await test('in-flight request completes after shutdown() is called', async () => {
@@ -2179,22 +2300,33 @@ await test('in-flight request completes after shutdown() is called', async () =>
2179
2300
  view: (_s, server) => `<p>${server.data}</p>`
2180
2301
  }
2181
2302
 
2182
- const { server, shutdown } = await createServer([slowSpec], { port, stream: false })
2183
- await new Promise(resolve => server.once('listening', resolve))
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))
2184
2307
 
2185
- // Start a request but don't resolve its server fetch yet
2186
- const pending = get(port, '/slow')
2308
+ // Start a request but don't resolve its server fetch yet
2309
+ const pending = get(port, '/slow')
2187
2310
 
2188
- // Small delay to ensure the request is in flight before shutdown
2189
- await new Promise(resolve => setTimeout(resolve, 20))
2311
+ // Small delay to ensure the request is in flight before shutdown
2312
+ await new Promise(resolve => setTimeout(resolve, 20))
2190
2313
 
2191
- shutdown()
2314
+ shutdown()
2192
2315
 
2193
- // Now resolve the slow fetch — the response should still complete
2194
- resolveSlowFetch()
2195
- const { status, body } = await pending
2196
- assert(status === 200, `Expected 200, got ${status}`)
2197
- assert(body.includes('<p>done</p>'), `Expected response body, got: ${body}`)
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
+ }
2198
2330
  })
2199
2331
 
2200
2332
  // ---------------------------------------------------------------------------
@@ -2313,4 +2445,5 @@ await test('ctx.formData() still handles URL-encoded body correctly', async () =
2313
2445
  // ---------------------------------------------------------------------------
2314
2446
 
2315
2447
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
2448
+ finished = true
2316
2449
  if (failed > 0) process.exit(1)