@invisibleloop/pulse 0.3.0 → 0.5.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.md +35 -23
- package/docs/server.js +106 -187
- package/docs/src/pages/hydration.js +19 -10
- package/docs/src/pages/server-api.js +40 -34
- package/docs/src/pages/spec.js +5 -7
- package/examples/contact.js +0 -1
- package/examples/counter.js +0 -1
- package/examples/dev.server.js +10 -8
- package/examples/pricing.js +0 -1
- package/examples/products.js +0 -1
- package/examples/quiz.js +0 -1
- package/examples/todos.js +0 -1
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/checklist.md +1 -5
- package/src/runtime/index.js +4 -4
- package/src/server/index.js +63 -3
- package/src/server/server.test.js +6 -6
- package/src/spec/schema.js +1 -10
- package/src/spec/schema.test.js +7 -2
package/CLAUDE.md
CHANGED
|
@@ -35,12 +35,11 @@ public/
|
|
|
35
35
|
|
|
36
36
|
## The Spec
|
|
37
37
|
|
|
38
|
-
A spec is a plain JS object. Every property is optional except `route
|
|
38
|
+
A spec is a plain JS object. Every property is optional except `route` and `view`. `state` defaults to `{}` — omit it on purely server-rendered pages.
|
|
39
39
|
|
|
40
40
|
```js
|
|
41
41
|
export const mySpec = {
|
|
42
42
|
route: '/path', // URL pattern, supports :params
|
|
43
|
-
hydrate: '/path/to/spec.js',// browser-importable path → enables hydration
|
|
44
43
|
|
|
45
44
|
meta: {
|
|
46
45
|
title: 'Page Title',
|
|
@@ -63,6 +62,7 @@ export const mySpec = {
|
|
|
63
62
|
store: ['user', 'cart'],
|
|
64
63
|
|
|
65
64
|
// Initial client state — deep cloned on mount, never mutated directly
|
|
65
|
+
// Optional — omit entirely on purely server-rendered pages (no mutations/actions/persist)
|
|
66
66
|
state: { count: 0 },
|
|
67
67
|
|
|
68
68
|
// Declarative min/max bounds — always enforced after mutations
|
|
@@ -199,29 +199,41 @@ The server auto-detects the manifest from `staticDir/dist/manifest.json` when `s
|
|
|
199
199
|
```js
|
|
200
200
|
import { createServer } from './src/server/index.js'
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
202
|
+
// Pass URL objects — hydrate is auto-derived, no need to set it in specs.
|
|
203
|
+
// Specs with mutations/actions/persist are hydrated automatically.
|
|
204
|
+
// Purely server-rendered specs get zero JavaScript.
|
|
205
|
+
await createServer(
|
|
206
|
+
[
|
|
207
|
+
new URL('./src/pages/home.js', import.meta.url),
|
|
208
|
+
new URL('./src/pages/contact.js', import.meta.url),
|
|
209
|
+
],
|
|
210
|
+
{
|
|
211
|
+
port: 3000,
|
|
212
|
+
stream: true, // streaming SSR (default)
|
|
213
|
+
staticDir: 'public', // serve static files + auto-load manifest
|
|
214
|
+
root: new URL('.', import.meta.url), // project root for deriving browser paths
|
|
215
|
+
manifest: null, // explicit manifest path or object (overrides auto-detect)
|
|
216
|
+
defaultCache: 3600, // default HTML cache TTL in seconds for all pages (prod only)
|
|
217
|
+
// also accepts true (3600s + swr 86400s) or { public, maxAge, staleWhileRevalidate }
|
|
218
|
+
// spec.cache overrides per-page; in-process + Cache-Control headers both set
|
|
219
|
+
fetcherTimeout: 5000, // ms before any server fetcher times out (null = no limit)
|
|
220
|
+
// spec.serverTimeout overrides per-page
|
|
221
|
+
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
222
|
+
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
223
|
+
csp: { // extra sources merged into the framework's default CSP
|
|
224
|
+
'style-src': ['https://fonts.googleapis.com'],
|
|
225
|
+
'font-src': ['https://fonts.gstatic.com'],
|
|
226
|
+
},
|
|
227
|
+
resolveBrand: async (host) => db.brands.findBySlug(host.split('.')[0]),
|
|
228
|
+
// multi-brand: result cached 60s, attached to ctx.brand
|
|
229
|
+
onRequest: (req, res) => { /* return false to short-circuit */ },
|
|
230
|
+
onError: (err, req, res) => { /* custom error handling */ }
|
|
231
|
+
}
|
|
232
|
+
)
|
|
223
233
|
```
|
|
224
234
|
|
|
235
|
+
`createServer` is async — always `await` it. Spec objects are still accepted alongside URL objects for backwards compatibility.
|
|
236
|
+
|
|
225
237
|
All specs are validated at startup — bad specs throw before the server accepts connections.
|
|
226
238
|
|
|
227
239
|
## Multi-brand Sites
|
package/docs/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import fs
|
|
2
|
-
import { createServer }
|
|
3
|
-
import { initLayoutManifest }
|
|
4
|
-
import { metrics }
|
|
5
|
-
import { metricsStore }
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { createServer } from '../src/server/index.js'
|
|
3
|
+
import { initLayoutManifest } from './src/lib/layout.js'
|
|
4
|
+
import { metrics } from './src/lib/stats.js'
|
|
5
|
+
import { metricsStore } from './src/lib/metrics-store.js'
|
|
6
6
|
metricsStore.current = metrics
|
|
7
7
|
|
|
8
8
|
// Populate hashed asset paths in layout.js before any page renders
|
|
@@ -10,194 +10,113 @@ try {
|
|
|
10
10
|
const raw = fs.readFileSync(new URL('./public/dist/manifest.json', import.meta.url), 'utf8')
|
|
11
11
|
initLayoutManifest(JSON.parse(raw))
|
|
12
12
|
} catch { /* dev: no manifest yet, layout falls back to unhashed paths */ }
|
|
13
|
-
import home from './src/pages/home.js'
|
|
14
|
-
import howItWorks from './src/pages/how-it-works.js'
|
|
15
|
-
import faq from './src/pages/faq.js'
|
|
16
|
-
import config from './src/pages/config.js'
|
|
17
|
-
import gettingStarted from './src/pages/getting-started.js'
|
|
18
|
-
import slashCommands from './src/pages/slash-commands.js'
|
|
19
|
-
import promptExamples from './src/pages/prompt-examples.js'
|
|
20
|
-
import components from './src/pages/components.js'
|
|
21
|
-
import projectStructure from './src/pages/project-structure.js'
|
|
22
|
-
import spec from './src/pages/spec.js'
|
|
23
|
-
import state from './src/pages/state.js'
|
|
24
|
-
import mutations from './src/pages/mutations.js'
|
|
25
|
-
import actions from './src/pages/actions.js'
|
|
26
|
-
import validation from './src/pages/validation.js'
|
|
27
|
-
import constraints from './src/pages/constraints.js'
|
|
28
|
-
import persist from './src/pages/persist.js'
|
|
29
|
-
import serverData from './src/pages/server-data.js'
|
|
30
|
-
import routing from './src/pages/routing.js'
|
|
31
|
-
import streaming from './src/pages/streaming.js'
|
|
32
|
-
import caching from './src/pages/caching.js'
|
|
33
|
-
import guard from './src/pages/guard.js'
|
|
34
|
-
import rawResponses from './src/pages/raw-responses.js'
|
|
35
|
-
import serverApi from './src/pages/server-api.js'
|
|
36
|
-
import hydration from './src/pages/hydration.js'
|
|
37
|
-
import navigation from './src/pages/navigation.js'
|
|
38
|
-
import images from './src/pages/images.js'
|
|
39
|
-
import extending from './src/pages/extending.js'
|
|
40
|
-
import deploy from './src/pages/deploy.js'
|
|
41
|
-
import supabase from './src/pages/supabase.js'
|
|
42
|
-
import auth from './src/pages/auth.js'
|
|
43
|
-
import stripe from './src/pages/stripe.js'
|
|
44
|
-
import meta from './src/pages/meta.js'
|
|
45
|
-
import performance from './src/pages/performance.js'
|
|
46
|
-
import accessibility from './src/pages/accessibility.js'
|
|
47
|
-
import testing from './src/pages/testing.js'
|
|
48
|
-
import store from './src/pages/store.js'
|
|
49
13
|
|
|
50
|
-
|
|
51
|
-
import compButton from './src/pages/components/button.js'
|
|
52
|
-
import compBadge from './src/pages/components/badge.js'
|
|
53
|
-
import compCard from './src/pages/components/card.js'
|
|
54
|
-
import compInput from './src/pages/components/input.js'
|
|
55
|
-
import compSelect from './src/pages/components/select.js'
|
|
56
|
-
import compTextarea from './src/pages/components/textarea.js'
|
|
57
|
-
import compAlert from './src/pages/components/alert.js'
|
|
58
|
-
import compStat from './src/pages/components/stat.js'
|
|
59
|
-
import compAvatar from './src/pages/components/avatar.js'
|
|
60
|
-
import compEmpty from './src/pages/components/empty.js'
|
|
61
|
-
import compTable from './src/pages/components/table.js'
|
|
62
|
-
import compNav from './src/pages/components/nav.js'
|
|
63
|
-
import compHero from './src/pages/components/hero.js'
|
|
64
|
-
import compAppBadge from './src/pages/components/app-badge.js'
|
|
65
|
-
import compFeature from './src/pages/components/feature.js'
|
|
66
|
-
import compTestimonial from './src/pages/components/testimonial.js'
|
|
67
|
-
import compPricing from './src/pages/components/pricing.js'
|
|
68
|
-
import compAccordion from './src/pages/components/accordion.js'
|
|
69
|
-
import compContainer from './src/pages/components/container.js'
|
|
70
|
-
import compSection from './src/pages/components/section.js'
|
|
71
|
-
import compGrid from './src/pages/components/grid.js'
|
|
72
|
-
import compStack from './src/pages/components/stack.js'
|
|
73
|
-
import compCluster from './src/pages/components/cluster.js'
|
|
74
|
-
import compDivider from './src/pages/components/divider.js'
|
|
75
|
-
import compBanner from './src/pages/components/banner.js'
|
|
76
|
-
import compMedia from './src/pages/components/media.js'
|
|
77
|
-
import compTooltip from './src/pages/components/tooltip.js'
|
|
78
|
-
import compModal from './src/pages/components/modal.js'
|
|
79
|
-
import compCarousel from './src/pages/components/carousel.js'
|
|
80
|
-
import compCta from './src/pages/components/cta.js'
|
|
81
|
-
import compFooter from './src/pages/components/footer.js'
|
|
82
|
-
import compCodeWindow from './src/pages/components/code-window.js'
|
|
83
|
-
import compFieldset from './src/pages/components/fieldset.js'
|
|
84
|
-
import compToggle from './src/pages/components/toggle.js'
|
|
85
|
-
import compCheckbox from './src/pages/components/checkbox.js'
|
|
86
|
-
import compFileUpload from './src/pages/components/fileupload.js'
|
|
87
|
-
import compSlider from './src/pages/components/slider.js'
|
|
88
|
-
import compSegmented from './src/pages/components/segmented.js'
|
|
89
|
-
import compRadio from './src/pages/components/radio.js'
|
|
90
|
-
import compRating from './src/pages/components/rating.js'
|
|
91
|
-
import compSearch from './src/pages/components/search.js'
|
|
92
|
-
import compSpinner from './src/pages/components/spinner.js'
|
|
93
|
-
import compProgress from './src/pages/components/progress.js'
|
|
94
|
-
import compBreadcrumbs from './src/pages/components/breadcrumbs.js'
|
|
95
|
-
import compStepper from './src/pages/components/stepper.js'
|
|
96
|
-
import compImage from './src/pages/components/image.js'
|
|
97
|
-
import compPullquote from './src/pages/components/pullquote.js'
|
|
98
|
-
import compProse from './src/pages/components/prose.js'
|
|
99
|
-
import compHeading from './src/pages/components/heading.js'
|
|
100
|
-
import compList from './src/pages/components/list.js'
|
|
101
|
-
import compTimeline from './src/pages/components/timeline.js'
|
|
102
|
-
import compCharts from './src/pages/components/charts.js'
|
|
103
|
-
import compIcons from './src/pages/components/icons.js'
|
|
104
|
-
|
|
105
|
-
createServer(
|
|
14
|
+
await createServer(
|
|
106
15
|
[
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
meta,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
16
|
+
// Framework
|
|
17
|
+
new URL('./src/pages/home.js', import.meta.url),
|
|
18
|
+
new URL('./src/pages/how-it-works.js', import.meta.url),
|
|
19
|
+
new URL('./src/pages/faq.js', import.meta.url),
|
|
20
|
+
new URL('./src/pages/config.js', import.meta.url),
|
|
21
|
+
new URL('./src/pages/getting-started.js', import.meta.url),
|
|
22
|
+
new URL('./src/pages/slash-commands.js', import.meta.url),
|
|
23
|
+
new URL('./src/pages/prompt-examples.js', import.meta.url),
|
|
24
|
+
new URL('./src/pages/components.js', import.meta.url),
|
|
25
|
+
new URL('./src/pages/project-structure.js', import.meta.url),
|
|
26
|
+
new URL('./src/pages/spec.js', import.meta.url),
|
|
27
|
+
// State & Behaviour
|
|
28
|
+
new URL('./src/pages/state.js', import.meta.url),
|
|
29
|
+
new URL('./src/pages/mutations.js', import.meta.url),
|
|
30
|
+
new URL('./src/pages/actions.js', import.meta.url),
|
|
31
|
+
new URL('./src/pages/validation.js', import.meta.url),
|
|
32
|
+
new URL('./src/pages/constraints.js', import.meta.url),
|
|
33
|
+
new URL('./src/pages/persist.js', import.meta.url),
|
|
34
|
+
// Server
|
|
35
|
+
new URL('./src/pages/server-data.js', import.meta.url),
|
|
36
|
+
new URL('./src/pages/store.js', import.meta.url),
|
|
37
|
+
new URL('./src/pages/routing.js', import.meta.url),
|
|
38
|
+
new URL('./src/pages/streaming.js', import.meta.url),
|
|
39
|
+
new URL('./src/pages/caching.js', import.meta.url),
|
|
40
|
+
new URL('./src/pages/guard.js', import.meta.url),
|
|
41
|
+
new URL('./src/pages/raw-responses.js', import.meta.url),
|
|
42
|
+
new URL('./src/pages/server-api.js', import.meta.url),
|
|
43
|
+
new URL('./src/pages/extending.js', import.meta.url),
|
|
44
|
+
// Client
|
|
45
|
+
new URL('./src/pages/hydration.js', import.meta.url),
|
|
46
|
+
new URL('./src/pages/navigation.js', import.meta.url),
|
|
47
|
+
new URL('./src/pages/images.js', import.meta.url),
|
|
48
|
+
// Deployment
|
|
49
|
+
new URL('./src/pages/deploy.js', import.meta.url),
|
|
50
|
+
// Reference
|
|
51
|
+
new URL('./src/pages/meta.js', import.meta.url),
|
|
52
|
+
new URL('./src/pages/performance.js', import.meta.url),
|
|
53
|
+
new URL('./src/pages/accessibility.js', import.meta.url),
|
|
54
|
+
new URL('./src/pages/testing.js', import.meta.url),
|
|
55
|
+
// Integrations
|
|
56
|
+
new URL('./src/pages/supabase.js', import.meta.url),
|
|
57
|
+
new URL('./src/pages/auth.js', import.meta.url),
|
|
58
|
+
new URL('./src/pages/stripe.js', import.meta.url),
|
|
59
|
+
// UI Components
|
|
60
|
+
new URL('./src/pages/components/alert.js', import.meta.url),
|
|
61
|
+
new URL('./src/pages/components/avatar.js', import.meta.url),
|
|
62
|
+
new URL('./src/pages/components/badge.js', import.meta.url),
|
|
63
|
+
new URL('./src/pages/components/breadcrumbs.js', import.meta.url),
|
|
64
|
+
new URL('./src/pages/components/button.js', import.meta.url),
|
|
65
|
+
new URL('./src/pages/components/card.js', import.meta.url),
|
|
66
|
+
new URL('./src/pages/components/checkbox.js', import.meta.url),
|
|
67
|
+
new URL('./src/pages/components/carousel.js', import.meta.url),
|
|
68
|
+
new URL('./src/pages/components/charts.js', import.meta.url),
|
|
69
|
+
new URL('./src/pages/components/empty.js', import.meta.url),
|
|
70
|
+
new URL('./src/pages/components/fieldset.js', import.meta.url),
|
|
71
|
+
new URL('./src/pages/components/fileupload.js', import.meta.url),
|
|
72
|
+
new URL('./src/pages/components/heading.js', import.meta.url),
|
|
73
|
+
new URL('./src/pages/components/icons.js', import.meta.url),
|
|
74
|
+
new URL('./src/pages/components/image.js', import.meta.url),
|
|
75
|
+
new URL('./src/pages/components/input.js', import.meta.url),
|
|
76
|
+
new URL('./src/pages/components/list.js', import.meta.url),
|
|
77
|
+
new URL('./src/pages/components/modal.js', import.meta.url),
|
|
78
|
+
new URL('./src/pages/components/progress.js', import.meta.url),
|
|
79
|
+
new URL('./src/pages/components/prose.js', import.meta.url),
|
|
80
|
+
new URL('./src/pages/components/pullquote.js', import.meta.url),
|
|
81
|
+
new URL('./src/pages/components/radio.js', import.meta.url),
|
|
82
|
+
new URL('./src/pages/components/rating.js', import.meta.url),
|
|
83
|
+
new URL('./src/pages/components/search.js', import.meta.url),
|
|
84
|
+
new URL('./src/pages/components/segmented.js', import.meta.url),
|
|
85
|
+
new URL('./src/pages/components/select.js', import.meta.url),
|
|
86
|
+
new URL('./src/pages/components/slider.js', import.meta.url),
|
|
87
|
+
new URL('./src/pages/components/spinner.js', import.meta.url),
|
|
88
|
+
new URL('./src/pages/components/stat.js', import.meta.url),
|
|
89
|
+
new URL('./src/pages/components/stepper.js', import.meta.url),
|
|
90
|
+
new URL('./src/pages/components/table.js', import.meta.url),
|
|
91
|
+
new URL('./src/pages/components/textarea.js', import.meta.url),
|
|
92
|
+
new URL('./src/pages/components/timeline.js', import.meta.url),
|
|
93
|
+
new URL('./src/pages/components/toggle.js', import.meta.url),
|
|
94
|
+
new URL('./src/pages/components/tooltip.js', import.meta.url),
|
|
95
|
+
// Landing Components
|
|
96
|
+
new URL('./src/pages/components/accordion.js', import.meta.url),
|
|
97
|
+
new URL('./src/pages/components/app-badge.js', import.meta.url),
|
|
98
|
+
new URL('./src/pages/components/cta.js', import.meta.url),
|
|
99
|
+
new URL('./src/pages/components/feature.js', import.meta.url),
|
|
100
|
+
new URL('./src/pages/components/hero.js', import.meta.url),
|
|
101
|
+
new URL('./src/pages/components/nav.js', import.meta.url),
|
|
102
|
+
new URL('./src/pages/components/pricing.js', import.meta.url),
|
|
103
|
+
new URL('./src/pages/components/testimonial.js', import.meta.url),
|
|
104
|
+
// Layout Components
|
|
105
|
+
new URL('./src/pages/components/banner.js', import.meta.url),
|
|
106
|
+
new URL('./src/pages/components/cluster.js', import.meta.url),
|
|
107
|
+
new URL('./src/pages/components/code-window.js', import.meta.url),
|
|
108
|
+
new URL('./src/pages/components/container.js', import.meta.url),
|
|
109
|
+
new URL('./src/pages/components/divider.js', import.meta.url),
|
|
110
|
+
new URL('./src/pages/components/footer.js', import.meta.url),
|
|
111
|
+
new URL('./src/pages/components/grid.js', import.meta.url),
|
|
112
|
+
new URL('./src/pages/components/media.js', import.meta.url),
|
|
113
|
+
new URL('./src/pages/components/section.js', import.meta.url),
|
|
114
|
+
new URL('./src/pages/components/stack.js', import.meta.url),
|
|
197
115
|
],
|
|
198
116
|
{
|
|
199
117
|
port: process.env.PORT ? Number(process.env.PORT) : 4000,
|
|
200
118
|
staticDir: new URL('./public', import.meta.url).pathname,
|
|
119
|
+
root: new URL('.', import.meta.url),
|
|
201
120
|
defaultCache: true,
|
|
202
121
|
csp: {
|
|
203
122
|
'img-src': ['https://picsum.photos', 'https://images.unsplash.com'],
|
|
@@ -8,7 +8,7 @@ export default {
|
|
|
8
8
|
route: '/hydration',
|
|
9
9
|
meta: {
|
|
10
10
|
title: 'Hydration — Pulse Docs',
|
|
11
|
-
description: 'How Pulse hydrates server-rendered HTML on the client —
|
|
11
|
+
description: 'How Pulse hydrates server-rendered HTML on the client — automatic hydration, mount(), and production bundles.',
|
|
12
12
|
styles: ['/docs.css'],
|
|
13
13
|
},
|
|
14
14
|
state: {},
|
|
@@ -18,13 +18,22 @@ export default {
|
|
|
18
18
|
next,
|
|
19
19
|
content: `
|
|
20
20
|
${h1('Hydration')}
|
|
21
|
-
${lead('Hydration in Pulse is
|
|
21
|
+
${lead('Hydration in Pulse is automatic. Specs with <code>mutations</code>, <code>actions</code>, or <code>persist</code> are hydrated automatically — Pulse binds events to the server-rendered HTML without re-rendering it. Purely server-rendered specs get zero JavaScript sent to the browser.')}
|
|
22
22
|
|
|
23
|
-
${section('enabling', '
|
|
24
|
-
<p>
|
|
25
|
-
${codeBlock(highlight(
|
|
23
|
+
${section('enabling', 'How hydration works')}
|
|
24
|
+
<p>Pass spec files as <code>URL</code> objects to <code>createServer</code>. Pulse detects whether each spec needs client-side interactivity and, if so, derives the browser-importable path automatically — no <code>hydrate</code> field needed in the spec:</p>
|
|
25
|
+
${codeBlock(highlight(`// server.js
|
|
26
|
+
await createServer(
|
|
27
|
+
[
|
|
28
|
+
new URL('./src/pages/counter.js', import.meta.url), // has mutations → auto-hydrated
|
|
29
|
+
new URL('./src/pages/about.js', import.meta.url), // no mutations → zero JS
|
|
30
|
+
],
|
|
31
|
+
{ port: 3000, root: new URL('.', import.meta.url) }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// src/pages/counter.js — no hydrate field needed
|
|
35
|
+
export default {
|
|
26
36
|
route: '/counter',
|
|
27
|
-
hydrate: '/src/pages/counter.js', // browser path to this file
|
|
28
37
|
state: { count: 0 },
|
|
29
38
|
mutations: {
|
|
30
39
|
increment: (state) => ({ count: state.count + 1 }),
|
|
@@ -40,7 +49,7 @@ export default {
|
|
|
40
49
|
}`, 'js'))}
|
|
41
50
|
|
|
42
51
|
${section('dev-bootstrap', 'Development bootstrap')}
|
|
43
|
-
<p>In development
|
|
52
|
+
<p>In development Pulse emits an inline bootstrap script that imports the spec and runtime source files directly — no build step required:</p>
|
|
44
53
|
${codeBlock(highlight(`<script type="module">
|
|
45
54
|
import spec from '/src/pages/counter.js'
|
|
46
55
|
import { mount } from '/src/runtime/index.js'
|
|
@@ -57,7 +66,7 @@ public/dist/
|
|
|
57
66
|
runtime-abc123.js # shared runtime (~3.8 kB brotli)
|
|
58
67
|
counter.boot-def456.js # per-page spec bundle (~0.4–0.9 kB brotli)
|
|
59
68
|
manifest.json # { '/src/pages/counter.js': '/dist/counter.boot-def456.js' }`, 'bash'))}
|
|
60
|
-
<p>When Pulse detects a manifest (via <code>staticDir</code> auto-detection or explicit <code>manifest</code> option), it resolves the
|
|
69
|
+
<p>When Pulse detects a manifest (via <code>staticDir</code> auto-detection or explicit <code>manifest</code> option), it resolves the auto-derived hydrate path to the bundle path and emits a single <code><script src></code> tag instead of the inline bootstrap:</p>
|
|
61
70
|
${codeBlock(highlight(`<script type="module" src="/dist/counter.boot-def456.js"></script>`, 'html'))}
|
|
62
71
|
|
|
63
72
|
${section('ssr-true', 'The { ssr: true } option')}
|
|
@@ -78,14 +87,14 @@ mount(
|
|
|
78
87
|
<p>After mount, all <code>data-event</code> and <code>data-action</code> attributes in the DOM are wired to the spec's mutations and actions. State updates trigger a full view re-render via <code>innerHTML</code> replacement.</p>
|
|
79
88
|
|
|
80
89
|
${section('no-hydrate', 'Pages without hydration')}
|
|
81
|
-
<p>
|
|
90
|
+
<p>Specs with no <code>mutations</code>, <code>actions</code>, or <code>persist</code> automatically get zero JavaScript — no runtime overhead, no hydration cost. This is the correct default for:</p>
|
|
82
91
|
<ul>
|
|
83
92
|
<li>Documentation pages</li>
|
|
84
93
|
<li>Marketing/landing pages</li>
|
|
85
94
|
<li>Blog posts and articles</li>
|
|
86
95
|
<li>Any page with no client-side interactivity</li>
|
|
87
96
|
</ul>
|
|
88
|
-
${callout('tip', '
|
|
97
|
+
${callout('tip', 'Many pages that appear to need JavaScript can be handled server-side with <a href="/routing">routing</a> and <a href="/server-data">server data</a>. Keep specs free of mutations and actions wherever possible.')}
|
|
89
98
|
|
|
90
99
|
${section('server-state', 'Passing server state to the client')}
|
|
91
100
|
<p>Server data fetched via <code>server.data()</code> is serialised into the page HTML as <code>window.__PULSE_SERVER__</code>. The client runtime reads this on mount, making server data available to the view during client re-renders without an additional network request.</p>
|
|
@@ -18,16 +18,16 @@ export default {
|
|
|
18
18
|
next,
|
|
19
19
|
content: `
|
|
20
20
|
${h1('Server API')}
|
|
21
|
-
${lead('<code>createServer(
|
|
21
|
+
${lead('<code>createServer(entries, options)</code> starts an HTTP server with all guarantees active. Specs are validated before the server accepts connections. SSR, streaming, brotli compression, immutable asset caching, security headers, CSP nonces, and HSTS are all handled automatically.')}
|
|
22
22
|
|
|
23
|
-
${section('signature', 'createServer(
|
|
23
|
+
${section('signature', 'createServer(entries, options)')}
|
|
24
24
|
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
25
25
|
|
|
26
|
-
createServer(
|
|
26
|
+
await createServer(entries, options)`, 'js'))}
|
|
27
27
|
${table(
|
|
28
28
|
['Parameter', 'Type', 'Description'],
|
|
29
29
|
[
|
|
30
|
-
['<code>
|
|
30
|
+
['<code>entries</code>', '<code>Array<URL | Spec></code>', 'Array of <code>URL</code> objects or plain spec objects. URL entries are imported and hydration is auto-derived. Validated at startup — a bad spec throws before the server accepts connections.'],
|
|
31
31
|
['<code>options</code>', '<code>object</code>', 'Server configuration options (see below).'],
|
|
32
32
|
]
|
|
33
33
|
)}
|
|
@@ -41,6 +41,7 @@ createServer(specs, options)`, 'js'))}
|
|
|
41
41
|
['<code>staticDir</code>', '<code>string</code>', '<code>undefined</code>', 'Path to a directory of static files to serve. Relative to the process working directory.'],
|
|
42
42
|
['<code>manifest</code>', '<code>string | object</code>', '<code>null</code>', 'Explicit manifest path or object. Overrides auto-detection from <code>staticDir/dist/manifest.json</code>.'],
|
|
43
43
|
['<code>trailingSlash</code>', '<code>"remove" | "add" | "allow"</code>', '<code>"remove"</code>', '<code>"remove"</code> — 301 redirect <code>/about/</code> → <code>/about</code>. <code>"add"</code> — 301 redirect <code>/about</code> → <code>/about/</code>. <code>"allow"</code> — serve both, no redirect.'],
|
|
44
|
+
['<code>root</code>', '<code>URL | string</code>', '<code>process.cwd()</code>', 'Project root used to derive browser-importable paths from URL entries. Pass <code>new URL(\'.\', import.meta.url)</code> for a CWD-independent value.'],
|
|
44
45
|
['<code>store</code>', '<code>object</code>', '<code>null</code>', 'Global store definition (default export from <code>pulse.store.js</code>). See <a href="/store">Global Store</a>.'],
|
|
45
46
|
['<code>maxBody</code>', '<code>number</code>', '<code>1048576</code>', 'Maximum request body size in bytes (default 1 MB). Requests exceeding this limit receive a 413 response.'],
|
|
46
47
|
['<code>defaultCache</code>', '<code>boolean | number | object</code>', '<code>null</code>', 'Default HTML cache TTL for all pages in production. <code>true</code> = 1 h + 24 h SWR. A number sets <code>max-age</code> in seconds. An object accepts <code>{ public, maxAge, staleWhileRevalidate }</code>. <code>spec.cache</code> overrides per-page.'],
|
|
@@ -55,37 +56,42 @@ createServer(specs, options)`, 'js'))}
|
|
|
55
56
|
|
|
56
57
|
${section('example', 'Full example')}
|
|
57
58
|
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
58
|
-
import home from './src/pages/home.js'
|
|
59
|
-
import contact from './src/pages/contact.js'
|
|
60
59
|
|
|
61
|
-
createServer(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
60
|
+
await createServer(
|
|
61
|
+
[
|
|
62
|
+
new URL('./src/pages/home.js', import.meta.url),
|
|
63
|
+
new URL('./src/pages/contact.js', import.meta.url),
|
|
64
|
+
],
|
|
65
|
+
{
|
|
66
|
+
port: 3000,
|
|
67
|
+
stream: true,
|
|
68
|
+
staticDir: 'public',
|
|
69
|
+
root: new URL('.', import.meta.url),
|
|
70
|
+
onRequest: (req, res) => {
|
|
71
|
+
// Add custom headers
|
|
72
|
+
res.setHeader('X-My-Header', 'my-value')
|
|
73
|
+
// Return false to block a request
|
|
74
|
+
if (req.url.startsWith('/admin') && !isAuthenticated(req)) {
|
|
75
|
+
res.writeHead(401)
|
|
76
|
+
res.end('Unauthorized')
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
// Return undefined (or nothing) to let Pulse handle it
|
|
80
|
+
},
|
|
81
|
+
onError: (err, req, res) => {
|
|
82
|
+
console.error(err)
|
|
83
|
+
if (!res.headersSent) {
|
|
84
|
+
res.writeHead(500, { 'Content-Type': 'text/html' })
|
|
85
|
+
res.end('<h1>Internal Server Error</h1>')
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
)`, 'js'))}
|
|
84
90
|
|
|
85
91
|
${section('multi-brand', 'Multi-brand sites')}
|
|
86
92
|
<p>One Pulse server can serve multiple brands, using the request domain as the key. Pass <code>resolveBrand</code> to <code>createServer</code> — it receives the <code>host</code> header and returns a brand config object of any shape you choose. The result is cached per host for 60 seconds and attached to <code>ctx.brand</code>.</p>
|
|
87
93
|
${codeBlock(highlight(`// server.js
|
|
88
|
-
createServer(specs, {
|
|
94
|
+
await createServer(specs, {
|
|
89
95
|
resolveBrand: async (host) => {
|
|
90
96
|
const slug = host.split('.')[0] // 'acme' from 'acme.myco.com'
|
|
91
97
|
return db.brands.findBySlug(slug) // { slug, name, accent, logo, ... }
|
|
@@ -126,7 +132,7 @@ createServer(specs, {
|
|
|
126
132
|
${section('static-files', 'Static file serving')}
|
|
127
133
|
<p>When <code>staticDir</code> is set, Pulse serves all files in that directory at their relative path. For example, a file at <code>public/app.css</code> is served at <code>/app.css</code>.</p>
|
|
128
134
|
<p>If <code>staticDir/dist/manifest.json</code> exists, Pulse automatically loads it to resolve production hydration bundle paths. No additional configuration is needed.</p>
|
|
129
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
135
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
130
136
|
staticDir: 'public', // serves public/* at /*
|
|
131
137
|
// manifest auto-detected from public/dist/manifest.json
|
|
132
138
|
})`, 'js'))}
|
|
@@ -217,7 +223,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
217
223
|
form-action 'self'`, 'bash'))}
|
|
218
224
|
<p>All inline scripts injected by the framework carry a matching <code>nonce</code> attribute. The nonce is also available as <code>ctx.nonce</code> so view functions can attach it to their own inline scripts.</p>
|
|
219
225
|
<p>To load resources from external origins — Google Fonts, a CDN, an external API — pass a <code>csp</code> object to <code>createServer</code>. Sources are merged into the framework defaults; existing directives are not replaced:</p>
|
|
220
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
226
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
221
227
|
csp: {
|
|
222
228
|
'style-src': ['https://fonts.googleapis.com'],
|
|
223
229
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -252,7 +258,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
252
258
|
${codeBlock(highlight(`GET /healthz → 200 OK
|
|
253
259
|
{ "status": "ok", "uptime": 42.3 }`, 'json'))}
|
|
254
260
|
<p>Configure the path or disable it entirely:</p>
|
|
255
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
261
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
256
262
|
healthCheck: '/ping', // custom path
|
|
257
263
|
// healthCheck: false, // disable
|
|
258
264
|
})`, 'js'))}
|
|
@@ -267,7 +273,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
267
273
|
<li>After <code>shutdownTimeout</code> ms (default 30 000 ms), the process force-exits to prevent a stuck request from blocking a deploy indefinitely.</li>
|
|
268
274
|
</ol>
|
|
269
275
|
<p>The <code>shutdown()</code> function is also returned from <code>createServer</code> so you can trigger it programmatically:</p>
|
|
270
|
-
${codeBlock(highlight(`const { server, shutdown } = createServer(specs, {
|
|
276
|
+
${codeBlock(highlight(`const { server, shutdown } = await createServer(specs, {
|
|
271
277
|
port: 3000,
|
|
272
278
|
shutdownTimeout: 10000, // 10 s — override the 30 s default
|
|
273
279
|
})
|
package/docs/src/pages/spec.js
CHANGED
|
@@ -25,10 +25,10 @@ export default {
|
|
|
25
25
|
['Field', 'Type', 'Required', 'Description'],
|
|
26
26
|
[
|
|
27
27
|
['<code>route</code>', '<code>string</code>', 'Yes', 'URL pattern for this page. Supports <code>:param</code> segments.'],
|
|
28
|
-
['<code>state</code>', '<code>object</code>', '
|
|
28
|
+
['<code>state</code>', '<code>object</code>', 'No', 'Initial client-side state. Deep-cloned on mount. Defaults to <code>{}</code> — omit on purely server-rendered pages.'],
|
|
29
29
|
['<code>view</code>', '<code>function</code>', 'Yes', 'Returns an HTML string. Receives <code>(state, serverState)</code>.'],
|
|
30
30
|
['<code>meta</code>', '<code>object</code>', 'No', 'Page metadata: title, description, styles, OG tags, schema.'],
|
|
31
|
-
['<code>hydrate</code>', '<code>string</code>', '
|
|
31
|
+
['<code>hydrate</code>', '<code>string</code>', 'Auto', 'Auto-derived from the URL entry in <code>createServer</code>. Do not set manually.'],
|
|
32
32
|
['<code>mutations</code>', '<code>object</code>', 'No', 'Synchronous state updaters keyed by name.'],
|
|
33
33
|
['<code>actions</code>', '<code>object</code>', 'No', 'Async operations with full lifecycle hooks.'],
|
|
34
34
|
['<code>validation</code>', '<code>object</code>', 'No', 'Declarative validation rules keyed by dot-path state keys.'],
|
|
@@ -53,7 +53,7 @@ route: '/blog/:year/:slug'`, 'js'))}
|
|
|
53
53
|
<p>Dynamic segments are available in server data and actions via <code>ctx.params</code>. See <a href="/routing">Routing</a> for more.</p>
|
|
54
54
|
|
|
55
55
|
${section('state', 'state')}
|
|
56
|
-
<p>The initial client-side state for the page. Always a plain object. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
|
|
56
|
+
<p>The initial client-side state for the page. Always a plain object. Defaults to <code>{}</code> — you can omit it entirely on purely server-rendered pages with no <code>mutations</code>, <code>actions</code>, or <code>persist</code>. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
|
|
57
57
|
${codeBlock(highlight(`state: {
|
|
58
58
|
count: 0,
|
|
59
59
|
user: { name: '', email: '' },
|
|
@@ -84,10 +84,8 @@ route: '/blog/:year/:slug'`, 'js'))}
|
|
|
84
84
|
<p>See <a href="/meta">Metadata & SEO</a> for the full reference.</p>
|
|
85
85
|
|
|
86
86
|
${section('hydrate', 'hydrate')}
|
|
87
|
-
<p>
|
|
88
|
-
${
|
|
89
|
-
// Production: resolved automatically via manifest.json`, 'js'))}
|
|
90
|
-
${callout('note', 'Omit <code>hydrate</code> for purely server-rendered pages with no client interactivity. Pulse sends zero JavaScript to the browser — no runtime overhead, no hydration cost.')}
|
|
87
|
+
<p>Auto-derived by the framework — do not set this in your spec. When a spec is registered via a <code>URL</code> entry in <code>createServer</code>, Pulse detects whether it needs hydration (has <code>mutations</code>, <code>actions</code>, or <code>persist</code>) and sets the browser path automatically. Purely server-rendered specs get zero JavaScript.</p>
|
|
88
|
+
${callout('note', 'See <a href="/hydration">Hydration</a> for how this works and how to register specs via URL entries.')}
|
|
91
89
|
|
|
92
90
|
${section('mutations', 'mutations')}
|
|
93
91
|
<p>Synchronous state updaters. Each mutation is a function <code>(state, event) => partialState</code>. The returned partial object is merged into state. See <a href="/mutations">Mutations</a>.</p>
|
package/examples/contact.js
CHANGED
package/examples/counter.js
CHANGED
package/examples/dev.server.js
CHANGED
|
@@ -18,12 +18,6 @@ import fs from 'fs'
|
|
|
18
18
|
import path from 'path'
|
|
19
19
|
import { createServer } from '../src/server/index.js'
|
|
20
20
|
import { themeScript } from './shared.js'
|
|
21
|
-
import counter from './counter.js'
|
|
22
|
-
import todos from './todos.js'
|
|
23
|
-
import contact from './contact.js'
|
|
24
|
-
import quiz from './quiz.js'
|
|
25
|
-
import products from './products.js'
|
|
26
|
-
import pricing from './pricing.js'
|
|
27
21
|
|
|
28
22
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
29
23
|
|
|
@@ -79,12 +73,20 @@ function staticHandler(req, res) {
|
|
|
79
73
|
return false
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
createServer(
|
|
83
|
-
[
|
|
76
|
+
await createServer(
|
|
77
|
+
[
|
|
78
|
+
new URL('./counter.js', import.meta.url),
|
|
79
|
+
new URL('./todos.js', import.meta.url),
|
|
80
|
+
new URL('./contact.js', import.meta.url),
|
|
81
|
+
new URL('./quiz.js', import.meta.url),
|
|
82
|
+
new URL('./products.js', import.meta.url),
|
|
83
|
+
new URL('./pricing.js', import.meta.url),
|
|
84
|
+
],
|
|
84
85
|
{
|
|
85
86
|
port: 3001,
|
|
86
87
|
stream: true,
|
|
87
88
|
staticDir: path.join(ROOT, 'public'),
|
|
89
|
+
root: new URL('..', import.meta.url), // pulse2/ — specs served at /examples/*
|
|
88
90
|
onRequest: staticHandler,
|
|
89
91
|
extraBody: themeScript,
|
|
90
92
|
}
|
package/examples/pricing.js
CHANGED
package/examples/products.js
CHANGED
package/examples/quiz.js
CHANGED
package/examples/todos.js
CHANGED
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.5.0
|
package/src/agent/checklist.md
CHANGED
|
@@ -4,11 +4,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
4
4
|
|
|
5
5
|
### Critical
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
```js
|
|
9
|
-
hydrate: '/src/pages/my-page.js', // browser-importable path to this file
|
|
10
|
-
```
|
|
11
|
-
Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
|
|
7
|
+
- **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
|
|
12
8
|
|
|
13
9
|
### Components first
|
|
14
10
|
|
package/src/runtime/index.js
CHANGED
|
@@ -24,8 +24,8 @@ const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
|
|
|
24
24
|
* @param {Object} [serverState] - Serialised server state from SSR
|
|
25
25
|
*/
|
|
26
26
|
export function mount(spec, el, serverState = {}, options = {}) {
|
|
27
|
-
if (!spec ||
|
|
28
|
-
throw new Error('[Pulse] mount: spec must have
|
|
27
|
+
if (!spec || !spec.view) {
|
|
28
|
+
throw new Error('[Pulse] mount: spec must have a view')
|
|
29
29
|
}
|
|
30
30
|
// Spec is validated server-side at startup — no need to re-validate in the browser
|
|
31
31
|
// Initialise the client store from SSR data (no-op after the first page mount).
|
|
@@ -60,7 +60,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// Deep clone initial state — never mutate the spec itself
|
|
63
|
-
let state = deepClone(spec.state)
|
|
63
|
+
let state = deepClone(spec.state ?? {})
|
|
64
64
|
|
|
65
65
|
// Restore persisted keys from localStorage
|
|
66
66
|
const persistKey = spec.persist?.length ? `pulse:${spec.route || location.pathname}` : null
|
|
@@ -69,7 +69,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
69
69
|
try {
|
|
70
70
|
const saved = JSON.parse(localStorage.getItem(persistKey) || '{}')
|
|
71
71
|
spec.persist.forEach(k => {
|
|
72
|
-
if (saved[k] !== undefined && saved[k] !== spec.state[k]) {
|
|
72
|
+
if (saved[k] !== undefined && saved[k] !== (spec.state ?? {})[k]) {
|
|
73
73
|
state[k] = saved[k]
|
|
74
74
|
restoredFromPersist = true
|
|
75
75
|
}
|
package/src/server/index.js
CHANGED
|
@@ -386,14 +386,70 @@ const MIME_TYPES = {
|
|
|
386
386
|
* Return false to short-circuit routing
|
|
387
387
|
*/
|
|
388
388
|
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Spec entry resolution — URL objects are imported and get hydrate auto-set
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Returns true if the spec needs a client-side hydration script.
|
|
395
|
+
* Specs with no mutations, actions, or persist are purely server-rendered.
|
|
396
|
+
*/
|
|
397
|
+
function needsHydration(spec) {
|
|
398
|
+
return !!(spec.mutations && Object.keys(spec.mutations).length) ||
|
|
399
|
+
!!(spec.actions && Object.keys(spec.actions).length) ||
|
|
400
|
+
!!spec.persist
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Accept mixed arrays of spec objects and URL objects.
|
|
405
|
+
* URL entries are dynamically imported; hydrate is auto-derived from the file
|
|
406
|
+
* URL by stripping the project root, so developers never need to set it.
|
|
407
|
+
*
|
|
408
|
+
* root — a file: URL string or URL object for the project root directory.
|
|
409
|
+
* Defaults to process.cwd(). Pass `new URL('.', import.meta.url)` from
|
|
410
|
+
* your server.js for a CWD-independent alternative.
|
|
411
|
+
*/
|
|
412
|
+
async function resolveSpecEntries(entries, root) {
|
|
413
|
+
// Normalise root to a href string ending with /
|
|
414
|
+
let rootHref
|
|
415
|
+
if (root instanceof URL) {
|
|
416
|
+
rootHref = root.href.endsWith('/') ? root.href : root.href + '/'
|
|
417
|
+
} else if (typeof root === 'string' && root.startsWith('file://')) {
|
|
418
|
+
rootHref = root.endsWith('/') ? root : root + '/'
|
|
419
|
+
} else {
|
|
420
|
+
// Default: process.cwd() converted to a file URL
|
|
421
|
+
rootHref = 'file://' + process.cwd().replace(/\\/g, '/') + '/'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const resolved = []
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
if (!(entry instanceof URL)) {
|
|
427
|
+
resolved.push(entry)
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
const mod = await import(entry.href)
|
|
431
|
+
const spec = mod.default ?? mod
|
|
432
|
+
if (!spec.hydrate && needsHydration(spec)) {
|
|
433
|
+
// Strip project root to get the browser-importable path
|
|
434
|
+
const browserPath = entry.href.startsWith(rootHref)
|
|
435
|
+
? '/' + entry.href.slice(rootHref.length)
|
|
436
|
+
: entry.pathname // fallback: use pathname as-is
|
|
437
|
+
resolved.push({ ...spec, hydrate: browserPath })
|
|
438
|
+
} else {
|
|
439
|
+
resolved.push(spec)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return resolved
|
|
443
|
+
}
|
|
444
|
+
|
|
389
445
|
/**
|
|
390
446
|
* Create and start a Pulse HTTP server.
|
|
391
447
|
*
|
|
392
|
-
* @param {import('../spec/schema.js').PulseSpec
|
|
448
|
+
* @param {Array<import('../spec/schema.js').PulseSpec|URL>} entries
|
|
393
449
|
* @param {ServerOptions} [options]
|
|
394
|
-
* @returns {http.Server}
|
|
450
|
+
* @returns {Promise<{server: http.Server, shutdown: Function, updateSpecs: Function}>}
|
|
395
451
|
*/
|
|
396
|
-
export function createServer(
|
|
452
|
+
export async function createServer(entries, options = {}) {
|
|
397
453
|
const {
|
|
398
454
|
port = 3000,
|
|
399
455
|
stream = true,
|
|
@@ -403,6 +459,7 @@ export function createServer(specs, options = {}) {
|
|
|
403
459
|
extraBody = '', // extra HTML injected before </body> on every page
|
|
404
460
|
dev = false, // dev mode — show detailed error pages
|
|
405
461
|
store = null, // global store definition (pulse.store.js default export)
|
|
462
|
+
root = null, // URL/string — project root for deriving browser paths from file: URLs
|
|
406
463
|
resolveBrand = null, // async (host) => brandConfig — keyed by domain
|
|
407
464
|
defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
|
|
408
465
|
fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
|
|
@@ -414,6 +471,9 @@ export function createServer(specs, options = {}) {
|
|
|
414
471
|
onRequest
|
|
415
472
|
} = options
|
|
416
473
|
|
|
474
|
+
// Resolve URL entries → spec objects, auto-setting hydrate where needed
|
|
475
|
+
const specs = await resolveSpecEntries(entries, root)
|
|
476
|
+
|
|
417
477
|
const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
|
|
418
478
|
|
|
419
479
|
// Validate store at startup — fail fast before the server accepts connections
|
|
@@ -134,7 +134,7 @@ let nextPort = 13337
|
|
|
134
134
|
|
|
135
135
|
async function withServer(specs, options, fn) {
|
|
136
136
|
const port = nextPort++
|
|
137
|
-
const { server } = createServer(specs, { ...options, port })
|
|
137
|
+
const { server } = await createServer(specs, { ...options, port })
|
|
138
138
|
await new Promise(resolve => server.once('listening', resolve))
|
|
139
139
|
try {
|
|
140
140
|
await fn(port)
|
|
@@ -345,7 +345,7 @@ console.log('\nStartup validation\n')
|
|
|
345
345
|
await test('throws on invalid spec at startup', async () => {
|
|
346
346
|
let threw = false
|
|
347
347
|
try {
|
|
348
|
-
createServer([{ route: '/bad' }], { port: 19999 })
|
|
348
|
+
await createServer([{ route: '/bad' }], { port: 19999 })
|
|
349
349
|
} catch (e) {
|
|
350
350
|
threw = true
|
|
351
351
|
assert(e.message.includes('/bad'), `Expected route in error: ${e.message}`)
|
|
@@ -1250,7 +1250,7 @@ console.log('\nGraceful shutdown\n')
|
|
|
1250
1250
|
|
|
1251
1251
|
await test('shutdown() is returned from createServer', async () => {
|
|
1252
1252
|
const port = nextPort++
|
|
1253
|
-
const result = createServer([helloSpec], { port, stream: false })
|
|
1253
|
+
const result = await createServer([helloSpec], { port, stream: false })
|
|
1254
1254
|
assert(typeof result.shutdown === 'function', 'Expected shutdown to be a function')
|
|
1255
1255
|
result.server.closeAllConnections?.()
|
|
1256
1256
|
await new Promise(resolve => result.server.close(resolve))
|
|
@@ -1258,7 +1258,7 @@ await test('shutdown() is returned from createServer', async () => {
|
|
|
1258
1258
|
|
|
1259
1259
|
await test('shutdown() stops the server from accepting new connections', async () => {
|
|
1260
1260
|
const port = nextPort++
|
|
1261
|
-
const { server, shutdown } = createServer([helloSpec], { port, stream: false })
|
|
1261
|
+
const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
|
|
1262
1262
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1263
1263
|
|
|
1264
1264
|
// Confirm server is up
|
|
@@ -1276,7 +1276,7 @@ await test('shutdown() stops the server from accepting new connections', async (
|
|
|
1276
1276
|
|
|
1277
1277
|
await test('shutdown() is idempotent — calling twice does not throw', async () => {
|
|
1278
1278
|
const port = nextPort++
|
|
1279
|
-
const { server, shutdown } = createServer([helloSpec], { port, stream: false })
|
|
1279
|
+
const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
|
|
1280
1280
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1281
1281
|
shutdown()
|
|
1282
1282
|
shutdown() // second call is a no-op
|
|
@@ -1295,7 +1295,7 @@ await test('in-flight request completes after shutdown() is called', async () =>
|
|
|
1295
1295
|
view: (_s, server) => `<p>${server.data}</p>`
|
|
1296
1296
|
}
|
|
1297
1297
|
|
|
1298
|
-
const { server, shutdown } = createServer([slowSpec], { port, stream: false })
|
|
1298
|
+
const { server, shutdown } = await createServer([slowSpec], { port, stream: false })
|
|
1299
1299
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1300
1300
|
|
|
1301
1301
|
// Start a request but don't resolve its server fetch yet
|
package/src/spec/schema.js
CHANGED
|
@@ -159,9 +159,7 @@ export function validateSpec(spec) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// state
|
|
162
|
-
if (spec.state
|
|
163
|
-
errors.push('spec.state is required — use {} if there is no client state')
|
|
164
|
-
} else if (typeof spec.state !== 'object' || Array.isArray(spec.state)) {
|
|
162
|
+
if (spec.state !== undefined && (typeof spec.state !== 'object' || Array.isArray(spec.state))) {
|
|
165
163
|
errors.push('spec.state must be a plain object')
|
|
166
164
|
}
|
|
167
165
|
|
|
@@ -371,13 +369,6 @@ export function validateSpec(spec) {
|
|
|
371
369
|
}
|
|
372
370
|
}
|
|
373
371
|
|
|
374
|
-
// hydrate — required whenever there is any client interactivity
|
|
375
|
-
if (!spec.hydrate && !spec.contentType) {
|
|
376
|
-
if (spec.mutations || spec.actions || spec.persist) {
|
|
377
|
-
warnings.push('spec.hydrate is missing — pages with mutations, actions, or persist need hydrate set to their browser-importable path, or client interactivity will silently do nothing')
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
372
|
// meta quality
|
|
382
373
|
if (!spec.meta) {
|
|
383
374
|
warnings.push('spec.meta is missing — add at minimum title and description for SEO')
|
package/src/spec/schema.test.js
CHANGED
|
@@ -54,8 +54,13 @@ test('rejects route that does not start with /', () => {
|
|
|
54
54
|
assertErrors({ route: 'contact', state: {}, view: () => '' }, 'must start with "/"')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
test('
|
|
58
|
-
|
|
57
|
+
test('state is optional — omitting it is valid', () => {
|
|
58
|
+
const { valid, errors } = validateSpec({ route: '/contact', view: () => '' })
|
|
59
|
+
if (!valid) throw new Error(`Expected valid but got: ${errors.join(', ')}`)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('state must be a plain object when provided', () => {
|
|
63
|
+
assertErrors({ route: '/contact', state: 'bad', view: () => '' }, 'spec.state must be a plain object')
|
|
59
64
|
})
|
|
60
65
|
|
|
61
66
|
test('requires view', () => {
|