cascivo 1.4.0 → 1.6.0
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/dist/{create-D_3pilHu.mjs → create-DmBKREqv.mjs} +2794 -243
- package/dist/create-DmBKREqv.mjs.map +1 -0
- package/dist/generated/audit-contract.json +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/dist/create-D_3pilHu.mjs.map +0 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.
|
|
2
|
-
`)}const x=f;function S(e){return e===`yarn`?`yarn`:`${e} install`}function C(e,t){return e===`npm`?`npm run ${t}`:`${e} ${t}`}function w(e,t){return`${e} run ${t}`}const T=[`react-vite`,`astro`,`cloudflare`],E=[`preact`,`react`],D=[`board`,`agent`,`notes`,`import`,`files`,`export`,`usage`,`crud`,`live`,`voice`,`publish`,`webhooks`,`digest`,`search`];function O(e){return D.includes(e)}function k(e){return E.includes(e)}function A(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``)||`section`}function j(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(``)||`Section`;return/^[0-9]/.test(t)?`Section${t}`:t}function M(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(e=>e!==``&&!/^\d+$/.test(e)&&!/^v\d+$/i.test(e)),n=[];for(let e of t){if(n.length>=3||n.length>0&&n.join(` `).length+1+e.length>24)break;n.push(e.charAt(0).toUpperCase()+e.slice(1))}return n.join(` `)||`App`}function N(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,`-`).replace(/^-+|-+$/g,``)||`cascivo-app`}function ee(e){let t=new Set,n=new Set,r=[];for(let i of e){let e=i.trim();if(!e)continue;let a=A(e),o=j(e),s=2;for(;t.has(a)||n.has(o);)a=`${A(e)}-${s}`,o=`${j(e)}${s}`,s++;t.add(a),n.add(o),r.push({key:a,label:e,component:o})}return r.length>0?r:[{key:`home`,label:`Home`,component:`Home`}]}function te(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:`tsc && vite build`,preview:`vite preview`,typecheck:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,"@vitejs/plugin-react":`^5.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^7.0.0`}};return JSON.stringify(t,null,2)+`
|
|
1
|
+
import{a as e,r as t}from"./config-DT-Cs7bC.mjs";import{i as n}from"./fs-B7O-fNID.mjs";import{n as r,r as i,t as a}from"./args-CtmltI1S.mjs";import{stdin as o,stdout as s}from"node:process";import{existsSync as c,readdirSync as l}from"node:fs";import{join as u}from"node:path";import{createInterface as d}from"node:readline/promises";const f={"@cascivo/react":`1.6.0`,"@cascivo/themes":`1.0.1`,"@cascivo/charts":`1.6.0`,"@cascivo/icons":`1.1.0`,"@cascivo/eslint-config":`0.4.1`,"@cascivo/app":`1.6.0`,"@cascivo/render":`1.6.0`,"@cascivo/storage":`1.6.0`,"@cascivo/email":`0.5.0`},p=`>=3.0.0`;function m(e){let t=e.startsWith(`/`)?e:`/${e}`;return t.length>1?t.replace(/\/+$/,``)||`/`:t}function h(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function g(e){let t=m(e).split(`/`).filter(Boolean),n=[],r=[],i=``;return t.forEach((a,o)=>{if(a===`*`){if(o!==t.length-1)throw Error(`"*" must be the last segment of a path pattern: "${e}"`);n.push(`*`),r.push(1),i+=`(?:/(.*))?`}else if(a.startsWith(`:`)){let t=a.slice(1);if(!/^[A-Za-z_$][\w$]*$/.test(t))throw Error(`Invalid param name "${t}" in path pattern "${e}"`);n.push(t),r.push(2),i+=`/([^/]+)`}else r.push(3),i+=`/${h(a)}`}),{pattern:e,keys:n,regex:RegExp(`^${i||`/`}/?$`),score:r}}function _(e,t){let n=Math.max(e.score.length,t.score.length);for(let r=0;r<n;r++){let n=(t.score[r]??0)-(e.score[r]??0);if(n!==0)return n}return 0}var v=/\.(tsx|jsx)$/;function y(e){if(!v.test(e)||/\.(test|spec)\.[jt]sx$/.test(e))return;let t=e.replace(v,``).split(`/`);if(!t.some(e=>e.startsWith(`_`)))return t.length===1&&t[0]===`404`?null:(t.at(-1)===`index`&&t.pop(),`/${t.map((n,r)=>{if(/^\[\.\.\.[A-Za-z_$][\w$]*\]$/.test(n)){if(r!==t.length-1)throw Error(`routes/${e}: a [...rest] segment must be last`);return`*`}let i=/^\[([A-Za-z_$][\w$]*)\]$/.exec(n);if(i)return`:${i[1]}`;if(/[[\]]/.test(n))throw Error(`routes/${e}: malformed segment "${n}"`);return n}).join(`/`)}`)}function b(e,t){let n=[],r=new Map;for(let t of e){let e=y(t);if(e===void 0)continue;let i=r.get(e);if(i)throw Error(`routes/${i} and routes/${t} both define ${e??`the 404 route`}`);r.set(e,t),n.push({file:t,path:e})}let i=e=>`${t}/${e.replace(v,``)}`,a=n.filter(e=>e.path!==null).map(e=>({...e,compiled:g(e.path)})).sort((e,t)=>_(e.compiled,t.compiled)||e.path.localeCompare(t.path)),o=n.find(e=>e.path===null);return[`// Generated by @cascivo/app/vite from the routes directory — do not edit.`,`// Add, rename or delete a route file and this file is rewritten.`,`import { lazyRoute } from '@cascivo/app'`,`import type { Route } from '@cascivo/app'`,``,`export const routes: Route[] = [`,...a.map(e=>` lazyRoute('${e.path}', () => import('${i(e.file)}')),`),`]`,``,o?`export const notFound: Route | undefined = lazyRoute('*', () => import('${i(o.file)}'))`:`export const notFound: Route | undefined = undefined`,``,"/** Every route pattern. Fill one with `buildPath` for a typed link. */",`export type AppPath = ${a.length>0?a.map(e=>`'${e.path}'`).join(` | `):`never`}`,``].join(`
|
|
2
|
+
`)}const x=f;function S(e){return e===`yarn`?`yarn`:`${e} install`}function C(e,t){return e===`npm`?`npm run ${t}`:`${e} ${t}`}function w(e,t){return`${e} run ${t}`}const T=[`react-vite`,`astro`,`cloudflare`],E=[`preact`,`react`],D=[`board`,`agent`,`notes`,`import`,`files`,`export`,`usage`,`crud`,`live`,`voice`,`publish`,`webhooks`,`digest`,`search`,`checkout`,`newsletter`];function O(e){return D.includes(e)}function k(e){return E.includes(e)}function A(e){return e.trim().toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``)||`section`}function j(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(``)||`Section`;return/^[0-9]/.test(t)?`Section${t}`:t}function M(e){let t=e.trim().split(/[^a-zA-Z0-9]+/).filter(e=>e!==``&&!/^\d+$/.test(e)&&!/^v\d+$/i.test(e)),n=[];for(let e of t){if(n.length>=3||n.length>0&&n.join(` `).length+1+e.length>24)break;n.push(e.charAt(0).toUpperCase()+e.slice(1))}return n.join(` `)||`App`}function N(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,`-`).replace(/^-+|-+$/g,``)||`cascivo-app`}function ee(e){let t=new Set,n=new Set,r=[];for(let i of e){let e=i.trim();if(!e)continue;let a=A(e),o=j(e),s=2;for(;t.has(a)||n.has(o);)a=`${A(e)}-${s}`,o=`${j(e)}${s}`,s++;t.add(a),n.add(o),r.push({key:a,label:e,component:o})}return r.length>0?r:[{key:`home`,label:`Home`,component:`Home`}]}function te(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:`tsc && vite build`,preview:`vite preview`,typecheck:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,"@vitejs/plugin-react":`^5.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^7.0.0`}};return JSON.stringify(t,null,2)+`
|
|
3
3
|
`}function P(e){return JSON.stringify(e,null,2).replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g,(e,t)=>`[${t.split(/,\n\s+/).join(`, `)}]`)+`
|
|
4
|
-
`}function
|
|
4
|
+
`}function F(){return P({compilerOptions:{target:`ES2022`,useDefineForClassFields:!0,lib:[`ES2022`,`DOM`,`DOM.Iterable`],module:`ESNext`,skipLibCheck:!0,moduleResolution:`bundler`,allowImportingTsExtensions:!0,resolveJsonModule:!0,isolatedModules:!0,moduleDetection:`force`,noEmit:!0,jsx:`react-jsx`,strict:!0,noUnusedLocals:!0,noUnusedParameters:!0,noFallthroughCasesInSwitch:!0},include:[`src`]})}function ne(){return`import react from '@vitejs/plugin-react'
|
|
5
5
|
import { defineConfig } from 'vite'
|
|
6
6
|
|
|
7
7
|
export default defineConfig({
|
|
8
8
|
plugins: [react()],
|
|
9
9
|
})
|
|
10
|
-
`}function
|
|
10
|
+
`}function I(e){return`<!doctype html>
|
|
11
11
|
<html lang="en" data-theme="${e.theme}">
|
|
12
12
|
<head>
|
|
13
13
|
<meta charset="UTF-8" />
|
|
@@ -46,7 +46,7 @@ export default defineConfig({
|
|
|
46
46
|
<script type="module" src="/src/main.tsx"><\/script>
|
|
47
47
|
</body>
|
|
48
48
|
</html>
|
|
49
|
-
`}function
|
|
49
|
+
`}function re(){return`import React from 'react'
|
|
50
50
|
import ReactDOM from 'react-dom/client'
|
|
51
51
|
import App from './App'
|
|
52
52
|
|
|
@@ -58,8 +58,8 @@ if (root) {
|
|
|
58
58
|
</React.StrictMode>,
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
|
-
`}function
|
|
62
|
-
`}function
|
|
61
|
+
`}function ie(){return`/// <reference types="vite/client" />
|
|
62
|
+
`}function ae(e){let t=e.map(e=>`import { ${e.component} } from './sections/${e.component}'`).join(`
|
|
63
63
|
`),n=e.map(e=>`'${e.key}'`).join(` | `),r=e.map(e=>` {
|
|
64
64
|
label: '${e.label.replace(/'/g,`\\'`)}',
|
|
65
65
|
active: section.value === '${e.key}',
|
|
@@ -91,7 +91,7 @@ ${i}
|
|
|
91
91
|
</Shell>
|
|
92
92
|
)
|
|
93
93
|
}
|
|
94
|
-
`}function
|
|
94
|
+
`}function oe(e){return`'use client'
|
|
95
95
|
import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'
|
|
96
96
|
import type { ReactNode } from 'react'
|
|
97
97
|
|
|
@@ -121,10 +121,7 @@ export interface ShellProps {
|
|
|
121
121
|
*/
|
|
122
122
|
export function Shell({ navItems, children }: ShellProps) {
|
|
123
123
|
return (
|
|
124
|
-
|
|
125
|
-
header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}
|
|
126
|
-
nav={<SideNav items={navItems} />}
|
|
127
|
-
>
|
|
124
|
+
${z(e,`navItems`)}
|
|
128
125
|
{children}
|
|
129
126
|
</AppShell>
|
|
130
127
|
)
|
|
@@ -177,8 +174,8 @@ export default [
|
|
|
177
174
|
// see https://cascivo.com/docs/using-with-strict-eslint.md
|
|
178
175
|
...cascivo,
|
|
179
176
|
]
|
|
180
|
-
`}function z(){return JSON.stringify({semi:!1,singleQuote:!0,printWidth:100},null,2)+`
|
|
181
|
-
`}function
|
|
177
|
+
`}function z(e,t){let n=`header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}`,r=`nav={<SideNav items={${t}} />}`,i=` <AppShell ${n} ${r}>`;return i.length<=100?i:` <AppShell\n ${n}\n ${r}\n >`}function B(){return JSON.stringify({semi:!1,singleQuote:!0,printWidth:100},null,2)+`
|
|
178
|
+
`}function V(){return`dist
|
|
182
179
|
node_modules
|
|
183
180
|
# Vendored cascivo source is formatted upstream — reformatting it makes every
|
|
184
181
|
# \`cascivo add\` update a merge conflict.
|
|
@@ -187,17 +184,17 @@ src/components/ui/
|
|
|
187
184
|
dist
|
|
188
185
|
*.local
|
|
189
186
|
.DS_Store
|
|
190
|
-
`}function
|
|
187
|
+
`}function H(e){return`
|
|
191
188
|
## Share a preview
|
|
192
189
|
|
|
193
190
|
No Cloudflare account needed. Run \`${C(e.pm??`npm`,`build`)}\`, then either:
|
|
194
191
|
|
|
195
192
|
- drag the \`dist/\` folder onto [Cloudflare Drop](https://www.cloudflare.com/drop/), or
|
|
196
|
-
- from a terminal: \`npx wrangler deploy --temporary --assets dist --name ${N(e.name)} --compatibility-date ${
|
|
193
|
+
- from a terminal: \`npx wrangler deploy --temporary --assets dist --name ${N(e.name)} --compatibility-date ${K}\`
|
|
197
194
|
|
|
198
195
|
Either way you get a public URL that lasts 60 minutes. Sign in to Cloudflare within that hour
|
|
199
196
|
to keep it.
|
|
200
|
-
`}function
|
|
197
|
+
`}function ce(e){let t=e.pm??`npm`;return`# ${e.name}
|
|
201
198
|
|
|
202
199
|
A [cascivo](https://cascivo.com) app — Vite + React + TypeScript, pre-wired with
|
|
203
200
|
the cascivo app shell, side navigation, and the \`${e.theme}\` theme.
|
|
@@ -239,7 +236,7 @@ setLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href ?? '#
|
|
|
239
236
|
That one call makes \`SideNav\`, \`ShellHeader\` and \`Breadcrumb\` render real router links.
|
|
240
237
|
Links you write in page content use \`<Link asChild>\` instead — two kinds of link, two
|
|
241
238
|
mechanisms. Full recipe: https://cascivo.com/docs/using-with-a-router.md
|
|
242
|
-
${
|
|
239
|
+
${H(e)}`}function U(e){return`# Agent instructions — ${e.name}
|
|
243
240
|
|
|
244
241
|
This is a [cascivo](https://cascivo.com) app. When generating or editing CSS, follow
|
|
245
242
|
the **layer contract** — cascivo styles live in cascade layers, and layer order beats
|
|
@@ -306,8 +303,8 @@ The vocabulary types are on a subpath: \`import type { Tone } from '@cascivo/rea
|
|
|
306
303
|
add \`@cascivo/core\` to this app's dependencies — it is transitive here.
|
|
307
304
|
|
|
308
305
|
More: cascivo's machine-readable guide is at https://cascivo.com/llms.txt.
|
|
309
|
-
`}function
|
|
310
|
-
`}function
|
|
306
|
+
`}function W(e,t){return t===0?`/`:`/${e.key}`}function G(e,t){return t===0?`index.astro`:`${e.key}.astro`}function le(e){let t={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`astro dev`,build:`astro build`,preview:`astro preview`,typecheck:`astro check`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@astrojs/react":`^5.0.0`,"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,astro:`^7.0.0`,react:`^19.0.0`,"react-dom":`^19.0.0`},devDependencies:{"@astrojs/check":`^0.9.0`,"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`}};return JSON.stringify(t,null,2)+`
|
|
307
|
+
`}function ue(){return`// @ts-check
|
|
311
308
|
import { defineConfig } from 'astro/config'
|
|
312
309
|
import react from '@astrojs/react'
|
|
313
310
|
|
|
@@ -330,8 +327,8 @@ export default defineConfig({
|
|
|
330
327
|
},
|
|
331
328
|
},
|
|
332
329
|
})
|
|
333
|
-
`}function
|
|
334
|
-
`}function
|
|
330
|
+
`}function de(){return JSON.stringify({extends:`astro/tsconfigs/strict`,include:[`.astro/types.d.ts`,`**/*`],exclude:[`dist`],compilerOptions:{jsx:`react-jsx`}},null,2)+`
|
|
331
|
+
`}function fe(){return`/* Cascade layer order. Must load before any cascivo CSS — see the note in
|
|
335
332
|
src/layouts/Layout.astro. Later layers beat earlier ones regardless of specificity.
|
|
336
333
|
|
|
337
334
|
cascivo.example is this app's own slot: above the component and blocks layers so your
|
|
@@ -358,7 +355,7 @@ export default defineConfig({
|
|
|
358
355
|
block-size: 100%;
|
|
359
356
|
}
|
|
360
357
|
}
|
|
361
|
-
`}function
|
|
358
|
+
`}function pe(e){return`---
|
|
362
359
|
// Import order matters: the layer statement must be established before any cascivo CSS.
|
|
363
360
|
import '../styles/layers.css'
|
|
364
361
|
import '@cascivo/themes/${e.theme}.css'
|
|
@@ -385,11 +382,11 @@ const { title } = Astro.props
|
|
|
385
382
|
<slot />
|
|
386
383
|
</body>
|
|
387
384
|
</html>
|
|
388
|
-
`}function
|
|
385
|
+
`}function me(e,t){return`import { AppShell, ShellHeader, SideNav, type SideNavItem } from '@cascivo/react'
|
|
389
386
|
import type { ReactNode } from 'react'
|
|
390
387
|
|
|
391
388
|
const NAV: { label: string; href: string }[] = [
|
|
392
|
-
${t.map((e,t)=>` { label: '${e.label.replace(/'/g,`\\'`)}', href: '${
|
|
389
|
+
${t.map((e,t)=>` { label: '${e.label.replace(/'/g,`\\'`)}', href: '${W(e,t)}' },`).join(`
|
|
393
390
|
`)}
|
|
394
391
|
]
|
|
395
392
|
|
|
@@ -412,15 +409,12 @@ export function Shell({ activePath, children }: ShellProps) {
|
|
|
412
409
|
}))
|
|
413
410
|
|
|
414
411
|
return (
|
|
415
|
-
|
|
416
|
-
header={<ShellHeader brand={{ name: '${M(e.name).replace(/'/g,`\\'`)}' }} />}
|
|
417
|
-
nav={<SideNav items={items} />}
|
|
418
|
-
>
|
|
412
|
+
${z(e,`items`)}
|
|
419
413
|
{children}
|
|
420
414
|
</AppShell>
|
|
421
415
|
)
|
|
422
416
|
}
|
|
423
|
-
`}function
|
|
417
|
+
`}function he(e){return`import { Card, CardContent, CardHeader, CardTitle, Flex, Heading, Text } from '@cascivo/react'
|
|
424
418
|
|
|
425
419
|
export function ${e.component}() {
|
|
426
420
|
return (
|
|
@@ -446,7 +440,7 @@ export function ${e.component}() {
|
|
|
446
440
|
</Flex>
|
|
447
441
|
)
|
|
448
442
|
}
|
|
449
|
-
`}function
|
|
443
|
+
`}function ge(e,t){return`---
|
|
450
444
|
import Layout from '../layouts/Layout.astro'
|
|
451
445
|
import { Shell } from '../components/Shell'
|
|
452
446
|
import { ${e.component} } from '../components/${e.component}'
|
|
@@ -455,16 +449,16 @@ import { ${e.component} } from '../components/${e.component}'
|
|
|
455
449
|
<Layout title="${e.label}">
|
|
456
450
|
{/* client:load hydrates the shell for its mobile nav drawer. The section below stays
|
|
457
451
|
server-rendered HTML — it is slotted in as children and ships no JS. */}
|
|
458
|
-
<Shell client:load activePath="${
|
|
452
|
+
<Shell client:load activePath="${W(e,t)}">
|
|
459
453
|
<${e.component} />
|
|
460
454
|
</Shell>
|
|
461
455
|
</Layout>
|
|
462
|
-
`}function
|
|
456
|
+
`}function _e(){return`node_modules
|
|
463
457
|
dist
|
|
464
458
|
.astro
|
|
465
459
|
*.local
|
|
466
460
|
.DS_Store
|
|
467
|
-
`}function
|
|
461
|
+
`}function ve(e,t){let n=e.pm??`npm`,r=t.map((e,t)=>`- \`${W(e,t)}\` — \`src/pages/${G(e,t)}\``).join(`
|
|
468
462
|
`);return`# ${e.name}
|
|
469
463
|
|
|
470
464
|
A [cascivo](https://cascivo.com) app — Astro + React islands + TypeScript, pre-wired with
|
|
@@ -507,8 +501,8 @@ and that order matters: layers take their position from first appearance, so a s
|
|
|
507
501
|
loaded after the theme cannot reorder anything.
|
|
508
502
|
|
|
509
503
|
Add more components with \`npx cascivo add <component>\`.
|
|
510
|
-
${
|
|
511
|
-
`}function
|
|
504
|
+
${H(e)}`}const K=`2026-09-01`;function ye(e){let t=e.pm??`npm`,n=X(e,`agent`),r=Q(e),i=$(e)===`preact`,a={name:N(e.name),private:!0,version:`0.0.0`,type:`module`,scripts:{dev:`vite`,build:r?`tsc && tsc -p tsconfig.worker.json && vite build`:`tsc && vite build`,preview:`vite preview`,deploy:`${C(t,`build`)} && wrangler deploy`,"deploy:preview":`${C(t,`build`)} && wrangler deploy --temporary`,typecheck:r?`tsc --noEmit && tsc --noEmit -p tsconfig.worker.json`:`tsc --noEmit`,lint:`eslint .`,format:`prettier --write .`,"format:check":`prettier --check .`},dependencies:{"@cascivo/app":x[`@cascivo/app`],"@cascivo/react":x[`@cascivo/react`],"@cascivo/themes":x[`@cascivo/themes`],"@preact/signals-react":p,...i?{preact:`^10.29.0`}:{react:`^19.0.0`,"react-dom":`^19.0.0`},...X(e,`usage`)||X(e,`live`)?{"@cascivo/charts":x[`@cascivo/charts`]}:{},...X(e,`export`)?{"@cloudflare/puppeteer":`^1.4.0`}:{},...X(e,`notes`)?{"@cascivo/storage":x[`@cascivo/storage`]}:{},...n?{"@ai-sdk/react":`^4.0.0`,"@cascivo/render":x[`@cascivo/render`],"@cloudflare/ai-chat":`^0.12.0`,agents:`^0.24.0`,ai:`^7.0.0`,"workers-ai-provider":`^4.0.0`,zod:`^4.0.0`}:{},...X(e,`voice`)&&!n?{agents:`^0.24.0`}:{},...X(e,`publish`)&&!n?{"@cascivo/render":x[`@cascivo/render`]}:{},...X(e,`checkout`)||X(e,`newsletter`)?{"@cascivo/email":x[`@cascivo/email`]}:{},...(X(e,`publish`)||X(e,`checkout`)||X(e,`newsletter`))&&i?{"preact-render-to-string":`^6.5.0`}:{}},devDependencies:{"@cascivo/eslint-config":x[`@cascivo/eslint-config`],"@cloudflare/vite-plugin":`^1.62.0`,...r?{"@cloudflare/workers-types":`^5.0.0`}:{},"@eslint/js":`^9.0.0`,"@types/react":`^19.0.0`,"@types/react-dom":`^19.0.0`,...i?{"@babel/core":`^7.0.0`,"@preact/preset-vite":`^2.10.0`,react:`^19.0.0`,"react-dom":`^19.0.0`}:{"@vitejs/plugin-react":`^6.0.0`},eslint:`^9.0.0`,"eslint-plugin-react-hooks":`^7.0.0`,prettier:`^3.0.0`,typescript:`^5.7.0`,"typescript-eslint":`^8.0.0`,vite:`^8.0.0`,wrangler:`^4.143.0`}},o=un(e);return JSON.stringify(Object.keys(o).length>0?{...a,cloudflare:{bindings:o}}:a,null,2)+`
|
|
505
|
+
`}function be(e){let t=JSON.parse(F());return t.include=Q(e)?[`src`]:[`src`,`worker`],P(t)}function xe(){return P({extends:`./tsconfig.json`,compilerOptions:{lib:[`ES2022`],types:[`@cloudflare/workers-types`,`vite/client`]},include:[`worker`]})}function Se(e,t){let n=X(t,`agent`),r=ke(t),i=e===`preact`?`import preact from '@preact/preset-vite'`:`import react from '@vitejs/plugin-react'`,a=e===`preact`?`preact()`:`react()`;return`${i}
|
|
512
506
|
import { cascivoRoutes } from '@cascivo/app/vite'
|
|
513
507
|
import { cloudflare } from '@cloudflare/vite-plugin'
|
|
514
508
|
import { defineConfig } from 'vite'
|
|
@@ -526,7 +520,7 @@ ${e===`preact`?`// The source is written against React's types; @preact/preset-v
|
|
|
526
520
|
// client, so dev and production execute the same runtime. Request routing is in wrangler.jsonc.${r?`
|
|
527
521
|
//
|
|
528
522
|
// Workers AI has no local mode: with remote bindings on, \`vite dev\` needs a Cloudflare login.
|
|
529
|
-
// So they are off, ${
|
|
523
|
+
// So they are off, ${X(t,`search`)?`and:${[n?`the assistant answers from worker/scripted-model.ts`:``,X(t,`voice`)?`the voice page uses the stand-ins in worker/scripted-voice.ts`:``,`search runs by keyword, on SQLite full-text search (worker/search.ts)`].filter(Boolean).map(e=>`\n// - ${e}`).join(``)}`:n&&X(t,`voice`)?`the assistant answers from worker/scripted-model.ts, and the voice
|
|
530
524
|
// page uses the stand-ins in worker/scripted-voice.ts`:n?`and the assistant answers from worker/scripted-model.ts`:`and the voice page uses the stand-ins in worker/scripted-voice.ts`}. Run
|
|
531
525
|
// \`VITE_REAL_AI=1 vite dev\` (after \`wrangler login\`) to use Workers AI.`:``}
|
|
532
526
|
export default defineConfig({
|
|
@@ -534,18 +528,18 @@ ${r?` plugins: [
|
|
|
534
528
|
${a},
|
|
535
529
|
cascivoRoutes(),
|
|
536
530
|
cloudflare({ remoteBindings: process.env['VITE_REAL_AI'] === '1' }),
|
|
537
|
-
],`:` plugins: [${a}, cascivoRoutes(), cloudflare()],`}${
|
|
531
|
+
],`:` plugins: [${a}, cascivoRoutes(), cloudflare()],`}${X(t,`publish`)?`
|
|
538
532
|
// A published page's HTML links the stylesheets its route needs, found in this manifest
|
|
539
533
|
// (worker/page-html.ts), so the page is styled for readers without JavaScript too.
|
|
540
534
|
build: { manifest: 'asset-manifest.json' },`:``}
|
|
541
535
|
})
|
|
542
|
-
`}function
|
|
543
|
-
`)}\n${e}],`}function
|
|
536
|
+
`}function q(e,t,n){let r=`${e}"${t}": [${n.join(`, `)}],`;return r.length<=100?r:`${e}"${t}": [\n${n.map(t=>`${e} ${t},`).join(`
|
|
537
|
+
`)}\n${e}],`}function Ce(e){let t=X(e,`publish`),n=Ae(e),r=X(e,`agent`),i=Z(e),a=[...n?[{name:`ROOMS`,className:`SyncRoom`}]:[],...r?[{name:`Assistant`,className:`Assistant`}]:[],...X(e,`live`)?[{name:`LIVE`,className:`LiveRoom`}]:[],...X(e,`voice`)?[{name:`Voice`,className:`Voice`}]:[]],o=[...n?[`// ROOMS: one SyncRoom per room (@cascivo/app/sync-server).`]:[],...r?[`// Assistant: one AIChatAgent per conversation; it stores the messages in SQLite.`]:[],...X(e,`live`)?[`// LIVE: the /ops dashboard's per-second totals (@cascivo/app/live-server).`]:[],...X(e,`voice`)?[`// Voice: one voice agent per conversation (agents/voice); it stores the transcript.`]:[]];return`// Cloudflare deploy config. \`${w(e.pm??`npm`,`deploy`)}\` builds and ships the SPA and
|
|
544
538
|
// the Worker together. https://developers.cloudflare.com/workers/wrangler/configuration/
|
|
545
539
|
{
|
|
546
540
|
"name": "${N(e.name)}",
|
|
547
541
|
"main": "./worker/index.ts",
|
|
548
|
-
"compatibility_date": "${
|
|
542
|
+
"compatibility_date": "${K}",
|
|
549
543
|
"assets": {
|
|
550
544
|
// Client-side app: an unknown path serves index.html, and the client renders it.
|
|
551
545
|
"not_found_handling": "single-page-application",
|
|
@@ -559,55 +553,56 @@ ${r?` plugins: [
|
|
|
559
553
|
${o.join(`
|
|
560
554
|
`)}
|
|
561
555
|
"durable_objects": {
|
|
562
|
-
${
|
|
556
|
+
${q(` `,`bindings`,a.map(e=>`{ "name": "${e.name}", "class_name": "${e.className}" }`))}
|
|
563
557
|
},
|
|
564
|
-
${
|
|
558
|
+
${q(` `,`migrations`,[`{ "tag": "v1", "new_sqlite_classes": [${a.map(e=>`"${e.className}"`).join(`, `)}] }`])}`:``}${X(e,`files`)?`
|
|
565
559
|
// Uploaded files, and Cloudflare Images for their resized previews.
|
|
566
|
-
${
|
|
567
|
-
"images": { "binding": "IMAGES" },`:``}${
|
|
568
|
-
// ${[
|
|
560
|
+
${q(` `,`r2_buckets`,[`{ "binding": "FILES", "bucket_name": "${N(e.name)}-files" }`])}
|
|
561
|
+
"images": { "binding": "IMAGES" },`:``}${De(e)?`
|
|
562
|
+
// ${[X(e,`files`)?`Uploads`:``,X(e,`export`)?`exports`:``,X(e,`publish`)?`published pages`:``,X(e,`digest`)?`digests sent now`:``,X(e,`search`)?`search indexing`:``,X(e,`checkout`)?`checkouts started`:``,X(e,`newsletter`)?`newsletter sign-ups and composer requests`:``,e.auth===`email`?`sign-in emails`:``].filter(Boolean).join(`, `).replace(/^./,e=>e.toUpperCase())} per caller: 20 a minute.
|
|
569
563
|
// namespace_id is any number unique within your account:
|
|
570
564
|
// https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
|
|
571
|
-
${
|
|
565
|
+
${q(` `,`ratelimits`,[`{ "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }`])}`:``}${we(e)}${X(e,`digest`)?`
|
|
572
566
|
// The weekly digest: Mondays at 08:00 UTC (worker/digest.ts).
|
|
573
|
-
"triggers": { "crons": ["0 8 * * 1"] },`:``}${
|
|
574
|
-
// Tables for ${[
|
|
567
|
+
"triggers": { "crons": ["0 8 * * 1"] },`:``}${Oe(e)?`
|
|
568
|
+
// Tables for ${[X(e,`crud`)?`customers`:``,X(e,`publish`)?`published pages`:``,X(e,`webhooks`)?`webhook deliveries`:``,X(e,`digest`)?`digest runs`:``,X(e,`search`)?`help articles`:``,X(e,`checkout`)?`orders`:``,X(e,`newsletter`)?`newsletter subscribers and issues`:``,e.auth===`email`?`accounts`:``].filter(Boolean).join(`, `)}.
|
|
575
569
|
// No database_id: wrangler creates the database on first deploy, and the Worker applies its
|
|
576
570
|
// own schema on its first query (\`migrate\`).
|
|
577
|
-
${
|
|
571
|
+
${q(` `,`d1_databases`,[`{ "binding": "DB", "database_name": "${N(e.name)}-db" }`])}`:``}${X(e,`usage`)?`
|
|
578
572
|
// Every API request is recorded here (worker/index.ts); /usage reads it back.
|
|
579
|
-
"analytics_engine_datasets": [{ "binding": "USAGE", "dataset": "${
|
|
580
|
-
// Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in
|
|
581
|
-
// batches of up to 100, or whatever arrived within a second.
|
|
582
|
-
"queues": {
|
|
583
|
-
${G(` `,`producers`,[`{ "binding": "EVENTS", "queue": "${N(e.name)}-events" }`])}
|
|
584
|
-
"consumers": [
|
|
585
|
-
{
|
|
586
|
-
"queue": "${N(e.name)}-events",
|
|
587
|
-
"max_batch_size": 100,
|
|
588
|
-
"max_batch_timeout": 1,
|
|
589
|
-
},
|
|
590
|
-
],
|
|
591
|
-
},`:``}${K(e,`import`)?`
|
|
573
|
+
"analytics_engine_datasets": [{ "binding": "USAGE", "dataset": "${Ee(e)}" }],`:``}${Te(e)}${X(e,`import`)?`
|
|
592
574
|
// The CSV import runs as a Workflow (worker/import-job.ts); its progress is a room.
|
|
593
|
-
"workflows": [{ "name": "import-job", "binding": "IMPORT_JOB", "class_name": "ImportJob" }],`:``}${
|
|
594
|
-
// Workers AI, which the ${[r?`assistant`:``,
|
|
595
|
-
"ai": { "binding": "AI" },`:``}${
|
|
575
|
+
"workflows": [{ "name": "import-job", "binding": "IMPORT_JOB", "class_name": "ImportJob" }],`:``}${ke(e)?`
|
|
576
|
+
// Workers AI, which the ${[r?`assistant`:``,X(e,`voice`)?`voice agent`:``,X(e,`search`)?`search page`:``].filter(Boolean).join(` and `)} call${[r,X(e,`voice`),X(e,`search`)].filter(Boolean).length>1?``:`s`}.
|
|
577
|
+
"ai": { "binding": "AI" },`:``}${X(e,`search`)?`
|
|
596
578
|
// The help articles' embeddings (worker/search.ts). Create it once before deploying (README).
|
|
597
|
-
${
|
|
579
|
+
${q(` `,`vectorize`,[`{ "binding": "ARTICLES_INDEX", "index_name": "${N(e.name)}-articles" }`])}`:``}${X(e,`export`)?`
|
|
598
580
|
// Browser Run: renders pages to PDF/PNG for /api/export.
|
|
599
|
-
"browser": { "binding": "BROWSER" },`:``}${i||
|
|
600
|
-
// ${[i?`The Agents SDK`:``,
|
|
581
|
+
"browser": { "binding": "BROWSER" },`:``}${i||X(e,`export`)?`
|
|
582
|
+
// ${[i?`The Agents SDK`:``,X(e,`export`)?`Cloudflare's puppeteer`:``].filter(Boolean).join(` and `)} use${i&&X(e,`export`)?``:`s`} Node.js APIs.
|
|
601
583
|
"compatibility_flags": ["nodejs_compat"],`:``}
|
|
602
584
|
// Add bindings here (KV, D1, R2, Durable Objects, Workers AI) and read them from the
|
|
603
585
|
// \`env\` argument of the Worker's fetch handler.
|
|
604
586
|
}
|
|
605
|
-
`}function
|
|
587
|
+
`}function we(e){let t=X(e,`digest`),n=X(e,`checkout`),r=X(e,`newsletter`),i=e.auth===`email`,a=[...e.auth===`access`?[`Cloudflare Access (README). Until both are set, the Worker refuses every request.`]:[],...i?[`Sign-in links go out through Email Service (README). AUTH_FROM must be an address on a`,`domain you have onboarded; until it is set, sign-in fails with a clear error.`]:[],...t?[`The weekly digest (README): who gets it, who sends it, and the deployed app's URL,`,`which the browser opens. Until they are set, each run is recorded as skipped.`]:[],...n?[`Receipts for paid orders go out through Email Service from RECEIPT_FROM, an address`,`on a domain you have onboarded (README). Until it is set, no receipt is sent.`]:[],...r?[`The newsletter sends through Amazon SES (README): the region your sending identity is`,`verified in, its From address, and the SNS topic SES reports bounces and complaints to.`]:[]],o=[...e.auth===`access`?[`ACCESS_TEAM_DOMAIN`,`ACCESS_AUD`]:[],...i?[`AUTH_FROM`]:[],...t?[`DIGEST_TO`,`DIGEST_FROM`,`APP_URL`]:[],...n?[`RECEIPT_FROM`]:[],...r?[`AWS_REGION`,`NEWSLETTER_FROM`,`SNS_TOPIC_ARN`]:[]];if(o.length===0)return``;let s=o.map(e=>`"${e}": ""`),c=` "vars": { ${s.join(`, `)} },`,l=c.length<=100?c:` "vars": {\n${s.map(e=>` ${e},`).join(`
|
|
606
588
|
`)}\n },`;return`
|
|
607
|
-
${
|
|
608
|
-
`)}${
|
|
589
|
+
${a.map(e=>` // ${e}`).join(`
|
|
590
|
+
`)}${i||t||n?`
|
|
609
591
|
"send_email": [{ "name": "EMAIL" }],`:``}
|
|
610
|
-
${
|
|
592
|
+
${l}`}function Te(e){let t=[...X(e,`live`)?[{comment:[`Events for /ops: POST /api/events sends them, and the Worker's queue handler takes them in`,`batches of up to 100, or whatever arrived within a second.`],binding:`EVENTS`,queue:`${N(e.name)}-events`,consumer:[`"max_batch_size": 100`,`"max_batch_timeout": 1`]}]:[],...X(e,`newsletter`)?[{comment:[`Newsletter sends (worker/newsletter.ts): one message of 25 readers at a time, so SES`,`is called at a steady pace. A throttled message is retried after 30 seconds.`],binding:`NEWSLETTER`,queue:Y(e),consumer:[`"max_batch_size": 1`,`"max_concurrency": 1`,`"max_retries": 10`,`"retry_delay": 30`]}]:[]];return t.length===0?``:`
|
|
593
|
+
${t.flatMap(e=>e.comment.map(e=>` // ${e}`)).join(`
|
|
594
|
+
`)}
|
|
595
|
+
"queues": {
|
|
596
|
+
${q(` `,`producers`,t.map(e=>`{ "binding": "${e.binding}", "queue": "${e.queue}" }`))}
|
|
597
|
+
"consumers": [
|
|
598
|
+
${t.map(e=>` {
|
|
599
|
+
"queue": "${e.queue}",
|
|
600
|
+
${e.consumer.map(e=>` ${e},`).join(`
|
|
601
|
+
`)}
|
|
602
|
+
},`).join(`
|
|
603
|
+
`)}
|
|
604
|
+
],
|
|
605
|
+
},`}function J(e){return X(e,`checkout`)&&e.auth===`email`}function Y(e){return`${N(e.name)}-newsletter`}function X(e,t){return e.examples?.includes(t)??!1}function Ee(e){return`${N(e.name).replace(/[^a-z0-9_]/g,`_`).replace(/^[^a-z_]/,`_$&`)}_usage`}function De(e){return X(e,`files`)||X(e,`export`)||X(e,`publish`)||X(e,`digest`)||X(e,`search`)||X(e,`checkout`)||X(e,`newsletter`)||e.auth===`email`}function Oe(e){return X(e,`crud`)||X(e,`publish`)||X(e,`webhooks`)||X(e,`digest`)||X(e,`search`)||X(e,`checkout`)||X(e,`newsletter`)||e.auth===`email`}function ke(e){return Z(e)||X(e,`search`)}function Z(e){return X(e,`agent`)||X(e,`voice`)}function Ae(e){return X(e,`board`)||X(e,`notes`)||X(e,`import`)||X(e,`webhooks`)||X(e,`checkout`)||X(e,`newsletter`)}function Q(e){return Z(e)||X(e,`import`)}function $(e){return e.runtime??(X(e,`agent`)?`react`:`preact`)}function je(e){let t=X(e,`import`),n=X(e,`files`),r=X(e,`usage`),i=X(e,`crud`),a=X(e,`live`),o=X(e,`publish`),s=X(e,`webhooks`),c=X(e,`digest`),l=X(e,`search`),u=X(e,`checkout`),d=X(e,`newsletter`),f=J(e);return`import { defineApi, ${t||n||r||i||a||o||s||c||l||u||d?`endpoint, `:``}stream } from '@cascivo/app/api'
|
|
611
606
|
${i?`import { parseTablePage, parseTableQuery } from '@cascivo/app/db'
|
|
612
607
|
`:``}${n?`import { parseStoredFile } from '@cascivo/app/uploads'
|
|
613
608
|
`:``}${t?`import { parseImportRequest, parseStarted } from './import-job'
|
|
@@ -618,6 +613,19 @@ ${i?`import { parseTablePage, parseTableQuery } from '@cascivo/app/db'
|
|
|
618
613
|
`:``}${s?`import { parseDeliveries, parseTestResult } from './webhooks'
|
|
619
614
|
`:``}${c?`import { parseDigestRun, parseDigestRuns } from './digest'
|
|
620
615
|
`:``}${l?`import { parseIndexed, parseSearchQuery, parseSearchResult } from './search'
|
|
616
|
+
`:``}${f?`import { parseBilling, parseRedirect, parseSyncInput } from './billing'
|
|
617
|
+
`:``}${u?`import { parseCheckoutStarted, parseOrder } from './checkout'
|
|
618
|
+
`:``}${d?`import {
|
|
619
|
+
parseConfirmed,
|
|
620
|
+
parseEmailInput,
|
|
621
|
+
parseIssue,
|
|
622
|
+
parseIssueInput,
|
|
623
|
+
parseKeyInput,
|
|
624
|
+
parseOverview,
|
|
625
|
+
parsePreview,
|
|
626
|
+
parseSubscribed,
|
|
627
|
+
parseTokenInput,
|
|
628
|
+
} from './newsletter'
|
|
621
629
|
`:``}
|
|
622
630
|
/**
|
|
623
631
|
* The contract between the browser and the Worker. Both import this file: the Worker serves
|
|
@@ -700,7 +708,61 @@ export const api = defineApi({
|
|
|
700
708
|
output: parseSearchResult,
|
|
701
709
|
}),
|
|
702
710
|
// Embeds every article into the Vectorize index.
|
|
703
|
-
indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`:``}${
|
|
711
|
+
indexArticles: endpoint({ method: 'POST', path: '/api/search/index', output: parseIndexed }),`:``}${u?`
|
|
712
|
+
// Opens a Stripe Checkout page for the product; the browser goes to its url.
|
|
713
|
+
startCheckout: endpoint({ method: 'POST', path: '/api/checkout', output: parseCheckoutStarted }),
|
|
714
|
+
// An order, checked with Stripe while it is pending (Stripe's webhook settles it too).
|
|
715
|
+
getOrder: endpoint({ method: 'GET', path: '/api/orders/:id', output: parseOrder }),`:``}${f?`
|
|
716
|
+
// The signed-in user's subscription (worker/billing.ts), and the ways to change it.
|
|
717
|
+
getBilling: endpoint({ method: 'GET', path: '/api/billing', output: parseBilling }),
|
|
718
|
+
startSubscription: endpoint({
|
|
719
|
+
method: 'POST',
|
|
720
|
+
path: '/api/billing/subscribe',
|
|
721
|
+
output: parseRedirect,
|
|
722
|
+
}),
|
|
723
|
+
syncBilling: endpoint({
|
|
724
|
+
method: 'POST',
|
|
725
|
+
path: '/api/billing/sync',
|
|
726
|
+
input: parseSyncInput,
|
|
727
|
+
output: parseBilling,
|
|
728
|
+
}),
|
|
729
|
+
openBillingPortal: endpoint({
|
|
730
|
+
method: 'POST',
|
|
731
|
+
path: '/api/billing/portal',
|
|
732
|
+
output: parseRedirect,
|
|
733
|
+
}),`:``}${d?`
|
|
734
|
+
// Signing up sends a confirmation link; only confirmed readers get issues.
|
|
735
|
+
subscribe: endpoint({
|
|
736
|
+
method: 'POST',
|
|
737
|
+
path: '/api/newsletter/subscribe',
|
|
738
|
+
input: parseEmailInput,
|
|
739
|
+
output: parseSubscribed,
|
|
740
|
+
}),
|
|
741
|
+
confirmSubscription: endpoint({
|
|
742
|
+
method: 'POST',
|
|
743
|
+
path: '/api/newsletter/confirm',
|
|
744
|
+
input: parseTokenInput,
|
|
745
|
+
output: parseConfirmed,
|
|
746
|
+
}),
|
|
747
|
+
// The composer's calls, each carrying NEWSLETTER_KEY.
|
|
748
|
+
newsletterOverview: endpoint({
|
|
749
|
+
method: 'POST',
|
|
750
|
+
path: '/api/newsletter/overview',
|
|
751
|
+
input: parseKeyInput,
|
|
752
|
+
output: parseOverview,
|
|
753
|
+
}),
|
|
754
|
+
previewIssue: endpoint({
|
|
755
|
+
method: 'POST',
|
|
756
|
+
path: '/api/newsletter/preview',
|
|
757
|
+
input: parseIssueInput,
|
|
758
|
+
output: parsePreview,
|
|
759
|
+
}),
|
|
760
|
+
sendIssue: endpoint({
|
|
761
|
+
method: 'POST',
|
|
762
|
+
path: '/api/newsletter/issues',
|
|
763
|
+
input: parseIssueInput,
|
|
764
|
+
output: parseIssue,
|
|
765
|
+
}),`:``}${i?`
|
|
704
766
|
// One page of customers for DataTable's query (sort, search, filters, page).
|
|
705
767
|
customers: endpoint({
|
|
706
768
|
method: 'POST',
|
|
@@ -722,12 +784,12 @@ export const api = defineApi({
|
|
|
722
784
|
}),
|
|
723
785
|
deleteCustomer: endpoint({ method: 'DELETE', path: '/api/customers/:id', output: parseDeleted }),`:``}
|
|
724
786
|
})
|
|
725
|
-
`}function
|
|
726
|
-
`:``}${
|
|
787
|
+
`}function Me(e){let t=Ae(e),n=X(e,`agent`),r=X(e,`import`),i=X(e,`files`),a=X(e,`export`),o=X(e,`usage`),s=X(e,`crud`),c=X(e,`live`),l=X(e,`voice`),u=X(e,`publish`),d=X(e,`webhooks`),f=X(e,`digest`),p=X(e,`search`),m=X(e,`checkout`),h=X(e,`newsletter`),g=J(e),_=Oe(e),v=Z(e),y=De(e),b=e.auth===`access`,x=e.auth===`email`,S=[...b?[`requireAccess`]:[],...y?[`clientIp`,`rateLimit`]:[]],C=t||v||i||a||b||c||y||u,w=v||i||a||b||y||d||u||m||h,T=[...r?[`job-`]:[],...d?[`webhooks$`]:[],...m?[`order-`]:[],...h?[`issue-`]:[]],E=[...d?[`/api/webhooks/`]:[],...m?[`/api/stripe/`]:[],...h?[`/api/sns/`]:[]],D=h?[`/api/newsletter/subscribe`,`/api/newsletter/confirm`,`/api/newsletter/unsubscribe`]:[],O=[...E,...D];return`${o?`import type { AnalyticsDataset } from '@cascivo/app/analytics'
|
|
788
|
+
`:``}${_?`import type { Database } from '@cascivo/app/db'
|
|
727
789
|
`:``}${a?`import { handleExport } from '@cascivo/app/export'
|
|
728
790
|
`:``}import { createHandler${u?`, HttpError`:``} } from '@cascivo/app/api'
|
|
729
|
-
${
|
|
730
|
-
`:``}${
|
|
791
|
+
${x?`import { handleAuth, requireUser } from '@cascivo/app/auth-server'
|
|
792
|
+
`:``}${S.length>0||d?`import { ${[...S,`guardResponse`].sort().join(`, `)} } from '@cascivo/app/guard'\n${y?`import type { RateLimiter } from '@cascivo/app/guard'
|
|
731
793
|
`:``}`:``}${r?`import { jobReporter } from '@cascivo/app/jobs-server'
|
|
732
794
|
`:``}${i?`import { handleUploads, listUploads } from '@cascivo/app/uploads-server'
|
|
733
795
|
import type { ImageResizer, UploadBucket } from '@cascivo/app/uploads-server'
|
|
@@ -737,7 +799,7 @@ import type { LiveBatch, LiveQueue } from '@cascivo/app/live-server'
|
|
|
737
799
|
import type { RoomNamespace } from '@cascivo/app/sync-server'
|
|
738
800
|
`:``}${a?`import puppeteer from '@cloudflare/puppeteer'
|
|
739
801
|
import type { BrowserWorker } from '@cloudflare/puppeteer'
|
|
740
|
-
`:``}${
|
|
802
|
+
`:``}${v?`import { routeAgentRequest } from 'agents'
|
|
741
803
|
`:``}import { api, TICKS_PER_STREAM } from '../src/api'
|
|
742
804
|
import type { Tick } from '../src/api'
|
|
743
805
|
${r?`import { importJob } from '../src/import-job'
|
|
@@ -745,7 +807,7 @@ ${r?`import { importJob } from '../src/import-job'
|
|
|
745
807
|
`:``}${o?`import { usageMetrics } from '../src/usage'
|
|
746
808
|
import { usageReport } from './usage'
|
|
747
809
|
`:``}${c?`import { OPS_ROOM, ops } from '../src/ops'
|
|
748
|
-
`:``}${
|
|
810
|
+
`:``}${x?`import { sendSignInLink } from './auth'
|
|
749
811
|
import type { SignInSender } from './auth'
|
|
750
812
|
`:``}${s?`import * as customerStore from './customers'
|
|
751
813
|
`:``}${u?`import * as pageStore from './pages'\nimport { renderPageHtml } from './page-html'\nimport type { Assets } from './page-html'\n${a?`import { pagePreview } from './page-preview'
|
|
@@ -753,7 +815,14 @@ import type { SignInSender } from './auth'
|
|
|
753
815
|
import { DELIVERIES_ROOM } from '../src/webhooks'
|
|
754
816
|
`:``}${f?`import * as digestJob from './digest'
|
|
755
817
|
import type { DigestSender } from './digest'
|
|
756
|
-
`:``}${p?`import * as articleSearch from './search'\nimport type { ${
|
|
818
|
+
`:``}${p?`import * as articleSearch from './search'\nimport type { ${v?``:`Embedder, `}VectorIndex } from './search'\n`:``}${g?`import * as billingStore from './billing'
|
|
819
|
+
`:``}${m?`import * as orderStore from './checkout'
|
|
820
|
+
import type { ReceiptSender } from './checkout'
|
|
821
|
+
import { ORDER_ID, orderRoom } from '../src/checkout'
|
|
822
|
+
`:``}${h?`import * as newsletterStore from './newsletter'
|
|
823
|
+
import type { NewsletterBatch, NewsletterQueue } from './newsletter'
|
|
824
|
+
import { ISSUE_ID, issueRoom } from '../src/newsletter'
|
|
825
|
+
`:``}${t?`
|
|
757
826
|
// The Durable Object class behind every room. wrangler.jsonc binds it as ROOMS, and it must be
|
|
758
827
|
// exported from the Worker's main module.
|
|
759
828
|
export { SyncRoom } from '@cascivo/app/sync-server'
|
|
@@ -774,7 +843,7 @@ export { ImportJob } from './import-job'
|
|
|
774
843
|
* Add bindings (KV, D1, R2, Durable Objects, Workers AI) in wrangler.jsonc and type them
|
|
775
844
|
* here; every handler receives them as \`env\`.
|
|
776
845
|
*/
|
|
777
|
-
${t||
|
|
846
|
+
${t||v||i||a||o||_||b||c||x||d||f||p||m||h?`export interface Env {${v?`
|
|
778
847
|
/** Workers AI, bound in wrangler.jsonc. */
|
|
779
848
|
AI: Ai`:p?`
|
|
780
849
|
/** Workers AI, bound in wrangler.jsonc. */
|
|
@@ -788,37 +857,58 @@ ${t||h||i||a||o||m||_||c||v||d||f||p?`export interface Env {${h?`
|
|
|
788
857
|
USAGE: AnalyticsDataset
|
|
789
858
|
/** Secrets for reading Analytics Engine back (see README). */
|
|
790
859
|
CF_ACCOUNT_ID?: string
|
|
791
|
-
CF_API_TOKEN?: string`:``}${
|
|
860
|
+
CF_API_TOKEN?: string`:``}${_?`
|
|
792
861
|
DB: Database`:``}${u?`
|
|
793
862
|
ASSETS: Assets`:``}${c?`
|
|
794
863
|
LIVE: RoomNamespace<unknown>
|
|
795
|
-
EVENTS: LiveQueue`:``}${
|
|
796
|
-
LIMITER: RateLimiter`:``}${
|
|
864
|
+
EVENTS: LiveQueue`:``}${y?`
|
|
865
|
+
LIMITER: RateLimiter`:``}${b?`
|
|
797
866
|
/** Set in wrangler.jsonc (see README). */
|
|
798
867
|
ACCESS_TEAM_DOMAIN: string
|
|
799
868
|
ACCESS_AUD: string`:``}${d?`
|
|
800
869
|
/** The webhook signing secret: \`wrangler secret put WEBHOOK_SECRET\` (.dev.vars locally). */
|
|
801
|
-
WEBHOOK_SECRET: string`:``}${
|
|
870
|
+
WEBHOOK_SECRET: string`:``}${x||f||m?`\n EMAIL: ${[x?`SignInSender`:``,f?`DigestSender`:``,m?`ReceiptSender`:``].filter(Boolean).join(` & `)}`:``}${x?`
|
|
802
871
|
/** The From address of sign-in emails, set in wrangler.jsonc. */
|
|
803
872
|
AUTH_FROM: string`:``}${f?`
|
|
804
873
|
/** The weekly digest (worker/digest.ts), set in wrangler.jsonc. */
|
|
805
874
|
DIGEST_TO: string
|
|
806
875
|
DIGEST_FROM: string
|
|
807
|
-
APP_URL: string`:``}
|
|
876
|
+
APP_URL: string`:``}${m?`
|
|
877
|
+
/** The From address of receipts, set in wrangler.jsonc. */
|
|
878
|
+
RECEIPT_FROM: string
|
|
879
|
+
/** Stripe secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
|
|
880
|
+
STRIPE_SECRET_KEY?: string
|
|
881
|
+
STRIPE_WEBHOOK_SECRET?: string`:``}${h?`
|
|
882
|
+
NEWSLETTER: NewsletterQueue
|
|
883
|
+
/** The newsletter (worker/newsletter.ts), set in wrangler.jsonc. */
|
|
884
|
+
AWS_REGION: string
|
|
885
|
+
NEWSLETTER_FROM: string
|
|
886
|
+
SNS_TOPIC_ARN: string
|
|
887
|
+
/** Secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
|
|
888
|
+
AWS_ACCESS_KEY_ID?: string
|
|
889
|
+
AWS_SECRET_ACCESS_KEY?: string
|
|
890
|
+
NEWSLETTER_KEY?: string`:``}
|
|
808
891
|
}`:`// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- bindings are added as members
|
|
809
892
|
export interface Env {}`}
|
|
810
893
|
|
|
811
894
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
812
|
-
${
|
|
895
|
+
${y?`
|
|
813
896
|
/**
|
|
814
897
|
* Requests that each count against LIMITER:
|
|
815
|
-
${[
|
|
898
|
+
${[x?`a sign-in email`:``,f?`a digest sent now (it starts a browser)`:``,p?`indexing the articles (it embeds every one)`:``,m?`a checkout started (it creates a Stripe session and an order)`:``,g?`a subscription checkout or billing portal opened`:``,h?`a newsletter sign-up (it sends an email), and each use of the newsletter key`:``,i?`a new upload (not each part of one)`:``,a?`an export`:``,u?`a published page`:``].filter(Boolean).map(e=>` * - ${e}`).join(`
|
|
816
899
|
`)}
|
|
817
900
|
*/
|
|
818
901
|
function countsAgainstLimit(request: Request): boolean {
|
|
819
902
|
const url = new URL(request.url)${p?`
|
|
820
903
|
if (url.pathname === '/api/search/index') return request.method === 'POST'`:``}${f?`
|
|
821
|
-
if (url.pathname === '/api/digest/run') return request.method === 'POST'`:``}${
|
|
904
|
+
if (url.pathname === '/api/digest/run') return request.method === 'POST'`:``}${m?`
|
|
905
|
+
if (url.pathname === '/api/checkout') return request.method === 'POST'`:``}${g?`
|
|
906
|
+
if (url.pathname === '/api/billing/subscribe' || url.pathname === '/api/billing/portal') {
|
|
907
|
+
return request.method === 'POST'
|
|
908
|
+
}`:``}${h?`
|
|
909
|
+
if (/^\\/api\\/newsletter\\/(subscribe|overview|preview|issues)$/.test(url.pathname)) {
|
|
910
|
+
return request.method === 'POST'
|
|
911
|
+
}`:``}${x?`
|
|
822
912
|
if (url.pathname === '/api/auth/start') return request.method === 'POST'`:``}${u?`
|
|
823
913
|
if (url.pathname === '/api/pages') return request.method === 'POST'`:``}${i?`
|
|
824
914
|
if (url.pathname === uploads.path) {
|
|
@@ -865,7 +955,24 @@ const handleApi = createHandler<typeof api, Env>(api, {
|
|
|
865
955
|
listDeliveries: ({ env }) => webhookStore.listDeliveries(env.DB),
|
|
866
956
|
sendTestDelivery: ({ request, env }) => webhookStore.sendTestDelivery(request.url, env),`:``}${p?`
|
|
867
957
|
search: ({ body, env }) => articleSearch.search(env, body.q),
|
|
868
|
-
indexArticles: ({ env }) => articleSearch.indexArticles(env),`:``}${
|
|
958
|
+
indexArticles: ({ env }) => articleSearch.indexArticles(env),`:``}${m?`
|
|
959
|
+
startCheckout: ({ request, env }) => orderStore.startCheckout(env, new URL(request.url).origin),
|
|
960
|
+
getOrder: ({ params, request, env }) =>
|
|
961
|
+
orderStore.getOrder(env, params.id, new URL(request.url).origin),`:``}${g?`
|
|
962
|
+
getBilling: ({ request, env }) => billingStore.getBilling(env, request),
|
|
963
|
+
startSubscription: ({ request, env }) =>
|
|
964
|
+
billingStore.startSubscription(env, request, new URL(request.url).origin),
|
|
965
|
+
syncBilling: ({ body, request, env }) => billingStore.syncBilling(env, request, body.sessionId),
|
|
966
|
+
openBillingPortal: ({ request, env }) =>
|
|
967
|
+
billingStore.openPortal(env, request, new URL(request.url).origin),`:``}${h?`
|
|
968
|
+
subscribe: ({ body, request, env }) =>
|
|
969
|
+
newsletterStore.subscribe(env, body.email, new URL(request.url).origin),
|
|
970
|
+
confirmSubscription: ({ body, env }) => newsletterStore.confirm(env, body.token),
|
|
971
|
+
newsletterOverview: ({ body, env }) => newsletterStore.overview(env, body.key),
|
|
972
|
+
previewIssue: ({ body, request, env }) =>
|
|
973
|
+
newsletterStore.preview(env, body, new URL(request.url).origin),
|
|
974
|
+
sendIssue: ({ body, request, env }) =>
|
|
975
|
+
newsletterStore.sendIssue(env, body, new URL(request.url).origin),`:``}${f?`
|
|
869
976
|
digestRuns: ({ env }) => digestJob.listRuns(env.DB),
|
|
870
977
|
runDigest: ({ request, env }) =>
|
|
871
978
|
digestJob.runDigest(
|
|
@@ -897,9 +1004,9 @@ async function handleAndRecord(request: Request, env: Env): Promise<Response> {
|
|
|
897
1004
|
return response
|
|
898
1005
|
}
|
|
899
1006
|
`:``}
|
|
900
|
-
// wrangler.jsonc routes only ${
|
|
1007
|
+
// wrangler.jsonc routes only ${v?`/api/* and /agents/*`:`/api/*`} here; everything else is a static asset or index.html.
|
|
901
1008
|
export default {
|
|
902
|
-
${
|
|
1009
|
+
${C?` ${w?`async `:``}fetch(request: Request, env: Env): Promise<Response>${w?``:` | Response`} {${b?`
|
|
903
1010
|
// Cloudflare Access is in front of the app (README); this refuses a request that came
|
|
904
1011
|
// around it (straight to *.workers.dev, say). \`vite dev\` has no Access, so not there.
|
|
905
1012
|
if (!import.meta.env.DEV) {
|
|
@@ -911,37 +1018,37 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
|
|
|
911
1018
|
} catch (error) {
|
|
912
1019
|
return guardResponse(error)
|
|
913
1020
|
}
|
|
914
|
-
}`:``}${
|
|
1021
|
+
}`:``}${y?`
|
|
915
1022
|
if (countsAgainstLimit(request)) {
|
|
916
1023
|
try {
|
|
917
1024
|
await rateLimit(env.LIMITER, clientIp(request))
|
|
918
1025
|
} catch (error) {
|
|
919
1026
|
return guardResponse(error)
|
|
920
1027
|
}
|
|
921
|
-
}`:``}${
|
|
1028
|
+
}`:``}${x?`
|
|
922
1029
|
// Sign-in links and sessions: /api/auth/* is answered here (worker/auth.ts sends mail).
|
|
923
1030
|
const signIn = await handleAuth(env.DB, {
|
|
924
1031
|
sendLink: (email, url) => sendSignInLink(env.EMAIL, env.AUTH_FROM, email, url),
|
|
925
1032
|
exposeLink: import.meta.env.DEV,
|
|
926
1033
|
})(request)
|
|
927
1034
|
if (signIn) return signIn
|
|
928
|
-
// Every other API write needs a signed-in user; reads stay public.${
|
|
929
|
-
// Webhooks carry a signature instead of a session, and are checked by it.`:``}
|
|
930
|
-
|
|
1035
|
+
// Every other API write needs a signed-in user; reads stay public.${E.length>0?`
|
|
1036
|
+
// Webhooks carry a signature instead of a session, and are checked by it.`:``}${D.length>0?`
|
|
1037
|
+
// Newsletter readers sign up, confirm and unsubscribe without an account.`:``}
|
|
1038
|
+
if (${O.length>0?`
|
|
931
1039
|
request.method !== 'GET' &&
|
|
932
|
-
request.method !== 'HEAD'
|
|
933
|
-
!new URL(request.url).pathname.startsWith('/api/webhooks/')
|
|
1040
|
+
request.method !== 'HEAD' &&${O.map(e=>`\n !new URL(request.url).pathname.startsWith('${e}')`).join(` &&`)}
|
|
934
1041
|
`:`request.method !== 'GET' && request.method !== 'HEAD'`}) {
|
|
935
1042
|
try {
|
|
936
1043
|
await requireUser(env.DB, request)
|
|
937
1044
|
} catch (error) {
|
|
938
1045
|
return guardResponse(error)
|
|
939
1046
|
}
|
|
940
|
-
}`:``}${
|
|
1047
|
+
}`:``}${v?`
|
|
941
1048
|
// /agents/<agent>/<conversation>: the WebSocket ${[n?`useAgent()`:``,l?`VoiceClient`:``].filter(Boolean).join(` or `)} opens.
|
|
942
1049
|
const agent = await routeAgentRequest(request, env)
|
|
943
1050
|
if (agent) return agent`:``}${i?`
|
|
944
|
-
// Uploads into R2 and the files they stored.${
|
|
1051
|
+
// Uploads into R2 and the files they stored.${b?``:` Anyone can upload: see README.`}
|
|
945
1052
|
const upload = await handleUploads(uploads, env.FILES, { images: env.IMAGES })(request)
|
|
946
1053
|
if (upload) return upload`:``}${a?`
|
|
947
1054
|
// /api/export?page=/report&format=pdf — each export starts a browser.
|
|
@@ -988,11 +1095,43 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
|
|
|
988
1095
|
// New deliveries, pushed to the /webhooks page: it may watch, never write.
|
|
989
1096
|
if (path === '/api/webhooks/live') {
|
|
990
1097
|
return roomResponse(request, env.ROOMS, DELIVERIES_ROOM, { readOnly: true })
|
|
991
|
-
}`:``}${
|
|
992
|
-
const
|
|
1098
|
+
}`:``}${m?`
|
|
1099
|
+
const checkoutPath = new URL(request.url).pathname
|
|
1100
|
+
// Stripe's webhook (worker/checkout.ts); a bad signature is a 401.
|
|
1101
|
+
if (checkoutPath === orderStore.STRIPE_WEBHOOK_PATH && request.method === 'POST') {
|
|
1102
|
+
try {
|
|
1103
|
+
return await orderStore.receiveStripe(request, env${g?`, billingStore.billingHooks(env)`:``})
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
return guardResponse(error)
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// An order's page watches its room for what Stripe reports: it may watch, never write.
|
|
1109
|
+
const orderLive = /^\\/api\\/orders\\/([^/]+)\\/live$/.exec(checkoutPath)
|
|
1110
|
+
if (orderLive && ORDER_ID.test(orderLive[1]!)) {
|
|
1111
|
+
return roomResponse(request, env.ROOMS, orderRoom(orderLive[1]!), { readOnly: true })
|
|
1112
|
+
}`:``}${h?`
|
|
1113
|
+
const newsletterPath = new URL(request.url).pathname
|
|
1114
|
+
// One-click unsubscribe (RFC 8058) and the unsubscribe page's button.
|
|
1115
|
+
if (newsletterPath === '/api/newsletter/unsubscribe' && request.method === 'POST') {
|
|
1116
|
+
return newsletterStore.unsubscribe(request, env)
|
|
1117
|
+
}
|
|
1118
|
+
// SES bounces and complaints, delivered by SNS (worker/newsletter.ts); verified by signature.
|
|
1119
|
+
if (newsletterPath === '/api/sns/ses' && request.method === 'POST') {
|
|
1120
|
+
try {
|
|
1121
|
+
return await newsletterStore.receiveFeedback(request, env)
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
return guardResponse(error)
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// An issue's sending progress, for the composer: it may watch, never write.
|
|
1127
|
+
const issueLive = /^\\/api\\/newsletter\\/issues\\/([^/]+)\\/live$/.exec(newsletterPath)
|
|
1128
|
+
if (issueLive && ISSUE_ID.test(issueLive[1]!)) {
|
|
1129
|
+
return roomResponse(request, env.ROOMS, issueRoom(issueLive[1]!), { readOnly: true })
|
|
1130
|
+
}`:``}${X(e,`board`)||X(e,`notes`)?`
|
|
1131
|
+
const room = /^\\/api\\/rooms\\/([^/]+)$/.exec(new URL(request.url).pathname)${T.length>0?`
|
|
993
1132
|
// Rooms only the server writes are watched read-only at their own routes, never opened
|
|
994
|
-
// here: ${[r?`a job's progress`:``,d?`webhook deliveries`:``].filter(Boolean).join(`, `)}.
|
|
995
|
-
if (room && !/^(${
|
|
1133
|
+
// here: ${[r?`a job's progress`:``,d?`webhook deliveries`:``,m?`orders`:``,h?`newsletter issues`:``].filter(Boolean).join(`, `)}.
|
|
1134
|
+
if (room && !/^(${T.join(`|`)})/.test(room[1]!)) {
|
|
996
1135
|
return roomResponse(request, env.ROOMS, room[1]!)
|
|
997
1136
|
}`:`
|
|
998
1137
|
if (room) return roomResponse(request, env.ROOMS, room[1]!)`}`:``}${r?`
|
|
@@ -1006,7 +1145,19 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
|
|
|
1006
1145
|
// The Cron Trigger in wrangler.jsonc: the weekly digest, recorded whatever happens.
|
|
1007
1146
|
async scheduled(_event: unknown, env: Env): Promise<void> {
|
|
1008
1147
|
await digestJob.runDigest(env, () => puppeteer.launch(env.BROWSER), 'cron')
|
|
1009
|
-
},`:``}${c?`
|
|
1148
|
+
},`:``}${c&&h?`
|
|
1149
|
+
// Two queues, one handler: each batch says which queue it came from. A throw retries it.
|
|
1150
|
+
async queue(batch: LiveBatch & NewsletterBatch & { queue: string }, env: Env): Promise<void> {
|
|
1151
|
+
// NEWSLETTER: one message of readers at a time, through SES (worker/newsletter.ts).
|
|
1152
|
+
if (batch.queue === '${Y(e)}') return newsletterStore.deliver(env, batch)
|
|
1153
|
+
// EVENTS: into the dashboard's room.
|
|
1154
|
+
await recordLive(
|
|
1155
|
+
ops,
|
|
1156
|
+
env.LIVE,
|
|
1157
|
+
OPS_ROOM,
|
|
1158
|
+
batch.messages.map((message) => message.body),
|
|
1159
|
+
)
|
|
1160
|
+
},`:c?`
|
|
1010
1161
|
// The EVENTS queue, a batch at a time, into the dashboard's room. A throw retries the batch.
|
|
1011
1162
|
async queue(batch: LiveBatch, env: Env): Promise<void> {
|
|
1012
1163
|
await recordLive(
|
|
@@ -1015,9 +1166,14 @@ ${b?` ${x?`async `:``}fetch(request: Request, env: Env): Promise<Response>${x?`
|
|
|
1015
1166
|
OPS_ROOM,
|
|
1016
1167
|
batch.messages.map((message) => message.body),
|
|
1017
1168
|
)
|
|
1169
|
+
},`:h?`
|
|
1170
|
+
// The NEWSLETTER queue: one message of readers at a time, through SES
|
|
1171
|
+
// (worker/newsletter.ts). A throw retries the message; readers already sent are skipped.
|
|
1172
|
+
async queue(batch: NewsletterBatch, env: Env): Promise<void> {
|
|
1173
|
+
await newsletterStore.deliver(env, batch)
|
|
1018
1174
|
},`:``}
|
|
1019
1175
|
}
|
|
1020
|
-
`}function
|
|
1176
|
+
`}function Ne(){return`import { createClient } from '@cascivo/app/api'
|
|
1021
1177
|
import { signal } from '@cascivo/react'
|
|
1022
1178
|
import { api } from './api'
|
|
1023
1179
|
import type { Tick } from './api'
|
|
@@ -1060,7 +1216,7 @@ export async function connect(): Promise<void> {
|
|
|
1060
1216
|
export function disconnect(): void {
|
|
1061
1217
|
controller?.abort()
|
|
1062
1218
|
}
|
|
1063
|
-
`}function
|
|
1219
|
+
`}function Pe(){return`import {
|
|
1064
1220
|
Badge,
|
|
1065
1221
|
Button,
|
|
1066
1222
|
Card,
|
|
@@ -1115,18 +1271,18 @@ export function LiveCard() {
|
|
|
1115
1271
|
</Card>
|
|
1116
1272
|
)
|
|
1117
1273
|
}
|
|
1118
|
-
`}function
|
|
1274
|
+
`}function Fe(e,t){return t===0?`/`:`/${e.key}`}function Ie(e,t){return t===0?`index.tsx`:`${e.key}.tsx`}function Le(){return`import { createRouter } from '@cascivo/app'
|
|
1119
1275
|
import { notFound, routes } from './routes.gen'
|
|
1120
1276
|
|
|
1121
1277
|
// \`routes.gen.ts\` is written by \`cascivoRoutes()\` (vite.config.ts) from \`src/routes/\`:
|
|
1122
1278
|
// add, rename or delete a file there and the route table follows.
|
|
1123
1279
|
export const router = createRouter({ routes, notFound })
|
|
1124
|
-
`}function
|
|
1280
|
+
`}function Re(e,t){let n=e.map((e,t)=>({label:e.label,href:Fe(e,t)}));X(t,`agent`)&&n.push({label:`Assistant`,href:`/assistant`}),X(t,`board`)&&n.push({label:`Board`,href:`/board`}),X(t,`notes`)&&n.push({label:`Notes`,href:`/notes`}),X(t,`import`)&&n.push({label:`Import`,href:`/import`}),X(t,`files`)&&n.push({label:`Files`,href:`/files`}),X(t,`export`)&&n.push({label:`Report`,href:`/report`}),X(t,`usage`)&&n.push({label:`Usage`,href:`/usage`}),X(t,`crud`)&&n.push({label:`Customers`,href:`/customers`}),X(t,`live`)&&n.push({label:`Ops`,href:`/ops`}),X(t,`voice`)&&n.push({label:`Voice`,href:`/voice`}),X(t,`publish`)&&n.push({label:`Publish`,href:`/publish`}),X(t,`webhooks`)&&n.push({label:`Webhooks`,href:`/webhooks`}),X(t,`digest`)&&n.push({label:`Digest`,href:`/digest`}),X(t,`search`)&&n.push({label:`Search`,href:`/search`}),X(t,`checkout`)&&n.push({label:`Checkout`,href:`/checkout`}),J(t)&&n.push({label:`Billing`,href:`/billing`}),X(t,`newsletter`)&&(n.push({label:`Newsletter`,href:`/newsletter`}),n.push({label:`Send newsletter`,href:`/newsletter/send`})),t.auth===`email`&&n.push({label:`Account`,href:`/account`});let r=n.map(e=>` {
|
|
1125
1281
|
label: '${e.label.replace(/'/g,`\\'`)}',
|
|
1126
1282
|
href: '${e.href}',
|
|
1127
1283
|
active: path === '${e.href}',
|
|
1128
1284
|
},`).join(`
|
|
1129
|
-
`),i=
|
|
1285
|
+
`),i=X(t,`export`);return`import { RouterView } from '@cascivo/app'
|
|
1130
1286
|
${i?`import { isExporting } from '@cascivo/app/export'
|
|
1131
1287
|
`:``}import { Spinner, useSignals, type SideNavItem } from '@cascivo/react'
|
|
1132
1288
|
import { router } from './router'
|
|
@@ -1157,7 +1313,7 @@ ${i?`
|
|
|
1157
1313
|
</Shell>
|
|
1158
1314
|
)
|
|
1159
1315
|
}
|
|
1160
|
-
`}function
|
|
1316
|
+
`}function ze(){return`import React from 'react'
|
|
1161
1317
|
import ReactDOM from 'react-dom/client'
|
|
1162
1318
|
import { setLinkComponent } from '@cascivo/react'
|
|
1163
1319
|
import App from './App'
|
|
@@ -1174,7 +1330,7 @@ if (root) {
|
|
|
1174
1330
|
</React.StrictMode>,
|
|
1175
1331
|
)
|
|
1176
1332
|
}
|
|
1177
|
-
`}function
|
|
1333
|
+
`}function Be(e){return`import { Flex, Heading, Text } from '@cascivo/react'
|
|
1178
1334
|
import { LiveCard } from '../LiveCard'
|
|
1179
1335
|
|
|
1180
1336
|
export default function ${e.component}() {
|
|
@@ -1190,12 +1346,12 @@ export default function ${e.component}() {
|
|
|
1190
1346
|
</Flex>
|
|
1191
1347
|
)
|
|
1192
1348
|
}
|
|
1193
|
-
`}function
|
|
1349
|
+
`}function Ve(e,t){return L(e).replace(`export function ${e.component}()`,`export default function ${e.component}()`).replace(`src/sections/${e.component}.tsx`,`src/routes/${Ie(e,t)}`)}function He(){return`import { EmptyState } from '@cascivo/react'
|
|
1194
1350
|
|
|
1195
1351
|
export default function NotFound() {
|
|
1196
1352
|
return <EmptyState title="Page not found" description="There is nothing at this address." />
|
|
1197
1353
|
}
|
|
1198
|
-
`}function
|
|
1354
|
+
`}function Ue(){return`import { connectRoom } from '@cascivo/app/sync'
|
|
1199
1355
|
|
|
1200
1356
|
export interface Note {
|
|
1201
1357
|
text: string
|
|
@@ -1246,7 +1402,7 @@ export function addNote(): void {
|
|
|
1246
1402
|
const offset = Object.keys(notes.value).length * 24
|
|
1247
1403
|
notes.set(crypto.randomUUID(), { text: '', x: 24 + (offset % 240), y: 24 + (offset % 160) })
|
|
1248
1404
|
}
|
|
1249
|
-
`}function
|
|
1405
|
+
`}function We(){return`import type { PointerEvent } from 'react'
|
|
1250
1406
|
import {
|
|
1251
1407
|
Badge,
|
|
1252
1408
|
Button,
|
|
@@ -1370,7 +1526,7 @@ export default function Board() {
|
|
|
1370
1526
|
</Flex>
|
|
1371
1527
|
)
|
|
1372
1528
|
}
|
|
1373
|
-
`}function
|
|
1529
|
+
`}function Ge(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
|
|
1374
1530
|
@layer cascivo.example {
|
|
1375
1531
|
.board {
|
|
1376
1532
|
position: relative;
|
|
@@ -1410,7 +1566,7 @@ export default function Board() {
|
|
|
1410
1566
|
pointer-events: none;
|
|
1411
1567
|
}
|
|
1412
1568
|
}
|
|
1413
|
-
`}function
|
|
1569
|
+
`}function Ke(){return`import type { ViewConfig } from '@cascivo/render'
|
|
1414
1570
|
import { validateView } from '@cascivo/render/validate'
|
|
1415
1571
|
|
|
1416
1572
|
/**
|
|
@@ -1436,7 +1592,7 @@ export function checkView(title: unknown, view: unknown): CheckedView {
|
|
|
1436
1592
|
// validateView has checked the whole shape, so the cast states a proven fact.
|
|
1437
1593
|
return { title, view: view as ViewConfig }
|
|
1438
1594
|
}
|
|
1439
|
-
`}function
|
|
1595
|
+
`}function qe(){return`import { AIChatAgent } from '@cloudflare/ai-chat'
|
|
1440
1596
|
import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'
|
|
1441
1597
|
import type { LanguageModel } from 'ai'
|
|
1442
1598
|
import { createWorkersAI } from 'workers-ai-provider'
|
|
@@ -1491,7 +1647,7 @@ export class Assistant extends AIChatAgent<Env> {
|
|
|
1491
1647
|
return result.toUIMessageStreamResponse()
|
|
1492
1648
|
}
|
|
1493
1649
|
}
|
|
1494
|
-
`}function
|
|
1650
|
+
`}function Je(){return`import { simulateReadableStream } from 'ai'
|
|
1495
1651
|
import { MockLanguageModelV4 } from 'ai/test'
|
|
1496
1652
|
|
|
1497
1653
|
/**
|
|
@@ -1593,7 +1749,7 @@ export function scriptedModel(): MockLanguageModelV4 {
|
|
|
1593
1749
|
},
|
|
1594
1750
|
})
|
|
1595
1751
|
}
|
|
1596
|
-
`}function
|
|
1752
|
+
`}function Ye(){return`import type { FormEvent } from 'react'
|
|
1597
1753
|
import { CascivoView } from '@cascivo/render'
|
|
1598
1754
|
import {
|
|
1599
1755
|
Badge,
|
|
@@ -1723,7 +1879,7 @@ export default function Assistant() {
|
|
|
1723
1879
|
</Flex>
|
|
1724
1880
|
)
|
|
1725
1881
|
}
|
|
1726
|
-
`}function
|
|
1882
|
+
`}function Xe(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
|
|
1727
1883
|
@layer cascivo.example {
|
|
1728
1884
|
.messages {
|
|
1729
1885
|
display: flex;
|
|
@@ -1756,7 +1912,7 @@ export default function Assistant() {
|
|
|
1756
1912
|
flex: 1;
|
|
1757
1913
|
}
|
|
1758
1914
|
}
|
|
1759
|
-
`}function
|
|
1915
|
+
`}function Ze(){return`import { connectRoom } from '@cascivo/app/sync'
|
|
1760
1916
|
import { indexedDBDriver } from '@cascivo/storage'
|
|
1761
1917
|
|
|
1762
1918
|
export interface Note {
|
|
@@ -1817,7 +1973,7 @@ export function addNote(): void {
|
|
|
1817
1973
|
export function editNote(id: string, note: Note, change: Partial<Note>): void {
|
|
1818
1974
|
notes.set(id, { ...note, ...change, updatedAt: Date.now() })
|
|
1819
1975
|
}
|
|
1820
|
-
`}function
|
|
1976
|
+
`}function Qe(){return`import {
|
|
1821
1977
|
Badge,
|
|
1822
1978
|
Button,
|
|
1823
1979
|
Card,
|
|
@@ -1893,7 +2049,7 @@ export default function Notes() {
|
|
|
1893
2049
|
</Flex>
|
|
1894
2050
|
)
|
|
1895
2051
|
}
|
|
1896
|
-
`}function
|
|
2052
|
+
`}function $e(){return`import { defineJob } from '@cascivo/app/jobs'
|
|
1897
2053
|
|
|
1898
2054
|
/**
|
|
1899
2055
|
* The CSV import job, shared by the Worker (which runs it) and the page (which watches it).
|
|
@@ -1953,7 +2109,7 @@ export function parseStarted(raw: unknown): { id: string } {
|
|
|
1953
2109
|
}
|
|
1954
2110
|
throw new Error('Malformed job id')
|
|
1955
2111
|
}
|
|
1956
|
-
`}function
|
|
2112
|
+
`}function et(){return`import { createClient } from '@cascivo/app/api'
|
|
1957
2113
|
import { watchJob } from '@cascivo/app/jobs'
|
|
1958
2114
|
import type { WatchedJob } from '@cascivo/app/jobs'
|
|
1959
2115
|
import { signal } from '@cascivo/react'
|
|
@@ -1989,7 +2145,7 @@ export async function startImport(csv: string): Promise<void> {
|
|
|
1989
2145
|
starting.value = false
|
|
1990
2146
|
}
|
|
1991
2147
|
}
|
|
1992
|
-
`}function
|
|
2148
|
+
`}function tt(){return`import { jobReporter } from '@cascivo/app/jobs-server'
|
|
1993
2149
|
import { WorkflowEntrypoint } from 'cloudflare:workers'
|
|
1994
2150
|
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'
|
|
1995
2151
|
import { importJob } from '../src/import-job'
|
|
@@ -2075,7 +2231,7 @@ export class ImportJob extends WorkflowEntrypoint<Env, { csv: string }> {
|
|
|
2075
2231
|
}
|
|
2076
2232
|
}
|
|
2077
2233
|
}
|
|
2078
|
-
`}function
|
|
2234
|
+
`}function nt(){return`import type { Step } from '@cascivo/react'
|
|
2079
2235
|
import {
|
|
2080
2236
|
Alert,
|
|
2081
2237
|
Button,
|
|
@@ -2187,7 +2343,7 @@ export default function Import() {
|
|
|
2187
2343
|
</Flex>
|
|
2188
2344
|
)
|
|
2189
2345
|
}
|
|
2190
|
-
`}function
|
|
2346
|
+
`}function rt(){return`import { defineUploads } from '@cascivo/app/uploads'
|
|
2191
2347
|
|
|
2192
2348
|
/**
|
|
2193
2349
|
* What the app accepts, shared by the page (which checks first) and the Worker (which
|
|
@@ -2199,7 +2355,7 @@ export const uploads = defineUploads({
|
|
|
2199
2355
|
maxBytes: 50 * 1024 * 1024,
|
|
2200
2356
|
types: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],
|
|
2201
2357
|
})
|
|
2202
|
-
`}function
|
|
2358
|
+
`}function it(){return`import { createClient } from '@cascivo/app/api'
|
|
2203
2359
|
import { startUpload } from '@cascivo/app/uploads'
|
|
2204
2360
|
import type { StoredFile, Upload } from '@cascivo/app/uploads'
|
|
2205
2361
|
import { signal } from '@cascivo/react'
|
|
@@ -2236,7 +2392,7 @@ export function removeUpload(id: string): void {
|
|
|
2236
2392
|
upload?.abort()
|
|
2237
2393
|
inFlight.value = inFlight.value.filter((u) => u.id !== id)
|
|
2238
2394
|
}
|
|
2239
|
-
`}function
|
|
2395
|
+
`}function at(){return`import type { UploaderFile } from '@cascivo/react'
|
|
2240
2396
|
import {
|
|
2241
2397
|
Card,
|
|
2242
2398
|
CardContent,
|
|
@@ -2317,7 +2473,7 @@ export default function Files() {
|
|
|
2317
2473
|
</Flex>
|
|
2318
2474
|
)
|
|
2319
2475
|
}
|
|
2320
|
-
`}function
|
|
2476
|
+
`}function ot(){return`/* Your app's own styles live in the cascivo.example layer (declared in index.html). */
|
|
2321
2477
|
@layer cascivo.example {
|
|
2322
2478
|
.grid {
|
|
2323
2479
|
display: grid;
|
|
@@ -2333,7 +2489,7 @@ export default function Files() {
|
|
|
2333
2489
|
background: var(--cascivo-color-bg-subtle);
|
|
2334
2490
|
}
|
|
2335
2491
|
}
|
|
2336
|
-
`}function
|
|
2492
|
+
`}function st(){return`import { exportUrl, isExporting } from '@cascivo/app/export'
|
|
2337
2493
|
import type { Column } from '@cascivo/react'
|
|
2338
2494
|
import { Badge, Button, Card, CardContent, DataTable, Flex, Heading, Text } from '@cascivo/react'
|
|
2339
2495
|
|
|
@@ -2401,7 +2557,7 @@ export default function Report() {
|
|
|
2401
2557
|
</Flex>
|
|
2402
2558
|
)
|
|
2403
2559
|
}
|
|
2404
|
-
`}function
|
|
2560
|
+
`}function ct(){return`import { defineMetrics } from '@cascivo/app/analytics'
|
|
2405
2561
|
|
|
2406
2562
|
/**
|
|
2407
2563
|
* What the Worker records for every API request, in Analytics Engine (worker/index.ts writes
|
|
@@ -2462,7 +2618,7 @@ export function parseUsageReport(raw: unknown): UsageReport {
|
|
|
2462
2618
|
totals: { requests: num(t['requests']), errors: num(t['errors']), avgMs: num(t['avgMs']) },
|
|
2463
2619
|
}
|
|
2464
2620
|
}
|
|
2465
|
-
`}function
|
|
2621
|
+
`}function lt(){return`import { numberField, queryAnalytics, stringField } from '@cascivo/app/analytics'
|
|
2466
2622
|
import type { AnalyticsCredentials } from '@cascivo/app/analytics'
|
|
2467
2623
|
import { usageMetrics } from '../src/usage'
|
|
2468
2624
|
import type { UsageReport } from '../src/usage'
|
|
@@ -2537,7 +2693,7 @@ export async function usageReport(credentials: AnalyticsCredentials | null): Pro
|
|
|
2537
2693
|
},
|
|
2538
2694
|
}
|
|
2539
2695
|
}
|
|
2540
|
-
`}function
|
|
2696
|
+
`}function ut(){return`import { BarChart, Kpi, LineChart } from '@cascivo/charts'
|
|
2541
2697
|
import { createClient } from '@cascivo/app/api'
|
|
2542
2698
|
import {
|
|
2543
2699
|
Button,
|
|
@@ -2625,7 +2781,7 @@ export default function Usage() {
|
|
|
2625
2781
|
</Flex>
|
|
2626
2782
|
)
|
|
2627
2783
|
}
|
|
2628
|
-
`}function
|
|
2784
|
+
`}function dt(){return`/**
|
|
2629
2785
|
* /search, shared by the Worker (which searches) and the page (which asks). \`semantic\` is
|
|
2630
2786
|
* Vectorize over Workers AI embeddings; \`keyword\` is the \`vite dev\` stand-in, SQLite's
|
|
2631
2787
|
* full-text search, because neither runs locally.
|
|
@@ -2689,7 +2845,7 @@ export function parseIndexed(raw: unknown): { indexed: number } {
|
|
|
2689
2845
|
}
|
|
2690
2846
|
throw new Error('Malformed reply')
|
|
2691
2847
|
}
|
|
2692
|
-
`}function
|
|
2848
|
+
`}function ft(){return`import { HttpError } from '@cascivo/app/api'
|
|
2693
2849
|
import { migrate, queryRows } from '@cascivo/app/db'
|
|
2694
2850
|
import type { Database } from '@cascivo/app/db'
|
|
2695
2851
|
import type { SearchHit, SearchResult } from '../src/search'
|
|
@@ -2835,7 +2991,7 @@ export async function indexArticles(env: SearchEnv): Promise<{ indexed: number }
|
|
|
2835
2991
|
}
|
|
2836
2992
|
return { indexed: (await env.ARTICLES_INDEX.describe()).vectorCount }
|
|
2837
2993
|
}
|
|
2838
|
-
`}function
|
|
2994
|
+
`}function pt(){return`/**
|
|
2839
2995
|
* The help articles /search looks through, seeded into D1 on the first query. Replace them with
|
|
2840
2996
|
* your own content: anything with an id, a title and a body can be indexed the same way.
|
|
2841
2997
|
*/
|
|
@@ -2961,7 +3117,7 @@ export const articles: { id: string; title: string; body: string }[] = [
|
|
|
2961
3117
|
body: 'Upload a CSV under Settings, Data, Import. Map each column to a field, preview the first rows, then run the import; errors are listed per row.',
|
|
2962
3118
|
},
|
|
2963
3119
|
]
|
|
2964
|
-
`}function
|
|
3120
|
+
`}function mt(){return`import { createClient } from '@cascivo/app/api'
|
|
2965
3121
|
import {
|
|
2966
3122
|
Alert,
|
|
2967
3123
|
Badge,
|
|
@@ -3089,7 +3245,7 @@ export default function SearchPage() {
|
|
|
3089
3245
|
</Flex>
|
|
3090
3246
|
)
|
|
3091
3247
|
}
|
|
3092
|
-
`}function
|
|
3248
|
+
`}function ht(){return`/**
|
|
3093
3249
|
* The weekly digest's runs, shared by the Worker (which records each one) and the /digest page
|
|
3094
3250
|
* (which lists them). A run happens on the Cron Trigger in wrangler.jsonc, or from "Send now".
|
|
3095
3251
|
*/
|
|
@@ -3136,7 +3292,7 @@ export function parseDigestRuns(raw: unknown): DigestRun[] {
|
|
|
3136
3292
|
if (!Array.isArray(raw)) throw new Error('Expected a list of runs')
|
|
3137
3293
|
return raw.map(parseDigestRun)
|
|
3138
3294
|
}
|
|
3139
|
-
`}function
|
|
3295
|
+
`}function gt(){return`import { migrate, queryRows } from '@cascivo/app/db'
|
|
3140
3296
|
import type { Database } from '@cascivo/app/db'
|
|
3141
3297
|
import { exportPage } from '@cascivo/app/export'
|
|
3142
3298
|
import type { ExportBrowser } from '@cascivo/app/export'
|
|
@@ -3265,7 +3421,7 @@ export async function listRuns(db: Database): Promise<DigestRun[]> {
|
|
|
3265
3421
|
parseDigestRun,
|
|
3266
3422
|
)
|
|
3267
3423
|
}
|
|
3268
|
-
`}function
|
|
3424
|
+
`}function _t(){return`import { createClient } from '@cascivo/app/api'
|
|
3269
3425
|
import {
|
|
3270
3426
|
Alert,
|
|
3271
3427
|
Badge,
|
|
@@ -3347,7 +3503,7 @@ export default function Digest() {
|
|
|
3347
3503
|
</Flex>
|
|
3348
3504
|
)
|
|
3349
3505
|
}
|
|
3350
|
-
`}function
|
|
3506
|
+
`}function vt(){return`/**
|
|
3351
3507
|
* Webhook deliveries, shared by the Worker (which verifies and stores them) and the
|
|
3352
3508
|
* /webhooks page (which lists them as they arrive).
|
|
3353
3509
|
*/
|
|
@@ -3390,7 +3546,7 @@ export function parseTestResult(raw: unknown): { status: number } {
|
|
|
3390
3546
|
}
|
|
3391
3547
|
throw new Error('Malformed reply')
|
|
3392
3548
|
}
|
|
3393
|
-
`}function
|
|
3549
|
+
`}function yt(){return`import { migrate, queryRows } from '@cascivo/app/db'
|
|
3394
3550
|
import type { Database } from '@cascivo/app/db'
|
|
3395
3551
|
import { verifyWebhook } from '@cascivo/app/guard'
|
|
3396
3552
|
import { writeRoom } from '@cascivo/app/sync-server'
|
|
@@ -3508,7 +3664,7 @@ export async function sendTestDelivery(
|
|
|
3508
3664
|
)
|
|
3509
3665
|
return { status: response.status }
|
|
3510
3666
|
}
|
|
3511
|
-
`}function
|
|
3667
|
+
`}function bt(){return`import { createClient } from '@cascivo/app/api'
|
|
3512
3668
|
import { connectRoom } from '@cascivo/app/sync'
|
|
3513
3669
|
import {
|
|
3514
3670
|
Alert,
|
|
@@ -3614,8 +3770,148 @@ export default function Webhooks() {
|
|
|
3614
3770
|
</Flex>
|
|
3615
3771
|
)
|
|
3616
3772
|
}
|
|
3617
|
-
`}function
|
|
3618
|
-
|
|
3773
|
+
`}function xt(e){return`/**
|
|
3774
|
+
* What /checkout sells and the orders it makes, shared by the Worker (which creates them and
|
|
3775
|
+
* hears from Stripe) and the pages (which show them).
|
|
3776
|
+
*/
|
|
3777
|
+
|
|
3778
|
+
/** The seller, as the receipt names it. */
|
|
3779
|
+
export const SHOP_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
|
|
3780
|
+
|
|
3781
|
+
/**
|
|
3782
|
+
* What you sell. The Worker sends this to Stripe, so a browser cannot change the price; the
|
|
3783
|
+
* page only displays it.
|
|
3784
|
+
*/
|
|
3785
|
+
export const PRODUCT = {
|
|
3786
|
+
name: 'Sticker pack',
|
|
3787
|
+
description: 'Twelve vinyl stickers of your favourite components, shipped worldwide.',
|
|
3788
|
+
/** In the currency's smallest unit: 900 is €9.00. */
|
|
3789
|
+
amount: 900,
|
|
3790
|
+
/** Three-letter ISO code, lowercase. */
|
|
3791
|
+
currency: 'eur',
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
/**
|
|
3795
|
+
* \`pending\` until Stripe confirms the payment. \`failed\` and \`expired\` are final. A paid
|
|
3796
|
+
* order becomes \`refunded\` when all of it is refunded, and \`disputed\` while a chargeback is
|
|
3797
|
+
* open (back to \`paid\` if it is won).
|
|
3798
|
+
*/
|
|
3799
|
+
export type OrderStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'disputed'
|
|
3800
|
+
|
|
3801
|
+
export interface Order {
|
|
3802
|
+
id: string
|
|
3803
|
+
status: OrderStatus
|
|
3804
|
+
/** What Stripe charged, in the currency's smallest unit. */
|
|
3805
|
+
amount: number
|
|
3806
|
+
currency: string
|
|
3807
|
+
/** Refunded so far, in the same unit: part of a paid order, or all of a refunded one. */
|
|
3808
|
+
refundedAmount: number
|
|
3809
|
+
createdAt: string
|
|
3810
|
+
paidAt: string | null
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
/** An order id: a UUID the Worker made. */
|
|
3814
|
+
export const ORDER_ID = /^[0-9a-f-]{36}$/
|
|
3815
|
+
|
|
3816
|
+
/** The room the Worker pushes an order's changes to; its page watches it. */
|
|
3817
|
+
export const orderRoom = (id: string) => \`order-\${id}\`
|
|
3818
|
+
|
|
3819
|
+
/** A price in the currency's smallest unit, for people: 900 eur is €9.00, 900 jpy is ¥900. */
|
|
3820
|
+
export function formatPrice(amount: number, currency: string): string {
|
|
3821
|
+
const format = new Intl.NumberFormat(undefined, { style: 'currency', currency })
|
|
3822
|
+
const digits = format.resolvedOptions().maximumFractionDigits ?? 2
|
|
3823
|
+
return format.format(amount / 10 ** digits)
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
const STATUSES = ['pending', 'paid', 'failed', 'expired', 'refunded', 'disputed']
|
|
3827
|
+
|
|
3828
|
+
export function parseOrder(raw: unknown): Order {
|
|
3829
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
3830
|
+
const { id, status, amount, currency, refundedAmount, createdAt, paidAt } = raw as Record<
|
|
3831
|
+
string,
|
|
3832
|
+
unknown
|
|
3833
|
+
>
|
|
3834
|
+
if (
|
|
3835
|
+
typeof id === 'string' &&
|
|
3836
|
+
typeof status === 'string' &&
|
|
3837
|
+
STATUSES.includes(status) &&
|
|
3838
|
+
typeof amount === 'number' &&
|
|
3839
|
+
typeof currency === 'string' &&
|
|
3840
|
+
typeof refundedAmount === 'number' &&
|
|
3841
|
+
typeof createdAt === 'string' &&
|
|
3842
|
+
(paidAt === null || typeof paidAt === 'string')
|
|
3843
|
+
) {
|
|
3844
|
+
return {
|
|
3845
|
+
id,
|
|
3846
|
+
// Checked against STATUSES just above.
|
|
3847
|
+
status: status as OrderStatus,
|
|
3848
|
+
amount,
|
|
3849
|
+
currency,
|
|
3850
|
+
refundedAmount,
|
|
3851
|
+
createdAt,
|
|
3852
|
+
paidAt,
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
throw new Error('Malformed order')
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
export function parseCheckoutStarted(raw: unknown): { url: string } {
|
|
3860
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
3861
|
+
const { url } = raw as Record<string, unknown>
|
|
3862
|
+
if (typeof url === 'string' && url.startsWith('https://')) return { url }
|
|
3863
|
+
}
|
|
3864
|
+
throw new Error('Malformed checkout')
|
|
3865
|
+
}
|
|
3866
|
+
`}function St(){return`import { HttpError } from '@cascivo/app/api'
|
|
3867
|
+
import { migrate, queryRows } from '@cascivo/app/db'
|
|
3868
|
+
import type { Database } from '@cascivo/app/db'
|
|
3869
|
+
import { verifyWebhook } from '@cascivo/app/guard'
|
|
3870
|
+
import { StripeError, createStripe, parseStripeEvent } from '@cascivo/app/stripe'
|
|
3871
|
+
import type {
|
|
3872
|
+
Charge,
|
|
3873
|
+
CheckoutEventType,
|
|
3874
|
+
CheckoutSession,
|
|
3875
|
+
Dispute,
|
|
3876
|
+
DisputeEventType,
|
|
3877
|
+
Invoice,
|
|
3878
|
+
Stripe,
|
|
3879
|
+
} from '@cascivo/app/stripe'
|
|
3880
|
+
import { writeRoom } from '@cascivo/app/sync-server'
|
|
3881
|
+
import type { RoomNamespace } from '@cascivo/app/sync-server'
|
|
3882
|
+
import { Receipt, receiptSubject, renderEmail } from '@cascivo/email'
|
|
3883
|
+
import { createElement } from 'react'
|
|
3884
|
+
import { ORDER_ID, PRODUCT, SHOP_NAME, formatPrice, orderRoom, parseOrder } from '../src/checkout'
|
|
3885
|
+
import type { Order, OrderStatus } from '../src/checkout'
|
|
3886
|
+
|
|
3887
|
+
const migrations = [
|
|
3888
|
+
{
|
|
3889
|
+
id: '0001_orders',
|
|
3890
|
+
statements: [
|
|
3891
|
+
\`CREATE TABLE orders (
|
|
3892
|
+
id TEXT PRIMARY KEY,
|
|
3893
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
3894
|
+
status TEXT NOT NULL,
|
|
3895
|
+
amount INTEGER NOT NULL,
|
|
3896
|
+
currency TEXT NOT NULL,
|
|
3897
|
+
email TEXT,
|
|
3898
|
+
created_at TEXT NOT NULL,
|
|
3899
|
+
paid_at TEXT,
|
|
3900
|
+
payment_intent_id TEXT,
|
|
3901
|
+
refunded_amount INTEGER NOT NULL DEFAULT 0,
|
|
3902
|
+
dispute_status TEXT
|
|
3903
|
+
)\`,
|
|
3904
|
+
// Refund and dispute events name the payment, not the session.
|
|
3905
|
+
'CREATE INDEX orders_payment_intent ON orders (payment_intent_id)',
|
|
3906
|
+
],
|
|
3907
|
+
},
|
|
3908
|
+
]
|
|
3909
|
+
|
|
3910
|
+
/** Where Stripe posts; add it as an endpoint in the Stripe dashboard (README). */
|
|
3911
|
+
export const STRIPE_WEBHOOK_PATH = '/api/stripe/webhook'
|
|
3912
|
+
|
|
3913
|
+
/** What sending a receipt needs of the Email Service binding (\`send_email\`). */
|
|
3914
|
+
export interface ReceiptSender {
|
|
3619
3915
|
send(message: {
|
|
3620
3916
|
from: string
|
|
3621
3917
|
to: string
|
|
@@ -3625,93 +3921,2234 @@ export interface SignInSender {
|
|
|
3625
3921
|
}): Promise<unknown>
|
|
3626
3922
|
}
|
|
3627
3923
|
|
|
3628
|
-
|
|
3629
|
-
|
|
3924
|
+
export interface CheckoutEnv {
|
|
3925
|
+
DB: Database
|
|
3926
|
+
ROOMS: RoomNamespace<unknown>
|
|
3927
|
+
EMAIL: ReceiptSender
|
|
3928
|
+
RECEIPT_FROM: string
|
|
3929
|
+
STRIPE_SECRET_KEY?: string
|
|
3930
|
+
STRIPE_WEBHOOK_SECRET?: string
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
const COLUMNS =
|
|
3934
|
+
'id, status, amount, currency, refunded_amount AS refundedAmount, created_at AS createdAt, paid_at AS paidAt'
|
|
3935
|
+
|
|
3936
|
+
export function stripeOf(env: { STRIPE_SECRET_KEY?: string }): Stripe {
|
|
3937
|
+
if (!env.STRIPE_SECRET_KEY) {
|
|
3938
|
+
throw new HttpError(
|
|
3939
|
+
503,
|
|
3940
|
+
'Set STRIPE_SECRET_KEY to a test key from the Stripe dashboard (README)',
|
|
3941
|
+
)
|
|
3942
|
+
}
|
|
3943
|
+
return createStripe(env.STRIPE_SECRET_KEY)
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
/** Stripe's refusal, for the page: its message in vite dev, a pointer to the log deployed. */
|
|
3947
|
+
export function refused(error: unknown): never {
|
|
3948
|
+
if (!(error instanceof StripeError)) throw error
|
|
3949
|
+
console.error('[checkout] Stripe refused:', error.status, error.code, error.message)
|
|
3950
|
+
throw new HttpError(
|
|
3951
|
+
502,
|
|
3952
|
+
import.meta.env.DEV
|
|
3953
|
+
? \`Stripe: \${error.message}\`
|
|
3954
|
+
: 'The payment provider refused the request (see the Worker log)',
|
|
3955
|
+
)
|
|
3956
|
+
}
|
|
3630
3957
|
|
|
3631
3958
|
/**
|
|
3632
|
-
*
|
|
3633
|
-
*
|
|
3959
|
+
* Creates a Stripe Checkout Session for PRODUCT and records the order as pending. The order id
|
|
3960
|
+
* is also the idempotency key, so a retried request cannot open a second session.
|
|
3634
3961
|
*/
|
|
3635
|
-
export async function
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3962
|
+
export async function startCheckout(env: CheckoutEnv, origin: string): Promise<{ url: string }> {
|
|
3963
|
+
const stripe = stripeOf(env)
|
|
3964
|
+
const id = crypto.randomUUID()
|
|
3965
|
+
let session: CheckoutSession
|
|
3966
|
+
try {
|
|
3967
|
+
session = await stripe.createCheckoutSession(
|
|
3968
|
+
{
|
|
3969
|
+
mode: 'payment',
|
|
3970
|
+
lineItems: [{ ...PRODUCT, quantity: 1 }],
|
|
3971
|
+
successUrl: \`\${origin}/checkout/\${id}\`,
|
|
3972
|
+
cancelUrl: \`\${origin}/checkout\`,
|
|
3973
|
+
clientReferenceId: id,
|
|
3974
|
+
},
|
|
3975
|
+
{ idempotencyKey: id },
|
|
3976
|
+
)
|
|
3977
|
+
} catch (error) {
|
|
3978
|
+
refused(error)
|
|
3979
|
+
}
|
|
3980
|
+
if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
|
|
3981
|
+
await migrate(env.DB, migrations)
|
|
3982
|
+
await queryRows(
|
|
3983
|
+
env.DB,
|
|
3984
|
+
\`INSERT INTO orders (id, session_id, status, amount, currency, created_at)
|
|
3985
|
+
VALUES (?, ?, 'pending', ?, ?, ?)\`,
|
|
3986
|
+
[id, session.id, PRODUCT.amount, PRODUCT.currency, new Date().toISOString()],
|
|
3987
|
+
(row) => row,
|
|
3988
|
+
)
|
|
3989
|
+
return { url: session.url }
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
/** Where an event moves the order, or \`null\` when it does not move it. */
|
|
3993
|
+
function statusAfter(type: CheckoutEventType, session: CheckoutSession): OrderStatus | null {
|
|
3994
|
+
switch (type) {
|
|
3995
|
+
case 'checkout.session.completed':
|
|
3996
|
+
// \`unpaid\` here is a bank debit that has not settled: the async events decide it.
|
|
3997
|
+
return session.paymentStatus === 'unpaid' ? null : 'paid'
|
|
3998
|
+
case 'checkout.session.async_payment_succeeded':
|
|
3999
|
+
return 'paid'
|
|
4000
|
+
case 'checkout.session.async_payment_failed':
|
|
4001
|
+
return 'failed'
|
|
4002
|
+
case 'checkout.session.expired':
|
|
4003
|
+
return 'expired'
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
/** The same decision from a session read back from Stripe, with no event to go by. */
|
|
4008
|
+
function statusOf(session: CheckoutSession): OrderStatus | null {
|
|
4009
|
+
if (session.status === 'complete' && session.paymentStatus !== 'unpaid') return 'paid'
|
|
4010
|
+
return session.status === 'expired' ? 'expired' : null
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
/**
|
|
4014
|
+
* Moves a pending order to its final status, once: a retried event, or the order page reading
|
|
4015
|
+
* the session before the webhook arrived, finds it settled and changes nothing. The order is
|
|
4016
|
+
* found by Stripe's session id, never by \`client_reference_id\`, which a buyer can set on a
|
|
4017
|
+
* Payment Link. Then the order's page hears about it, and a paid order gets its receipt.
|
|
4018
|
+
*/
|
|
4019
|
+
async function settle(
|
|
4020
|
+
env: CheckoutEnv,
|
|
4021
|
+
session: CheckoutSession,
|
|
4022
|
+
status: OrderStatus,
|
|
4023
|
+
origin: string,
|
|
3640
4024
|
): Promise<void> {
|
|
4025
|
+
await migrate(env.DB, migrations)
|
|
4026
|
+
const [order] = await queryRows(
|
|
4027
|
+
env.DB,
|
|
4028
|
+
\`UPDATE orders SET status = ?, paid_at = ?, amount = COALESCE(?, amount),
|
|
4029
|
+
currency = COALESCE(?, currency), email = ?, payment_intent_id = ?
|
|
4030
|
+
WHERE session_id = ? AND status = 'pending' RETURNING \${COLUMNS}\`,
|
|
4031
|
+
[
|
|
4032
|
+
status,
|
|
4033
|
+
status === 'paid' ? new Date().toISOString() : null,
|
|
4034
|
+
session.amountTotal,
|
|
4035
|
+
session.currency,
|
|
4036
|
+
session.customerEmail,
|
|
4037
|
+
session.paymentIntentId,
|
|
4038
|
+
session.id,
|
|
4039
|
+
],
|
|
4040
|
+
parseOrder,
|
|
4041
|
+
)
|
|
4042
|
+
if (!order) return
|
|
4043
|
+
await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
|
|
4044
|
+
if (order.status === 'paid' && session.customerEmail) {
|
|
4045
|
+
await sendReceipt(env, order, session.customerEmail, origin)
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
/**
|
|
4050
|
+
* Emails a receipt rendered with @cascivo/email. \`vite dev\` renders it and logs it instead.
|
|
4051
|
+
* A receipt that cannot be sent is logged, not thrown: the payment is recorded either way, and
|
|
4052
|
+
* Stripe retrying the event would not send it again.
|
|
4053
|
+
*/
|
|
4054
|
+
async function sendReceipt(env: CheckoutEnv, order: Order, to: string, origin: string) {
|
|
4055
|
+
const total = formatPrice(order.amount, order.currency)
|
|
4056
|
+
const props = {
|
|
4057
|
+
productName: SHOP_NAME,
|
|
4058
|
+
orderId: order.id.slice(0, 8).toUpperCase(),
|
|
4059
|
+
items: [{ description: PRODUCT.name, amount: total }],
|
|
4060
|
+
total,
|
|
4061
|
+
invoiceHref: \`\${origin}/checkout/\${order.id}\`,
|
|
4062
|
+
}
|
|
4063
|
+
const message = renderEmail(createElement(Receipt, props), { subject: receiptSubject(props) })
|
|
3641
4064
|
if (import.meta.env.DEV) {
|
|
3642
|
-
console.log(\`[
|
|
4065
|
+
console.log(\`[checkout] receipt for \${to}: "\${message.subject}" (\${message.html.length} bytes)\`)
|
|
3643
4066
|
return
|
|
3644
4067
|
}
|
|
3645
|
-
if (!
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
4068
|
+
if (!env.RECEIPT_FROM) {
|
|
4069
|
+
console.warn('[checkout] no receipt sent: set RECEIPT_FROM in wrangler.jsonc')
|
|
4070
|
+
return
|
|
4071
|
+
}
|
|
4072
|
+
try {
|
|
4073
|
+
await env.EMAIL.send({
|
|
4074
|
+
from: env.RECEIPT_FROM,
|
|
4075
|
+
to,
|
|
4076
|
+
subject: message.subject,
|
|
4077
|
+
text: message.text,
|
|
4078
|
+
html: message.html,
|
|
4079
|
+
})
|
|
4080
|
+
} catch (error) {
|
|
4081
|
+
console.error('[checkout] receipt not sent:', error)
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
/**
|
|
4086
|
+
* A refund, made in the Stripe dashboard or with \`createRefund\`. \`charge.refunded\` carries the
|
|
4087
|
+
* running total, so a retried or late event cannot count a refund twice. All of it refunded
|
|
4088
|
+
* makes the order \`refunded\`; part of it leaves it \`paid\`, with the amount shown.
|
|
4089
|
+
*/
|
|
4090
|
+
async function refundOrder(env: CheckoutEnv, charge: Charge): Promise<void> {
|
|
4091
|
+
if (!charge.paymentIntentId) return
|
|
4092
|
+
await migrate(env.DB, migrations)
|
|
4093
|
+
const [order] = await queryRows(
|
|
4094
|
+
env.DB,
|
|
4095
|
+
\`UPDATE orders SET refunded_amount = MAX(refunded_amount, ?),
|
|
4096
|
+
status = CASE WHEN ? = 1 THEN 'refunded' ELSE status END
|
|
4097
|
+
WHERE payment_intent_id = ? AND status IN ('paid', 'refunded') RETURNING \${COLUMNS}\`,
|
|
4098
|
+
[charge.amountRefunded, charge.refunded ? 1 : 0, charge.paymentIntentId],
|
|
4099
|
+
parseOrder,
|
|
4100
|
+
)
|
|
4101
|
+
if (order) await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
/**
|
|
4105
|
+
* A chargeback. While it is open the order is \`disputed\`: hold back anything not yet
|
|
4106
|
+
* delivered, and answer it with evidence in the Stripe dashboard before the deadline shown
|
|
4107
|
+
* there. Won, the order is \`paid\` again; lost, it stays \`disputed\`. The dispute's status is
|
|
4108
|
+
* stored, so a \`created\` event arriving after \`closed\` cannot reopen it.
|
|
4109
|
+
*/
|
|
4110
|
+
async function disputeOrder(
|
|
4111
|
+
env: CheckoutEnv,
|
|
4112
|
+
type: DisputeEventType,
|
|
4113
|
+
dispute: Dispute,
|
|
4114
|
+
): Promise<void> {
|
|
4115
|
+
if (!dispute.paymentIntentId) return
|
|
4116
|
+
await migrate(env.DB, migrations)
|
|
4117
|
+
const [order] =
|
|
4118
|
+
type === 'charge.dispute.created'
|
|
4119
|
+
? await queryRows(
|
|
4120
|
+
env.DB,
|
|
4121
|
+
\`UPDATE orders SET status = 'disputed', dispute_status = ?
|
|
4122
|
+
WHERE payment_intent_id = ? AND status = 'paid' AND dispute_status IS NULL
|
|
4123
|
+
RETURNING \${COLUMNS}\`,
|
|
4124
|
+
[dispute.status, dispute.paymentIntentId],
|
|
4125
|
+
parseOrder,
|
|
4126
|
+
)
|
|
4127
|
+
: await queryRows(
|
|
4128
|
+
env.DB,
|
|
4129
|
+
\`UPDATE orders SET dispute_status = ?,
|
|
4130
|
+
status = CASE WHEN ? = 'won' THEN 'paid' ELSE 'disputed' END
|
|
4131
|
+
WHERE payment_intent_id = ? AND status IN ('paid', 'disputed') RETURNING \${COLUMNS}\`,
|
|
4132
|
+
[dispute.status, dispute.status, dispute.paymentIntentId],
|
|
4133
|
+
parseOrder,
|
|
4134
|
+
)
|
|
4135
|
+
if (!order) return
|
|
4136
|
+
if (type === 'charge.dispute.created') {
|
|
4137
|
+
console.warn(
|
|
4138
|
+
\`[checkout] order \${order.id} is disputed (\${dispute.reason ?? 'no reason given'})\`,
|
|
4139
|
+
)
|
|
4140
|
+
}
|
|
4141
|
+
await writeRoom(env.ROOMS, orderRoom(order.id), 'order', { ...order })
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/** What the webhook hands to worker/billing.ts, when the app bills subscriptions. */
|
|
4145
|
+
export interface BillingHooks {
|
|
4146
|
+
/** A subscription changed: store its current state. */
|
|
4147
|
+
subscription(subscriptionId: string): Promise<void>
|
|
4148
|
+
/** A renewal could not be charged: tell the customer. */
|
|
4149
|
+
paymentFailed(invoice: Invoice, origin: string): Promise<void>
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
/**
|
|
4153
|
+
* Stripe's webhook: verified against STRIPE_WEBHOOK_SECRET before anything in it is read,
|
|
4154
|
+
* then each Checkout event settles its order, and refunds and disputes update it. Subscription
|
|
4155
|
+
* and invoice events, and completed subscription checkouts, go to \`billing\` when the app bills
|
|
4156
|
+
* subscriptions (worker/billing.ts). Other events are acknowledged, so Stripe stops sending them.
|
|
4157
|
+
*/
|
|
4158
|
+
export async function receiveStripe(
|
|
4159
|
+
request: Request,
|
|
4160
|
+
env: CheckoutEnv,
|
|
4161
|
+
billing?: BillingHooks,
|
|
4162
|
+
): Promise<Response> {
|
|
4163
|
+
if (!env.STRIPE_WEBHOOK_SECRET) throw new HttpError(503, 'Set STRIPE_WEBHOOK_SECRET (README)')
|
|
4164
|
+
const { body } = await verifyWebhook(request, {
|
|
4165
|
+
scheme: 'stripe',
|
|
4166
|
+
secret: env.STRIPE_WEBHOOK_SECRET,
|
|
3652
4167
|
})
|
|
4168
|
+
const event = parseStripeEvent(body)
|
|
4169
|
+
const origin = new URL(request.url).origin
|
|
4170
|
+
if (event.kind === 'subscription') await billing?.subscription(event.subscription.id)
|
|
4171
|
+
if (event.kind === 'invoice' && event.type === 'invoice.payment_failed') {
|
|
4172
|
+
await billing?.paymentFailed(event.invoice, origin)
|
|
4173
|
+
}
|
|
4174
|
+
if (event.kind === 'refund') await refundOrder(env, event.charge)
|
|
4175
|
+
if (event.kind === 'dispute') await disputeOrder(env, event.type, event.dispute)
|
|
4176
|
+
if (event.kind === 'checkout' && event.session.mode === 'subscription') {
|
|
4177
|
+
if (event.session.subscriptionId) await billing?.subscription(event.session.subscriptionId)
|
|
4178
|
+
} else if (event.kind === 'checkout') {
|
|
4179
|
+
const status = statusAfter(event.type, event.session)
|
|
4180
|
+
if (status) await settle(env, event.session, status, origin)
|
|
4181
|
+
}
|
|
4182
|
+
return Response.json({ received: true })
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
function parseStored(raw: unknown): { order: Order; sessionId: string } {
|
|
4186
|
+
const sessionId = typeof raw === 'object' && raw !== null ? Reflect.get(raw, 'sessionId') : null
|
|
4187
|
+
if (typeof sessionId !== 'string') throw new Error('Malformed order row')
|
|
4188
|
+
return { order: parseOrder(raw), sessionId }
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
async function readOrder(env: CheckoutEnv, id: string) {
|
|
4192
|
+
await migrate(env.DB, migrations)
|
|
4193
|
+
const [row] = await queryRows(
|
|
4194
|
+
env.DB,
|
|
4195
|
+
\`SELECT \${COLUMNS}, session_id AS sessionId FROM orders WHERE id = ?\`,
|
|
4196
|
+
[id],
|
|
4197
|
+
parseStored,
|
|
4198
|
+
)
|
|
4199
|
+
if (!row) throw new HttpError(404, 'No such order')
|
|
4200
|
+
return row
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
/**
|
|
4204
|
+
* An order, for its page. A pending one is checked with Stripe first, so the page is right
|
|
4205
|
+
* even when the webhook is late or not set up yet (as in \`vite dev\` without \`stripe listen\`).
|
|
4206
|
+
*/
|
|
4207
|
+
export async function getOrder(env: CheckoutEnv, id: string, origin: string): Promise<Order> {
|
|
4208
|
+
if (!ORDER_ID.test(id)) throw new HttpError(404, 'No such order')
|
|
4209
|
+
const { order, sessionId } = await readOrder(env, id)
|
|
4210
|
+
if (order.status !== 'pending' || !env.STRIPE_SECRET_KEY) return order
|
|
4211
|
+
try {
|
|
4212
|
+
const session = await stripeOf(env).retrieveCheckoutSession(sessionId)
|
|
4213
|
+
const status = statusOf(session)
|
|
4214
|
+
if (!status) return order
|
|
4215
|
+
await settle(env, session, status, origin)
|
|
4216
|
+
} catch (error) {
|
|
4217
|
+
// Stripe unreachable: show what is known; the webhook settles the order later.
|
|
4218
|
+
console.error('[checkout] could not read the session back:', error)
|
|
4219
|
+
return order
|
|
4220
|
+
}
|
|
4221
|
+
return (await readOrder(env, id)).order
|
|
3653
4222
|
}
|
|
3654
|
-
`}function
|
|
4223
|
+
`}function Ct(){return`import { createClient } from '@cascivo/app/api'
|
|
4224
|
+
import {
|
|
3655
4225
|
Alert,
|
|
3656
4226
|
Button,
|
|
3657
4227
|
Card,
|
|
3658
4228
|
CardContent,
|
|
3659
4229
|
Flex,
|
|
3660
4230
|
Heading,
|
|
3661
|
-
Input,
|
|
3662
|
-
Link,
|
|
3663
|
-
Spinner,
|
|
3664
4231
|
Text,
|
|
3665
4232
|
signal,
|
|
3666
4233
|
useSignals,
|
|
3667
4234
|
} from '@cascivo/react'
|
|
3668
|
-
import
|
|
3669
|
-
import {
|
|
4235
|
+
import { api } from '../api'
|
|
4236
|
+
import { PRODUCT, formatPrice } from '../checkout'
|
|
3670
4237
|
|
|
3671
|
-
const
|
|
3672
|
-
|
|
3673
|
-
const devLink = signal<string | null>(null)
|
|
4238
|
+
const client = createClient(api)
|
|
4239
|
+
const starting = signal(false)
|
|
3674
4240
|
const failure = signal<string | null>(null)
|
|
3675
|
-
const sending = signal(false)
|
|
3676
4241
|
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
if (typeof email !== 'string') return
|
|
4242
|
+
/** Asks the Worker for a Stripe Checkout page and goes there: Stripe takes the card, not us. */
|
|
4243
|
+
async function buy(): Promise<void> {
|
|
4244
|
+
starting.value = true
|
|
3681
4245
|
failure.value = null
|
|
3682
|
-
sending.value = true
|
|
3683
4246
|
try {
|
|
3684
|
-
const {
|
|
3685
|
-
|
|
3686
|
-
devLink.value = link
|
|
4247
|
+
const { url } = await client.startCheckout()
|
|
4248
|
+
location.assign(url)
|
|
3687
4249
|
} catch (error) {
|
|
3688
|
-
failure.value = error instanceof Error ? error.message : 'Could not
|
|
3689
|
-
|
|
3690
|
-
sending.value = false
|
|
4250
|
+
failure.value = error instanceof Error ? error.message : 'Could not start the checkout'
|
|
4251
|
+
starting.value = false
|
|
3691
4252
|
}
|
|
3692
4253
|
}
|
|
3693
4254
|
|
|
3694
|
-
export default function
|
|
4255
|
+
export default function Checkout() {
|
|
3695
4256
|
useSignals()
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
</
|
|
3713
|
-
|
|
3714
|
-
|
|
4257
|
+
return (
|
|
4258
|
+
<Flex gap={4}>
|
|
4259
|
+
<Flex gap={1}>
|
|
4260
|
+
<Heading level={1}>Checkout</Heading>
|
|
4261
|
+
<Text muted>
|
|
4262
|
+
Paid on Stripe's hosted page. Stripe tells the Worker when the payment succeeds; the
|
|
4263
|
+
Worker records the order, updates its page and emails a receipt.
|
|
4264
|
+
</Text>
|
|
4265
|
+
</Flex>
|
|
4266
|
+
<Card>
|
|
4267
|
+
<CardContent>
|
|
4268
|
+
<Flex gap={3}>
|
|
4269
|
+
<Flex gap={1}>
|
|
4270
|
+
<Heading level={2}>{PRODUCT.name}</Heading>
|
|
4271
|
+
<Text muted>{PRODUCT.description}</Text>
|
|
4272
|
+
</Flex>
|
|
4273
|
+
<Text size="lg">{formatPrice(PRODUCT.amount, PRODUCT.currency)}</Text>
|
|
4274
|
+
<Flex direction="horizontal">
|
|
4275
|
+
<Button loading={starting.value} onClick={() => void buy()}>
|
|
4276
|
+
Buy now
|
|
4277
|
+
</Button>
|
|
4278
|
+
</Flex>
|
|
4279
|
+
</Flex>
|
|
4280
|
+
</CardContent>
|
|
4281
|
+
</Card>
|
|
4282
|
+
{failure.value ? (
|
|
4283
|
+
<Alert variant="destructive" title="The checkout did not start">
|
|
4284
|
+
{failure.value}
|
|
4285
|
+
</Alert>
|
|
4286
|
+
) : null}
|
|
4287
|
+
<Text size="sm" muted>
|
|
4288
|
+
In test mode, pay with the card 4242 4242 4242 4242, any future date and any CVC.
|
|
4289
|
+
</Text>
|
|
4290
|
+
</Flex>
|
|
4291
|
+
)
|
|
4292
|
+
}
|
|
4293
|
+
`}function wt(){return`import type { RouteProps } from '@cascivo/app'
|
|
4294
|
+
import { createClient } from '@cascivo/app/api'
|
|
4295
|
+
import { connectRoom } from '@cascivo/app/sync'
|
|
4296
|
+
import {
|
|
4297
|
+
Alert,
|
|
4298
|
+
Badge,
|
|
4299
|
+
EmptyState,
|
|
4300
|
+
Flex,
|
|
4301
|
+
Heading,
|
|
4302
|
+
Link,
|
|
4303
|
+
Spinner,
|
|
4304
|
+
Text,
|
|
4305
|
+
signal,
|
|
4306
|
+
useEffectPropSignal,
|
|
4307
|
+
useSignalEffect,
|
|
4308
|
+
useSignals,
|
|
4309
|
+
} from '@cascivo/react'
|
|
4310
|
+
import { api } from '../../api'
|
|
4311
|
+
import { PRODUCT, formatPrice, parseOrder } from '../../checkout'
|
|
4312
|
+
import type { Order } from '../../checkout'
|
|
4313
|
+
|
|
4314
|
+
const client = createClient(api)
|
|
4315
|
+
/** Orders seen, by id; \`null\` when the id has none. */
|
|
4316
|
+
const orders = signal<Readonly<Record<string, Order | null>>>({})
|
|
4317
|
+
|
|
4318
|
+
/** Keeps the newest word on an order: a final status is never replaced by \`pending\`. */
|
|
4319
|
+
function remember(id: string, order: Order | null): void {
|
|
4320
|
+
const known = orders.peek()[id]
|
|
4321
|
+
if (known && known.status !== 'pending' && order?.status === 'pending') return
|
|
4322
|
+
orders.value = { ...orders.peek(), [id]: order }
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
async function load(id: string): Promise<void> {
|
|
4326
|
+
try {
|
|
4327
|
+
remember(id, await client.getOrder({ params: { id } }))
|
|
4328
|
+
} catch {
|
|
4329
|
+
remember(id, null)
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
/** Watches the order's room, where the Worker pushes what Stripe reports. Returns the cleanup. */
|
|
4334
|
+
function watch(id: string): () => void {
|
|
4335
|
+
const room = connectRoom(\`/api/orders/\${id}/live\`)
|
|
4336
|
+
const pushed = room.signal<Order | null>('order', null, (raw) =>
|
|
4337
|
+
raw === null ? null : parseOrder(raw),
|
|
4338
|
+
)
|
|
4339
|
+
const stop = pushed.signal.subscribe((order) => {
|
|
4340
|
+
if (order) remember(id, order)
|
|
4341
|
+
})
|
|
4342
|
+
return () => {
|
|
4343
|
+
stop()
|
|
4344
|
+
room.close()
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
const STATUS = {
|
|
4349
|
+
pending: { variant: 'warning', label: 'Waiting for Stripe' },
|
|
4350
|
+
paid: { variant: 'success', label: 'Paid' },
|
|
4351
|
+
failed: { variant: 'destructive', label: 'Payment failed' },
|
|
4352
|
+
expired: { variant: 'secondary', label: 'Expired' },
|
|
4353
|
+
refunded: { variant: 'secondary', label: 'Refunded' },
|
|
4354
|
+
disputed: { variant: 'destructive', label: 'Disputed' },
|
|
4355
|
+
} as const
|
|
4356
|
+
|
|
4357
|
+
/** \`/checkout/:order\` — where Stripe sends the buyer back. It updates when Stripe confirms. */
|
|
4358
|
+
export default function OrderPage({ params }: RouteProps<'/checkout/:order'>) {
|
|
4359
|
+
useSignals()
|
|
4360
|
+
const id = useEffectPropSignal(params.order)
|
|
4361
|
+
useSignalEffect(() => {
|
|
4362
|
+
void load(id.value)
|
|
4363
|
+
return watch(id.value)
|
|
4364
|
+
})
|
|
4365
|
+
const order = orders.value[params.order]
|
|
4366
|
+
|
|
4367
|
+
if (order === undefined) return <Spinner label="Loading" />
|
|
4368
|
+
if (order === null) {
|
|
4369
|
+
return <EmptyState title="No such order" description="Check the link in your receipt." />
|
|
4370
|
+
}
|
|
4371
|
+
return (
|
|
4372
|
+
<Flex gap={4}>
|
|
4373
|
+
<Flex gap={1}>
|
|
4374
|
+
<Heading level={1}>Your order</Heading>
|
|
4375
|
+
<Text muted>
|
|
4376
|
+
{PRODUCT.name} · {formatPrice(order.amount, order.currency)}
|
|
4377
|
+
</Text>
|
|
4378
|
+
</Flex>
|
|
4379
|
+
<Flex direction="horizontal" align="center" gap={2} wrap>
|
|
4380
|
+
<Badge variant={STATUS[order.status].variant}>{STATUS[order.status].label}</Badge>
|
|
4381
|
+
<Text size="sm" muted>
|
|
4382
|
+
Ordered {new Date(order.createdAt).toLocaleString()}
|
|
4383
|
+
</Text>
|
|
4384
|
+
</Flex>
|
|
4385
|
+
{order.status === 'pending' ? (
|
|
4386
|
+
<Alert variant="info" title="Confirming your payment">
|
|
4387
|
+
This page updates by itself when Stripe confirms. A bank payment can take a few days.
|
|
4388
|
+
</Alert>
|
|
4389
|
+
) : null}
|
|
4390
|
+
{order.status === 'paid' && order.refundedAmount === 0 ? (
|
|
4391
|
+
<Alert variant="success" title="Thank you">
|
|
4392
|
+
Your payment went through. A receipt is on its way to your inbox.
|
|
4393
|
+
</Alert>
|
|
4394
|
+
) : null}
|
|
4395
|
+
{order.status === 'paid' && order.refundedAmount > 0 ? (
|
|
4396
|
+
<Alert variant="info" title="Partly refunded">
|
|
4397
|
+
{formatPrice(order.refundedAmount, order.currency)} of this order was refunded to the card
|
|
4398
|
+
that paid it.
|
|
4399
|
+
</Alert>
|
|
4400
|
+
) : null}
|
|
4401
|
+
{order.status === 'refunded' ? (
|
|
4402
|
+
<Alert variant="info" title="Refunded">
|
|
4403
|
+
The full amount went back to the card that paid it. It can take a few days to show.
|
|
4404
|
+
</Alert>
|
|
4405
|
+
) : null}
|
|
4406
|
+
{order.status === 'disputed' ? (
|
|
4407
|
+
<Alert variant="warning" title="This payment is disputed">
|
|
4408
|
+
The card's bank is reviewing a chargeback. The order is on hold until it is decided.
|
|
4409
|
+
</Alert>
|
|
4410
|
+
) : null}
|
|
4411
|
+
{order.status === 'failed' ? (
|
|
4412
|
+
<Alert variant="destructive" title="The payment failed">
|
|
4413
|
+
Nothing was charged. <Link href="/checkout">Try again</Link>
|
|
4414
|
+
</Alert>
|
|
4415
|
+
) : null}
|
|
4416
|
+
{order.status === 'expired' ? (
|
|
4417
|
+
<Alert variant="warning" title="This checkout expired">
|
|
4418
|
+
It was not paid in time. <Link href="/checkout">Start again</Link>
|
|
4419
|
+
</Alert>
|
|
4420
|
+
) : null}
|
|
4421
|
+
</Flex>
|
|
4422
|
+
)
|
|
4423
|
+
}
|
|
4424
|
+
`}function Tt(){return`/**
|
|
4425
|
+
* The newsletter's wire types, shared by the Worker (worker/newsletter.ts) and its pages.
|
|
4426
|
+
* Every payload is parsed on arrival: the network is not trusted because the types match.
|
|
4427
|
+
*/
|
|
4428
|
+
|
|
4429
|
+
/** Longest subject and body the composer accepts. */
|
|
4430
|
+
export const MAX_SUBJECT = 200
|
|
4431
|
+
export const MAX_BODY = 50_000
|
|
4432
|
+
|
|
4433
|
+
export interface SubscriberCounts {
|
|
4434
|
+
/** Signed up, has not clicked the confirmation link yet. */
|
|
4435
|
+
pending: number
|
|
4436
|
+
subscribed: number
|
|
4437
|
+
unsubscribed: number
|
|
4438
|
+
/** Bounced for good or complained: never mailed again (worker/newsletter.ts). */
|
|
4439
|
+
suppressed: number
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
/** One sent issue and how far its sending has got. */
|
|
4443
|
+
export interface Issue {
|
|
4444
|
+
id: string
|
|
4445
|
+
subject: string
|
|
4446
|
+
createdAt: string
|
|
4447
|
+
/** Subscribers it was queued for. */
|
|
4448
|
+
total: number
|
|
4449
|
+
sent: number
|
|
4450
|
+
failed: number
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
export interface Overview {
|
|
4454
|
+
subscribers: SubscriberCounts
|
|
4455
|
+
issues: Issue[]
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
/** The room the Worker pushes an issue's progress to; the composer watches it. */
|
|
4459
|
+
export const issueRoom = (id: string) => \`issue-\${id}\`
|
|
4460
|
+
|
|
4461
|
+
/** An issue id: a UUID the Worker made. */
|
|
4462
|
+
export const ISSUE_ID = /^[0-9a-f-]{36}$/
|
|
4463
|
+
|
|
4464
|
+
const isRecord = (raw: unknown): raw is Record<string, unknown> =>
|
|
4465
|
+
typeof raw === 'object' && raw !== null
|
|
4466
|
+
|
|
4467
|
+
function text(raw: Record<string, unknown>, key: string, max: number): string {
|
|
4468
|
+
const value = raw[key]
|
|
4469
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > max) {
|
|
4470
|
+
throw new Error(\`Expected \${key}: some text, at most \${max} characters\`)
|
|
4471
|
+
}
|
|
4472
|
+
return value
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
export function parseEmailInput(raw: unknown): { email: string } {
|
|
4476
|
+
if (!isRecord(raw)) throw new Error('Expected { email }')
|
|
4477
|
+
return { email: text(raw, 'email', 254) }
|
|
4478
|
+
}
|
|
4479
|
+
|
|
4480
|
+
export function parseTokenInput(raw: unknown): { token: string } {
|
|
4481
|
+
if (!isRecord(raw)) throw new Error('Expected { token }')
|
|
4482
|
+
return { token: text(raw, 'token', 100) }
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
/** The composer's key: NEWSLETTER_KEY, which only the sender knows. */
|
|
4486
|
+
export function parseKeyInput(raw: unknown): { key: string } {
|
|
4487
|
+
if (!isRecord(raw)) throw new Error('Expected { key }')
|
|
4488
|
+
return { key: text(raw, 'key', 200) }
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
export interface IssueInput {
|
|
4492
|
+
key: string
|
|
4493
|
+
subject: string
|
|
4494
|
+
/** The body in Markdown, rendered by @cascivo/email's Markdown. */
|
|
4495
|
+
body: string
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
export function parseIssueInput(raw: unknown): IssueInput {
|
|
4499
|
+
if (!isRecord(raw)) throw new Error('Expected { key, subject, body }')
|
|
4500
|
+
return {
|
|
4501
|
+
key: text(raw, 'key', 200),
|
|
4502
|
+
subject: text(raw, 'subject', MAX_SUBJECT),
|
|
4503
|
+
body: text(raw, 'body', MAX_BODY),
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
export function parseSubscribed(raw: unknown): { devLink: string | null } {
|
|
4508
|
+
if (isRecord(raw) && (raw['devLink'] === null || typeof raw['devLink'] === 'string')) {
|
|
4509
|
+
return { devLink: raw['devLink'] }
|
|
4510
|
+
}
|
|
4511
|
+
throw new Error('Malformed reply')
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
export function parseConfirmed(raw: unknown): { email: string } {
|
|
4515
|
+
if (isRecord(raw) && typeof raw['email'] === 'string') return { email: raw['email'] }
|
|
4516
|
+
throw new Error('Malformed reply')
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
export function parsePreview(raw: unknown): { html: string; bytes: number } {
|
|
4520
|
+
if (isRecord(raw) && typeof raw['html'] === 'string' && typeof raw['bytes'] === 'number') {
|
|
4521
|
+
return { html: raw['html'], bytes: raw['bytes'] }
|
|
4522
|
+
}
|
|
4523
|
+
throw new Error('Malformed preview')
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
const count = (raw: Record<string, unknown>, key: string): number => {
|
|
4527
|
+
const value = raw[key]
|
|
4528
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
4529
|
+
throw new Error(\`Expected a count for \${key}\`)
|
|
4530
|
+
}
|
|
4531
|
+
return value
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
export function parseIssue(raw: unknown): Issue {
|
|
4535
|
+
if (isRecord(raw) && typeof raw['id'] === 'string' && typeof raw['createdAt'] === 'string') {
|
|
4536
|
+
return {
|
|
4537
|
+
id: raw['id'],
|
|
4538
|
+
subject: text(raw, 'subject', MAX_SUBJECT),
|
|
4539
|
+
createdAt: raw['createdAt'],
|
|
4540
|
+
total: count(raw, 'total'),
|
|
4541
|
+
sent: count(raw, 'sent'),
|
|
4542
|
+
failed: count(raw, 'failed'),
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
throw new Error('Malformed issue')
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
export function parseOverview(raw: unknown): Overview {
|
|
4549
|
+
if (isRecord(raw) && isRecord(raw['subscribers']) && Array.isArray(raw['issues'])) {
|
|
4550
|
+
const s = raw['subscribers']
|
|
4551
|
+
return {
|
|
4552
|
+
subscribers: {
|
|
4553
|
+
pending: count(s, 'pending'),
|
|
4554
|
+
subscribed: count(s, 'subscribed'),
|
|
4555
|
+
unsubscribed: count(s, 'unsubscribed'),
|
|
4556
|
+
suppressed: count(s, 'suppressed'),
|
|
4557
|
+
},
|
|
4558
|
+
issues: raw['issues'].map(parseIssue),
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
throw new Error('Malformed overview')
|
|
4562
|
+
}
|
|
4563
|
+
`}function Et(e){return`import {
|
|
4564
|
+
Body,
|
|
4565
|
+
Button,
|
|
4566
|
+
Container,
|
|
4567
|
+
Footer,
|
|
4568
|
+
Head,
|
|
4569
|
+
Heading,
|
|
4570
|
+
Html,
|
|
4571
|
+
Link,
|
|
4572
|
+
Markdown,
|
|
4573
|
+
Preview,
|
|
4574
|
+
Section,
|
|
4575
|
+
Text,
|
|
4576
|
+
renderEmail,
|
|
4577
|
+
} from '@cascivo/email'
|
|
4578
|
+
import type { RenderResult } from '@cascivo/email'
|
|
4579
|
+
import { createElement as h } from 'react'
|
|
4580
|
+
|
|
4581
|
+
/** Who the newsletter is from, as its emails say. */
|
|
4582
|
+
export const NEWSLETTER_NAME = '${M(e.name).replace(/'/g,`\\'`)}'
|
|
4583
|
+
|
|
4584
|
+
/** Stands in for each reader's unsubscribe token; worker/newsletter.ts swaps it per message. */
|
|
4585
|
+
export const TOKEN_SLOT = '__UNSUBSCRIBE_TOKEN__'
|
|
4586
|
+
|
|
4587
|
+
/**
|
|
4588
|
+
* An issue, rendered once with @cascivo/email: the body is Markdown, drawn through the email
|
|
4589
|
+
* primitives (raw HTML in it stays literal text). The footer carries the unsubscribe link,
|
|
4590
|
+
* with TOKEN_SLOT where each reader's token goes.
|
|
4591
|
+
*/
|
|
4592
|
+
export function renderIssue(subject: string, body: string, origin: string): RenderResult {
|
|
4593
|
+
const unsubscribe = \`\${origin}/newsletter/unsubscribe?token=\${TOKEN_SLOT}\`
|
|
4594
|
+
return renderEmail(
|
|
4595
|
+
h(
|
|
4596
|
+
Html,
|
|
4597
|
+
null,
|
|
4598
|
+
h(Head, { title: subject }),
|
|
4599
|
+
h(
|
|
4600
|
+
Body,
|
|
4601
|
+
null,
|
|
4602
|
+
h(Preview, null, subject),
|
|
4603
|
+
h(
|
|
4604
|
+
Container,
|
|
4605
|
+
null,
|
|
4606
|
+
h(Section, { padding: 32 }, h(Heading, { level: 1 }, subject), h(Markdown, null, body)),
|
|
4607
|
+
h(
|
|
4608
|
+
Footer,
|
|
4609
|
+
null,
|
|
4610
|
+
\`You get this because you subscribed to \${NEWSLETTER_NAME}. \`,
|
|
4611
|
+
h(Link, { href: unsubscribe }, 'Unsubscribe'),
|
|
4612
|
+
),
|
|
4613
|
+
),
|
|
4614
|
+
),
|
|
4615
|
+
),
|
|
4616
|
+
{ subject },
|
|
4617
|
+
)
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
/** The double opt-in email: nobody is mailed an issue until they open this link. */
|
|
4621
|
+
export function renderConfirmation(confirmUrl: string): RenderResult {
|
|
4622
|
+
const subject = \`Confirm your subscription to \${NEWSLETTER_NAME}\`
|
|
4623
|
+
return renderEmail(
|
|
4624
|
+
h(
|
|
4625
|
+
Html,
|
|
4626
|
+
null,
|
|
4627
|
+
h(Head, { title: subject }),
|
|
4628
|
+
h(
|
|
4629
|
+
Body,
|
|
4630
|
+
null,
|
|
4631
|
+
h(Preview, null, 'One click and you are on the list.'),
|
|
4632
|
+
h(
|
|
4633
|
+
Container,
|
|
4634
|
+
null,
|
|
4635
|
+
h(
|
|
4636
|
+
Section,
|
|
4637
|
+
{ padding: 32 },
|
|
4638
|
+
h(Heading, { level: 1 }, 'Confirm your subscription'),
|
|
4639
|
+
h(Text, null, \`Someone, hopefully you, asked to get \${NEWSLETTER_NAME} by email.\`),
|
|
4640
|
+
h(Button, { href: confirmUrl }, 'Yes, subscribe me'),
|
|
4641
|
+
h(
|
|
4642
|
+
Text,
|
|
4643
|
+
{ variant: 'muted', size: '14px' },
|
|
4644
|
+
'The link works for 24 hours. If you did not ask, ignore this email: you will not hear from us again.',
|
|
4645
|
+
),
|
|
4646
|
+
),
|
|
4647
|
+
),
|
|
4648
|
+
),
|
|
4649
|
+
),
|
|
4650
|
+
{ subject },
|
|
4651
|
+
)
|
|
4652
|
+
}
|
|
4653
|
+
`}function Dt(){return`import { HttpError } from '@cascivo/app/api'
|
|
4654
|
+
import { normalizeEmail } from '@cascivo/app/auth-server'
|
|
4655
|
+
import { migrate, queryRows } from '@cascivo/app/db'
|
|
4656
|
+
import type { Database } from '@cascivo/app/db'
|
|
4657
|
+
import { SesError, createSes, handleSns, parseSesNotification } from '@cascivo/app/ses'
|
|
4658
|
+
import { writeRoom } from '@cascivo/app/sync-server'
|
|
4659
|
+
import type { RoomNamespace } from '@cascivo/app/sync-server'
|
|
4660
|
+
import { assertSendable, sendEmail } from '@cascivo/email'
|
|
4661
|
+
import type { RenderResult } from '@cascivo/email'
|
|
4662
|
+
import { ISSUE_ID, issueRoom, parseIssue } from '../src/newsletter'
|
|
4663
|
+
import type { Issue, IssueInput, Overview, SubscriberCounts } from '../src/newsletter'
|
|
4664
|
+
import { TOKEN_SLOT, renderConfirmation, renderIssue } from './newsletter-email'
|
|
4665
|
+
|
|
4666
|
+
const migrations = [
|
|
4667
|
+
{
|
|
4668
|
+
id: '0001_newsletter',
|
|
4669
|
+
statements: [
|
|
4670
|
+
\`CREATE TABLE subscribers (
|
|
4671
|
+
email TEXT PRIMARY KEY,
|
|
4672
|
+
status TEXT NOT NULL,
|
|
4673
|
+
confirm_hash TEXT,
|
|
4674
|
+
confirm_expires INTEGER,
|
|
4675
|
+
unsubscribe_token TEXT NOT NULL UNIQUE,
|
|
4676
|
+
reason TEXT,
|
|
4677
|
+
created_at TEXT NOT NULL,
|
|
4678
|
+
confirmed_at TEXT
|
|
4679
|
+
)\`,
|
|
4680
|
+
\`CREATE TABLE issues (
|
|
4681
|
+
id TEXT PRIMARY KEY,
|
|
4682
|
+
subject TEXT NOT NULL,
|
|
4683
|
+
body TEXT NOT NULL,
|
|
4684
|
+
total INTEGER NOT NULL,
|
|
4685
|
+
created_at TEXT NOT NULL
|
|
4686
|
+
)\`,
|
|
4687
|
+
// One row per reader per issue, written as each send finishes: a retried queue message
|
|
4688
|
+
// skips whoever already has one, so nobody gets an issue twice.
|
|
4689
|
+
\`CREATE TABLE deliveries (
|
|
4690
|
+
issue_id TEXT NOT NULL,
|
|
4691
|
+
email TEXT NOT NULL,
|
|
4692
|
+
status TEXT NOT NULL,
|
|
4693
|
+
detail TEXT,
|
|
4694
|
+
at TEXT NOT NULL,
|
|
4695
|
+
PRIMARY KEY (issue_id, email)
|
|
4696
|
+
)\`,
|
|
4697
|
+
],
|
|
4698
|
+
},
|
|
4699
|
+
]
|
|
4700
|
+
|
|
4701
|
+
/** Readers per queue message. Each message is sent in one go, one reader after another. */
|
|
4702
|
+
const CHUNK = 25
|
|
4703
|
+
/** A confirmation link works for a day. */
|
|
4704
|
+
const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000
|
|
4705
|
+
/** A pending address gets another link at most this often, so the form cannot flood an inbox. */
|
|
4706
|
+
const RESEND_AFTER_MS = 10 * 60 * 1000
|
|
4707
|
+
|
|
4708
|
+
export interface NewsletterMessage {
|
|
4709
|
+
issueId: string
|
|
4710
|
+
emails: string[]
|
|
4711
|
+
/** The app's origin, for the unsubscribe links: a queue consumer has no request. */
|
|
4712
|
+
origin: string
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
/** What sending needs of the NEWSLETTER queue binding. */
|
|
4716
|
+
export interface NewsletterQueue {
|
|
4717
|
+
sendBatch(messages: Iterable<{ body: NewsletterMessage }>): Promise<void>
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
/** The slice of a Queue consumer's batch \`deliver\` reads. */
|
|
4721
|
+
export interface NewsletterBatch {
|
|
4722
|
+
readonly messages: readonly { readonly body: unknown }[]
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
export interface NewsletterEnv {
|
|
4726
|
+
DB: Database
|
|
4727
|
+
ROOMS: RoomNamespace<unknown>
|
|
4728
|
+
NEWSLETTER: NewsletterQueue
|
|
4729
|
+
/** Set in wrangler.jsonc: the SES region, the From address, the SNS topic for feedback. */
|
|
4730
|
+
AWS_REGION: string
|
|
4731
|
+
NEWSLETTER_FROM: string
|
|
4732
|
+
SNS_TOPIC_ARN: string
|
|
4733
|
+
/** Secrets: \`wrangler secret put\` (.dev.vars locally). Unset until you add them. */
|
|
4734
|
+
AWS_ACCESS_KEY_ID?: string
|
|
4735
|
+
AWS_SECRET_ACCESS_KEY?: string
|
|
4736
|
+
NEWSLETTER_KEY?: string
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
const encoder = new TextEncoder()
|
|
4740
|
+
|
|
4741
|
+
const cell = (raw: unknown, key: string): unknown =>
|
|
4742
|
+
typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
|
|
4743
|
+
|
|
4744
|
+
/** A string column of a D1 row. Rows are read like any payload: checked, not cast. */
|
|
4745
|
+
function column(raw: unknown, key: string): string {
|
|
4746
|
+
const value = cell(raw, key)
|
|
4747
|
+
if (typeof value !== 'string') throw new Error(\`Expected a string in column \${key}\`)
|
|
4748
|
+
return value
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
async function sha256(value: string): Promise<string> {
|
|
4752
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))
|
|
4753
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
/** 32 random bytes, URL-safe: a confirmation or unsubscribe token. */
|
|
4757
|
+
function token(): string {
|
|
4758
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
|
4759
|
+
return btoa(String.fromCharCode(...bytes))
|
|
4760
|
+
.replace(/\\+/g, '-')
|
|
4761
|
+
.replace(/\\//g, '_')
|
|
4762
|
+
.replace(/=+$/, '')
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
/** Refuses a request without NEWSLETTER_KEY. Compares digests, so timing says nothing. */
|
|
4766
|
+
async function requireKey(env: NewsletterEnv, key: string): Promise<void> {
|
|
4767
|
+
if (!env.NEWSLETTER_KEY) throw new HttpError(503, 'Set NEWSLETTER_KEY (README)')
|
|
4768
|
+
if ((await sha256(key)) !== (await sha256(env.NEWSLETTER_KEY))) {
|
|
4769
|
+
throw new HttpError(403, 'Wrong newsletter key')
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
type Send = (
|
|
4774
|
+
to: string,
|
|
4775
|
+
message: RenderResult,
|
|
4776
|
+
headers?: Record<string, string>,
|
|
4777
|
+
) => Promise<'sent' | 'logged'>
|
|
4778
|
+
|
|
4779
|
+
/**
|
|
4780
|
+
* Sends one email through SES, or, in \`vite dev\` without AWS credentials, logs it instead.
|
|
4781
|
+
* Deployed without them, it refuses with what to set. The SES client is an \`EmailSender\`, so
|
|
4782
|
+
* \`sendEmail\` checks the message (subject, preheader, text part, size) and every header first.
|
|
4783
|
+
*/
|
|
4784
|
+
function mailer(env: NewsletterEnv): Send {
|
|
4785
|
+
const configured =
|
|
4786
|
+
env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_REGION && env.NEWSLETTER_FROM
|
|
4787
|
+
if (!configured) {
|
|
4788
|
+
if (!import.meta.env.DEV) {
|
|
4789
|
+
throw new HttpError(
|
|
4790
|
+
503,
|
|
4791
|
+
'Set AWS_REGION and NEWSLETTER_FROM in wrangler.jsonc, and the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets (README)',
|
|
4792
|
+
)
|
|
4793
|
+
}
|
|
4794
|
+
return async (to, message) => {
|
|
4795
|
+
console.log(\`[newsletter] not sent (no SES credentials) to \${to}: "\${message.subject}"\`)
|
|
4796
|
+
return 'logged'
|
|
4797
|
+
}
|
|
4798
|
+
}
|
|
4799
|
+
const ses = createSes({
|
|
4800
|
+
region: env.AWS_REGION,
|
|
4801
|
+
accessKeyId: env.AWS_ACCESS_KEY_ID!,
|
|
4802
|
+
secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
|
|
4803
|
+
})
|
|
4804
|
+
return async (to, message, headers) => {
|
|
4805
|
+
await sendEmail(ses, message, {
|
|
4806
|
+
from: env.NEWSLETTER_FROM,
|
|
4807
|
+
to,
|
|
4808
|
+
...(headers ? { headers } : {}),
|
|
4809
|
+
})
|
|
4810
|
+
return 'sent'
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
/**
|
|
4815
|
+
* Signs someone up: a pending subscriber and a confirmation email. The answer is the same
|
|
4816
|
+
* whoever asks, so the form does not tell strangers who is on the list. \`vite dev\` without
|
|
4817
|
+
* SES also returns the confirmation link, to open instead of an email.
|
|
4818
|
+
*/
|
|
4819
|
+
export async function subscribe(
|
|
4820
|
+
env: NewsletterEnv,
|
|
4821
|
+
rawEmail: string,
|
|
4822
|
+
origin: string,
|
|
4823
|
+
): Promise<{ devLink: string | null }> {
|
|
4824
|
+
const email = normalizeEmail(rawEmail)
|
|
4825
|
+
const send = mailer(env)
|
|
4826
|
+
await migrate(env.DB, migrations)
|
|
4827
|
+
const confirm = token()
|
|
4828
|
+
const now = new Date()
|
|
4829
|
+
const expires = now.getTime() + CONFIRM_TTL_MS
|
|
4830
|
+
// A new or unsubscribed address gets a link; a pending one gets a fresh link at most every
|
|
4831
|
+
// ten minutes. A subscribed or suppressed one is left alone, and nothing is sent.
|
|
4832
|
+
const [row] = await queryRows(
|
|
4833
|
+
env.DB,
|
|
4834
|
+
\`INSERT INTO subscribers
|
|
4835
|
+
(email, status, confirm_hash, confirm_expires, unsubscribe_token, created_at)
|
|
4836
|
+
VALUES (?, 'pending', ?, ?, ?, ?)
|
|
4837
|
+
ON CONFLICT (email) DO UPDATE SET
|
|
4838
|
+
status = 'pending', reason = NULL,
|
|
4839
|
+
confirm_hash = excluded.confirm_hash, confirm_expires = excluded.confirm_expires
|
|
4840
|
+
WHERE subscribers.status = 'unsubscribed'
|
|
4841
|
+
OR (subscribers.status = 'pending' AND subscribers.confirm_expires < ?)
|
|
4842
|
+
RETURNING email\`,
|
|
4843
|
+
[email, await sha256(confirm), expires, token(), now.toISOString(), expires - RESEND_AFTER_MS],
|
|
4844
|
+
(raw) => column(raw, 'email'),
|
|
4845
|
+
)
|
|
4846
|
+
if (!row) return { devLink: null }
|
|
4847
|
+
const link = \`\${origin}/newsletter/confirm?token=\${confirm}\`
|
|
4848
|
+
const message = renderConfirmation(link)
|
|
4849
|
+
let outcome: 'sent' | 'logged'
|
|
4850
|
+
try {
|
|
4851
|
+
outcome = await send(email, message)
|
|
4852
|
+
} catch (error) {
|
|
4853
|
+
if (!(error instanceof SesError)) throw error
|
|
4854
|
+
console.error('[newsletter] confirmation not sent:', error.code, error.message)
|
|
4855
|
+
// Let the reader try again at once rather than wait out the resend interval.
|
|
4856
|
+
await queryRows(
|
|
4857
|
+
env.DB,
|
|
4858
|
+
"UPDATE subscribers SET confirm_expires = 0 WHERE email = ? AND status = 'pending' RETURNING email",
|
|
4859
|
+
[email],
|
|
4860
|
+
(raw) => raw,
|
|
4861
|
+
)
|
|
4862
|
+
throw new HttpError(502, 'Could not send the confirmation email. Try again in a moment.')
|
|
4863
|
+
}
|
|
4864
|
+
return { devLink: outcome === 'logged' ? link : null }
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
/** Opens a confirmation link: the subscriber is on the list from now on. */
|
|
4868
|
+
export async function confirm(env: NewsletterEnv, rawToken: string): Promise<{ email: string }> {
|
|
4869
|
+
await migrate(env.DB, migrations)
|
|
4870
|
+
const [row] = await queryRows(
|
|
4871
|
+
env.DB,
|
|
4872
|
+
\`UPDATE subscribers SET status = 'subscribed', confirm_hash = NULL, confirmed_at = ?
|
|
4873
|
+
WHERE confirm_hash = ? AND confirm_expires > ? AND status = 'pending' RETURNING email\`,
|
|
4874
|
+
[new Date().toISOString(), await sha256(rawToken), Date.now()],
|
|
4875
|
+
(raw) => ({ email: column(raw, 'email') }),
|
|
4876
|
+
)
|
|
4877
|
+
if (!row) throw new HttpError(400, 'This link has expired or was used already. Sign up again.')
|
|
4878
|
+
return row
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
/**
|
|
4882
|
+
* \`POST /api/newsletter/unsubscribe?token=…\`: the page's button, and the one-click
|
|
4883
|
+
* unsubscribe mail clients send (RFC 8058) from the List-Unsubscribe header. Always 200, so a
|
|
4884
|
+
* token says nothing about who it belongs to, and a second click is not an error.
|
|
4885
|
+
*/
|
|
4886
|
+
export async function unsubscribe(request: Request, env: NewsletterEnv): Promise<Response> {
|
|
4887
|
+
const unsubscribeToken = new URL(request.url).searchParams.get('token') ?? ''
|
|
4888
|
+
await migrate(env.DB, migrations)
|
|
4889
|
+
await queryRows(
|
|
4890
|
+
env.DB,
|
|
4891
|
+
\`UPDATE subscribers SET status = 'unsubscribed', reason = 'unsubscribed'
|
|
4892
|
+
WHERE unsubscribe_token = ? AND status IN ('pending', 'subscribed') RETURNING email\`,
|
|
4893
|
+
[unsubscribeToken],
|
|
4894
|
+
(raw) => raw,
|
|
4895
|
+
)
|
|
4896
|
+
return Response.json({ unsubscribed: true })
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
const ISSUE_COLUMNS = \`issues.id, issues.subject, issues.created_at AS createdAt, issues.total,
|
|
4900
|
+
(SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status != 'failed') AS sent,
|
|
4901
|
+
(SELECT COUNT(*) FROM deliveries d WHERE d.issue_id = issues.id AND d.status = 'failed') AS failed\`
|
|
4902
|
+
|
|
4903
|
+
async function readIssue(db: Database, id: string): Promise<Issue> {
|
|
4904
|
+
const [issue] = await queryRows(
|
|
4905
|
+
db,
|
|
4906
|
+
\`SELECT \${ISSUE_COLUMNS} FROM issues WHERE id = ?\`,
|
|
4907
|
+
[id],
|
|
4908
|
+
parseIssue,
|
|
4909
|
+
)
|
|
4910
|
+
if (!issue) throw new HttpError(404, 'No such issue')
|
|
4911
|
+
return issue
|
|
4912
|
+
}
|
|
4913
|
+
|
|
4914
|
+
/** Subscriber counts and the last 20 issues, for the composer. */
|
|
4915
|
+
export async function overview(env: NewsletterEnv, key: string): Promise<Overview> {
|
|
4916
|
+
await requireKey(env, key)
|
|
4917
|
+
await migrate(env.DB, migrations)
|
|
4918
|
+
const subscribers: SubscriberCounts = {
|
|
4919
|
+
pending: 0,
|
|
4920
|
+
subscribed: 0,
|
|
4921
|
+
unsubscribed: 0,
|
|
4922
|
+
suppressed: 0,
|
|
4923
|
+
}
|
|
4924
|
+
const counts = await queryRows(
|
|
4925
|
+
env.DB,
|
|
4926
|
+
'SELECT status, COUNT(*) AS n FROM subscribers GROUP BY status',
|
|
4927
|
+
[],
|
|
4928
|
+
(raw) => ({ status: column(raw, 'status'), n: Number(cell(raw, 'n')) }),
|
|
4929
|
+
)
|
|
4930
|
+
for (const { status, n } of counts) {
|
|
4931
|
+
if (status === 'pending' || status === 'subscribed') subscribers[status] = n
|
|
4932
|
+
if (status === 'unsubscribed' || status === 'suppressed') subscribers[status] = n
|
|
4933
|
+
}
|
|
4934
|
+
const issues = await queryRows(
|
|
4935
|
+
env.DB,
|
|
4936
|
+
\`SELECT \${ISSUE_COLUMNS} FROM issues ORDER BY created_at DESC LIMIT 20\`,
|
|
4937
|
+
[],
|
|
4938
|
+
parseIssue,
|
|
4939
|
+
)
|
|
4940
|
+
return { subscribers, issues }
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
/** The issue as readers will get it, for the composer's preview. */
|
|
4944
|
+
export async function preview(
|
|
4945
|
+
env: NewsletterEnv,
|
|
4946
|
+
input: IssueInput,
|
|
4947
|
+
origin: string,
|
|
4948
|
+
): Promise<{ html: string; bytes: number }> {
|
|
4949
|
+
await requireKey(env, input.key)
|
|
4950
|
+
const { html, stats } = renderIssue(input.subject, input.body, origin)
|
|
4951
|
+
return { html, bytes: stats.bytes }
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
/**
|
|
4955
|
+
* Sends an issue to every confirmed subscriber: the issue is stored, and its readers go onto
|
|
4956
|
+
* the NEWSLETTER queue in chunks, which \`deliver\` sends at the queue's pace.
|
|
4957
|
+
*/
|
|
4958
|
+
export async function sendIssue(
|
|
4959
|
+
env: NewsletterEnv,
|
|
4960
|
+
input: IssueInput,
|
|
4961
|
+
origin: string,
|
|
4962
|
+
): Promise<Issue> {
|
|
4963
|
+
await requireKey(env, input.key)
|
|
4964
|
+
mailer(env) // Deployed without SES, refuse now rather than fail in the queue.
|
|
4965
|
+
try {
|
|
4966
|
+
// An issue too large to send (Gmail clips it) is refused here, not retried in the queue.
|
|
4967
|
+
assertSendable(renderIssue(input.subject, input.body, origin))
|
|
4968
|
+
} catch (error) {
|
|
4969
|
+
throw new HttpError(400, error instanceof Error ? error.message : String(error))
|
|
4970
|
+
}
|
|
4971
|
+
await migrate(env.DB, migrations)
|
|
4972
|
+
const readers = await queryRows(
|
|
4973
|
+
env.DB,
|
|
4974
|
+
"SELECT email FROM subscribers WHERE status = 'subscribed' ORDER BY email",
|
|
4975
|
+
[],
|
|
4976
|
+
(raw) => column(raw, 'email'),
|
|
4977
|
+
)
|
|
4978
|
+
const id = crypto.randomUUID()
|
|
4979
|
+
await queryRows(
|
|
4980
|
+
env.DB,
|
|
4981
|
+
'INSERT INTO issues (id, subject, body, total, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id',
|
|
4982
|
+
[id, input.subject, input.body, readers.length, new Date().toISOString()],
|
|
4983
|
+
(raw) => raw,
|
|
4984
|
+
)
|
|
4985
|
+
const messages: { body: NewsletterMessage }[] = []
|
|
4986
|
+
for (let i = 0; i < readers.length; i += CHUNK) {
|
|
4987
|
+
messages.push({ body: { issueId: id, emails: readers.slice(i, i + CHUNK), origin } })
|
|
4988
|
+
}
|
|
4989
|
+
// sendBatch takes at most 100 messages a call.
|
|
4990
|
+
for (let i = 0; i < messages.length; i += 100) {
|
|
4991
|
+
await env.NEWSLETTER.sendBatch(messages.slice(i, i + 100))
|
|
4992
|
+
}
|
|
4993
|
+
const issue = await readIssue(env.DB, id)
|
|
4994
|
+
await writeRoom(env.ROOMS, issueRoom(id), 'issue', { ...issue })
|
|
4995
|
+
return issue
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
function parseMessage(raw: unknown): NewsletterMessage {
|
|
4999
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
5000
|
+
const { issueId, emails, origin } = raw as Record<string, unknown>
|
|
5001
|
+
if (
|
|
5002
|
+
typeof issueId === 'string' &&
|
|
5003
|
+
ISSUE_ID.test(issueId) &&
|
|
5004
|
+
Array.isArray(emails) &&
|
|
5005
|
+
emails.every((e) => typeof e === 'string') &&
|
|
5006
|
+
typeof origin === 'string'
|
|
5007
|
+
) {
|
|
5008
|
+
return { issueId, emails: emails as string[], origin }
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
throw new Error('Malformed newsletter message')
|
|
5012
|
+
}
|
|
5013
|
+
|
|
5014
|
+
/**
|
|
5015
|
+
* The NEWSLETTER queue's consumer: sends each reader in the message their copy, with their
|
|
5016
|
+
* own unsubscribe link and the one-click headers bulk senders need. A reader who left or was
|
|
5017
|
+
* suppressed since the issue was queued is skipped. SES throttling or a server error throws,
|
|
5018
|
+
* and the queue retries the message: whoever was sent already has a delivery row and is not
|
|
5019
|
+
* sent again. A refusal for one address (an invalid one, say) is recorded and the rest go on.
|
|
5020
|
+
*/
|
|
5021
|
+
export async function deliver(env: NewsletterEnv, batch: NewsletterBatch): Promise<void> {
|
|
5022
|
+
await migrate(env.DB, migrations)
|
|
5023
|
+
const send = mailer(env)
|
|
5024
|
+
for (const { body } of batch.messages) {
|
|
5025
|
+
const { issueId, emails, origin } = parseMessage(body)
|
|
5026
|
+
const [issue] = await queryRows(
|
|
5027
|
+
env.DB,
|
|
5028
|
+
'SELECT subject, body FROM issues WHERE id = ?',
|
|
5029
|
+
[issueId],
|
|
5030
|
+
(raw) => ({ subject: column(raw, 'subject'), body: column(raw, 'body') }),
|
|
5031
|
+
)
|
|
5032
|
+
if (!issue) continue
|
|
5033
|
+
const rendered = renderIssue(issue.subject, issue.body, origin)
|
|
5034
|
+
const placeholders = emails.map(() => '?').join(', ')
|
|
5035
|
+
const readers = await queryRows(
|
|
5036
|
+
env.DB,
|
|
5037
|
+
\`SELECT s.email, s.unsubscribe_token AS token FROM subscribers s
|
|
5038
|
+
WHERE s.email IN (\${placeholders}) AND s.status = 'subscribed'
|
|
5039
|
+
AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.issue_id = ? AND d.email = s.email)\`,
|
|
5040
|
+
[...emails, issueId],
|
|
5041
|
+
(raw) => ({ email: column(raw, 'email'), token: column(raw, 'token') }),
|
|
5042
|
+
)
|
|
5043
|
+
for (const { email, token: readerToken } of readers) {
|
|
5044
|
+
const unsubscribe = \`\${origin}/api/newsletter/unsubscribe?token=\${readerToken}\`
|
|
5045
|
+
let status: string
|
|
5046
|
+
let detail: string | null = null
|
|
5047
|
+
try {
|
|
5048
|
+
status = await send(
|
|
5049
|
+
email,
|
|
5050
|
+
{
|
|
5051
|
+
...rendered,
|
|
5052
|
+
html: rendered.html.replaceAll(TOKEN_SLOT, readerToken),
|
|
5053
|
+
text: rendered.text.replaceAll(TOKEN_SLOT, readerToken),
|
|
5054
|
+
},
|
|
5055
|
+
{
|
|
5056
|
+
'List-Unsubscribe': \`<\${unsubscribe}>\`,
|
|
5057
|
+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
5058
|
+
},
|
|
5059
|
+
)
|
|
5060
|
+
} catch (error) {
|
|
5061
|
+
if (!(error instanceof SesError) || error.retryable) throw error
|
|
5062
|
+
status = 'failed'
|
|
5063
|
+
detail = \`\${error.code ?? error.status}: \${error.message}\`.slice(0, 300)
|
|
5064
|
+
}
|
|
5065
|
+
await queryRows(
|
|
5066
|
+
env.DB,
|
|
5067
|
+
\`INSERT INTO deliveries (issue_id, email, status, detail, at) VALUES (?, ?, ?, ?, ?)
|
|
5068
|
+
ON CONFLICT DO NOTHING RETURNING email\`,
|
|
5069
|
+
[issueId, email, status, detail, new Date().toISOString()],
|
|
5070
|
+
(raw) => raw,
|
|
5071
|
+
)
|
|
5072
|
+
}
|
|
5073
|
+
const progress = await readIssue(env.DB, issueId)
|
|
5074
|
+
await writeRoom(env.ROOMS, issueRoom(issueId), 'issue', { ...progress })
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
/**
|
|
5079
|
+
* SES's feedback, delivered by SNS: a permanent bounce or a complaint suppresses the address
|
|
5080
|
+
* for good. Sending to them again is what gets an SES account put under review.
|
|
5081
|
+
*/
|
|
5082
|
+
export async function receiveFeedback(request: Request, env: NewsletterEnv): Promise<Response> {
|
|
5083
|
+
if (!env.SNS_TOPIC_ARN) throw new HttpError(503, 'Set SNS_TOPIC_ARN in wrangler.jsonc (README)')
|
|
5084
|
+
return handleSns(request, {
|
|
5085
|
+
topicArn: env.SNS_TOPIC_ARN,
|
|
5086
|
+
onNotification: async ({ message }) => {
|
|
5087
|
+
const event = parseSesNotification(message)
|
|
5088
|
+
const suppress =
|
|
5089
|
+
event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')
|
|
5090
|
+
if (!suppress || event.recipients.length === 0) return
|
|
5091
|
+
await migrate(env.DB, migrations)
|
|
5092
|
+
// D1 binds at most 100 parameters; one bounce rarely names more than a few addresses.
|
|
5093
|
+
const recipients = event.recipients
|
|
5094
|
+
.slice(0, 90)
|
|
5095
|
+
.map((address) => address.trim().toLowerCase())
|
|
5096
|
+
await queryRows(
|
|
5097
|
+
env.DB,
|
|
5098
|
+
\`UPDATE subscribers SET status = 'suppressed', reason = ?
|
|
5099
|
+
WHERE email IN (\${recipients.map(() => '?').join(', ')}) RETURNING email\`,
|
|
5100
|
+
[event.kind, ...recipients],
|
|
5101
|
+
(raw) => raw,
|
|
5102
|
+
)
|
|
5103
|
+
},
|
|
5104
|
+
})
|
|
5105
|
+
}
|
|
5106
|
+
`}function Ot(){return`import { createClient } from '@cascivo/app/api'
|
|
5107
|
+
import {
|
|
5108
|
+
Alert,
|
|
5109
|
+
Button,
|
|
5110
|
+
Card,
|
|
5111
|
+
CardContent,
|
|
5112
|
+
Flex,
|
|
5113
|
+
Heading,
|
|
5114
|
+
Input,
|
|
5115
|
+
Link,
|
|
5116
|
+
Text,
|
|
5117
|
+
signal,
|
|
5118
|
+
useSignals,
|
|
5119
|
+
} from '@cascivo/react'
|
|
5120
|
+
import type { FormEvent } from 'react'
|
|
5121
|
+
import { api } from '../api'
|
|
5122
|
+
|
|
5123
|
+
const client = createClient(api)
|
|
5124
|
+
const sentTo = signal<string | null>(null)
|
|
5125
|
+
/** Set only in \`vite dev\` without SES: the confirmation link, to open instead of an email. */
|
|
5126
|
+
const devLink = signal<string | null>(null)
|
|
5127
|
+
const failure = signal<string | null>(null)
|
|
5128
|
+
const sending = signal(false)
|
|
5129
|
+
|
|
5130
|
+
async function signUp(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
5131
|
+
event.preventDefault()
|
|
5132
|
+
const email = new FormData(event.currentTarget).get('email')
|
|
5133
|
+
if (typeof email !== 'string') return
|
|
5134
|
+
failure.value = null
|
|
5135
|
+
sending.value = true
|
|
5136
|
+
try {
|
|
5137
|
+
const { devLink: link } = await client.subscribe({ body: { email } })
|
|
5138
|
+
sentTo.value = email
|
|
5139
|
+
devLink.value = link
|
|
5140
|
+
} catch (error) {
|
|
5141
|
+
failure.value = error instanceof Error ? error.message : 'Could not sign you up'
|
|
5142
|
+
} finally {
|
|
5143
|
+
sending.value = false
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5147
|
+
export default function Newsletter() {
|
|
5148
|
+
useSignals()
|
|
5149
|
+
return (
|
|
5150
|
+
<Flex gap={4}>
|
|
5151
|
+
<Flex gap={1}>
|
|
5152
|
+
<Heading level={1}>Newsletter</Heading>
|
|
5153
|
+
<Text muted>
|
|
5154
|
+
An email now and then. We send a link first: you are on the list only once you open it,
|
|
5155
|
+
and every issue has a one-click unsubscribe.
|
|
5156
|
+
</Text>
|
|
5157
|
+
</Flex>
|
|
5158
|
+
<Card>
|
|
5159
|
+
<CardContent>
|
|
5160
|
+
<form onSubmit={(event) => void signUp(event)}>
|
|
5161
|
+
<Flex direction="horizontal" align="end" gap={2} wrap>
|
|
5162
|
+
<Input name="email" type="email" label="Email" autoComplete="email" required />
|
|
5163
|
+
<Button type="submit" loading={sending.value}>
|
|
5164
|
+
Subscribe
|
|
5165
|
+
</Button>
|
|
5166
|
+
</Flex>
|
|
5167
|
+
</form>
|
|
5168
|
+
</CardContent>
|
|
5169
|
+
</Card>
|
|
5170
|
+
{sentTo.value ? (
|
|
5171
|
+
<Alert variant="success" title="Check your inbox">
|
|
5172
|
+
If {sentTo.value} is not on the list yet, a confirmation link is on its way.
|
|
5173
|
+
</Alert>
|
|
5174
|
+
) : null}
|
|
5175
|
+
{devLink.value ? (
|
|
5176
|
+
<Alert variant="info" title="vite dev sends no email without SES">
|
|
5177
|
+
<Link href={devLink.value}>Open the confirmation link</Link>
|
|
5178
|
+
</Alert>
|
|
5179
|
+
) : null}
|
|
5180
|
+
{failure.value ? (
|
|
5181
|
+
<Alert variant="destructive" title="Not signed up">
|
|
5182
|
+
{failure.value}
|
|
5183
|
+
</Alert>
|
|
5184
|
+
) : null}
|
|
5185
|
+
</Flex>
|
|
5186
|
+
)
|
|
5187
|
+
}
|
|
5188
|
+
`}function kt(){return`import { createClient } from '@cascivo/app/api'
|
|
5189
|
+
import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
|
|
5190
|
+
import { api } from '../../api'
|
|
5191
|
+
import { router } from '../../router'
|
|
5192
|
+
|
|
5193
|
+
const client = createClient(api)
|
|
5194
|
+
const confirmed = signal<string | null>(null)
|
|
5195
|
+
const failure = signal<string | null>(null)
|
|
5196
|
+
const busy = signal(false)
|
|
5197
|
+
|
|
5198
|
+
/**
|
|
5199
|
+
* The page a confirmation link opens. It confirms only when you press the button: mail
|
|
5200
|
+
* scanners open every link in a message, and would otherwise subscribe whoever was typed in.
|
|
5201
|
+
*/
|
|
5202
|
+
async function confirm(): Promise<void> {
|
|
5203
|
+
const token = new URLSearchParams(router.search.value).get('token')
|
|
5204
|
+
if (!token) {
|
|
5205
|
+
failure.value = 'This link has no token. Sign up again.'
|
|
5206
|
+
return
|
|
5207
|
+
}
|
|
5208
|
+
busy.value = true
|
|
5209
|
+
failure.value = null
|
|
5210
|
+
try {
|
|
5211
|
+
confirmed.value = (await client.confirmSubscription({ body: { token } })).email
|
|
5212
|
+
} catch (error) {
|
|
5213
|
+
failure.value = error instanceof Error ? error.message : 'Could not confirm'
|
|
5214
|
+
} finally {
|
|
5215
|
+
busy.value = false
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
|
|
5219
|
+
export default function ConfirmSubscription() {
|
|
5220
|
+
useSignals()
|
|
5221
|
+
return (
|
|
5222
|
+
<Flex gap={4}>
|
|
5223
|
+
<Heading level={1}>Confirm your subscription</Heading>
|
|
5224
|
+
{confirmed.value ? (
|
|
5225
|
+
<Alert variant="success" title="You are on the list">
|
|
5226
|
+
The next issue goes to {confirmed.value}.
|
|
5227
|
+
</Alert>
|
|
5228
|
+
) : (
|
|
5229
|
+
<Flex gap={2}>
|
|
5230
|
+
<Text muted>One click and you get the newsletter.</Text>
|
|
5231
|
+
<Flex direction="horizontal">
|
|
5232
|
+
<Button loading={busy.value} onClick={() => void confirm()}>
|
|
5233
|
+
Yes, subscribe me
|
|
5234
|
+
</Button>
|
|
5235
|
+
</Flex>
|
|
5236
|
+
</Flex>
|
|
5237
|
+
)}
|
|
5238
|
+
{failure.value ? (
|
|
5239
|
+
<Alert variant="destructive" title="Not confirmed">
|
|
5240
|
+
{failure.value}
|
|
5241
|
+
</Alert>
|
|
5242
|
+
) : null}
|
|
5243
|
+
</Flex>
|
|
5244
|
+
)
|
|
5245
|
+
}
|
|
5246
|
+
`}function At(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
|
|
5247
|
+
import { router } from '../../router'
|
|
5248
|
+
|
|
5249
|
+
const done = signal(false)
|
|
5250
|
+
const failure = signal<string | null>(null)
|
|
5251
|
+
const busy = signal(false)
|
|
5252
|
+
|
|
5253
|
+
/**
|
|
5254
|
+
* The footer link of every issue. Like the confirmation, it acts on a button press, never on
|
|
5255
|
+
* opening the page. Mail clients that support one-click unsubscribe skip this page and POST
|
|
5256
|
+
* to the same endpoint from the List-Unsubscribe header.
|
|
5257
|
+
*/
|
|
5258
|
+
async function unsubscribe(): Promise<void> {
|
|
5259
|
+
const token = new URLSearchParams(router.search.value).get('token') ?? ''
|
|
5260
|
+
busy.value = true
|
|
5261
|
+
failure.value = null
|
|
5262
|
+
try {
|
|
5263
|
+
const response = await fetch(\`/api/newsletter/unsubscribe?token=\${encodeURIComponent(token)}\`, {
|
|
5264
|
+
method: 'POST',
|
|
5265
|
+
})
|
|
5266
|
+
if (!response.ok) throw new Error(\`Request failed with status \${response.status}\`)
|
|
5267
|
+
done.value = true
|
|
5268
|
+
} catch (error) {
|
|
5269
|
+
failure.value = error instanceof Error ? error.message : 'Could not unsubscribe'
|
|
5270
|
+
} finally {
|
|
5271
|
+
busy.value = false
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
|
|
5275
|
+
export default function Unsubscribe() {
|
|
5276
|
+
useSignals()
|
|
5277
|
+
return (
|
|
5278
|
+
<Flex gap={4}>
|
|
5279
|
+
<Heading level={1}>Unsubscribe</Heading>
|
|
5280
|
+
{done.value ? (
|
|
5281
|
+
<Alert variant="success" title="You are unsubscribed">
|
|
5282
|
+
You will get no more issues. Sign up again any time.
|
|
5283
|
+
</Alert>
|
|
5284
|
+
) : (
|
|
5285
|
+
<Flex gap={2}>
|
|
5286
|
+
<Text muted>Stop getting the newsletter at this address.</Text>
|
|
5287
|
+
<Flex direction="horizontal">
|
|
5288
|
+
<Button variant="destructive" loading={busy.value} onClick={() => void unsubscribe()}>
|
|
5289
|
+
Unsubscribe
|
|
5290
|
+
</Button>
|
|
5291
|
+
</Flex>
|
|
5292
|
+
</Flex>
|
|
5293
|
+
)}
|
|
5294
|
+
{failure.value ? (
|
|
5295
|
+
<Alert variant="destructive" title="Not unsubscribed">
|
|
5296
|
+
{failure.value}
|
|
5297
|
+
</Alert>
|
|
5298
|
+
) : null}
|
|
5299
|
+
</Flex>
|
|
5300
|
+
)
|
|
5301
|
+
}
|
|
5302
|
+
`}function jt(){return`import { createClient } from '@cascivo/app/api'
|
|
5303
|
+
import { connectRoom } from '@cascivo/app/sync'
|
|
5304
|
+
import {
|
|
5305
|
+
Alert,
|
|
5306
|
+
Badge,
|
|
5307
|
+
Button,
|
|
5308
|
+
Card,
|
|
5309
|
+
CardContent,
|
|
5310
|
+
EmptyState,
|
|
5311
|
+
Flex,
|
|
5312
|
+
Heading,
|
|
5313
|
+
Input,
|
|
5314
|
+
Text,
|
|
5315
|
+
Textarea,
|
|
5316
|
+
signal,
|
|
5317
|
+
useSignals,
|
|
5318
|
+
} from '@cascivo/react'
|
|
5319
|
+
import type { MouseEvent } from 'react'
|
|
5320
|
+
import { api } from '../../api'
|
|
5321
|
+
import { MAX_BODY, MAX_SUBJECT, parseIssue } from '../../newsletter'
|
|
5322
|
+
import type { Issue, Overview } from '../../newsletter'
|
|
5323
|
+
|
|
5324
|
+
const client = createClient(api)
|
|
5325
|
+
const overview = signal<Overview | null>(null)
|
|
5326
|
+
const previewHtml = signal<string | null>(null)
|
|
5327
|
+
const busy = signal<'load' | 'preview' | 'send' | null>(null)
|
|
5328
|
+
const failure = signal<string | null>(null)
|
|
5329
|
+
const notice = signal<string | null>(null)
|
|
5330
|
+
|
|
5331
|
+
/** The composer's fields, read from the form the clicked button belongs to. */
|
|
5332
|
+
function fields(event: MouseEvent<HTMLButtonElement>) {
|
|
5333
|
+
const form = event.currentTarget.form
|
|
5334
|
+
const data = form ? new FormData(form) : new FormData()
|
|
5335
|
+
const read = (name: string) => {
|
|
5336
|
+
const value = data.get(name)
|
|
5337
|
+
return typeof value === 'string' ? value : ''
|
|
5338
|
+
}
|
|
5339
|
+
return { key: read('key'), subject: read('subject'), body: read('body') }
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
async function run(kind: 'load' | 'preview' | 'send', task: () => Promise<void>): Promise<void> {
|
|
5343
|
+
busy.value = kind
|
|
5344
|
+
failure.value = null
|
|
5345
|
+
notice.value = null
|
|
5346
|
+
try {
|
|
5347
|
+
await task()
|
|
5348
|
+
} catch (error) {
|
|
5349
|
+
failure.value = error instanceof Error ? error.message : 'Something went wrong'
|
|
5350
|
+
} finally {
|
|
5351
|
+
busy.value = null
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5355
|
+
/** Shows an issue's progress as the Worker pushes it, until every reader has had their copy. */
|
|
5356
|
+
function watch(issue: Issue): void {
|
|
5357
|
+
const room = connectRoom(\`/api/newsletter/issues/\${issue.id}/live\`)
|
|
5358
|
+
const pushed = room.signal<Issue | null>('issue', null, (raw) =>
|
|
5359
|
+
raw === null ? null : parseIssue(raw),
|
|
5360
|
+
)
|
|
5361
|
+
const stop = pushed.signal.subscribe((latest) => {
|
|
5362
|
+
const current = overview.peek()
|
|
5363
|
+
if (!latest || !current) return
|
|
5364
|
+
overview.value = {
|
|
5365
|
+
...current,
|
|
5366
|
+
issues: current.issues.map((i) => (i.id === latest.id ? latest : i)),
|
|
5367
|
+
}
|
|
5368
|
+
if (latest.sent + latest.failed >= latest.total) {
|
|
5369
|
+
stop()
|
|
5370
|
+
room.close()
|
|
5371
|
+
}
|
|
5372
|
+
})
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5375
|
+
function load(event: MouseEvent<HTMLButtonElement>): void {
|
|
5376
|
+
const { key } = fields(event)
|
|
5377
|
+
void run('load', async () => {
|
|
5378
|
+
overview.value = await client.newsletterOverview({ body: { key } })
|
|
5379
|
+
})
|
|
5380
|
+
}
|
|
5381
|
+
|
|
5382
|
+
function preview(event: MouseEvent<HTMLButtonElement>): void {
|
|
5383
|
+
const input = fields(event)
|
|
5384
|
+
void run('preview', async () => {
|
|
5385
|
+
previewHtml.value = (await client.previewIssue({ body: input })).html
|
|
5386
|
+
})
|
|
5387
|
+
}
|
|
5388
|
+
|
|
5389
|
+
function send(event: MouseEvent<HTMLButtonElement>): void {
|
|
5390
|
+
const input = fields(event)
|
|
5391
|
+
const readers = overview.value?.subscribers.subscribed ?? 0
|
|
5392
|
+
if (!window.confirm(\`Send "\${input.subject}" to \${readers} subscribers?\`)) return
|
|
5393
|
+
void run('send', async () => {
|
|
5394
|
+
const issue = await client.sendIssue({ body: input })
|
|
5395
|
+
overview.value = await client.newsletterOverview({ body: { key: input.key } })
|
|
5396
|
+
notice.value = \`Queued for \${issue.total} subscribers.\`
|
|
5397
|
+
watch(issue)
|
|
5398
|
+
})
|
|
5399
|
+
}
|
|
5400
|
+
|
|
5401
|
+
export default function SendNewsletter() {
|
|
5402
|
+
useSignals()
|
|
5403
|
+
const counts = overview.value?.subscribers
|
|
5404
|
+
return (
|
|
5405
|
+
<Flex gap={4}>
|
|
5406
|
+
<Flex gap={1}>
|
|
5407
|
+
<Heading level={1}>Send the newsletter</Heading>
|
|
5408
|
+
<Text muted>
|
|
5409
|
+
Written in Markdown, rendered with @cascivo/email, sent through Amazon SES to every
|
|
5410
|
+
confirmed subscriber. Only someone with NEWSLETTER_KEY can send.
|
|
5411
|
+
</Text>
|
|
5412
|
+
</Flex>
|
|
5413
|
+
<form onSubmit={(event) => event.preventDefault()}>
|
|
5414
|
+
<Flex gap={3}>
|
|
5415
|
+
<Flex direction="horizontal" align="end" gap={2} wrap>
|
|
5416
|
+
<Input name="key" type="password" label="Newsletter key" autoComplete="off" required />
|
|
5417
|
+
<Button variant="secondary" loading={busy.value === 'load'} onClick={load}>
|
|
5418
|
+
Show subscribers
|
|
5419
|
+
</Button>
|
|
5420
|
+
</Flex>
|
|
5421
|
+
<Input name="subject" label="Subject" maxLength={MAX_SUBJECT} required />
|
|
5422
|
+
<Textarea
|
|
5423
|
+
name="body"
|
|
5424
|
+
label="Body"
|
|
5425
|
+
hint="Markdown: headings, **bold**, links, lists, quotes and images."
|
|
5426
|
+
rows={12}
|
|
5427
|
+
maxLength={MAX_BODY}
|
|
5428
|
+
required
|
|
5429
|
+
/>
|
|
5430
|
+
<Flex direction="horizontal" gap={2} wrap>
|
|
5431
|
+
<Button variant="secondary" loading={busy.value === 'preview'} onClick={preview}>
|
|
5432
|
+
Preview
|
|
5433
|
+
</Button>
|
|
5434
|
+
<Button loading={busy.value === 'send'} disabled={!counts} onClick={send}>
|
|
5435
|
+
Send to {counts ? counts.subscribed : '…'} subscribers
|
|
5436
|
+
</Button>
|
|
5437
|
+
</Flex>
|
|
5438
|
+
</Flex>
|
|
5439
|
+
</form>
|
|
5440
|
+
{failure.value ? (
|
|
5441
|
+
<Alert variant="destructive" title="Not done">
|
|
5442
|
+
{failure.value}
|
|
5443
|
+
</Alert>
|
|
5444
|
+
) : null}
|
|
5445
|
+
{notice.value ? (
|
|
5446
|
+
<Alert variant="success" title="Sending">
|
|
5447
|
+
{notice.value}
|
|
5448
|
+
</Alert>
|
|
5449
|
+
) : null}
|
|
5450
|
+
{previewHtml.value ? (
|
|
5451
|
+
<Card>
|
|
5452
|
+
<CardContent>
|
|
5453
|
+
{/* sandbox: the preview runs no script and cannot reach this page. */}
|
|
5454
|
+
<iframe
|
|
5455
|
+
title="Preview"
|
|
5456
|
+
sandbox=""
|
|
5457
|
+
srcDoc={previewHtml.value}
|
|
5458
|
+
width="100%"
|
|
5459
|
+
height="640"
|
|
5460
|
+
/>
|
|
5461
|
+
</CardContent>
|
|
5462
|
+
</Card>
|
|
5463
|
+
) : null}
|
|
5464
|
+
{counts ? (
|
|
5465
|
+
<Flex direction="horizontal" gap={2} wrap>
|
|
5466
|
+
<Badge variant="success">{counts.subscribed} subscribed</Badge>
|
|
5467
|
+
<Badge variant="warning">{counts.pending} not confirmed</Badge>
|
|
5468
|
+
<Badge variant="secondary">{counts.unsubscribed} unsubscribed</Badge>
|
|
5469
|
+
<Badge variant="destructive">{counts.suppressed} suppressed</Badge>
|
|
5470
|
+
</Flex>
|
|
5471
|
+
) : null}
|
|
5472
|
+
{overview.value && overview.value.issues.length === 0 ? (
|
|
5473
|
+
<EmptyState title="No issues yet" description="Write one above and send it." />
|
|
5474
|
+
) : null}
|
|
5475
|
+
{overview.value && overview.value.issues.length > 0 ? (
|
|
5476
|
+
<Flex gap={2} role="list" aria-label="Issues">
|
|
5477
|
+
{overview.value.issues.map((issue) => (
|
|
5478
|
+
<Flex key={issue.id} role="listitem" direction="horizontal" align="center" gap={2} wrap>
|
|
5479
|
+
<Badge variant={issue.sent + issue.failed >= issue.total ? 'success' : 'warning'}>
|
|
5480
|
+
{issue.sent}/{issue.total} sent
|
|
5481
|
+
</Badge>
|
|
5482
|
+
{issue.failed > 0 ? <Badge variant="destructive">{issue.failed} failed</Badge> : null}
|
|
5483
|
+
<Text>{issue.subject}</Text>
|
|
5484
|
+
<Text size="sm" muted>
|
|
5485
|
+
{new Date(issue.createdAt).toLocaleString()}
|
|
5486
|
+
</Text>
|
|
5487
|
+
</Flex>
|
|
5488
|
+
))}
|
|
5489
|
+
</Flex>
|
|
5490
|
+
) : null}
|
|
5491
|
+
</Flex>
|
|
5492
|
+
)
|
|
5493
|
+
}
|
|
5494
|
+
`}function Mt(){return`/**
|
|
5495
|
+
* The subscription plan /billing sells, shared by the Worker (worker/billing.ts) and the page.
|
|
5496
|
+
* A subscription belongs to a signed-in user, so this exists only with --auth email.
|
|
5497
|
+
*/
|
|
5498
|
+
|
|
5499
|
+
/** The plan. The Worker sends this to Stripe; the page only displays it. */
|
|
5500
|
+
export const PLAN = {
|
|
5501
|
+
name: 'Pro',
|
|
5502
|
+
description: 'Everything in the app, billed monthly. Cancel any time from the billing portal.',
|
|
5503
|
+
/** In the currency's smallest unit, per interval: 900 is €9.00. */
|
|
5504
|
+
amount: 900,
|
|
5505
|
+
currency: 'eur',
|
|
5506
|
+
interval: 'month' as const,
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
/** \`none\` before the first subscription; otherwise Stripe's subscription status. */
|
|
5510
|
+
export type BillingStatus =
|
|
5511
|
+
| 'none'
|
|
5512
|
+
| 'incomplete'
|
|
5513
|
+
| 'incomplete_expired'
|
|
5514
|
+
| 'trialing'
|
|
5515
|
+
| 'active'
|
|
5516
|
+
| 'past_due'
|
|
5517
|
+
| 'canceled'
|
|
5518
|
+
| 'unpaid'
|
|
5519
|
+
| 'paused'
|
|
5520
|
+
|
|
5521
|
+
const STATUSES: readonly BillingStatus[] = [
|
|
5522
|
+
'none',
|
|
5523
|
+
'incomplete',
|
|
5524
|
+
'incomplete_expired',
|
|
5525
|
+
'trialing',
|
|
5526
|
+
'active',
|
|
5527
|
+
'past_due',
|
|
5528
|
+
'canceled',
|
|
5529
|
+
'unpaid',
|
|
5530
|
+
'paused',
|
|
5531
|
+
]
|
|
5532
|
+
|
|
5533
|
+
export interface Billing {
|
|
5534
|
+
status: BillingStatus
|
|
5535
|
+
/**
|
|
5536
|
+
* Whether the plan's features are on: the subscription is active or trialing, or past due
|
|
5537
|
+
* while Stripe retries the renewal (\`isEntitled\` from @cascivo/app/stripe, in the Worker).
|
|
5538
|
+
*/
|
|
5539
|
+
active: boolean
|
|
5540
|
+
/** When the current period ends: the next charge, or the end of a cancelled plan. */
|
|
5541
|
+
currentPeriodEnd: string | null
|
|
5542
|
+
/** Cancelled, but running until currentPeriodEnd. */
|
|
5543
|
+
cancelAtPeriodEnd: boolean
|
|
5544
|
+
/** Has a Stripe customer, so the billing portal can open. */
|
|
5545
|
+
canManage: boolean
|
|
5546
|
+
}
|
|
5547
|
+
|
|
5548
|
+
export function parseBilling(raw: unknown): Billing {
|
|
5549
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
5550
|
+
const { status, active, currentPeriodEnd, cancelAtPeriodEnd, canManage } = raw as Record<
|
|
5551
|
+
string,
|
|
5552
|
+
unknown
|
|
5553
|
+
>
|
|
5554
|
+
const known = STATUSES.find((s) => s === status)
|
|
5555
|
+
if (
|
|
5556
|
+
known &&
|
|
5557
|
+
typeof active === 'boolean' &&
|
|
5558
|
+
(currentPeriodEnd === null || typeof currentPeriodEnd === 'string') &&
|
|
5559
|
+
typeof cancelAtPeriodEnd === 'boolean' &&
|
|
5560
|
+
typeof canManage === 'boolean'
|
|
5561
|
+
) {
|
|
5562
|
+
return { status: known, active, currentPeriodEnd, cancelAtPeriodEnd, canManage }
|
|
5563
|
+
}
|
|
5564
|
+
}
|
|
5565
|
+
throw new Error('Malformed billing')
|
|
5566
|
+
}
|
|
5567
|
+
|
|
5568
|
+
/** Where to send the browser next: Stripe's checkout or its billing portal. */
|
|
5569
|
+
export function parseRedirect(raw: unknown): { url: string } {
|
|
5570
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
5571
|
+
const { url } = raw as Record<string, unknown>
|
|
5572
|
+
if (typeof url === 'string' && url.startsWith('https://')) return { url }
|
|
5573
|
+
}
|
|
5574
|
+
throw new Error('Malformed redirect')
|
|
5575
|
+
}
|
|
5576
|
+
|
|
5577
|
+
export function parseSyncInput(raw: unknown): { sessionId: string } {
|
|
5578
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
5579
|
+
const { sessionId } = raw as Record<string, unknown>
|
|
5580
|
+
if (typeof sessionId === 'string' && /^cs_[\\w]{1,250}$/.test(sessionId)) return { sessionId }
|
|
5581
|
+
}
|
|
5582
|
+
throw new Error('Expected { sessionId }: a Checkout Session id')
|
|
5583
|
+
}
|
|
5584
|
+
`}function Nt(){return`import { HttpError } from '@cascivo/app/api'
|
|
5585
|
+
import { requireUser } from '@cascivo/app/auth-server'
|
|
5586
|
+
import { migrate, queryRows } from '@cascivo/app/db'
|
|
5587
|
+
import type { Database } from '@cascivo/app/db'
|
|
5588
|
+
import { isEntitled, requireEntitlement } from '@cascivo/app/stripe'
|
|
5589
|
+
import type { Invoice, Subscription } from '@cascivo/app/stripe'
|
|
5590
|
+
import {
|
|
5591
|
+
Body,
|
|
5592
|
+
Button,
|
|
5593
|
+
Container,
|
|
5594
|
+
Head,
|
|
5595
|
+
Heading,
|
|
5596
|
+
Html,
|
|
5597
|
+
Preview,
|
|
5598
|
+
Section,
|
|
5599
|
+
Text,
|
|
5600
|
+
renderEmail,
|
|
5601
|
+
} from '@cascivo/email'
|
|
5602
|
+
import { createElement as h } from 'react'
|
|
5603
|
+
import { PLAN, parseBilling } from '../src/billing'
|
|
5604
|
+
import type { Billing } from '../src/billing'
|
|
5605
|
+
import { SHOP_NAME, formatPrice } from '../src/checkout'
|
|
5606
|
+
import { refused, stripeOf } from './checkout'
|
|
5607
|
+
import type { BillingHooks, ReceiptSender } from './checkout'
|
|
5608
|
+
|
|
5609
|
+
const migrations = [
|
|
5610
|
+
{
|
|
5611
|
+
id: '0001_billing',
|
|
5612
|
+
statements: [
|
|
5613
|
+
\`CREATE TABLE billing (
|
|
5614
|
+
user_id TEXT PRIMARY KEY,
|
|
5615
|
+
customer_id TEXT,
|
|
5616
|
+
subscription_id TEXT,
|
|
5617
|
+
status TEXT NOT NULL,
|
|
5618
|
+
current_period_end INTEGER,
|
|
5619
|
+
cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
|
|
5620
|
+
updated_at TEXT NOT NULL,
|
|
5621
|
+
reminded TEXT
|
|
5622
|
+
)\`,
|
|
5623
|
+
],
|
|
5624
|
+
},
|
|
5625
|
+
]
|
|
5626
|
+
|
|
5627
|
+
export interface BillingEnv {
|
|
5628
|
+
DB: Database
|
|
5629
|
+
EMAIL: ReceiptSender
|
|
5630
|
+
RECEIPT_FROM: string
|
|
5631
|
+
STRIPE_SECRET_KEY?: string
|
|
5632
|
+
}
|
|
5633
|
+
|
|
5634
|
+
interface Row {
|
|
5635
|
+
status: string
|
|
5636
|
+
customerId: string | null
|
|
5637
|
+
currentPeriodEnd: number | null
|
|
5638
|
+
cancelAtPeriodEnd: number
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
const cell = (raw: unknown, key: string): unknown =>
|
|
5642
|
+
typeof raw === 'object' && raw !== null ? Reflect.get(raw, key) : undefined
|
|
5643
|
+
|
|
5644
|
+
function parseRow(raw: unknown): Row {
|
|
5645
|
+
const status = cell(raw, 'status')
|
|
5646
|
+
const customerId = cell(raw, 'customerId')
|
|
5647
|
+
const end = cell(raw, 'currentPeriodEnd')
|
|
5648
|
+
if (typeof status !== 'string') throw new Error('Malformed billing row')
|
|
5649
|
+
return {
|
|
5650
|
+
status,
|
|
5651
|
+
customerId: typeof customerId === 'string' ? customerId : null,
|
|
5652
|
+
currentPeriodEnd: typeof end === 'number' ? end : null,
|
|
5653
|
+
cancelAtPeriodEnd: Number(cell(raw, 'cancelAtPeriodEnd')),
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
async function readRow(db: Database, userId: string): Promise<Row | null> {
|
|
5658
|
+
await migrate(db, migrations)
|
|
5659
|
+
const [row] = await queryRows(
|
|
5660
|
+
db,
|
|
5661
|
+
\`SELECT status, customer_id AS customerId, current_period_end AS currentPeriodEnd,
|
|
5662
|
+
cancel_at_period_end AS cancelAtPeriodEnd FROM billing WHERE user_id = ?\`,
|
|
5663
|
+
[userId],
|
|
5664
|
+
parseRow,
|
|
5665
|
+
)
|
|
5666
|
+
return row ?? null
|
|
5667
|
+
}
|
|
5668
|
+
|
|
5669
|
+
/** A stored row as the page sees it; the status is checked against the known ones. */
|
|
5670
|
+
function toBilling(row: Row | null): Billing {
|
|
5671
|
+
const billing = parseBilling({
|
|
5672
|
+
status: row?.status ?? 'none',
|
|
5673
|
+
active: false,
|
|
5674
|
+
currentPeriodEnd:
|
|
5675
|
+
row?.currentPeriodEnd != null ? new Date(row.currentPeriodEnd * 1000).toISOString() : null,
|
|
5676
|
+
cancelAtPeriodEnd: row?.cancelAtPeriodEnd === 1,
|
|
5677
|
+
canManage: row?.customerId != null,
|
|
5678
|
+
})
|
|
5679
|
+
return { ...billing, active: isEntitled(billing.status) }
|
|
5680
|
+
}
|
|
5681
|
+
|
|
5682
|
+
/**
|
|
5683
|
+
* Stores a subscription's current state for the user its metadata names. The metadata is set
|
|
5684
|
+
* by startSubscription, server side: a subscription made any other way (a Payment Link, the
|
|
5685
|
+
* dashboard) names no user here and is ignored. A different subscription replaces the stored
|
|
5686
|
+
* one only when that one is over, so a late event about an old plan cannot end a new one.
|
|
5687
|
+
*/
|
|
5688
|
+
async function store(db: Database, subscription: Subscription): Promise<void> {
|
|
5689
|
+
const userId = subscription.metadata['user']
|
|
5690
|
+
if (!userId) return
|
|
5691
|
+
await migrate(db, migrations)
|
|
5692
|
+
await queryRows(
|
|
5693
|
+
db,
|
|
5694
|
+
\`INSERT INTO billing
|
|
5695
|
+
(user_id, customer_id, subscription_id, status, current_period_end, cancel_at_period_end, updated_at)
|
|
5696
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
5697
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
5698
|
+
customer_id = excluded.customer_id, subscription_id = excluded.subscription_id,
|
|
5699
|
+
status = excluded.status, current_period_end = excluded.current_period_end,
|
|
5700
|
+
cancel_at_period_end = excluded.cancel_at_period_end, updated_at = excluded.updated_at
|
|
5701
|
+
WHERE billing.subscription_id IS excluded.subscription_id
|
|
5702
|
+
OR billing.status NOT IN ('active', 'trialing', 'past_due')
|
|
5703
|
+
RETURNING user_id\`,
|
|
5704
|
+
[
|
|
5705
|
+
userId,
|
|
5706
|
+
subscription.customerId,
|
|
5707
|
+
subscription.id,
|
|
5708
|
+
subscription.status,
|
|
5709
|
+
subscription.currentPeriodEnd,
|
|
5710
|
+
subscription.cancelAtPeriodEnd ? 1 : 0,
|
|
5711
|
+
new Date().toISOString(),
|
|
5712
|
+
],
|
|
5713
|
+
(raw) => raw,
|
|
5714
|
+
)
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
/**
|
|
5718
|
+
* The webhook's part (worker/checkout.ts passes subscription events here). The event's copy
|
|
5719
|
+
* may be stale, since events arrive out of order: the subscription is read back from Stripe.
|
|
5720
|
+
*/
|
|
5721
|
+
export async function syncSubscription(env: BillingEnv, subscriptionId: string): Promise<void> {
|
|
5722
|
+
await store(env.DB, await stripeOf(env).retrieveSubscription(subscriptionId))
|
|
5723
|
+
}
|
|
5724
|
+
|
|
5725
|
+
/** The signed-in user's plan. */
|
|
5726
|
+
export async function getBilling(env: BillingEnv, request: Request): Promise<Billing> {
|
|
5727
|
+
const user = await requireUser(env.DB, request)
|
|
5728
|
+
return toBilling(await readRow(env.DB, user.id))
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
/**
|
|
5732
|
+
* Refuses a user whose plan is not on: 401 signed out, 402 without the plan. Call it in the
|
|
5733
|
+
* Worker before serving a paid feature, never trusting what the page shows.
|
|
5734
|
+
*/
|
|
5735
|
+
export async function requirePlan(env: BillingEnv, request: Request): Promise<void> {
|
|
5736
|
+
const user = await requireUser(env.DB, request)
|
|
5737
|
+
requireEntitlement((await readRow(env.DB, user.id))?.status)
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
/** The email for a renewal that could not be charged, with where to pay it. */
|
|
5741
|
+
function renderPaymentFailed(invoice: Invoice, payHref: string) {
|
|
5742
|
+
const subject = \`Your \${PLAN.name} payment did not go through\`
|
|
5743
|
+
const amount = formatPrice(invoice.amountDue, invoice.currency)
|
|
5744
|
+
return renderEmail(
|
|
5745
|
+
h(
|
|
5746
|
+
Html,
|
|
5747
|
+
null,
|
|
5748
|
+
h(Head, { title: subject }),
|
|
5749
|
+
h(
|
|
5750
|
+
Body,
|
|
5751
|
+
null,
|
|
5752
|
+
h(Preview, null, \`We could not charge \${amount}. Update your card to keep \${PLAN.name}.\`),
|
|
5753
|
+
h(
|
|
5754
|
+
Container,
|
|
5755
|
+
null,
|
|
5756
|
+
h(
|
|
5757
|
+
Section,
|
|
5758
|
+
{ padding: 32 },
|
|
5759
|
+
h(Heading, { level: 1 }, 'Your payment did not go through'),
|
|
5760
|
+
h(
|
|
5761
|
+
Text,
|
|
5762
|
+
null,
|
|
5763
|
+
\`We could not charge \${amount} for \${SHOP_NAME} \${PLAN.name}. Your plan stays on while we try again; pay with another card to keep it.\`,
|
|
5764
|
+
),
|
|
5765
|
+
h(Button, { href: payHref }, 'Update payment'),
|
|
5766
|
+
),
|
|
5767
|
+
),
|
|
5768
|
+
),
|
|
5769
|
+
),
|
|
5770
|
+
{ subject },
|
|
5771
|
+
)
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
/**
|
|
5775
|
+
* A renewal Stripe could not charge (\`invoice.payment_failed\`). The customer hears about it
|
|
5776
|
+
* once per attempt, with Stripe's page for paying the invoice with another card. The plan stays
|
|
5777
|
+
* on while Stripe retries (\`past_due\`); the Stripe dashboard's failed-payment settings decide
|
|
5778
|
+
* when it ends. Only customers this app bills are written to: a retried event, or an invoice
|
|
5779
|
+
* for something else on the same Stripe account, sends nothing.
|
|
5780
|
+
*/
|
|
5781
|
+
export async function remindPayment(
|
|
5782
|
+
env: BillingEnv,
|
|
5783
|
+
invoice: Invoice,
|
|
5784
|
+
origin: string,
|
|
5785
|
+
): Promise<void> {
|
|
5786
|
+
if (!invoice.customerId || !invoice.customerEmail) return
|
|
5787
|
+
await migrate(env.DB, migrations)
|
|
5788
|
+
const attempt = \`\${invoice.id}:\${invoice.attemptCount}\`
|
|
5789
|
+
const [row] = await queryRows(
|
|
5790
|
+
env.DB,
|
|
5791
|
+
'UPDATE billing SET reminded = ? WHERE customer_id = ? AND reminded IS NOT ? RETURNING user_id',
|
|
5792
|
+
[attempt, invoice.customerId, attempt],
|
|
5793
|
+
(raw) => raw,
|
|
5794
|
+
)
|
|
5795
|
+
if (!row) return
|
|
5796
|
+
const message = renderPaymentFailed(invoice, invoice.hostedInvoiceUrl ?? \`\${origin}/billing\`)
|
|
5797
|
+
if (import.meta.env.DEV) {
|
|
5798
|
+
console.log(\`[billing] payment reminder for \${invoice.customerEmail}: "\${message.subject}"\`)
|
|
5799
|
+
return
|
|
5800
|
+
}
|
|
5801
|
+
if (!env.RECEIPT_FROM) {
|
|
5802
|
+
console.warn('[billing] no payment reminder sent: set RECEIPT_FROM in wrangler.jsonc')
|
|
5803
|
+
return
|
|
5804
|
+
}
|
|
5805
|
+
try {
|
|
5806
|
+
await env.EMAIL.send({
|
|
5807
|
+
from: env.RECEIPT_FROM,
|
|
5808
|
+
to: invoice.customerEmail,
|
|
5809
|
+
subject: message.subject,
|
|
5810
|
+
text: message.text,
|
|
5811
|
+
html: message.html,
|
|
5812
|
+
})
|
|
5813
|
+
} catch (error) {
|
|
5814
|
+
// Logged, not thrown: Stripe retrying the event would not send it again.
|
|
5815
|
+
console.error('[billing] payment reminder not sent:', error)
|
|
5816
|
+
}
|
|
5817
|
+
}
|
|
5818
|
+
|
|
5819
|
+
/** What the Stripe webhook (worker/checkout.ts) hands over to billing. */
|
|
5820
|
+
export function billingHooks(env: BillingEnv): BillingHooks {
|
|
5821
|
+
return {
|
|
5822
|
+
subscription: (id) => syncSubscription(env, id),
|
|
5823
|
+
paymentFailed: (invoice, origin) => remindPayment(env, invoice, origin),
|
|
5824
|
+
}
|
|
5825
|
+
}
|
|
5826
|
+
|
|
5827
|
+
/** Opens a subscription checkout for PLAN, naming the user in the subscription's metadata. */
|
|
5828
|
+
export async function startSubscription(
|
|
5829
|
+
env: BillingEnv,
|
|
5830
|
+
request: Request,
|
|
5831
|
+
origin: string,
|
|
5832
|
+
): Promise<{ url: string }> {
|
|
5833
|
+
const user = await requireUser(env.DB, request)
|
|
5834
|
+
const row = await readRow(env.DB, user.id)
|
|
5835
|
+
if (row && toBilling(row).active) {
|
|
5836
|
+
throw new HttpError(409, 'You already have the plan. Manage it in the billing portal.')
|
|
5837
|
+
}
|
|
5838
|
+
const stripe = stripeOf(env)
|
|
5839
|
+
try {
|
|
5840
|
+
const session = await stripe.createCheckoutSession({
|
|
5841
|
+
mode: 'subscription',
|
|
5842
|
+
lineItems: [{ ...PLAN, quantity: 1 }],
|
|
5843
|
+
// Stripe fills in {CHECKOUT_SESSION_ID}, so the page can sync before any webhook.
|
|
5844
|
+
successUrl: \`\${origin}/billing?session={CHECKOUT_SESSION_ID}\`,
|
|
5845
|
+
cancelUrl: \`\${origin}/billing\`,
|
|
5846
|
+
...(row?.customerId ? { customer: row.customerId } : { customerEmail: user.email }),
|
|
5847
|
+
clientReferenceId: user.id,
|
|
5848
|
+
subscriptionMetadata: { user: user.id },
|
|
5849
|
+
})
|
|
5850
|
+
if (!session.url) throw new HttpError(502, 'Stripe returned no checkout page')
|
|
5851
|
+
return { url: session.url }
|
|
5852
|
+
} catch (error) {
|
|
5853
|
+
if (error instanceof HttpError) throw error
|
|
5854
|
+
refused(error)
|
|
5855
|
+
}
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
/**
|
|
5859
|
+
* Back from Stripe's checkout: reads the session and its subscription, so the page is right
|
|
5860
|
+
* before the webhook arrives (or in \`vite dev\` without \`stripe listen\`). Only the user the
|
|
5861
|
+
* subscription names can sync it.
|
|
5862
|
+
*/
|
|
5863
|
+
export async function syncBilling(
|
|
5864
|
+
env: BillingEnv,
|
|
5865
|
+
request: Request,
|
|
5866
|
+
sessionId: string,
|
|
5867
|
+
): Promise<Billing> {
|
|
5868
|
+
const user = await requireUser(env.DB, request)
|
|
5869
|
+
const stripe = stripeOf(env)
|
|
5870
|
+
try {
|
|
5871
|
+
const session = await stripe.retrieveCheckoutSession(sessionId)
|
|
5872
|
+
if (session.mode === 'subscription' && session.subscriptionId) {
|
|
5873
|
+
const subscription = await stripe.retrieveSubscription(session.subscriptionId)
|
|
5874
|
+
if (subscription.metadata['user'] !== user.id) {
|
|
5875
|
+
throw new HttpError(403, 'This checkout belongs to another account')
|
|
5876
|
+
}
|
|
5877
|
+
await store(env.DB, subscription)
|
|
5878
|
+
}
|
|
5879
|
+
} catch (error) {
|
|
5880
|
+
if (error instanceof HttpError) throw error
|
|
5881
|
+
refused(error)
|
|
5882
|
+
}
|
|
5883
|
+
return toBilling(await readRow(env.DB, user.id))
|
|
5884
|
+
}
|
|
5885
|
+
|
|
5886
|
+
/** Opens Stripe's Customer Portal: plan, card, invoices and cancellation, all hosted. */
|
|
5887
|
+
export async function openPortal(
|
|
5888
|
+
env: BillingEnv,
|
|
5889
|
+
request: Request,
|
|
5890
|
+
origin: string,
|
|
5891
|
+
): Promise<{ url: string }> {
|
|
5892
|
+
const user = await requireUser(env.DB, request)
|
|
5893
|
+
const row = await readRow(env.DB, user.id)
|
|
5894
|
+
if (!row?.customerId) throw new HttpError(409, 'Subscribe first: there is nothing to manage yet')
|
|
5895
|
+
try {
|
|
5896
|
+
return await stripeOf(env).createPortalSession({
|
|
5897
|
+
customer: row.customerId,
|
|
5898
|
+
returnUrl: \`\${origin}/billing\`,
|
|
5899
|
+
})
|
|
5900
|
+
} catch (error) {
|
|
5901
|
+
refused(error)
|
|
5902
|
+
}
|
|
5903
|
+
}
|
|
5904
|
+
`}function Pt(){return`import { createClient } from '@cascivo/app/api'
|
|
5905
|
+
import {
|
|
5906
|
+
Alert,
|
|
5907
|
+
Badge,
|
|
5908
|
+
Button,
|
|
5909
|
+
Card,
|
|
5910
|
+
CardContent,
|
|
5911
|
+
Flex,
|
|
5912
|
+
Heading,
|
|
5913
|
+
Link,
|
|
5914
|
+
Spinner,
|
|
5915
|
+
Text,
|
|
5916
|
+
signal,
|
|
5917
|
+
useSignalEffect,
|
|
5918
|
+
useSignals,
|
|
5919
|
+
} from '@cascivo/react'
|
|
5920
|
+
import { api } from '../api'
|
|
5921
|
+
import { auth } from '../auth'
|
|
5922
|
+
import { PLAN } from '../billing'
|
|
5923
|
+
import type { Billing, BillingStatus } from '../billing'
|
|
5924
|
+
import { formatPrice } from '../checkout'
|
|
5925
|
+
import { router } from '../router'
|
|
5926
|
+
|
|
5927
|
+
const client = createClient(api)
|
|
5928
|
+
const billing = signal<Billing | null>(null)
|
|
5929
|
+
const busy = signal<'subscribe' | 'portal' | null>(null)
|
|
5930
|
+
const failure = signal<string | null>(null)
|
|
5931
|
+
|
|
5932
|
+
/**
|
|
5933
|
+
* Loads the plan. Back from Stripe's checkout, the URL carries the session id: syncing it
|
|
5934
|
+
* shows the new subscription at once, before the webhook. Then the id leaves the URL.
|
|
5935
|
+
*/
|
|
5936
|
+
async function load(): Promise<void> {
|
|
5937
|
+
failure.value = null
|
|
5938
|
+
try {
|
|
5939
|
+
const sessionId = new URLSearchParams(router.search.peek()).get('session')
|
|
5940
|
+
if (sessionId) {
|
|
5941
|
+
billing.value = await client.syncBilling({ body: { sessionId } })
|
|
5942
|
+
router.navigate('/billing', { replace: true })
|
|
5943
|
+
} else {
|
|
5944
|
+
billing.value = await client.getBilling()
|
|
5945
|
+
}
|
|
5946
|
+
} catch (error) {
|
|
5947
|
+
failure.value = error instanceof Error ? error.message : 'Could not load billing'
|
|
5948
|
+
}
|
|
5949
|
+
}
|
|
5950
|
+
|
|
5951
|
+
/** Both buttons leave for a Stripe page: checkout, or the billing portal. */
|
|
5952
|
+
async function leaveFor(kind: 'subscribe' | 'portal'): Promise<void> {
|
|
5953
|
+
busy.value = kind
|
|
5954
|
+
failure.value = null
|
|
5955
|
+
try {
|
|
5956
|
+
const { url } =
|
|
5957
|
+
kind === 'subscribe' ? await client.startSubscription() : await client.openBillingPortal()
|
|
5958
|
+
location.assign(url)
|
|
5959
|
+
} catch (error) {
|
|
5960
|
+
failure.value = error instanceof Error ? error.message : 'Stripe did not open'
|
|
5961
|
+
busy.value = null
|
|
5962
|
+
}
|
|
5963
|
+
}
|
|
5964
|
+
|
|
5965
|
+
const LABEL: Record<BillingStatus, string> = {
|
|
5966
|
+
none: 'No plan',
|
|
5967
|
+
incomplete: 'Payment pending',
|
|
5968
|
+
incomplete_expired: 'Payment expired',
|
|
5969
|
+
trialing: 'Trial',
|
|
5970
|
+
active: 'Active',
|
|
5971
|
+
past_due: 'Payment failed: retrying',
|
|
5972
|
+
canceled: 'Cancelled',
|
|
5973
|
+
unpaid: 'Unpaid',
|
|
5974
|
+
paused: 'Paused',
|
|
5975
|
+
}
|
|
5976
|
+
|
|
5977
|
+
export default function BillingPage() {
|
|
5978
|
+
useSignals()
|
|
5979
|
+
useSignalEffect(() => {
|
|
5980
|
+
if (auth.user.value) void load()
|
|
5981
|
+
})
|
|
5982
|
+
const user = auth.user.value
|
|
5983
|
+
const plan = billing.value
|
|
5984
|
+
|
|
5985
|
+
if (user === undefined) return <Spinner label="Loading" />
|
|
5986
|
+
if (user === null) {
|
|
5987
|
+
return (
|
|
5988
|
+
<Flex gap={4}>
|
|
5989
|
+
<Heading level={1}>Billing</Heading>
|
|
5990
|
+
<Text>
|
|
5991
|
+
<Link href="/account">Sign in</Link> to subscribe: a plan belongs to your account.
|
|
5992
|
+
</Text>
|
|
5993
|
+
</Flex>
|
|
5994
|
+
)
|
|
5995
|
+
}
|
|
5996
|
+
return (
|
|
5997
|
+
<Flex gap={4}>
|
|
5998
|
+
<Flex gap={1}>
|
|
5999
|
+
<Heading level={1}>Billing</Heading>
|
|
6000
|
+
<Text muted>
|
|
6001
|
+
Paid on Stripe's hosted checkout; changed, paused or cancelled in Stripe's billing portal.
|
|
6002
|
+
The Worker keeps your plan in step through Stripe's webhook.
|
|
6003
|
+
</Text>
|
|
6004
|
+
</Flex>
|
|
6005
|
+
<Card>
|
|
6006
|
+
<CardContent>
|
|
6007
|
+
<Flex gap={3}>
|
|
6008
|
+
<Flex direction="horizontal" align="center" gap={2} wrap>
|
|
6009
|
+
<Heading level={2}>{PLAN.name}</Heading>
|
|
6010
|
+
{plan ? (
|
|
6011
|
+
<Badge variant={plan.active ? 'success' : 'secondary'}>{LABEL[plan.status]}</Badge>
|
|
6012
|
+
) : null}
|
|
6013
|
+
</Flex>
|
|
6014
|
+
<Text muted>{PLAN.description}</Text>
|
|
6015
|
+
<Text size="lg">
|
|
6016
|
+
{formatPrice(PLAN.amount, PLAN.currency)} a {PLAN.interval}
|
|
6017
|
+
</Text>
|
|
6018
|
+
{plan?.currentPeriodEnd ? (
|
|
6019
|
+
<Text size="sm" muted>
|
|
6020
|
+
{plan.cancelAtPeriodEnd ? 'Ends' : 'Renews'} on{' '}
|
|
6021
|
+
{new Date(plan.currentPeriodEnd).toLocaleDateString()}
|
|
6022
|
+
</Text>
|
|
6023
|
+
) : null}
|
|
6024
|
+
<Flex direction="horizontal" gap={2} wrap>
|
|
6025
|
+
{plan && !plan.active ? (
|
|
6026
|
+
<Button
|
|
6027
|
+
loading={busy.value === 'subscribe'}
|
|
6028
|
+
onClick={() => void leaveFor('subscribe')}
|
|
6029
|
+
>
|
|
6030
|
+
Subscribe
|
|
6031
|
+
</Button>
|
|
6032
|
+
) : null}
|
|
6033
|
+
{plan?.canManage ? (
|
|
6034
|
+
<Button
|
|
6035
|
+
variant="secondary"
|
|
6036
|
+
loading={busy.value === 'portal'}
|
|
6037
|
+
onClick={() => void leaveFor('portal')}
|
|
6038
|
+
>
|
|
6039
|
+
Manage billing
|
|
6040
|
+
</Button>
|
|
6041
|
+
) : null}
|
|
6042
|
+
</Flex>
|
|
6043
|
+
</Flex>
|
|
6044
|
+
</CardContent>
|
|
6045
|
+
</Card>
|
|
6046
|
+
{failure.value ? (
|
|
6047
|
+
<Alert variant="destructive" title="Not done">
|
|
6048
|
+
{failure.value}
|
|
6049
|
+
</Alert>
|
|
6050
|
+
) : null}
|
|
6051
|
+
</Flex>
|
|
6052
|
+
)
|
|
6053
|
+
}
|
|
6054
|
+
`}function Ft(){return"import { createAuth } from '@cascivo/app/auth'\n\n/**\n * Who is signed in, shared by every page: `auth.user.value` is `undefined` while the first\n * check runs, then the user or `null`. The Worker side is `handleAuth` in worker/index.ts.\n */\nexport const auth = createAuth()\n"}function It(){return`/** What sending a sign-in link needs of the Email Service binding (\`send_email\`). */
|
|
6055
|
+
export interface SignInSender {
|
|
6056
|
+
send(message: {
|
|
6057
|
+
from: string
|
|
6058
|
+
to: string
|
|
6059
|
+
subject: string
|
|
6060
|
+
text: string
|
|
6061
|
+
html: string
|
|
6062
|
+
}): Promise<unknown>
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
const escape = (value: string) =>
|
|
6066
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
6067
|
+
|
|
6068
|
+
/**
|
|
6069
|
+
* Emails a sign-in link. \`vite dev\` sends nothing: the link is logged, and the Account page
|
|
6070
|
+
* shows it (\`exposeLink\` in worker/index.ts).
|
|
6071
|
+
*/
|
|
6072
|
+
export async function sendSignInLink(
|
|
6073
|
+
sender: SignInSender,
|
|
6074
|
+
from: string,
|
|
6075
|
+
email: string,
|
|
6076
|
+
url: string,
|
|
6077
|
+
): Promise<void> {
|
|
6078
|
+
if (import.meta.env.DEV) {
|
|
6079
|
+
console.log(\`[auth] sign-in link for \${email}: \${url}\`)
|
|
6080
|
+
return
|
|
6081
|
+
}
|
|
6082
|
+
if (!from) throw new Error('Set AUTH_FROM in wrangler.jsonc to an address on your domain')
|
|
6083
|
+
await sender.send({
|
|
6084
|
+
from,
|
|
6085
|
+
to: email,
|
|
6086
|
+
subject: 'Your sign-in link',
|
|
6087
|
+
text: \`Sign in: \${url}\\n\\nThe link works once, for 15 minutes. If you did not ask for it, ignore this email.\`,
|
|
6088
|
+
html: \`<p><a href="\${escape(url)}">Sign in</a></p><p>The link works once, for 15 minutes. If you did not ask for it, ignore this email.</p>\`,
|
|
6089
|
+
})
|
|
6090
|
+
}
|
|
6091
|
+
`}function Lt(){return`import {
|
|
6092
|
+
Alert,
|
|
6093
|
+
Button,
|
|
6094
|
+
Card,
|
|
6095
|
+
CardContent,
|
|
6096
|
+
Flex,
|
|
6097
|
+
Heading,
|
|
6098
|
+
Input,
|
|
6099
|
+
Link,
|
|
6100
|
+
Spinner,
|
|
6101
|
+
Text,
|
|
6102
|
+
signal,
|
|
6103
|
+
useSignals,
|
|
6104
|
+
} from '@cascivo/react'
|
|
6105
|
+
import type { FormEvent } from 'react'
|
|
6106
|
+
import { auth } from '../auth'
|
|
6107
|
+
|
|
6108
|
+
const sentTo = signal<string | null>(null)
|
|
6109
|
+
/** Set only in \`vite dev\`, where no email is sent: the link to open instead. */
|
|
6110
|
+
const devLink = signal<string | null>(null)
|
|
6111
|
+
const failure = signal<string | null>(null)
|
|
6112
|
+
const sending = signal(false)
|
|
6113
|
+
|
|
6114
|
+
async function start(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
6115
|
+
event.preventDefault()
|
|
6116
|
+
const email = new FormData(event.currentTarget).get('email')
|
|
6117
|
+
if (typeof email !== 'string') return
|
|
6118
|
+
failure.value = null
|
|
6119
|
+
sending.value = true
|
|
6120
|
+
try {
|
|
6121
|
+
const { link } = await auth.start(email)
|
|
6122
|
+
sentTo.value = email
|
|
6123
|
+
devLink.value = link
|
|
6124
|
+
} catch (error) {
|
|
6125
|
+
failure.value = error instanceof Error ? error.message : 'Could not send the link'
|
|
6126
|
+
} finally {
|
|
6127
|
+
sending.value = false
|
|
6128
|
+
}
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6131
|
+
export default function Account() {
|
|
6132
|
+
useSignals()
|
|
6133
|
+
const user = auth.user.value
|
|
6134
|
+
|
|
6135
|
+
if (user === undefined) return <Spinner label="Loading" />
|
|
6136
|
+
if (user) {
|
|
6137
|
+
return (
|
|
6138
|
+
<Flex gap={4}>
|
|
6139
|
+
<Heading level={1}>Account</Heading>
|
|
6140
|
+
<Card>
|
|
6141
|
+
<CardContent>
|
|
6142
|
+
<Flex gap={3}>
|
|
6143
|
+
<Text>Signed in as {user.email}</Text>
|
|
6144
|
+
<Flex direction="horizontal">
|
|
6145
|
+
<Button variant="secondary" onClick={() => void auth.signOut()}>
|
|
6146
|
+
Sign out
|
|
6147
|
+
</Button>
|
|
6148
|
+
</Flex>
|
|
6149
|
+
</Flex>
|
|
6150
|
+
</CardContent>
|
|
6151
|
+
</Card>
|
|
3715
6152
|
</Flex>
|
|
3716
6153
|
)
|
|
3717
6154
|
}
|
|
@@ -3747,7 +6184,7 @@ export default function Account() {
|
|
|
3747
6184
|
</Flex>
|
|
3748
6185
|
)
|
|
3749
6186
|
}
|
|
3750
|
-
`}function
|
|
6187
|
+
`}function Rt(){return`import { Alert, Button, Flex, Heading, Text, signal, useSignals } from '@cascivo/react'
|
|
3751
6188
|
import { auth } from '../../auth'
|
|
3752
6189
|
import { router } from '../../router'
|
|
3753
6190
|
|
|
@@ -3797,7 +6234,7 @@ export default function VerifySignIn() {
|
|
|
3797
6234
|
</Flex>
|
|
3798
6235
|
)
|
|
3799
6236
|
}
|
|
3800
|
-
`}function
|
|
6237
|
+
`}function zt(){return`import type { ViewConfig } from '@cascivo/render'
|
|
3801
6238
|
import { validateView } from '@cascivo/render/validate'
|
|
3802
6239
|
|
|
3803
6240
|
/**
|
|
@@ -3869,7 +6306,7 @@ export function parsePage(raw: unknown): Page {
|
|
|
3869
6306
|
const { view } = raw as Record<string, unknown>
|
|
3870
6307
|
return { ...summary, ...parsePageInput({ title: summary.title, view }) }
|
|
3871
6308
|
}
|
|
3872
|
-
`}function
|
|
6309
|
+
`}function Bt(){return`import { HttpError } from '@cascivo/app/api'
|
|
3873
6310
|
import { migrate, queryRows } from '@cascivo/app/db'
|
|
3874
6311
|
import type { Database } from '@cascivo/app/db'
|
|
3875
6312
|
import { isSlug, parsePage } from '../src/pages'
|
|
@@ -3934,7 +6371,7 @@ export async function getPage(db: Database, slug: string): Promise<Page> {
|
|
|
3934
6371
|
if (!page) throw new HttpError(404, 'No such page')
|
|
3935
6372
|
return page
|
|
3936
6373
|
}
|
|
3937
|
-
`}function
|
|
6374
|
+
`}function Vt(){return`import { CascivoView } from '@cascivo/render'
|
|
3938
6375
|
import { Flex, Heading } from '@cascivo/react'
|
|
3939
6376
|
import { createElement } from 'react'
|
|
3940
6377
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
@@ -4060,7 +6497,7 @@ export async function renderPageHtml(
|
|
|
4060
6497
|
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' },
|
|
4061
6498
|
})
|
|
4062
6499
|
}
|
|
4063
|
-
`}function
|
|
6500
|
+
`}function Ht(){return`import { migrate, queryRows } from '@cascivo/app/db'
|
|
4064
6501
|
import type { Database } from '@cascivo/app/db'
|
|
4065
6502
|
import { exportPage } from '@cascivo/app/export'
|
|
4066
6503
|
import type { ExportBrowser } from '@cascivo/app/export'
|
|
@@ -4117,7 +6554,7 @@ export async function pagePreview(
|
|
|
4117
6554
|
headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' },
|
|
4118
6555
|
})
|
|
4119
6556
|
}
|
|
4120
|
-
`}function
|
|
6557
|
+
`}function Ut(){return`import { createClient } from '@cascivo/app/api'
|
|
4121
6558
|
import { CascivoView } from '@cascivo/render'
|
|
4122
6559
|
import type { ViewConfig } from '@cascivo/render'
|
|
4123
6560
|
import { validateView } from '@cascivo/render/validate'
|
|
@@ -4277,7 +6714,7 @@ export default function Publish() {
|
|
|
4277
6714
|
</Flex>
|
|
4278
6715
|
)
|
|
4279
6716
|
}
|
|
4280
|
-
`}function
|
|
6717
|
+
`}function Wt(){return`import type { RouteProps } from '@cascivo/app'
|
|
4281
6718
|
import { createClient } from '@cascivo/app/api'
|
|
4282
6719
|
import { CascivoView } from '@cascivo/render'
|
|
4283
6720
|
import {
|
|
@@ -4327,7 +6764,7 @@ export default function PublishedPage({ params }: RouteProps<'/p/:slug'>) {
|
|
|
4327
6764
|
</Flex>
|
|
4328
6765
|
)
|
|
4329
6766
|
}
|
|
4330
|
-
`}function
|
|
6767
|
+
`}function Gt(){return`import { signal } from '@cascivo/react'
|
|
4331
6768
|
import { VoiceClient } from 'agents/voice/client'
|
|
4332
6769
|
import type { TranscriptMessage, VoiceStatus } from 'agents/voice/client'
|
|
4333
6770
|
|
|
@@ -4418,7 +6855,7 @@ export function voice(): VoiceClient {
|
|
|
4418
6855
|
client = c
|
|
4419
6856
|
return c
|
|
4420
6857
|
}
|
|
4421
|
-
`}function
|
|
6858
|
+
`}function Kt(){return`import { Agent } from 'agents'
|
|
4422
6859
|
import type { Connection } from 'agents'
|
|
4423
6860
|
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from 'agents/voice'
|
|
4424
6861
|
import type { TextSource, VoiceTurnContext } from 'agents/voice'
|
|
@@ -4482,7 +6919,7 @@ export class Voice extends VoiceAgent {
|
|
|
4482
6919
|
}
|
|
4483
6920
|
}
|
|
4484
6921
|
}
|
|
4485
|
-
`}function
|
|
6922
|
+
`}function qt(){return`import type {
|
|
4486
6923
|
TTSProvider,
|
|
4487
6924
|
Transcriber,
|
|
4488
6925
|
TranscriberSession,
|
|
@@ -4528,7 +6965,7 @@ const REPLIES: Record<string, string> = {
|
|
|
4528
6965
|
export function scriptedReply(transcript: string): string {
|
|
4529
6966
|
return REPLIES[transcript] ?? \`You said: \${transcript}\`
|
|
4530
6967
|
}
|
|
4531
|
-
`}function
|
|
6968
|
+
`}function Jt(){return`import {
|
|
4532
6969
|
Badge,
|
|
4533
6970
|
Button,
|
|
4534
6971
|
Card,
|
|
@@ -4645,7 +7082,7 @@ export default function VoicePage() {
|
|
|
4645
7082
|
</Flex>
|
|
4646
7083
|
)
|
|
4647
7084
|
}
|
|
4648
|
-
`}function
|
|
7085
|
+
`}function Yt(){return`import { defineLive } from '@cascivo/app/live'
|
|
4649
7086
|
|
|
4650
7087
|
/**
|
|
4651
7088
|
* The live dashboard's metrics, shared by the Worker (which records them) and the page (which
|
|
@@ -4667,7 +7104,7 @@ export function parseAccepted(raw: unknown): Accepted {
|
|
|
4667
7104
|
}
|
|
4668
7105
|
throw new Error('Malformed reply')
|
|
4669
7106
|
}
|
|
4670
|
-
`}function
|
|
7107
|
+
`}function Xt(){return`import { Kpi, LineChart } from '@cascivo/charts'
|
|
4671
7108
|
import { createClient } from '@cascivo/app/api'
|
|
4672
7109
|
import { watchLive } from '@cascivo/app/live'
|
|
4673
7110
|
import type { LiveEvent, LivePoint } from '@cascivo/app/live'
|
|
@@ -4790,7 +7227,7 @@ export default function Ops() {
|
|
|
4790
7227
|
</Flex>
|
|
4791
7228
|
)
|
|
4792
7229
|
}
|
|
4793
|
-
`}function
|
|
7230
|
+
`}function Zt(){return`import { defineTable } from '@cascivo/app/db'
|
|
4794
7231
|
|
|
4795
7232
|
/**
|
|
4796
7233
|
* The customers table, shared by the Worker (which queries it) and the page (which shows
|
|
@@ -4866,7 +7303,7 @@ export function parseDeleted(raw: unknown): { id: string } {
|
|
|
4866
7303
|
}
|
|
4867
7304
|
throw new Error('Malformed delete result')
|
|
4868
7305
|
}
|
|
4869
|
-
`}function
|
|
7306
|
+
`}function Qt(){return`import { createClient } from '@cascivo/app/api'
|
|
4870
7307
|
import type { TableQuery } from '@cascivo/app/db'
|
|
4871
7308
|
import { signal } from '@cascivo/react'
|
|
4872
7309
|
import { api } from './api'
|
|
@@ -4921,7 +7358,7 @@ export async function remove(id: string): Promise<void> {
|
|
|
4921
7358
|
await client.deleteCustomer({ params: { id } })
|
|
4922
7359
|
await load()
|
|
4923
7360
|
}
|
|
4924
|
-
`}function
|
|
7361
|
+
`}function $t(){return`import { HttpError } from '@cascivo/app/api'
|
|
4925
7362
|
import { migrate, queryTable } from '@cascivo/app/db'
|
|
4926
7363
|
import type { Database, TablePage, TableQuery } from '@cascivo/app/db'
|
|
4927
7364
|
import { customersTable, parseCustomer } from '../src/customers'
|
|
@@ -4990,7 +7427,7 @@ export async function deleteCustomer(db: Database, id: string): Promise<{ id: st
|
|
|
4990
7427
|
if (!row) throw new HttpError(404, 'No such customer')
|
|
4991
7428
|
return { id }
|
|
4992
7429
|
}
|
|
4993
|
-
`}function
|
|
7430
|
+
`}function en(){return`import type { Migration } from '@cascivo/app/db'
|
|
4994
7431
|
|
|
4995
7432
|
const FIRST = [
|
|
4996
7433
|
'Ada',
|
|
@@ -5040,7 +7477,7 @@ export const migrations: Migration[] = [
|
|
|
5040
7477
|
],
|
|
5041
7478
|
},
|
|
5042
7479
|
]
|
|
5043
|
-
`}function
|
|
7480
|
+
`}function tn(){return`import type { Column } from '@cascivo/react'
|
|
5044
7481
|
import {
|
|
5045
7482
|
Badge,
|
|
5046
7483
|
Button,
|
|
@@ -5220,15 +7657,16 @@ export default function Customers() {
|
|
|
5220
7657
|
</Flex>
|
|
5221
7658
|
)
|
|
5222
7659
|
}
|
|
5223
|
-
`}function
|
|
7660
|
+
`}function nn(){return`${V()}# Rewritten by @cascivo/app/vite whenever a route file changes.
|
|
5224
7661
|
src/routes.gen.ts
|
|
5225
|
-
`}function
|
|
7662
|
+
`}function rn(){return`node_modules
|
|
5226
7663
|
dist
|
|
5227
7664
|
.wrangler
|
|
5228
7665
|
.dev.vars*
|
|
7666
|
+
!.dev.vars.example
|
|
5229
7667
|
*.local
|
|
5230
7668
|
.DS_Store
|
|
5231
|
-
`}function
|
|
7669
|
+
`}function an(e){let t=e.pm??`npm`,n=$(e);return`# ${e.name}
|
|
5232
7670
|
|
|
5233
7671
|
A [cascivo](https://cascivo.com) app on Cloudflare: a client-rendered
|
|
5234
7672
|
${n===`preact`?`Preact`:`React`} app and its API, deployed as one Worker.
|
|
@@ -5265,7 +7703,7 @@ This builds the app, then deploys it to a temporary Cloudflare account with
|
|
|
5265
7703
|
|
|
5266
7704
|
It works only while wrangler is logged out. If you are logged in, use \`deploy\` instead.
|
|
5267
7705
|
A temporary account supports Workers, static assets, KV, D1 and Durable Objects. It does not
|
|
5268
|
-
support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves the app, but its ${
|
|
7706
|
+
support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves the app, but its ${X(e,`agent`)?`assistant`:`voice page`} cannot reach the model: deploy it\nto your own account for that.`:``}
|
|
5269
7707
|
|
|
5270
7708
|
## Structure
|
|
5271
7709
|
|
|
@@ -5282,7 +7720,7 @@ support Workers AI, R2, Workflows or Browser Run.${Z(e)?` So a preview serves th
|
|
|
5282
7720
|
- \`src/Shell.tsx\` — the app shell (header + side nav + content slot).
|
|
5283
7721
|
|
|
5284
7722
|
Persist client state with \`persistedSignal\` from \`@cascivo/storage\` (localStorage or
|
|
5285
|
-
IndexedDB).${
|
|
7723
|
+
IndexedDB).${X(e,`board`)?"\n\n## Board (multiplayer)\n\n`/board` is a shared board. Open it in two windows: notes, edits and cursors sync live.\n\n- `src/board.ts` — `connectRoom('/api/rooms/<name>')` from `@cascivo/app/sync`; `room.map('notes', parseNote)`\n is a signal every visitor shares, and `room.presence` carries cursors.\n- `worker/index.ts` — exports `SyncRoom`, one Durable Object per room, and routes\n `/api/rooms/:name` to it (bound as `ROOMS` in `wrangler.jsonc`).\n- `/board?room=team` is a separate board.\n\nEach path (one note) is last-writer-wins in the order the room receives writes, so two people\nediting the same note at once settle on one value; editing different notes never collides.\nDurable Objects work on a temporary account, so `deploy:preview` shares a live board with\nno Cloudflare account.":``}${X(e,`notes`)?"\n\n## Notes (local-first)\n\n`/notes` keeps working when the connection drops. Edits are saved on this device and sync\nwhen the room is reachable again; a badge shows how many are still waiting.\n\n- `src/notes.ts` — `connectRoom(url, { storage: indexedDBDriver() })`: the room's last\n state and every unconfirmed edit are kept in IndexedDB, so offline edits survive a reload\n or a closed tab and go out on the next connection. `room.unsynced` counts them. (Opening\n the app with no network at all also needs its files cached, by a service worker.)\n- The list is `/notes?list=<name>`. Without one, each browser makes its own and remembers\n it; open the same `?list=` on another device to sync with it.\n- It uses the same `SyncRoom` Durable Object as any other room (`ROOMS` in\n `wrangler.jsonc`). To query notes across lists, override `SyncRoom.onWrite` and mirror\n each write into D1.\n\nA note is last-writer-wins: an edit made offline replaces whatever the note held when it\narrives. Edits to different notes never collide.":``}${X(e,`import`)?"\n\n## Import (background job)\n\n`/import` runs a CSV import as a Cloudflare Workflow and shows its progress live.\n\n- `worker/import-job.ts` — `ImportJob`, the Workflow. Each `step.do` is retried on its own\n and its result kept, so a failure or a deploy resumes the job where it was. Put your writes\n in `importContacts`.\n- `src/import-job.ts` — the job (`defineJob` from `@cascivo/app/jobs`): its steps and the\n parser for its result, shared by both sides.\n- Progress is a read-only room: the Workflow reports with `jobReporter`\n (`@cascivo/app/jobs-server`), and the page watches `/api/jobs/:id` with `watchJob`. The job\n id is in the URL, so a reload or another tab picks the job back up.\n\nReport from inside a `step.do`: a Workflow replays `run()` from the top after each step, and\na report outside one would run again. Workflows run in `vite dev` locally, but not on a\ntemporary account, so `deploy:preview` serves the page without starting imports.":``}${X(e,`files`)?`
|
|
5286
7724
|
|
|
5287
7725
|
## Files (uploads)
|
|
5288
7726
|
|
|
@@ -5298,7 +7736,7 @@ IndexedDB).${K(e,`board`)?"\n\n## Board (multiplayer)\n\n`/board` is a shared bo
|
|
|
5298
7736
|
|
|
5299
7737
|
Each caller (by IP) may start 20 uploads a minute (\`ratelimits\` in \`wrangler.jsonc\`).${e.auth?``:"\n**It has no auth.** Anyone who can reach the app can upload: put Cloudflare Access in front of\nit (`--auth access`), add accounts (`--auth email`), or check who is asking in\n`worker/index.ts` before `handleUploads` runs."} Create the bucket once before deploying:
|
|
5300
7738
|
\`npx wrangler r2 bucket create ${N(e.name)}-files\`. R2 and Images do not run on a
|
|
5301
|
-
temporary account.`:``}${
|
|
7739
|
+
temporary account.`:``}${X(e,`export`)?'\n\n## Report (PDF and PNG export)\n\n`/report` has "Download PDF" and "Download PNG" buttons. The Worker renders the page in\nCloudflare\'s Browser Run and returns the file.\n\n- `worker/index.ts` — `handleExport` (`@cascivo/app/export`) serves\n `/api/export?page=/report&format=pdf`. It opens the page at this app\'s own origin with\n `?export=1`, waits for the network to go quiet, and prints it. Only paths of this app\n outside `/api/` can be exported.\n- `src/App.tsx` — `isExporting()` drops the shell, so the file holds the page alone.\n- For a scheduled report, call `exportPage` from a Cron Trigger and attach the PDF to an\n email (`sendEmail` from `@cascivo/email` takes `attachments`).\n\nThe browser opens the page without the visitor\'s cookies, so a page that needs a session\nrenders signed out. Each export starts a browser session, which is billed, so each caller\n(by IP) may start 20 a minute (`ratelimits` in `wrangler.jsonc`). In `vite dev` Browser Run starts a local Chrome (downloaded on\nfirst use); it does not run on a temporary account.':``}${X(e,`live`)?`
|
|
5302
7740
|
|
|
5303
7741
|
## Ops (live dashboard)
|
|
5304
7742
|
|
|
@@ -5318,7 +7756,7 @@ A new viewer, or one back from a dropped connection, starts with the whole windo
|
|
|
5318
7756
|
at least once: a batch retried after a lost reply counts twice. \`/api/events\` has no auth, so
|
|
5319
7757
|
anyone can move the numbers: check who is sending before real use (\`--auth access\` puts
|
|
5320
7758
|
Cloudflare Access in front of the Worker). Create the queue once before deploying:
|
|
5321
|
-
\`npx wrangler queues create ${N(e.name)}-events\`.`:``}${
|
|
7759
|
+
\`npx wrangler queues create ${N(e.name)}-events\`.`:``}${X(e,`usage`)?"\n\n## Usage (analytics)\n\nEvery API request is recorded in Workers Analytics Engine, and `/usage` charts the last\n24 hours: requests per hour, the busiest routes, errors and latency.\n\n- `src/usage.ts` — `defineMetrics` (`@cascivo/app/analytics`) names the columns once\n (`path`, `method`, `status`, `duration_ms`), so writes and queries never disagree about\n which blob is which.\n- `worker/index.ts` — `handleAndRecord` writes one data point per API request.\n- `worker/usage.ts` — the queries, over Analytics Engine's SQL API.\n\nWriting needs only the binding. **Reading needs two secrets**, because the SQL API is HTTP\nwith an account token:\n\n```sh\nnpx wrangler secret put CF_ACCOUNT_ID\nnpx wrangler secret put CF_API_TOKEN # a token with Account Analytics: Read\n```\n\nFor `vite dev`, put both in `.dev.vars`. Data written locally is not in your account's\ndataset, so in development the charts show what your deployed app recorded. Until the secrets\nexist, the page says what to set.":``}${X(e,`search`)?`
|
|
5322
7760
|
|
|
5323
7761
|
## Search (by meaning)
|
|
5324
7762
|
|
|
@@ -5336,7 +7774,119 @@ Create the index once before deploying, with the embedding model's dimensions:
|
|
|
5336
7774
|
Neither Vectorize nor Workers AI runs locally, so \`vite dev\` searches by keyword with SQLite's
|
|
5337
7775
|
full-text search instead, and the page says so. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses the real ones
|
|
5338
7776
|
(after \`npx wrangler login\`). Workers AI and Vectorize bill per use beyond their free
|
|
5339
|
-
allocations, and a temporary account has neither.`:``}${
|
|
7777
|
+
allocations, and a temporary account has neither.`:``}${X(e,`digest`)?'\n\n## Weekly digest (Cron Trigger)\n\nEvery Monday at 08:00 UTC (`triggers.crons` in `wrangler.jsonc`), the Worker renders\n`/report` to a PDF with Browser Run and emails it. `/digest` lists each run — sent,\nskipped or failed, and why — and "Send now" runs one at once.\n\n- `worker/digest.ts` — `runDigest`: `exportPage` (`@cascivo/app/export`), then Email\n Service with the PDF attached. Every run is recorded in D1, so a digest that did not arrive\n says why.\n- `worker/index.ts` — the `scheduled` handler the Cron Trigger calls.\n\nSet `DIGEST_TO` (comma-separated), `DIGEST_FROM` (an address on a domain onboarded to Email\nService) and `APP_URL` (the deployed app\'s origin: a cron has no request to read it from) in\n`wrangler.jsonc`. Until they are set, runs are recorded as skipped. To fire the cron in\n`vite dev`, open `/cdn-cgi/handler/scheduled`; "Send now" uses the page\'s own origin, so it\nneeds no `APP_URL`. Browser Run starts a local Chrome in development.':``}${X(e,`webhooks`)?"\n\n## Webhooks\n\n`/webhooks` lists GitHub webhook deliveries as they arrive. Point a repository's webhook at\n`https://<your app>/api/webhooks/github` (content type `application/json`) with a secret, and\ngive the Worker the same secret:\n`npx wrangler secret put WEBHOOK_SECRET`. Locally it is in `.dev.vars`, and \"Send a test\ndelivery\" signs one with it.\n\n- `worker/webhooks.ts` — `verifyWebhook` (`@cascivo/app/guard`) checks the\n `X-Hub-Signature-256` HMAC over the raw body before anything is parsed, and refuses a bad\n signature with a 401. Each delivery is stored in D1 under GitHub's delivery id, so a retry is ignored, and a\n new one is written to a read-only room the page watches.\n- `verifyWebhook` also checks Stripe (`scheme: 'stripe'`) and Standard Webhooks\n (`'standard'`: Svix, Clerk, Resend…), both with a five-minute timestamp window against\n replays.\n\nThe page answers within milliseconds, well inside GitHub's ten seconds. For slow work, send the\ndelivery to a Queue from `receiveGithub` and answer at once.":``}${X(e,`checkout`)?`
|
|
7778
|
+
|
|
7779
|
+
## Checkout (Stripe)
|
|
7780
|
+
|
|
7781
|
+
\`/checkout\` sells one product on Stripe's hosted Checkout page: the card never touches this
|
|
7782
|
+
app. Stripe tells the Worker when the payment succeeds; the Worker marks the order paid, pushes
|
|
7783
|
+
it to the order page, and emails a receipt rendered with \`@cascivo/email\`.
|
|
7784
|
+
|
|
7785
|
+
1. In the Stripe dashboard, in test mode, copy the secret key (\`sk_test_…\`) into \`.dev.vars\`
|
|
7786
|
+
as \`STRIPE_SECRET_KEY\`, then run the app and buy with the card \`4242 4242 4242 4242\`.
|
|
7787
|
+
The order page confirms the payment by reading the session back from Stripe, so this works
|
|
7788
|
+
before any webhook is set up.
|
|
7789
|
+
2. To receive the webhook locally, run
|
|
7790
|
+
\`stripe listen --forward-to localhost:5173/api/stripe/webhook\` (the Stripe CLI) and put the
|
|
7791
|
+
\`whsec_…\` secret it prints in \`.dev.vars\` as \`STRIPE_WEBHOOK_SECRET\`.
|
|
7792
|
+
3. Deployed: \`npx wrangler secret put STRIPE_SECRET_KEY\` and
|
|
7793
|
+
\`npx wrangler secret put STRIPE_WEBHOOK_SECRET\`. In the dashboard, add a webhook endpoint
|
|
7794
|
+
at \`https://<your app>/api/stripe/webhook\` for \`checkout.session.completed\`,
|
|
7795
|
+
\`checkout.session.async_payment_succeeded\`, \`checkout.session.async_payment_failed\`,
|
|
7796
|
+
\`checkout.session.expired\`, \`charge.refunded\`, \`charge.dispute.created\` and
|
|
7797
|
+
\`charge.dispute.closed\`; its signing secret is \`STRIPE_WEBHOOK_SECRET\`.
|
|
7798
|
+
4. Receipts: set \`RECEIPT_FROM\` in \`wrangler.jsonc\` to an address on a domain you have
|
|
7799
|
+
onboarded to Email Service. \`vite dev\` renders each receipt and logs it instead.
|
|
7800
|
+
|
|
7801
|
+
What you sell is \`PRODUCT\` in \`src/checkout.ts\`. The Worker sends that price to Stripe, so a
|
|
7802
|
+
browser cannot change it.
|
|
7803
|
+
|
|
7804
|
+
- \`worker/checkout.ts\` — \`createStripe\` (\`@cascivo/app/stripe\`) creates the session, with
|
|
7805
|
+
the order id as its idempotency key. The webhook is checked by \`verifyWebhook\` (scheme
|
|
7806
|
+
\`stripe\`: signature and a five-minute window) before \`parseStripeEvent\` reads it.
|
|
7807
|
+
- An order moves from \`pending\` to \`paid\`, \`failed\` or \`expired\` once. A retried event, or
|
|
7808
|
+
the page getting there before the webhook, changes nothing, so the receipt goes out once.
|
|
7809
|
+
Orders are found by Stripe's session id, never by \`client_reference_id\`, which a buyer can
|
|
7810
|
+
set on a Payment Link.
|
|
7811
|
+
- A bank debit completes the session as \`unpaid\`: the order stays pending until
|
|
7812
|
+
\`async_payment_succeeded\` or \`async_payment_failed\` arrives, possibly days later.
|
|
7813
|
+
- Refunds are made in the Stripe dashboard (or with \`createRefund\`); \`charge.refunded\` records
|
|
7814
|
+
the amount, and an order refunded in full becomes \`refunded\`. A chargeback makes it
|
|
7815
|
+
\`disputed\` until it is decided: answer it with evidence in the dashboard. Both find the
|
|
7816
|
+
order by the payment it stored when it was paid.
|
|
7817
|
+
- \`src/routes/checkout/[order].tsx\` — where Stripe sends the buyer back. It watches the
|
|
7818
|
+
order's read-only room, so it updates when the webhook arrives.
|
|
7819
|
+
|
|
7820
|
+
Each caller (by IP) may start 20 checkouts a minute.${J(e)?`
|
|
7821
|
+
|
|
7822
|
+
### Subscriptions (\`/billing\`)
|
|
7823
|
+
|
|
7824
|
+
Signed-in users subscribe to \`PLAN\` (\`src/billing.ts\`) on Stripe's checkout and change,
|
|
7825
|
+
pause or cancel it in Stripe's hosted billing portal, so the app has no billing screens to build.
|
|
7826
|
+
|
|
7827
|
+
1. Add \`customer.subscription.created\`, \`customer.subscription.updated\`,
|
|
7828
|
+
\`customer.subscription.deleted\` and \`invoice.payment_failed\` to the webhook endpoint's
|
|
7829
|
+
events.
|
|
7830
|
+
2. In the Stripe dashboard, save the Customer Portal's settings once (test mode too): until
|
|
7831
|
+
then, Stripe refuses to open it.
|
|
7832
|
+
3. Gate a paid feature in the Worker with \`await billingStore.requirePlan(env, request)\` (402
|
|
7833
|
+
without the plan), never on what the page shows.
|
|
7834
|
+
|
|
7835
|
+
- \`worker/billing.ts\` — the subscription names its user in its metadata, which only the
|
|
7836
|
+
Worker sets (\`client_reference_id\` can be set by a buyer on a Payment Link). Every
|
|
7837
|
+
subscription event is read back from Stripe before it is stored, because events arrive out of
|
|
7838
|
+
order, and a late event about an older subscription cannot end a live one.
|
|
7839
|
+
- Back from checkout, \`/billing\` reads the session and its subscription at once, so it is right
|
|
7840
|
+
before the webhook arrives, and only for the user the subscription names.
|
|
7841
|
+
- A renewal that cannot be charged leaves the plan on (\`past_due\`) while Stripe retries, and
|
|
7842
|
+
emails the customer once per attempt with Stripe's page to pay with another card. How long
|
|
7843
|
+
Stripe retries, and whether it then cancels, is set in the dashboard (Billing → Subscriptions
|
|
7844
|
+
and emails); turn off Stripe's own failed-payment emails there, or customers get two.`:`
|
|
7845
|
+
|
|
7846
|
+
Subscriptions need an account to belong to: \`cascivo create --framework cloudflare
|
|
7847
|
+
--example checkout --auth email\` adds a \`/billing\` page with a monthly plan and Stripe's
|
|
7848
|
+
billing portal.`}`:``}${X(e,`newsletter`)?`
|
|
7849
|
+
|
|
7850
|
+
## Newsletter (Amazon SES)
|
|
7851
|
+
|
|
7852
|
+
\`/newsletter\` signs readers up; \`/newsletter/send\` writes an issue in Markdown, previews it
|
|
7853
|
+
as the email it will be (rendered with \`@cascivo/email\`), and sends it to every confirmed
|
|
7854
|
+
reader through Amazon SES. It runs in \`vite dev\` with nothing set up: emails are logged instead
|
|
7855
|
+
of sent, and the sign-up page shows the confirmation link. The composer's key is in
|
|
7856
|
+
\`.dev.vars\` (\`NEWSLETTER_KEY\`).
|
|
7857
|
+
|
|
7858
|
+
To send for real:
|
|
7859
|
+
|
|
7860
|
+
1. In SES, verify the domain you send from (SES gives DNS records for DKIM; add them in
|
|
7861
|
+
Cloudflare DNS, with an SPF and a DMARC record), and ask AWS to move the account out of the
|
|
7862
|
+
sandbox, where it can mail only verified addresses.
|
|
7863
|
+
2. Create an IAM user allowed \`ses:SendEmail\` and nothing else, and give its keys to the
|
|
7864
|
+
Worker: \`npx wrangler secret put AWS_ACCESS_KEY_ID\` and \`AWS_SECRET_ACCESS_KEY\` (in
|
|
7865
|
+
\`.dev.vars\` locally). Set \`AWS_REGION\` and \`NEWSLETTER_FROM\` in \`wrangler.jsonc\`, and
|
|
7866
|
+
\`npx wrangler secret put NEWSLETTER_KEY\` to a long random value.
|
|
7867
|
+
3. Bounces and complaints: create an SNS topic, set it as the SES identity's bounce and
|
|
7868
|
+
complaint notification topic, and subscribe \`https://<your app>/api/sns/ses\` to it (HTTPS).
|
|
7869
|
+
Put the topic's ARN in \`SNS_TOPIC_ARN\`. The Worker confirms the subscription itself.
|
|
7870
|
+
4. Create the queue once: \`npx wrangler queues create ${Y(e)}\`.
|
|
7871
|
+
|
|
7872
|
+
- \`worker/newsletter.ts\` — double opt-in: a sign-up gets a confirmation link (valid a day,
|
|
7873
|
+
resent at most every ten minutes) and gets no issue until it is opened. Sending stores the
|
|
7874
|
+
issue and puts its readers on the \`NEWSLETTER\` queue, 25 per message; the consumer sends
|
|
7875
|
+
them one by one through \`createSes\` (\`@cascivo/app/ses\`), handed to \`sendEmail\` from
|
|
7876
|
+
\`@cascivo/email\`, which checks each message before it leaves, one message at a time. Each
|
|
7877
|
+
send is recorded, so a message retried after SES throttling skips whoever already has it.
|
|
7878
|
+
- Every issue carries \`List-Unsubscribe\` and \`List-Unsubscribe-Post\` (one-click
|
|
7879
|
+
unsubscribe, RFC 8058, which Gmail and Yahoo require of bulk senders) and a footer link to
|
|
7880
|
+
\`/newsletter/unsubscribe\`.
|
|
7881
|
+
- \`handleSns\` checks each SNS message's signature against SNS's certificate before
|
|
7882
|
+
\`parseSesNotification\` reads it. A permanent bounce or a complaint suppresses the address
|
|
7883
|
+
for good: mailing it again is what gets an SES account reviewed.
|
|
7884
|
+
- \`worker/newsletter-email.ts\` — the issue and confirmation emails. The body is Markdown drawn
|
|
7885
|
+
through the email primitives; raw HTML in it stays literal text.
|
|
7886
|
+
|
|
7887
|
+
Sign-ups and every use of the newsletter key count against the rate limit (20 a minute per
|
|
7888
|
+
IP). Sending reads every confirmed address into memory; past a few hundred thousand readers,
|
|
7889
|
+
page through them instead.`:``}${e.auth===`email`?"\n\n## Accounts (email sign-in)\n\nAnyone can create an account with their email address: `/account` emails a one-time link,\nand opening it signs them in with a session cookie. **Every API write needs a signed-in\nuser; reads stay public.**\n\n- `worker/index.ts` — `handleAuth` (`@cascivo/app/auth-server`) answers `/api/auth/*`, and\n `requireUser` refuses any other write without a session (401) or from another site (403).\n Call `requireUser(env.DB, request)` in a handler to know who is asking.\n- `worker/auth.ts` — sends the link through Email Service. Set `AUTH_FROM` in\n `wrangler.jsonc` to an address on a domain you have onboarded to Email Service.\n- `src/auth.ts` — `auth.user`, a signal every page can read.\n- `src/routes/signin/verify.tsx` — the page a link opens. It signs in on a button press,\n because mail scanners open every link in a message.\n\nUsers, links and sessions live in D1, stored as hashes. Links expire after 15 minutes and\nwork once; sessions last 30 days. Each caller (by IP) may request 20 links a minute. In\n`vite dev` no email is sent: the Account page shows the link instead. WebSocket connections\n(rooms, agents) are not covered by the write rule; check `currentUser` before forwarding them\nif they need a user.":``}${e.auth===`access`?'\n\n## Access (who may use the app)\n\nThe Worker refuses every request Cloudflare Access did not let through: `requireAccess`\n(`@cascivo/app/guard`) at the top of `worker/index.ts` verifies the token Access signs.\n\n1. In Cloudflare One (Zero Trust), add a self-hosted Access application for this app\'s\n hostname, with a policy saying who may sign in.\n2. Put your team domain (`<team>.cloudflareaccess.com`) and the application\'s Audience (AUD)\n tag in `vars` in `wrangler.jsonc`, and deploy.\n\nUntil both are set, every API request answers 500 ("Access is not configured"). Access guards\nthe pages at the edge; the Worker\'s check also refuses API calls that reach it around Access,\nat `*.workers.dev` for example (set `"workers_dev": false` if the app should have no public\naddress at all). `vite dev` skips the check. A temporary account has no Access, so an app made\nwith `--auth access` cannot use `deploy:preview`.':``}${X(e,`crud`)?"\n\n## Customers (D1)\n\n`/customers` is a D1 table behind `DataTable`'s server mode: sorting, search, the name and\nseats filters, and paging all run as SQL in the Worker, and the page shows one page at a time.\n\n- `src/customers.ts` — `defineTable` (`@cascivo/app/db`) lists what may be sorted, searched and\n filtered. `queryTable` builds SQL from that list only, with every value a bound parameter:\n a query for any other column is refused with a 400.\n- `worker/migrations.ts` — the schema and 60 sample rows. The Worker applies them itself on its\n first query (`migrate`), so a fresh deploy, the Deploy button and `deploy:preview` need no\n migration step. Append migrations; never edit one that has shipped.\n- `worker/customers.ts` — create, update and delete, each checked by `parseCustomerInput`.\n\nD1 works on a temporary account, so `deploy:preview` shares the table with no sign-up. If you\nprefer wrangler's own migrations (`wrangler d1 migrations apply`), move the SQL into\n`migrations/` and drop the `migrate` call.":``}${X(e,`agent`)?`
|
|
5340
7890
|
|
|
5341
7891
|
## Assistant (generative UI)
|
|
5342
7892
|
|
|
@@ -5355,7 +7905,7 @@ again. The page renders the result with \`<CascivoView>\`: real components, no g
|
|
|
5355
7905
|
offline and without an account. \`VITE_REAL_AI=1 ${C(t,`dev`)}\` uses Workers AI instead
|
|
5356
7906
|
(after \`npx wrangler login\`); a deployed Worker always does.
|
|
5357
7907
|
|
|
5358
|
-
Workers AI bills per use beyond its free daily allocation.`:``}${
|
|
7908
|
+
Workers AI bills per use beyond its free daily allocation.`:``}${X(e,`publish`)?`
|
|
5359
7909
|
|
|
5360
7910
|
## Publish (views as pages)
|
|
5361
7911
|
|
|
@@ -5373,10 +7923,10 @@ code of its author's and needs no sandbox.
|
|
|
5373
7923
|
- \`src/routes/p/[slug].tsx\` — a published page.
|
|
5374
7924
|
- \`worker/page-html.ts\` — the Worker answers \`/p/<slug>\` with index.html carrying the
|
|
5375
7925
|
page's title, description and Open Graph tags, so a shared link previews properly, and the
|
|
5376
|
-
rendered page in a \`<noscript>\` for readers without JavaScript.${
|
|
7926
|
+
rendered page in a \`<noscript>\` for readers without JavaScript.${X(e,`export`)?"\n- `worker/page-preview.ts` — the preview image (`og:image`, 1200 × 630): Browser Run\n renders the page once, and D1 keeps the PNG.":``}
|
|
5377
7927
|
|
|
5378
7928
|
${e.auth?``:"**Anyone who can reach the app can publish**, and a page is served from your domain: put\nCloudflare Access in front (`--auth access`), add accounts (`--auth email`), or check who\nis publishing in `worker/index.ts`. "}Each caller (by IP) may publish 20 pages a minute (\`ratelimits\` in
|
|
5379
|
-
\`wrangler.jsonc\`). D1 works on a temporary account.`:``}${
|
|
7929
|
+
\`wrangler.jsonc\`). D1 works on a temporary account.`:``}${X(e,`voice`)?`
|
|
5380
7930
|
|
|
5381
7931
|
## Voice
|
|
5382
7932
|
|
|
@@ -5401,10 +7951,10 @@ Add more components with \`npx cascivo add <component>\`.
|
|
|
5401
7951
|
|
|
5402
7952
|
## ${n===`preact`?`Preact or React`:`React or Preact`}
|
|
5403
7953
|
|
|
5404
|
-
${
|
|
7954
|
+
${X(e,`agent`)?`This app runs on React. The Agents SDK's hooks call React 19's \`use()\`, which
|
|
5405
7955
|
Preact's compat layer does not implement, so the assistant page needs React. (The other pages
|
|
5406
7956
|
would run on Preact unchanged: the source is typed against React either way.)`:"The source is typed against React. The runtime is one plugin in `vite.config.ts`:\n`@preact/preset-vite` runs it on Preact, `@vitejs/plugin-react` on React. Switching\nneeds no source changes; swap the plugin and the matching dependencies. For this starter,\nPreact ships about a third of the client JS (~27 KB gzip against ~85 KB)."}
|
|
5407
|
-
`}function
|
|
7957
|
+
`}function on(e){return`${U(e)}
|
|
5408
7958
|
## Server
|
|
5409
7959
|
|
|
5410
7960
|
The API contract is \`src/api.ts\` (\`defineApi\` from \`@cascivo/app/api\`); its handlers are
|
|
@@ -5426,14 +7976,15 @@ To show the user a running version, run \`${w(e.pm??`npm`,`deploy:preview`)}\`.
|
|
|
5426
7976
|
It needs no Cloudflare account. Give the user both URLs it prints: the live one, and the
|
|
5427
7977
|
claim URL. The deployment is public, and it is deleted after 60 minutes unless the user
|
|
5428
7978
|
opens the claim URL and signs in.
|
|
5429
|
-
`}function
|
|
7979
|
+
`}function sn(e){let t=oe(e),n=t.indexOf(` * Adding a router?`),r=t.indexOf(` */
|
|
5430
7980
|
export function Shell`);return t.slice(0,n)+` * Routing lives in src/router.ts; nav items carry \`href\`s, and main.tsx registers the
|
|
5431
7981
|
* router's Link so they navigate client-side.
|
|
5432
|
-
`+t.slice(r)}function
|
|
5433
|
-
`
|
|
7982
|
+
`+t.slice(r)}function cn(e){return[...X(e,`webhooks`)?[`# The secret vite dev signs and checks test webhook deliveries with.`,`WEBHOOK_SECRET=dev-only-webhook-secret`]:[],...X(e,`checkout`)?[`# Stripe, in test mode (README): the secret key from the dashboard, and the signing`,"# secret `stripe listen` prints.",`STRIPE_SECRET_KEY=`,`STRIPE_WEBHOOK_SECRET=`]:[],...X(e,`newsletter`)?[`# The key /newsletter/send asks for. Deployed, choose a long random one.`,`NEWSLETTER_KEY=dev-only-newsletter-key`,`# An IAM user allowed ses:SendEmail (README). Without them, vite dev logs each email.`,`AWS_ACCESS_KEY_ID=`,`AWS_SECRET_ACCESS_KEY=`]:[]].map(e=>`${e}\n`).join(``)}function ln(e){return cn(e).split(`
|
|
7983
|
+
`).map(e=>e.startsWith(`#`)?e:e.replace(/=.*$/,`=`)).join(`
|
|
7984
|
+
`)}function un(e){let t=e=>Object.fromEntries(e.map(([e,t])=>[e,{description:t}]));return{...X(e,`webhooks`)?t([[`WEBHOOK_SECRET`,"The secret of the GitHub webhook that posts to `/api/webhooks/github`."]]):{},...X(e,`checkout`)?t([[`STRIPE_SECRET_KEY`,"Your Stripe secret key, from the [API keys page](https://dashboard.stripe.com/test/apikeys). A test key (`sk_test_…`) takes test cards only."],[`STRIPE_WEBHOOK_SECRET`,"The signing secret (`whsec_…`) of a Stripe webhook endpoint at `https://<this app>/api/stripe/webhook`. No endpoint yet? Enter `later`: orders are confirmed on their own page without it. Set the real one with `npx wrangler secret put STRIPE_WEBHOOK_SECRET`."],[`RECEIPT_FROM`,`The From address of receipts, on a domain onboarded to Cloudflare Email Service. Leave it empty to send none.`]]):{},...X(e,`newsletter`)?t([[`NEWSLETTER_KEY`,"The key `/newsletter/send` asks for. Make a long random one: `openssl rand -hex 32`."],[`AWS_ACCESS_KEY_ID`,"An IAM user allowed `ses:SendEmail` and nothing else (README)."],[`AWS_SECRET_ACCESS_KEY`,`That IAM user's secret access key.`],[`AWS_REGION`,"The SES region your sending domain is verified in, e.g. `eu-west-1`."],[`NEWSLETTER_FROM`,`The From address of issues, on your SES-verified domain.`],[`SNS_TOPIC_ARN`,"The SNS topic SES reports bounces and complaints to, subscribed to `https://<this app>/api/sns/ses`."]]):{}}}function dn(e,t){let n=$(e),r=[...t.map((e,t)=>({file:Ie(e,t),contents:t===0?Be(e):Ve(e,t)})),{file:`404.tsx`,contents:He()},...X(e,`agent`)?[{file:`assistant.tsx`,contents:Ye()}]:[],...X(e,`board`)?[{file:`board.tsx`,contents:We()}]:[],...X(e,`notes`)?[{file:`notes.tsx`,contents:Qe()}]:[],...X(e,`import`)?[{file:`import.tsx`,contents:nt()}]:[],...X(e,`files`)?[{file:`files.tsx`,contents:at()}]:[],...X(e,`export`)?[{file:`report.tsx`,contents:st()}]:[],...X(e,`usage`)?[{file:`usage.tsx`,contents:ut()}]:[],...X(e,`crud`)?[{file:`customers.tsx`,contents:tn()}]:[],...X(e,`live`)?[{file:`ops.tsx`,contents:Xt()}]:[],...X(e,`voice`)?[{file:`voice.tsx`,contents:Jt()}]:[],...X(e,`publish`)?[{file:`publish.tsx`,contents:Ut()},{file:`p/[slug].tsx`,contents:Wt()}]:[],...X(e,`webhooks`)?[{file:`webhooks.tsx`,contents:bt()}]:[],...X(e,`digest`)?[{file:`digest.tsx`,contents:_t()}]:[],...X(e,`search`)?[{file:`search.tsx`,contents:mt()}]:[],...X(e,`checkout`)?[{file:`checkout.tsx`,contents:Ct()},{file:`checkout/[order].tsx`,contents:wt()}]:[],...J(e)?[{file:`billing.tsx`,contents:Pt()}]:[],...X(e,`newsletter`)?[{file:`newsletter.tsx`,contents:Ot()},{file:`newsletter/confirm.tsx`,contents:kt()},{file:`newsletter/unsubscribe.tsx`,contents:At()},{file:`newsletter/send.tsx`,contents:jt()}]:[],...e.auth===`email`?[{file:`account.tsx`,contents:Lt()},{file:`signin/verify.tsx`,contents:Rt()}]:[]];return[{path:`package.json`,contents:ye(e)},{path:`tsconfig.json`,contents:be(e)},...Q(e)?[{path:`tsconfig.worker.json`,contents:xe()}]:[],{path:`vite.config.ts`,contents:Se(n,e)},{path:`wrangler.jsonc`,contents:Ce(e)},{path:`index.html`,contents:I(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:nn()},{path:`.gitignore`,contents:rn()},{path:`README.md`,contents:an(e)},{path:`AGENTS.md`,contents:on(e)},{path:`worker/index.ts`,contents:Me(e)},{path:`src/api.ts`,contents:je(e)},{path:`src/live.ts`,contents:Ne()},{path:`src/LiveCard.tsx`,contents:Pe()},{path:`src/main.tsx`,contents:ze()},{path:`src/vite-env.d.ts`,contents:ie()},{path:`src/router.ts`,contents:Le()},{path:`src/App.tsx`,contents:Re(t,e)},{path:`src/Shell.tsx`,contents:sn(e)},...X(e,`agent`)?[{path:`worker/assistant.ts`,contents:qe()},{path:`worker/scripted-model.ts`,contents:Je()},{path:`src/assistant.ts`,contents:Ke()},{path:`src/assistant.module.css`,contents:Xe()}]:[],...X(e,`notes`)?[{path:`src/notes.ts`,contents:Ze()}]:[],...X(e,`import`)?[{path:`src/import-job.ts`,contents:$e()},{path:`src/import-page.ts`,contents:et()},{path:`worker/import-job.ts`,contents:tt()}]:[],...X(e,`crud`)?[{path:`src/customers.ts`,contents:Zt()},{path:`src/customers-page.ts`,contents:Qt()},{path:`worker/customers.ts`,contents:$t()},{path:`worker/migrations.ts`,contents:en()}]:[],...X(e,`live`)?[{path:`src/ops.ts`,contents:Yt()}]:[],...X(e,`search`)?[{path:`src/search.ts`,contents:dt()},{path:`worker/search.ts`,contents:ft()},{path:`worker/articles.ts`,contents:pt()}]:[],...X(e,`digest`)?[{path:`src/digest.ts`,contents:ht()},{path:`worker/digest.ts`,contents:gt()}]:[],...X(e,`webhooks`)?[{path:`src/webhooks.ts`,contents:vt()},{path:`worker/webhooks.ts`,contents:yt()}]:[],...X(e,`checkout`)?[{path:`src/checkout.ts`,contents:xt(e)},{path:`worker/checkout.ts`,contents:St()}]:[],...J(e)?[{path:`src/billing.ts`,contents:Mt()},{path:`worker/billing.ts`,contents:Nt()}]:[],...X(e,`newsletter`)?[{path:`src/newsletter.ts`,contents:Tt()},{path:`worker/newsletter.ts`,contents:Dt()},{path:`worker/newsletter-email.ts`,contents:Et(e)}]:[],...X(e,`webhooks`)||X(e,`checkout`)||X(e,`newsletter`)?[{path:`.dev.vars`,contents:cn(e)},{path:`.dev.vars.example`,contents:ln(e)}]:[],...e.auth===`email`?[{path:`src/auth.ts`,contents:Ft()},{path:`worker/auth.ts`,contents:It()}]:[],...X(e,`publish`)?[{path:`src/pages.ts`,contents:zt()},{path:`worker/pages.ts`,contents:Bt()},{path:`worker/page-html.ts`,contents:Vt()},...X(e,`export`)?[{path:`worker/page-preview.ts`,contents:Ht()}]:[]]:[],...X(e,`voice`)?[{path:`src/voice.ts`,contents:Gt()},{path:`worker/voice.ts`,contents:Kt()},{path:`worker/scripted-voice.ts`,contents:qt()}]:[],...X(e,`usage`)?[{path:`src/usage.ts`,contents:ct().replace(`dataset: 'app_usage'`,`dataset: '${Ee(e)}'`)},{path:`worker/usage.ts`,contents:lt()}]:[],...X(e,`files`)?[{path:`src/upload-policy.ts`,contents:rt()},{path:`src/files.ts`,contents:it()},{path:`src/files.module.css`,contents:ot()}]:[],...X(e,`board`)?[{path:`src/board.ts`,contents:Ue()},{path:`src/board.module.css`,contents:Ge()}]:[],...r.map(({file:e,contents:t})=>({path:`src/routes/${e}`,contents:t})),{path:`src/routes.gen.ts`,contents:b(r.map(e=>e.file),`./routes`)}]}function fn(e){let t=ee(e.sections);if(e.framework===`astro`)return pn(e,t);if(e.framework===`cloudflare`){let n=e.examples?.includes(`digest`)&&!e.examples.includes(`export`)?[...e.examples,`export`]:e.examples;return dn({...e,...n?{examples:n}:{}},t)}return[{path:`package.json`,contents:te(e)},{path:`tsconfig.json`,contents:F()},{path:`vite.config.ts`,contents:ne()},{path:`index.html`,contents:I(e)},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:V()},{path:`.gitignore`,contents:se()},{path:`README.md`,contents:ce(e)},{path:`AGENTS.md`,contents:U(e)},{path:`src/main.tsx`,contents:re()},{path:`src/vite-env.d.ts`,contents:ie()},{path:`src/App.tsx`,contents:ae(t)},{path:`src/Shell.tsx`,contents:oe(e)},...t.map(e=>({path:`src/sections/${e.component}.tsx`,contents:L(e)}))]}function pn(e,t){return[{path:`package.json`,contents:le(e)},{path:`tsconfig.json`,contents:de()},{path:`astro.config.mjs`,contents:ue()},{path:`eslint.config.js`,contents:R()},{path:`.prettierrc`,contents:B()},{path:`.prettierignore`,contents:V()},{path:`.gitignore`,contents:_e()},{path:`README.md`,contents:ve(e,t)},{path:`AGENTS.md`,contents:U(e)},{path:`src/styles/layers.css`,contents:fe()},{path:`src/layouts/Layout.astro`,contents:pe(e)},{path:`src/components/Shell.tsx`,contents:me(e,t)},...t.map((e,t)=>({path:`src/pages/${G(e,t)}`,contents:ge(e,t)})),...t.map(e=>({path:`src/components/${e.component}.tsx`,contents:he(e)}))]}const mn=[`Dashboard`,`Reports`,`Settings`];async function hn(f,p=process.cwd()){let m=f.includes(`--yes`)||f.includes(`-y`),h=r(f,[`pm`,`package-manager`,`theme`,`sections`,`template`,`framework`,`runtime`,`example`,`auth`])[0],g=a(f,`theme`),_=a(f,`sections`),v=(a(f,`framework`)??``).toLowerCase();if(v&&!T.includes(v)){console.error(`Unknown framework "${v}". Expected one of: ${T.join(`, `)}.`),process.exitCode=1;return}let y=(a(f,`runtime`)??``).toLowerCase();if(y&&!k(y)){console.error(`Unknown runtime "${y}". Expected one of: ${E.join(`, `)}.`),process.exitCode=1;return}let b=(a(f,`example`)??``).split(`,`).map(e=>e.trim().toLowerCase()).filter(Boolean),x=b.find(e=>!O(e));if(x){console.error(`Unknown example "${x}". Expected one of: ${D.join(`, `)}.`),process.exitCode=1;return}let A=(a(f,`auth`)??``).toLowerCase();if(A&&A!==`access`&&A!==`email`){console.error(`Unknown auth "${A}". Expected: access or email.`),process.exitCode=1;return}let j=i(f);if(`error`in j){console.error(j.error),process.exitCode=1;return}let M=e(p,{preferLockfileOverUserAgent:!0,...j.pm?{override:j.pm}:{}}),N=!m&&o.isTTY?d({input:o,output:s}):null;try{let e=h;!e&&N&&(e=(await N.question(`Project name? [my-cascivo-app]: `)).trim()),e||=`my-cascivo-app`;let r=(g??``).toLowerCase();!t.includes(r)&&N&&(r=(await N.question(`Theme? (${t.join(`/`)}) [light]: `)).trim().toLowerCase());let i=t.includes(r)?r:`light`,o=_;!o&&N&&(o=(await N.question(`Nav sections? (comma-separated) [${mn.join(`, `)}]: `)).trim());let s=(o??``).split(`,`).map(e=>e.trim()).filter(Boolean),d=v;!d&&N&&(d=(await N.question(`Framework? (${T.join(`/`)}) [react-vite]: `)).trim().toLowerCase());let m=T.includes(d)?d:`react-vite`;if(b.length>0&&m!==`cloudflare`){console.error(`--example needs --framework cloudflare (it adds a Worker-backed page).`),process.exitCode=1;return}if(A&&m!==`cloudflare`){console.error(`--auth needs --framework cloudflare (it guards the Worker).`),process.exitCode=1;return}if(b.includes(`agent`)&&y===`preact`){console.error(`--example agent needs --runtime react: the Agents SDK's hooks call React 19's use(), which Preact does not implement.`),process.exitCode=1;return}let x={name:e,framework:m,theme:i,sections:s.length>0?s:mn,pm:M,...k(y)?{runtime:y}:{},...b.length>0?{examples:b.filter(O)}:{},...A===`access`||A===`email`?{auth:A}:{}},E=u(p,e);if(c(E)&&l(E).length>0){console.error(`Target directory "${e}" already exists and is not empty.`),process.exitCode=1;return}let D=fn(x);for(let e of D)await n(u(E,e.path),e.contents);console.log(`\nCreated ${e} (${m}) with the ${i} theme (${D.length} files).`);let j=a(f,`template`);if(j){let{add:e}=await import(`./index.mjs`).then(e=>e.t),{loadConfig:t}=await import(`./config-DT-Cs7bC.mjs`).then(e=>e.i);console.log(`\nInstalling template "${j}"…`),await e([j],await t(),{cwd:E,pm:M})}console.log(`
|
|
5434
7985
|
Next steps:`),console.log(` cd ${e}`),console.log(` ${S(M)}`),console.log(` ${C(M,`dev`)}`),console.log(`
|
|
5435
7986
|
Good to know:`),console.log(` No cascivo.config.ts is written — this app uses the prebuilt @cascivo/react`),console.log(" packages and never copies source. `cascivo add <component>` writes the"),console.log(` config itself the first time you vendor a component.`),m===`cloudflare`?(console.log("\n worker/index.ts is the API (wrangler.jsonc routes /api/* to it); `dev`"),console.log(" runs it in workerd. Deploy with `npx wrangler login` once, then the"),console.log(` deploy script: ${w(M,`deploy`)}`),console.log(`
|
|
5436
7987
|
No account yet? Share a 60-minute preview, claimable into a free account:`),console.log(` ${w(M,`deploy:preview`)}`)):m===`astro`?(console.log(`
|
|
5437
7988
|
Pages are real Astro routes — no client router to add. Only src/`),console.log(` components/Shell.tsx hydrates (client:load, for the mobile nav drawer);`),console.log(` page content is server-rendered and ships no JS. See`),console.log(` https://cascivo.com/docs/using-with-astro.md`)):(console.log(`
|
|
5438
|
-
Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,`),console.log(` and register your Link once with setLinkComponent — see`),console.log(` https://cascivo.com/docs/using-with-a-router.md`))}finally{N?.close()}}export{
|
|
5439
|
-
//# sourceMappingURL=create-
|
|
7989
|
+
Adding a router? Keep src/Shell.tsx, delete src/App.tsx + src/sections/,`),console.log(` and register your Link once with setLinkComponent — see`),console.log(` https://cascivo.com/docs/using-with-a-router.md`))}finally{N?.close()}}export{hn as create};
|
|
7990
|
+
//# sourceMappingURL=create-DmBKREqv.mjs.map
|