bunderstack 0.7.0 → 0.8.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 CHANGED
@@ -6,15 +6,20 @@ and get CRUD APIs, auth, file storage, realtime, typed custom endpoints
6
6
  single `Request → Response` handler.
7
7
 
8
8
  ```sh
9
- bun add bunderstack
9
+ bun add bunderstack better-auth drizzle-orm hono zod @libsql/client
10
10
  ```
11
11
 
12
12
  ```ts
13
13
  import { createBunderstack } from 'bunderstack'
14
+ import { libsql } from 'bunderstack/database/libsql'
14
15
  import * as schema from './schema'
15
16
 
16
17
  const app = await createBunderstack({
17
18
  schema,
19
+ database: {
20
+ adapter: libsql(),
21
+ url: 'file:./data.db',
22
+ },
18
23
  auth: { emailAndPassword: { enabled: true } },
19
24
  access: {
20
25
  posts: { ownerColumn: 'userId', list: 'public', create: 'authenticated' },
@@ -34,15 +39,15 @@ through env vars alone — no code changes required.
34
39
 
35
40
  ### Overrides (beat code-level config)
36
41
 
37
- | Var | Effect |
38
- | --- | --- |
39
- | `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
40
- | `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
41
- | `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
42
- | `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
43
- | `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
44
- | `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
45
- | `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
42
+ | Var | Effect |
43
+ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
44
+ | `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
45
+ | `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
46
+ | `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
47
+ | `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
48
+ | `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
49
+ | `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
50
+ | `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
46
51
 
47
52
  Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
48
53
  code-level config wins over.
@@ -50,8 +55,9 @@ code-level config wins over.
50
55
  ### Introspection
51
56
 
52
57
  Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
53
- guaranteed offline (in-memory database, no Redis) and missing user env vars
54
- don't throw. Then read `app.manifest`:
58
+ guaranteed offline (the selected adapter returns its `drizzle.mock({ schema })`
59
+ database, no Redis) and missing user env vars don't throw. Then read
60
+ `app.manifest`:
55
61
 
56
62
  ```ts
57
63
  process.env.BUNDERSTACK_INTROSPECT = '1'
@@ -60,6 +66,11 @@ console.log(JSON.stringify(app.manifest))
60
66
  // { version: 2, dialect, tables, tableMap, systemTables, background, ... }
