@alfredmouelle/create-stack 0.1.0 → 0.1.2
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/README.md +101 -40
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +4 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +3 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +12 -5
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- package/index.mjs +33 -44
- package/lib/build.mjs +7 -15
- package/lib/env.mjs +5 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +65 -10
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/analytics/capability.json +0 -26
- package/_stack/packages/cache/capability.json +0 -21
- package/_stack/packages/error-tracking/capability.json +0 -21
- package/_stack/packages/jobs/capability.json +0 -26
- package/_stack/packages/logger/capability.json +0 -21
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/packages/storage/capability.json +0 -32
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
package/README.md
CHANGED
|
@@ -1,56 +1,117 @@
|
|
|
1
1
|
# create-stack
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
counterpart of the `bootstrap` skill's CREATE mode. It forks a base app
|
|
5
|
-
(`apps/tanstack-base` or `apps/next-base`) and strips it down to your selection.
|
|
3
|
+
> `@alfredmouelle/create-stack`
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
Interactive, **deterministic** project installer. It forks a fully-wired base app
|
|
6
|
+
(**Next.js App Router** or **TanStack Start**) and strips it down to exactly the
|
|
7
|
+
foundations and provider you pick — Drizzle, tRPC, better-auth, data tables and a
|
|
8
|
+
mailer — then stamps identity, generates `.env`, initializes git and verifies the
|
|
9
|
+
result (typecheck + Biome).
|
|
10
|
+
|
|
11
|
+
No template guesswork: the output is a real, buildable app from day one.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
pnpm dlx @alfredmouelle/create-stack my-app
|
|
17
|
+
# or, using the create-* convention:
|
|
18
|
+
pnpm create @alfredmouelle/stack my-app
|
|
19
|
+
# npm / yarn:
|
|
20
|
+
npm create @alfredmouelle/stack my-app
|
|
21
|
+
yarn create @alfredmouelle/stack my-app
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run with no extra flags → an **interactive wizard** asks every question. Pass any
|
|
25
|
+
selection flag → **non-interactive** mode (scriptable / CI).
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- **Node** ≥ 22
|
|
30
|
+
- **pnpm** (the generated project is a pnpm project)
|
|
31
|
+
- **git** and **rsync** available on `PATH` (macOS/Linux ship both)
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
create-stack [project] [flags]
|
|
15
37
|
```
|
|
16
38
|
|
|
17
|
-
|
|
39
|
+
`project` is the target directory (and default package name). It must be empty or
|
|
40
|
+
not exist yet. In non-interactive mode it is required.
|
|
41
|
+
|
|
42
|
+
### Flags
|
|
43
|
+
|
|
44
|
+
| Flag | Values | Default | Description |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `--framework` | `tanstack` \| `next` | `tanstack` | Base app to fork. |
|
|
47
|
+
| `--foundations` | csv of `drizzle,trpc,better-auth,data-table` | all | Foundations to keep; the rest are stripped. |
|
|
48
|
+
| `--mailer` | `resend` \| `brevo` \| `ses` \| `none` | `resend` | Mailer provider. `none` is rejected when `better-auth` is kept. |
|
|
49
|
+
| `--no-install` | — | install on | Skip `pnpm install` + verification. |
|
|
50
|
+
| `--yes`, `-y` | — | — | Non-interactive with all defaults. |
|
|
51
|
+
|
|
52
|
+
Passing any of `--framework`, `--foundations`, `--mailer` or `--no-install`
|
|
53
|
+
(or `--yes`) switches the CLI to non-interactive mode; missing values fall back
|
|
54
|
+
to the defaults above.
|
|
18
55
|
|
|
19
|
-
|
|
20
|
-
- **Foundations** — Drizzle, tRPC, better-auth, data-table (hard deps resolved:
|
|
21
|
-
tRPC/better-auth ⇒ Drizzle; better-auth ⇒ mailer)
|
|
22
|
-
- **Mailer provider** — Resend, Brevo, Amazon SES (or none, if no better-auth)
|
|
23
|
-
- **Extra capabilities** — storage, jobs, cache, … (added afterwards via the
|
|
24
|
-
`add-capability` skill; not baked into the base)
|
|
56
|
+
### Dependency resolution
|
|
25
57
|
|
|
26
|
-
|
|
27
|
-
identity, generates `.env(.example)`, and optionally installs + verifies
|
|
28
|
-
(typecheck + Biome).
|
|
58
|
+
Selections are normalized for you:
|
|
29
59
|
|
|
30
|
-
|
|
60
|
+
- `trpc` ⇒ pulls in `drizzle`
|
|
61
|
+
- `better-auth` ⇒ pulls in `drizzle` **and** forces a real mailer (not `none`)
|
|
31
62
|
|
|
32
|
-
|
|
33
|
-
the `patterns/*/pattern.json` manifests drive deps/scripts/env/file lists.
|
|
34
|
-
- **Deterministic strip**: whole-directory deletes + dep/env/script diffs from the
|
|
35
|
-
manifests, plus a few hardcoded code *seams* (tRPC↔auth context, root provider
|
|
36
|
-
wiring) handled via shipped reduced variants in `templates/`.
|
|
37
|
-
- If you edit a seam file in a base app (`server/api/trpc.ts`, the root
|
|
38
|
-
`router.tsx` / `__root.tsx` / `layout.tsx`, the schema barrel), update the
|
|
39
|
-
matching transform in `lib/strip.mjs` or the `templates/` variant.
|
|
63
|
+
### Examples
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
```bash
|
|
66
|
+
# Full interactive wizard
|
|
67
|
+
pnpm dlx @alfredmouelle/create-stack my-app
|
|
68
|
+
|
|
69
|
+
# Everything, defaults (TanStack Start + all foundations + Resend), no questions
|
|
70
|
+
pnpm dlx @alfredmouelle/create-stack my-app --yes
|
|
42
71
|
|
|
72
|
+
# Next.js with just Drizzle + tRPC, Amazon SES mailer, don't install
|
|
73
|
+
pnpm dlx @alfredmouelle/create-stack api --framework next \
|
|
74
|
+
--foundations drizzle,trpc --mailer ses --no-install
|
|
75
|
+
|
|
76
|
+
# Minimal: Drizzle only, no mailer
|
|
77
|
+
pnpm dlx @alfredmouelle/create-stack db-svc --foundations drizzle --mailer none
|
|
43
78
|
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
|
|
80
|
+
## What you get
|
|
81
|
+
|
|
82
|
+
- **Framework** — Next.js App Router *or* TanStack Start, fully wired (SSR, routing).
|
|
83
|
+
- **Drizzle ORM** — Postgres client, schema barrel, keyset pagination, seed harness.
|
|
84
|
+
- **tRPC v11** — typed API, SSR/RSC integration, health router.
|
|
85
|
+
- **better-auth** — email+password + verification, optional Google OAuth, session
|
|
86
|
+
guards, auth UI pages.
|
|
87
|
+
- **Mailer** — Resend / Brevo / SES behind one port; React Email templates.
|
|
88
|
+
- **Data tables** — TanStack Table primitives (DataTable, InfiniteDataTable, …).
|
|
89
|
+
- **Baseline** — Tailwind v4 + shadcn, Geist, theme toggle, strict Biome, typed
|
|
90
|
+
env (`src/env.ts`), Dockerfile, a generated `.gitignore` and `.env`/`.env.example`.
|
|
91
|
+
|
|
92
|
+
Unselected foundations are removed cleanly (files, deps, env vars and wiring),
|
|
93
|
+
and the project is left **bootable and green** (typecheck + Biome).
|
|
94
|
+
|
|
95
|
+
## Adding more capabilities
|
|
96
|
+
|
|
97
|
+
The base bakes in the **mailer** (chosen via `--mailer`). Other capabilities —
|
|
98
|
+
storage, jobs, cache, logger, analytics, error-tracking, http — are added *after*
|
|
99
|
+
scaffolding with the `add-capability` skill (it wires each adapter's env/config per
|
|
100
|
+
provider, which this CLI deliberately leaves out).
|
|
101
|
+
|
|
102
|
+
## After scaffolding
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd my-app
|
|
106
|
+
pnpm install # only if you passed --no-install
|
|
107
|
+
cp .env.example .env # fill in the values
|
|
108
|
+
pnpm dev
|
|
56
109
|
```
|
|
110
|
+
|
|
111
|
+
## Notes
|
|
112
|
+
|
|
113
|
+
- The published package is **self-contained**: the base apps, pattern manifests
|
|
114
|
+
and mailer adapters are bundled at publish time, so `pnpm dlx` needs nothing but
|
|
115
|
+
this package.
|
|
116
|
+
- The generated project is a fresh git repo (`git init`, files staged) — make your
|
|
117
|
+
first commit when ready.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc --noEmit
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files.watcherExclude": {
|
|
3
|
+
"**/routeTree.gen.ts": true
|
|
4
|
+
},
|
|
5
|
+
"search.exclude": {
|
|
6
|
+
"**/routeTree.gen.ts": true
|
|
7
|
+
},
|
|
8
|
+
"files.readonlyInclude": {
|
|
9
|
+
"**/routeTree.gen.ts": true
|
|
10
|
+
},
|
|
11
|
+
"[javascript]": {
|
|
12
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
13
|
+
},
|
|
14
|
+
"[javascriptreact]": {
|
|
15
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
16
|
+
},
|
|
17
|
+
"[typescript]": {
|
|
18
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
19
|
+
},
|
|
20
|
+
"[typescriptreact]": {
|
|
21
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
22
|
+
},
|
|
23
|
+
"[json]": {
|
|
24
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
25
|
+
},
|
|
26
|
+
"[jsonc]": {
|
|
27
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
28
|
+
},
|
|
29
|
+
"[css]": {
|
|
30
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
31
|
+
},
|
|
32
|
+
"editor.codeActionsOnSave": {
|
|
33
|
+
"source.organizeImports.biome": "explicit"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lsp": {
|
|
3
|
+
"biome": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"require_config_file": true,
|
|
6
|
+
"config_path": "biome.jsonc"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"languages": {
|
|
12
|
+
"Markdown": {
|
|
13
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
14
|
+
},
|
|
15
|
+
"JSONC": {
|
|
16
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
17
|
+
},
|
|
18
|
+
"JSON": {
|
|
19
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
20
|
+
},
|
|
21
|
+
"HTML": { "formatter": { "language_server": { "name": "biome" } } },
|
|
22
|
+
"CSS": { "formatter": { "language_server": { "name": "biome" } } },
|
|
23
|
+
"JavaScript": {
|
|
24
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
25
|
+
"code_actions_on_format": {
|
|
26
|
+
"source.fixAll.biome": true,
|
|
27
|
+
"source.organizeImports.biome": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"TypeScript": {
|
|
31
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
32
|
+
"code_actions_on_format": {
|
|
33
|
+
"source.fixAll.biome": true,
|
|
34
|
+
"source.organizeImports.biome": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"TSX": {
|
|
38
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
39
|
+
"code_actions_on_format": {
|
|
40
|
+
"source.fixAll.biome": true,
|
|
41
|
+
"source.organizeImports.biome": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'
|
|
|
2
2
|
import type { ComponentProps } from 'react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Spinning lucide loader icon. */
|
|
6
6
|
export function Spinner({ className, ...props }: ComponentProps<typeof Loader2>) {
|
|
7
7
|
return <Loader2 aria-hidden="true" className={cn('size-4 animate-spin', className)} {...props} />
|
|
8
8
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
import { Body, Container, Head, Html, Link, Preview, Section, Tailwind, Text } from 'react-email'
|
|
2
4
|
import { EmailThemeProvider, useEmailTheme } from './context'
|
|
3
5
|
import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './theme'
|
|
@@ -5,9 +7,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
5
7
|
export interface EmailLayoutProps {
|
|
6
8
|
preview: string
|
|
7
9
|
children: React.ReactNode
|
|
8
|
-
/**
|
|
10
|
+
/** Theme override, merged onto default. */
|
|
9
11
|
theme?: EmailTheme | EmailThemeOverride
|
|
10
|
-
/** Footer year. Pass explicitly
|
|
12
|
+
/** Footer year. Pass explicitly for deterministic output. */
|
|
11
13
|
year?: number
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
import { createContext, useContext } from 'react'
|
|
2
4
|
import { defaultTheme, type EmailTheme } from './theme'
|
|
3
5
|
|
|
@@ -12,7 +14,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
12
14
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
16
18
|
export function useEmailTheme(): EmailTheme {
|
|
17
19
|
return useContext(EmailThemeContext)
|
|
18
20
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* or wrap a tree in `<EmailThemeProvider>`.
|
|
2
|
+
* Swappable email design tokens. Override a subset via {@link createEmailTheme}
|
|
3
|
+
* and pass to `<EmailLayout theme={...}>`, or wrap in `<EmailThemeProvider>`.
|
|
5
4
|
*/
|
|
6
5
|
export interface EmailTheme {
|
|
7
6
|
brand: {
|
|
8
|
-
/**
|
|
7
|
+
/** Header band + footer. */
|
|
9
8
|
name: string
|
|
10
|
-
/** Footer line (
|
|
9
|
+
/** Footer line (year prefixed automatically). */
|
|
11
10
|
footer: string
|
|
12
11
|
}
|
|
13
12
|
fontFamily: string
|
|
@@ -21,7 +20,7 @@ export interface EmailTheme {
|
|
|
21
20
|
border: string
|
|
22
21
|
borderSubtle: string
|
|
23
22
|
destructive: string
|
|
24
|
-
/** Text
|
|
23
|
+
/** Text on accent/destructive buttons. */
|
|
25
24
|
onAccent: string
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -52,7 +51,7 @@ export type EmailThemeOverride = {
|
|
|
52
51
|
colors?: Partial<EmailTheme['colors']>
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Deep-merge
|
|
54
|
+
/** Deep-merge override onto base (defaults to {@link defaultTheme}). */
|
|
56
55
|
export function createEmailTheme(
|
|
57
56
|
override: EmailThemeOverride = {},
|
|
58
57
|
base: EmailTheme = defaultTheme,
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createEnv } from '@t3-oss/env-core'
|
|
2
2
|
import * as v from 'valibot'
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** Required in production, optional in dev/test. */
|
|
5
5
|
export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
|
|
6
6
|
process.env.NODE_ENV === 'production' ? schema : v.optional(schema)
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Typed
|
|
10
|
-
* capabilities (add-capability) extend the `server` block and `runtimeEnv` with
|
|
9
|
+
* Typed env. Foundations + capabilities extend `server` and `runtimeEnv` with
|
|
11
10
|
* the keys they need.
|
|
12
11
|
*/
|
|
13
12
|
export const env = createEnv({
|
|
@@ -2,7 +2,7 @@ import 'server-only'
|
|
|
2
2
|
import { redirect } from 'next/navigation'
|
|
3
3
|
import { getSession } from '~/server/better-auth/server'
|
|
4
4
|
|
|
5
|
-
/** Require
|
|
5
|
+
/** Require signed-in user in a Server Component/page; else redirect. */
|
|
6
6
|
export async function requireAuth() {
|
|
7
7
|
const session = await getSession()
|
|
8
8
|
if (!session) redirect('/auth/sign-in')
|
|
@@ -36,7 +36,7 @@ export const auth = betterAuth({
|
|
|
36
36
|
},
|
|
37
37
|
socialProviders,
|
|
38
38
|
user: {
|
|
39
|
-
//
|
|
39
|
+
// Extra user columns here (mirror in auth.schema.ts):
|
|
40
40
|
// additionalFields: {
|
|
41
41
|
// role: { type: 'string', defaultValue: 'user', input: false },
|
|
42
42
|
// },
|
|
@@ -2,10 +2,10 @@ import { headers } from 'next/headers'
|
|
|
2
2
|
import { cache } from 'react'
|
|
3
3
|
import { auth } from '.'
|
|
4
4
|
|
|
5
|
-
/** Per-request cached session
|
|
5
|
+
/** Per-request cached session (uses cookie cache). */
|
|
6
6
|
export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }))
|
|
7
7
|
|
|
8
|
-
/** Bypasses
|
|
8
|
+
/** Bypasses cookie cache; use for sensitive checks. */
|
|
9
9
|
export const getFreshSession = cache(async () =>
|
|
10
10
|
auth.api.getSession({
|
|
11
11
|
headers: await headers(),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Drizzle schema barrel. One `export *` per `*.schema.ts`.
|
|
2
2
|
export * from './auth.schema'
|
|
@@ -3,11 +3,11 @@ import { config } from 'dotenv'
|
|
|
3
3
|
|
|
4
4
|
config({ path: ['.env.local', '.env'] })
|
|
5
5
|
|
|
6
|
-
// Deterministic
|
|
6
|
+
// Deterministic across runs.
|
|
7
7
|
faker.seed(42)
|
|
8
8
|
|
|
9
9
|
async function main() {
|
|
10
|
-
//
|
|
10
|
+
// Seed tables here (idempotent), via faker:
|
|
11
11
|
// const { db } = await import('~/server/db')
|
|
12
12
|
// await db.insert(user).values({
|
|
13
13
|
// id: faker.string.uuid(),
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port'
|
|
5
5
|
import { ResendConfigSchema } from './config'
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Structural view of the Resend client we depend on (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/** Inject a custom/mock client. Defaults to
|
|
33
|
+
/** Inject a custom/mock client. Defaults to real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early so a missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -2,7 +2,7 @@ import type { MailAddress, MailRecipient } from './port'
|
|
|
2
2
|
|
|
3
3
|
const ADDRESS_RE = /^\s*(.*?)\s*<([^>]+)>\s*$/
|
|
4
4
|
|
|
5
|
-
/** Coerce
|
|
5
|
+
/** Coerce recipient to {@link MailAddress}. */
|
|
6
6
|
export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
7
7
|
if (typeof input !== 'string') return input
|
|
8
8
|
const match = input.match(ADDRESS_RE)
|
|
@@ -10,12 +10,12 @@ export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
|
10
10
|
return { email: input.trim() }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Coerce
|
|
13
|
+
/** Coerce recipient(s) to normalized array. */
|
|
14
14
|
export function normalizeRecipients(input: MailRecipient | MailRecipient[]): MailAddress[] {
|
|
15
15
|
return (Array.isArray(input) ? input : [input]).map(normalizeAddress)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Render
|
|
18
|
+
/** Render address to RFC string (`Name <email>`). */
|
|
19
19
|
export function formatAddress(address: MailAddress): string {
|
|
20
20
|
return address.name ? `${address.name} <${address.email}>` : address.email
|
|
21
21
|
}
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
import type { ReactElement } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** Structured email address. */
|
|
4
4
|
export interface MailAddress {
|
|
5
5
|
email: string
|
|
6
6
|
name?: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A recipient, either as a plain string (`"hi@acme.com"` or
|
|
11
|
-
* `"Acme <hi@acme.com>"`) or a structured {@link MailAddress}.
|
|
12
|
-
*/
|
|
9
|
+
/** Recipient: string (`"hi@acme.com"` / `"Acme <hi@acme.com>"`) or {@link MailAddress}. */
|
|
13
10
|
export type MailRecipient = string | MailAddress
|
|
14
11
|
|
|
15
12
|
export interface MailAttachment {
|
|
16
13
|
filename: string
|
|
17
|
-
/** Raw bytes or
|
|
14
|
+
/** Raw bytes or base64 string. */
|
|
18
15
|
content: Uint8Array | string
|
|
19
16
|
contentType?: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the mailer before it reaches the adapter.
|
|
20
|
+
* Message authored by app code. Never raw HTML: body is a React Email
|
|
21
|
+
* component, rendered to HTML + text by the mailer before the adapter.
|
|
26
22
|
*/
|
|
27
23
|
export interface MailMessage {
|
|
28
24
|
to: MailRecipient | MailRecipient[]
|
|
29
25
|
subject: string
|
|
30
|
-
/**
|
|
26
|
+
/** React Email component; rendered to HTML + text automatically. */
|
|
31
27
|
react: ReactElement
|
|
32
|
-
/** Overrides the default `from
|
|
28
|
+
/** Overrides the mailer's default `from`. */
|
|
33
29
|
from?: MailRecipient
|
|
34
30
|
replyTo?: MailRecipient
|
|
35
31
|
cc?: MailRecipient | MailRecipient[]
|
|
36
32
|
bcc?: MailRecipient | MailRecipient[]
|
|
37
33
|
headers?: Record<string, string>
|
|
38
|
-
/** Provider-agnostic
|
|
34
|
+
/** Provider-agnostic tags for analytics/filtering. */
|
|
39
35
|
tags?: Record<string, string>
|
|
40
36
|
attachments?: MailAttachment[]
|
|
41
37
|
}
|
|
@@ -46,17 +42,14 @@ export interface SentMail {
|
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
* The port the
|
|
50
|
-
*
|
|
45
|
+
* The port the app depends on. Swap providers by swapping the adapter passed
|
|
46
|
+
* to {@link createMailer}; this interface never changes.
|
|
51
47
|
*/
|
|
52
48
|
export interface Mailer {
|
|
53
49
|
send(message: MailMessage): Promise<SentMail>
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
/**
|
|
57
|
-
* The message shape an adapter receives: addresses are normalized and the body
|
|
58
|
-
* is already rendered to `html` + `text`. Adapters do no rendering.
|
|
59
|
-
*/
|
|
52
|
+
/** Shape an adapter receives: addresses normalized, body pre-rendered to `html` + `text`. */
|
|
60
53
|
export interface RenderedMessage {
|
|
61
54
|
to: MailAddress[]
|
|
62
55
|
from: MailAddress
|
|
@@ -71,13 +64,13 @@ export interface RenderedMessage {
|
|
|
71
64
|
attachments?: MailAttachment[]
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
/**
|
|
67
|
+
/** Contract each provider implements. */
|
|
75
68
|
export interface MailerAdapter {
|
|
76
69
|
readonly name: string
|
|
77
70
|
send(message: RenderedMessage): Promise<SentMail>
|
|
78
71
|
}
|
|
79
72
|
|
|
80
|
-
/** Normalized error
|
|
73
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
81
74
|
export class MailerError extends Error {
|
|
82
75
|
readonly adapter: string
|
|
83
76
|
|
|
@@ -6,10 +6,10 @@ export interface RenderedBody {
|
|
|
6
6
|
text: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Turns a React Email component into HTML + text. */
|
|
10
10
|
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
11
|
|
|
12
|
-
/** Default renderer
|
|
12
|
+
/** Default renderer via `@react-email/render`. */
|
|
13
13
|
export const renderEmail: EmailRenderer = async (react) => {
|
|
14
14
|
const [html, text] = await Promise.all([render(react), render(react, { plainText: true })])
|
|
15
15
|
return { html, text }
|
|
@@ -3,21 +3,19 @@ import type { Mailer, MailerAdapter, MailRecipient, RenderedMessage } from './co
|
|
|
3
3
|
import { type EmailRenderer, renderEmail } from './core/render'
|
|
4
4
|
|
|
5
5
|
export interface CreateMailerOptions {
|
|
6
|
-
/**
|
|
6
|
+
/** Provider implementation (Resend, Brevo, …). */
|
|
7
7
|
adapter: MailerAdapter
|
|
8
|
-
/** Default sender
|
|
8
|
+
/** Default sender when a message omits `from`. */
|
|
9
9
|
from: MailRecipient
|
|
10
|
-
/** Override the
|
|
10
|
+
/** Override the renderer (mostly for tests). */
|
|
11
11
|
render?: EmailRenderer
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Build a {@link Mailer}.
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* address, applies the default sender, then hands a {@link RenderedMessage} to
|
|
20
|
-
* the adapter.
|
|
15
|
+
* Build a {@link Mailer}. Composition root: pick the adapter here; the rest of
|
|
16
|
+
* the app depends only on the `Mailer` port. Renders the React body to
|
|
17
|
+
* HTML + text, normalizes addresses, applies the default sender, then hands a
|
|
18
|
+
* {@link RenderedMessage} to the adapter.
|
|
21
19
|
*/
|
|
22
20
|
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
21
|
const defaultFrom = normalizeAddress(options.from)
|
|
@@ -11,7 +11,7 @@ function required(value: string | undefined, name: string): string {
|
|
|
11
11
|
return value
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Lazy
|
|
14
|
+
// Lazy: app boots without RESEND_API_KEY; adapter built on first send.
|
|
15
15
|
let mailer: Mailer | null = null
|
|
16
16
|
function getMailer(): Mailer {
|
|
17
17
|
if (!mailer) {
|
|
@@ -13,10 +13,10 @@ import { createQueryClient } from './query-client'
|
|
|
13
13
|
let clientQueryClientSingleton: QueryClient | undefined
|
|
14
14
|
const getQueryClient = () => {
|
|
15
15
|
if (typeof window === 'undefined') {
|
|
16
|
-
// Server: always
|
|
16
|
+
// Server: always new client.
|
|
17
17
|
return createQueryClient()
|
|
18
18
|
}
|
|
19
|
-
// Browser: reuse
|
|
19
|
+
// Browser: reuse across renders.
|
|
20
20
|
clientQueryClientSingleton ??= createQueryClient()
|
|
21
21
|
return clientQueryClientSingleton
|
|
22
22
|
}
|
|
@@ -7,7 +7,7 @@ import { type AppRouter, createCaller } from '~/server/api/root'
|
|
|
7
7
|
import { createTRPCContext } from '~/server/api/trpc'
|
|
8
8
|
import { createQueryClient } from './query-client'
|
|
9
9
|
|
|
10
|
-
/** Context for tRPC calls
|
|
10
|
+
/** Context for tRPC calls from RSCs. */
|
|
11
11
|
const createContext = cache(async () => {
|
|
12
12
|
const heads = new Headers(await headers())
|
|
13
13
|
heads.set('x-trpc-source', 'rsc')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsr generate && tsc --noEmit
|