@invisibleloop/pulse 0.20.11 → 0.20.19

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.
Files changed (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -350
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -1,194 +0,0 @@
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
- theme: 'light',
11
- title: 'Payments (Stripe) — Pulse Docs',
12
- description: 'Integrating Stripe Checkout and webhooks with Pulse using actions, raw response specs, and server data fetchers.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- state: {},
16
- view: () => renderLayout({
17
- currentHref: '/stripe',
18
- prev,
19
- next,
20
- content: `
21
- ${h1('Payments (Stripe)')}
22
- ${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.')}
23
-
24
- ${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.')}
25
-
26
- ${section('setup', 'Setup')}
27
- ${codeBlock(highlight(`npm install stripe`, 'bash'))}
28
- ${codeBlock(highlight(`# .env
29
- STRIPE_SECRET_KEY=sk_test_...
30
- STRIPE_WEBHOOK_SECRET=whsec_...
31
- APP_URL=http://localhost:3000`, 'bash'))}
32
-
33
- ${section('checkout', 'Checkout action')}
34
- <p>Create a Stripe Checkout session in an action's <code>run</code> function and redirect the browser to it.</p>
35
- ${codeBlock(highlight(`// src/pages/pricing.js
36
- import Stripe from 'stripe'
37
-
38
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
39
- const APP_URL = process.env.APP_URL
40
-
41
- export default {
42
- route: '/pricing',
43
-
44
- state: { status: 'idle' },
45
-
46
- view: (state) => \`
47
- <main id="main-content">
48
- <h1>Pricing</h1>
49
- <form data-action="checkout">
50
- <input type="hidden" name="priceId" value="price_xxxx">
51
- <button type="submit">
52
- \${state.status === 'loading' ? 'Redirecting…' : 'Buy now'}
53
- </button>
54
- </form>
55
- \${state.status === 'error'
56
- ? '<p role="alert">Something went wrong. Please try again.</p>'
57
- : ''}
58
- </main>
59
- \`,
60
-
61
- actions: {
62
- checkout: {
63
- onStart: () => ({ status: 'loading' }),
64
-
65
- run: async (state, serverState, formData) => {
66
- const priceId = formData.get('priceId')
67
-
68
- const session = await stripe.checkout.sessions.create({
69
- mode: 'payment',
70
- line_items: [{ price: priceId, quantity: 1 }],
71
- success_url: \`\${APP_URL}/checkout/success?session={CHECKOUT_SESSION_ID}\`,
72
- cancel_url: \`\${APP_URL}/checkout/cancel\`,
73
- })
74
-
75
- return { url: session.url }
76
- },
77
-
78
- onSuccess: (state, result) => {
79
- // Redirect to Stripe's hosted checkout page
80
- window.location.href = result.url
81
- return { status: 'redirecting' }
82
- },
83
-
84
- onError: () => ({ status: 'error' }),
85
- },
86
- },
87
- }`, 'js'))}
88
-
89
- ${section('success', 'Success and cancel pages')}
90
- ${codeBlock(highlight(`// src/pages/checkout/success.js
91
- import Stripe from 'stripe'
92
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
93
-
94
- export default {
95
- route: '/checkout/success',
96
- meta: { title: 'Payment successful', styles: ['/app.css'] },
97
-
98
- server: {
99
- session: async (ctx) => {
100
- const { session } = ctx.query
101
- if (!session) return null
102
- return stripe.checkout.sessions.retrieve(session)
103
- },
104
- },
105
-
106
- state: {},
107
- view: (state, server) => \`
108
- <main id="main-content">
109
- <h1>Payment successful</h1>
110
- \${server.session
111
- ? \`<p>Thank you! Your order reference is <strong>\${server.session.id}</strong>.</p>\`
112
- : '<p>Thank you for your purchase.</p>'
113
- }
114
- <a href="/">Back to home</a>
115
- </main>
116
- \`,
117
- }`, 'js'))}
118
-
119
- ${codeBlock(highlight(`// src/pages/checkout/cancel.js
120
- export default {
121
- route: '/checkout/cancel',
122
- meta: { title: 'Payment cancelled', styles: ['/app.css'] },
123
- state: {},
124
- view: () => \`
125
- <main id="main-content">
126
- <h1>Payment cancelled</h1>
127
- <p>No charge was made.</p>
128
- <a href="/pricing">Back to pricing</a>
129
- </main>
130
- \`,
131
- }`, 'js'))}
132
-
133
- ${section('webhooks', 'Webhook handler')}
134
- <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>
135
- ${codeBlock(highlight(`// src/pages/webhooks/stripe.js
136
- import Stripe from 'stripe'
137
-
138
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
139
- const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
140
-
141
- export default {
142
- route: '/webhooks/stripe',
143
- contentType: 'application/json',
144
-
145
- server: {
146
- event: async (ctx) => {
147
- const sig = ctx.headers['stripe-signature']
148
- const body = ctx.rawBody // raw Buffer — see note below
149
-
150
- try {
151
- return stripe.webhooks.constructEvent(body, sig, webhookSecret)
152
- } catch (err) {
153
- return { error: err.message }
154
- }
155
- },
156
- },
157
-
158
- render: (ctx, server) => {
159
- if (server.event.error) {
160
- ctx.setHeader('X-Webhook-Error', server.event.error)
161
- return JSON.stringify({ error: server.event.error })
162
- }
163
-
164
- const event = server.event
165
-
166
- if (event.type === 'checkout.session.completed') {
167
- const session = event.data.object
168
- // fulfil the order...
169
- }
170
-
171
- if (event.type === 'customer.subscription.deleted') {
172
- // handle cancellation...
173
- }
174
-
175
- return JSON.stringify({ received: true })
176
- },
177
- }`, 'js'))}
178
-
179
- ${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.')}
180
-
181
- ${section('reference', 'Pattern summary')}
182
- ${table(
183
- ['Concern', 'Pulse primitive'],
184
- [
185
- ['Initiate checkout', '<code>action.run</code> — calls Stripe API server-side, returns checkout URL'],
186
- ['Redirect to Stripe', '<code>action.onSuccess</code> — sets <code>window.location.href</code>'],
187
- ['Confirm payment', '<code>spec.server</code> on success page — retrieves session from Stripe'],
188
- ['Handle webhooks', 'Raw response spec with <code>render</code> returning JSON'],
189
- ['Verify signature', '<code>spec.server</code> fetcher using <code>stripe.webhooks.constructEvent</code>'],
190
- ]
191
- )}
192
- `,
193
- }),
194
- }
@@ -1,324 +0,0 @@
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
- theme: 'light',
11
- title: 'Supabase — Pulse Docs',
12
- description: 'Integrate Supabase database queries, authentication, and file storage with Pulse server fetchers, actions, and guard.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- state: {},
16
- view: () => renderLayout({
17
- currentHref: '/supabase',
18
- prev,
19
- next,
20
- content: `
21
- ${h1('Supabase')}
22
- ${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.')}
23
-
24
- ${section('setup', 'Setup')}
25
- ${codeBlock(highlight(`npm install @supabase/supabase-js`, 'bash'))}
26
- ${codeBlock(highlight(`# .env
27
- SUPABASE_URL=https://your-project.supabase.co
28
- SUPABASE_ANON_KEY=your-anon-key
29
- SUPABASE_SERVICE_KEY=your-service-role-key # server-side only`, 'bash'))}
30
- <p>Create two client helpers — one for public queries (respects Row Level Security), one for admin operations:</p>
31
- ${codeBlock(highlight(`// src/lib/supabase.js
32
- import { createClient } from '@supabase/supabase-js'
33
-
34
- const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY } = process.env
35
-
36
- // Public client — use in server fetchers for user-scoped queries
37
- export function supabase(accessToken) {
38
- const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
39
- global: accessToken
40
- ? { headers: { Authorization: \`Bearer \${accessToken}\` } }
41
- : {},
42
- })
43
- return client
44
- }
45
-
46
- // Admin client — bypasses RLS; use only for trusted server operations
47
- export const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)`, 'js'))}
48
- ${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.')}
49
-
50
- ${section('querying', 'Querying data')}
51
- <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>
52
- ${codeBlock(highlight(`// src/pages/posts.js
53
- import { supabase } from '../lib/supabase.js'
54
- import { escHtml } from '@invisibleloop/pulse/html'
55
-
56
- export default {
57
- route: '/posts',
58
-
59
- server: {
60
- posts: async () => {
61
- const { data, error } = await supabase().from('posts')
62
- .select('id, title, slug, created_at')
63
- .order('created_at', { ascending: false })
64
- .limit(20)
65
-
66
- if (error) throw new Error(error.message)
67
- return data
68
- },
69
- },
70
-
71
- state: {},
72
-
73
- view: (_state, server) => \`
74
- <main id="main-content">
75
- <h1>Posts</h1>
76
- <ul>
77
- \${server.posts.map(p => \`
78
- <li><a href="/posts/\${escHtml(p.slug)}">\${escHtml(p.title)}</a></li>
79
- \`).join('')}
80
- </ul>
81
- </main>
82
- \`,
83
- }`, 'js'))}
84
- ${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.')}
85
-
86
- ${section('auth-setup', 'Authentication')}
87
- <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>
88
-
89
- ${section('login', 'Login page')}
90
- ${codeBlock(highlight(`// src/pages/auth/login.js
91
- import { supabase } from '../../lib/supabase.js'
92
- import { escHtml } from '@invisibleloop/pulse/html'
93
-
94
- export default {
95
- route: '/login',
96
-
97
- guard: async (ctx) => {
98
- // Already logged in — send to dashboard
99
- if (ctx.cookies.access_token) return { redirect: '/dashboard' }
100
- },
101
-
102
- state: { status: 'idle', error: '' },
103
-
104
- view: (state) => \`
105
- <main id="main-content">
106
- <h1>Sign in</h1>
107
- <form data-action="login">
108
- <label for="email">Email</label>
109
- <input id="email" name="email" type="email" required autocomplete="email">
110
-
111
- <label for="password">Password</label>
112
- <input id="password" name="password" type="password" required autocomplete="current-password">
113
-
114
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
115
-
116
- <button type="submit">
117
- \${state.status === 'loading' ? 'Signing in…' : 'Sign in'}
118
- </button>
119
- </form>
120
- </main>
121
- \`,
122
-
123
- actions: {
124
- login: {
125
- onStart: () => ({ status: 'loading', error: '' }),
126
-
127
- run: async (_state, _server, formData) => {
128
- const { data, error } = await supabase().auth.signInWithPassword({
129
- email: formData.get('email'),
130
- password: formData.get('password'),
131
- })
132
- if (error) throw new Error(error.message)
133
- return data.session
134
- },
135
-
136
- onSuccess: (state, session, ctx) => {
137
- const opts = { httpOnly: true, sameSite: 'Lax', path: '/' }
138
- ctx.setCookie('access_token', session.access_token, { ...opts, maxAge: 3600 })
139
- ctx.setCookie('refresh_token', session.refresh_token, { ...opts, maxAge: 604800 })
140
- ctx.setHeader('Location', '/dashboard')
141
- return { status: 'success' }
142
- },
143
-
144
- onError: (_state, err) => ({
145
- status: 'idle',
146
- error: err.message || 'Sign in failed',
147
- }),
148
- },
149
- },
150
- }`, 'js'))}
151
-
152
- ${section('guard', 'Protecting routes')}
153
- <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>
154
- ${codeBlock(highlight(`// src/pages/dashboard.js
155
- import { supabase, admin } from '../lib/supabase.js'
156
-
157
- export default {
158
- route: '/dashboard',
159
-
160
- guard: async (ctx) => {
161
- const token = ctx.cookies.access_token
162
- if (!token) return { redirect: '/login' }
163
-
164
- // Verify the token is still valid
165
- const { error } = await supabase(token).auth.getUser()
166
- if (error) return { redirect: '/login' }
167
- },
168
-
169
- server: {
170
- // Token is available in guard ctx — pass it through server state or
171
- // re-read from cookies in the fetcher
172
- profile: async (ctx) => {
173
- const { data } = await supabase(ctx.cookies.access_token)
174
- .from('profiles')
175
- .select('name, plan')
176
- .single()
177
- return data
178
- },
179
- },
180
-
181
- state: {},
182
- view: (_state, server) => \`
183
- <main id="main-content">
184
- <h1>Dashboard</h1>
185
- <p>Welcome, \${server.profile.name}</p>
186
- </main>
187
- \`,
188
- }`, 'js'))}
189
- ${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.')}
190
-
191
- ${section('logout', 'Logout')}
192
- ${codeBlock(highlight(`// src/pages/auth/logout.js
193
- export default {
194
- route: '/logout',
195
- contentType: 'text/html',
196
-
197
- render: (ctx) => {
198
- const opts = { httpOnly: true, sameSite: 'Lax', path: '/', maxAge: 0 }
199
- ctx.setCookie('access_token', '', opts)
200
- ctx.setCookie('refresh_token', '', opts)
201
- return { redirect: '/login' }
202
- },
203
- }`, 'js'))}
204
-
205
- ${section('signup', 'Sign up')}
206
- ${codeBlock(highlight(`// src/pages/auth/signup.js
207
- import { supabase } from '../../lib/supabase.js'
208
- import { escHtml } from '@invisibleloop/pulse/html'
209
-
210
- export default {
211
- route: '/signup',
212
-
213
- state: { status: 'idle', error: '' },
214
-
215
- view: (state) => \`
216
- <main id="main-content">
217
- <h1>Create account</h1>
218
- \${state.status === 'success'
219
- ? '<p role="status">Check your email to confirm your account.</p>'
220
- : \`
221
- <form data-action="signup">
222
- <label for="email">Email</label>
223
- <input id="email" name="email" type="email" required>
224
-
225
- <label for="password">Password</label>
226
- <input id="password" name="password" type="password" required minlength="8">
227
-
228
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
229
-
230
- <button type="submit">
231
- \${state.status === 'loading' ? 'Creating account…' : 'Create account'}
232
- </button>
233
- </form>
234
- \`}
235
- </main>
236
- \`,
237
-
238
- actions: {
239
- signup: {
240
- onStart: () => ({ status: 'loading', error: '' }),
241
-
242
- run: async (_state, _server, formData) => {
243
- const { error } = await supabase().auth.signUp({
244
- email: formData.get('email'),
245
- password: formData.get('password'),
246
- })
247
- if (error) throw new Error(error.message)
248
- },
249
-
250
- onSuccess: () => ({ status: 'success', error: '' }),
251
- onError: (_state, err) => ({ status: 'idle', error: err.message }),
252
- },
253
- },
254
- }`, 'js'))}
255
-
256
- ${section('storage', 'File storage')}
257
- <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>
258
- ${codeBlock(highlight(`// src/pages/upload.js
259
- import { admin } from '../lib/supabase.js'
260
- import { escHtml } from '@invisibleloop/pulse/html'
261
-
262
- export default {
263
- route: '/upload',
264
-
265
- state: { status: 'idle', url: '', error: '' },
266
-
267
- view: (state) => \`
268
- <main id="main-content">
269
- <h1>Upload file</h1>
270
- <form data-action="upload" enctype="multipart/form-data">
271
- <input name="file" type="file" accept="image/*" required>
272
- <button type="submit">
273
- \${state.status === 'loading' ? 'Uploading…' : 'Upload'}
274
- </button>
275
- </form>
276
- \${state.url ? \`<img src="\${escHtml(state.url)}" alt="Uploaded file" width="400" height="300">\` : ''}
277
- \${state.error ? \`<p role="alert">\${escHtml(state.error)}</p>\` : ''}
278
- </main>
279
- \`,
280
-
281
- actions: {
282
- upload: {
283
- onStart: () => ({ status: 'loading', error: '' }),
284
-
285
- run: async (_state, _server, formData) => {
286
- const file = formData.get('file')
287
- const buffer = await file.arrayBuffer()
288
- const ext = file.name.split('.').pop()
289
- const path = \`\${crypto.randomUUID()}.\${ext}\`
290
-
291
- const { error } = await admin.storage
292
- .from('uploads')
293
- .upload(path, buffer, { contentType: file.type })
294
-
295
- if (error) throw new Error(error.message)
296
-
297
- const { data } = admin.storage.from('uploads').getPublicUrl(path)
298
- return data.publicUrl
299
- },
300
-
301
- onSuccess: (_state, url) => ({ status: 'idle', url }),
302
- onError: (_state, err) => ({ status: 'idle', error: err.message }),
303
- },
304
- },
305
- }`, 'js'))}
306
-
307
- ${section('rls', 'Row Level Security')}
308
- <p>Always enable RLS on tables that hold user data. A minimal policy that lets users read only their own rows:</p>
309
- ${codeBlock(highlight(`-- Enable RLS on the table
310
- alter table posts enable row level security;
311
-
312
- -- Users can only select their own posts
313
- create policy "users read own posts"
314
- on posts for select
315
- using (auth.uid() = user_id);
316
-
317
- -- Users can only insert rows for themselves
318
- create policy "users insert own posts"
319
- on posts for insert
320
- with check (auth.uid() = user_id);`, 'bash'))}
321
- ${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.')}
322
- `,
323
- }),
324
- }