61
67
  ```
62
68
 
69
+ Real database clients belong to the app. Call `await app.close()` when a
70
+ standalone process or test is finished; it closes the real libSQL, PGlite,
71
+ postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
72
+ mocks own no client, so there is nothing to close for that database path.
73
+
63
74
  ### Background runtime
64
75
 
65
76
  Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
5
5
  "keywords": [
6
6
  "backend",
@@ -33,6 +33,10 @@
33
33
  "exports": {
34
34
  ".": "./src/index.ts",
35
35
  "./access": "./src/access.ts",
36
+ "./database/bun-sql": "./src/database/bun-sql.ts",
37
+ "./database/libsql": "./src/database/libsql.ts",
38
+ "./database/pglite": "./src/database/pglite.ts",
39
+ "./database/postgres-js": "./src/database/postgres-js.ts",
36
40
  "./provision": "./src/provision.ts",
37
41
  "./schema": "./src/schema-export.ts",
38
42
  "./schema/pg": "./src/schema-export-pg.ts",
@@ -40,7 +44,8 @@
40
44
  "./typeid/pg": "./src/typeid-pg.ts",
41
45
  "./env": "./src/env.ts",
42
46
  "./trpc": "./src/trpc.ts",
43
- "./cron": "./src/cron.ts"
47
+ "./cron": "./src/cron.ts",
48
+ "./email/smtp": "./src/email/smtp.ts"
44
49
  },
45
50
  "scripts": {
46
51
  "test": "bun test",
@@ -49,26 +54,31 @@
49
54
  "db:migrate": "drizzle-kit migrate"
50
55
  },
51
56
  "dependencies": {
52
- "@trpc/server": "^11.0.0",
53
- "better-auth": "^1.0.0",
54
- "hono": "^4.0.0",
55
- "superjson": "^2.2.0",
56
- "zod": "^4.4.3"
57
+ "superjson": "^2.2.0"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@electric-sql/pglite": ">=0.3.0",
60
61
  "@libsql/client": ">=0.14.0",
62
+ "@trpc/server": "^11.0.0",
63
+ "@types/nodemailer": "^6",
64
+ "better-auth": "^1.0.0",
61
65
  "drizzle-kit": "^0.30.0",
62
- "drizzle-orm": "^0.45.0"
66
+ "drizzle-orm": "^0.45.0",
67
+ "hono": "^4.0.0",
68
+ "zod": "^4.4.3"
63
69
  },
64
70
  "peerDependencies": {
65
71
  "@electric-sql/pglite": ">=0.3.0",
66
72
  "@libsql/client": ">=0.14.0",
73
+ "@trpc/server": "^11.0.0",
74
+ "better-auth": "^1.0.0",
67
75
  "drizzle-kit": "^0.30.0",
68
76
  "drizzle-orm": "^0.45.0",
69
- "nodemailer": "^6",
77
+ "hono": "^4.0.0",
78
+ "nodemailer": ">=6 <10",
70
79
  "postgres": ">=3.4.0",
71
- "typescript": "^5"
80
+ "typescript": ">=5",
81
+ "zod": "^4.4.3"
72
82
  },
73
83
  "peerDependenciesMeta": {
74
84
  "@electric-sql/pglite": {
@@ -85,6 +95,9 @@
85
95
  },
86
96
  "postgres": {
87
97
  "optional": true
98
+ },
99
+ "typescript": {
100
+ "optional": true
88
101
  }
89
102
  }
90
103
  }
package/src/config.ts CHANGED
@@ -3,12 +3,12 @@ import { betterAuth } from 'better-auth'
3
3
  import { z } from 'zod'
4
4
 
5
5
  import type { TableAccessInput } from './access'
6
+ import type { DatabaseAdapter } from './database/adapter'
7
+ import type { EmailConfigInput } from './email'
6
8
  import type { IdempotencyConfig } from './idempotency'
7
9
  import type { RateLimitConfig } from './rate-limit'
8
10
 
9
11
  import { validateEnv, type BaseEnv, type EnvConfigInput } from './env'
10
- import type { EmailConfigInput } from './email'
11
-
12
12
  import {
13
13
  resolveBuckets,
14
14
  type ResolvedStorageBuckets,
@@ -22,9 +22,10 @@ export type BetterAuthConfig = Omit<
22
22
 
23
23
  export const BunderstackOptionsSchema = z.object({
24
24
  schema: z.record(z.string(), z.unknown()),
25
- access: z.record(z.string(), z.any()).optional(),
25
+ access: z.record(z.string(), z.unknown()).optional(),
26
26
  database: z
27
27
  .object({
28
+ adapter: z.unknown(),
28
29
  url: z.string().optional(),
29
30
  authToken: z.string().optional(),
30
31
  migrations: z.string().optional(),
@@ -82,10 +83,24 @@ export type BunderstackConfig<
82
83
  TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
83
84
  > = Omit<
84
85
  z.input<typeof BunderstackOptionsSchema>,
85
- 'schema' | 'access' | 'auth' | 'storage' | 'env' | 'email' | 'trpc' | 'jobs'
86
+ | 'schema'
87
+ | 'access'
88
+ | 'auth'
89
+ | 'storage'
90
+ | 'env'
91
+ | 'email'
92
+ | 'trpc'
93
+ | 'jobs'
94
+ | 'database'
86
95
  > & {
87
96
  schema: TSchema
88
97
  access?: TAccess
98
+ database: {
99
+ adapter: DatabaseAdapter
100
+ url?: string
101
+ authToken?: string
102
+ migrations?: string
103
+ }
89
104
  auth?: BetterAuthConfig
90
105
  storage?: TStorage
91
106
  env?: TEnv
@@ -106,7 +121,12 @@ export type BunderstackConfig<
106
121
  }
107
122
 
108
123
  export type ResolvedConfig = {
109
- database: { url: string; authToken?: string; migrations: string }
124
+ database: {
125
+ adapter: DatabaseAdapter
126
+ url: string
127
+ authToken?: string
128
+ migrations: string
129
+ }
110
130
  auth: BetterAuthConfig
111
131
  storage: ResolvedStorageBuckets
112
132
  realtime?:
@@ -134,12 +154,22 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
134
154
  const resolvedEnv =
135
155
  env ?? validateEnv(options.env as EnvConfigInput | undefined)
136
156
 
157
+ const adapter = options.database?.adapter
158
+ if (!adapter) {
159
+ throw new Error('[bunderstack] database.adapter is required')
160
+ }
161
+
162
+ const defaultUrl =
163
+ adapter.dialect === 'sqlite' ? 'file:./data.db' : 'file:./data.pglite'
164
+
137
165
  return {
138
166
  database: {
167
+ adapter,
139
168
  url:
140
169
  platformSource['BUNDERSTACK_DATABASE_URL'] ??
141
170
  parsed.database?.url ??
142
- resolvedEnv.DATABASE_URL,
171
+ resolvedEnv.DATABASE_URL ??
172
+ defaultUrl,
143
173
  authToken:
144
174
  platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
145
175
  parsed.database?.authToken ??
@@ -0,0 +1,26 @@
1
+ import type { DbFor, Driver } from '../db'
2
+ import type { AnyDb, Dialect } from '../dialect'
3
+
4
+ export type DatabaseConnection = {
5
+ url: string
6
+ authToken?: string
7
+ }
8
+
9
+ export type DatabaseConnectOptions = { introspect: boolean }
10
+
11
+ export type DatabaseConnectionResult<TSchema extends Record<string, unknown>> =
12
+ {
13
+ db: DbFor<TSchema>
14
+ close?: () => void | Promise<void>
15
+ }
16
+
17
+ export type DatabaseAdapter = {
18
+ readonly dialect: Dialect
19
+ readonly driver: Driver
20
+ connect<TSchema extends Record<string, unknown>>(
21
+ schema: TSchema,
22
+ connection: DatabaseConnection,
23
+ options: DatabaseConnectOptions,
24
+ ): Promise<DatabaseConnectionResult<TSchema>>
25
+ migrate(db: AnyDb, migrationsFolder: string): Promise<void>
26
+ }
@@ -0,0 +1,23 @@
1
+ import { drizzle } from 'drizzle-orm/bun-sql'
2
+ import { migrate } from 'drizzle-orm/bun-sql/migrator'
3
+
4
+ import type { DatabaseAdapter } from './adapter'
5
+
6
+ export function bunSql(): DatabaseAdapter {
7
+ return {
8
+ dialect: 'pg',
9
+ driver: 'bun-sql',
10
+ async connect(schema, { url }, { introspect }) {
11
+ if (introspect) return { db: drizzle.mock({ schema }) as never }
12
+
13
+ if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
14
+ throw new Error('[bunderstack] bunSql adapter requires a Postgres URL')
15
+ }
16
+ const db = drizzle(url, { schema })
17
+ return { db: db as never, close: () => db.$client.close() }
18
+ },
19
+ async migrate(db, migrationsFolder) {
20
+ await migrate(db as never, { migrationsFolder })
21
+ },
22
+ }
23
+ }
@@ -0,0 +1,28 @@
1
+ import { drizzle } from 'drizzle-orm/libsql'
2
+ import { migrate } from 'drizzle-orm/libsql/migrator'
3
+
4
+ import type { DatabaseAdapter } from './adapter'
5
+
6
+ export function libsql(): DatabaseAdapter {
7
+ return {
8
+ dialect: 'sqlite',
9
+ driver: 'libsql',
10
+ async connect(schema, connection, { introspect }) {
11
+ if (introspect) return { db: drizzle.mock({ schema }) as never }
12
+
13
+ if (
14
+ connection.url.startsWith('postgres://') ||
15
+ connection.url.startsWith('postgresql://')
16
+ ) {
17
+ throw new Error(
18
+ '[bunderstack] libsql adapter cannot connect to a Postgres URL',
19
+ )
20
+ }
21
+ const db = drizzle({ connection, schema })
22
+ return { db: db as never, close: () => db.$client.close() }
23
+ },
24
+ async migrate(db, migrationsFolder) {
25
+ await migrate(db as never, { migrationsFolder })
26
+ },
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ import { drizzle } from 'drizzle-orm/pglite'
2
+ import { migrate } from 'drizzle-orm/pglite/migrator'
3
+ import { mkdir } from 'node:fs/promises'
4
+
5
+ import type { DatabaseAdapter } from './adapter'
6
+
7
+ const rawPath = (url: string) =>
8
+ url.startsWith('file:') ? url.slice('file:'.length) : url
9
+
10
+ export function pglite(): DatabaseAdapter {
11
+ return {
12
+ dialect: 'pg',
13
+ driver: 'pglite',
14
+ async connect(schema, { url }, { introspect }) {
15
+ if (introspect) return { db: drizzle.mock({ schema }) as never }
16
+
17
+ if (url.startsWith('postgres://') || url.startsWith('postgresql://')) {
18
+ throw new Error(
19
+ '[bunderstack] pglite adapter cannot connect to a Postgres URL',
20
+ )
21
+ }
22
+ const raw = rawPath(url)
23
+ const dataDir = raw === ':memory:' ? 'memory://' : raw
24
+ if (!dataDir.startsWith('memory://'))
25
+ await mkdir(dataDir, { recursive: true })
26
+ const db = drizzle(dataDir, { schema })
27
+ return { db: db as never, close: () => db.$client.close() }
28
+ },
29
+ async migrate(db, migrationsFolder) {
30
+ await migrate(db as never, { migrationsFolder })
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,33 @@
1
+ import { drizzle } from 'drizzle-orm/postgres-js'
2
+ import { migrate } from 'drizzle-orm/postgres-js/migrator'
3
+
4
+ import type {
5
+ DatabaseAdapter,
6
+ DatabaseConnection,
7
+ DatabaseConnectOptions,
8
+ } from './adapter'
9
+
10
+ export function postgresJs(): DatabaseAdapter {
11
+ return {
12
+ dialect: 'pg',
13
+ driver: 'postgres-js',
14
+ async connect<TSchema extends Record<string, unknown>>(
15
+ schema: TSchema,
16
+ { url }: DatabaseConnection,
17
+ { introspect }: DatabaseConnectOptions,
18
+ ) {
19
+ if (introspect) return { db: drizzle.mock({ schema }) as never }
20
+
21
+ if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
22
+ throw new Error(
23
+ '[bunderstack] postgresJs adapter requires a Postgres URL',
24
+ )
25
+ }
26
+ const db = drizzle<TSchema>({ connection: url, schema })
27
+ return { db: db as never, close: () => db.$client.end() }
28
+ },
29
+ async migrate(db, migrationsFolder) {
30
+ await migrate(db as never, { migrationsFolder })
31
+ },
32
+ }
33
+ }
package/src/db.ts CHANGED
@@ -1,11 +1,11 @@
1
- // src/db.ts — dialect/driver dispatch. Every driver module loads via dynamic
2
- // import so the driver packages stay optional peers; the ignore comments keep
3
- // bundlers (vite/nitro, webpack) from resolving them at build time.
4
1
  import type { LibSQLDatabase } from 'drizzle-orm/libsql'
5
2
  import type { PgDatabase, PgQueryResultHKT, PgTable } from 'drizzle-orm/pg-core'
6
3
 
7
- import { mkdir } from 'node:fs/promises'
8
-
4
+ import type {
5
+ DatabaseAdapter,
6
+ DatabaseConnection,
7
+ DatabaseConnectionResult,
8
+ } from './database/adapter'
9
9
  import type { Dialect } from './dialect'
10
10
 
11
11
  export type Driver = 'libsql' | 'pglite' | 'bun-sql' | 'postgres-js'
@@ -20,84 +20,42 @@ export type DbFor<TSchema extends Record<string, unknown>> = [
20
20
  const PG_SERVER_RE = /^postgres(ql)?:\/\//
21
21
  const LIBSQL_REMOTE_RE = /^(libsql|wss?|https?):\/\//
22
22
 
23
- /** Exported for testing the missing-package error wrapping in isolation. */
24
- export async function importDriver<T>(specifier: string, hint: string): Promise<T> {
25
- try {
26
- return (await import(
27
- /* @vite-ignore */ /* webpackIgnore: true */ specifier
28
- )) as T
29
- } catch (cause) {
30
- throw new Error(`[bunderstack] ${hint}`, { cause })
31
- }
32
- }
33
-
34
- export async function createDb<TSchema extends Record<string, unknown>>(
35
- schema: TSchema,
36
- cfg: { url: string; authToken?: string; dialect: Dialect },
37
- ): Promise<{ db: DbFor<TSchema>; driver: Driver }> {
38
- if (cfg.dialect === 'sqlite') {
39
- if (PG_SERVER_RE.test(cfg.url)) {
23
+ export function validateDatabaseUrl(url: string, dialect: Dialect) {
24
+ if (dialect === 'sqlite') {
25
+ if (PG_SERVER_RE.test(url)) {
40
26
  throw new Error(
41
27
  '[bunderstack] DATABASE_URL is a Postgres URL but the schema uses sqliteTable. ' +
42
28
  'Define the schema with drizzle-orm/pg-core, or point DATABASE_URL at a SQLite database.',
43
29
  )
44
30
  }
45
- const { drizzle } = await importDriver<typeof import('drizzle-orm/libsql')>(
46
- 'drizzle-orm/libsql',
47
- 'SQLite support requires @libsql/client, which is not installed.\n' +
48
- ' Run `bun add @libsql/client`.',
49
- )
50
- const db = drizzle({
51
- connection: { url: cfg.url, authToken: cfg.authToken },
52
- schema,
53
- })
54
- return { db: db as unknown as DbFor<TSchema>, driver: 'libsql' }
55
- }
56
-
57
- if (LIBSQL_REMOTE_RE.test(cfg.url)) {
58
- throw new Error(
59
- '[bunderstack] DATABASE_URL looks like a libsql/Turso URL but the schema uses pgTable. ' +
60
- 'Set DATABASE_URL=postgres://… (or leave it unset for local PGlite).',
61
- )
62
- }
63
-
64
- if (PG_SERVER_RE.test(cfg.url)) {
65
- if (typeof Bun !== 'undefined') {
66
- const { drizzle } = await import(
67
- /* @vite-ignore */ /* webpackIgnore: true */ 'drizzle-orm/bun-sql'
31
+ } else if (dialect === 'pg') {
32
+ if (LIBSQL_REMOTE_RE.test(url)) {
33
+ throw new Error(
34
+ '[bunderstack] DATABASE_URL looks like a libsql/Turso URL but the schema uses pgTable. ' +
35
+ 'Set DATABASE_URL=postgres://… (or leave it unset for local PGlite).',
68
36
  )
69
- return {
70
- db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
71
- driver: 'bun-sql',
72
- }
73
- }
74
- const { drizzle } = await importDriver<
75
- typeof import('drizzle-orm/postgres-js')
76
- >(
77
- 'drizzle-orm/postgres-js',
78
- 'Postgres on Node requires the `postgres` driver, which is not installed.\n' +
79
- ' Run `npm install postgres`. (Under Bun the built-in Bun.sql is used instead.)',
80
- )
81
- return {
82
- db: drizzle(cfg.url, { schema }) as unknown as DbFor<TSchema>,
83
- driver: 'postgres-js',
84
37
  }
85
38
  }
39
+ }
86
40
 
87
- // Local PGlite: `file:<dir>`, a bare path, `:memory:`, or `memory://`.
88
- const raw = cfg.url.startsWith('file:') ? cfg.url.slice('file:'.length) : cfg.url
89
- const dataDir = raw === ':memory:' ? 'memory://' : raw
90
- if (!dataDir.startsWith('memory://')) {
91
- await mkdir(dataDir, { recursive: true })
41
+ export async function createDb<TSchema extends Record<string, unknown>>(
42
+ schema: TSchema,
43
+ cfg: DatabaseConnection & {
44
+ adapter: DatabaseAdapter
45
+ dialect: Dialect
46
+ introspect?: boolean
47
+ },
48
+ ): Promise<DatabaseConnectionResult<TSchema> & { driver: Driver }> {
49
+ if (cfg.adapter.dialect !== cfg.dialect) {
50
+ throw new Error(
51
+ `[bunderstack] database adapter dialect ${cfg.adapter.dialect} does not match ${cfg.dialect} schema`,
52
+ )
92
53
  }
93
- const { drizzle } = await importDriver<typeof import('drizzle-orm/pglite')>(
94
- 'drizzle-orm/pglite',
95
- 'Local Postgres development requires PGlite, which is not installed.\n' +
96
- ' Run `bun add -d @electric-sql/pglite` — bunderstack runs an embedded Postgres in ./data.pglite.\n' +
97
- ' In production set DATABASE_URL=postgres://… (PGlite is not needed there).',
54
+ if (!cfg.introspect) validateDatabaseUrl(cfg.url, cfg.dialect)
55
+ const result = await cfg.adapter.connect(
56
+ schema,
57
+ { url: cfg.url, authToken: cfg.authToken },
58
+ { introspect: cfg.introspect ?? false },
98
59
  )
99
- return {
100
- db: drizzle(dataDir, { schema }) as unknown as DbFor<TSchema>,
101
- driver: 'pglite',
102
- }
60
+ return { ...result, driver: cfg.adapter.driver }
103
61
  }
@@ -0,0 +1,45 @@
1
+ import nodemailer from 'nodemailer'
2
+
3
+ import type { EmailAdapter, EmailMessage } from '../email'
4
+
5
+ type SmtpTransport = {
6
+ sendMail(message: Record<string, unknown>): Promise<{ messageId?: string }>
7
+ }
8
+
9
+ export function createSmtpAdapter(
10
+ options: { url: string },
11
+ createTransport: (url: string) => SmtpTransport = (url) =>
12
+ nodemailer.createTransport(url) as SmtpTransport,
13
+ ): EmailAdapter {
14
+ const toArray = (v: string | string[] | undefined) =>
15
+ v === undefined ? undefined : Array.isArray(v) ? v : [v]
16
+
17
+ let transportPromise: Promise<{
18
+ sendMail(opts: Record<string, unknown>): Promise<{ messageId?: string }>
19
+ }> | null = null
20
+
21
+ const getTransport = () => {
22
+ transportPromise ??= Promise.resolve(createTransport(options.url))
23
+ return transportPromise
24
+ }
25
+
26
+ return {
27
+ async send(msg) {
28
+ const transport = await getTransport()
29
+ const info = await transport.sendMail({
30
+ from: msg.from,
31
+ to: toArray(msg.to)!.join(', '),
32
+ subject: msg.subject,
33
+ html: msg.html,
34
+ text: msg.text,
35
+ replyTo: msg.replyTo,
36
+ cc: toArray(msg.cc)?.join(', '),
37
+ bcc: toArray(msg.bcc)?.join(', '),
38
+ })
39
+ return { id: info.messageId }
40
+ },
41
+ }
42
+ }
43
+
44
+ export const smtp = (options: { url: string }): EmailAdapter =>
45
+ createSmtpAdapter(options)
package/src/email.ts CHANGED
@@ -1,4 +1,5 @@
1
- // src/email.ts — email sending: resend / smtp / console / custom adapter.
1
+ // src/email.ts — email sending: console / resend / custom adapters.
2
+ // SMTP is provided by the static `bunderstack/email/smtp` factory subpath.
2
3
 
3
4
  export type EmailMessage = {
4
5
  to: string | string[]
@@ -20,7 +21,7 @@ export type EmailAdapter = {
20
21
 
21
22
  export type EmailConfigInput = {
22
23
  from: string
23
- provider?: 'resend' | 'smtp' | 'console' | EmailAdapter | EmailAdapter['send']
24
+ provider?: 'resend' | 'console' | EmailAdapter | EmailAdapter['send']
24
25
  }
25
26
 
26
27
  export type EmailFacade = {
@@ -31,11 +32,9 @@ export type CreateEmailOptions = {
31
32
  env: { RESEND_API_KEY?: string; SMTP_URL?: string; NODE_ENV?: string }
32
33
  /** Test seam for the resend adapter. */
33
34
  fetchFn?: typeof fetch
34
- /** Test seam for the nodemailer presence check. */
35
- canResolveModule?: (specifier: string) => boolean
36
35
  }
37
36
 
38
- /** String provider tag ('resend' | 'smtp' | 'console') or undefined. */
37
+ /** Root string provider tag ('resend' | 'console') or undefined. */
39
38
  export function emailProviderTag(
40
39
  config: EmailConfigInput | undefined,
41
40
  ): string | undefined {
@@ -98,54 +97,6 @@ function createResendAdapter(
98
97
  }
99
98
  }
100
99
 
101
- function createSmtpAdapter(
102
- smtpUrl: string,
103
- canResolve: (specifier: string) => boolean,
104
- ): EmailAdapter {
105
- if (!canResolve('nodemailer')) {
106
- throw new Error(
107
- "email provider 'smtp' requires nodemailer — install it with: bun add nodemailer",
108
- )
109
- }
110
- // Lazy import so boot stays sync; cached across sends. Variable specifier
111
- // keeps TS from resolving the optional peer's types at compile time.
112
- const specifier = 'nodemailer'
113
- let transportPromise: Promise<{
114
- sendMail(opts: Record<string, unknown>): Promise<{ messageId?: string }>
115
- }> | null = null
116
- const getTransport = () => {
117
- transportPromise ??= import(/* @vite-ignore */ specifier).then((mod) =>
118
- (mod.default ?? mod).createTransport(smtpUrl),
119
- )
120
- return transportPromise
121
- }
122
- return {
123
- async send(msg) {
124
- const transport = await getTransport()
125
- const info = await transport.sendMail({
126
- from: msg.from,
127
- to: toArray(msg.to)!.join(', '),
128
- subject: msg.subject,
129
- html: msg.html,
130
- text: msg.text,
131
- replyTo: msg.replyTo,
132
- cc: toArray(msg.cc)?.join(', '),
133
- bcc: toArray(msg.bcc)?.join(', '),
134
- })
135
- return { id: info.messageId }
136
- },
137
- }
138
- }
139
-
140
- function defaultCanResolve(specifier: string): boolean {
141
- try {
142
- Bun.resolveSync(specifier, import.meta.dir)
143
- return true
144
- } catch {
145
- return false
146
- }
147
- }
148
-
149
100
  function resolveAdapter(
150
101
  config: EmailConfigInput,
151
102
  opts: CreateEmailOptions,
@@ -157,18 +108,21 @@ function resolveAdapter(
157
108
  switch (provider) {
158
109
  case 'resend':
159
110
  return createResendAdapter(opts.env.RESEND_API_KEY ?? '', fetchFn)
160
- case 'smtp':
161
- return createSmtpAdapter(
162
- opts.env.SMTP_URL ?? '',
163
- opts.canResolveModule ?? defaultCanResolve,
164
- )
111
+
165
112
  case 'console':
166
113
  return createConsoleAdapter()
167
114
  case undefined:
168
115
  if (opts.env.NODE_ENV === 'production') {
169
116
  throw new Error(
170
117
  'email is configured without a provider — set email.provider ' +
171
- "('resend' | 'smtp' | a custom adapter) for production",
118
+ "('resend' | a custom adapter) for production",
119
+ )
120
+ }
121
+ return createConsoleAdapter()
122
+ default:
123
+ if (provider === 'smtp') {
124
+ throw new Error(
125
+ "email provider 'smtp' was removed. Import `smtp` from 'bunderstack/email/smtp' instead.",
172
126
  )
173
127
  }
174
128
  return createConsoleAdapter()