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/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
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
|
+
}
|