@invisibleloop/pulse 0.1.21
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/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
|
@@ -0,0 +1,99 @@
|
|
|
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('/routing')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/routing',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Routing — Pulse Docs',
|
|
11
|
+
description: 'How Pulse routes requests to page specs — static and dynamic routes, params, and conventions.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/routing',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Routing')}
|
|
21
|
+
${lead('Every route in Pulse is explicitly declared in the spec. There is no file-based routing, no magic directory conventions, and no implicit mapping. Every page\'s URL is visible in its spec file and nowhere else.')}
|
|
22
|
+
|
|
23
|
+
${section('route-field', 'The route field')}
|
|
24
|
+
<p>Every spec has a <code>route</code> field. This is the URL pattern the spec handles:</p>
|
|
25
|
+
${codeBlock(highlight(`export default {
|
|
26
|
+
route: '/about',
|
|
27
|
+
state: {},
|
|
28
|
+
view: () => \`<h1>About</h1>\`,
|
|
29
|
+
}`, 'js'))}
|
|
30
|
+
<p>Pulse matches the exact path. Trailing slashes are normalised — <code>/about</code> and <code>/about/</code> are treated the same.</p>
|
|
31
|
+
|
|
32
|
+
${section('dynamic', 'Dynamic segments')}
|
|
33
|
+
<p>Use a colon prefix for dynamic path segments. Named segments are captured and available in <code>ctx.params</code> in <a href="/server-data">server data</a>:</p>
|
|
34
|
+
${codeBlock(highlight(`export default {
|
|
35
|
+
route: '/products/:id',
|
|
36
|
+
state: { quantity: 1 },
|
|
37
|
+
server: {
|
|
38
|
+
data: async (ctx) => {
|
|
39
|
+
// ctx.params.id is the captured segment
|
|
40
|
+
const product = await db.products.find(ctx.params.id)
|
|
41
|
+
return { product }
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
view: (state, server) => \`<h1>\${server.product.name}</h1>\`,
|
|
45
|
+
}`, 'js'))}
|
|
46
|
+
|
|
47
|
+
${section('multi-segment', 'Multiple dynamic segments')}
|
|
48
|
+
<p>Any number of dynamic segments can appear in a route:</p>
|
|
49
|
+
${codeBlock(highlight(`route: '/blog/:year/:month/:slug'
|
|
50
|
+
// Matches: /blog/2025/03/my-first-post
|
|
51
|
+
// ctx.params = { year: '2025', month: '03', slug: 'my-first-post' }`, 'js'))}
|
|
52
|
+
|
|
53
|
+
${section('registering', 'Registering routes')}
|
|
54
|
+
<p>Specs are registered explicitly by passing them to <code>createServer</code> as an array. Routes are matched in order — more specific routes must come before more general ones:</p>
|
|
55
|
+
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
56
|
+
import home from './src/pages/home.js'
|
|
57
|
+
import products from './src/pages/products.js'
|
|
58
|
+
import product from './src/pages/product.js' // more specific — comes first
|
|
59
|
+
import blog from './src/pages/blog.js'
|
|
60
|
+
|
|
61
|
+
createServer([home, product, products, blog], { port: 3000 })`, 'js'))}
|
|
62
|
+
|
|
63
|
+
${section('query', 'Query strings')}
|
|
64
|
+
<p>Query string parameters are not part of the route pattern but are accessible via <code>ctx.query</code> in server data:</p>
|
|
65
|
+
${codeBlock(highlight(`// URL: /products?category=shoes&sort=price
|
|
66
|
+
server: {
|
|
67
|
+
data: async (ctx) => {
|
|
68
|
+
const { category, sort } = ctx.query
|
|
69
|
+
return { products: await db.products.list({ category, sort }) }
|
|
70
|
+
},
|
|
71
|
+
}`, 'js'))}
|
|
72
|
+
|
|
73
|
+
${section('not-found', '404 handling')}
|
|
74
|
+
<p>If no spec matches the incoming request path, Pulse returns a 404 response. The response body is a minimal HTML page. To customise the 404 page, use the <code>onError</code> option in <code>createServer</code>:</p>
|
|
75
|
+
${codeBlock(highlight(`createServer(specs, {
|
|
76
|
+
onError: (err, req, res) => {
|
|
77
|
+
if (err.status === 404) {
|
|
78
|
+
res.writeHead(404, { 'Content-Type': 'text/html' })
|
|
79
|
+
res.end('<h1>Not found</h1>')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
})`, 'js'))}
|
|
83
|
+
|
|
84
|
+
${section('conventions', 'File naming conventions')}
|
|
85
|
+
<p>While Pulse does not auto-discover files, the recommended convention maps file names to routes:</p>
|
|
86
|
+
${table(
|
|
87
|
+
['File', 'Route'],
|
|
88
|
+
[
|
|
89
|
+
['<code>src/pages/home.js</code>', '<code>/</code>'],
|
|
90
|
+
['<code>src/pages/about.js</code>', '<code>/about</code>'],
|
|
91
|
+
['<code>src/pages/products.js</code>', '<code>/products</code>'],
|
|
92
|
+
['<code>src/pages/product.js</code>', '<code>/products/:id</code>'],
|
|
93
|
+
['<code>src/pages/blog-post.js</code>', '<code>/blog/:slug</code>'],
|
|
94
|
+
]
|
|
95
|
+
)}
|
|
96
|
+
${callout('tip', 'The filename does not need to match the route exactly — it is just a helpful convention. A file named <code>product.js</code> can handle <code>/products/:id</code> without any issue.')}
|
|
97
|
+
`,
|
|
98
|
+
}),
|
|
99
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
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('/server-api')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/server-api',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Server API — Pulse Docs',
|
|
11
|
+
description: 'Complete reference for createServer — all options, hooks, and response behaviour.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/server-api',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Server API')}
|
|
21
|
+
${lead('<code>createServer(specs, 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
|
+
|
|
23
|
+
${section('signature', 'createServer(specs, options)')}
|
|
24
|
+
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
25
|
+
|
|
26
|
+
createServer(specs, options)`, 'js'))}
|
|
27
|
+
${table(
|
|
28
|
+
['Parameter', 'Type', 'Description'],
|
|
29
|
+
[
|
|
30
|
+
['<code>specs</code>', '<code>Spec[]</code>', 'Array of page spec objects. Validated at startup — a bad spec throws before the server accepts connections.'],
|
|
31
|
+
['<code>options</code>', '<code>object</code>', 'Server configuration options (see below).'],
|
|
32
|
+
]
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
${section('options', 'Options')}
|
|
36
|
+
${table(
|
|
37
|
+
['Option', 'Type', 'Default', 'Description'],
|
|
38
|
+
[
|
|
39
|
+
['<code>port</code>', '<code>number</code>', '<code>3000</code>', 'Port to listen on.'],
|
|
40
|
+
['<code>stream</code>', '<code>boolean</code>', '<code>true</code>', 'Enable streaming SSR globally. Individual specs also declare a <code>stream</code> field to opt in.'],
|
|
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
|
+
['<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
|
+
['<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>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
|
+
['<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
|
+
['<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.'],
|
|
47
|
+
['<code>fetcherTimeout</code>', '<code>number</code>', '<code>null</code>', 'Global timeout in milliseconds for all server fetchers. A fetcher that does not resolve within this limit rejects with a timeout error (→ 500). Override per page with <code>spec.serverTimeout</code>.'],
|
|
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
|
+
['<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
|
+
['<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>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
|
+
]
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
${section('example', 'Full example')}
|
|
57
|
+
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
58
|
+
import home from './src/pages/home.js'
|
|
59
|
+
import contact from './src/pages/contact.js'
|
|
60
|
+
|
|
61
|
+
createServer([home, contact], {
|
|
62
|
+
port: 3000,
|
|
63
|
+
stream: true,
|
|
64
|
+
staticDir: 'public',
|
|
65
|
+
onRequest: (req, res) => {
|
|
66
|
+
// Add custom headers
|
|
67
|
+
res.setHeader('X-My-Header', 'my-value')
|
|
68
|
+
// Return false to block a request
|
|
69
|
+
if (req.url.startsWith('/admin') && !isAuthenticated(req)) {
|
|
70
|
+
res.writeHead(401)
|
|
71
|
+
res.end('Unauthorized')
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
// Return undefined (or nothing) to let Pulse handle it
|
|
75
|
+
},
|
|
76
|
+
onError: (err, req, res) => {
|
|
77
|
+
console.error(err)
|
|
78
|
+
if (!res.headersSent) {
|
|
79
|
+
res.writeHead(500, { 'Content-Type': 'text/html' })
|
|
80
|
+
res.end('<h1>Internal Server Error</h1>')
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
})`, 'js'))}
|
|
84
|
+
|
|
85
|
+
${section('multi-brand', 'Multi-brand sites')}
|
|
86
|
+
<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
|
+
${codeBlock(highlight(`// server.js
|
|
88
|
+
createServer(specs, {
|
|
89
|
+
resolveBrand: async (host) => {
|
|
90
|
+
const slug = host.split('.')[0] // 'acme' from 'acme.myco.com'
|
|
91
|
+
return db.brands.findBySlug(slug) // { slug, name, accent, logo, ... }
|
|
92
|
+
}
|
|
93
|
+
})`, 'js'))}
|
|
94
|
+
<p><code>ctx.brand</code> is available in <code>guard</code>, <code>server</code> fetchers, and any <code>meta</code> field. Meta fields can be functions that receive <code>ctx</code> — Pulse calls them per request:</p>
|
|
95
|
+
${codeBlock(highlight(`export default {
|
|
96
|
+
route: '/',
|
|
97
|
+
|
|
98
|
+
meta: {
|
|
99
|
+
title: (ctx) => \`\${ctx.brand.name} — Home\`,
|
|
100
|
+
description: (ctx) => ctx.brand.tagline,
|
|
101
|
+
styles: (ctx) => ['/pulse-ui.css', \`/themes/\${ctx.brand.slug}.css\`],
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// Expose brand config to the view via server state
|
|
105
|
+
server: {
|
|
106
|
+
brand: (ctx) => ctx.brand,
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
view: (state, { brand }) => \`
|
|
110
|
+
<header>
|
|
111
|
+
<img src="\${brand.logo}" alt="\${brand.name}">
|
|
112
|
+
<nav>...</nav>
|
|
113
|
+
</header>
|
|
114
|
+
<main>...</main>
|
|
115
|
+
\`,
|
|
116
|
+
|
|
117
|
+
guard: async (ctx) => {
|
|
118
|
+
if (!ctx.brand) return { redirect: '/not-found' }
|
|
119
|
+
},
|
|
120
|
+
}`, 'js'))}
|
|
121
|
+
${callout('tip', 'Keep brand theme differences in CSS custom properties. One <code>/pulse-ui.css</code> handles layout and components — each <code>/themes/brand.css</code> only overrides <code>:root</code> variables like <code>--color-accent</code> and <code>--font-heading</code>. Theme files are typically under 1 kB.')}
|
|
122
|
+
|
|
123
|
+
${section('startup-validation', 'Startup validation')}
|
|
124
|
+
<p>All specs are validated against the Pulse schema at startup. An invalid spec throws before the server accepts any connections — misconfigured specs are caught immediately, not when a user first hits the route. There is no silent failure path.</p>
|
|
125
|
+
|
|
126
|
+
${section('static-files', 'Static file serving')}
|
|
127
|
+
<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
|
+
<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, {
|
|
130
|
+
staticDir: 'public', // serves public/* at /*
|
|
131
|
+
// manifest auto-detected from public/dist/manifest.json
|
|
132
|
+
})`, 'js'))}
|
|
133
|
+
|
|
134
|
+
${section('response-behaviour', 'Response behaviour')}
|
|
135
|
+
${table(
|
|
136
|
+
['Request type', 'Response'],
|
|
137
|
+
[
|
|
138
|
+
['Full page request (GET/HEAD)', 'SSR HTML with doctype, head, body, and optional hydration script'],
|
|
139
|
+
['<code>X-Pulse-Navigate: true</code> header', 'JSON: <code>{ html, title, hydrate, serverState }</code> for client-side navigation'],
|
|
140
|
+
['POST/PUT/DELETE to a raw response spec', 'Handled by <code>spec.render</code> — used for webhooks and API endpoints'],
|
|
141
|
+
['POST/PUT/DELETE to a page spec', '405 Method Not Allowed'],
|
|
142
|
+
['Static file', 'File contents with appropriate Content-Type'],
|
|
143
|
+
['No matching route', '404 response'],
|
|
144
|
+
]
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
${section('body-parsing', 'Reading request bodies')}
|
|
148
|
+
<p>Body parsing is available in <code>guard</code>, <code>server.*</code> fetchers, and <code>render</code> (raw specs). All methods are lazy — the stream is consumed once and the result is memoised per request.</p>
|
|
149
|
+
${table(
|
|
150
|
+
['Method', 'Returns', 'Description'],
|
|
151
|
+
[
|
|
152
|
+
['<code>await ctx.json()</code>', '<code>object | null</code>', 'Parse a JSON request body. Returns <code>null</code> for an empty body.'],
|
|
153
|
+
['<code>await ctx.text()</code>', '<code>string</code>', 'Read the body as a plain string.'],
|
|
154
|
+
['<code>await ctx.formData()</code>', '<code>object | null</code>', 'Parse a URL-encoded body into a plain object. Returns <code>null</code> for an empty body.'],
|
|
155
|
+
['<code>await ctx.buffer()</code>', '<code>Buffer</code>', 'Read the raw body as a Node.js Buffer.'],
|
|
156
|
+
]
|
|
157
|
+
)}
|
|
158
|
+
<p>Bodies larger than <code>maxBody</code> (default 1 MB) are rejected with a <strong>413</strong> before the handler runs. Set <code>maxBody</code> in <code>createServer</code> options to adjust.</p>
|
|
159
|
+
<p>Page specs only accept <strong>GET and HEAD</strong> by default — POST returns 405. To accept other methods, declare <code>spec.methods</code>:</p>
|
|
160
|
+
${codeBlock(highlight(`export default {
|
|
161
|
+
route: '/contact',
|
|
162
|
+
methods: ['GET', 'POST'],
|
|
163
|
+
|
|
164
|
+
guard: async (ctx) => {
|
|
165
|
+
if (ctx.method === 'POST') {
|
|
166
|
+
const data = await ctx.formData()
|
|
167
|
+
if (!data.email) return { status: 422, json: { error: 'Email required' } }
|
|
168
|
+
await db.leads.create(data)
|
|
169
|
+
return { redirect: '/contact?sent=1' }
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
state: {},
|
|
174
|
+
view: () => \`<form method="POST">...</form>\`,
|
|
175
|
+
}`, 'js'))}
|
|
176
|
+
${callout('note', 'Raw response specs (<code>contentType</code> set) accept any HTTP method without <code>spec.methods</code> — they are always method-agnostic.')}
|
|
177
|
+
|
|
178
|
+
${section('escaping', 'Escaping user data')}
|
|
179
|
+
<p>Import <code>escHtml</code> from <code>@invisibleloop/pulse/html</code> to safely embed untrusted values in HTML view strings:</p>
|
|
180
|
+
${codeBlock(highlight(`import { escHtml } from '@invisibleloop/pulse/html'
|
|
181
|
+
|
|
182
|
+
view: (state) => \`
|
|
183
|
+
<p>Hello, \${escHtml(state.username)}</p>
|
|
184
|
+
\``, 'js'))}
|
|
185
|
+
${callout('warning', 'Always use <code>escHtml</code> around values that originate from user input, URL params, or external APIs. Omitting it is an XSS vulnerability.')}
|
|
186
|
+
<p>To use a nonce on a view-authored inline script, pass <code>ctx.nonce</code> through a server fetcher:</p>
|
|
187
|
+
${codeBlock(highlight(`server: {
|
|
188
|
+
meta: async (ctx) => ({ nonce: ctx.nonce }),
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
view: (state, server) => \`
|
|
192
|
+
<script nonce="\${server.meta.nonce}">console.log('inline ok')</script>
|
|
193
|
+
\``, 'js'))}
|
|
194
|
+
<p>Inline scripts without the matching nonce are blocked by the CSP.</p>
|
|
195
|
+
|
|
196
|
+
${section('security-headers', 'Security headers')}
|
|
197
|
+
<p>Pulse sends the following headers on <strong>every</strong> response — including 404 and 500 errors. There is no configuration required and no way to accidentally omit them:</p>
|
|
198
|
+
${codeBlock(highlight(`X-Content-Type-Options: nosniff
|
|
199
|
+
X-Frame-Options: DENY
|
|
200
|
+
Referrer-Policy: strict-origin-when-cross-origin
|
|
201
|
+
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
|
202
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
203
|
+
Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
204
|
+
|
|
205
|
+
${section('csp', 'Content Security Policy')}
|
|
206
|
+
<p>HTML page responses include a <code>Content-Security-Policy</code> header. Scripts require a per-request cryptographic nonce; stylesheets are restricted to same-origin; everything else defaults to <code>'none'</code>:</p>
|
|
207
|
+
${codeBlock(highlight(`Content-Security-Policy:
|
|
208
|
+
default-src 'none';
|
|
209
|
+
script-src 'self' 'nonce-{random}';
|
|
210
|
+
style-src 'self';
|
|
211
|
+
style-src-attr 'unsafe-inline';
|
|
212
|
+
img-src 'self' data:;
|
|
213
|
+
font-src 'self';
|
|
214
|
+
connect-src 'self';
|
|
215
|
+
frame-ancestors 'none';
|
|
216
|
+
base-uri 'self';
|
|
217
|
+
form-action 'self'`, 'bash'))}
|
|
218
|
+
<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
|
+
<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, {
|
|
221
|
+
csp: {
|
|
222
|
+
'style-src': ['https://fonts.googleapis.com'],
|
|
223
|
+
'font-src': ['https://fonts.gstatic.com'],
|
|
224
|
+
'connect-src': ['https://api.example.com'],
|
|
225
|
+
'img-src': ['https://images.unsplash.com'],
|
|
226
|
+
},
|
|
227
|
+
})`, 'js'))}
|
|
228
|
+
${callout('note', 'The <code>style-src-attr \'unsafe-inline\'</code> directive is required for inline <code>style="..."</code> attributes used by the UI component library to set CSS custom properties (e.g. spinner size, progress fill). It is scoped to attributes only — <code><style></code> blocks are fully nonce-controlled.')}
|
|
229
|
+
|
|
230
|
+
${section('hsts', 'HSTS')}
|
|
231
|
+
<p>When a request arrives with <code>x-forwarded-proto: https</code> (or over a TLS socket), Pulse adds:</p>
|
|
232
|
+
${codeBlock(highlight(`Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`, 'bash'))}
|
|
233
|
+
<p>This is automatic — no configuration required. On plain HTTP the header is omitted. The <code>preload</code> directive means you can submit the domain to <a href="https://hstspreload.org" target="_blank" rel="noopener">hstspreload.org</a> so browsers enforce HTTPS before the first connection — this is a separate manual step, not automatic.</p>
|
|
234
|
+
|
|
235
|
+
${section('cookies', 'Cookie defaults')}
|
|
236
|
+
<p>Cookies set via <code>ctx.setCookie()</code> default to <code>SameSite=Lax</code>. CSRF protection is on by default — omitting a <code>sameSite</code> option does not weaken it.</p>
|
|
237
|
+
|
|
238
|
+
${section('compression', 'Compression')}
|
|
239
|
+
<p>Pulse compresses all compressible responses using brotli (preferred) or gzip (fallback), based on the <code>Accept-Encoding</code> header. Streaming responses use transform streams so compression and delivery happen concurrently.</p>
|
|
240
|
+
|
|
241
|
+
${section('nav-header', 'X-Pulse-Navigate header')}
|
|
242
|
+
<p>When a request includes <code>X-Pulse-Navigate: true</code>, Pulse returns a JSON response instead of full HTML. This is used by the client-side navigation system to swap page content without a full reload:</p>
|
|
243
|
+
${codeBlock(highlight(`{
|
|
244
|
+
"html": "<main>...rendered content...</main>",
|
|
245
|
+
"title": "Page Title — Site",
|
|
246
|
+
"hydrate": "/dist/page.boot-abc123.js",
|
|
247
|
+
"serverState": { "product": { "id": 1, "name": "..." } }
|
|
248
|
+
}`, 'js'))}
|
|
249
|
+
|
|
250
|
+
${section('health-check', 'Health check endpoint')}
|
|
251
|
+
<p>Pulse exposes a built-in health check at <code>/healthz</code> (configurable). It responds before <code>onRequest</code>, static file serving, and route matching — so load balancers and orchestration systems always get a response even if a hook is faulty.</p>
|
|
252
|
+
${codeBlock(highlight(`GET /healthz → 200 OK
|
|
253
|
+
{ "status": "ok", "uptime": 42.3 }`, 'json'))}
|
|
254
|
+
<p>Configure the path or disable it entirely:</p>
|
|
255
|
+
${codeBlock(highlight(`createServer(specs, {
|
|
256
|
+
healthCheck: '/ping', // custom path
|
|
257
|
+
// healthCheck: false, // disable
|
|
258
|
+
})`, 'js'))}
|
|
259
|
+
${callout('note', '<code>HEAD /healthz</code> is also supported — returns the same status headers with no body. The endpoint sets <code>Cache-Control: no-store</code> so proxies never serve a stale health status.')}
|
|
260
|
+
|
|
261
|
+
${section('graceful-shutdown', 'Graceful shutdown')}
|
|
262
|
+
<p>Pulse registers <code>SIGTERM</code> and <code>SIGINT</code> handlers automatically. When either signal arrives:</p>
|
|
263
|
+
<ol>
|
|
264
|
+
<li><code>server.close()</code> stops accepting new connections.</li>
|
|
265
|
+
<li>Idle keep-alive sockets are destroyed immediately.</li>
|
|
266
|
+
<li>In-flight requests are allowed to finish naturally.</li>
|
|
267
|
+
<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
|
+
</ol>
|
|
269
|
+
<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, {
|
|
271
|
+
port: 3000,
|
|
272
|
+
shutdownTimeout: 10000, // 10 s — override the 30 s default
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// SIGTERM is already wired automatically.
|
|
276
|
+
// Call manually when needed — idempotent, safe to call multiple times.
|
|
277
|
+
shutdown()`, 'js'))}
|
|
278
|
+
${callout('note', 'Idle keep-alive sockets are destroyed immediately on shutdown. In-flight streaming responses finish sending before the socket is closed — no partial responses are delivered to clients.')}
|
|
279
|
+
`,
|
|
280
|
+
}),
|
|
281
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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('/server-data')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/server-data',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Server Data — Pulse Docs',
|
|
11
|
+
description: 'Fetch, transform, and combine data on the server before rendering — external APIs, multiple fetchers, parallel requests.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/server-data',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Server Data')}
|
|
21
|
+
${lead('The <code>server</code> field fetches data before the page renders. It runs exclusively on the server — credentials, database access, and API secrets stay there. The browser never receives the fetcher code, only its serialised output.')}
|
|
22
|
+
|
|
23
|
+
${section('basic', 'Basic usage')}
|
|
24
|
+
<p>Declare a <code>data</code> async function inside the <code>server</code> object. It receives a <code>ctx</code> object with request context and returns a plain object:</p>
|
|
25
|
+
${codeBlock(highlight(`export default {
|
|
26
|
+
route: '/products/:id',
|
|
27
|
+
state: { quantity: 1 },
|
|
28
|
+
server: {
|
|
29
|
+
data: async (ctx) => {
|
|
30
|
+
const product = await db.products.findById(ctx.params.id)
|
|
31
|
+
const related = await db.products.findRelated(product.category)
|
|
32
|
+
return { product, related }
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
view: (state, server) => \`
|
|
36
|
+
<main>
|
|
37
|
+
<h1>\${server.product.name}</h1>
|
|
38
|
+
<p>\${server.product.description}</p>
|
|
39
|
+
<p>Price: £\${server.product.price}</p>
|
|
40
|
+
<div class="quantity">
|
|
41
|
+
<button data-event="decrement">-</button>
|
|
42
|
+
<span>\${state.quantity}</span>
|
|
43
|
+
<button data-event="increment">+</button>
|
|
44
|
+
</div>
|
|
45
|
+
</main>
|
|
46
|
+
\`,
|
|
47
|
+
}`, 'js'))}
|
|
48
|
+
|
|
49
|
+
${section('ctx', 'The ctx object')}
|
|
50
|
+
<p>The <code>ctx</code> argument passed to <code>server.data()</code> contains the full request context:</p>
|
|
51
|
+
${table(
|
|
52
|
+
['Property', 'Type', 'Description'],
|
|
53
|
+
[
|
|
54
|
+
['<code>ctx.params</code>', '<code>object</code>', 'URL path parameters from dynamic route segments (e.g. <code>:id</code>).'],
|
|
55
|
+
['<code>ctx.query</code>', '<code>object</code>', 'Parsed query string parameters (e.g. <code>?page=2&sort=asc</code>).'],
|
|
56
|
+
['<code>ctx.headers</code>', '<code>object</code>', 'Incoming request headers (lowercase keys).'],
|
|
57
|
+
['<code>ctx.cookies</code>', '<code>object</code>', 'Parsed cookies from the <code>Cookie</code> header.'],
|
|
58
|
+
]
|
|
59
|
+
)}
|
|
60
|
+
${codeBlock(highlight(`server: {
|
|
61
|
+
data: async (ctx) => {
|
|
62
|
+
// Dynamic route: /blog/:year/:slug
|
|
63
|
+
const { year, slug } = ctx.params
|
|
64
|
+
|
|
65
|
+
// Query string: ?page=2
|
|
66
|
+
const page = parseInt(ctx.query.page ?? '1', 10)
|
|
67
|
+
|
|
68
|
+
// Authentication via cookie
|
|
69
|
+
const session = ctx.cookies.sessionId
|
|
70
|
+
? await sessions.find(ctx.cookies.sessionId)
|
|
71
|
+
: null
|
|
72
|
+
|
|
73
|
+
const post = await db.posts.findBySlug(year, slug)
|
|
74
|
+
return { post, page, session }
|
|
75
|
+
},
|
|
76
|
+
}`, 'js'))}
|
|
77
|
+
|
|
78
|
+
${section('view-arg', 'Server state in the view')}
|
|
79
|
+
<p>The resolved values from all server fetchers are merged into a single object and passed to the <code>view</code> function as its second argument, conventionally named <code>server</code>. Each fetcher key becomes a property:</p>
|
|
80
|
+
${codeBlock(highlight(`// server: { post: async (ctx) => ... }
|
|
81
|
+
|
|
82
|
+
view: (state, server) => \`
|
|
83
|
+
<article>
|
|
84
|
+
<h1>\${server.post.title}</h1>
|
|
85
|
+
<time>\${server.post.date}</time>
|
|
86
|
+
\${server.post.body}
|
|
87
|
+
</article>
|
|
88
|
+
\``, 'js'))}
|
|
89
|
+
${callout('note', 'If no <code>server</code> fetchers are declared, the second argument to <code>view</code> is an empty object <code>{}</code>.')}
|
|
90
|
+
|
|
91
|
+
${section('ssr-only', 'SSR only — not available on the client')}
|
|
92
|
+
<p>Server data is resolved before the HTML is generated and is never re-fetched in the browser. After hydration, the serialised output is available to the view as <code>window.__PULSE_SERVER__</code> for client-side re-renders — it is the same value the server computed, not a new request.</p>
|
|
93
|
+
${callout('warning', 'Server state is serialised into the page HTML as <code>window.__PULSE_SERVER__</code> and is visible to anyone who views source. Filter fetcher output to only what the view needs — never include credentials, internal IDs, or user data beyond what must be rendered.')}
|
|
94
|
+
|
|
95
|
+
${section('errors', 'Error handling')}
|
|
96
|
+
<p>If <code>server.data()</code> throws, the server returns a 500 error response. Handle errors gracefully by catching inside the function and returning a safe fallback:</p>
|
|
97
|
+
${codeBlock(highlight(`server: {
|
|
98
|
+
data: async (ctx) => {
|
|
99
|
+
try {
|
|
100
|
+
const product = await db.products.findById(ctx.params.id)
|
|
101
|
+
if (!product) return { product: null, notFound: true }
|
|
102
|
+
return { product, notFound: false }
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('Failed to load product', err)
|
|
105
|
+
return { product: null, notFound: true }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
view: (state, server) => server.notFound
|
|
110
|
+
? \`<p>Product not found.</p>\`
|
|
111
|
+
: \`<h1>\${server.product.name}</h1>\``, 'js'))}
|
|
112
|
+
|
|
113
|
+
${section('multiple-fetchers', 'Multiple named fetchers')}
|
|
114
|
+
<p>The <code>server</code> object supports any number of named async functions. Each one receives <code>ctx</code> and its return value is available on <code>server</code> in the view under the same key. Fetchers run in parallel — the page renders once all have resolved:</p>
|
|
115
|
+
${codeBlock(highlight(`export default {
|
|
116
|
+
route: '/products/:id',
|
|
117
|
+
state: { quantity: 1 },
|
|
118
|
+
server: {
|
|
119
|
+
product: async (ctx) => db.products.findById(ctx.params.id),
|
|
120
|
+
reviews: async (ctx) => db.reviews.forProduct(ctx.params.id),
|
|
121
|
+
related: async (ctx) => db.products.related(ctx.params.id),
|
|
122
|
+
},
|
|
123
|
+
view: (state, server) => \`
|
|
124
|
+
<h1>\${server.product.name}</h1>
|
|
125
|
+
<p>\${server.reviews.length} reviews</p>
|
|
126
|
+
\${server.related.map(p => \`<a href="/products/\${p.id}">\${p.name}</a>\`).join('')}
|
|
127
|
+
\`,
|
|
128
|
+
}`, 'js'))}
|
|
129
|
+
|
|
130
|
+
${section('external-apis', 'External API fetching')}
|
|
131
|
+
<p>Server fetchers run in Node.js. API keys and credentials are read from environment variables and never leave the server — only the fetcher's return value is serialised into the page:</p>
|
|
132
|
+
${codeBlock(highlight(`server: {
|
|
133
|
+
weather: async (ctx) => {
|
|
134
|
+
const res = await fetch(
|
|
135
|
+
\`https://api.weather.example.com/current?city=\${ctx.query.city}\`,
|
|
136
|
+
{ headers: { Authorization: \`Bearer \${process.env.WEATHER_API_KEY}\` } }
|
|
137
|
+
)
|
|
138
|
+
if (!res.ok) return null
|
|
139
|
+
return res.json()
|
|
140
|
+
},
|
|
141
|
+
}`, 'js'))}
|
|
142
|
+
|
|
143
|
+
${section('transforming', 'Transforming API responses')}
|
|
144
|
+
<p>Fetchers are the right place to reshape external responses before they reach the view. Filter to only what the view needs — this reduces payload size and prevents internal fields from being serialised into the page HTML:</p>
|
|
145
|
+
${codeBlock(highlight(`server: {
|
|
146
|
+
article: async (ctx) => {
|
|
147
|
+
const res = await fetch(\`https://cms.example.com/articles/\${ctx.params.slug}\`)
|
|
148
|
+
const data = await res.json()
|
|
149
|
+
|
|
150
|
+
// Shape and filter before serialisation
|
|
151
|
+
return {
|
|
152
|
+
title: data.fields.title,
|
|
153
|
+
body: data.fields.bodyHtml,
|
|
154
|
+
publishedAt: new Date(data.sys.createdAt).toLocaleDateString('en-GB'),
|
|
155
|
+
author: data.fields.author.name,
|
|
156
|
+
// data.sys.revision, internal IDs etc. are dropped here
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}`, 'js'))}
|
|
160
|
+
|
|
161
|
+
${section('parallel', 'Parallel fetches within a single fetcher')}
|
|
162
|
+
<p>When multiple independent requests are needed inside a single fetcher, <code>Promise.all</code> runs them concurrently so the total wait time is the slowest request, not the sum:</p>
|
|
163
|
+
${codeBlock(highlight(`server: {
|
|
164
|
+
page: async (ctx) => {
|
|
165
|
+
const [hero, featured, nav] = await Promise.all([
|
|
166
|
+
fetch('https://cms.example.com/hero').then(r => r.json()),
|
|
167
|
+
fetch('https://cms.example.com/featured').then(r => r.json()),
|
|
168
|
+
fetch('https://cms.example.com/nav').then(r => r.json()),
|
|
169
|
+
])
|
|
170
|
+
return { hero, featured, nav }
|
|
171
|
+
},
|
|
172
|
+
}`, 'js'))}
|
|
173
|
+
|
|
174
|
+
${section('caching-link', 'Caching server data')}
|
|
175
|
+
<p>Use <a href="/caching"><code>serverTtl</code></a> to cache fetcher results in-process for a number of seconds. This avoids hitting external APIs or a database on every request for data that changes infrequently.</p>
|
|
176
|
+
${codeBlock(highlight(`export default {
|
|
177
|
+
route: '/homepage',
|
|
178
|
+
serverTtl: 60, // cache all server fetchers for 60 seconds
|
|
179
|
+
server: {
|
|
180
|
+
featured: async () => fetch('https://api.example.com/featured').then(r => r.json()),
|
|
181
|
+
},
|
|
182
|
+
}`, 'js'))}
|
|
183
|
+
`,
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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('/slash-commands')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/slash-commands',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Slash Commands — Pulse Docs',
|
|
11
|
+
description: 'The built-in slash commands available in the Pulse AI agent session.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/slash-commands',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Slash Commands')}
|
|
21
|
+
${lead('Slash commands close the development loop inside the agent session. Building, auditing, and verifying performance happen without leaving the conversation — and every audit is checked against the thresholds you have declared in config.')}
|
|
22
|
+
|
|
23
|
+
${section('commands', 'Available commands')}
|
|
24
|
+
${table(
|
|
25
|
+
['Command', 'What it does'],
|
|
26
|
+
[
|
|
27
|
+
['<code>/pulse-dev</code>', 'Starts (or restarts) the development server. The server watches for file changes and reloads automatically.'],
|
|
28
|
+
['<code>/pulse-stop</code>', 'Stops the running development server.'],
|
|
29
|
+
['<code>/pulse-build</code>', 'Runs a production build. Bundles all specs via esbuild into <code>public/dist/</code> with content-hashed filenames.'],
|
|
30
|
+
['<code>/pulse-start</code>', 'Starts the production server against the built output. Used to verify production behaviour before deploying.'],
|
|
31
|
+
['<code>/pulse-report</code>', 'Runs a Lighthouse audit against a production build and opens the performance report dashboard. Captures Performance score, web vitals, bundle sizes, and request counts.'],
|
|
32
|
+
]
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
${section('usage', 'Using commands')}
|
|
36
|
+
<p>Commands are typed directly into the agent chat:</p>
|
|
37
|
+
${codeBlock(highlight(`/pulse-dev
|
|
38
|
+
/pulse-report`, 'bash'))}
|
|
39
|
+
<p>The agent executes the relevant CLI steps and reports back with results, including whether any Lighthouse score or Core Web Vitals metric failed a configured threshold.</p>
|
|
40
|
+
|
|
41
|
+
${callout('note', '<code>/pulse-report</code> performs a full production build before auditing. This guarantees accurate scores and correct brotli-compressed bundle sizes — development builds are unminified and serve no production metrics.')}
|
|
42
|
+
|
|
43
|
+
${section('plain-language', 'Plain language prompts')}
|
|
44
|
+
<p>Slash commands cover the most common operations. For everything else, describe the goal — the agent handles the implementation within Pulse's spec structure:</p>
|
|
45
|
+
${codeBlock(highlight(`"Create a blog index page that fetches posts from an API"
|
|
46
|
+
"Add email validation to the contact form"
|
|
47
|
+
"Build a checkout flow with a Stripe payment step"
|
|
48
|
+
"Add a guard to the dashboard so unauthenticated users are redirected to /login"`, 'bash'))}
|
|
49
|
+
<p>The agent produces spec files that conform to Pulse's structure — the framework enforces correctness, so there is no manual wiring to verify.</p>
|
|
50
|
+
|
|
51
|
+
${section('report-dashboard', 'Performance report dashboard')}
|
|
52
|
+
<p>The report dashboard is available at <code>/_pulse/report</code> when the dev server is running. It shows a history of Lighthouse audits across all pages — Performance score, Core Web Vitals, bundle sizes, and request counts. Threshold failures are highlighted. Each audit is saved to <code>.pulse/reports/</code> as JSON.</p>
|
|
53
|
+
`,
|
|
54
|
+
}),
|
|
55
|
+
}
|