@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.
@@ -102,6 +102,40 @@ actions: {
102
102
  ```
103
103
  `_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
104
104
 
105
+ ## Server-side forms — `spec.submit` (works without client JS)
106
+
107
+ A spec with a `submit` handler accepts POST on its own route. The form works with JavaScript disabled — hydrated `actions` become an enhancement, not a requirement.
108
+
109
+ ```js
110
+ export default {
111
+ route: '/contact',
112
+ submit: async (ctx) => {
113
+ const data = await ctx.formData()
114
+ if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
115
+ if (!isValid(data.email)) return { status: 422, errors: { email: 'Invalid email' }, values: data }
116
+ await sendMessage(data)
117
+ return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
118
+ },
119
+ view: (state, server) => `
120
+ <main id="main-content">
121
+ ${server.form?.errors?.email ? `<p class="error">${escHtml(server.form.errors.email)}</p>` : ''}
122
+ <form method="POST">
123
+ ${server.csrf}
124
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
125
+ ${button({ label: 'Send', type: 'submit' })}
126
+ </form>
127
+ </main>`,
128
+ }
129
+ ```
130
+
131
+ **Rules:**
132
+ - `${server.csrf}` **must be inside every POST form** — CSRF protection is automatic and POSTs without a valid token are rejected with 403. Opt out with `csrf: false` only for endpoints with their own authentication (e.g. signed webhooks).
133
+ - `{ redirect }` → 303 See Other. Always redirect after a successful mutation — rendering success directly from the POST means refresh resubmits the form.
134
+ - Any other return value re-renders the page with it exposed as `server.form` — put `errors` and the submitted `values` there, and echo values back into inputs so failed validation doesn't wipe what the user typed. An optional `status` field sets the response status (e.g. 422).
135
+ - Escape user-submitted values before interpolating them into HTML (`escHtml`).
136
+ - **Progressive enhancement:** add `data-action="send"` to the same form — hydrated visitors get the async action with no page reload; no-JS visitors fall back to the POST.
137
+ - Multi-instance deployments: pass a stable `secret` to `createServer` (e.g. from an env var) so CSRF tokens issued by one instance validate on another.
138
+
105
139
  ## Server context — redirects, cookies, POST bodies
106
140
 
107
141
  The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
@@ -46,10 +46,31 @@ export default {
46
46
  // Resolved server-side before render. Passed to view() as second arg.
47
47
  posts: async () => fetchPostsFromDb(),
48
48
  },
49
+ // Server-side form handling — POST works with JavaScript disabled.
50
+ // { redirect } → 303 PRG; anything else → re-render with the value as server.form.
51
+ // CSRF enforced automatically: the view MUST include ${server.csrf} inside the <form>.
52
+ submit: async (ctx) => {
53
+ const data = await ctx.formData()
54
+ if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
55
+ await save(data)
56
+ return { redirect: '/thanks' }
57
+ },
49
58
  view: (state, serverState) => `<h1>${state.title}</h1>`,
50
59
  }
