bunderstack 0.1.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/src/email.ts ADDED
@@ -0,0 +1,200 @@
1
+ // src/email.ts — email sending: resend / smtp / console / custom adapter.
2
+
3
+ export type EmailMessage = {
4
+ to: string | string[]
5
+ subject: string
6
+ html?: string
7
+ text?: string
8
+ from?: string
9
+ replyTo?: string
10
+ cc?: string | string[]
11
+ bcc?: string | string[]
12
+ }
13
+
14
+ export type SentEmail = { id?: string }
15
+
16
+ /** Adapters receive the message with `from` already resolved. */
17
+ export type EmailAdapter = {
18
+ send(msg: EmailMessage & { from: string }): Promise<SentEmail>
19
+ }
20
+
21
+ export type EmailConfigInput = {
22
+ from: string
23
+ provider?: 'resend' | 'smtp' | 'console' | EmailAdapter | EmailAdapter['send']
24
+ }
25
+
26
+ export type EmailFacade = {
27
+ send(msg: EmailMessage): Promise<SentEmail>
28
+ }
29
+
30
+ export type CreateEmailOptions = {
31
+ env: { RESEND_API_KEY?: string; SMTP_URL?: string; NODE_ENV?: string }
32
+ /** Test seam for the resend adapter. */
33
+ fetchFn?: typeof fetch
34
+ /** Test seam for the nodemailer presence check. */
35
+ canResolveModule?: (specifier: string) => boolean
36
+ }
37
+
38
+ /** String provider tag ('resend' | 'smtp' | 'console') or undefined. */
39
+ export function emailProviderTag(
40
+ config: EmailConfigInput | undefined,
41
+ ): string | undefined {
42
+ return typeof config?.provider === 'string' ? config.provider : undefined
43
+ }
44
+
45
+ const toArray = (v: string | string[] | undefined) =>
46
+ v === undefined ? undefined : Array.isArray(v) ? v : [v]
47
+
48
+ function createConsoleAdapter(): EmailAdapter {
49
+ return {
50
+ async send(msg) {
51
+ const line = '─'.repeat(60)
52
+ console.log(
53
+ [
54
+ line,
55
+ '📧 email (console provider — not sent)',
56
+ `from: ${msg.from}`,
57
+ `to: ${toArray(msg.to)!.join(', ')}`,
58
+ `subject: ${msg.subject}`,
59
+ line,
60
+ msg.text ?? msg.html ?? '',
61
+ line,
62
+ ].join('\n'),
63
+ )
64
+ return {}
65
+ },
66
+ }
67
+ }
68
+
69
+ function createResendAdapter(
70
+ apiKey: string,
71
+ fetchFn: typeof fetch,
72
+ ): EmailAdapter {
73
+ return {
74
+ async send(msg) {
75
+ const res = await fetchFn('https://api.resend.com/emails', {
76
+ method: 'POST',
77
+ headers: {
78
+ Authorization: `Bearer ${apiKey}`,
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ body: JSON.stringify({
82
+ from: msg.from,
83
+ to: toArray(msg.to),
84
+ subject: msg.subject,
85
+ html: msg.html,
86
+ text: msg.text,
87
+ reply_to: msg.replyTo,
88
+ cc: toArray(msg.cc),
89
+ bcc: toArray(msg.bcc),
90
+ }),
91
+ })
92
+ if (!res.ok) {
93
+ throw new Error(`resend API error (${res.status}): ${await res.text()}`)
94
+ }
95
+ const data = (await res.json()) as { id?: string }
96
+ return { id: data.id }
97
+ },
98
+ }
99
+ }
100
+
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(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
+ function resolveAdapter(
150
+ config: EmailConfigInput,
151
+ opts: CreateEmailOptions,
152
+ ): EmailAdapter {
153
+ const provider = config.provider
154
+ if (typeof provider === 'function') return { send: provider }
155
+ if (typeof provider === 'object') return provider
156
+ const fetchFn = opts.fetchFn ?? globalThis.fetch.bind(globalThis)
157
+ switch (provider) {
158
+ case 'resend':
159
+ return createResendAdapter(opts.env.RESEND_API_KEY ?? '', fetchFn)
160
+ case 'smtp':
161
+ return createSmtpAdapter(
162
+ opts.env.SMTP_URL ?? '',
163
+ opts.canResolveModule ?? defaultCanResolve,
164
+ )
165
+ case 'console':
166
+ return createConsoleAdapter()
167
+ case undefined:
168
+ if (opts.env.NODE_ENV === 'production') {
169
+ throw new Error(
170
+ 'email is configured without a provider — set email.provider ' +
171
+ "('resend' | 'smtp' | a custom adapter) for production",
172
+ )
173
+ }
174
+ return createConsoleAdapter()
175
+ }
176
+ }
177
+
178
+ export function createEmail(
179
+ config: EmailConfigInput | undefined,
180
+ opts: CreateEmailOptions,
181
+ ): EmailFacade {
182
+ if (!config) {
183
+ return {
184
+ async send() {
185
+ throw new Error(
186
+ 'email is not configured — add an email key to your bunderstack config',
187
+ )
188
+ },
189
+ }
190
+ }
191
+ const adapter = resolveAdapter(config, opts)
192
+ return {
193
+ async send(msg) {
194
+ if (!msg.html && !msg.text) {
195
+ throw new Error('email message needs html or text content')
196
+ }
197
+ return adapter.send({ ...msg, from: msg.from ?? config.from })
198
+ },
199
+ }
200
+ }
package/src/env.ts ADDED
@@ -0,0 +1,153 @@
1
+ // src/env.ts — env validation. Browser-safe: imports zod only.
2
+ import { z, type ZodType } from 'zod'
3
+
4
+ export const CLIENT_PREFIX = 'PUBLIC_' as const
5
+
6
+ export type EnvConfigInput = {
7
+ server?: Record<string, ZodType>
8
+ client?: Record<string, ZodType>
9
+ /** Explicit value source for client vars (e.g. Vite's import.meta.env). */
10
+ runtimeEnv?: Record<string, unknown>
11
+ }
12
+
13
+ /** Vars bunderstack itself consumes, always validated. */
14
+ export type BaseEnv = {
15
+ NODE_ENV?: string
16
+ DATABASE_URL: string
17
+ DATABASE_AUTH_TOKEN?: string
18
+ AUTH_SECRET: string
19
+ REDIS_URL?: string
20
+ RESEND_API_KEY?: string
21
+ SMTP_URL?: string
22
+ }
23
+
24
+ type InferVars<T> = T extends Record<string, ZodType>
25
+ ? { [K in keyof T]: z.output<T[K]> }
26
+ : unknown
27
+
28
+ // Non-distributive so `ValidatedEnv<undefined>` is BaseEnv, not `never`.
29
+ export type ValidatedEnv<TEnv extends EnvConfigInput | undefined> = [
30
+ TEnv,
31
+ ] extends [EnvConfigInput]
32
+ ? BaseEnv &
33
+ InferVars<NonNullable<TEnv>['server']> &
34
+ InferVars<NonNullable<TEnv>['client']>
35
+ : BaseEnv
36
+
37
+ export class BunderstackEnvError extends Error {
38
+ readonly issues: string[]
39
+
40
+ constructor(issues: string[]) {
41
+ super(`Invalid environment:\n - ${issues.join('\n - ')}`)
42
+ this.name = 'BunderstackEnvError'
43
+ this.issues = issues
44
+ }
45
+ }
46
+
47
+ export type ValidateEnvOptions = {
48
+ /** String tag of the configured email provider ('resend' | 'smtp'), if any. */
49
+ emailProvider?: string
50
+ /** Value source; defaults to process.env. Tests pass this explicitly. */
51
+ source?: Record<string, string | undefined>
52
+ }
53
+
54
+ const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
55
+
56
+ function validateSection(
57
+ section: Record<string, ZodType> | undefined,
58
+ kind: 'server' | 'client',
59
+ source: Record<string, unknown>,
60
+ issues: string[],
61
+ out: Record<string, unknown>,
62
+ ) {
63
+ for (const [key, schema] of Object.entries(section ?? {})) {
64
+ const isPublic = key.startsWith(CLIENT_PREFIX)
65
+ if (kind === 'server' && isPublic) {
66
+ issues.push(
67
+ `${key}: server vars must not start with ${CLIENT_PREFIX} (move it to env.client)`,
68
+ )
69
+ continue
70
+ }
71
+ if (kind === 'client' && !isPublic) {
72
+ issues.push(
73
+ `${key}: client vars must start with ${CLIENT_PREFIX} (rename it or move it to env.server)`,
74
+ )
75
+ continue
76
+ }
77
+ const result = schema.safeParse(source[key])
78
+ if (result.success) {
79
+ out[key] = result.data
80
+ } else {
81
+ for (const issue of result.error.issues) {
82
+ issues.push(`${key}: ${issue.message}`)
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
89
+ envConfig: TEnv,
90
+ options: ValidateEnvOptions = {},
91
+ ): ValidatedEnv<TEnv> {
92
+ const source =
93
+ options.source ?? (process.env as Record<string, string | undefined>)
94
+ const issues: string[] = []
95
+ const isProduction = source.NODE_ENV === 'production'
96
+
97
+ const base: BaseEnv = {
98
+ NODE_ENV: source.NODE_ENV,
99
+ DATABASE_URL: source.DATABASE_URL ?? 'file:./data.db',
100
+ DATABASE_AUTH_TOKEN: source.DATABASE_AUTH_TOKEN,
101
+ AUTH_SECRET: source.AUTH_SECRET ?? DEV_AUTH_SECRET,
102
+ REDIS_URL: source.REDIS_URL,
103
+ RESEND_API_KEY: source.RESEND_API_KEY,
104
+ SMTP_URL: source.SMTP_URL,
105
+ }
106
+ if (isProduction && !source.AUTH_SECRET) {
107
+ issues.push('AUTH_SECRET: required in production')
108
+ }
109
+ if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
110
+ issues.push("RESEND_API_KEY: required when email provider is 'resend'")
111
+ }
112
+ if (options.emailProvider === 'smtp' && !source.SMTP_URL) {
113
+ issues.push("SMTP_URL: required when email provider is 'smtp'")
114
+ }
115
+
116
+ const userVars: Record<string, unknown> = {}
117
+ validateSection(envConfig?.server, 'server', source, issues, userVars)
118
+ validateSection(envConfig?.client, 'client', source, issues, userVars)
119
+
120
+ if (issues.length > 0) throw new BunderstackEnvError(issues)
121
+ return { ...base, ...userVars } as ValidatedEnv<TEnv>
122
+ }
123
+
124
+ /**
125
+ * Browser-side companion (t3-env style): validates ONLY the client section.
126
+ * Server keys exist on the returned object as traps that throw on access, so
127
+ * a leaked import fails loudly instead of silently reading undefined.
128
+ */
129
+ export function createClientEnv<TEnv extends EnvConfigInput>(
130
+ envConfig: TEnv,
131
+ ): InferVars<TEnv['client']> {
132
+ const source =
133
+ envConfig.runtimeEnv ??
134
+ (typeof process !== 'undefined'
135
+ ? (process.env as Record<string, unknown>)
136
+ : {})
137
+ const issues: string[] = []
138
+ const values: Record<string, unknown> = {}
139
+ validateSection(envConfig.client, 'client', source, issues, values)
140
+ if (issues.length > 0) throw new BunderstackEnvError(issues)
141
+
142
+ const serverKeys = new Set(Object.keys(envConfig.server ?? {}))
143
+ return new Proxy(values, {
144
+ get(target, prop) {
145
+ if (typeof prop === 'string' && serverKeys.has(prop)) {
146
+ throw new Error(
147
+ `${prop} is server-only and not available in client env`,
148
+ )
149
+ }
150
+ return Reflect.get(target, prop)
151
+ },
152
+ }) as InferVars<TEnv['client']>
153
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { Context } from 'hono'
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status'
3
+
4
+ export const ErrorCode = {
5
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
6
+ FORBIDDEN: 'FORBIDDEN',
7
+ NOT_FOUND: 'NOT_FOUND',
8
+ INVALID_CURSOR: 'INVALID_CURSOR',
9
+ RATE_LIMITED: 'RATE_LIMITED',
10
+ IDEMPOTENCY_REPLAY: 'IDEMPOTENCY_REPLAY',
11
+ IDEMPOTENCY_CONFLICT: 'IDEMPOTENCY_CONFLICT',
12
+ } as const
13
+
14
+ export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
15
+
16
+ export type ApiErrorBody = {
17
+ error: string
18
+ code?: ErrorCodeValue
19
+ details?: unknown
20
+ }
21
+
22
+ export function apiError(
23
+ c: Context,
24
+ code: ErrorCodeValue,
25
+ message: string,
26
+ status: ContentfulStatusCode,
27
+ details?: unknown,
28
+ ) {
29
+ const body: ApiErrorBody = {
30
+ error: message,
31
+ code,
32
+ ...(details ? { details } : {}),
33
+ }
34
+ return c.json(body, status)
35
+ }
36
+
37
+ export class ListQueryError extends Error {
38
+ readonly code: ErrorCodeValue
39
+ readonly details?: unknown
40
+
41
+ constructor(
42
+ message: string,
43
+ code: ErrorCodeValue = ErrorCode.VALIDATION_ERROR,
44
+ details?: unknown,
45
+ ) {
46
+ super(message)
47
+ this.name = 'ListQueryError'
48
+ this.code = code
49
+ this.details = details
50
+ }
51
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,53 @@
1
+ // src/handler.ts
2
+ import { Hono } from 'hono'
3
+
4
+ import { createRateLimiter, type RateLimitConfig } from './rate-limit'
5
+
6
+ interface HandlerParts {
7
+ crudRouter: Hono
8
+ authHandler?: (req: Request) => Promise<Response>
9
+ storageRouter?: Hono
10
+ realtimeRouter?: Hono
11
+ trpcHandler?: (req: Request) => Promise<Response>
12
+ rateLimit?: boolean | RateLimitConfig
13
+ }
14
+
15
+ export function buildHandler(parts: HandlerParts): {
16
+ handler: (req: Request) => Promise<Response>
17
+ router: Hono
18
+ } {
19
+ const app = new Hono()
20
+ const checkRateLimit = createRateLimiter(parts.rateLimit)
21
+
22
+ const health = (c: { json: (data: unknown) => Response }) =>
23
+ c.json({ status: 'ok' })
24
+ app.get('/health', health)
25
+ app.get('/api/health', health)
26
+ app.route('/api', parts.crudRouter)
27
+
28
+ if (parts.authHandler) {
29
+ app.all('/api/auth/*', (c) => parts.authHandler!(c.req.raw))
30
+ }
31
+
32
+ if (parts.storageRouter) {
33
+ app.route('/api/files', parts.storageRouter)
34
+ app.route('/files', parts.storageRouter)
35
+ }
36
+
37
+ if (parts.realtimeRouter) {
38
+ app.route('/api', parts.realtimeRouter)
39
+ }
40
+
41
+ if (parts.trpcHandler) {
42
+ app.all('/api/trpc/*', (c) => parts.trpcHandler!(c.req.raw))
43
+ }
44
+
45
+ const inner = (req: Request): Promise<Response> =>
46
+ Promise.resolve(app.fetch(req))
47
+ const handler = async (req: Request): Promise<Response> => {
48
+ const limited = await checkRateLimit(req)
49
+ if (limited) return limited
50
+ return inner(req)
51
+ }
52
+ return { handler, router: app }
53
+ }
@@ -0,0 +1,102 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+
3
+ import { and, eq, lt } from 'drizzle-orm'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ import { bunderstackIdempotency } from './internal-tables'
7
+
8
+ export type IdempotencyConfig = {
9
+ ttlMs?: number
10
+ }
11
+
12
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
13
+
14
+ function hashBody(body: string): string {
15
+ return createHash('sha256').update(body).digest('hex')
16
+ }
17
+
18
+ export type IdempotencyLookup =
19
+ | { type: 'replay'; status: number; response: string }
20
+ | { type: 'conflict' }
21
+ | { type: 'proceed' }
22
+
23
+ export async function lookupIdempotency(
24
+ db: LibSQLDatabase<Record<string, unknown>>,
25
+ tableName: string,
26
+ key: string,
27
+ body: string,
28
+ config: IdempotencyConfig,
29
+ ): Promise<IdempotencyLookup> {
30
+ const now = Date.now()
31
+
32
+ // TTL sweep: drop expired rows before reading.
33
+ await db
34
+ .delete(bunderstackIdempotency)
35
+ .where(lt(bunderstackIdempotency.expiresAt, now))
36
+
37
+ const bodyHash = hashBody(body)
38
+ const rows = await db
39
+ .select({
40
+ bodyHash: bunderstackIdempotency.bodyHash,
41
+ status: bunderstackIdempotency.status,
42
+ response: bunderstackIdempotency.response,
43
+ expiresAt: bunderstackIdempotency.expiresAt,
44
+ })
45
+ .from(bunderstackIdempotency)
46
+ .where(
47
+ and(
48
+ eq(bunderstackIdempotency.key, key),
49
+ eq(bunderstackIdempotency.tableName, tableName),
50
+ ),
51
+ )
52
+ .limit(1)
53
+
54
+ const row = rows[0]
55
+
56
+ if (!row) return { type: 'proceed' }
57
+ if (Number(row.expiresAt) < now) return { type: 'proceed' }
58
+ if (row.bodyHash !== bodyHash) return { type: 'conflict' }
59
+ return {
60
+ type: 'replay',
61
+ status: Number(row.status),
62
+ response: String(row.response),
63
+ }
64
+ }
65
+
66
+ export async function storeIdempotency(
67
+ db: LibSQLDatabase<Record<string, unknown>>,
68
+ tableName: string,
69
+ key: string,
70
+ body: string,
71
+ status: number,
72
+ response: unknown,
73
+ config: IdempotencyConfig,
74
+ ): Promise<void> {
75
+ const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
76
+ const expiresAt = Date.now() + ttlMs
77
+ const bodyHash = hashBody(body)
78
+ const responseText = JSON.stringify(response)
79
+
80
+ await db
81
+ .insert(bunderstackIdempotency)
82
+ .values({
83
+ key,
84
+ tableName,
85
+ bodyHash,
86
+ status,
87
+ response: responseText,
88
+ expiresAt,
89
+ })
90
+ .onConflictDoUpdate({
91
+ target: [bunderstackIdempotency.key, bunderstackIdempotency.tableName],
92
+ set: { bodyHash, status, response: responseText, expiresAt },
93
+ })
94
+ }
95
+
96
+ export function resolveIdempotencyConfig(
97
+ config: boolean | IdempotencyConfig | undefined,
98
+ ): IdempotencyConfig | null {
99
+ if (!config) return null
100
+ if (config === true) return {}
101
+ return config
102
+ }