@alfredmouelle/create-stack 0.1.1 → 0.2.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/README.md +41 -18
- 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 +2 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +1 -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 +6 -7
- 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/analytics/package.json +26 -0
- package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
- package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
- package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
- package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
- package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
- package/_stack/packages/analytics/src/core/port.ts +30 -0
- package/_stack/packages/analytics/src/index.ts +17 -0
- package/_stack/packages/cache/package.json +25 -0
- package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
- package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
- package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
- package/_stack/packages/cache/src/core/port.ts +29 -0
- package/_stack/packages/cache/src/core/wrap.ts +20 -0
- package/_stack/packages/cache/src/index.ts +12 -0
- package/_stack/packages/error-tracking/package.json +25 -0
- package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
- package/_stack/packages/error-tracking/src/core/port.ts +39 -0
- package/_stack/packages/error-tracking/src/index.ts +14 -0
- package/_stack/packages/http/package.json +20 -0
- package/_stack/packages/http/src/api.ts +373 -0
- package/_stack/packages/http/src/index.ts +14 -0
- package/_stack/packages/http/src/responses.ts +25 -0
- package/_stack/packages/http/src/types.ts +9 -0
- package/_stack/packages/jobs/package.json +27 -0
- package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
- package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
- package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
- package/_stack/packages/jobs/src/core/port.ts +37 -0
- package/_stack/packages/jobs/src/index.ts +23 -0
- package/_stack/packages/logger/package.json +25 -0
- package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
- package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
- package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
- package/_stack/packages/logger/src/core/port.ts +21 -0
- package/_stack/packages/logger/src/index.ts +12 -0
- 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/_stack/packages/storage/package.json +27 -0
- package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
- package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
- package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
- package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
- package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
- package/_stack/packages/storage/src/core/port.ts +41 -0
- package/_stack/packages/storage/src/index.ts +21 -0
- package/index.mjs +89 -55
- package/lib/build.mjs +21 -11
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +26 -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 +12 -11
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/mailer/capability.json +0 -28
- 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
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
Interactive, **deterministic** project installer. It forks a fully-wired base app
|
|
6
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
|
|
8
|
-
mailer
|
|
9
|
-
|
|
7
|
+
foundations and provider you pick — Drizzle, tRPC, better-auth, data tables, a
|
|
8
|
+
mailer and optional capabilities (storage, cache, jobs, logger, analytics,
|
|
9
|
+
error-tracking) — then stamps identity, generates `.env`, initializes git and
|
|
10
|
+
verifies the result (typecheck + Biome).
|
|
10
11
|
|
|
11
12
|
No template guesswork: the output is a real, buildable app from day one.
|
|
12
13
|
|
|
@@ -46,13 +47,20 @@ not exist yet. In non-interactive mode it is required.
|
|
|
46
47
|
| `--framework` | `tanstack` \| `next` | `tanstack` | Base app to fork. |
|
|
47
48
|
| `--foundations` | csv of `drizzle,trpc,better-auth,data-table` | all | Foundations to keep; the rest are stripped. |
|
|
48
49
|
| `--mailer` | `resend` \| `brevo` \| `ses` \| `none` | `resend` | Mailer provider. `none` is rejected when `better-auth` is kept. |
|
|
49
|
-
| `--
|
|
50
|
+
| `--storage` | `s3` \| `r2` \| `gcs` \| `local` | `s3` | Object storage capability (omit to skip). |
|
|
51
|
+
| `--cache` | `redis` \| `memory` | `redis` | Key/value cache capability (omit to skip). |
|
|
52
|
+
| `--jobs` | `inngest` \| `trigger` \| `memory` | `inngest` | Background jobs capability (omit to skip). `inngest` also scaffolds the serve route. |
|
|
53
|
+
| `--logger` | `pino` \| `console` | `pino` | Structured logging capability (omit to skip). |
|
|
54
|
+
| `--analytics` | `posthog` \| `plausible` \| `noop` | `posthog` | Product analytics capability (omit to skip). |
|
|
55
|
+
| `--error-tracking` | `sentry` \| `console` | `sentry` | Error reporting capability (omit to skip). |
|
|
50
56
|
| `--no-install` | — | install on | Skip `pnpm install` + verification. |
|
|
51
57
|
| `--yes`, `-y` | — | — | Non-interactive with all defaults. |
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
Each capability flag is optional: pass it (bare for the default adapter, or with a
|
|
60
|
+
value) to vendor that capability; omit it to leave it out. Passing any selection
|
|
61
|
+
flag — `--framework`, `--foundations`, `--mailer`, any capability, or `--no-install`
|
|
62
|
+
(or `--yes`) — switches the CLI to non-interactive mode; missing values fall back to
|
|
63
|
+
the defaults above.
|
|
56
64
|
|
|
57
65
|
### Dependency resolution
|
|
58
66
|
|
|
@@ -77,9 +85,9 @@ pnpm dlx @alfredmouelle/create-stack api --framework next \
|
|
|
77
85
|
# Minimal: Drizzle only, no mailer
|
|
78
86
|
pnpm dlx @alfredmouelle/create-stack db-svc --foundations drizzle --mailer none
|
|
79
87
|
|
|
80
|
-
#
|
|
81
|
-
pnpm dlx @alfredmouelle/create-stack app
|
|
82
|
-
--
|
|
88
|
+
# With capabilities: R2 storage, Redis cache, Inngest jobs, Sentry errors
|
|
89
|
+
pnpm dlx @alfredmouelle/create-stack my-app \
|
|
90
|
+
--storage r2 --cache --jobs --error-tracking
|
|
83
91
|
```
|
|
84
92
|
|
|
85
93
|
## What you get
|
|
@@ -97,12 +105,27 @@ pnpm dlx @alfredmouelle/create-stack app --foundations drizzle,trpc,better-auth
|
|
|
97
105
|
Unselected foundations are removed cleanly (files, deps, env vars and wiring),
|
|
98
106
|
and the project is left **bootable and green** (typecheck + Biome).
|
|
99
107
|
|
|
100
|
-
##
|
|
108
|
+
## Capabilities
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
Beyond the **mailer** (always baked in, chosen via `--mailer`), the CLI can vendor
|
|
111
|
+
any of the swappable capabilities at scaffold time — pick them in the wizard or via
|
|
112
|
+
flags. Each is copied behind a port (into `src/server/<capability>/`) with a generated
|
|
113
|
+
composition root that reads typed env and constructs the adapter lazily, so the app
|
|
114
|
+
boots even before you fill in the keys:
|
|
115
|
+
|
|
116
|
+
| Capability | Adapters | Notes |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `storage` | s3, r2, gcs, local | `getStorage()` accessor. |
|
|
119
|
+
| `cache` | redis, memory | `getCache()` accessor. |
|
|
120
|
+
| `jobs` | inngest, trigger, memory | `inngest` also scaffolds `serve.ts` + the framework route. |
|
|
121
|
+
| `logger` | pino, console | `getLogger()` accessor. |
|
|
122
|
+
| `analytics` | posthog, plausible, noop | `plausible` vendors `~/lib/http`. |
|
|
123
|
+
| `error-tracking` | sentry, console | `getErrorTracking()` accessor. |
|
|
124
|
+
|
|
125
|
+
Adapter deps and env keys are wired into `package.json` and `src/env.ts` automatically;
|
|
126
|
+
cross-package imports (`@alfredmouelle/http`) are vendored into `src/lib/http` and
|
|
127
|
+
rewritten. To add a capability to an **existing** project (or swap an adapter), use the
|
|
128
|
+
`add-capability` skill.
|
|
106
129
|
|
|
107
130
|
## After scaffolding
|
|
108
131
|
|
|
@@ -115,8 +138,8 @@ pnpm dev
|
|
|
115
138
|
|
|
116
139
|
## Notes
|
|
117
140
|
|
|
118
|
-
- The published package is **self-contained**: the base apps,
|
|
119
|
-
and
|
|
120
|
-
this package.
|
|
141
|
+
- The published package is **self-contained**: the base apps, the mailer adapters
|
|
142
|
+
and every capability package (`+ http`) are bundled at publish time, so `pnpm dlx`
|
|
143
|
+
needs nothing but this package.
|
|
121
144
|
- The generated project is a fresh git repo (`git init`, files staged) — make your
|
|
122
145
|
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
|
}
|
|
@@ -7,9 +7,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
7
7
|
export interface EmailLayoutProps {
|
|
8
8
|
preview: string
|
|
9
9
|
children: React.ReactNode
|
|
10
|
-
/**
|
|
10
|
+
/** Theme override, merged onto default. */
|
|
11
11
|
theme?: EmailTheme | EmailThemeOverride
|
|
12
|
-
/** Footer year. Pass explicitly
|
|
12
|
+
/** Footer year. Pass explicitly for deterministic output. */
|
|
13
13
|
year?: number
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -14,7 +14,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
14
14
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
18
18
|
export function useEmailTheme(): EmailTheme {
|
|
19
19
|
return useContext(EmailThemeContext)
|
|
20
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
|
|
@@ -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
|
+
}
|