@invisibleloop/pulse 0.17.1 → 0.17.3

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.
Files changed (43) hide show
  1. package/.claude/commands/build-page.md +1 -1
  2. package/.claude/commands/verify.md +6 -4
  3. package/.claude/pulse-checklist.md +10 -0
  4. package/CLAUDE.md +56 -2
  5. package/docs/.claude/pulse-checklist.md +10 -0
  6. package/docs/public/pulse-ui.css +8 -2
  7. package/docs/server.js +2 -0
  8. package/docs/src/lib/nav.js +2 -0
  9. package/docs/src/pages/actions.js +2 -0
  10. package/docs/src/pages/error-pages.js +98 -0
  11. package/docs/src/pages/forms.js +128 -0
  12. package/docs/src/pages/guard.js +1 -0
  13. package/docs/src/pages/routing.js +7 -9
  14. package/docs/src/pages/server-api.js +2 -1
  15. package/examples/.claude/pulse-checklist.md +10 -0
  16. package/examples/public/pulse-ui.css +52 -2
  17. package/package.json +2 -2
  18. package/public/.pulse-ui-version +1 -1
  19. package/public/pulse-ui.css +8 -2
  20. package/scripts/build.js +1 -204
  21. package/scripts/css-purge.js +241 -0
  22. package/scripts/css-purge.test.js +94 -0
  23. package/src/agent/checklist.md +10 -0
  24. package/src/agent/commands/verify.md +6 -4
  25. package/src/agent/guide-routing.md +21 -0
  26. package/src/agent/guide-server.md +34 -0
  27. package/src/agent/guide-spec.md +25 -0
  28. package/src/agent/guide-styles.md +3 -1
  29. package/src/agent/skills/build-page/SKILL.md +1 -1
  30. package/src/agent/skills/verify/SKILL.md +2 -2
  31. package/src/agent/workflow.md +15 -3
  32. package/src/mcp/mcp.test.js +55 -1
  33. package/src/mcp/server.js +82 -5
  34. package/src/runtime/ssr.js +30 -7
  35. package/src/server/index.js +119 -16
  36. package/src/server/server.test.js +200 -0
  37. package/src/spec/schema.js +20 -3
  38. package/src/spec/schema.test.js +33 -0
  39. package/src/ui/drop-cap.js +1 -1
  40. package/src/ui/gallery.js +5 -1
  41. package/src/ui/ui.test.js +34 -0
  42. package/types/schema.d.ts +31 -2
  43. package/types/server.d.ts +7 -0
