@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
|
@@ -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.
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -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.
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
//
|
|
601
|
-
|
|
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
|
-
|
|
731
|
+
let match = matchRoute(router, pathname)
|
|
732
|
+
let responseStatus = 200
|
|
666
733
|
|
|
667
734
|
if (!match) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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, ''')
|
|
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, '&')
|
|
1800
|
+
.replace(/</g, '<')
|
|
1801
|
+
.replace(/>/g, '>')
|
|
1802
|
+
.replace(/"/g, '"')
|
|
1803
|
+
.replace(/'/g, ''')
|
|
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">
|