@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,1386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — HTTP Server
|
|
3
|
+
*
|
|
4
|
+
* Takes a map of specs, handles routing, streams responses.
|
|
5
|
+
* Pure Node.js http module — no Express, no dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { createServer } from './src/server/index.js'
|
|
9
|
+
* import { contactSpec } from './specs/contact.js'
|
|
10
|
+
*
|
|
11
|
+
* createServer([contactSpec], { port: 3000 })
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import http from 'http'
|
|
15
|
+
import fs from 'fs'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import zlib from 'zlib'
|
|
18
|
+
import crypto from 'crypto'
|
|
19
|
+
import { promisify } from 'util'
|
|
20
|
+
import { renderToString, renderToStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
|
|
21
|
+
import { validateSpec } from '../spec/schema.js'
|
|
22
|
+
import { validateStore, resolveStoreState } from '../store/index.js'
|
|
23
|
+
|
|
24
|
+
const gzipAsync = promisify(zlib.gzip)
|
|
25
|
+
const brotliAsync = promisify(zlib.brotliCompress)
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Security headers — applied to every response
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const SECURITY_HEADERS = {
|
|
32
|
+
'X-Content-Type-Options': 'nosniff',
|
|
33
|
+
'X-Frame-Options': 'DENY',
|
|
34
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
35
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
36
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
37
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return Strict-Transport-Security header when the request is over HTTPS.
|
|
42
|
+
* Detects HTTPS via the x-forwarded-proto header (CDN/proxy) or the socket.
|
|
43
|
+
*/
|
|
44
|
+
function httpsHeaders(req) {
|
|
45
|
+
const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.socket?.encrypted
|
|
46
|
+
return isHttps ? { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' } : {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Base CSP directives — applied to every page response.
|
|
51
|
+
* Keys are directive names; values are arrays of sources.
|
|
52
|
+
* Callers may extend individual directives by merging extra sources.
|
|
53
|
+
*/
|
|
54
|
+
const BASE_CSP = {
|
|
55
|
+
'default-src': ["'none'"],
|
|
56
|
+
'script-src': ["'self'"], // nonce appended at request time
|
|
57
|
+
'style-src': ["'self'"],
|
|
58
|
+
'style-src-attr': ["'unsafe-inline'"], // scoped: UI components use inline style= for CSS vars
|
|
59
|
+
'img-src': ["'self'", 'data:'],
|
|
60
|
+
'font-src': ["'self'"],
|
|
61
|
+
'connect-src': ["'self'"],
|
|
62
|
+
'frame-ancestors':["'none'"],
|
|
63
|
+
'base-uri': ["'self'"],
|
|
64
|
+
'form-action': ["'self'"],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serializeCsp(directives) {
|
|
68
|
+
return Object.entries(directives).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the Content-Security-Policy header for a page response.
|
|
73
|
+
* @param {string} nonce Per-request nonce for inline scripts.
|
|
74
|
+
* @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
|
|
75
|
+
*/
|
|
76
|
+
function buildCsp(nonce, ext = {}) {
|
|
77
|
+
const d = { ...BASE_CSP, 'script-src': ["'self'", `'nonce-${nonce}'`] }
|
|
78
|
+
for (const [k, sources] of Object.entries(ext)) {
|
|
79
|
+
d[k] = [...(d[k] || []), ...sources]
|
|
80
|
+
}
|
|
81
|
+
return serializeCsp(d)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the nonce-free CSP for cached responses.
|
|
86
|
+
* Safe because cached pages have no inline scripts.
|
|
87
|
+
* @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
|
|
88
|
+
*/
|
|
89
|
+
function buildCachedCsp(ext = {}) {
|
|
90
|
+
const d = { ...BASE_CSP }
|
|
91
|
+
for (const [k, sources] of Object.entries(ext)) {
|
|
92
|
+
d[k] = [...(d[k] || []), ...sources]
|
|
93
|
+
}
|
|
94
|
+
return serializeCsp(d)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Compression helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Pick the best supported encoding from the Accept-Encoding header. */
|
|
102
|
+
function negotiateEncoding(req) {
|
|
103
|
+
const accept = req.headers['accept-encoding'] || ''
|
|
104
|
+
if (accept.includes('br')) return 'br'
|
|
105
|
+
if (accept.includes('gzip')) return 'gzip'
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Compress a Buffer using the given encoding. Returns the original if none. */
|
|
110
|
+
async function compressBuffer(buf, encoding) {
|
|
111
|
+
if (encoding === 'br') return brotliAsync(buf)
|
|
112
|
+
if (encoding === 'gzip') return gzipAsync(buf)
|
|
113
|
+
return buf
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Create a transform stream for the given encoding, or null. */
|
|
117
|
+
function createCompressor(encoding) {
|
|
118
|
+
if (encoding === 'br') return zlib.createBrotliCompress()
|
|
119
|
+
if (encoding === 'gzip') return zlib.createGzip()
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Multipart form data parser
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse a multipart/form-data body Buffer into a plain object.
|
|
129
|
+
*
|
|
130
|
+
* Regular text fields → string values.
|
|
131
|
+
* File fields → { filename, type, data: Buffer, size }.
|
|
132
|
+
* Repeated field names → array of values.
|
|
133
|
+
*
|
|
134
|
+
* @param {Buffer} buf - Raw request body
|
|
135
|
+
* @param {string} boundary - Boundary string from the Content-Type header
|
|
136
|
+
* @returns {Record<string, unknown>}
|
|
137
|
+
*/
|
|
138
|
+
function parseMultipart(buf, boundary) {
|
|
139
|
+
const result = {}
|
|
140
|
+
const delim = Buffer.from(`\r\n--${boundary}`)
|
|
141
|
+
const first = Buffer.from(`--${boundary}`)
|
|
142
|
+
const CRLFX2 = Buffer.from('\r\n\r\n')
|
|
143
|
+
|
|
144
|
+
let pos = buf.indexOf(first)
|
|
145
|
+
if (pos === -1) return result
|
|
146
|
+
pos += first.length
|
|
147
|
+
|
|
148
|
+
while (pos < buf.length) {
|
|
149
|
+
// After each boundary: \r\n = more parts, -- = final boundary
|
|
150
|
+
if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break
|
|
151
|
+
if (buf[pos] === 0x0d && buf[pos + 1] === 0x0a) pos += 2
|
|
152
|
+
else break
|
|
153
|
+
|
|
154
|
+
// Find next boundary — marks the end of this part's body
|
|
155
|
+
const next = buf.indexOf(delim, pos)
|
|
156
|
+
if (next === -1) break
|
|
157
|
+
|
|
158
|
+
const part = buf.subarray(pos, next)
|
|
159
|
+
const headerEnd = part.indexOf(CRLFX2)
|
|
160
|
+
if (headerEnd === -1) { pos = next + delim.length; continue }
|
|
161
|
+
|
|
162
|
+
// Parse part headers
|
|
163
|
+
const headerStr = part.subarray(0, headerEnd).toString('utf8')
|
|
164
|
+
const body = part.subarray(headerEnd + 4)
|
|
165
|
+
const headers = {}
|
|
166
|
+
for (const line of headerStr.split('\r\n')) {
|
|
167
|
+
const colon = line.indexOf(':')
|
|
168
|
+
if (colon === -1) continue
|
|
169
|
+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Extract name / filename from Content-Disposition
|
|
173
|
+
const cd = headers['content-disposition'] || ''
|
|
174
|
+
const nameMatch = cd.match(/\bname="([^"]*)"/)
|
|
175
|
+
const fileMatch = cd.match(/\bfilename="([^"]*)"/)
|
|
176
|
+
if (!nameMatch) { pos = next + delim.length; continue }
|
|
177
|
+
|
|
178
|
+
const name = nameMatch[1]
|
|
179
|
+
const value = fileMatch
|
|
180
|
+
? { filename: fileMatch[1], type: headers['content-type'] || 'application/octet-stream', data: body, size: body.length }
|
|
181
|
+
: body.toString('utf8')
|
|
182
|
+
|
|
183
|
+
// Support repeated names (e.g. checkboxes, multi-file inputs)
|
|
184
|
+
const existing = result[name]
|
|
185
|
+
if (existing !== undefined) {
|
|
186
|
+
result[name] = Array.isArray(existing) ? [...existing, value] : [existing, value]
|
|
187
|
+
} else {
|
|
188
|
+
result[name] = value
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pos = next + delim.length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// In-process TTL cache — server data memoization
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
class TtlCache {
|
|
202
|
+
constructor() { this._store = new Map() }
|
|
203
|
+
|
|
204
|
+
get(key) {
|
|
205
|
+
const entry = this._store.get(key)
|
|
206
|
+
if (!entry) return undefined
|
|
207
|
+
if (Date.now() > entry.expiresAt) { this._store.delete(key); return undefined }
|
|
208
|
+
return entry.value
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set(key, value, ttlSeconds) {
|
|
212
|
+
this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const serverDataCache = new TtlCache()
|
|
217
|
+
const pageHtmlCache = new TtlCache()
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Cache-Control builder
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Normalise a cache config value.
|
|
225
|
+
* Accepts a number (maxAge seconds), a boolean (true = public, maxAge=3600),
|
|
226
|
+
* or an object { public, maxAge, staleWhileRevalidate }.
|
|
227
|
+
*/
|
|
228
|
+
function resolveCache(value) {
|
|
229
|
+
if (!value) return null
|
|
230
|
+
if (value === true) return { public: true, maxAge: 3600, staleWhileRevalidate: 86400 }
|
|
231
|
+
if (typeof value === 'number') return { public: true, maxAge: value }
|
|
232
|
+
return value
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build the Cache-Control value for an HTML response.
|
|
237
|
+
* In dev mode always returns no-store.
|
|
238
|
+
* spec.cache takes precedence over the server-level defaultCache.
|
|
239
|
+
*
|
|
240
|
+
* spec.cache / defaultCache:
|
|
241
|
+
* true → public, max-age=3600, stale-while-revalidate=86400
|
|
242
|
+
* number → public, max-age={n}
|
|
243
|
+
* { public, maxAge, staleWhileRevalidate }
|
|
244
|
+
*/
|
|
245
|
+
function buildCacheControl(spec, dev, defaultCache = null) {
|
|
246
|
+
if (dev) return 'no-store'
|
|
247
|
+
|
|
248
|
+
const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
|
|
249
|
+
if (!cfg) return 'no-store'
|
|
250
|
+
|
|
251
|
+
const { public: isPublic = false, maxAge = 0, staleWhileRevalidate } = cfg
|
|
252
|
+
const parts = [isPublic ? 'public' : 'private']
|
|
253
|
+
if (maxAge > 0) parts.push(`max-age=${maxAge}`)
|
|
254
|
+
if (staleWhileRevalidate) parts.push(`stale-while-revalidate=${staleWhileRevalidate}`)
|
|
255
|
+
return parts.join(', ')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Return the TTL in seconds for the in-process page cache,
|
|
260
|
+
* or 0 if this response should not be cached in-process.
|
|
261
|
+
*/
|
|
262
|
+
function pageCacheTtl(spec, dev, defaultCache) {
|
|
263
|
+
if (dev) return 0
|
|
264
|
+
const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
|
|
265
|
+
return cfg?.maxAge || 0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Dev import map — lets browser resolve @invisibleloop/pulse/* bare specifiers
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* In dev mode, spec files use bare package specifiers (e.g. @invisibleloop/pulse/image)
|
|
274
|
+
* so Node.js can resolve them during SSR. The browser can't resolve bare specifiers
|
|
275
|
+
* without an import map, so we inject one in dev HTML responses.
|
|
276
|
+
*
|
|
277
|
+
* Must appear in <head> before any <script type="module">.
|
|
278
|
+
*/
|
|
279
|
+
/** Dev import map — lets browser resolve bare specifiers. Requires nonce for CSP. */
|
|
280
|
+
function devImportMap(nonce) {
|
|
281
|
+
return `<script type="importmap" nonce="${nonce}">
|
|
282
|
+
{
|
|
283
|
+
"imports": {
|
|
284
|
+
"@invisibleloop/pulse/image": "/@pulse/runtime/image.js",
|
|
285
|
+
"@invisibleloop/pulse/ui": "/@pulse/ui/index.js"
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
</script>`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Cached render — wraps renderToString with optional server-data TTL cache
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Render a spec to a string, optionally caching server data for `spec.serverTtl` seconds.
|
|
297
|
+
*
|
|
298
|
+
* In dev mode (or when serverTtl is not set) this is a pass-through to `renderToString`.
|
|
299
|
+
* In prod with serverTtl set, server data fetcher results are memoized in-process and
|
|
300
|
+
* the page is re-rendered with cached data on subsequent requests within the TTL window.
|
|
301
|
+
*
|
|
302
|
+
* @param {Object} spec
|
|
303
|
+
* @param {Object} ctx
|
|
304
|
+
* @param {boolean} dev
|
|
305
|
+
* @returns {Promise<{ html: string, serverState: Object, timing: Object }>}
|
|
306
|
+
*/
|
|
307
|
+
async function cachedRenderToString(spec, ctx, dev) {
|
|
308
|
+
if (dev || !spec.serverTtl) {
|
|
309
|
+
return renderToString(spec, ctx)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Build a cache key scoped to this route + request parameters
|
|
313
|
+
const key = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
314
|
+
|
|
315
|
+
const cached = serverDataCache.get(key)
|
|
316
|
+
if (cached) return cached
|
|
317
|
+
|
|
318
|
+
const result = await renderToString(spec, ctx)
|
|
319
|
+
serverDataCache.set(key, result, spec.serverTtl)
|
|
320
|
+
return result
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const MIME_TYPES = {
|
|
324
|
+
'.html': 'text/html; charset=utf-8',
|
|
325
|
+
'.css': 'text/css; charset=utf-8',
|
|
326
|
+
'.js': 'application/javascript',
|
|
327
|
+
'.json': 'application/json',
|
|
328
|
+
'.svg': 'image/svg+xml',
|
|
329
|
+
'.png': 'image/png',
|
|
330
|
+
'.jpg': 'image/jpeg',
|
|
331
|
+
'.jpeg': 'image/jpeg',
|
|
332
|
+
'.ico': 'image/x-icon',
|
|
333
|
+
'.woff2':'font/woff2',
|
|
334
|
+
'.woff': 'font/woff',
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// createServer
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @typedef {Object} ServerOptions
|
|
343
|
+
* @property {number} [port=3000]
|
|
344
|
+
* @property {boolean} [stream=true] - Use streaming SSR by default
|
|
345
|
+
* @property {'remove'|'add'|'allow'} [trailingSlash='remove']
|
|
346
|
+
* - 'remove' — redirect /about/ → /about (301), canonical = no-slash (default)
|
|
347
|
+
* - 'add' — redirect /about → /about/ (301), canonical = slash
|
|
348
|
+
* - 'allow' — serve both, no redirect, canonical = no-slash
|
|
349
|
+
* @property {function} [onError] - Error handler (err, req, res) => void
|
|
350
|
+
* @property {function} [onRequest] - Request hook (req, res) => void | false
|
|
351
|
+
* Return false to short-circuit routing
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Create and start a Pulse HTTP server.
|
|
356
|
+
*
|
|
357
|
+
* @param {import('../spec/schema.js').PulseSpec[]} specs
|
|
358
|
+
* @param {ServerOptions} [options]
|
|
359
|
+
* @returns {http.Server}
|
|
360
|
+
*/
|
|
361
|
+
export function createServer(specs, options = {}) {
|
|
362
|
+
const {
|
|
363
|
+
port = 3000,
|
|
364
|
+
stream = true,
|
|
365
|
+
staticDir = null,
|
|
366
|
+
manifest = null, // path to manifest.json or plain object
|
|
367
|
+
trailingSlash = 'remove', // 'remove' | 'add' | 'allow'
|
|
368
|
+
extraBody = '', // extra HTML injected before </body> on every page
|
|
369
|
+
dev = false, // dev mode — show detailed error pages
|
|
370
|
+
store = null, // global store definition (pulse.store.js default export)
|
|
371
|
+
resolveBrand = null, // async (host) => brandConfig — keyed by domain
|
|
372
|
+
defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
|
|
373
|
+
fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
|
|
374
|
+
maxBody = 1024 * 1024, // max request body size in bytes (default 1 MB)
|
|
375
|
+
shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
|
|
376
|
+
healthCheck = '/healthz', // path for health check endpoint, or false to disable
|
|
377
|
+
csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
|
|
378
|
+
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
379
|
+
onRequest
|
|
380
|
+
} = options
|
|
381
|
+
|
|
382
|
+
const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
|
|
383
|
+
|
|
384
|
+
// Validate store at startup — fail fast before the server accepts connections
|
|
385
|
+
if (store) {
|
|
386
|
+
const { valid, errors } = validateStore(store)
|
|
387
|
+
if (!valid) {
|
|
388
|
+
throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Per-host brand cache — avoids hitting the data store on every request
|
|
393
|
+
const brandCache = new TtlCache()
|
|
394
|
+
|
|
395
|
+
// Load manifest — maps source hydrate paths to production bundle paths
|
|
396
|
+
const hydrateMap = loadManifest(manifest, staticDir)
|
|
397
|
+
const runtimeBundle = hydrateMap['_runtime'] || ''
|
|
398
|
+
|
|
399
|
+
// Validate all specs upfront — fail at startup, not at request time
|
|
400
|
+
for (const spec of specs) {
|
|
401
|
+
const { valid, errors } = validateSpec(spec)
|
|
402
|
+
const routeErrors = []
|
|
403
|
+
if (!spec.route || typeof spec.route !== 'string') {
|
|
404
|
+
routeErrors.push('spec.route is required and must be a string (e.g. "/contact")')
|
|
405
|
+
} else if (!spec.route.startsWith('/')) {
|
|
406
|
+
routeErrors.push('spec.route must start with "/" (e.g. "/contact")')
|
|
407
|
+
}
|
|
408
|
+
const allErrors = [...errors, ...routeErrors]
|
|
409
|
+
if (!valid || routeErrors.length > 0) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Invalid spec for route "${spec?.route}":\n` +
|
|
412
|
+
allErrors.map(e => ` — ${e}`).join('\n')
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Build route table — let so it can be swapped on hot reload
|
|
418
|
+
let router = buildRouter(specs)
|
|
419
|
+
|
|
420
|
+
const server = http.createServer(async (req, res) => {
|
|
421
|
+
try {
|
|
422
|
+
// Parse URL — needed before health check and routing
|
|
423
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
424
|
+
const pathname = url.pathname
|
|
425
|
+
|
|
426
|
+
// Health check — before onRequest and routing so load balancers always get a response
|
|
427
|
+
if (healthPath && pathname === healthPath && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
428
|
+
const body = JSON.stringify({ status: 'ok', uptime: process.uptime() })
|
|
429
|
+
res.writeHead(200, {
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
'Cache-Control': 'no-store',
|
|
432
|
+
...SECURITY_HEADERS,
|
|
433
|
+
})
|
|
434
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Request hook — allows middleware-like behaviour
|
|
439
|
+
if (onRequest) {
|
|
440
|
+
const result = onRequest(req, res)
|
|
441
|
+
if (result === false) return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Static file serving — GET/HEAD only
|
|
445
|
+
if (staticDir && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
446
|
+
const served = serveStatic(req, res, staticDir, dev)
|
|
447
|
+
if (served) return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Trailing slash normalisation — GET/HEAD only (redirects don't apply to POST etc.)
|
|
451
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && pathname !== '/') {
|
|
452
|
+
if (trailingSlash === 'remove' && pathname.endsWith('/')) {
|
|
453
|
+
const target = pathname.slice(0, -1) + (url.search || '')
|
|
454
|
+
res.writeHead(301, { Location: target, ...SECURITY_HEADERS, ...httpsHeaders(req) })
|
|
455
|
+
res.end()
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (trailingSlash === 'add' && !pathname.endsWith('/')) {
|
|
459
|
+
const target = pathname + '/' + (url.search || '')
|
|
460
|
+
res.writeHead(301, { Location: target, ...SECURITY_HEADERS, ...httpsHeaders(req) })
|
|
461
|
+
res.end()
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
// 'allow' — no redirect
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Match route
|
|
468
|
+
const match = matchRoute(router, pathname)
|
|
469
|
+
|
|
470
|
+
if (!match) {
|
|
471
|
+
res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
|
|
472
|
+
res.end(notFoundHtml(pathname))
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Method gating — raw response specs accept any HTTP method.
|
|
477
|
+
// Page specs default to GET/HEAD only; opt in to other methods via spec.methods.
|
|
478
|
+
if (!match.spec.contentType) {
|
|
479
|
+
const allowed = match.spec.methods
|
|
480
|
+
? match.spec.methods.map(m => m.toUpperCase())
|
|
481
|
+
: ['GET', 'HEAD']
|
|
482
|
+
if (!allowed.includes(req.method)) {
|
|
483
|
+
res.writeHead(405, {
|
|
484
|
+
'Content-Type': 'text/plain',
|
|
485
|
+
'Allow': allowed.join(', '),
|
|
486
|
+
...SECURITY_HEADERS, ...httpsHeaders(req)
|
|
487
|
+
})
|
|
488
|
+
res.end('Method Not Allowed')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Per-request CSP nonce — fresh cryptographic random value for every response
|
|
494
|
+
const nonce = crypto.randomBytes(16).toString('base64url')
|
|
495
|
+
|
|
496
|
+
// Build request context passed to guard, server data fetchers, and render
|
|
497
|
+
const ctx = buildContext(req, url, match.params, nonce, maxBody)
|
|
498
|
+
|
|
499
|
+
// Brand resolution — attach ctx.brand before guard or server data runs
|
|
500
|
+
if (resolveBrand) {
|
|
501
|
+
const host = req.headers.host || ''
|
|
502
|
+
const cached = brandCache.get(host)
|
|
503
|
+
if (cached !== undefined) {
|
|
504
|
+
ctx.brand = cached
|
|
505
|
+
} else {
|
|
506
|
+
ctx.brand = await resolveBrand(host)
|
|
507
|
+
brandCache.set(host, ctx.brand, 60)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Global store — resolve once per request, attach to ctx before guard/server fetchers.
|
|
512
|
+
// Use global fetcherTimeout for store fetchers; spec-level override applied below.
|
|
513
|
+
ctx.fetcherTimeout = fetcherTimeout ?? null
|
|
514
|
+
if (store) {
|
|
515
|
+
ctx.store = await resolveStoreState(store, ctx)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const spec = resolveSpec(match.spec, hydrateMap)
|
|
519
|
+
|
|
520
|
+
// Per-spec timeout overrides the global default
|
|
521
|
+
if (spec.serverTimeout != null) ctx.fetcherTimeout = spec.serverTimeout
|
|
522
|
+
|
|
523
|
+
// Guard — per-route authorization check runs before any data fetching.
|
|
524
|
+
// Can return:
|
|
525
|
+
// { redirect: '/path' } — 302 redirect
|
|
526
|
+
// { status, body, headers?, json? } — custom response (e.g. 422 + error JSON)
|
|
527
|
+
if (typeof spec.guard === 'function') {
|
|
528
|
+
const result = await spec.guard(ctx)
|
|
529
|
+
if (result?.redirect) {
|
|
530
|
+
res.writeHead(302, mergeCtxHeaders(ctx, { Location: result.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
|
|
531
|
+
res.end()
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
if (result?.status) {
|
|
535
|
+
const body = result.json != null ? JSON.stringify(result.json) : (result.body ?? '')
|
|
536
|
+
const ct = result.json != null ? 'application/json' : (result.headers?.['Content-Type'] ?? 'text/plain')
|
|
537
|
+
const headers = { 'Content-Type': ct, ...SECURITY_HEADERS, ...httpsHeaders(req), ...(result.headers || {}) }
|
|
538
|
+
res.writeHead(result.status, mergeCtxHeaders(ctx, headers))
|
|
539
|
+
res.end(body)
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Build canonical URL — prefer spec.meta.canonical, otherwise derive from request.
|
|
545
|
+
// Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
|
|
546
|
+
const proto = req.headers['x-forwarded-proto'] || 'http'
|
|
547
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
|
|
548
|
+
const canonicalPath = trailingSlash === 'add' && pathname !== '/'
|
|
549
|
+
? (pathname.endsWith('/') ? pathname : pathname + '/')
|
|
550
|
+
: (pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
|
|
551
|
+
const canonicalUrl = spec.meta?.canonical || `${proto}://${host}${canonicalPath}`
|
|
552
|
+
|
|
553
|
+
// Raw content spec (RSS, sitemap, JSON API, webhooks) — bypass HTML pipeline
|
|
554
|
+
if (spec.contentType) {
|
|
555
|
+
await handleRawResponse(spec, ctx, req, res, dev)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Client-side navigation request — return JSON fragment, not a full document
|
|
560
|
+
if (req.headers['x-pulse-navigate'] === 'true') {
|
|
561
|
+
await handleNavResponse(spec, ctx, res, dev)
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (stream) {
|
|
566
|
+
await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
|
|
567
|
+
} else {
|
|
568
|
+
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (err?.status === 413) {
|
|
573
|
+
if (!res.headersSent) {
|
|
574
|
+
res.writeHead(413, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS })
|
|
575
|
+
res.end('Request body too large')
|
|
576
|
+
}
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
onError(err, req, res)
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Connection tracking for graceful shutdown
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
// Map<socket, isActive> — true while a request is being handled
|
|
588
|
+
const connections = new Map()
|
|
589
|
+
|
|
590
|
+
server.on('connection', socket => {
|
|
591
|
+
connections.set(socket, false)
|
|
592
|
+
socket.on('close', () => connections.delete(socket))
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// Mark socket active at request start, idle at response finish.
|
|
596
|
+
// If we're already draining, destroy the socket as soon as it goes idle.
|
|
597
|
+
server.on('request', (req, res) => {
|
|
598
|
+
const socket = req.socket
|
|
599
|
+
connections.set(socket, true)
|
|
600
|
+
res.on('finish', () => {
|
|
601
|
+
connections.set(socket, false)
|
|
602
|
+
if (draining) socket.destroy()
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
let draining = false
|
|
607
|
+
|
|
608
|
+
function shutdown() {
|
|
609
|
+
if (draining) return
|
|
610
|
+
draining = true
|
|
611
|
+
|
|
612
|
+
console.log('⚡ Pulse shutting down gracefully…')
|
|
613
|
+
|
|
614
|
+
// Stop accepting new connections; exit once all connections are closed
|
|
615
|
+
server.close(() => process.exit(0))
|
|
616
|
+
|
|
617
|
+
// Destroy sockets that are idle (keep-alive but between requests)
|
|
618
|
+
for (const [socket, active] of connections) {
|
|
619
|
+
if (!active) socket.destroy()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Force-exit after shutdownTimeout so a stuck request can't block a deploy
|
|
623
|
+
setTimeout(() => {
|
|
624
|
+
console.error(`⚡ Pulse force-exiting after ${shutdownTimeout}ms shutdown timeout`)
|
|
625
|
+
process.exit(1)
|
|
626
|
+
}, shutdownTimeout).unref()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
process.on('SIGTERM', shutdown)
|
|
630
|
+
process.on('SIGINT', shutdown)
|
|
631
|
+
|
|
632
|
+
server.listen(port, () => {
|
|
633
|
+
console.log(`⚡ Pulse running at http://localhost:${port}`)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
server,
|
|
638
|
+
shutdown,
|
|
639
|
+
updateSpecs(newSpecs) {
|
|
640
|
+
router = buildRouter(newSpecs)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
// Response handlers
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Client-side navigation — render the body fragment and return it as JSON.
|
|
651
|
+
* The browser swaps #pulse-root with the html, updates the title, re-mounts.
|
|
652
|
+
*/
|
|
653
|
+
async function handleNavResponse(spec, ctx, res, dev = false) {
|
|
654
|
+
const { html, serverState } = await cachedRenderToString(spec, ctx, dev)
|
|
655
|
+
const meta = resolveMeta(spec.meta, ctx)
|
|
656
|
+
|
|
657
|
+
const payload = JSON.stringify({
|
|
658
|
+
html,
|
|
659
|
+
title: meta.title || 'Pulse',
|
|
660
|
+
styles: meta.styles || [],
|
|
661
|
+
scripts: meta.scripts || [],
|
|
662
|
+
hydrate: spec.hydrate || null,
|
|
663
|
+
serverState: Object.keys(serverState).length > 0 ? serverState : undefined,
|
|
664
|
+
storeState: ctx.store && Object.keys(ctx.store).length > 0 ? ctx.store : undefined,
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
res.writeHead(200, {
|
|
668
|
+
'Content-Type': 'application/json',
|
|
669
|
+
'Cache-Control': buildCacheControl(spec, dev),
|
|
670
|
+
...SECURITY_HEADERS,
|
|
671
|
+
})
|
|
672
|
+
res.end(payload)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Render to a complete string then send — simpler, easier to cache.
|
|
677
|
+
* Checks the in-process page cache before rendering; stores result after.
|
|
678
|
+
*/
|
|
679
|
+
async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
|
|
680
|
+
const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
681
|
+
// Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
|
|
682
|
+
const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
|
|
683
|
+
|
|
684
|
+
let html, serverTimingValue, fromCache = false
|
|
685
|
+
|
|
686
|
+
const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
|
|
687
|
+
if (cached) {
|
|
688
|
+
html = cached.html
|
|
689
|
+
fromCache = true
|
|
690
|
+
} else {
|
|
691
|
+
const { html: content, serverState, timing } = await cachedRenderToString(spec, ctx, dev)
|
|
692
|
+
const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
|
|
693
|
+
const resolvedSpec = { ...spec, meta: resolveMeta(spec.meta, ctx) }
|
|
694
|
+
const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
|
|
695
|
+
const wrapped = wrapDocument({ content, spec: resolvedSpec, serverState, storeState: ctx.store || null, storeDef: store || null, timing, extraBody: resolvedExtraBody, extraHead: (dev ? devImportMap(nonce) + '\n ' : '') + canonicalTag, nonce, runtimeBundle })
|
|
696
|
+
html = wrapped.html
|
|
697
|
+
serverTimingValue = wrapped.serverTimingValue
|
|
698
|
+
if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const encoding = negotiateEncoding(req)
|
|
702
|
+
const buf = await compressBuffer(Buffer.from(html, 'utf8'), encoding)
|
|
703
|
+
|
|
704
|
+
const headers = mergeCtxHeaders(ctx, {
|
|
705
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
706
|
+
'Cache-Control': buildCacheControl(spec, dev, defaultCache),
|
|
707
|
+
'Content-Security-Policy': fromCache ? buildCachedCsp(csp) : buildCsp(nonce, csp),
|
|
708
|
+
'Vary': 'Accept-Encoding',
|
|
709
|
+
...SECURITY_HEADERS,
|
|
710
|
+
...httpsHeaders(req),
|
|
711
|
+
})
|
|
712
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
713
|
+
if (serverTimingValue) headers['Server-Timing'] = serverTimingValue
|
|
714
|
+
|
|
715
|
+
res.writeHead(200, headers)
|
|
716
|
+
res.end(buf)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Stream the response — shell first, deferred segments follow.
|
|
721
|
+
* On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
|
|
722
|
+
*/
|
|
723
|
+
async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
|
|
724
|
+
// Serve from in-process page cache when available — skip streaming overhead.
|
|
725
|
+
// Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
|
|
726
|
+
const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
727
|
+
const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
|
|
728
|
+
const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
|
|
729
|
+
|
|
730
|
+
if (cached) {
|
|
731
|
+
const encoding = negotiateEncoding(req)
|
|
732
|
+
const buf = await compressBuffer(Buffer.from(cached.html, 'utf8'), encoding)
|
|
733
|
+
const headers = mergeCtxHeaders(ctx, {
|
|
734
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
735
|
+
'Cache-Control': buildCacheControl(spec, dev, defaultCache),
|
|
736
|
+
'Content-Security-Policy': buildCachedCsp(csp),
|
|
737
|
+
'Vary': 'Accept-Encoding',
|
|
738
|
+
...SECURITY_HEADERS,
|
|
739
|
+
...httpsHeaders(req),
|
|
740
|
+
})
|
|
741
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
742
|
+
res.writeHead(200, headers)
|
|
743
|
+
res.end(buf)
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
const t0 = performance.now()
|
|
747
|
+
|
|
748
|
+
// Write the document opening immediately so the browser starts parsing
|
|
749
|
+
const meta = resolveMeta(spec.meta, ctx)
|
|
750
|
+
const title = meta.title || 'Pulse'
|
|
751
|
+
|
|
752
|
+
const stylePreloads = (meta.styles || [])
|
|
753
|
+
.map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
|
|
754
|
+
.join('\n')
|
|
755
|
+
|
|
756
|
+
const runtimePreload = runtimeBundle && spec.hydrate?.startsWith('/dist/')
|
|
757
|
+
? ` <link rel="modulepreload" as="script" href="${escHtml(runtimeBundle)}">`
|
|
758
|
+
: ''
|
|
759
|
+
|
|
760
|
+
const metaTags = [
|
|
761
|
+
canonicalUrl ? ` <link rel="canonical" href="${escHtml(canonicalUrl)}">` : '',
|
|
762
|
+
meta.description ? ` <meta name="description" content="${escHtml(meta.description)}">` : '',
|
|
763
|
+
meta.ogTitle ? ` <meta property="og:title" content="${escHtml(meta.ogTitle)}">` : '',
|
|
764
|
+
meta.ogImage ? ` <meta property="og:image" content="${escHtml(meta.ogImage)}">` : '',
|
|
765
|
+
...(meta.styles || []).map((href, i) => ` <link rel="stylesheet" href="${escHtml(href)}"${i === 0 ? ' fetchpriority="high"' : ''}>`),
|
|
766
|
+
(meta.deferredStyles || []).length > 0
|
|
767
|
+
? ` <script nonce="${nonce}">(function(){${
|
|
768
|
+
(meta.deferredStyles || []).map(href =>
|
|
769
|
+
`var l=document.createElement('link');l.rel='stylesheet';l.href='${escHtml(href)}';document.head.appendChild(l);`
|
|
770
|
+
).join('')
|
|
771
|
+
}})();</script>`
|
|
772
|
+
: '',
|
|
773
|
+
meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
|
|
774
|
+
].filter(Boolean).join('\n')
|
|
775
|
+
|
|
776
|
+
const bodyAttr = meta.theme ? ` data-theme="${escHtml(meta.theme)}"` : ''
|
|
777
|
+
|
|
778
|
+
const docOpen = `<!DOCTYPE html>
|
|
779
|
+
<html lang="en">
|
|
780
|
+
<head>
|
|
781
|
+
<meta charset="UTF-8">
|
|
782
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
783
|
+
<link rel="icon" href="data:,">
|
|
784
|
+
<title>${escHtml(title)}</title>
|
|
785
|
+
${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
|
|
786
|
+
</head>
|
|
787
|
+
<body${bodyAttr}>
|
|
788
|
+
<div id="pulse-root">`
|
|
789
|
+
|
|
790
|
+
const storeImport = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
|
|
791
|
+
? `\n import store from '${escHtml(store.hydrate)}'`
|
|
792
|
+
: ''
|
|
793
|
+
const storeArg = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
|
|
794
|
+
? ', { ssr: true, store }'
|
|
795
|
+
: ', { ssr: true }'
|
|
796
|
+
|
|
797
|
+
const hydrateScript = spec.hydrate
|
|
798
|
+
? spec.hydrate.startsWith('/dist/')
|
|
799
|
+
? `\n <script type="module" src="${escHtml(spec.hydrate)}"></script>`
|
|
800
|
+
: `\n <script type="module" nonce="${nonce}">
|
|
801
|
+
import spec from '${escHtml(spec.hydrate)}'
|
|
802
|
+
import { mount } from '/@pulse/runtime/index.js'
|
|
803
|
+
import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
|
|
804
|
+
const root = document.getElementById('pulse-root')
|
|
805
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
|
|
806
|
+
initNavigation(root, mount)
|
|
807
|
+
</script>`
|
|
808
|
+
: ''
|
|
809
|
+
|
|
810
|
+
const scriptTags = (meta.scripts || [])
|
|
811
|
+
.map(src => ` <script src="${escHtml(src)}" defer></script>`)
|
|
812
|
+
.join('\n')
|
|
813
|
+
|
|
814
|
+
const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
|
|
815
|
+
|
|
816
|
+
// Emit window.__PULSE_STORE__ so the client store singleton can be initialised.
|
|
817
|
+
// Also exposes __updatePulseStore__ for navigate.js to refresh store on navigation.
|
|
818
|
+
const storeScript = ctx.store && Object.keys(ctx.store).length > 0
|
|
819
|
+
? `\n <script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
|
|
820
|
+
: ''
|
|
821
|
+
|
|
822
|
+
const docClose = `
|
|
823
|
+
</div>
|
|
824
|
+
${scriptTags}${storeScript}${hydrateScript}
|
|
825
|
+
${resolvedExtraBody}
|
|
826
|
+
</body>
|
|
827
|
+
</html>`
|
|
828
|
+
|
|
829
|
+
const encoding = negotiateEncoding(req)
|
|
830
|
+
const compressor = createCompressor(encoding)
|
|
831
|
+
|
|
832
|
+
const headers = mergeCtxHeaders(ctx, {
|
|
833
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
834
|
+
'Transfer-Encoding': 'chunked',
|
|
835
|
+
'Cache-Control': buildCacheControl(spec, dev, defaultCache),
|
|
836
|
+
'Content-Security-Policy': buildCsp(nonce, csp),
|
|
837
|
+
'Vary': 'Accept-Encoding',
|
|
838
|
+
...SECURITY_HEADERS,
|
|
839
|
+
...httpsHeaders(req),
|
|
840
|
+
})
|
|
841
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
842
|
+
|
|
843
|
+
res.writeHead(200, headers)
|
|
844
|
+
|
|
845
|
+
// Route writes through the compressor (if any) → response
|
|
846
|
+
const out = compressor ?? res
|
|
847
|
+
if (compressor) compressor.pipe(res)
|
|
848
|
+
const write = (chunk) => out.write(chunk)
|
|
849
|
+
const end = (chunk) => out.end(chunk)
|
|
850
|
+
|
|
851
|
+
// Buffer chunks so we can store the full HTML in the page cache after sending
|
|
852
|
+
const chunks = ttl > 0 ? [docOpen] : null
|
|
853
|
+
|
|
854
|
+
write(docOpen)
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
// Pipe the spec's stream into the response
|
|
858
|
+
const stream = renderToStream(spec, ctx, nonce)
|
|
859
|
+
const reader = stream.getReader()
|
|
860
|
+
const decoder = new TextDecoder()
|
|
861
|
+
|
|
862
|
+
while (true) {
|
|
863
|
+
const { done, value } = await reader.read()
|
|
864
|
+
if (done) break
|
|
865
|
+
const chunk = decoder.decode(value)
|
|
866
|
+
write(chunk)
|
|
867
|
+
chunks?.push(chunk)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Inject server timing for client resumption
|
|
871
|
+
const total = (performance.now() - t0).toFixed(2)
|
|
872
|
+
const timingScript = `\n <script nonce="${nonce}">window.__PULSE_TIMING__ = { total: ${total} };</script>`
|
|
873
|
+
write(timingScript)
|
|
874
|
+
// Timing script has a nonce — exclude from cache so cached HTML has no inline scripts
|
|
875
|
+
chunks?.push(docClose)
|
|
876
|
+
|
|
877
|
+
end(docClose)
|
|
878
|
+
|
|
879
|
+
// Store assembled HTML in page cache for subsequent requests
|
|
880
|
+
if (chunks) pageHtmlCache.set(cacheKey, { html: chunks.join('') }, ttl)
|
|
881
|
+
} catch (err) {
|
|
882
|
+
// Headers already sent — inject an error script that replaces pulse-root content.
|
|
883
|
+
// JSON.stringify safely encodes the HTML for insertion into a script context.
|
|
884
|
+
try {
|
|
885
|
+
write(streamErrorScript(err, dev, nonce))
|
|
886
|
+
end('\n</body>\n</html>')
|
|
887
|
+
} catch {
|
|
888
|
+
res.destroy()
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Raw content response — serves non-HTML content (RSS, sitemaps, JSON APIs, etc.)
|
|
895
|
+
* Bypasses the HTML pipeline entirely. Resolves spec.server data, then calls
|
|
896
|
+
* spec.render(ctx, serverState) to produce the response body.
|
|
897
|
+
*
|
|
898
|
+
* Supports: spec.server, spec.cache, spec.serverTtl — same as page specs.
|
|
899
|
+
* Compresses text/*, *\/xml, and *\/json content types automatically.
|
|
900
|
+
*/
|
|
901
|
+
async function handleRawResponse(spec, ctx, req, res, dev) {
|
|
902
|
+
let content
|
|
903
|
+
|
|
904
|
+
if (!dev && spec.serverTtl) {
|
|
905
|
+
const key = 'raw:' + spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
906
|
+
content = serverDataCache.get(key)
|
|
907
|
+
if (!content) {
|
|
908
|
+
const serverState = await resolveServerState(spec, ctx)
|
|
909
|
+
content = await spec.render(ctx, serverState)
|
|
910
|
+
serverDataCache.set(key, content, spec.serverTtl)
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
const serverState = await resolveServerState(spec, ctx)
|
|
914
|
+
content = await spec.render(ctx, serverState)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// render() may return { redirect } instead of a string — used for auth callbacks etc.
|
|
918
|
+
if (content && typeof content === 'object' && content.redirect) {
|
|
919
|
+
res.writeHead(302, mergeCtxHeaders(ctx, { Location: content.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
|
|
920
|
+
res.end()
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const ct = spec.contentType
|
|
925
|
+
const compressible = /text\/|\/xml|\/json/.test(ct)
|
|
926
|
+
const encoding = compressible ? negotiateEncoding(req) : null
|
|
927
|
+
const buf = await compressBuffer(Buffer.from(content, 'utf8'), encoding)
|
|
928
|
+
|
|
929
|
+
const headers = mergeCtxHeaders(ctx, {
|
|
930
|
+
'Content-Type': ct,
|
|
931
|
+
'Cache-Control': buildCacheControl(spec, dev),
|
|
932
|
+
...SECURITY_HEADERS,
|
|
933
|
+
...httpsHeaders(req),
|
|
934
|
+
})
|
|
935
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
936
|
+
if (compressible) headers['Vary'] = 'Accept-Encoding'
|
|
937
|
+
|
|
938
|
+
res.writeHead(200, headers)
|
|
939
|
+
res.end(buf)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
// Router
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Build a router from an array of specs.
|
|
948
|
+
* Supports static routes (/about) and param routes (/products/:id).
|
|
949
|
+
*
|
|
950
|
+
* @param {import('../spec/schema.js').PulseSpec[]} specs
|
|
951
|
+
* @returns {{ pattern: RegExp, params: string[], spec: Object }[]}
|
|
952
|
+
*/
|
|
953
|
+
function buildRouter(specs) {
|
|
954
|
+
return specs.map(spec => {
|
|
955
|
+
const { pattern, params } = routeToRegex(spec.route)
|
|
956
|
+
return { pattern, params, spec }
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Match a pathname against the router.
|
|
962
|
+
* Returns the first match with extracted params, or null.
|
|
963
|
+
*
|
|
964
|
+
* @param {ReturnType<typeof buildRouter>} router
|
|
965
|
+
* @param {string} pathname
|
|
966
|
+
* @returns {{ spec: Object, params: Object } | null}
|
|
967
|
+
*/
|
|
968
|
+
function matchRoute(router, pathname) {
|
|
969
|
+
for (const route of router) {
|
|
970
|
+
const match = pathname.match(route.pattern)
|
|
971
|
+
if (!match) continue
|
|
972
|
+
|
|
973
|
+
const params = {}
|
|
974
|
+
route.params.forEach((name, i) => {
|
|
975
|
+
params[name] = decodeURIComponent(match[i + 1])
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
return { spec: route.spec, params }
|
|
979
|
+
}
|
|
980
|
+
return null
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Convert a route pattern to a regex.
|
|
985
|
+
* /products/:id → /^\/products\/([^/]+)\/?$/
|
|
986
|
+
* /about → /^\/about\/?$/
|
|
987
|
+
*
|
|
988
|
+
* @param {string} route
|
|
989
|
+
* @returns {{ pattern: RegExp, params: string[] }}
|
|
990
|
+
*/
|
|
991
|
+
function routeToRegex(route) {
|
|
992
|
+
const params = []
|
|
993
|
+
const pattern = route
|
|
994
|
+
.replace(/:([^/]+)/g, (_, name) => { params.push(name); return '([^/]+)' })
|
|
995
|
+
.replace(/\//g, '\\/')
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
pattern: new RegExp(`^${pattern}\\/?$`),
|
|
999
|
+
params
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// Request context
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Build the context object passed to server data fetchers.
|
|
1009
|
+
* Includes parsed URL, route params, headers, and convenience helpers.
|
|
1010
|
+
*
|
|
1011
|
+
* @param {http.IncomingMessage} req
|
|
1012
|
+
* @param {URL} url
|
|
1013
|
+
* @param {Object} params
|
|
1014
|
+
* @returns {Object}
|
|
1015
|
+
*/
|
|
1016
|
+
function buildContext(req, url, params, nonce = '', maxBody = 1024 * 1024) {
|
|
1017
|
+
const _responseHeaders = []
|
|
1018
|
+
|
|
1019
|
+
// Memoised body buffer — reads the request stream exactly once.
|
|
1020
|
+
// Returns a rejected promise if the body exceeds maxBody bytes.
|
|
1021
|
+
let _bodyPromise = null
|
|
1022
|
+
function _readBody() {
|
|
1023
|
+
if (_bodyPromise) return _bodyPromise
|
|
1024
|
+
_bodyPromise = new Promise((resolve, reject) => {
|
|
1025
|
+
const chunks = []
|
|
1026
|
+
let size = 0
|
|
1027
|
+
req.on('data', chunk => {
|
|
1028
|
+
size += chunk.length
|
|
1029
|
+
if (size > maxBody) {
|
|
1030
|
+
return reject(Object.assign(new Error(`Request body exceeds ${maxBody} bytes`), { status: 413 }))
|
|
1031
|
+
}
|
|
1032
|
+
chunks.push(chunk)
|
|
1033
|
+
})
|
|
1034
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
1035
|
+
req.on('error', reject)
|
|
1036
|
+
})
|
|
1037
|
+
return _bodyPromise
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
// Route params — e.g. { id: '42' } for /products/:id
|
|
1042
|
+
params,
|
|
1043
|
+
|
|
1044
|
+
// Query string — e.g. { q: 'widget' } for ?q=widget
|
|
1045
|
+
query: Object.fromEntries(url.searchParams),
|
|
1046
|
+
|
|
1047
|
+
// Raw headers
|
|
1048
|
+
headers: req.headers,
|
|
1049
|
+
|
|
1050
|
+
// Convenience
|
|
1051
|
+
pathname: url.pathname,
|
|
1052
|
+
method: req.method,
|
|
1053
|
+
nonce,
|
|
1054
|
+
|
|
1055
|
+
// Cookie helper — returns parsed cookies as a plain object
|
|
1056
|
+
get cookies() {
|
|
1057
|
+
return parseCookies(req.headers.cookie || '')
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
// Body parsers — async, memoised, safe to call from guard or server fetchers
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
|
|
1064
|
+
// Parse the request body as JSON.
|
|
1065
|
+
// Returns null when the body is empty.
|
|
1066
|
+
async json() {
|
|
1067
|
+
const buf = await _readBody()
|
|
1068
|
+
if (!buf.length) return null
|
|
1069
|
+
return JSON.parse(buf.toString('utf8'))
|
|
1070
|
+
},
|
|
1071
|
+
|
|
1072
|
+
// Return the request body as a plain string.
|
|
1073
|
+
async text() {
|
|
1074
|
+
const buf = await _readBody()
|
|
1075
|
+
return buf.toString('utf8')
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
// Parse the request body into a plain object.
|
|
1079
|
+
// Handles application/x-www-form-urlencoded and multipart/form-data.
|
|
1080
|
+
// Multipart file fields are returned as { filename, type, data: Buffer, size }.
|
|
1081
|
+
// Returns null when the body is empty.
|
|
1082
|
+
async formData() {
|
|
1083
|
+
const buf = await _readBody()
|
|
1084
|
+
if (!buf.length) return null
|
|
1085
|
+
const ct = req.headers['content-type'] || ''
|
|
1086
|
+
if (ct.includes('multipart/form-data')) {
|
|
1087
|
+
const m = ct.match(/boundary=([^\s;]+)/)
|
|
1088
|
+
if (!m) return null
|
|
1089
|
+
return parseMultipart(buf, m[1].replace(/^"|"$/g, ''))
|
|
1090
|
+
}
|
|
1091
|
+
return Object.fromEntries(new URLSearchParams(buf.toString('utf8')))
|
|
1092
|
+
},
|
|
1093
|
+
|
|
1094
|
+
// Return the raw request body as a Buffer.
|
|
1095
|
+
async buffer() {
|
|
1096
|
+
return _readBody()
|
|
1097
|
+
},
|
|
1098
|
+
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
// Response helpers
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
// Set an arbitrary response header (e.g. Location, custom headers)
|
|
1104
|
+
setHeader(name, value) {
|
|
1105
|
+
_responseHeaders.push([name, value])
|
|
1106
|
+
},
|
|
1107
|
+
|
|
1108
|
+
// Set a Set-Cookie response header with common options
|
|
1109
|
+
// opts: { httpOnly, secure, path, maxAge, sameSite, domain }
|
|
1110
|
+
setCookie(name, value, opts = {}) {
|
|
1111
|
+
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
|
|
1112
|
+
if (opts.path !== undefined) cookie += `; Path=${opts.path}`
|
|
1113
|
+
else cookie += `; Path=/`
|
|
1114
|
+
if (opts.maxAge !== undefined) cookie += `; Max-Age=${opts.maxAge}`
|
|
1115
|
+
if (opts.domain !== undefined) cookie += `; Domain=${opts.domain}`
|
|
1116
|
+
cookie += `; SameSite=${opts.sameSite ?? 'Lax'}`
|
|
1117
|
+
if (opts.httpOnly) cookie += `; HttpOnly`
|
|
1118
|
+
if (opts.secure) cookie += `; Secure`
|
|
1119
|
+
_responseHeaders.push(['Set-Cookie', cookie])
|
|
1120
|
+
},
|
|
1121
|
+
|
|
1122
|
+
// Internal — consumed by the response handlers
|
|
1123
|
+
_responseHeaders,
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Merge response headers set via ctx.setHeader / ctx.setCookie into a headers
|
|
1129
|
+
* object suitable for res.writeHead(). Handles multiple Set-Cookie values.
|
|
1130
|
+
*/
|
|
1131
|
+
function mergeCtxHeaders(ctx, headers) {
|
|
1132
|
+
if (!ctx._responseHeaders.length) return headers
|
|
1133
|
+
const result = { ...headers }
|
|
1134
|
+
for (const [name, value] of ctx._responseHeaders) {
|
|
1135
|
+
const key = name.toLowerCase() === 'set-cookie' ? 'Set-Cookie' : name
|
|
1136
|
+
if (key === 'Set-Cookie') {
|
|
1137
|
+
const existing = result['Set-Cookie']
|
|
1138
|
+
result['Set-Cookie'] = existing
|
|
1139
|
+
? (Array.isArray(existing) ? [...existing, value] : [existing, value])
|
|
1140
|
+
: [value]
|
|
1141
|
+
} else {
|
|
1142
|
+
result[key] = value
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return result
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
// Manifest & spec resolution
|
|
1150
|
+
// ---------------------------------------------------------------------------
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Load a hydrate manifest.
|
|
1154
|
+
* Accepts a manifest object, a path to a JSON file, or auto-detects from
|
|
1155
|
+
* staticDir/dist/manifest.json when staticDir is set.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {Object|string|null} manifest
|
|
1158
|
+
* @param {string|null} staticDir
|
|
1159
|
+
* @returns {Object} map of source paths → bundle paths
|
|
1160
|
+
*/
|
|
1161
|
+
function loadManifest(manifest, staticDir) {
|
|
1162
|
+
if (!manifest && !staticDir) return {}
|
|
1163
|
+
|
|
1164
|
+
if (manifest && typeof manifest === 'object') return manifest
|
|
1165
|
+
|
|
1166
|
+
const manifestPath = typeof manifest === 'string'
|
|
1167
|
+
? manifest
|
|
1168
|
+
: staticDir ? path.join(staticDir, 'dist', 'manifest.json') : null
|
|
1169
|
+
|
|
1170
|
+
if (!manifestPath) return {}
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
1174
|
+
} catch {
|
|
1175
|
+
return {}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Return a copy of the spec with the hydrate path and any meta.styles paths
|
|
1181
|
+
* resolved to their production bundle paths (if manifest entries exist).
|
|
1182
|
+
*
|
|
1183
|
+
* @param {Object} spec
|
|
1184
|
+
* @param {Object} hydrateMap
|
|
1185
|
+
* @returns {Object}
|
|
1186
|
+
*/
|
|
1187
|
+
function resolveSpec(spec, hydrateMap) {
|
|
1188
|
+
let resolved = spec
|
|
1189
|
+
|
|
1190
|
+
if (spec.hydrate && hydrateMap[spec.hydrate]) {
|
|
1191
|
+
resolved = { ...resolved, hydrate: hydrateMap[spec.hydrate] }
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (Array.isArray(spec.meta?.styles)) {
|
|
1195
|
+
const resolvedStyles = spec.meta.styles.map(href => hydrateMap[href] || href)
|
|
1196
|
+
if (resolvedStyles.some((s, i) => s !== spec.meta.styles[i])) {
|
|
1197
|
+
resolved = { ...resolved, meta: { ...resolved.meta, styles: resolvedStyles } }
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return resolved
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// ---------------------------------------------------------------------------
|
|
1205
|
+
// Static file serving
|
|
1206
|
+
// ---------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Attempt to serve a static file from staticDir.
|
|
1210
|
+
* Returns true if the file was served, false if not found.
|
|
1211
|
+
*
|
|
1212
|
+
* @param {http.IncomingMessage} req
|
|
1213
|
+
* @param {http.ServerResponse} res
|
|
1214
|
+
* @param {string} staticDir - Absolute path to the static files directory
|
|
1215
|
+
* @returns {boolean}
|
|
1216
|
+
*/
|
|
1217
|
+
function serveStatic(req, res, staticDir, dev = false) {
|
|
1218
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1219
|
+
const pathname = decodeURIComponent(url.pathname)
|
|
1220
|
+
|
|
1221
|
+
// Prevent directory traversal
|
|
1222
|
+
const filePath = path.join(staticDir, pathname)
|
|
1223
|
+
if (!filePath.startsWith(staticDir + path.sep) && filePath !== staticDir) return false
|
|
1224
|
+
|
|
1225
|
+
let stat
|
|
1226
|
+
try { stat = fs.statSync(filePath) } catch { return false }
|
|
1227
|
+
if (!stat.isFile()) return false
|
|
1228
|
+
|
|
1229
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
1230
|
+
const mime = MIME_TYPES[ext] || 'application/octet-stream'
|
|
1231
|
+
|
|
1232
|
+
// Content-hashed bundles under /dist/ can be cached indefinitely.
|
|
1233
|
+
// In dev, all other files get no-store so CSS/JS changes are reflected immediately.
|
|
1234
|
+
const isImmutable = pathname.startsWith('/dist/')
|
|
1235
|
+
const cache = isImmutable
|
|
1236
|
+
? 'public, max-age=31536000, immutable'
|
|
1237
|
+
: dev ? 'no-store' : 'public, max-age=3600'
|
|
1238
|
+
|
|
1239
|
+
// Only compress compressible text types
|
|
1240
|
+
const compressible = ['.js', '.css', '.html', '.json', '.svg'].includes(ext)
|
|
1241
|
+
const encoding = compressible ? negotiateEncoding(req) : null
|
|
1242
|
+
const compressor = createCompressor(encoding)
|
|
1243
|
+
|
|
1244
|
+
const headers = {
|
|
1245
|
+
'Content-Type': mime,
|
|
1246
|
+
'Cache-Control': cache,
|
|
1247
|
+
'Vary': 'Accept-Encoding',
|
|
1248
|
+
...SECURITY_HEADERS,
|
|
1249
|
+
}
|
|
1250
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
1251
|
+
|
|
1252
|
+
res.writeHead(200, headers)
|
|
1253
|
+
const fileStream = fs.createReadStream(filePath)
|
|
1254
|
+
if (compressor) {
|
|
1255
|
+
fileStream.pipe(compressor).pipe(res)
|
|
1256
|
+
} else {
|
|
1257
|
+
fileStream.pipe(res)
|
|
1258
|
+
}
|
|
1259
|
+
return true
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ---------------------------------------------------------------------------
|
|
1263
|
+
// Utilities
|
|
1264
|
+
// ---------------------------------------------------------------------------
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Resolve any function values in spec.meta by calling them with ctx.
|
|
1268
|
+
* Allows meta.title, meta.styles, meta.description etc. to be per-request
|
|
1269
|
+
* functions — useful for multi-brand sites where values vary by domain.
|
|
1270
|
+
*
|
|
1271
|
+
* @param {Object|undefined} meta
|
|
1272
|
+
* @param {Object} ctx
|
|
1273
|
+
* @returns {Object}
|
|
1274
|
+
*/
|
|
1275
|
+
function resolveMeta(meta, ctx) {
|
|
1276
|
+
if (!meta) return {}
|
|
1277
|
+
const resolved = {}
|
|
1278
|
+
for (const [key, val] of Object.entries(meta)) {
|
|
1279
|
+
resolved[key] = typeof val === 'function' ? val(ctx) : val
|
|
1280
|
+
}
|
|
1281
|
+
return resolved
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function parseCookies(cookieHeader) {
|
|
1285
|
+
if (!cookieHeader) return {}
|
|
1286
|
+
return Object.fromEntries(
|
|
1287
|
+
cookieHeader.split(';').map(pair => {
|
|
1288
|
+
const [key, ...rest] = pair.trim().split('=')
|
|
1289
|
+
return [key.trim(), decodeURIComponent(rest.join('=').trim())]
|
|
1290
|
+
})
|
|
1291
|
+
)
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function escHtml(str) {
|
|
1295
|
+
return String(str)
|
|
1296
|
+
.replace(/&/g, '&')
|
|
1297
|
+
.replace(/</g, '<')
|
|
1298
|
+
.replace(/>/g, '>')
|
|
1299
|
+
.replace(/"/g, '"')
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function notFoundHtml(pathname) {
|
|
1303
|
+
return `<!DOCTYPE html>
|
|
1304
|
+
<html lang="en">
|
|
1305
|
+
<head><meta charset="UTF-8"><title>404 — Not Found</title></head>
|
|
1306
|
+
<body>
|
|
1307
|
+
<h1>404</h1>
|
|
1308
|
+
<p>No route found for <code>${escHtml(pathname)}</code></p>
|
|
1309
|
+
</body>
|
|
1310
|
+
</html>`
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function defaultErrorHandler(err, _req, res, dev = false) {
|
|
1314
|
+
console.error('[Pulse] Server error:', err)
|
|
1315
|
+
if (res.headersSent) return
|
|
1316
|
+
|
|
1317
|
+
const html = dev ? errorPage(err) : errorPage500()
|
|
1318
|
+
res.writeHead(500, {
|
|
1319
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1320
|
+
...SECURITY_HEADERS,
|
|
1321
|
+
})
|
|
1322
|
+
res.end(html)
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function errorPage(err) {
|
|
1326
|
+
const message = escHtml(err?.message || String(err))
|
|
1327
|
+
const stack = escHtml(err?.stack || '')
|
|
1328
|
+
return `<!DOCTYPE html>
|
|
1329
|
+
<html lang="en">
|
|
1330
|
+
<head>
|
|
1331
|
+
<meta charset="UTF-8">
|
|
1332
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1333
|
+
<title>Pulse Error</title>
|
|
1334
|
+
<style>
|
|
1335
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1336
|
+
body { font-family: system-ui, sans-serif; background: #111; color: #f0f0f0; padding: 2rem; line-height: 1.5; }
|
|
1337
|
+
h1 { font-size: 1.25rem; color: #ff6b6b; margin-bottom: 1rem; display: flex; align-items: center; gap: .5rem; }
|
|
1338
|
+
h1::before { content: '⚠'; }
|
|
1339
|
+
.message { font-size: 1rem; color: #ffd6d6; background: #1e1010; border-left: 3px solid #ff6b6b; padding: .75rem 1rem; margin-bottom: 1.5rem; border-radius: 0 4px 4px 0; word-break: break-word; }
|
|
1340
|
+
pre { font-size: .8rem; color: #999; background: #1a1a1a; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
1341
|
+
.label { font-size: .7rem; color: #555; text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
|
|
1342
|
+
</style>
|
|
1343
|
+
</head>
|
|
1344
|
+
<body>
|
|
1345
|
+
<h1>Pulse render error</h1>
|
|
1346
|
+
<div class="message">${message}</div>
|
|
1347
|
+
<div class="label">Stack trace</div>
|
|
1348
|
+
<pre>${stack}</pre>
|
|
1349
|
+
</body>
|
|
1350
|
+
</html>`
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function streamErrorScript(err, dev, nonce = '') {
|
|
1354
|
+
const inner = dev
|
|
1355
|
+
? `<div style="padding:1.5rem;font-family:system-ui,sans-serif;background:#1e1010;border-left:3px solid #ff6b6b;border-radius:0 4px 4px 0;margin:1rem">` +
|
|
1356
|
+
`<p style="color:#ff6b6b;font-weight:600;margin:0 0 .5rem">Pulse render error</p>` +
|
|
1357
|
+
`<p style="color:#ffd6d6;margin:0 0 1rem;word-break:break-word">${escHtml(err?.message || String(err))}</p>` +
|
|
1358
|
+
`<pre style="font-size:.75rem;color:#999;white-space:pre-wrap;word-break:break-word;margin:0">${escHtml(err?.stack || '')}</pre>` +
|
|
1359
|
+
`</div>`
|
|
1360
|
+
: `<div style="padding:2rem;text-align:center;font-family:system-ui,sans-serif;color:#666">Something went wrong.</div>`
|
|
1361
|
+
|
|
1362
|
+
return `<script nonce="${nonce}">(function(){var r=document.getElementById('pulse-root');if(r)r.innerHTML=${JSON.stringify(inner)};})()</script>`
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function errorPage500() {
|
|
1366
|
+
return `<!DOCTYPE html>
|
|
1367
|
+
<html lang="en">
|
|
1368
|
+
<head>
|
|
1369
|
+
<meta charset="UTF-8">
|
|
1370
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1371
|
+
<title>500 — Server Error</title>
|
|
1372
|
+
<style>
|
|
1373
|
+
body { font-family: system-ui, sans-serif; background: #111; color: #f0f0f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
1374
|
+
.box { text-align: center; }
|
|
1375
|
+
h1 { font-size: 4rem; color: #333; }
|
|
1376
|
+
p { color: #666; margin-top: .5rem; }
|
|
1377
|
+
</style>
|
|
1378
|
+
</head>
|
|
1379
|
+
<body>
|
|
1380
|
+
<div class="box">
|
|
1381
|
+
<h1>500</h1>
|
|
1382
|
+
<p>Something went wrong.</p>
|
|
1383
|
+
</div>
|
|
1384
|
+
</body>
|
|
1385
|
+
</html>`
|
|
1386
|
+
}
|