@@ -367,12 +367,14 @@ function buildCacheKey(spec, ctx) {
367
367
  * - spec.stream.deferred — deferred segments inject content via inline <script nonce="...">
368
368
  * tags; caching bakes in one nonce while subsequent CSP headers
369
369
  * carry a different nonce, causing a violation
370
+ * - spec.submit — form pages embed a per-visitor CSRF token; caching
371
+ * would serve one visitor's token to everyone
370
372
  *
371
373
  * In all these cases the cached HTML would contain a nonce that doesn't match
372
374
  * the nonce-free CSP used for cached responses, causing a CSP violation.
373
375
  */
374
376
  function isCacheablePage(spec) {
375
- return !spec.server && !spec.store?.length && !spec.hydrate && !spec.stream?.deferred?.length
377
+ return !spec.server && !spec.store?.length && !spec.hydrate && !spec.stream?.deferred?.length && !spec.submit
376
378
  }
377
379
 
378
380
  // ---------------------------------------------------------------------------
@@ -542,6 +544,8 @@ export async function createServer(entries, options = {}) {
542
544
  shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
543
545
  healthCheck = '/healthz', // path for health check endpoint, or false to disable
544
546
  csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
547
+ secret = null, // HMAC secret for CSRF tokens — set a stable value when running
548
+ // multiple instances; defaults to a random per-boot secret
545
549
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
546
550
  onRequest,
547
551
  agentMode = false, // show agent-active indicator in banner
@@ -585,8 +589,8 @@ export async function createServer(entries, options = {}) {
585
589
  const routeErrors = []
586
590
  if (!spec.route || typeof spec.route !== 'string') {
587
591
  routeErrors.push('spec.route is required and must be a string (e.g. "/contact")')
588
- } else if (!spec.route.startsWith('/')) {
589
- routeErrors.push('spec.route must start with "/" (e.g. "/contact")')
592
+ } else if (!spec.route.startsWith('/') && spec.route !== '*') {
593
+ routeErrors.push('spec.route must start with "/" (e.g. "/contact"), or be "*" for the custom not-found page')
590
594
  }
591
595
  const allErrors = [...errors, ...routeErrors]
592
596
  if (!valid || routeErrors.length > 0) {
@@ -597,8 +601,18 @@ export async function createServer(entries, options = {}) {
597
601
  }
598
602
  }
599
603
 
600
- // Build route table — let so it can be swapped on hot reload
601
- let router = buildRouter(specs)
604
+ // Custom not-found page — a spec with route '*' renders (with status 404)
605
+ // whenever no route matches. It goes through the normal render pipeline, so
606
+ // it gets the site's layout, styles, hydration, and validation like any page.
607
+ let notFoundSpec = specs.find(s => s.route === '*') || null
608
+
609
+ // Build route table — let so it can be swapped on hot reload.
610
+ // The '*' spec is excluded: it is not a route, it is the fallback.
611
+ let router = buildRouter(specs.filter(s => s.route !== '*'))
612
+
613
+ // CSRF secret — per-boot random unless pinned via options.secret. Tokens are
614
+ // tied to a cookie, so a restart only means in-flight forms re-render once.
615
+ const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
602
616
 
603
617
  // Resolve the logger module once at startup rather than dynamically importing
604
618
  // it on every request's `finish` event. `logRequest` stays null until loaded.
@@ -662,20 +676,32 @@ export async function createServer(entries, options = {}) {
662
676
  }
663
677
 
664
678
  // Match route
665
- const match = matchRoute(router, pathname)
679
+ let match = matchRoute(router, pathname)
680
+ let responseStatus = 200
666
681
 
667
682
  if (!match) {
668
- res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
669
- res.end(notFoundHtml(pathname))
670
- return
683
+ // Custom not-found page — a '*' spec renders through the normal pipeline
684
+ // with status 404, so the site's branded 404 gets layout, styles, and
685
+ // hydration like any other page. Client-side navigation requests still
686
+ // get the plain 404 (navigate.js falls back to a full page load, which
687
+ // then renders the custom page).
688
+ if (notFoundSpec && (req.method === 'GET' || req.method === 'HEAD') && req.headers['x-pulse-navigate'] !== 'true') {
689
+ match = { spec: notFoundSpec, params: {} }
690
+ responseStatus = 404
691
+ } else {
692
+ res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
693
+ res.end(notFoundHtml(pathname))
694
+ return
695
+ }
671
696
  }
672
697
 
673
698
  // Method gating — raw response specs accept any HTTP method.
674
- // Page specs default to GET/HEAD only; opt in to other methods via spec.methods.
699
+ // Page specs default to GET/HEAD only; spec.submit adds POST automatically;
700
+ // opt in to other methods via spec.methods.
675
701
  if (!match.spec.contentType) {
676
702
  const allowed = match.spec.methods
677
703
  ? match.spec.methods.map(m => m.toUpperCase())
678
- : ['GET', 'HEAD']
704
+ : (typeof match.spec.submit === 'function' ? ['GET', 'HEAD', 'POST'] : ['GET', 'HEAD'])
679
705
  if (!allowed.includes(req.method)) {
680
706
  res.writeHead(405, {
681
707
  'Content-Type': 'text/plain',
@@ -738,6 +764,57 @@ export async function createServer(entries, options = {}) {
738
764
  }
739
765
  }
740
766
 
767
+ // -----------------------------------------------------------------------
768
+ // CSRF — specs with a `submit` handler get token protection automatically
769
+ // (opt out per spec with csrf: false, e.g. for token-authenticated routes).
770
+ // Double-submit pattern: an HttpOnly cookie holds a random id; the form
771
+ // carries HMAC(secret, id) in a hidden _csrf field; POST verifies the pair.
772
+ // Views embed the field inside <form method="POST"> as ${server.csrf}.
773
+ // -----------------------------------------------------------------------
774
+ if (typeof spec.submit === 'function' && spec.csrf !== false) {
775
+ let csrfId = ctx.cookies.pulse_csrf
776
+ if (!csrfId) {
777
+ csrfId = crypto.randomBytes(16).toString('base64url')
778
+ ctx.setCookie('pulse_csrf', csrfId, { httpOnly: true, sameSite: 'Lax', path: '/' })
779
+ }
780
+ ctx._csrfField = `<input type="hidden" name="_csrf" value="${csrfToken(csrfSecret, csrfId)}">`
781
+
782
+ if (req.method === 'POST') {
783
+ const form = await ctx.formData()
784
+ const submitted = typeof form?._csrf === 'string' ? form._csrf : ''
785
+ const cookieId = ctx.cookies.pulse_csrf
786
+ if (!cookieId || !safeEqual(submitted, csrfToken(csrfSecret, cookieId))) {
787
+ res.writeHead(403, mergeCtxHeaders(ctx, {
788
+ 'Content-Type': 'text/plain',
789
+ ...SECURITY_HEADERS, ...httpsHeaders(req),
790
+ }))
791
+ res.end('Invalid or missing CSRF token. Reload the page and resubmit the form.')
792
+ return
793
+ }
794
+ }
795
+ }
796
+
797
+ // -----------------------------------------------------------------------
798
+ // Server-side form handling — spec.submit handles POST without client JS.
799
+ // { redirect: '/path' } → 303 See Other (POST-redirect-GET)
800
+ // anything else → re-render the page; the return value appears
801
+ // in the view as serverState.form (errors,
802
+ // submitted values, etc.). Optional status field
803
+ // sets the response status (e.g. 422).
804
+ // -----------------------------------------------------------------------
805
+ if (req.method === 'POST' && typeof spec.submit === 'function') {
806
+ const result = await spec.submit(ctx)
807
+ if (result?.redirect) {
808
+ res.writeHead(303, mergeCtxHeaders(ctx, { Location: result.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
809
+ res.end()
810
+ return
811
+ }
812
+ ctx._form = result ?? {}
813
+ if (typeof result?.status === 'number') responseStatus = result.status
814
+ // POST re-renders always use the buffered path — the request body is
815
+ // consumed and streaming a form response has no benefit.
816
+ }
817
+
741
818
  // Derive the canonical base URL from the request.
742
819
  // Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
743
820
  // spec.meta.canonical (string or function) is resolved inside each handler, where serverState
@@ -776,7 +853,12 @@ export async function createServer(entries, options = {}) {
776
853
  return
777
854
  }
778
855
 
779
- if (stream) {
856
+ // Non-200 renders (custom 404) and POST re-renders always use the buffered
857
+ // string path — they are small, must carry an explicit status, and POST
858
+ // bodies are already consumed.
859
+ if (responseStatus !== 200 || req.method === 'POST') {
860
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus)
861
+ } else if (stream) {
780
862
  await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
781
863
  } else {
782
864
  await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
@@ -865,7 +947,8 @@ export async function createServer(entries, options = {}) {
865
947
  server,
866
948
  shutdown,
867
949
  updateSpecs(newSpecs) {
868
- router = buildRouter(newSpecs)
950
+ notFoundSpec = newSpecs.find(s => s.route === '*') || null
951
+ router = buildRouter(newSpecs.filter(s => s.route !== '*'))
869
952
  }
870
953
  }
871
954
  }
@@ -943,9 +1026,12 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
943
1026
  * Render to a complete string then send — simpler, easier to cache.
944
1027
  * Checks the in-process page cache before rendering; stores result after.
945
1028
  */
946
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
1029
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200) {
947
1030
  const cacheKey = buildCacheKey(spec, ctx)
948
- const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
1031
+ // Never cache POST re-renders or non-200 responses (custom 404s) — POST output
1032
+ // varies per submission and a cached 404 body would be served with a 200 later.
1033
+ const cacheable = status === 200 && ctx.method !== 'POST' && isCacheablePage(spec)
1034
+ const ttl = cacheable ? pageCacheTtl(spec, dev, defaultCache) : 0
949
1035
 
950
1036
  let html, serverTimingValue, fromCache = false
951
1037
 
@@ -984,7 +1070,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
984
1070
  if (encoding) headers['Content-Encoding'] = encoding
985
1071
  if (serverTimingValue) headers['Server-Timing'] = serverTimingValue
986
1072
 
987
- res.writeHead(200, headers)
1073
+ res.writeHead(status, headers)
988
1074
  res.end(buf)
989
1075
  }
990
1076
 
@@ -1585,6 +1671,23 @@ function escHtml(str) {
1585
1671
  .replace(/'/g, '&#39;')
1586
1672
  }
1587
1673
 
1674
+ /**
1675
+ * Derive the CSRF token for a cookie id: HMAC-SHA256(secret, id), base64url.
1676
+ * The token goes in the form's hidden _csrf field; the id stays in an HttpOnly
1677
+ * cookie. An attacker on another origin can trigger the cookie being sent but
1678
+ * cannot read it or compute the HMAC, so they cannot forge the pair.
1679
+ */
1680
+ function csrfToken(secret, id) {
1681
+ return crypto.createHmac('sha256', secret).update(String(id)).digest('base64url')
1682
+ }
1683
+
1684
+ /** Constant-time string comparison — length mismatch returns false without throwing. */
1685
+ function safeEqual(a, b) {
1686
+ const ba = Buffer.from(String(a))
1687
+ const bb = Buffer.from(String(b))
1688
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb)
1689
+ }
1690
+
1588
1691
  function notFoundHtml(pathname) {
1589
1692
  return `<!DOCTYPE html>
1590
1693
  <html lang="en">
@@ -1246,6 +1246,206 @@ 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
+
1249
1449
  console.log('\nMalformed request handling\n')
1250
1450
 
1251
1451
  await test('malformed percent-encoding in path returns 404, not 500', async () => {
@@ -137,13 +137,30 @@ 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
+ // csrf — opt out of CSRF protection on the submit handler (default: enforced)
157
+ if (spec.csrf !== undefined) {
158
+ if (typeof spec.csrf !== 'boolean') {
159
+ 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)')
160
+ }
161
+ if (spec.csrf === false && typeof spec.submit === 'function') {
162
+ 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).')
163
+ }
147
164
  }
148
165
 
149
166
  // contentType — marks a raw content spec (RSS, sitemap, JSON API, etc.)
@@ -412,6 +412,39 @@ 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
+
415
448
  // ---------------------------------------------------------------------------
416
449
 
417
450
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
@@ -24,6 +24,6 @@ export function dropCap({
24
24
  const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
25
25
 
26
26
  return `<p class="${e(classes)}">
27
- <span class="ui-drop-cap-letter" style="float:left;font-size:${lines * 1.2}em;line-height:${lines * 0.8};margin-right:0.1em" aria-hidden="true">${e(letter)}</span>${e(content)}
27
+ <span class="ui-drop-cap-letter" style="font-size:${+(lines * 1.2).toFixed(2)}em" aria-hidden="true">${e(letter)}</span>${e(content)}
28
28
  </p>`
29
29
  }
package/src/ui/gallery.js CHANGED
@@ -11,6 +11,9 @@
11
11
  * @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
12
12
  * @param {boolean} opts.rounded - Rounded corners on images
13
13
  * @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
14
+ * @param {boolean} opts.lazy - loading="lazy" on images (default: true). Set false for
15
+ * above-the-fold galleries or when full-page screenshots
16
+ * during development need every image rendered eagerly.
14
17
  * @param {string} opts.class
15
18
  */
16
19
 
@@ -26,6 +29,7 @@ export function gallery({
26
29
  gap = 'md',
27
30
  rounded = false,
28
31
  ratio = '4/3',
32
+ lazy = true,
29
33
  class: cls = '',
30
34
  } = {}) {
31
35
  if (!LAYOUTS.has(layout)) layout = 'grid'
@@ -42,7 +46,7 @@ export function gallery({
42
46
  ].filter(Boolean).join(' ')
43
47
 
44
48
  const items = images.map(img => {
45
- const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img" loading="lazy" decoding="async">`
49
+ const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img"${lazy ? ' loading="lazy"' : ''} decoding="async">`
46
50
  const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
47
51
  const inner = img.href
48
52
  ? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
package/src/ui/ui.test.js CHANGED
@@ -25,6 +25,8 @@ import { pricing } from './pricing.js'
25
25
  import { accordion } from './accordion.js'
26
26
  import { nav } from './nav.js'
27
27
  import { appBadge } from './app-badge.js'
28
+ import { dropCap } from './drop-cap.js'
29
+ import { gallery } from './gallery.js'
28
30
  import { container } from './container.js'
29
31
  import { section } from './section.js'
30
32
  import { grid } from './grid.js'
@@ -1319,4 +1321,36 @@ test('uiImage: escapes src to prevent XSS', () => {
1319
1321
  assert.doesNotMatch(html, /<script>/)
1320
1322
  })
1321
1323
 
1324
+ test('dropCap: inline style carries only the dynamic font-size — alignment lives in CSS', () => {
1325
+ // Regression: a relative inline line-height (lines * 0.8 → 2.4 for 3 lines)
1326
+ // made the float box ~3x the glyph height, so serif caps floated below the
1327
+ // opening line instead of top-aligning. line-height/float/margin now live in
1328
+ // .ui-drop-cap-letter (pulse-ui.css) with a fixed ~cap-height value.
1329
+ const html = dropCap({ letter: 'O', content: 'nce upon a time.' })
1330
+ assert.match(html, /class="ui-drop-cap-letter"/)
1331
+ assert.match(html, /style="font-size:3\.6em"/)
1332
+ assert.doesNotMatch(html, /line-height/)
1333
+ assert.doesNotMatch(html, /float:left/)
1334
+ })
1335
+
1336
+ test('dropCap: lines option scales the font-size', () => {
1337
+ const html = dropCap({ letter: 'O', content: 'nce.', lines: 5 })
1338
+ assert.match(html, /style="font-size:6em"/)
1339
+ })
1340
+
1341
+ test('gallery: images are lazy by default', () => {
1342
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }] })
1343
+ assert.match(html, /loading="lazy"/)
1344
+ })
1345
+
1346
+ test('gallery: lazy:false renders eager images', () => {
1347
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }], lazy: false })
1348
+ assert.doesNotMatch(html, /loading="lazy"/)
1349
+ })
1350
+
1351
+ test('dropCap: escapes letter and content', () => {
1352
+ const html = dropCap({ letter: '<', content: '<script>alert(1)</script>' })
1353
+ assert.doesNotMatch(html, /<script>/)
1354
+ })
1355
+
1322
1356
  console.log('✓ All UI component tests passed')
package/types/schema.d.ts CHANGED
@@ -65,6 +65,16 @@ 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
+
68
78
  export type GuardResult =
69
79
  | { redirect: string }
70
80
  | { status: number; json?: unknown; body?: string; headers?: Record<string, string> }
@@ -131,7 +141,10 @@ export interface MetaConfig {
131
141
  // ---------------------------------------------------------------------------
132
142
 
133
143
  export interface PulseSpec<S extends object = Record<string, unknown>> {
134
- /** URL pattern. Supports :param segments. */
144
+ /**
145
+ * URL pattern. Supports :param segments.
146
+ * Use '*' for the custom not-found page — rendered with status 404 when no route matches.
147
+ */
135
148
  route: string
136
149
 
137
150
  /**
@@ -194,9 +207,25 @@ export interface PulseSpec<S extends object = Record<string, unknown>> {
194
207
  /** Global store keys this page subscribes to. Appear in view's server arg. */
195
208
  store?: string[]
196
209
 
197
- /** HTTP methods this page accepts. Default ['GET', 'HEAD']. */
210
+ /** HTTP methods this page accepts. Default ['GET', 'HEAD'] (plus POST when submit is set). */
198
211
  methods?: Array<'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'>
199
212
 
213
+ /**
214
+ * Server-side form handler — POST to this route works without client JS.
215
+ * Return { redirect } for a 303 POST-redirect-GET; any other value re-renders
216
+ * the page with the value exposed to the view as serverState.form (an optional
217
+ * status field sets the response status, e.g. 422).
218
+ * CSRF protection is enforced automatically: the view must include
219
+ * ${server.csrf} inside the <form> or POSTs are rejected with 403.
220
+ */
221
+ submit?: (ctx: RequestContext) => Promise<SubmitResult | void> | SubmitResult | void
222
+
223
+ /**
224
+ * Set false to disable CSRF protection on the submit handler.
225
+ * Only for endpoints with their own authentication (e.g. signed webhooks).
226
+ */
227
+ csrf?: boolean
228
+
200
229
  /**
201
230
  * Called before server data fetchers on every request.
202
231
  * Return { redirect } to redirect, { status, json } for a custom response,
package/types/server.d.ts CHANGED
@@ -72,6 +72,13 @@ 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
+
75
82
  /**
76
83
  * Extra CSP sources to merge into the framework's default Content-Security-Policy.
77
84
  * Use this to allow external stylesheets, fonts, or API origins.