@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
|
@@ -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',
|
|
@@ -67,6 +68,13 @@ export const mySpec = {
|
|
|
67
68
|
// query param, list it here so distinct values get distinct cache entries.
|
|
68
69
|
cacheVary: ['q', 'page'],
|
|
69
70
|
|
|
71
|
+
// Sitemap inclusion (when createServer has sitemap: true).
|
|
72
|
+
// Static routes are included automatically — only set this to:
|
|
73
|
+
// false → exclude this page
|
|
74
|
+
// true → include a guarded page (excluded by default)
|
|
75
|
+
// async () => [...] → enumerate a dynamic :param route's URLs
|
|
76
|
+
sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
|
|
77
|
+
|
|
70
78
|
// Global store keys this page subscribes to — appears in view's server arg
|
|
71
79
|
// Store mutations update all subscribed pages without a server round-trip
|
|
72
80
|
store: ['user', 'cart'],
|
|
@@ -121,6 +129,19 @@ export const mySpec = {
|
|
|
121
129
|
}
|
|
122
130
|
},
|
|
123
131
|
|
|
132
|
+
// Server-side form handling — POST to this route, no client JS required.
|
|
133
|
+
// CSRF protection is automatic: include ${server.csrf} inside the <form>.
|
|
134
|
+
// { redirect: '/path' } → 303 POST-redirect-GET
|
|
135
|
+
// anything else → page re-renders; return value appears as server.form
|
|
136
|
+
// (optional status field sets the response status, e.g. 422)
|
|
137
|
+
submit: async (ctx) => {
|
|
138
|
+
const data = await ctx.formData()
|
|
139
|
+
if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
|
|
140
|
+
await saveSignup(data)
|
|
141
|
+
return { redirect: '/thanks' }
|
|
142
|
+
},
|
|
143
|
+
// csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
|
|
144
|
+
|
|
124
145
|
// Streaming SSR — split view into shell (instant) + deferred segments
|
|
125
146
|
stream: {
|
|
126
147
|
shell: ['header', 'nav'],
|
|
@@ -141,6 +162,42 @@ export const mySpec = {
|
|
|
141
162
|
export default mySpec // required for hydration imports
|
|
142
163
|
```
|
|
143
164
|
|
|
165
|
+
## Server-Side Forms (no client JS)
|
|
166
|
+
|
|
167
|
+
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):
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
view: (state, server) => `
|
|
171
|
+
<main id="main-content">
|
|
172
|
+
${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
|
|
173
|
+
<form method="POST">
|
|
174
|
+
${server.csrf}
|
|
175
|
+
${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
|
|
176
|
+
${button({ label: 'Sign up', type: 'submit' })}
|
|
177
|
+
</form>
|
|
178
|
+
</main>`,
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
|
|
182
|
+
- **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.
|
|
183
|
+
- Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
|
|
184
|
+
- Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
|
|
185
|
+
|
|
186
|
+
## Custom 404 Page
|
|
187
|
+
|
|
188
|
+
Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
// src/pages/not-found.js
|
|
192
|
+
export default {
|
|
193
|
+
route: '*',
|
|
194
|
+
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
195
|
+
view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
|
|
200
|
+
|
|
144
201
|
## HTML Event Binding
|
|
145
202
|
|
|
146
203
|
Pulse binds to DOM attributes — no JSX, no templates.
|
|
@@ -230,6 +287,13 @@ await createServer(
|
|
|
230
287
|
// spec.serverTimeout overrides per-page
|
|
231
288
|
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
232
289
|
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
290
|
+
secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
|
|
291
|
+
// when running multiple instances (default: random per boot)
|
|
292
|
+
sitemap: true, // auto-serve /sitemap.xml + robots.txt from registered routes
|
|
293
|
+
// also accepts { origin: 'https://mysite.com' } to pin the URL origin;
|
|
294
|
+
// dynamic :param routes contribute via spec.sitemap enumerators;
|
|
295
|
+
// a physical sitemap.xml/robots.txt in staticDir always wins
|
|
296
|
+
robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
|
|
233
297
|
csp: { // extra sources merged into the framework's default CSP
|
|
234
298
|
'style-src': ['https://fonts.googleapis.com'],
|
|
235
299
|
'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(
|
package/docs/src/pages/meta.js
CHANGED
|
@@ -115,6 +115,33 @@ meta: {
|
|
|
115
115
|
}`, 'js'))}
|
|
116
116
|
${callout('note', '<strong>Streaming caveat:</strong> when <code>stream: true</code> (the default), the <code><head></code> is written before server fetchers resolve, so <code>serverState</code> will be <code>null</code> for streaming responses. If your canonical depends on server data, set <code>stream: false</code> on that spec, or derive it from <code>ctx.params</code> instead.')}
|
|
117
117
|
|
|
118
|
+
${section('sitemap', 'Sitemap & robots.txt')}
|
|
119
|
+
<p>Pulse generates <code>/sitemap.xml</code> and <code>/robots.txt</code> from the registered routes — enable it with one option:</p>
|
|
120
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
121
|
+
sitemap: true, // or { origin: 'https://mysite.com' } to pin the origin
|
|
122
|
+
})`, 'js'))}
|
|
123
|
+
<p>Static page routes are included automatically. Excluded by default: the <code>route: '*'</code> not-found page, raw content specs, guarded pages (set <code>sitemap: true</code> on the spec to opt one in), and any page with <code>sitemap: false</code>.</p>
|
|
124
|
+
<p>Dynamic <code>:param</code> routes can't be enumerated automatically — give them a <code>sitemap</code> enumerator or they are skipped (the server logs a startup hint listing them):</p>
|
|
125
|
+
${codeBlock(highlight(`export default {
|
|
126
|
+
route: '/blog/:slug',
|
|
127
|
+
sitemap: async () => {
|
|
128
|
+
const posts = await db.posts.allPublished()
|
|
129
|
+
return posts.map(p => ({ path: \`/blog/\${p.slug}\`, lastmod: p.updatedAt }))
|
|
130
|
+
},
|
|
131
|
+
// entries are '/path' strings or { path, lastmod } objects
|
|
132
|
+
}`, 'js'))}
|
|
133
|
+
${table(
|
|
134
|
+
['Where', 'Value', 'Effect'],
|
|
135
|
+
[
|
|
136
|
+
['<code>createServer</code>', '<code>sitemap: true | { origin }</code>', 'Enables generation. Origin defaults to the request host; pin it for multi-domain setups.'],
|
|
137
|
+
['<code>createServer</code>', '<code>robots: false | string</code>', 'Disable robots.txt, or serve a custom string verbatim. Default auto-generates allow-all + the Sitemap line.'],
|
|
138
|
+
['spec', '<code>sitemap: false</code>', 'Exclude this page.'],
|
|
139
|
+
['spec', '<code>sitemap: true</code>', 'Include a guarded page that would be excluded by default.'],
|
|
140
|
+
['spec', '<code>sitemap: array | function</code>', 'Enumerate URLs — required for dynamic routes.'],
|
|
141
|
+
]
|
|
142
|
+
)}
|
|
143
|
+
${callout('note', 'A physical <code>sitemap.xml</code> or <code>robots.txt</code> in <code>staticDir</code> always wins over the generated one — drop a file in <code>public/</code> to take full manual control. For fully hand-built XML, see <a href="/raw-responses#sitemap">Raw Responses</a>.')}
|
|
144
|
+
|
|
118
145
|
${section('styles', 'Stylesheets')}
|
|
119
146
|
<p>The <code>styles</code> array accepts any number of stylesheet URLs. They are emitted as <code><link rel="stylesheet"></code> tags in the <code><head></code> in the order declared:</p>
|
|
120
147
|
${codeBlock(highlight(`meta: {
|
|
@@ -66,6 +66,7 @@ export default {
|
|
|
66
66
|
}`, 'js'))}
|
|
67
67
|
|
|
68
68
|
${section('sitemap', 'XML sitemap')}
|
|
69
|
+
${callout('tip', 'For most sites you don\'t need a raw spec for this — set <code>sitemap: true</code> in <code>createServer</code> and Pulse generates <code>/sitemap.xml</code> and <code>robots.txt</code> from the registered routes automatically. See <a href="/meta#sitemap">Metadata & SEO</a>. Write a raw sitemap spec only when you need full control over the XML.')}
|
|
69
70
|
${codeBlock(highlight(`export default {
|
|
70
71
|
route: '/sitemap.xml',
|
|
71
72
|
contentType: 'application/xml',
|
|
@@ -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,11 @@ 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.'],
|
|
52
|
+
['<code>sitemap</code>', '<code>boolean | { origin }</code>', '<code>false</code>', 'Auto-serve <code>/sitemap.xml</code> + <code>robots.txt</code> from the registered routes. Dynamic <code>:param</code> routes contribute via <code>spec.sitemap</code> enumerators. See <a href="/meta#sitemap">Metadata & SEO</a>.'],
|
|
53
|
+
['<code>robots</code>', '<code>string | false</code>', 'auto', 'robots.txt behaviour when sitemap is enabled — <code>false</code> disables, a string is served verbatim.'],
|
|
51
54
|
['<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>.'],
|
|
55
|
+
['<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
56
|
]
|
|
54
57
|
)}
|
|
55
58
|
|
|
@@ -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.
|
|
1
|
+
0.18.0
|
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,52 @@ 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
|
+
|
|
150
|
+
## Sitemap & robots.txt
|
|
151
|
+
|
|
152
|
+
Enable in `pulse.config.js` / `createServer` — the framework serves `/sitemap.xml` and `/robots.txt` generated from the registered routes:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
// pulse.config.js
|
|
156
|
+
export default {
|
|
157
|
+
sitemap: true, // or { origin: 'https://mysite.com' } to pin the origin
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- **Static routes are included automatically.** Excluded by default: `route: '*'`, raw content specs, guarded pages (set `sitemap: true` on the spec to opt a guarded page in), and `sitemap: false` pages.
|
|
162
|
+
- **Dynamic `:param` routes need an enumerator** or they are skipped (a startup hint lists them):
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
export default {
|
|
166
|
+
route: '/blog/:slug',
|
|
167
|
+
sitemap: async () => (await db.posts.list()).map(p => ({ path: `/blog/${p.slug}`, lastmod: p.updatedAt })),
|
|
168
|
+
// entries can be '/path' strings or { path, lastmod } objects
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- `robots: false` disables robots.txt; a string serves verbatim. A physical `sitemap.xml`/`robots.txt` in `staticDir` always wins over the generated one.
|
|
173
|
+
- **Always enable this on production sites** — it is one config line and crawlers need it.
|
|
174
|
+
|
|
129
175
|
## Canonical URLs
|
|
130
176
|
|
|
131
177
|
Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
|