@invisibleloop/pulse 0.17.2 → 0.18.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/pulse-checklist.md +8 -0
- package/CLAUDE.md +65 -1
- package/docs/.claude/pulse-checklist.md +8 -0
- package/docs/server.js +2 -0
- package/docs/src/lib/nav.js +2 -0
- package/docs/src/pages/actions.js +2 -0
- package/docs/src/pages/error-pages.js +98 -0
- package/docs/src/pages/forms.js +128 -0
- package/docs/src/pages/guard.js +1 -0
- package/docs/src/pages/meta.js +27 -0
- package/docs/src/pages/raw-responses.js +1 -0
- package/docs/src/pages/routing.js +7 -9
- package/docs/src/pages/server-api.js +4 -1
- package/examples/.claude/pulse-checklist.md +8 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/checklist.md +8 -0
- package/src/agent/guide-routing.md +46 -0
- package/src/agent/guide-server.md +34 -0
- package/src/agent/guide-spec.md +21 -0
- package/src/runtime/ssr.js +30 -7
- package/src/server/index.js +260 -16
- package/src/server/server.test.js +314 -0
- package/src/spec/schema.js +39 -3
- package/src/spec/schema.test.js +49 -0
- package/types/schema.d.ts +42 -2
- package/types/server.d.ts +22 -0
|
@@ -1246,6 +1246,320 @@ await test('health check fires before route matching — shadows a user spec at
|
|
|
1246
1246
|
|
|
1247
1247
|
// ---------------------------------------------------------------------------
|
|
1248
1248
|
|
|
1249
|
+
console.log('\nCustom 404 page (route: "*")\n')
|
|
1250
|
+
|
|
1251
|
+
const notFoundPageSpec = {
|
|
1252
|
+
route: '*',
|
|
1253
|
+
meta: { title: 'Lost?' },
|
|
1254
|
+
view: () => '<main id="main-content"><h1>Custom not found</h1><a href="/">Home</a></main>',
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
await test('unknown route renders the "*" spec with status 404', async () => {
|
|
1258
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
|
|
1259
|
+
const { status, body } = await get(port, '/nope')
|
|
1260
|
+
assert(status === 404, `Expected 404, got ${status}`)
|
|
1261
|
+
assert(body.includes('Custom not found'), `Expected custom 404 content, got: ${body.slice(0, 200)}`)
|
|
1262
|
+
assert(body.includes('<title>Lost?</title>'), `Expected custom 404 title, got: ${body.slice(0, 300)}`)
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
await test('custom 404 renders in streaming mode too (forced string path)', async () => {
|
|
1267
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: true }, async (port) => {
|
|
1268
|
+
const { status, body } = await get(port, '/still-nope')
|
|
1269
|
+
assert(status === 404, `Expected 404, got ${status}`)
|
|
1270
|
+
assert(body.includes('Custom not found'), `Expected custom 404 content`)
|
|
1271
|
+
})
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
await test('known routes are unaffected by the "*" spec', async () => {
|
|
1275
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false }, async (port) => {
|
|
1276
|
+
const { status, body } = await get(port, '/hello')
|
|
1277
|
+
assert(status === 200, `Expected 200, got ${status}`)
|
|
1278
|
+
assert(body.includes('Hello world'), 'Expected normal page content')
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
await test('custom 404 responses are not cached as 200s', async () => {
|
|
1283
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: false, defaultCache: 3600, dev: false }, async (port) => {
|
|
1284
|
+
const a = await get(port, '/missing')
|
|
1285
|
+
const b = await get(port, '/missing')
|
|
1286
|
+
assert(a.status === 404 && b.status === 404, `Expected both 404, got ${a.status}/${b.status}`)
|
|
1287
|
+
})
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
await test('without a "*" spec the default 404 still works', async () => {
|
|
1291
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1292
|
+
const { status, body } = await get(port, '/nope')
|
|
1293
|
+
assert(status === 404 && body.includes('404'), 'Expected default 404 page')
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
console.log('\nServer-side forms (spec.submit) + CSRF\n')
|
|
1300
|
+
|
|
1301
|
+
function requestFull(port, method, path, { headers = {}, body = null } = {}) {
|
|
1302
|
+
return new Promise((resolve, reject) => {
|
|
1303
|
+
const req = http.request({ method, hostname: 'localhost', port, path, headers }, (res) => {
|
|
1304
|
+
let data = ''
|
|
1305
|
+
res.on('data', c => { data += c })
|
|
1306
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
|
|
1307
|
+
})
|
|
1308
|
+
req.on('error', reject)
|
|
1309
|
+
if (body) req.write(body)
|
|
1310
|
+
req.end()
|
|
1311
|
+
})
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function postForm(port, path, fields, cookie = '') {
|
|
1315
|
+
const body = new URLSearchParams(fields).toString()
|
|
1316
|
+
return requestFull(port, 'POST', path, {
|
|
1317
|
+
headers: {
|
|
1318
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1319
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1320
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
1321
|
+
},
|
|
1322
|
+
body,
|
|
1323
|
+
})
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** GET the form page and extract the CSRF cookie + token for a valid POST. */
|
|
1327
|
+
async function getCsrfPair(port, path) {
|
|
1328
|
+
const res = await get(port, path)
|
|
1329
|
+
const setCookie = [].concat(res.headers['set-cookie'] || []).find(c => c.startsWith('pulse_csrf='))
|
|
1330
|
+
const cookie = setCookie ? setCookie.split(';')[0] : ''
|
|
1331
|
+
const token = res.body.match(/name="_csrf" value="([^"]+)"/)?.[1] || ''
|
|
1332
|
+
return { cookie, token, body: res.body }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const formSpec = {
|
|
1336
|
+
route: '/signup',
|
|
1337
|
+
meta: { title: 'Sign up' },
|
|
1338
|
+
submit: async (ctx) => {
|
|
1339
|
+
const data = await ctx.formData()
|
|
1340
|
+
if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
|
|
1341
|
+
if (data.email === 'taken@example.com') return { status: 422, errors: { email: 'Already registered' }, values: data }
|
|
1342
|
+
return { redirect: '/signup?done=1' }
|
|
1343
|
+
},
|
|
1344
|
+
view: (state, server) => `
|
|
1345
|
+
<main id="main-content">
|
|
1346
|
+
<h1>Sign up</h1>
|
|
1347
|
+
${server.form?.errors?.email ? `<p class="error">${server.form.errors.email}</p>` : ''}
|
|
1348
|
+
<form method="POST">
|
|
1349
|
+
${server.csrf ?? ''}
|
|
1350
|
+
<label for="email">Email</label>
|
|
1351
|
+
<input id="email" name="email" value="${server.form?.values?.email ?? ''}">
|
|
1352
|
+
<button type="submit">Go</button>
|
|
1353
|
+
</form>
|
|
1354
|
+
</main>`,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
await test('GET on a submit page sets the CSRF cookie and embeds the hidden field', async () => {
|
|
1358
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1359
|
+
const { cookie, token, body } = await getCsrfPair(port, '/signup')
|
|
1360
|
+
assert(cookie.startsWith('pulse_csrf='), `Expected pulse_csrf cookie, got: ${cookie}`)
|
|
1361
|
+
assert(token.length > 20, `Expected a CSRF token in the form, got: ${token}`)
|
|
1362
|
+
assert(body.includes('name="_csrf"'), 'Expected hidden _csrf input in the form')
|
|
1363
|
+
})
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
await test('CSRF field is present in streaming mode too', async () => {
|
|
1367
|
+
await withServer([formSpec], { stream: true }, async (port) => {
|
|
1368
|
+
const { token } = await getCsrfPair(port, '/signup')
|
|
1369
|
+
assert(token.length > 20, 'Expected CSRF token in streamed HTML')
|
|
1370
|
+
})
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
await test('POST without a CSRF token is rejected with 403', async () => {
|
|
1374
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1375
|
+
const { status } = await postForm(port, '/signup', { email: 'a@b.com' })
|
|
1376
|
+
assert(status === 403, `Expected 403, got ${status}`)
|
|
1377
|
+
})
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
await test('POST with a forged token is rejected with 403', async () => {
|
|
1381
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1382
|
+
const { cookie } = await getCsrfPair(port, '/signup')
|
|
1383
|
+
const { status } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: 'forged-token-value' }, cookie)
|
|
1384
|
+
assert(status === 403, `Expected 403, got ${status}`)
|
|
1385
|
+
})
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
await test('valid POST follows POST-redirect-GET with 303', async () => {
|
|
1389
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1390
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1391
|
+
const { status, headers } = await postForm(port, '/signup', { email: 'a@b.com', _csrf: token }, cookie)
|
|
1392
|
+
assert(status === 303, `Expected 303, got ${status}`)
|
|
1393
|
+
assert(headers.location === '/signup?done=1', `Expected PRG redirect, got ${headers.location}`)
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
await test('validation failure re-renders the page with server.form (errors + values)', async () => {
|
|
1398
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1399
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1400
|
+
const { status, body } = await postForm(port, '/signup', { email: '', _csrf: token }, cookie)
|
|
1401
|
+
assert(status === 200, `Expected 200 re-render, got ${status}`)
|
|
1402
|
+
assert(body.includes('Email is required'), `Expected error message in re-render, got: ${body.slice(0, 400)}`)
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
await test('submit can set a custom response status (422) and values are echoed back', async () => {
|
|
1407
|
+
await withServer([formSpec], { stream: false }, async (port) => {
|
|
1408
|
+
const { cookie, token } = await getCsrfPair(port, '/signup')
|
|
1409
|
+
const { status, body } = await postForm(port, '/signup', { email: 'taken@example.com', _csrf: token }, cookie)
|
|
1410
|
+
assert(status === 422, `Expected 422, got ${status}`)
|
|
1411
|
+
assert(body.includes('Already registered'), 'Expected error in re-render')
|
|
1412
|
+
assert(body.includes('value="taken@example.com"'), 'Expected submitted value echoed into the input')
|
|
1413
|
+
})
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
await test('csrf: false opts a submit route out of CSRF protection', async () => {
|
|
1417
|
+
const webhookish = {
|
|
1418
|
+
route: '/hook',
|
|
1419
|
+
csrf: false,
|
|
1420
|
+
submit: async () => ({ redirect: '/hook?ok=1' }),
|
|
1421
|
+
view: () => '<main id="main-content">hook</main>',
|
|
1422
|
+
}
|
|
1423
|
+
await withServer([webhookish], { stream: false }, async (port) => {
|
|
1424
|
+
const { status, headers } = await postForm(port, '/hook', { x: '1' })
|
|
1425
|
+
assert(status === 303 && headers.location === '/hook?ok=1', `Expected 303 without CSRF, got ${status}`)
|
|
1426
|
+
})
|
|
1427
|
+
})
|
|
1428
|
+
|
|
1429
|
+
await test('POST to a page without submit is still 405', async () => {
|
|
1430
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1431
|
+
const { status } = await postForm(port, '/hello', { x: '1' })
|
|
1432
|
+
assert(status === 405, `Expected 405, got ${status}`)
|
|
1433
|
+
})
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
await test('submit pages are excluded from the in-process page cache', async () => {
|
|
1437
|
+
// Two different clients must get different CSRF tokens — a cached page would
|
|
1438
|
+
// serve client A's token (and cookie pairing) to client B.
|
|
1439
|
+
await withServer([formSpec], { stream: false, defaultCache: 3600 }, async (port) => {
|
|
1440
|
+
const a = await getCsrfPair(port, '/signup')
|
|
1441
|
+
const b = await getCsrfPair(port, '/signup')
|
|
1442
|
+
assert(a.token && b.token && a.token !== b.token,
|
|
1443
|
+
`Expected per-visitor CSRF tokens, got identical values (page was cached)`)
|
|
1444
|
+
})
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
// ---------------------------------------------------------------------------
|
|
1448
|
+
|
|
1449
|
+
console.log('\nSitemap + robots.txt\n')
|
|
1450
|
+
|
|
1451
|
+
const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
|
|
1452
|
+
const blogSpec = {
|
|
1453
|
+
route: '/blog/:slug',
|
|
1454
|
+
sitemap: async () => ['/blog/first-post', { path: '/blog/second-post', lastmod: '2026-01-15' }],
|
|
1455
|
+
server: { post: async (ctx) => ({ slug: ctx.params.slug }) },
|
|
1456
|
+
view: (_s, server) => `<main id="main-content">${server.post.slug}</main>`,
|
|
1457
|
+
}
|
|
1458
|
+
const secretSpec = { route: '/admin', guard: async () => {}, view: () => '<main id="main-content">admin</main>' }
|
|
1459
|
+
const hiddenSpec = { route: '/internal', sitemap: false, view: () => '<main id="main-content">internal</main>' }
|
|
1460
|
+
|
|
1461
|
+
await test('sitemap: true serves /sitemap.xml with static routes', async () => {
|
|
1462
|
+
await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true }, async (port) => {
|
|
1463
|
+
const { status, headers, body } = await get(port, '/sitemap.xml')
|
|
1464
|
+
assert(status === 200, `Expected 200, got ${status}`)
|
|
1465
|
+
assert(headers['content-type'].includes('application/xml'), `Expected XML, got ${headers['content-type']}`)
|
|
1466
|
+
assert(body.includes(`<loc>http://localhost:${port}/hello</loc>`), `Missing /hello: ${body}`)
|
|
1467
|
+
assert(body.includes(`<loc>http://localhost:${port}/about</loc>`), `Missing /about: ${body}`)
|
|
1468
|
+
})
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
await test('dynamic routes contribute via spec.sitemap enumerator (with lastmod)', async () => {
|
|
1472
|
+
await withServer([blogSpec], { stream: false, sitemap: true, quiet: true }, async (port) => {
|
|
1473
|
+
const { body } = await get(port, '/sitemap.xml')
|
|
1474
|
+
assert(body.includes('/blog/first-post</loc>'), `Missing enumerated path: ${body}`)
|
|
1475
|
+
assert(body.includes('/blog/second-post</loc>'), 'Missing second enumerated path')
|
|
1476
|
+
assert(body.includes('<lastmod>2026-01-15</lastmod>'), 'Missing lastmod')
|
|
1477
|
+
})
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
await test('dynamic routes without an enumerator are skipped, not broken', async () => {
|
|
1481
|
+
const bare = { route: '/items/:id', server: { i: async (ctx) => ctx.params.id }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
|
|
1482
|
+
await withServer([helloSpec, bare], { stream: false, sitemap: true, quiet: true }, async (port) => {
|
|
1483
|
+
const { status, body } = await get(port, '/sitemap.xml')
|
|
1484
|
+
assert(status === 200)
|
|
1485
|
+
assert(!body.includes(':id'), `Raw :param leaked into sitemap: ${body}`)
|
|
1486
|
+
assert(body.includes('/hello</loc>'), 'Static route should still be present')
|
|
1487
|
+
})
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
await test('guarded pages, route "*", and sitemap:false are excluded', async () => {
|
|
1491
|
+
const nf = { route: '*', view: () => '<main id="main-content">404</main>' }
|
|
1492
|
+
await withServer([helloSpec, secretSpec, hiddenSpec, nf], { stream: false, sitemap: true }, async (port) => {
|
|
1493
|
+
const { body } = await get(port, '/sitemap.xml')
|
|
1494
|
+
assert(!body.includes('/admin'), 'Guarded page must be excluded by default')
|
|
1495
|
+
assert(!body.includes('/internal'), 'sitemap:false page must be excluded')
|
|
1496
|
+
assert(!body.includes('<loc>http://localhost:' + port + '/*'), 'route * must be excluded')
|
|
1497
|
+
assert(body.includes('/hello</loc>'))
|
|
1498
|
+
})
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
await test('a guarded page opts in with sitemap: true', async () => {
|
|
1502
|
+
const publicGuarded = { route: '/landing', guard: async () => {}, sitemap: true, view: () => '<main id="main-content">x</main>' }
|
|
1503
|
+
await withServer([publicGuarded], { stream: false, sitemap: true }, async (port) => {
|
|
1504
|
+
const { body } = await get(port, '/sitemap.xml')
|
|
1505
|
+
assert(body.includes('/landing</loc>'), `Expected opted-in guarded page: ${body}`)
|
|
1506
|
+
})
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
await test('robots.txt is generated and points at the sitemap', async () => {
|
|
1510
|
+
await withServer([helloSpec], { stream: false, sitemap: true }, async (port) => {
|
|
1511
|
+
const { status, body } = await get(port, '/robots.txt')
|
|
1512
|
+
assert(status === 200)
|
|
1513
|
+
assert(body.includes('User-agent: *'), `Missing user-agent: ${body}`)
|
|
1514
|
+
assert(body.includes(`Sitemap: http://localhost:${port}/sitemap.xml`), `Missing sitemap line: ${body}`)
|
|
1515
|
+
})
|
|
1516
|
+
})
|
|
1517
|
+
|
|
1518
|
+
await test('robots: false disables robots.txt; custom string is served verbatim', async () => {
|
|
1519
|
+
await withServer([helloSpec], { stream: false, sitemap: true, robots: false }, async (port) => {
|
|
1520
|
+
const { status } = await get(port, '/robots.txt')
|
|
1521
|
+
assert(status === 404, `Expected 404 with robots:false, got ${status}`)
|
|
1522
|
+
})
|
|
1523
|
+
await withServer([helloSpec], { stream: false, sitemap: true, robots: 'User-agent: *\nDisallow: /private\n' }, async (port) => {
|
|
1524
|
+
const { body } = await get(port, '/robots.txt')
|
|
1525
|
+
assert(body.includes('Disallow: /private'), 'Expected custom robots content')
|
|
1526
|
+
})
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
await test('sitemap origin can be pinned via { origin }', async () => {
|
|
1530
|
+
await withServer([helloSpec], { stream: false, sitemap: { origin: 'https://mysite.com' } }, async (port) => {
|
|
1531
|
+
const { body } = await get(port, '/sitemap.xml')
|
|
1532
|
+
assert(body.includes('<loc>https://mysite.com/hello</loc>'), `Expected pinned origin: ${body}`)
|
|
1533
|
+
const robotsRes = await get(port, '/robots.txt')
|
|
1534
|
+
assert(robotsRes.body.includes('Sitemap: https://mysite.com/sitemap.xml'), 'robots should use pinned origin')
|
|
1535
|
+
})
|
|
1536
|
+
})
|
|
1537
|
+
|
|
1538
|
+
await test('trailingSlash "add" is reflected in sitemap URLs', async () => {
|
|
1539
|
+
await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true, trailingSlash: 'add' }, async (port) => {
|
|
1540
|
+
const { body } = await get(port, '/sitemap.xml')
|
|
1541
|
+
assert(body.includes('/about/</loc>'), `Expected trailing slash on URLs: ${body}`)
|
|
1542
|
+
})
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1545
|
+
await test('sitemap disabled by default — /sitemap.xml is 404', async () => {
|
|
1546
|
+
await withServer([helloSpec], { stream: false }, async (port) => {
|
|
1547
|
+
const { status } = await get(port, '/sitemap.xml')
|
|
1548
|
+
assert(status === 404, `Expected 404 when sitemap not enabled, got ${status}`)
|
|
1549
|
+
})
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
await test('a failing enumerator does not take down the sitemap', async () => {
|
|
1553
|
+
const broken = { route: '/x/:id', sitemap: async () => { throw new Error('db down') }, server: { i: async () => 1 }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
|
|
1554
|
+
await withServer([helloSpec, broken], { stream: false, sitemap: true, quiet: true }, async (port) => {
|
|
1555
|
+
const { status, body } = await get(port, '/sitemap.xml')
|
|
1556
|
+
assert(status === 200, `Expected 200 despite enumerator failure, got ${status}`)
|
|
1557
|
+
assert(body.includes('/hello</loc>'), 'Other routes should still be listed')
|
|
1558
|
+
})
|
|
1559
|
+
})
|
|
1560
|
+
|
|
1561
|
+
// ---------------------------------------------------------------------------
|
|
1562
|
+
|
|
1249
1563
|
console.log('\nMalformed request handling\n')
|
|
1250
1564
|
|
|
1251
1565
|
await test('malformed percent-encoding in path returns 404, not 500', async () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -137,13 +137,49 @@ export function validateSpec(spec) {
|
|
|
137
137
|
return { valid: false, errors: ['Spec must be a plain object'] }
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// route
|
|
140
|
+
// route — '*' is the custom not-found page, rendered with status 404 when no route matches
|
|
141
141
|
if (spec.route === undefined) {
|
|
142
142
|
warnings.push('spec.route is not set — the server cannot register this page without a route')
|
|
143
143
|
} else if (typeof spec.route !== 'string') {
|
|
144
144
|
errors.push('spec.route must be a string (e.g. "/contact")')
|
|
145
|
-
} else if (!spec.route.startsWith('/')) {
|
|
146
|
-
errors.push('spec.route must start with "/" (e.g. "/contact")')
|
|
145
|
+
} else if (!spec.route.startsWith('/') && spec.route !== '*') {
|
|
146
|
+
errors.push('spec.route must start with "/" (e.g. "/contact"), or be "*" for the custom not-found page')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// submit — server-side form handler: POST to the route is handled without client JS
|
|
150
|
+
if (spec.submit !== undefined) {
|
|
151
|
+
if (typeof spec.submit !== 'function') {
|
|
152
|
+
errors.push('spec.submit must be a function — async (ctx) => ({ redirect } | { ...form state }). It handles POST requests to this route server-side.')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// sitemap — inclusion control for the auto-generated sitemap (createServer { sitemap: true })
|
|
157
|
+
// false → exclude this page
|
|
158
|
+
// true → include a guarded static page that would be excluded by default
|
|
159
|
+
// array/function → enumerate paths (required for dynamic :param routes)
|
|
160
|
+
if (spec.sitemap !== undefined) {
|
|
161
|
+
const t = spec.sitemap
|
|
162
|
+
const ok = typeof t === 'boolean' || typeof t === 'function' || Array.isArray(t)
|
|
163
|
+
if (!ok) {
|
|
164
|
+
errors.push('spec.sitemap must be a boolean, an array of paths, or a function — e.g. sitemap: async () => [\'/blog/a\', { path: \'/blog/b\', lastmod: \'2026-01-01\' }]')
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(t)) {
|
|
167
|
+
const bad = t.find(item => !(typeof item === 'string' && item.startsWith('/')) &&
|
|
168
|
+
!(item && typeof item === 'object' && typeof item.path === 'string' && item.path.startsWith('/')))
|
|
169
|
+
if (bad !== undefined) {
|
|
170
|
+
errors.push('spec.sitemap array entries must be "/path" strings or { path: \'/path\', lastmod? } objects')
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// csrf — opt out of CSRF protection on the submit handler (default: enforced)
|
|
176
|
+
if (spec.csrf !== undefined) {
|
|
177
|
+
if (typeof spec.csrf !== 'boolean') {
|
|
178
|
+
errors.push('spec.csrf must be a boolean — set csrf: false only to disable CSRF protection for spec.submit (e.g. for token-authenticated endpoints)')
|
|
179
|
+
}
|
|
180
|
+
if (spec.csrf === false && typeof spec.submit === 'function') {
|
|
181
|
+
warnings.push('spec.csrf is false — POST submissions to this route are NOT CSRF-protected. Only do this for endpoints with their own auth (e.g. signed webhooks).')
|
|
182
|
+
}
|
|
147
183
|
}
|
|
148
184
|
|
|
149
185
|
// contentType — marks a raw content spec (RSS, sitemap, JSON API, etc.)
|
package/src/spec/schema.test.js
CHANGED
|
@@ -412,6 +412,55 @@ test('does not warn when meta.theme is a function', () => {
|
|
|
412
412
|
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is a function')
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
+
// --- route '*', submit, csrf ------------------------------------------------
|
|
416
|
+
|
|
417
|
+
test('route "*" (custom not-found page) is valid', () => {
|
|
418
|
+
const { valid, errors } = validateSpec({ route: '*', view: () => '<main id="main-content">404</main>' })
|
|
419
|
+
assert(valid, `Expected valid, got: ${errors.join('; ')}`)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('non-"/" routes other than "*" are still rejected', () => {
|
|
423
|
+
const { valid } = validateSpec({ route: 'about', view: () => '' })
|
|
424
|
+
assert(!valid, 'Expected route without leading / to be invalid')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('submit must be a function', () => {
|
|
428
|
+
const { valid, errors } = validateSpec({ route: '/x', view: () => '', submit: 'nope' })
|
|
429
|
+
assert(!valid && errors.some(e => e.includes('submit')), 'Expected submit type error')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('a valid submit function passes', () => {
|
|
433
|
+
const { valid } = validateSpec({ route: '/x', view: () => '', submit: async () => ({ redirect: '/' }) })
|
|
434
|
+
assert(valid, 'Expected spec with submit function to be valid')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('csrf must be a boolean', () => {
|
|
438
|
+
const { valid, errors } = validateSpec({ route: '/x', view: () => '', csrf: 'off' })
|
|
439
|
+
assert(!valid && errors.some(e => e.includes('csrf')), 'Expected csrf type error')
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('csrf: false with submit warns about unprotected POSTs', () => {
|
|
443
|
+
const { valid, warnings } = validateSpec({ route: '/x', view: () => '', submit: async () => {}, csrf: false })
|
|
444
|
+
assert(valid, 'csrf: false is valid')
|
|
445
|
+
assert(warnings.some(w => w.includes('CSRF')), 'Expected a warning about disabled CSRF protection')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// --- sitemap ------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
test('sitemap accepts boolean, array, and function', () => {
|
|
451
|
+
for (const v of [true, false, ['/a', { path: '/b', lastmod: '2026-01-01' }], async () => ['/a']]) {
|
|
452
|
+
const { valid, errors } = validateSpec({ route: '/x', view: () => '', sitemap: v })
|
|
453
|
+
assert(valid, `Expected sitemap ${typeof v} to be valid: ${errors.join('; ')}`)
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('sitemap rejects invalid types and malformed array entries', () => {
|
|
458
|
+
const { valid: v1 } = validateSpec({ route: '/x', view: () => '', sitemap: 'yes' })
|
|
459
|
+
assert(!v1, 'Expected string sitemap to be invalid')
|
|
460
|
+
const { valid: v2, errors } = validateSpec({ route: '/x', view: () => '', sitemap: ['no-slash'] })
|
|
461
|
+
assert(!v2 && errors.some(e => e.includes('sitemap')), 'Expected malformed array entry to be invalid')
|
|
462
|
+
})
|
|
463
|
+
|
|
415
464
|
// ---------------------------------------------------------------------------
|
|
416
465
|
|
|
417
466
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/types/schema.d.ts
CHANGED
|
@@ -65,6 +65,19 @@ export interface RequestContext {
|
|
|
65
65
|
// Guard return value
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Return value of spec.submit().
|
|
70
|
+
* { redirect } → 303 POST-redirect-GET. Any other object re-renders the page
|
|
71
|
+
* with the value exposed as serverState.form; an optional status field sets
|
|
72
|
+
* the HTTP response status (e.g. 422 for validation failures).
|
|
73
|
+
*/
|
|
74
|
+
export type SubmitResult =
|
|
75
|
+
| { redirect: string }
|
|
76
|
+
| { status?: number; [key: string]: unknown }
|
|
77
|
+
|
|
78
|
+
/** A sitemap entry: a '/path' string, or { path, lastmod } for a per-URL lastmod date. */
|
|
79
|
+
export type SitemapEntry = string | { path: string; lastmod?: string }
|
|
80
|
+
|
|
68
81
|
export type GuardResult =
|
|
69
82
|
| { redirect: string }
|
|
70
83
|
| { status: number; json?: unknown; body?: string; headers?: Record<string, string> }
|
|
@@ -131,7 +144,10 @@ export interface MetaConfig {
|
|
|
131
144
|
// ---------------------------------------------------------------------------
|
|
132
145
|
|
|
133
146
|
export interface PulseSpec<S extends object = Record<string, unknown>> {
|
|
134
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* URL pattern. Supports :param segments.
|
|
149
|
+
* Use '*' for the custom not-found page — rendered with status 404 when no route matches.
|
|
150
|
+
*/
|
|
135
151
|
route: string
|
|
136
152
|
|
|
137
153
|
/**
|
|
@@ -194,9 +210,33 @@ export interface PulseSpec<S extends object = Record<string, unknown>> {
|
|
|
194
210
|
/** Global store keys this page subscribes to. Appear in view's server arg. */
|
|
195
211
|
store?: string[]
|
|
196
212
|
|
|
197
|
-
/** HTTP methods this page accepts. Default ['GET', 'HEAD']. */
|
|
213
|
+
/** HTTP methods this page accepts. Default ['GET', 'HEAD'] (plus POST when submit is set). */
|
|
198
214
|
methods?: Array<'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'>
|
|
199
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Server-side form handler — POST to this route works without client JS.
|
|
218
|
+
* Return { redirect } for a 303 POST-redirect-GET; any other value re-renders
|
|
219
|
+
* the page with the value exposed to the view as serverState.form (an optional
|
|
220
|
+
* status field sets the response status, e.g. 422).
|
|
221
|
+
* CSRF protection is enforced automatically: the view must include
|
|
222
|
+
* ${server.csrf} inside the <form> or POSTs are rejected with 403.
|
|
223
|
+
*/
|
|
224
|
+
submit?: (ctx: RequestContext) => Promise<SubmitResult | void> | SubmitResult | void
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Set false to disable CSRF protection on the submit handler.
|
|
228
|
+
* Only for endpoints with their own authentication (e.g. signed webhooks).
|
|
229
|
+
*/
|
|
230
|
+
csrf?: boolean
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Inclusion control for the auto-generated sitemap (createServer { sitemap: true }).
|
|
234
|
+
* false → exclude this page. true → include a guarded static page that would be
|
|
235
|
+
* excluded by default. Array or function → enumerate paths — required for dynamic
|
|
236
|
+
* :param routes. Entries are '/path' strings or { path, lastmod } objects.
|
|
237
|
+
*/
|
|
238
|
+
sitemap?: boolean | SitemapEntry[] | (() => Promise<SitemapEntry[]> | SitemapEntry[])
|
|
239
|
+
|
|
200
240
|
/**
|
|
201
241
|
* Called before server data fetchers on every request.
|
|
202
242
|
* Return { redirect } to redirect, { status, json } for a custom response,
|
package/types/server.d.ts
CHANGED
|
@@ -72,6 +72,28 @@ export interface ServerOptions {
|
|
|
72
72
|
/** Called on every request before routing. Return false to short-circuit Pulse handling. */
|
|
73
73
|
onRequest?: (req: IncomingMessage, res: ServerResponse) => false | void | unknown
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* HMAC secret for CSRF tokens (spec.submit forms). Defaults to a random
|
|
77
|
+
* per-boot value — set a stable secret (e.g. from an env var) when running
|
|
78
|
+
* multiple instances so tokens issued by one instance validate on another.
|
|
79
|
+
*/
|
|
80
|
+
secret?: string | null
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Auto-serve /sitemap.xml (and robots.txt) from the registered routes.
|
|
84
|
+
* true derives the origin from request headers; { origin } pins it.
|
|
85
|
+
* Static page routes are included automatically; dynamic :param routes
|
|
86
|
+
* contribute via spec.sitemap; guarded pages are excluded unless the spec
|
|
87
|
+
* opts in. A physical sitemap.xml in staticDir always wins.
|
|
88
|
+
*/
|
|
89
|
+
sitemap?: boolean | { origin?: string }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* robots.txt behaviour when sitemap is enabled. null/undefined auto-generates
|
|
93
|
+
* (allow all + Sitemap line); false disables; a string is served verbatim.
|
|
94
|
+
*/
|
|
95
|
+
robots?: string | false | null
|
|
96
|
+
|
|
75
97
|
/**
|
|
76
98
|
* Extra CSP sources to merge into the framework's default Content-Security-Policy.
|
|
77
99
|
* Use this to allow external stylesheets, fonts, or API origins.
|