@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
|
@@ -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
|
}
|
|
@@ -5,9 +5,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
5
5
|
export interface EmailLayoutProps {
|
|
6
6
|
preview: string
|
|
7
7
|
children: React.ReactNode
|
|
8
|
-
/** Per-email theme override, merged onto
|
|
8
|
+
/** Per-email theme override, merged onto default. */
|
|
9
9
|
theme?: EmailTheme | EmailThemeOverride
|
|
10
|
-
/** Footer year
|
|
10
|
+
/** Footer year; pass explicitly for deterministic output. */
|
|
11
11
|
year?: number
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -12,7 +12,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
12
12
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
16
16
|
export function useEmailTheme(): EmailTheme {
|
|
17
17
|
return useContext(EmailThemeContext)
|
|
18
18
|
}
|
|
@@ -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
|
+
* + `<EmailLayout theme={...}>`, or wrap a tree 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 over 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,15 +1,11 @@
|
|
|
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
|
-
/**
|
|
9
|
-
* Typed environment. Start minimal — patterns (drizzle, better-auth, …) and
|
|
10
|
-
* capabilities (add-capability) extend the `server` block and `runtimeEnv` with
|
|
11
|
-
* the keys they need.
|
|
12
|
-
*/
|
|
8
|
+
/** Typed env; foundations/capabilities extend `server` + `runtimeEnv` as needed. */
|
|
13
9
|
export const env = createEnv({
|
|
14
10
|
shared: {
|
|
15
11
|
NODE_ENV: v.optional(v.picklist(['development', 'test', 'production']), 'development'),
|
|
@@ -8,7 +8,7 @@ import TanStackQueryDevtools from '~/trpc/devtools'
|
|
|
8
8
|
|
|
9
9
|
import appCss from '../styles.css?url'
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// Pre-hydration: set theme class to avoid flash of wrong theme.
|
|
12
12
|
const themeScript = `(function(){try{var t=localStorage.getItem('theme')||'system';var m=window.matchMedia('(prefers-color-scheme:dark)').matches;document.documentElement.classList.toggle('dark',t==='dark'||(t==='system'&&m));}catch(e){}})();`
|
|
13
13
|
|
|
14
14
|
interface RouterContext {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
|
2
2
|
import { getServerSession } from '~/server/better-auth/session'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Layout group guarding every route under it: redirects to sign-in when there's
|
|
6
|
-
* no session, and exposes the session to child loaders via `beforeLoad`.
|
|
7
|
-
*/
|
|
4
|
+
/** Guards child routes: redirects to sign-in when no session; exposes session via `beforeLoad`. */
|
|
8
5
|
export const Route = createFileRoute('/_authed')({
|
|
9
6
|
beforeLoad: async () => {
|
|
10
7
|
const session = await getServerSession()
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Loads
|
|
2
|
-
// (so this server route typechecks even without other react-start imports).
|
|
1
|
+
// Loads TanStack Start augmentation adding `server` to route options (typechecks without other react-start imports).
|
|
3
2
|
|
|
4
3
|
import { createFileRoute } from '@tanstack/react-router'
|
|
5
4
|
import type {} from '@tanstack/react-start'
|
|
@@ -43,7 +43,7 @@ export const auth = betterAuth({
|
|
|
43
43
|
},
|
|
44
44
|
socialProviders,
|
|
45
45
|
user: {
|
|
46
|
-
//
|
|
46
|
+
// Add extra columns here (mirror in auth.schema.ts):
|
|
47
47
|
// additionalFields: {
|
|
48
48
|
// role: { type: 'string', defaultValue: 'user', input: false },
|
|
49
49
|
// },
|
|
@@ -3,14 +3,13 @@ import { getRequest } from '@tanstack/react-start/server'
|
|
|
3
3
|
import { auth } from '.'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Server-
|
|
7
|
-
*
|
|
6
|
+
* Server-fn resolving the better-auth session from request headers; use in
|
|
7
|
+
* loaders / `beforeLoad`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* imported transitively by routes) and trip the import-protection plugin.
|
|
9
|
+
* `getRequest` is called INSIDE the handler on purpose: the Start plugin
|
|
10
|
+
* extracts handlers server-side and strips the import from the client bundle.
|
|
11
|
+
* Calling it at module scope would leak `@tanstack/react-start/server` into
|
|
12
|
+
* client code (routes import this transitively) and trip import-protection.
|
|
14
13
|
*/
|
|
15
14
|
export const getServerSession = createServerFn({ method: 'GET' }).handler(() =>
|
|
16
15
|
auth.api.getSession({ headers: getRequest().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 your 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
|
-
/** Minimal structural view of the Resend client
|
|
7
|
+
/** Minimal structural view of the Resend client (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
|
-
/**
|
|
33
|
+
/** Custom/mock client; defaults to a 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 a recipient
|
|
5
|
+
/** Coerce a recipient to a structured {@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 a
|
|
13
|
+
/** Coerce a recipient or list to a 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 an address
|
|
18
|
+
/** Render an address to RFC-style `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 as authored by app code. Never raw HTML: the body is always a React
|
|
21
|
+
* Email 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
|
}
|
|
@@ -45,18 +41,12 @@ export interface SentMail {
|
|
|
45
41
|
id: string
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
/**
|
|
49
|
-
* The port the application depends on. Swapping providers means swapping the
|
|
50
|
-
* adapter passed to {@link createMailer}; this interface never changes.
|
|
51
|
-
*/
|
|
44
|
+
/** The port the app depends on; swap providers via {@link createMailer}'s adapter — this never changes. */
|
|
52
45
|
export interface Mailer {
|
|
53
46
|
send(message: MailMessage): Promise<SentMail>
|
|
54
47
|
}
|
|
55
48
|
|
|
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
|
-
*/
|
|
49
|
+
/** Shape an adapter receives: normalized addresses, body pre-rendered to `html` + `text`. Adapters don't render. */
|
|
60
50
|
export interface RenderedMessage {
|
|
61
51
|
to: MailAddress[]
|
|
62
52
|
from: MailAddress
|
|
@@ -71,13 +61,13 @@ export interface RenderedMessage {
|
|
|
71
61
|
attachments?: MailAttachment[]
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
64
|
+
/** Contract each provider implements; intentionally tiny. */
|
|
75
65
|
export interface MailerAdapter {
|
|
76
66
|
readonly name: string
|
|
77
67
|
send(message: RenderedMessage): Promise<SentMail>
|
|
78
68
|
}
|
|
79
69
|
|
|
80
|
-
/** Normalized error
|
|
70
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
81
71
|
export class MailerError extends Error {
|
|
82
72
|
readonly adapter: string
|
|
83
73
|
|
|
@@ -6,7 +6,7 @@ export interface RenderedBody {
|
|
|
6
6
|
text: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Turns a React Email component into HTML + plain text. */
|
|
10
10
|
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
11
|
|
|
12
12
|
/** Default renderer backed by `@react-email/render`. */
|
|
@@ -3,21 +3,20 @@ 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 impl (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
|
|
15
|
+
* Build a {@link Mailer}. Composition root: pick the adapter here; the rest of
|
|
16
|
+
* the app depends only on the `Mailer` port.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the adapter.
|
|
18
|
+
* Renders the React body to HTML + text, normalizes addresses, applies the
|
|
19
|
+
* default sender, then hands a {@link RenderedMessage} to the adapter.
|
|
21
20
|
*/
|
|
22
21
|
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
22
|
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 so the app boots without RESEND_API_KEY;
|
|
14
|
+
// Lazy so the 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) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfredmouelle/analytics",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsdown",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@alfredmouelle/http": "workspace:*",
|
|
17
|
+
"posthog-node": "^5.38.2",
|
|
18
|
+
"valibot": "^1.4.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.10.2",
|
|
22
|
+
"tsdown": "^0.22.3",
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vitest": "^4.1.9"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnalyticsPort } from '../../core/port.js'
|
|
2
|
+
|
|
3
|
+
/** No-op analytics adapter (dev/tests/disabled); call sites still depend on the port. */
|
|
4
|
+
export function noopAdapter(): AnalyticsPort {
|
|
5
|
+
return {
|
|
6
|
+
name: 'noop',
|
|
7
|
+
capture() {},
|
|
8
|
+
identify() {},
|
|
9
|
+
async flush() {},
|
|
10
|
+
async shutdown() {},
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const PlausibleConfigSchema = v.object({
|
|
4
|
+
/** Site domain as registered in Plausible (e.g. `acme.com`). */
|
|
5
|
+
domain: v.pipe(v.string(), v.minLength(1, 'Plausible domain is required')),
|
|
6
|
+
/** Plausible host. Defaults to `https://plausible.io`. */
|
|
7
|
+
apiHost: v.optional(v.string()),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export type PlausibleConfig = v.InferOutput<typeof PlausibleConfigSchema>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { apiFetch } from '@alfredmouelle/http'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import type { AnalyticsPort, CaptureEvent } from '../../core/port.js'
|
|
4
|
+
import { PlausibleConfigSchema } from './config.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_HOST = 'https://plausible.io'
|
|
7
|
+
const DEFAULT_USER_AGENT = '@alfredmouelle/analytics (+https://plausible.io)'
|
|
8
|
+
|
|
9
|
+
export interface PlausibleAdapterOptions {
|
|
10
|
+
/** Site domain as registered in Plausible (e.g. `acme.com`). */
|
|
11
|
+
domain: string
|
|
12
|
+
/** Plausible host. Defaults to `https://plausible.io`. */
|
|
13
|
+
apiHost?: string
|
|
14
|
+
/** Fallback page URL when an event has no `url`. */
|
|
15
|
+
defaultUrl?: string
|
|
16
|
+
/** User-Agent sent to Plausible; it derives the cookieless visitor id from UA + client IP, so use a realistic value. */
|
|
17
|
+
userAgent?: string
|
|
18
|
+
/** Inject a custom fetch (mock/scoped client). */
|
|
19
|
+
fetchImpl?: typeof globalThis.fetch
|
|
20
|
+
/** Called on fire-and-forget request failure; defaults to swallowing. */
|
|
21
|
+
onError?: (error: unknown) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PlausibleEventPayload {
|
|
25
|
+
name: string
|
|
26
|
+
domain: string
|
|
27
|
+
url: string
|
|
28
|
+
referrer?: string
|
|
29
|
+
props?: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Plausible adapter via server-side Events API (`POST /api/event`). `capture` is
|
|
34
|
+
* fire-and-forget; requests are tracked so `flush`/`shutdown` drain them.
|
|
35
|
+
* Cookieless, no person profiles, so `identify` is a no-op; `distinctId` is
|
|
36
|
+
* forwarded as a `distinct_id` prop for visibility only (counting uses UA + IP).
|
|
37
|
+
* `url`/`referrer`/`ip` read from event `properties`; `url` falls back to `defaultUrl`.
|
|
38
|
+
*/
|
|
39
|
+
export function plausibleAdapter(options: PlausibleAdapterOptions): AnalyticsPort {
|
|
40
|
+
const config = v.parse(PlausibleConfigSchema, {
|
|
41
|
+
domain: options.domain,
|
|
42
|
+
apiHost: options.apiHost,
|
|
43
|
+
})
|
|
44
|
+
const apiHost = config.apiHost ?? DEFAULT_API_HOST
|
|
45
|
+
const userAgent = options.userAgent ?? DEFAULT_USER_AGENT
|
|
46
|
+
const defaultUrl = options.defaultUrl ?? `https://${config.domain}/`
|
|
47
|
+
const onError = options.onError ?? (() => {})
|
|
48
|
+
const pending = new Set<Promise<void>>()
|
|
49
|
+
|
|
50
|
+
function send(payload: PlausibleEventPayload, ip?: string): void {
|
|
51
|
+
const headers: Record<string, string> = { 'User-Agent': userAgent }
|
|
52
|
+
if (ip) headers['X-Forwarded-For'] = ip
|
|
53
|
+
|
|
54
|
+
const request = apiFetch('/api/event', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
baseUrl: apiHost,
|
|
57
|
+
headers,
|
|
58
|
+
body: payload,
|
|
59
|
+
parseAs: 'none',
|
|
60
|
+
fetchImpl: options.fetchImpl,
|
|
61
|
+
})
|
|
62
|
+
.then(() => {})
|
|
63
|
+
.catch(onError)
|
|
64
|
+
|
|
65
|
+
pending.add(request)
|
|
66
|
+
void request.finally(() => pending.delete(request))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: 'plausible',
|
|
71
|
+
capture(event: CaptureEvent) {
|
|
72
|
+
const { url, referrer, ip, ...rest } = (event.properties ?? {}) as Record<string, unknown>
|
|
73
|
+
send(
|
|
74
|
+
{
|
|
75
|
+
name: event.event,
|
|
76
|
+
domain: config.domain,
|
|
77
|
+
url: typeof url === 'string' ? url : defaultUrl,
|
|
78
|
+
referrer: typeof referrer === 'string' ? referrer : undefined,
|
|
79
|
+
props: { distinct_id: event.distinctId, ...rest },
|
|
80
|
+
},
|
|
81
|
+
typeof ip === 'string' ? ip : undefined,
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
identify() {
|
|
85
|
+
// Cookieless, no person profiles — nothing to do.
|
|
86
|
+
},
|
|
87
|
+
async flush() {
|
|
88
|
+
await Promise.all([...pending])
|
|
89
|
+
},
|
|
90
|
+
async shutdown() {
|
|
91
|
+
await Promise.all([...pending])
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { PostHog } from 'posthog-node'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import type { AnalyticsPort, CaptureEvent, IdentifyParams } from '../../core/port.js'
|
|
4
|
+
import { PostHogConfigSchema } from './config.js'
|
|
5
|
+
|
|
6
|
+
/** Minimal structural view of the PostHog client (eases testing). */
|
|
7
|
+
export interface PostHogLike {
|
|
8
|
+
capture(payload: CaptureEvent): void
|
|
9
|
+
identify(payload: IdentifyParams): void
|
|
10
|
+
flush(): Promise<void>
|
|
11
|
+
shutdown(): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PostHogAdapterOptions {
|
|
15
|
+
apiKey: string
|
|
16
|
+
/** PostHog host (defaults to PostHog's default). */
|
|
17
|
+
host?: string
|
|
18
|
+
/** Inject a custom/mock client; defaults to a real `PostHog`. */
|
|
19
|
+
client?: PostHogLike
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function posthogAdapter(options: PostHogAdapterOptions): AnalyticsPort {
|
|
23
|
+
// Validate early: missing key fails at construction, not at capture().
|
|
24
|
+
const config = v.parse(PostHogConfigSchema, { apiKey: options.apiKey })
|
|
25
|
+
const client: PostHogLike =
|
|
26
|
+
options.client ?? (new PostHog(config.apiKey, { host: options.host }) as unknown as PostHogLike)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: 'posthog',
|
|
30
|
+
capture(event: CaptureEvent) {
|
|
31
|
+
client.capture({
|
|
32
|
+
distinctId: event.distinctId,
|
|
33
|
+
event: event.event,
|
|
34
|
+
properties: event.properties,
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
identify(params: IdentifyParams) {
|
|
38
|
+
client.identify({
|
|
39
|
+
distinctId: params.distinctId,
|
|
40
|
+
properties: params.properties,
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
flush() {
|
|
44
|
+
return client.flush()
|
|
45
|
+
},
|
|
46
|
+
shutdown() {
|
|
47
|
+
return client.shutdown()
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Analytics event to capture. Fire-and-forget. */
|
|
2
|
+
export interface CaptureEvent {
|
|
3
|
+
/** Event name, e.g. `'user_signed_up'`. */
|
|
4
|
+
event: string
|
|
5
|
+
/** Stable user/actor id. */
|
|
6
|
+
distinctId: string
|
|
7
|
+
/** Event metadata. */
|
|
8
|
+
properties?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Set/update properties on a user/actor. Fire-and-forget. */
|
|
12
|
+
export interface IdentifyParams {
|
|
13
|
+
/** Stable user/actor id. */
|
|
14
|
+
distinctId: string
|
|
15
|
+
/** Person properties to set. */
|
|
16
|
+
properties?: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* App-facing port; swap adapters at the composition root, never this interface.
|
|
21
|
+
* `capture`/`identify` are fire-and-forget (like PostHog SDK): enqueue and return.
|
|
22
|
+
* `flush` drains pending events; `shutdown` flushes + releases before exit.
|
|
23
|
+
*/
|
|
24
|
+
export interface AnalyticsPort {
|
|
25
|
+
readonly name: string
|
|
26
|
+
capture(event: CaptureEvent): void
|
|
27
|
+
identify(params: IdentifyParams): void
|
|
28
|
+
flush(): Promise<void>
|
|
29
|
+
shutdown(): Promise<void>
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { noopAdapter } from './adapters/noop/index.js'
|
|
2
|
+
export { type PlausibleConfig, PlausibleConfigSchema } from './adapters/plausible/config.js'
|
|
3
|
+
export {
|
|
4
|
+
type PlausibleAdapterOptions,
|
|
5
|
+
plausibleAdapter,
|
|
6
|
+
} from './adapters/plausible/index.js'
|
|
7
|
+
export { type PostHogConfig, PostHogConfigSchema } from './adapters/posthog/config.js'
|
|
8
|
+
export {
|
|
9
|
+
type PostHogAdapterOptions,
|
|
10
|
+
type PostHogLike,
|
|
11
|
+
posthogAdapter,
|
|
12
|
+
} from './adapters/posthog/index.js'
|
|
13
|
+
export type {
|
|
14
|
+
AnalyticsPort,
|
|
15
|
+
CaptureEvent,
|
|
16
|
+
IdentifyParams,
|
|
17
|
+
} from './core/port.js'
|