@invisibleloop/pulse 0.20.30 → 0.20.31
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 +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/server/index.js +9 -4
- package/src/server/server.test.js +163 -30
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.31
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
2145
|
-
|
|
2146
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2260
|
+
shutdown()
|
|
2153
2261
|
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
|
2183
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2308
|
+
// Start a request but don't resolve its server fetch yet
|
|
2309
|
+
const pending = get(port, '/slow')
|
|
2187
2310
|
|
|
2188
|
-
|
|
2189
|
-
|
|
2311
|
+
// Small delay to ensure the request is in flight before shutdown
|
|
2312
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
2190
2313
|
|
|
2191
|
-
|
|
2314
|
+
shutdown()
|
|
2192
2315
|
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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)
|