51
60
  ```
52
61
 
62
+ ## Server-side forms (`submit`)
63
+
64
+ - A spec with `submit` accepts POST on its own route — plain `<form method="POST">`, no client JS needed.
65
+ - **Always put `${server.csrf}` inside the form** — it renders the hidden CSRF input. POSTs without a valid token are rejected with 403. (`csrf: false` opts out — only for endpoints with their own auth.)
66
+ - `server.form` holds the return value of `submit()` on a re-render: show `server.form?.errors`, echo `server.form?.values` back into inputs via `value=` so failed validation doesn't wipe the form.
67
+ - After a successful mutation always `return { redirect: '/path' }` (POST-redirect-GET) — never render success from the POST itself. An optional `status` field on a re-render return sets the response status (e.g. 422).
68
+ - Progressive enhancement: the same form can also have `data-action="name"` — hydrated visitors get the async action, no-JS visitors use the POST.
69
+
70
+ ## Custom not-found page
71
+
72
+ A spec with `route: '*'` renders with **status 404** whenever no route matches — same pipeline as any page (layout, styles, hydration, validation). Every site should have one; the default framework 404 is unbranded.
73
+
53
74
  ## Streaming SSR — shell + deferred segments
54
75
 
55
76
  Use streaming when a page has slow data and you want the shell to paint instantly while slower content streams in.
@@ -36,7 +36,7 @@ export async function renderToString(spec, ctx = {}, pageState = {}) {
36
36
  const serverState = await resolveServerState(spec, ctx)
37
37
 
38
38
  // Merge declared store keys into server state — store keys lose to page-level keys
39
- const mergedServerState = mergeStoreKeys(spec, serverState, ctx.store)
39
+ const mergedServerState = applyCtxExtras(mergeStoreKeys(spec, serverState, ctx.store), ctx)
40
40
 
41
41
  const tData = performance.now()
42
42
 
@@ -105,7 +105,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
105
105
 
106
106
  // Await only the fetchers the shell segments need
107
107
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
108
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
108
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
109
109
 
110
110
  // Write shell immediately — deferred fetchers may still be in-flight
111
111
  const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
@@ -122,7 +122,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
122
122
  // Promise.all runs them concurrently — the fastest segment wins.
123
123
  await Promise.all(deferred.map(async (key) => {
124
124
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
125
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
125
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
126
126
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
127
127
 
128
128
  // Inline script replaces the placeholder with the rendered content
@@ -145,7 +145,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
145
145
  const allServerState = fetcherCache.size > 0
146
146
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
147
147
  : {}
148
- const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
148
+ const mergedAllState = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
149
149
  if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
151
  `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
@@ -218,7 +218,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
218
218
 
219
219
  // 2. Await shell fetchers then send shell HTML
220
220
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
221
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
221
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
222
222
  let shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
223
223
 
224
224
  // Append <pulse-deferred> placeholders so the client knows where to inject deferred content
@@ -231,7 +231,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
231
231
  // 3. Stream each deferred segment as soon as its own fetchers resolve
232
232
  await Promise.all(deferred.map(async (key) => {
233
233
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
234
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
234
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
235
235
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
236
236
  writeLine({ type: 'deferred', id: key, html: segHtml })
237
237
  }))
@@ -240,7 +240,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
240
240
  const allServerState = fetcherCache.size > 0
241
241
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
242
242
  : {}
243
- const mergedAll = mergeStoreKeys(spec, allServerState, ctx.store)
243
+ const mergedAll = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
244
244
 
