@invisibleloop/pulse 0.17.2 → 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.
- package/.claude/pulse-checklist.md +8 -0
- package/CLAUDE.md +53 -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/routing.js +7 -9
- package/docs/src/pages/server-api.js +2 -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 +21 -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 +119 -16
- package/src/server/server.test.js +200 -0
- package/src/spec/schema.js +20 -3
- package/src/spec/schema.test.js +33 -0
- package/types/schema.d.ts +31 -2
- package/types/server.d.ts +7 -0
|
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
113
113
|
### Security
|
|
114
114
|
|
|
115
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
116
124
|
|
|
117
125
|
### Tests
|
|
118
126
|
|
package/CLAUDE.md
CHANGED
|
@@ -41,7 +41,8 @@ A spec is a plain JS object. Every property is optional except `route` and `view
|
|
|
41
41
|
|
|
42
42
|
```js
|
|
43
43
|
export const mySpec = {
|
|
44
|
-
route: '/path', // URL pattern, supports :params
|
|
44
|
+
route: '/path', // URL pattern, supports :params. Use '*' for the custom
|
|
45
|
+
// not-found page — rendered with status 404 when no route matches
|
|
45
46
|
|
|
46
47
|
meta: {
|
|
47
48
|
title: 'Page Title',
|
|
@@ -121,6 +122,19 @@ export const mySpec = {
|
|
|
121
122
|
}
|
|
122
123
|
},
|
|
123
124
|
|
|
125
|
+
// Server-side form handling — POST to this route, no client JS required.
|
|
126
|
+
// CSRF protection is automatic: include ${server.csrf} inside the <form>.
|
|
127
|
+
// { redirect: '/path' } → 303 POST-redirect-GET
|
|
128
|
+
// anything else → page re-renders; return value appears as server.form
|
|
129
|
+
// (optional status field sets the response status, e.g. 422)
|
|
130
|
+
submit: async (ctx) => {
|
|
131
|
+
const data = await ctx.formData()
|
|
132
|
+
if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
|
|
133
|
+
await saveSignup(data)
|
|
134
|
+
return { redirect: '/thanks' }
|
|
135
|
+
},
|
|
136
|
+
// csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
|
|
137
|
+
|
|
124
138
|
// Streaming SSR — split view into shell (instant) + deferred segments
|
|
125
139
|
stream: {
|
|
126
140
|
shell: ['header', 'nav'],
|
|
@@ -141,6 +155,42 @@ export const mySpec = {
|
|
|
141
155
|
export default mySpec // required for hydration imports
|
|
142
156
|
```
|
|
143
157
|
|
|
158
|
+
## Server-Side Forms (no client JS)
|
|
159
|
+
|
|
160
|
+
A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
view: (state, server) => `
|
|
164
|
+
<main id="main-content">
|
|
165
|
+
${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
|
|
166
|
+
<form method="POST">
|
|
167
|
+
${server.csrf}
|
|
168
|
+
${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
|
|
169
|
+
${button({ label: 'Sign up', type: 'submit' })}
|
|
170
|
+
</form>
|
|
171
|
+
</main>`,
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
|
|
175
|
+
- **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
|
|
176
|
+
- Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
|
|
177
|
+
- Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
|
|
178
|
+
|
|
179
|
+
## Custom 404 Page
|
|
180
|
+
|
|
181
|
+
Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// src/pages/not-found.js
|
|
185
|
+
export default {
|
|
186
|
+
route: '*',
|
|
187
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
188
|
+
view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
|
|
193
|
+
|
|
144
194
|
## HTML Event Binding
|
|
145
195
|
|
|
146
196
|
Pulse binds to DOM attributes — no JSX, no templates.
|
|
@@ -230,6 +280,8 @@ await createServer(
|
|
|
230
280
|
// spec.serverTimeout overrides per-page
|
|
231
281
|
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
232
282
|
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
283
|
+
secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
|
|
284
|
+
// when running multiple instances (default: random per boot)
|
|
233
285
|
csp: { // extra sources merged into the framework's default CSP
|
|
234
286
|
'style-src': ['https://fonts.googleapis.com'],
|
|
235
287
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
113
113
|
### Security
|
|
114
114
|
|
|
115
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
116
124
|
|
|
117
125
|
### Tests
|
|
118
126
|
|
package/docs/server.js
CHANGED
|
@@ -45,9 +45,11 @@ await createServer(
|
|
|
45
45
|
new URL('./src/pages/markdown.js', import.meta.url),
|
|
46
46
|
new URL('./src/pages/store.js', import.meta.url),
|
|
47
47
|
new URL('./src/pages/routing.js', import.meta.url),
|
|
48
|
+
new URL('./src/pages/error-pages.js', import.meta.url),
|
|
48
49
|
new URL('./src/pages/streaming.js', import.meta.url),
|
|
49
50
|
new URL('./src/pages/caching.js', import.meta.url),
|
|
50
51
|
new URL('./src/pages/guard.js', import.meta.url),
|
|
52
|
+
new URL('./src/pages/forms.js', import.meta.url),
|
|
51
53
|
new URL('./src/pages/raw-responses.js', import.meta.url),
|
|
52
54
|
new URL('./src/pages/server-api.js', import.meta.url),
|
|
53
55
|
new URL('./src/pages/extending.js', import.meta.url),
|
package/docs/src/lib/nav.js
CHANGED
|
@@ -41,9 +41,11 @@ export const NAV = [
|
|
|
41
41
|
{ label: 'Markdown', href: '/markdown' },
|
|
42
42
|
{ label: 'Global Store', href: '/store' },
|
|
43
43
|
{ label: 'Routing', href: '/routing' },
|
|
44
|
+
{ label: 'Error Pages', href: '/error-pages' },
|
|
44
45
|
{ label: 'Streaming SSR', href: '/streaming' },
|
|
45
46
|
{ label: 'Caching', href: '/caching' },
|
|
46
47
|
{ label: 'Guard', href: '/guard' },
|
|
48
|
+
{ label: 'Forms', href: '/forms' },
|
|
47
49
|
{ label: 'Raw Responses', href: '/raw-responses' },
|
|
48
50
|
{ label: 'Server API', href: '/server-api' },
|
|
49
51
|
{ label: 'Extending Pulse', href: '/extending' },
|
|
@@ -20,6 +20,8 @@ export default {
|
|
|
20
20
|
${h1('Actions')}
|
|
21
21
|
${lead('Actions handle async operations with an enforced lifecycle. The order of steps — capture inputs, validate, run, succeed or fail — is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.')}
|
|
22
22
|
|
|
23
|
+
${callout('tip', 'Actions run client-side and need hydration. For forms that must also work with JavaScript disabled, pair the action with a server-side <a href="/forms"><code>submit</code> handler</a> — the same form serves both paths.')}
|
|
24
|
+
|
|
23
25
|
${section('lifecycle', 'The action lifecycle')}
|
|
24
26
|
<p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
|
|
25
27
|
${codeBlock(highlight(`onStart(state, formData)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/error-pages')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/error-pages',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Error Pages — Pulse Docs',
|
|
11
|
+
description: 'Custom 404 pages in Pulse. A spec with route "*" renders through the normal pipeline with status 404 whenever no route matches.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
view: () => renderLayout({
|
|
15
|
+
currentHref: '/error-pages',
|
|
16
|
+
prev,
|
|
17
|
+
next,
|
|
18
|
+
content: `
|
|
19
|
+
${h1('Error Pages')}
|
|
20
|
+
${lead('A spec with <code>route: \'*\'</code> is your site\'s custom 404 page. It renders through the normal pipeline — layout, styles, components, hydration — with status <strong>404</strong> whenever no route matches. Without one, visitors to a bad URL get the framework\'s unbranded default.')}
|
|
21
|
+
|
|
22
|
+
${section('not-found', 'The custom 404 page')}
|
|
23
|
+
<p>Create a spec like any other page, with <code>'*'</code> as the route. Use the shared <code>layout()</code> so the page carries the site's nav and footer:</p>
|
|
24
|
+
${codeBlock(highlight(`import { layout } from '../components/layout.js'
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
route: '*',
|
|
28
|
+
meta: {
|
|
29
|
+
title: 'Page not found',
|
|
30
|
+
styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
|
|
31
|
+
},
|
|
32
|
+
view: () => layout({
|
|
33
|
+
content: \`
|
|
34
|
+
<h1>Page not found</h1>
|
|
35
|
+
<p>That page doesn't exist. <a href="/">Back home</a></p>
|
|
36
|
+
\`,
|
|
37
|
+
}),
|
|
38
|
+
}`, 'js', ), 'src/pages/not-found.js')}
|
|
39
|
+
<p>The filename doesn't matter — <code>not-found.js</code> is just a readable convention. The <code>route: '*'</code> property is what registers it as the fallback.</p>
|
|
40
|
+
|
|
41
|
+
${callout('tip', 'Every site should have one. The framework\'s default 404 is a bare unstyled page with no navigation — a dead end for visitors and a missed signal for search engines crawling old URLs.')}
|
|
42
|
+
|
|
43
|
+
${section('behaviour', 'How it behaves')}
|
|
44
|
+
${table(
|
|
45
|
+
['Aspect', 'Behaviour'],
|
|
46
|
+
[
|
|
47
|
+
['Status code', 'Always <strong>404</strong> — correct for SEO; crawlers de-index dead URLs'],
|
|
48
|
+
['Pipeline', 'Full normal render: <code>meta</code>, styles, components, hydration if it has mutations/actions'],
|
|
49
|
+
['Validation', 'Validated at startup like every spec — a broken 404 page fails fast'],
|
|
50
|
+
['Matching', 'Only when no other route matches. It is a fallback, not a route — it never shadows real pages'],
|
|
51
|
+
['Client-side navigation', 'A bad link falls back to a full page load, which renders the custom 404'],
|
|
52
|
+
['Caching', '404 responses are never stored in the in-process page cache'],
|
|
53
|
+
]
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
${section('useful-404', 'Making the 404 useful')}
|
|
57
|
+
<p>The best 404 pages help the visitor recover. Since the page is a normal spec, it can use any component — search, popular links, even server data:</p>
|
|
58
|
+
${codeBlock(highlight(`export default {
|
|
59
|
+
route: '*',
|
|
60
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
61
|
+
server: {
|
|
62
|
+
popular: async () => getPopularPages(), // suggest somewhere to go
|
|
63
|
+
},
|
|
64
|
+
view: (state, server) => layout({
|
|
65
|
+
content: \`
|
|
66
|
+
<h1>Page not found</h1>
|
|
67
|
+
<p>Try one of these instead:</p>
|
|
68
|
+
\${list({ items: (server.popular ?? []).map(p => \`<a href="\${p.href}">\${p.title}</a>\`) })}
|
|
69
|
+
\`,
|
|
70
|
+
}),
|
|
71
|
+
}`, 'js'))}
|
|
72
|
+
|
|
73
|
+
${section('server-errors', '500s and other errors')}
|
|
74
|
+
<p>Server errors are handled by <code>createServer</code>'s <code>onError</code> option, not a spec — when a render throws, the spec pipeline itself may be the thing that is broken, so the handler works at the HTTP level:</p>
|
|
75
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
76
|
+
onError: (err, req, res) => {
|
|
77
|
+
console.error(err)
|
|
78
|
+
if (res.headersSent) return
|
|
79
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
80
|
+
res.end('<h1>Something went wrong</h1><p><a href="/">Back home</a></p>')
|
|
81
|
+
},
|
|
82
|
+
})`, 'js'))}
|
|
83
|
+
<p>For errors thrown inside a <em>view</em> specifically, prefer <code>onViewError</code> on the spec — it returns a 200 with fallback HTML instead of a 500, keeping the page alive when one piece of data is malformed.</p>
|
|
84
|
+
|
|
85
|
+
${callout('note', 'Three layers, three jobs: <code>route: \'*\'</code> for URLs that don\'t exist, <code>onViewError</code> for views that throw on bad data, <code>onError</code> for everything else that goes wrong server-side.')}
|
|
86
|
+
|
|
87
|
+
${section('reference', 'Reference')}
|
|
88
|
+
${table(
|
|
89
|
+
['Property', 'Where', 'Notes'],
|
|
90
|
+
[
|
|
91
|
+
['<code>route: \'*\'</code>', 'spec', 'Custom not-found page — rendered with status 404 when no route matches'],
|
|
92
|
+
['<code>onViewError</code>', 'spec', 'Fallback HTML when the view throws — returns 200'],
|
|
93
|
+
['<code>onError</code>', '<code>createServer</code> option', 'HTTP-level handler for unhandled server errors (500s)'],
|
|
94
|
+
]
|
|
95
|
+
)}
|
|
96
|
+
`,
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/forms')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/forms',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Forms — Pulse Docs',
|
|
11
|
+
description: 'Server-side form handling in Pulse. spec.submit processes POST requests without client JavaScript, with automatic CSRF protection and POST-redirect-GET.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
view: () => renderLayout({
|
|
15
|
+
currentHref: '/forms',
|
|
16
|
+
prev,
|
|
17
|
+
next,
|
|
18
|
+
content: `
|
|
19
|
+
${h1('Forms')}
|
|
20
|
+
${lead('A spec with a <code>submit</code> handler accepts POST requests on its own route — the form works with JavaScript disabled. CSRF protection is automatic, and successful submissions follow POST-redirect-GET. Client-side <code>actions</code> become an enhancement, not a requirement.')}
|
|
21
|
+
|
|
22
|
+
${section('basics', 'Basic usage')}
|
|
23
|
+
<p>Add a <code>submit</code> function to the spec and render a plain <code><form method="POST"></code>. The route accepts POST automatically — no <code>methods</code> declaration needed.</p>
|
|
24
|
+
${codeBlock(highlight(`export default {
|
|
25
|
+
route: '/contact',
|
|
26
|
+
|
|
27
|
+
submit: async (ctx) => {
|
|
28
|
+
const data = await ctx.formData()
|
|
29
|
+
if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
|
|
30
|
+
await sendMessage(data)
|
|
31
|
+
return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
view: (state, server) => \`
|
|
35
|
+
<main id="main-content">
|
|
36
|
+
\${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
|
|
37
|
+
<form method="POST">
|
|
38
|
+
\${server.csrf}
|
|
39
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true,
|
|
40
|
+
value: server.form?.values?.email ?? '' })}
|
|
41
|
+
\${button({ label: 'Send', type: 'submit' })}
|
|
42
|
+
</form>
|
|
43
|
+
</main>
|
|
44
|
+
\`,
|
|
45
|
+
}`, 'js'))}
|
|
46
|
+
<p>The flow: the browser POSTs the form, <code>submit(ctx)</code> runs server-side, and either redirects (success) or re-renders the page with the returned value available to the view as <code>server.form</code> (validation failure).</p>
|
|
47
|
+
|
|
48
|
+
${callout('warning', '<strong><code>\${server.csrf}</code> must be inside every POST form.</strong> It renders the hidden CSRF token input — submissions without a valid token are rejected with <strong>403</strong> before <code>submit</code> runs.')}
|
|
49
|
+
|
|
50
|
+
${section('return-values', 'Return values')}
|
|
51
|
+
${table(
|
|
52
|
+
['Return', 'Response', 'Use for'],
|
|
53
|
+
[
|
|
54
|
+
['<code>{ redirect: \'/path\' }</code>', '<strong>303 See Other</strong>', 'Successful submissions — POST-redirect-GET means refresh never resubmits'],
|
|
55
|
+
['<code>{ errors, values, … }</code>', 'Page re-renders (200), value exposed as <code>server.form</code>', 'Validation failures — show errors, echo values back into inputs'],
|
|
56
|
+
['<code>{ status: 422, errors, … }</code>', 'Same re-render with status <strong>422</strong>', 'Validation failures where the status code matters (API clients, tests)'],
|
|
57
|
+
['<code>undefined</code>', 'Page re-renders (200), <code>server.form</code> is <code>{}</code>', 'Fire-and-forget side effects'],
|
|
58
|
+
]
|
|
59
|
+
)}
|
|
60
|
+
<p><strong>Always redirect after a successful mutation.</strong> Rendering a success message directly from the POST means a browser refresh resubmits the form. Redirect to a URL that shows the confirmation instead (e.g. <code>/contact?sent=1</code>).</p>
|
|
61
|
+
|
|
62
|
+
${section('server-form', 'server.form — errors and echoed values')}
|
|
63
|
+
<p>On a re-render, whatever <code>submit</code> returned is available to the view as <code>server.form</code>. On a plain GET it is <code>undefined</code> — always use optional chaining.</p>
|
|
64
|
+
${codeBlock(highlight(`// In submit — return errors AND the submitted values:
|
|
65
|
+
return { errors: { email: 'Invalid email' }, values: data }
|
|
66
|
+
|
|
67
|
+
// In the view — show the error, echo the value back so the form isn't wiped:
|
|
68
|
+
\${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
|
|
69
|
+
\${input({ name: 'email', label: 'Email', value: server.form?.values?.email ?? '' })}`, 'js'))}
|
|
70
|
+
${callout('warning', 'Escape user-submitted values before interpolating them into HTML — <code>server.form.values</code> is raw user input. Component props like <code>input({ value })</code> are auto-escaped; manual interpolation needs <code>escHtml()</code>.')}
|
|
71
|
+
|
|
72
|
+
${section('csrf', 'CSRF protection')}
|
|
73
|
+
<p>Every <code>submit</code> spec gets CSRF protection automatically — there is nothing to configure. Under the hood Pulse uses an HMAC double-submit pattern:</p>
|
|
74
|
+
${table(
|
|
75
|
+
['Piece', 'Where it lives', 'What it does'],
|
|
76
|
+
[
|
|
77
|
+
['Random id', '<code>pulse_csrf</code> cookie (HttpOnly, SameSite=Lax)', 'Set on the first GET of the form page'],
|
|
78
|
+
['Token', 'Hidden <code>_csrf</code> input via <code>\${server.csrf}</code>', '<code>HMAC-SHA256(secret, id)</code> — embedded into the form server-side'],
|
|
79
|
+
['Verification', 'On every POST, before <code>submit</code> runs', 'Recomputes the HMAC from the cookie and compares in constant time — mismatch is 403'],
|
|
80
|
+
]
|
|
81
|
+
)}
|
|
82
|
+
<p>An attacker on another origin can cause the cookie to be sent, but cannot read it or compute the HMAC — so they cannot forge the pair.</p>
|
|
83
|
+
|
|
84
|
+
${sub('multi-instance', 'Multiple server instances')}
|
|
85
|
+
<p>The HMAC secret defaults to a random value per server boot. Behind a load balancer, pin it so tokens issued by one instance validate on another:</p>
|
|
86
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
87
|
+
secret: process.env.PULSE_SECRET, // stable across instances and restarts
|
|
88
|
+
})`, 'js'))}
|
|
89
|
+
|
|
90
|
+
${sub('opt-out', 'Opting out')}
|
|
91
|
+
<p>Set <code>csrf: false</code> on the spec to disable protection — only for endpoints with their own authentication, such as signed webhooks:</p>
|
|
92
|
+
${codeBlock(highlight(`export default {
|
|
93
|
+
route: '/webhooks/stripe',
|
|
94
|
+
csrf: false, // Stripe signs its requests — verify the signature instead
|
|
95
|
+
submit: async (ctx) => {
|
|
96
|
+
verifyStripeSignature(ctx.headers['stripe-signature'], await ctx.text())
|
|
97
|
+
// ...
|
|
98
|
+
},
|
|
99
|
+
view: () => '<main id="main-content">ok</main>',
|
|
100
|
+
}`, 'js'))}
|
|
101
|
+
${callout('warning', 'Never set <code>csrf: false</code> to "fix" a 403. The 403 means the form is missing <code>\${server.csrf}</code> — add the field, don\'t remove the protection.')}
|
|
102
|
+
|
|
103
|
+
${section('progressive-enhancement', 'Progressive enhancement')}
|
|
104
|
+
<p>The same form can serve both worlds: <code>method="POST"</code> is the no-JS fallback, <code>data-action</code> is the hydrated path. With JavaScript enabled the runtime intercepts the submit and runs the async action (no page reload); without it the browser POSTs to <code>submit</code>.</p>
|
|
105
|
+
${codeBlock(highlight(`<form method="POST" data-action="send">
|
|
106
|
+
\${server.csrf}
|
|
107
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true })}
|
|
108
|
+
\${button({ label: 'Send', type: 'submit' })}
|
|
109
|
+
</form>`, 'html'))}
|
|
110
|
+
<p>Keep the two paths consistent: <code>submit</code> and the action's <code>run</code> should call the same underlying function so validation and side effects cannot drift apart.</p>
|
|
111
|
+
|
|
112
|
+
${section('caching', 'Interaction with caching')}
|
|
113
|
+
<p>Pages with <code>submit</code> are automatically excluded from the in-process page cache — the CSRF token is per-visitor, and a cached page would serve one visitor's token to everyone. POST responses are never cached.</p>
|
|
114
|
+
|
|
115
|
+
${section('reference', 'Reference')}
|
|
116
|
+
${table(
|
|
117
|
+
['Property', 'Type', 'Notes'],
|
|
118
|
+
[
|
|
119
|
+
['<code>submit</code>', '<code>async (ctx) => { redirect } | object | void</code>', 'Handles POST on the route. Guard runs first.'],
|
|
120
|
+
['<code>csrf</code>', '<code>boolean</code>', 'Default <code>true</code>. Set <code>false</code> only for self-authenticated endpoints.'],
|
|
121
|
+
['<code>server.csrf</code>', 'string (view)', 'Hidden <code>_csrf</code> input — required inside every POST form.'],
|
|
122
|
+
['<code>server.form</code>', 'object | undefined (view)', 'Return value of <code>submit</code> on a re-render; <code>undefined</code> on GET.'],
|
|
123
|
+
['<code>secret</code>', 'string (createServer option)', 'Stable HMAC secret for multi-instance deployments.'],
|
|
124
|
+
]
|
|
125
|
+
)}
|
|
126
|
+
`,
|
|
127
|
+
}),
|
|
128
|
+
}
|
package/docs/src/pages/guard.js
CHANGED
|
@@ -105,6 +105,7 @@ export default {
|
|
|
105
105
|
// return nothing to let a GET request proceed to the view
|
|
106
106
|
}`, 'js'))}
|
|
107
107
|
${callout('note', 'To use <code>guard</code> as a POST handler, the spec must declare <code>methods: [\'GET\', \'POST\']</code>. Without it, POST requests are rejected with 405 before guard runs.')}
|
|
108
|
+
${callout('tip', 'For HTML form submissions, prefer <a href="/forms"><code>spec.submit</code></a> — it accepts POST automatically, gets CSRF protection for free, and re-renders the page with validation errors. Guard-based POST handling is best for API-style endpoints.')}
|
|
108
109
|
|
|
109
110
|
${section('reference', 'Reference')}
|
|
110
111
|
${table(
|
|
@@ -80,15 +80,13 @@ server: {
|
|
|
80
80
|
}`, 'js'))}
|
|
81
81
|
|
|
82
82
|
${section('not-found', '404 handling')}
|
|
83
|
-
<p>If no spec matches the incoming request path, Pulse returns a 404
|
|
84
|
-
${codeBlock(highlight(`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
})`, 'js'))}
|
|
83
|
+
<p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
|
|
84
|
+
${codeBlock(highlight(`export default {
|
|
85
|
+
route: '*',
|
|
86
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
87
|
+
view: () => \`<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>\`,
|
|
88
|
+
}`, 'js'), 'src/pages/not-found.js')}
|
|
89
|
+
<p>See <a href="/error-pages">Error Pages</a> for the full behaviour, including 500 handling.</p>
|
|
92
90
|
|
|
93
91
|
${section('conventions', 'File naming conventions')}
|
|
94
92
|
<p>While Pulse does not auto-discover files, the recommended convention maps file names to routes:</p>
|
|
@@ -48,8 +48,9 @@ await createServer(entries, options)`, 'js'))}
|
|
|
48
48
|
['<code>shutdownTimeout</code>', '<code>number</code>', '<code>30000</code>', 'Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See <a href="#graceful-shutdown">Graceful shutdown</a>.'],
|
|
49
49
|
['<code>healthCheck</code>', '<code>string | false</code>', '<code>"/healthz"</code>', 'Path for the built-in health check endpoint. Returns <code>{ status: "ok", uptime }</code>. Set to <code>false</code> to disable. The endpoint bypasses <code>onRequest</code> so load balancers always get a response.'],
|
|
50
50
|
['<code>resolveBrand</code>', '<code>async (host) => any</code>', '<code>undefined</code>', 'Multi-brand support. Called once per host (cached 60s). Result is attached to <code>ctx.brand</code> and available in <code>guard</code>, <code>server</code>, and <code>meta</code> functions.'],
|
|
51
|
+
['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
|
|
51
52
|
['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
|
|
52
|
-
['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>.'],
|
|
53
|
+
['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
|
|
53
54
|
]
|
|
54
55
|
)}
|
|
55
56
|
|
|
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
113
113
|
### Security
|
|
114
114
|
|
|
115
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
116
124
|
|
|
117
125
|
### Tests
|
|
118
126
|
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.17.
|
|
1
|
+
0.17.3
|
package/src/agent/checklist.md
CHANGED
|
@@ -113,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
113
113
|
### Security
|
|
114
114
|
|
|
115
115
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
116
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
117
|
+
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
118
|
+
|
|
119
|
+
### Server-side forms & error pages
|
|
120
|
+
|
|
121
|
+
- **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
|
|
122
|
+
- On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
|
|
123
|
+
- **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
|
|
116
124
|
|
|
117
125
|
### Tests
|
|
118
126
|
|
|
@@ -126,6 +126,27 @@ Convention: name dynamic-route files [param].js inside a subfolder:
|
|
|
126
126
|
|
|
127
127
|
This is purely a human readability convention. Pulse does not process [ ] in filenames.
|
|
128
128
|
|
|
129
|
+
## Custom 404 page
|
|
130
|
+
|
|
131
|
+
Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration) with status 404 whenever no route matches:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
// src/pages/not-found.js
|
|
135
|
+
import { layout } from '../components/layout.js'
|
|
136
|
+
|
|
137
|
+
export default {
|
|
138
|
+
route: '*',
|
|
139
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
140
|
+
view: () => layout({
|
|
141
|
+
content: `<h1>Page not found</h1><p>That page doesn't exist. <a href="/">Back home</a></p>`,
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- Use the shared `layout()` so the 404 carries the site's nav and footer.
|
|
147
|
+
- **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
|
|
148
|
+
- 500 errors are customised via `createServer`'s `onError` option, not a spec.
|
|
149
|
+
|
|
129
150
|
## Canonical URLs
|
|
130
151
|
|
|
131
152
|
Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
|
|
@@ -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,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
|
-
//
|
|
601
|
-
|
|
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
|
-
|
|
679
|
+
let match = matchRoute(router, pathname)
|
|
680
|
+
let responseStatus = 200
|
|
666
681
|
|
|
667
682
|
if (!match) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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, ''')
|
|
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 () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -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.)
|
package/src/spec/schema.test.js
CHANGED
|
@@ -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`)
|
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
|
-
/**
|
|
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.
|