@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,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — SSR Renderer
|
|
3
|
+
*
|
|
4
|
+
* Takes a spec and an HTTP request context.
|
|
5
|
+
* Resolves server state, executes view functions, returns streamable HTML.
|
|
6
|
+
*
|
|
7
|
+
* Two modes:
|
|
8
|
+
* renderToString — resolves everything, returns a complete HTML string
|
|
9
|
+
* renderToStream — streams shell immediately, deferred segments follow
|
|
10
|
+
*
|
|
11
|
+
* No framework. No dependencies. Pure Node.js streams.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { assertValidSpec, getStreamOrder } from '../spec/schema.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// renderToString
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a spec to a complete HTML string.
|
|
22
|
+
* Resolves all server state before rendering — no streaming.
|
|
23
|
+
* Use this for simple pages or when you need the full HTML before sending.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
26
|
+
* @param {Object} [ctx] - Request context passed to server data fetchers
|
|
27
|
+
* @param {Object} [pageState] - Optional page-level state (from a parent layout)
|
|
28
|
+
* @returns {Promise<{ html: string, serverState: Object, timing: Object }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function renderToString(spec, ctx = {}, pageState = {}) {
|
|
31
|
+
assertValidSpec(spec)
|
|
32
|
+
|
|
33
|
+
const t0 = performance.now()
|
|
34
|
+
|
|
35
|
+
// Resolve all server data in parallel
|
|
36
|
+
const serverState = await resolveServerState(spec, ctx)
|
|
37
|
+
|
|
38
|
+
// Merge declared store keys into server state — store keys lose to page-level keys
|
|
39
|
+
const mergedServerState = mergeStoreKeys(spec, serverState, ctx.store)
|
|
40
|
+
|
|
41
|
+
const tData = performance.now()
|
|
42
|
+
|
|
43
|
+
// Merge initial client state with any page-level state
|
|
44
|
+
const clientState = { ...spec.state, ...pageState }
|
|
45
|
+
|
|
46
|
+
// Render all segments
|
|
47
|
+
const html = renderSegments(spec, clientState, mergedServerState)
|
|
48
|
+
|
|
49
|
+
const tRender = performance.now()
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
html,
|
|
53
|
+
serverState: mergedServerState,
|
|
54
|
+
timing: {
|
|
55
|
+
data: +(tData - t0).toFixed(2),
|
|
56
|
+
render: +(tRender - tData).toFixed(2),
|
|
57
|
+
total: +(tRender - t0).toFixed(2)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// renderToStream
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a spec to a Node.js Readable stream.
|
|
68
|
+
*
|
|
69
|
+
* Shell segments are written immediately on the first flush.
|
|
70
|
+
* Deferred segments are written as their server data resolves.
|
|
71
|
+
* Each deferred segment is wrapped in a <pulse-chunk> element and
|
|
72
|
+
* replaced client-side by a tiny inline script.
|
|
73
|
+
*
|
|
74
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
75
|
+
* @param {Object} [ctx]
|
|
76
|
+
* @returns {ReadableStream} - Web Streams API ReadableStream
|
|
77
|
+
*/
|
|
78
|
+
export function renderToStream(spec, ctx = {}, nonce = '') {
|
|
79
|
+
assertValidSpec(spec)
|
|
80
|
+
|
|
81
|
+
const { shell, deferred } = getStreamOrder(spec)
|
|
82
|
+
const clientState = { ...spec.state }
|
|
83
|
+
|
|
84
|
+
let controller
|
|
85
|
+
|
|
86
|
+
const stream = new ReadableStream({
|
|
87
|
+
start(c) { controller = c }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Run async — don't await here, stream is returned immediately
|
|
91
|
+
;(async () => {
|
|
92
|
+
try {
|
|
93
|
+
// Resolve shell server data (only what shell segments need)
|
|
94
|
+
const shellServerState = await resolveServerStateForSegments(spec, ctx, shell)
|
|
95
|
+
|
|
96
|
+
// Merge declared store keys — store keys lose to page-level keys
|
|
97
|
+
const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
|
|
98
|
+
|
|
99
|
+
// Write shell immediately
|
|
100
|
+
const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
|
|
101
|
+
controller.enqueue(encode(shellHtml))
|
|
102
|
+
|
|
103
|
+
// Write deferred segments as they resolve
|
|
104
|
+
if (deferred.length > 0) {
|
|
105
|
+
// Enqueue placeholder elements so the browser knows where to insert
|
|
106
|
+
const placeholders = deferred
|
|
107
|
+
.map(key => `<pulse-deferred id="pd-${key}"></pulse-deferred>`)
|
|
108
|
+
.join('')
|
|
109
|
+
controller.enqueue(encode(placeholders))
|
|
110
|
+
|
|
111
|
+
// Resolve and stream each deferred segment
|
|
112
|
+
await Promise.all(deferred.map(async (key) => {
|
|
113
|
+
const segServerState = await resolveServerStateForSegments(spec, ctx, [key])
|
|
114
|
+
const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
|
|
115
|
+
const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
|
|
116
|
+
|
|
117
|
+
// Inline script replaces the placeholder with the rendered content
|
|
118
|
+
const chunk = `
|
|
119
|
+
<template id="pt-${key}">${segHtml}</template>
|
|
120
|
+
<script nonce="${nonce}">
|
|
121
|
+
(function() {
|
|
122
|
+
var t = document.getElementById('pt-${key}');
|
|
123
|
+
var p = document.getElementById('pd-${key}');
|
|
124
|
+
if (t && p) { p.replaceWith(t.content.cloneNode(true)); t.remove(); }
|
|
125
|
+
})();
|
|
126
|
+
</script>`
|
|
127
|
+
|
|
128
|
+
controller.enqueue(encode(chunk))
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Inject server state so the client hydration can read it without
|
|
133
|
+
// a second request — mirrors what wrapDocument does for the string path.
|
|
134
|
+
// Emit when there is page server data or store data to serialise.
|
|
135
|
+
if (Object.keys(mergedShellState).length > 0) {
|
|
136
|
+
const script = `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedShellState)};</script>`
|
|
137
|
+
controller.enqueue(encode(script))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
controller.close()
|
|
141
|
+
} catch (err) {
|
|
142
|
+
controller.error(err)
|
|
143
|
+
}
|
|
144
|
+
})()
|
|
145
|
+
|
|
146
|
+
return stream
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Page HTML wrapper
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wrap rendered content in a full HTML document.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} options
|
|
157
|
+
* @param {string} options.content - The rendered body content
|
|
158
|
+
* @param {Object} [options.spec] - The spec (used for meta, title)
|
|
159
|
+
* @param {Object} [options.serverState] - Serialised for client resumption
|
|
160
|
+
* @param {Object} [options.timing] - Server-Timing values
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '' }) {
|
|
164
|
+
const meta = spec.meta || {}
|
|
165
|
+
const title = meta.title || 'Pulse'
|
|
166
|
+
|
|
167
|
+
const bodyAttr = meta.theme ? ` data-theme="${esc(meta.theme)}"` : ''
|
|
168
|
+
|
|
169
|
+
const metaTags = [
|
|
170
|
+
meta.description ? `<meta name="description" content="${esc(meta.description)}">` : '',
|
|
171
|
+
meta.ogTitle ? `<meta property="og:title" content="${esc(meta.ogTitle)}">` : '',
|
|
172
|
+
meta.ogImage ? `<meta property="og:image" content="${esc(meta.ogImage)}">` : '',
|
|
173
|
+
].filter(Boolean).join('\n ')
|
|
174
|
+
|
|
175
|
+
const stylePreloads = (meta.styles || [])
|
|
176
|
+
.map(href => `<link rel="preload" as="style" href="${esc(href)}">`)
|
|
177
|
+
.join('\n ')
|
|
178
|
+
|
|
179
|
+
const runtimePreload = runtimeBundle && isBundle(spec.hydrate)
|
|
180
|
+
? `<link rel="modulepreload" as="script" href="${esc(runtimeBundle)}">`
|
|
181
|
+
: ''
|
|
182
|
+
|
|
183
|
+
const styleLinks = (meta.styles || [])
|
|
184
|
+
.map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i === 0 ? ' fetchpriority="high"' : ''}>`)
|
|
185
|
+
.join('\n ')
|
|
186
|
+
|
|
187
|
+
// Deferred styles — injected via a nonce'd script so CSP is respected.
|
|
188
|
+
// Dynamically-inserted <link rel="stylesheet"> elements are non-render-blocking.
|
|
189
|
+
const deferredStyleLinks = (meta.deferredStyles || []).length > 0
|
|
190
|
+
? `<script nonce="${nonce}">(function(){${
|
|
191
|
+
(meta.deferredStyles || []).map(href =>
|
|
192
|
+
`var l=document.createElement('link');l.rel='stylesheet';l.href='${esc(href)}';document.head.appendChild(l);`
|
|
193
|
+
).join('')
|
|
194
|
+
}})();</script>`
|
|
195
|
+
: ''
|
|
196
|
+
|
|
197
|
+
const scriptTags = (meta.scripts || [])
|
|
198
|
+
.map(src => `<script src="${esc(src)}" defer></script>`)
|
|
199
|
+
.join('\n ')
|
|
200
|
+
|
|
201
|
+
const schemaScript = meta.schema
|
|
202
|
+
? `<script type="application/ld+json">${JSON.stringify(meta.schema)}</script>`
|
|
203
|
+
: ''
|
|
204
|
+
|
|
205
|
+
// Serialise server state into the page so the client runtime can read it
|
|
206
|
+
// without making a second request
|
|
207
|
+
const serverStateScript = Object.keys(serverState).length > 0
|
|
208
|
+
? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
|
|
209
|
+
: ''
|
|
210
|
+
|
|
211
|
+
// Serialise store state so the client store singleton can be initialised.
|
|
212
|
+
// Also exposes window.__updatePulseStore__ so navigate.js can refresh the
|
|
213
|
+
// singleton with fresh server data on client-side navigations.
|
|
214
|
+
const storeStateScript = storeState && Object.keys(storeState).length > 0
|
|
215
|
+
? `<script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
|
|
216
|
+
: ''
|
|
217
|
+
|
|
218
|
+
// Hydration bootstrap — makes the server-rendered HTML interactive.
|
|
219
|
+
// When hydrate points to a self-executing bundle (/dist/…) a single <script>
|
|
220
|
+
// tag is enough; the bundle imports spec + runtime and calls mount() itself.
|
|
221
|
+
// In dev mode (source file path) we emit the explicit inline import block.
|
|
222
|
+
const storeImport = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
|
|
223
|
+
? `\n import store from '${esc(storeDef.hydrate)}'`
|
|
224
|
+
: ''
|
|
225
|
+
const storeArg = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
|
|
226
|
+
? ', { ssr: true, store }'
|
|
227
|
+
: ', { ssr: true }'
|
|
228
|
+
|
|
229
|
+
const hydrateScript = spec.hydrate
|
|
230
|
+
? isBundle(spec.hydrate)
|
|
231
|
+
? `<script type="module" src="${esc(spec.hydrate)}"></script>`
|
|
232
|
+
: `<script type="module" nonce="${nonce}">
|
|
233
|
+
import spec from '${esc(spec.hydrate)}'
|
|
234
|
+
import { mount } from '/@pulse/runtime/index.js'
|
|
235
|
+
import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
|
|
236
|
+
const root = document.getElementById('pulse-root')
|
|
237
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
|
|
238
|
+
initNavigation(root, mount)
|
|
239
|
+
</script>`
|
|
240
|
+
: ''
|
|
241
|
+
|
|
242
|
+
// Server-Timing header value (caller is responsible for setting the header)
|
|
243
|
+
const serverTimingValue = timing.total !== undefined
|
|
244
|
+
? `data;dur=${timing.data}, render;dur=${timing.render}, total;dur=${timing.total}`
|
|
245
|
+
: null
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
html: `<!DOCTYPE html>
|
|
249
|
+
<html lang="en">
|
|
250
|
+
<head>
|
|
251
|
+
<meta charset="UTF-8">
|
|
252
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
253
|
+
<link rel="icon" href="data:,">
|
|
254
|
+
<title>${esc(title)}</title>
|
|
255
|
+
${stylePreloads}
|
|
256
|
+
${runtimePreload}
|
|
257
|
+
${extraHead}
|
|
258
|
+
${metaTags}
|
|
259
|
+
${styleLinks}
|
|
260
|
+
${deferredStyleLinks}
|
|
261
|
+
${schemaScript}
|
|
262
|
+
</head>
|
|
263
|
+
<body${bodyAttr}>
|
|
264
|
+
<a href="#main-content" class="pulse-skip-link">Skip to main content</a>
|
|
265
|
+
<div id="pulse-root">
|
|
266
|
+
${content}
|
|
267
|
+
</div>
|
|
268
|
+
${storeStateScript}
|
|
269
|
+
${serverStateScript}
|
|
270
|
+
${scriptTags}
|
|
271
|
+
${hydrateScript}
|
|
272
|
+
${extraBody}
|
|
273
|
+
</body>
|
|
274
|
+
</html>`,
|
|
275
|
+
serverTimingValue
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Internal — store state merging
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Merge declared store keys into a server state object.
|
|
285
|
+
* Only keys listed in spec.store are included — nothing leaks from the store
|
|
286
|
+
* to pages that don't declare a dependency on it.
|
|
287
|
+
* Page-level server state always wins over store values with the same key.
|
|
288
|
+
*
|
|
289
|
+
* @param {Object} spec
|
|
290
|
+
* @param {Object} serverState
|
|
291
|
+
* @param {Object} [storeState]
|
|
292
|
+
* @returns {Object}
|
|
293
|
+
*/
|
|
294
|
+
function mergeStoreKeys(spec, serverState, storeState) {
|
|
295
|
+
if (!spec.store?.length || !storeState) return serverState
|
|
296
|
+
const slice = {}
|
|
297
|
+
for (const key of spec.store) {
|
|
298
|
+
if (storeState[key] !== undefined) slice[key] = storeState[key]
|
|
299
|
+
}
|
|
300
|
+
return { ...slice, ...serverState }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Internal — server state resolution
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolve all server data fetchers in parallel.
|
|
309
|
+
*
|
|
310
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
311
|
+
* @param {Object} ctx
|
|
312
|
+
* @returns {Promise<Object>}
|
|
313
|
+
*/
|
|
314
|
+
export async function resolveServerState(spec, ctx) {
|
|
315
|
+
if (!spec.server) return {}
|
|
316
|
+
|
|
317
|
+
const timeout = ctx.fetcherTimeout ?? null
|
|
318
|
+
|
|
319
|
+
const entries = await Promise.all(
|
|
320
|
+
Object.entries(spec.server).map(async ([key, fn]) => {
|
|
321
|
+
const value = await withTimeout(fn(ctx), timeout, key)
|
|
322
|
+
return [key, value]
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return Object.fromEntries(entries)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve server data for a specific set of segments only.
|
|
331
|
+
* Used by the streaming renderer to avoid fetching deferred data
|
|
332
|
+
* before the shell is sent.
|
|
333
|
+
*
|
|
334
|
+
* Currently resolves all server state — segment-level data dependencies
|
|
335
|
+
* will be an opt-in annotation in a future iteration.
|
|
336
|
+
*
|
|
337
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
338
|
+
* @param {Object} ctx
|
|
339
|
+
* @param {string[]} _segments - Segment keys (reserved for future scoping)
|
|
340
|
+
* @returns {Promise<Object>}
|
|
341
|
+
*/
|
|
342
|
+
async function resolveServerStateForSegments(spec, ctx, _segments) {
|
|
343
|
+
return resolveServerState(spec, ctx)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Internal — view rendering
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Render all view segments to a single HTML string.
|
|
352
|
+
*
|
|
353
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
354
|
+
* @param {Object} clientState
|
|
355
|
+
* @param {Object} serverState
|
|
356
|
+
* @returns {string}
|
|
357
|
+
*/
|
|
358
|
+
function renderSegments(spec, clientState, serverState) {
|
|
359
|
+
try {
|
|
360
|
+
if (typeof spec.view === 'function') {
|
|
361
|
+
return spec.view(clientState, serverState)
|
|
362
|
+
}
|
|
363
|
+
return Object.entries(spec.view)
|
|
364
|
+
.map(([, fn]) => fn(clientState, serverState))
|
|
365
|
+
.join('')
|
|
366
|
+
} catch (err) {
|
|
367
|
+
// If the spec declares a custom error renderer, use it and continue.
|
|
368
|
+
// Otherwise rethrow — the server's existing error handler (dev/prod pages) takes over.
|
|
369
|
+
if (spec.onViewError) {
|
|
370
|
+
console.error('[Pulse SSR] View error (caught by onViewError):', err)
|
|
371
|
+
return spec.onViewError(err, clientState, serverState)
|
|
372
|
+
}
|
|
373
|
+
throw err
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Render a named subset of view segments.
|
|
379
|
+
*
|
|
380
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
381
|
+
* @param {string[]} keys
|
|
382
|
+
* @param {Object} clientState
|
|
383
|
+
* @param {Object} serverState
|
|
384
|
+
* @returns {string}
|
|
385
|
+
*/
|
|
386
|
+
function renderNamedSegments(spec, keys, clientState, serverState) {
|
|
387
|
+
try {
|
|
388
|
+
if (typeof spec.view === 'function') {
|
|
389
|
+
return spec.view(clientState, serverState)
|
|
390
|
+
}
|
|
391
|
+
return keys
|
|
392
|
+
.map(key => {
|
|
393
|
+
if (!spec.view[key]) {
|
|
394
|
+
console.warn(`[Pulse SSR] View segment "${key}" not found`)
|
|
395
|
+
return ''
|
|
396
|
+
}
|
|
397
|
+
return spec.view[key](clientState, serverState)
|
|
398
|
+
})
|
|
399
|
+
.join('')
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (spec.onViewError) {
|
|
402
|
+
console.error('[Pulse SSR] View error (caught by onViewError):', err)
|
|
403
|
+
return spec.onViewError(err, clientState, serverState)
|
|
404
|
+
}
|
|
405
|
+
throw err
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Utilities
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Race a promise against a timeout rejection.
|
|
415
|
+
* Returns the promise result if it resolves within `ms`.
|
|
416
|
+
* Rejects with a descriptive error if the timeout fires first.
|
|
417
|
+
* No-op when ms is falsy (null / 0 / undefined).
|
|
418
|
+
*
|
|
419
|
+
* @param {Promise<any>} promise
|
|
420
|
+
* @param {number|null} ms - Timeout in milliseconds
|
|
421
|
+
* @param {string} name - Fetcher key for error messages
|
|
422
|
+
* @returns {Promise<any>}
|
|
423
|
+
*/
|
|
424
|
+
export function withTimeout(promise, ms, name) {
|
|
425
|
+
if (!ms) return promise
|
|
426
|
+
return Promise.race([
|
|
427
|
+
promise,
|
|
428
|
+
new Promise((_, reject) =>
|
|
429
|
+
setTimeout(
|
|
430
|
+
() => reject(new Error(`Server fetcher "${name}" timed out after ${ms}ms`)),
|
|
431
|
+
ms
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
])
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const encoder = new TextEncoder()
|
|
438
|
+
|
|
439
|
+
function encode(str) {
|
|
440
|
+
return encoder.encode(str)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns true when the hydrate path points to a pre-built self-executing
|
|
445
|
+
* bundle (i.e. resolved via the manifest) rather than a raw source file.
|
|
446
|
+
* Bundles live under /dist/ and carry a content hash in their filename.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} hydratePath
|
|
449
|
+
* @returns {boolean}
|
|
450
|
+
*/
|
|
451
|
+
function isBundle(hydratePath) {
|
|
452
|
+
return hydratePath.startsWith('/dist/')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Escape HTML special characters for safe attribute insertion.
|
|
457
|
+
*/
|
|
458
|
+
function esc(str) {
|
|
459
|
+
return String(str)
|
|
460
|
+
.replace(/&/g, '&')
|
|
461
|
+
.replace(/</g, '<')
|
|
462
|
+
.replace(/>/g, '>')
|
|
463
|
+
.replace(/"/g, '"')
|
|
464
|
+
}
|