245
245
  writeLine({
246
246
  type: 'done',
@@ -426,6 +426,29 @@ function mergeStoreKeys(spec, serverState, storeState) {
426
426
  return { ...slice, ...serverState }
427
427
  }
428
428
 
429
+ /**
430
+ * Merge per-request extras set by the server into the view's server state:
431
+ *
432
+ * ctx._form → serverState.form — the return value of spec.submit() on a
433
+ * POST re-render (validation errors, submitted values, etc.)
434
+ * ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
435
+ * views include it inside <form method="POST"> as ${server.csrf}
436
+ *
437
+ * Only set by the server for specs that declare `submit` — other pages see
438
+ * neither key. Explicit server fetcher keys with the same names win.
439
+ *
440
+ * @param {Object} serverState
441
+ * @param {Object} ctx
442
+ * @returns {Object}
443
+ */
444
+ function applyCtxExtras(serverState, ctx) {
445
+ if (ctx?._form === undefined && ctx?._csrfField === undefined) return serverState
446
+ const extras = {}
447
+ if (ctx._form !== undefined) extras.form = ctx._form
448
+ if (ctx._csrfField !== undefined) extras.csrf = ctx._csrfField
449
+ return { ...extras, ...serverState }
450
+ }
451
+
429
452
  // ---------------------------------------------------------------------------
430
453
  // Internal — server state resolution
431
454
  // ---------------------------------------------------------------------------
@@ -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,13 @@ 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
549
+ sitemap = false, // true | { origin } — auto-serve /sitemap.xml + robots.txt from
550
+ // the registered routes. Dynamic :param routes contribute via
551
+ // spec.sitemap = async () => ['/blog/a', ...]
552
+ robots = null, // robots.txt content when sitemap is enabled:
553
+ // null = auto-generate, false = disable, string = serve verbatim
545
554
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
546
555
  onRequest,
547
556
  agentMode = false, // show agent-active indicator in banner
@@ -585,8 +594,8 @@ export async function createServer(entries, options = {}) {
585
594
  const routeErrors = []
586
595
  if (!spec.route || typeof spec.route !== 'string') {
587
596
  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")')
597
+ } else if (!spec.route.startsWith('/') && spec.route !== '*') {
598
+ routeErrors.push('spec.route must start with "/" (e.g. "/contact"), or be "*" for the custom not-found page')
590
599
  }
591
600
  const allErrors = [...errors, ...routeErrors]
592
601
  if (!valid || routeErrors.length > 0) {
@@ -597,8 +606,32 @@ export async function createServer(entries, options = {}) {
597
606
  }
598
607
  }
599
608
 
600
- // Build route table — let so it can be swapped on hot reload
601
- let router = buildRouter(specs)
609
+ // Custom not-found page — a spec with route '*' renders (with status 404)
610
+ // whenever no route matches. It goes through the normal render pipeline, so
611
+ // it gets the site's layout, styles, hydration, and validation like any page.
612
+ let notFoundSpec = specs.find(s => s.route === '*') || null
613
+
614
+ // Build route table — let so it can be swapped on hot reload.
615
+ // The '*' spec is excluded: it is not a route, it is the fallback.
616
+ let router = buildRouter(specs.filter(s => s.route !== '*'))
617
+
618
+ // CSRF secret — per-boot random unless pinned via options.secret. Tokens are
619
+ // tied to a cookie, so a restart only means in-flight forms re-render once.
620
+ const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
621
+
622
+ // Sitemap — track the live spec list (updated on hot reload) and surface a
623
+ // startup hint for dynamic routes that can't be enumerated automatically.
624
+ const sitemapEnabled = !!sitemap
625
+ const sitemapOrigin = (typeof sitemap === 'object' && sitemap?.origin) ? String(sitemap.origin).replace(/\/$/, '') : null
626
+ let allSpecs = specs
627
+ if (sitemapEnabled && !quiet) {
628
+ const unenumerated = specs.filter(s =>
629
+ !s.contentType && s.route !== '*' && s.route.includes(':') && s.sitemap === undefined
630
+ )
631
+ if (unenumerated.length > 0) {
632
+ console.log(` ℹ sitemap: ${unenumerated.length} dynamic route${unenumerated.length === 1 ? '' : 's'} not in the sitemap — add \`sitemap: async () => ['/path', …]\` to enumerate: ${unenumerated.map(s => s.route).join(', ')}`)
633
+ }
634
+ }
602
635
 
603
636
  // Resolve the logger module once at startup rather than dynamically importing
604
637
  // it on every request's `finish` event. `logRequest` stays null until loaded.
@@ -644,6 +677,39 @@ export async function createServer(entries, options = {}) {
644
677
  if (served) return
645
678
  }
646
679
 
680
+ // Auto-generated sitemap.xml + robots.txt — after static serving so a
681
+ // physical file in staticDir always wins over the generated one.
682
+ if (sitemapEnabled && (req.method === 'GET' || req.method === 'HEAD') &&
683
+ (pathname === '/sitemap.xml' || (pathname === '/robots.txt' && robots !== false))) {
684
+ const origin = sitemapOrigin ||
685
+ `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`}`
686
+
687
+ let body, contentType
688
+ if (pathname === '/robots.txt') {
689
+ contentType = 'text/plain; charset=utf-8'
690
+ body = typeof robots === 'string'
691
+ ? robots
692
+ : `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
693
+ } else {
694
+ contentType = 'application/xml; charset=utf-8'
695
+ const entries = await collectSitemapEntries(allSpecs, trailingSlash)
696
+ body = buildSitemapXml(entries, origin)
697
+ }
698
+
699
+ const encoding = negotiateEncoding(req)
700
+ const buf = await compressBuffer(Buffer.from(body, 'utf8'), encoding)
701
+ const headers = {
702
+ 'Content-Type': contentType,
703
+ 'Cache-Control': dev ? 'no-store' : 'public, max-age=3600',
704
+ 'Vary': 'Accept-Encoding',
705
+ ...SECURITY_HEADERS, ...httpsHeaders(req),
706
+ }
707
+ if (encoding) headers['Content-Encoding'] = encoding
708
+ res.writeHead(200, headers)
709
+ res.end(req.method === 'HEAD' ? undefined : buf)
710
+ return
711
+ }
712
+
647
713
  // Trailing slash normalisation — GET/HEAD only (redirects don't apply to POST etc.)
648
714
  if ((req.method === 'GET' || req.method === 'HEAD') && pathname !== '/') {
649
715
  if (trailingSlash === 'remove' && pathname.endsWith('/')) {
@@ -662,20 +728,32 @@ export async function createServer(entries, options = {}) {
662
728
  }
663
729
 
664
730
  // Match route
665
- const match = matchRoute(router, pathname)
731
+ let match = matchRoute(router, pathname)
732
+ let responseStatus = 200
666
733
 
667
734
  if (!match) {
668
- res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
669
- res.end(notFoundHtml(pathname))
670
- return
735
+ // Custom not-found page — a '*' spec renders through the normal pipeline
736
+ // with status 404, so the site's branded 404 gets layout, styles, and
737
+ // hydration like any other page. Client-side navigation requests still
738
+ // get the plain 404 (navigate.js falls back to a full page load, which
739
+ // then renders the custom page).
740
+ if (notFoundSpec && (req.method === 'GET' || req.method === 'HEAD') && req.headers['x-pulse-navigate'] !== 'true') {
741
+ match = { spec: notFoundSpec, params: {} }
742
+ responseStatus = 404
743
+ } else {
744
+ res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
745
+ res.end(notFoundHtml(pathname))
746
+ return
747
+ }
671
748
  }
672
749
 
673
750
  // 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.
751
+ // Page specs default to GET/HEAD only; spec.submit adds POST automatically;
752
+ // opt in to other methods via spec.methods.
675
753
  if (!match.spec.contentType) {
676
754
  const allowed = match.spec.methods
677
755
  ? match.spec.methods.map(m => m.toUpperCase())
678
- : ['GET', 'HEAD']
756
+ : (typeof match.spec.submit === 'function' ? ['GET', 'HEAD', 'POST'] : ['GET', 'HEAD'])
679
757
  if (!allowed.includes(req.method)) {
680
758
  res.writeHead(405, {
681
759
  'Content-Type': 'text/plain',
@@ -738,6 +816,57 @@ export async function createServer(entries, options = {}) {
738
816
  }
739
817
  }
740
818
 
819
+ // -----------------------------------------------------------------------
820
+ // CSRF — specs with a `submit` handler get token protection automatically
821
+ // (opt out per spec with csrf: false, e.g. for token-authenticated routes).
822
+ // Double-submit pattern: an HttpOnly cookie holds a random id; the form
823
+ // carries HMAC(secret, id) in a hidden _csrf field; POST verifies the pair.
824
+ // Views embed the field inside <form method="POST"> as ${server.csrf}.
825
+ // -----------------------------------------------------------------------
826
+ if (typeof spec.submit === 'function' && spec.csrf !== false) {
827
+ let csrfId = ctx.cookies.pulse_csrf
828
+ if (!csrfId) {
829
+ csrfId = crypto.randomBytes(16).toString('base64url')
830
+ ctx.setCookie('pulse_csrf', csrfId, { httpOnly: true, sameSite: 'Lax', path: '/' })
831
+ }
832
+ ctx._csrfField = `<input type="hidden" name="_csrf" value="${csrfToken(csrfSecret, csrfId)}">`
833
+
834
+ if (req.method === 'POST') {
835
+ const form = await ctx.formData()
836
+ const submitted = typeof form?._csrf === 'string' ? form._csrf : ''
837
+ const cookieId = ctx.cookies.pulse_csrf
838
+ if (!cookieId || !safeEqual(submitted, csrfToken(csrfSecret, cookieId))) {
839
+ res.writeHead(403, mergeCtxHeaders(ctx, {
840
+ 'Content-Type': 'text/plain',
841
+ ...SECURITY_HEADERS, ...httpsHeaders(req),
842
+ }))
843
+ res.end('Invalid or missing CSRF token. Reload the page and resubmit the form.')
844
+ return
845
+ }
846
+ }
847
+ }
848
+
849
+ // -----------------------------------------------------------------------
850
+ // Server-side form handling — spec.submit handles POST without client JS.
851
+ // { redirect: '/path' } → 303 See Other (POST-redirect-GET)
852
+ // anything else → re-render the page; the return value appears
853
+ // in the view as serverState.form (errors,
854
+ // submitted values, etc.). Optional status field
855
+ // sets the response status (e.g. 422).
856
+ // -----------------------------------------------------------------------
857
+ if (req.method === 'POST' && typeof spec.submit === 'function') {
858
+ const result = await spec.submit(ctx)
859
+ if (result?.redirect) {
860
+ res.writeHead(303, mergeCtxHeaders(ctx, { Location: result.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
861
+ res.end()
862
+ return
863
+ }
864
+ ctx._form = result ?? {}
865
+ if (typeof result?.status === 'number') responseStatus = result.status
866
+ // POST re-renders always use the buffered path — the request body is
867
+ // consumed and streaming a form response has no benefit.
868
+ }
869
+
741
870
  // Derive the canonical base URL from the request.
742
871
  // Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
743
872
  // spec.meta.canonical (string or function) is resolved inside each handler, where serverState
@@ -776,7 +905,12 @@ export async function createServer(entries, options = {}) {
776
905
  return
777
906
  }
778
907
 
779
- if (stream) {
908
+ // Non-200 renders (custom 404) and POST re-renders always use the buffered
909
+ // string path — they are small, must carry an explicit status, and POST
910
+ // bodies are already consumed.
911
+ if (responseStatus !== 200 || req.method === 'POST') {
912
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus)
913
+ } else if (stream) {
780
914
  await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
781
915
  } else {
782
916
  await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
@@ -865,7 +999,9 @@ export async function createServer(entries, options = {}) {
865
999
  server,
866
1000
  shutdown,
867
1001
  updateSpecs(newSpecs) {
868
- router = buildRouter(newSpecs)
1002
+ notFoundSpec = newSpecs.find(s => s.route === '*') || null
1003
+ router = buildRouter(newSpecs.filter(s => s.route !== '*'))
1004
+ allSpecs = newSpecs
869
1005
  }
870
1006
  }
871
1007
  }
@@ -943,9 +1079,12 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
943
1079
  * Render to a complete string then send — simpler, easier to cache.
944
1080
  * Checks the in-process page cache before rendering; stores result after.
945
1081
  */
946
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
1082
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200) {
947
1083
  const cacheKey = buildCacheKey(spec, ctx)
948
- const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
1084
+ // Never cache POST re-renders or non-200 responses (custom 404s) — POST output
1085
+ // varies per submission and a cached 404 body would be served with a 200 later.
1086
+ const cacheable = status === 200 && ctx.method !== 'POST' && isCacheablePage(spec)
1087
+ const ttl = cacheable ? pageCacheTtl(spec, dev, defaultCache) : 0
949
1088
 
950
1089
  let html, serverTimingValue, fromCache = false
951
1090
 
@@ -984,7 +1123,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
984
1123
  if (encoding) headers['Content-Encoding'] = encoding
985
1124
  if (serverTimingValue) headers['Server-Timing'] = serverTimingValue
986
1125
 
987
- res.writeHead(200, headers)
1126
+ res.writeHead(status, headers)
988
1127
  res.end(buf)
989
1128
  }
990
1129
 
@@ -1585,6 +1724,111 @@ function escHtml(str) {
1585
1724
  .replace(/'/g, '&#39;')
1586
1725
  }
1587
1726
 
1727
+ /**
1728
+ * Collect sitemap entries from the registered specs.
1729
+ *
1730
+ * Inclusion rules:
1731
+ * - page specs with static routes are included automatically
1732
+ * - route '*' (not-found), raw content specs, and spec.sitemap === false are excluded
1733
+ * - guarded pages are excluded by default (likely auth-gated) — set spec.sitemap
1734
+ * to true/array/function to include them explicitly
1735
+ * - dynamic :param routes contribute only via spec.sitemap — an array of paths
1736
+ * or an async function returning one. Entries can be strings or
1737
+ * { path, lastmod } objects.
1738
+ *
1739
+ * @param {Array} specs
1740
+ * @param {'remove'|'add'|'allow'} trailingSlash
1741
+ * @returns {Promise<Array<{ path: string, lastmod?: string }>>}
1742
+ */
1743
+ async function collectSitemapEntries(specs, trailingSlash) {
1744
+ const entries = []
1745
+
1746
+ const normalise = (item) => {
1747
+ const e = typeof item === 'string' ? { path: item } : { path: item?.path, lastmod: item?.lastmod }
1748
+ if (!e.path || typeof e.path !== 'string' || !e.path.startsWith('/')) return null
1749
+ // Apply the trailing-slash mode so sitemap URLs match the canonical form
1750
+ if (e.path !== '/') {
1751
+ if (trailingSlash === 'add' && !e.path.endsWith('/')) e.path = e.path + '/'
1752
+ if (trailingSlash !== 'add' && e.path.endsWith('/')) e.path = e.path.slice(0, -1)
1753
+ }
1754
+ return e
1755
+ }
1756
+
1757
+ for (const spec of specs) {
1758
+ if (spec.contentType || spec.route === '*' || spec.sitemap === false) continue
1759
+
1760
+ const isDynamic = spec.route.includes(':')
1761
+
1762
+ if (typeof spec.sitemap === 'function') {
1763
+ try {
1764
+ const list = await spec.sitemap()
1765
+ for (const item of (Array.isArray(list) ? list : [])) {
1766
+ const e = normalise(item)
1767
+ if (e) entries.push(e)
1768
+ }
1769
+ } catch (err) {
1770
+ // An enumerator failure must not take down the whole sitemap
1771
+ console.error(`[Pulse] sitemap enumerator for "${spec.route}" failed:`, err.message)
1772
+ }
1773
+ continue
1774
+ }
1775
+
1776
+ if (Array.isArray(spec.sitemap)) {
1777
+ for (const item of spec.sitemap) {
1778
+ const e = normalise(item)
1779
+ if (e) entries.push(e)
1780
+ }
1781
+ continue
1782
+ }
1783
+
1784
+ if (isDynamic) continue // no enumerator → skip
1785
+ if (typeof spec.guard === 'function' && spec.sitemap !== true) continue // auth-gated by default
1786
+
1787
+ const e = normalise(spec.route)
1788
+ if (e) entries.push(e)
1789
+ }
1790
+
1791
+ // Dedupe by path, preserving first occurrence (which may carry lastmod)
1792
+ const seen = new Set()
1793
+ return entries.filter(e => seen.has(e.path) ? false : (seen.add(e.path), true))
1794
+ }
1795
+
1796
+ /** Escape a value for XML text content. */
1797
+ function escXml(str) {
1798
+ return String(str)
1799
+ .replace(/&/g, '&amp;')
1800
+ .replace(/</g, '&lt;')
1801
+ .replace(/>/g, '&gt;')
1802
+ .replace(/"/g, '&quot;')
1803
+ .replace(/'/g, '&apos;')
1804
+ }
1805
+
1806
+ /** Build the sitemap XML document from collected entries. */
1807
+ function buildSitemapXml(entries, origin) {
1808
+ const urls = entries.map(e => {
1809
+ const lastmod = e.lastmod ? `\n <lastmod>${escXml(e.lastmod)}</lastmod>` : ''
1810
+ return ` <url>\n <loc>${escXml(origin + e.path)}</loc>${lastmod}\n </url>`
1811
+ }).join('\n')
1812
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`
1813
+ }
1814
+
1815
+ /**
1816
+ * Derive the CSRF token for a cookie id: HMAC-SHA256(secret, id), base64url.
1817
+ * The token goes in the form's hidden _csrf field; the id stays in an HttpOnly
1818
+ * cookie. An attacker on another origin can trigger the cookie being sent but
1819
+ * cannot read it or compute the HMAC, so they cannot forge the pair.
1820
+ */
1821
+ function csrfToken(secret, id) {
1822
+ return crypto.createHmac('sha256', secret).update(String(id)).digest('base64url')
1823
+ }
1824
+
1825
+ /** Constant-time string comparison — length mismatch returns false without throwing. */
1826
+ function safeEqual(a, b) {
1827
+ const ba = Buffer.from(String(a))
1828
+ const bb = Buffer.from(String(b))
1829
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb)
1830
+ }
1831
+
1588
1832
  function notFoundHtml(pathname) {
1589
1833
  return `<!DOCTYPE html>
1590
1834
  <html lang="en">