@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,193 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/stripe')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/stripe',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Payments (Stripe) — Pulse Docs',
|
|
11
|
+
description: 'Integrating Stripe Checkout and webhooks with Pulse using actions, raw response specs, and server data fetchers.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/stripe',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Payments (Stripe)')}
|
|
21
|
+
${lead('Pulse uses Stripe\'s hosted Checkout — no client-side Stripe JS required. Checkout sessions are created server-side in an action\'s <code>run</code> function. Stripe handles the payment UI entirely. Webhooks are verified and handled through a raw response spec.')}
|
|
22
|
+
|
|
23
|
+
${callout('info', 'Pulse has no external client-side JS. Use Stripe\'s hosted Checkout page (redirect flow) rather than Stripe Elements, which requires loading Stripe\'s client library.')}
|
|
24
|
+
|
|
25
|
+
${section('setup', 'Setup')}
|
|
26
|
+
${codeBlock(highlight(`npm install stripe`, 'bash'))}
|
|
27
|
+
${codeBlock(highlight(`# .env
|
|
28
|
+
STRIPE_SECRET_KEY=sk_test_...
|
|
29
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
30
|
+
APP_URL=http://localhost:3000`, 'bash'))}
|
|
31
|
+
|
|
32
|
+
${section('checkout', 'Checkout action')}
|
|
33
|
+
<p>Create a Stripe Checkout session in an action's <code>run</code> function and redirect the browser to it.</p>
|
|
34
|
+
${codeBlock(highlight(`// src/pages/pricing.js
|
|
35
|
+
import Stripe from 'stripe'
|
|
36
|
+
|
|
37
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
38
|
+
const APP_URL = process.env.APP_URL
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
route: '/pricing',
|
|
42
|
+
|
|
43
|
+
state: { status: 'idle' },
|
|
44
|
+
|
|
45
|
+
view: (state) => \`
|
|
46
|
+
<main id="main-content">
|
|
47
|
+
<h1>Pricing</h1>
|
|
48
|
+
<form data-action="checkout">
|
|
49
|
+
<input type="hidden" name="priceId" value="price_xxxx">
|
|
50
|
+
<button type="submit">
|
|
51
|
+
\${state.status === 'loading' ? 'Redirecting…' : 'Buy now'}
|
|
52
|
+
</button>
|
|
53
|
+
</form>
|
|
54
|
+
\${state.status === 'error'
|
|
55
|
+
? '<p role="alert">Something went wrong. Please try again.</p>'
|
|
56
|
+
: ''}
|
|
57
|
+
</main>
|
|
58
|
+
\`,
|
|
59
|
+
|
|
60
|
+
actions: {
|
|
61
|
+
checkout: {
|
|
62
|
+
onStart: () => ({ status: 'loading' }),
|
|
63
|
+
|
|
64
|
+
run: async (state, serverState, formData) => {
|
|
65
|
+
const priceId = formData.get('priceId')
|
|
66
|
+
|
|
67
|
+
const session = await stripe.checkout.sessions.create({
|
|
68
|
+
mode: 'payment',
|
|
69
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
70
|
+
success_url: \`\${APP_URL}/checkout/success?session={CHECKOUT_SESSION_ID}\`,
|
|
71
|
+
cancel_url: \`\${APP_URL}/checkout/cancel\`,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return { url: session.url }
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
onSuccess: (state, result) => {
|
|
78
|
+
// Redirect to Stripe's hosted checkout page
|
|
79
|
+
window.location.href = result.url
|
|
80
|
+
return { status: 'redirecting' }
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
onError: () => ({ status: 'error' }),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}`, 'js'))}
|
|
87
|
+
|
|
88
|
+
${section('success', 'Success and cancel pages')}
|
|
89
|
+
${codeBlock(highlight(`// src/pages/checkout/success.js
|
|
90
|
+
import Stripe from 'stripe'
|
|
91
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
92
|
+
|
|
93
|
+
export default {
|
|
94
|
+
route: '/checkout/success',
|
|
95
|
+
meta: { title: 'Payment successful', styles: ['/app.css'] },
|
|
96
|
+
|
|
97
|
+
server: {
|
|
98
|
+
session: async (ctx) => {
|
|
99
|
+
const { session } = ctx.query
|
|
100
|
+
if (!session) return null
|
|
101
|
+
return stripe.checkout.sessions.retrieve(session)
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
state: {},
|
|
106
|
+
view: (state, server) => \`
|
|
107
|
+
<main id="main-content">
|
|
108
|
+
<h1>Payment successful</h1>
|
|
109
|
+
\${server.session
|
|
110
|
+
? \`<p>Thank you! Your order reference is <strong>\${server.session.id}</strong>.</p>\`
|
|
111
|
+
: '<p>Thank you for your purchase.</p>'
|
|
112
|
+
}
|
|
113
|
+
<a href="/">Back to home</a>
|
|
114
|
+
</main>
|
|
115
|
+
\`,
|
|
116
|
+
}`, 'js'))}
|
|
117
|
+
|
|
118
|
+
${codeBlock(highlight(`// src/pages/checkout/cancel.js
|
|
119
|
+
export default {
|
|
120
|
+
route: '/checkout/cancel',
|
|
121
|
+
meta: { title: 'Payment cancelled', styles: ['/app.css'] },
|
|
122
|
+
state: {},
|
|
123
|
+
view: () => \`
|
|
124
|
+
<main id="main-content">
|
|
125
|
+
<h1>Payment cancelled</h1>
|
|
126
|
+
<p>No charge was made.</p>
|
|
127
|
+
<a href="/pricing">Back to pricing</a>
|
|
128
|
+
</main>
|
|
129
|
+
\`,
|
|
130
|
+
}`, 'js'))}
|
|
131
|
+
|
|
132
|
+
${section('webhooks', 'Webhook handler')}
|
|
133
|
+
<p>Stripe sends signed POST requests to your webhook endpoint. Use a raw response spec to verify the signature and handle events. The raw body is required for signature verification — access it via <code>ctx.rawBody</code> if your server is configured to populate it, or read it from the request stream.</p>
|
|
134
|
+
${codeBlock(highlight(`// src/pages/webhooks/stripe.js
|
|
135
|
+
import Stripe from 'stripe'
|
|
136
|
+
|
|
137
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
138
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
|
139
|
+
|
|
140
|
+
export default {
|
|
141
|
+
route: '/webhooks/stripe',
|
|
142
|
+
contentType: 'application/json',
|
|
143
|
+
|
|
144
|
+
server: {
|
|
145
|
+
event: async (ctx) => {
|
|
146
|
+
const sig = ctx.headers['stripe-signature']
|
|
147
|
+
const body = ctx.rawBody // raw Buffer — see note below
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return stripe.webhooks.constructEvent(body, sig, webhookSecret)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { error: err.message }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
render: (ctx, server) => {
|
|
158
|
+
if (server.event.error) {
|
|
159
|
+
ctx.setHeader('X-Webhook-Error', server.event.error)
|
|
160
|
+
return JSON.stringify({ error: server.event.error })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const event = server.event
|
|
164
|
+
|
|
165
|
+
if (event.type === 'checkout.session.completed') {
|
|
166
|
+
const session = event.data.object
|
|
167
|
+
// fulfil the order...
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (event.type === 'customer.subscription.deleted') {
|
|
171
|
+
// handle cancellation...
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return JSON.stringify({ received: true })
|
|
175
|
+
},
|
|
176
|
+
}`, 'js'))}
|
|
177
|
+
|
|
178
|
+
${callout('warning', 'Stripe signature verification requires the raw request body before JSON parsing. Configure your Pulse server with <code>onRequest</code> to capture <code>ctx.rawBody</code> for the webhook route, or use a dedicated webhook path handled before Pulse\'s request pipeline.')}
|
|
179
|
+
|
|
180
|
+
${section('reference', 'Pattern summary')}
|
|
181
|
+
${table(
|
|
182
|
+
['Concern', 'Pulse primitive'],
|
|
183
|
+
[
|
|
184
|
+
['Initiate checkout', '<code>action.run</code> — calls Stripe API server-side, returns checkout URL'],
|
|
185
|
+
['Redirect to Stripe', '<code>action.onSuccess</code> — sets <code>window.location.href</code>'],
|
|
186
|
+
['Confirm payment', '<code>spec.server</code> on success page — retrieves session from Stripe'],
|
|
187
|
+
['Handle webhooks', 'Raw response spec with <code>render</code> returning JSON'],
|
|
188
|
+
['Verify signature', '<code>spec.server</code> fetcher using <code>stripe.webhooks.constructEvent</code>'],
|
|
189
|
+
]
|
|
190
|
+
)}
|
|
191
|
+
`,
|
|
192
|
+
}),
|
|
193
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
|
|
2
|
+
import { prevNext } from '../lib/nav.js'
|
|
3
|
+
import { highlight } from '../lib/highlight.js'
|
|
4
|
+
|
|
5
|
+
const { prev, next } = prevNext('/supabase')
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
route: '/supabase',
|
|
9
|
+
meta: {
|
|
10
|
+
title: 'Supabase — Pulse Docs',
|
|
11
|
+
description: 'Integrate Supabase database queries, authentication, and file storage with Pulse server fetchers, actions, and guard.',
|
|
12
|
+
styles: ['/docs.css'],
|
|
13
|
+
},
|
|
14
|
+
state: {},
|
|
15
|
+
view: () => renderLayout({
|
|
16
|
+
currentHref: '/supabase',
|
|
17
|
+
prev,
|
|
18
|
+
next,
|
|
19
|
+
content: `
|
|
20
|
+
${h1('Supabase')}
|
|
21
|
+
${lead('Supabase provides Postgres, authentication, and file storage. In Pulse, all Supabase queries run in server fetchers — credentials stay on the server, query results are filtered before serialisation, and <code>guard</code> enforces session checks before any data is fetched.')}
|
|
22
|
+
|
|
23
|
+
${section('setup', 'Setup')}
|
|
24
|
+
${codeBlock(highlight(`npm install @supabase/supabase-js`, 'bash'))}
|
|
25
|
+
${codeBlock(highlight(`# .env
|
|
26
|
+
SUPABASE_URL=https://your-project.supabase.co
|
|
27
|
+
SUPABASE_ANON_KEY=your-anon-key
|
|
28
|
+
SUPABASE_SERVICE_KEY=your-service-role-key # server-side only`, 'bash'))}
|
|
29
|
+
<p>Create two client helpers — one for public queries (respects Row Level Security), one for admin operations:</p>
|
|
30
|
+
${codeBlock(highlight(`// src/lib/supabase.js
|
|
31
|
+
import { createClient } from '@supabase/supabase-js'
|
|
32
|
+
|
|
33
|
+
const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY } = process.env
|
|
34
|
+
|
|
35
|
+
// Public client — use in server fetchers for user-scoped queries
|
|
36
|
+
export function supabase(accessToken) {
|
|
37
|
+
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
38
|
+
global: accessToken
|
|
39
|
+
? { headers: { Authorization: \`Bearer \${accessToken}\` } }
|
|
40
|
+
: {},
|
|
41
|
+
})
|
|
42
|
+
return client
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Admin client — bypasses RLS; use only for trusted server operations
|
|
46
|
+
export const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)`, 'js'))}
|
|
47
|
+
${callout('warning', 'The service key bypasses Row Level Security and has full database access. Keep it server-side only — in environment variables that are never sent to the browser or included in logs.')}
|
|
48
|
+
|
|
49
|
+
${section('querying', 'Querying data')}
|
|
50
|
+
<p>Call Supabase inside <code>server</code> fetchers — they run on the server before every render. The result is passed to <code>view</code> as the second argument.</p>
|
|
51
|
+
${codeBlock(highlight(`// src/pages/posts.js
|
|
52
|
+
import { supabase } from '../lib/supabase.js'
|
|
53
|
+
import { escHtml } from '@invisibleloop/pulse/html'
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
route: '/posts',
|
|
57
|
+
|
|
58
|
+
server: {
|
|
59
|
+
posts: async () => {
|
|
60
|
+
const { data, error } = await supabase().from('posts')
|
|
61
|
+
.select('id, title, slug, created_at')
|
|
62
|
+
.order('created_at', { ascending: false })
|
|
63
|
+
.limit(20)
|
|
64
|
+
|
|
65
|
+
if (error) throw new Error(error.message)
|
|
66
|
+
return data
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
state: {},
|
|
71
|
+
|
|
72
|
+
view: (_state, server) => \`
|
|
73
|
+
<main id="main-content">
|
|
74
|
+
<h1>Posts</h1>
|
|
75
|
+
<ul>
|
|
76
|
+
\${server.posts.map(p => \`
|
|
77
|
+
<li><a href="/posts/\${escHtml(p.slug)}">\${escHtml(p.title)}</a></li>
|
|
78
|
+
\`).join('')}
|
|
79
|
+
</ul>
|
|
80
|
+
</main>
|
|
81
|
+
\`,
|
|
82
|
+
}`, 'js'))}
|
|
83
|
+
${callout('tip', 'Add <code>serverTtl</code> to cache the Supabase query result in-process. A 60-second TTL on a public listing page means one database hit per minute across all visitors — Supabase stays fast even under load.')}
|
|
84
|
+
|
|
85
|
+
${section('auth-setup', 'Authentication')}
|
|
86
|
+
<p>Supabase Auth issues a JWT access token and a refresh token on login. Store both in <code>httpOnly</code> cookies — they are never accessible to JavaScript and survive page navigations.</p>
|
|
87
|
+
|
|
88
|
+
${section('login', 'Login page')}
|
|
89
|
+
${codeBlock(highlight(`// src/pages/auth/login.js
|
|
90
|
+
import { supabase } from '../../lib/supabase.js'
|
|
91
|
+
import { escHtml } from '@invisibleloop/pulse/html'
|
|
92
|
+
|
|
93
|
+
export default {
|
|
94
|
+
route: '/login',
|
|
95
|
+
|
|
96
|
+
guard: async (ctx) => {
|
|
97
|
+
// Already logged in — send to dashboard
|
|
98
|
+
if (ctx.cookies.access_token) return { redirect: '/dashboard' }
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
state: { status: 'idle', error: '' },
|
|
102
|
+
|
|
103
|
+
view: (state) => \`
|
|
104
|
+
<main id="main-content">
|
|
105
|
+
<h1>Sign in</h1>
|
|
106
|
+
<form data-action="login">
|
|
107
|
+
<label for="email">Email</label>
|
|
108
|
+
<input id="email" name="email" type="email" required autocomplete="email">
|
|
109
|
+
|
|
110
|
+
<label for="password">Password</label>
|
|
111
|
+
<input id="password" name="password" type="password" required autocomplete="current-password">
|
|
112
|
+
|
|
113
|
+
\${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
|
|
114
|
+
|
|
115
|
+
<button type="submit">
|
|
116
|
+
\${state.status === 'loading' ? 'Signing in…' : 'Sign in'}
|
|
117
|
+
</button>
|
|
118
|
+
</form>
|
|
119
|
+
</main>
|
|
120
|
+
\`,
|
|
121
|
+
|
|
122
|
+
actions: {
|
|
123
|
+
login: {
|
|
124
|
+
onStart: () => ({ status: 'loading', error: '' }),
|
|
125
|
+
|
|
126
|
+
run: async (_state, _server, formData) => {
|
|
127
|
+
const { data, error } = await supabase().auth.signInWithPassword({
|
|
128
|
+
email: formData.get('email'),
|
|
129
|
+
password: formData.get('password'),
|
|
130
|
+
})
|
|
131
|
+
if (error) throw new Error(error.message)
|
|
132
|
+
return data.session
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
onSuccess: (state, session, ctx) => {
|
|
136
|
+
const opts = { httpOnly: true, sameSite: 'Lax', path: '/' }
|
|
137
|
+
ctx.setCookie('access_token', session.access_token, { ...opts, maxAge: 3600 })
|
|
138
|
+
ctx.setCookie('refresh_token', session.refresh_token, { ...opts, maxAge: 604800 })
|
|
139
|
+
ctx.setHeader('Location', '/dashboard')
|
|
140
|
+
return { status: 'success' }
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
onError: (_state, err) => ({
|
|
144
|
+
status: 'idle',
|
|
145
|
+
error: err.message || 'Sign in failed',
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}`, 'js'))}
|
|
150
|
+
|
|
151
|
+
${section('guard', 'Protecting routes')}
|
|
152
|
+
<p>Use <code>guard</code> to verify the session before any server data is fetched. Pass the token to your fetchers so Supabase enforces Row Level Security for that user.</p>
|
|
153
|
+
${codeBlock(highlight(`// src/pages/dashboard.js
|
|
154
|
+
import { supabase, admin } from '../lib/supabase.js'
|
|
155
|
+
|
|
156
|
+
export default {
|
|
157
|
+
route: '/dashboard',
|
|
158
|
+
|
|
159
|
+
guard: async (ctx) => {
|
|
160
|
+
const token = ctx.cookies.access_token
|
|
161
|
+
if (!token) return { redirect: '/login' }
|
|
162
|
+
|
|
163
|
+
// Verify the token is still valid
|
|
164
|
+
const { error } = await supabase(token).auth.getUser()
|
|
165
|
+
if (error) return { redirect: '/login' }
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
server: {
|
|
169
|
+
// Token is available in guard ctx — pass it through server state or
|
|
170
|
+
// re-read from cookies in the fetcher
|
|
171
|
+
profile: async (ctx) => {
|
|
172
|
+
const { data } = await supabase(ctx.cookies.access_token)
|
|
173
|
+
.from('profiles')
|
|
174
|
+
.select('name, plan')
|
|
175
|
+
.single()
|
|
176
|
+
return data
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
state: {},
|
|
181
|
+
view: (_state, server) => \`
|
|
182
|
+
<main id="main-content">
|
|
183
|
+
<h1>Dashboard</h1>
|
|
184
|
+
<p>Welcome, \${server.profile.name}</p>
|
|
185
|
+
</main>
|
|
186
|
+
\`,
|
|
187
|
+
}`, 'js'))}
|
|
188
|
+
${callout('note', 'Row Level Security is enforced by Postgres, not by your application code. A policy that checks <code>auth.uid() = user_id</code> means a bug in your server code cannot accidentally expose another user\'s data — the database rejects the query before it returns anything.')}
|
|
189
|
+
|
|
190
|
+
${section('logout', 'Logout')}
|
|
191
|
+
${codeBlock(highlight(`// src/pages/auth/logout.js
|
|
192
|
+
export default {
|
|
193
|
+
route: '/logout',
|
|
194
|
+
contentType: 'text/html',
|
|
195
|
+
|
|
196
|
+
render: (ctx) => {
|
|
197
|
+
const opts = { httpOnly: true, sameSite: 'Lax', path: '/', maxAge: 0 }
|
|
198
|
+
ctx.setCookie('access_token', '', opts)
|
|
199
|
+
ctx.setCookie('refresh_token', '', opts)
|
|
200
|
+
return { redirect: '/login' }
|
|
201
|
+
},
|
|
202
|
+
}`, 'js'))}
|
|
203
|
+
|
|
204
|
+
${section('signup', 'Sign up')}
|
|
205
|
+
${codeBlock(highlight(`// src/pages/auth/signup.js
|
|
206
|
+
import { supabase } from '../../lib/supabase.js'
|
|
207
|
+
import { escHtml } from '@invisibleloop/pulse/html'
|
|
208
|
+
|
|
209
|
+
export default {
|
|
210
|
+
route: '/signup',
|
|
211
|
+
|
|
212
|
+
state: { status: 'idle', error: '' },
|
|
213
|
+
|
|
214
|
+
view: (state) => \`
|
|
215
|
+
<main id="main-content">
|
|
216
|
+
<h1>Create account</h1>
|
|
217
|
+
\${state.status === 'success'
|
|
218
|
+
? '<p role="status">Check your email to confirm your account.</p>'
|
|
219
|
+
: \`
|
|
220
|
+
<form data-action="signup">
|
|
221
|
+
<label for="email">Email</label>
|
|
222
|
+
<input id="email" name="email" type="email" required>
|
|
223
|
+
|
|
224
|
+
<label for="password">Password</label>
|
|
225
|
+
<input id="password" name="password" type="password" required minlength="8">
|
|
226
|
+
|
|
227
|
+
\${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
|
|
228
|
+
|
|
229
|
+
<button type="submit">
|
|
230
|
+
\${state.status === 'loading' ? 'Creating account…' : 'Create account'}
|
|
231
|
+
</button>
|
|
232
|
+
</form>
|
|
233
|
+
\`}
|
|
234
|
+
</main>
|
|
235
|
+
\`,
|
|
236
|
+
|
|
237
|
+
actions: {
|
|
238
|
+
signup: {
|
|
239
|
+
onStart: () => ({ status: 'loading', error: '' }),
|
|
240
|
+
|
|
241
|
+
run: async (_state, _server, formData) => {
|
|
242
|
+
const { error } = await supabase().auth.signUp({
|
|
243
|
+
email: formData.get('email'),
|
|
244
|
+
password: formData.get('password'),
|
|
245
|
+
})
|
|
246
|
+
if (error) throw new Error(error.message)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
onSuccess: () => ({ status: 'success', error: '' }),
|
|
250
|
+
onError: (_state, err) => ({ status: 'idle', error: err.message }),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}`, 'js'))}
|
|
254
|
+
|
|
255
|
+
${section('storage', 'File storage')}
|
|
256
|
+
<p>Upload files to Supabase Storage from an action. The file arrives in <code>FormData</code> — convert it to an <code>ArrayBuffer</code> and pass it to the storage client.</p>
|
|
257
|
+
${codeBlock(highlight(`// src/pages/upload.js
|
|
258
|
+
import { admin } from '../lib/supabase.js'
|
|
259
|
+
import { escHtml } from '@invisibleloop/pulse/html'
|
|
260
|
+
|
|
261
|
+
export default {
|
|
262
|
+
route: '/upload',
|
|
263
|
+
|
|
264
|
+
state: { status: 'idle', url: '', error: '' },
|
|
265
|
+
|
|
266
|
+
view: (state) => \`
|
|
267
|
+
<main id="main-content">
|
|
268
|
+
<h1>Upload file</h1>
|
|
269
|
+
<form data-action="upload" enctype="multipart/form-data">
|
|
270
|
+
<input name="file" type="file" accept="image/*" required>
|
|
271
|
+
<button type="submit">
|
|
272
|
+
\${state.status === 'loading' ? 'Uploading…' : 'Upload'}
|
|
273
|
+
</button>
|
|
274
|
+
</form>
|
|
275
|
+
\${state.url ? \`<img src="\${escHtml(state.url)}" alt="Uploaded file" width="400" height="300">\` : ''}
|
|
276
|
+
\${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
|
|
277
|
+
</main>
|
|
278
|
+
\`,
|
|
279
|
+
|
|
280
|
+
actions: {
|
|
281
|
+
upload: {
|
|
282
|
+
onStart: () => ({ status: 'loading', error: '' }),
|
|
283
|
+
|
|
284
|
+
run: async (_state, _server, formData) => {
|
|
285
|
+
const file = formData.get('file')
|
|
286
|
+
const buffer = await file.arrayBuffer()
|
|
287
|
+
const ext = file.name.split('.').pop()
|
|
288
|
+
const path = \`\${crypto.randomUUID()}.\${ext}\`
|
|
289
|
+
|
|
290
|
+
const { error } = await admin.storage
|
|
291
|
+
.from('uploads')
|
|
292
|
+
.upload(path, buffer, { contentType: file.type })
|
|
293
|
+
|
|
294
|
+
if (error) throw new Error(error.message)
|
|
295
|
+
|
|
296
|
+
const { data } = admin.storage.from('uploads').getPublicUrl(path)
|
|
297
|
+
return data.publicUrl
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
onSuccess: (_state, url) => ({ status: 'idle', url }),
|
|
301
|
+
onError: (_state, err) => ({ status: 'idle', error: err.message }),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}`, 'js'))}
|
|
305
|
+
|
|
306
|
+
${section('rls', 'Row Level Security')}
|
|
307
|
+
<p>Always enable RLS on tables that hold user data. A minimal policy that lets users read only their own rows:</p>
|
|
308
|
+
${codeBlock(highlight(`-- Enable RLS on the table
|
|
309
|
+
alter table posts enable row level security;
|
|
310
|
+
|
|
311
|
+
-- Users can only select their own posts
|
|
312
|
+
create policy "users read own posts"
|
|
313
|
+
on posts for select
|
|
314
|
+
using (auth.uid() = user_id);
|
|
315
|
+
|
|
316
|
+
-- Users can only insert rows for themselves
|
|
317
|
+
create policy "users insert own posts"
|
|
318
|
+
on posts for insert
|
|
319
|
+
with check (auth.uid() = user_id);`, 'bash'))}
|
|
320
|
+
${callout('tip', 'The public client (with the user\'s access token) applies Row Level Security — queries are automatically scoped to that user\'s data. The admin client bypasses RLS entirely, which is what you want for webhook handlers, background jobs, and admin operations, but not for user-scoped queries.')}
|
|
321
|
+
`,
|
|
322
|
+
}),
|
|
323
|
+
}
|