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
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
and,
|
|
5
|
+
asc,
|
|
6
|
+
desc,
|
|
7
|
+
eq,
|
|
8
|
+
getTableColumns,
|
|
9
|
+
gt,
|
|
10
|
+
inArray,
|
|
11
|
+
like,
|
|
12
|
+
lt,
|
|
13
|
+
or,
|
|
14
|
+
sql,
|
|
15
|
+
type SQL,
|
|
16
|
+
} from 'drizzle-orm'
|
|
17
|
+
|
|
18
|
+
import type { ResolvedTableAccess, SortOrder } from './access'
|
|
19
|
+
|
|
20
|
+
import { ErrorCode, ListQueryError } from './errors'
|
|
21
|
+
|
|
22
|
+
/** Caps both `?limit=` and the number of values in a comma-separated `IN` filter. */
|
|
23
|
+
export const MAX_LIST_LIMIT = 200
|
|
24
|
+
|
|
25
|
+
export const RESERVED_LIST_PARAMS = new Set([
|
|
26
|
+
'limit',
|
|
27
|
+
'offset',
|
|
28
|
+
'sort',
|
|
29
|
+
'order',
|
|
30
|
+
'q',
|
|
31
|
+
'cursor',
|
|
32
|
+
'count',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
export type ParsedListParams = {
|
|
36
|
+
limit: number
|
|
37
|
+
offset?: number
|
|
38
|
+
sort: string
|
|
39
|
+
order: SortOrder
|
|
40
|
+
q: string
|
|
41
|
+
cursor?: string
|
|
42
|
+
count: boolean
|
|
43
|
+
filters: Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ListResult<T> = {
|
|
47
|
+
items: T[]
|
|
48
|
+
limit: number
|
|
49
|
+
offset?: number
|
|
50
|
+
cursor?: string
|
|
51
|
+
nextCursor?: string
|
|
52
|
+
hasMore: boolean
|
|
53
|
+
total?: number
|
|
54
|
+
q?: string
|
|
55
|
+
sort: string
|
|
56
|
+
order: SortOrder
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type CursorPayload = {
|
|
60
|
+
sort: string
|
|
61
|
+
order: SortOrder
|
|
62
|
+
v: string | number | null
|
|
63
|
+
id: string | number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isCursorPayload(value: unknown): value is CursorPayload {
|
|
71
|
+
if (!isRecord(value)) return false
|
|
72
|
+
return (
|
|
73
|
+
typeof value.sort === 'string' &&
|
|
74
|
+
(value.order === 'asc' || value.order === 'desc') &&
|
|
75
|
+
(value.v === null ||
|
|
76
|
+
typeof value.v === 'string' ||
|
|
77
|
+
typeof value.v === 'number') &&
|
|
78
|
+
(typeof value.id === 'string' || typeof value.id === 'number')
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseBoolean(value: string | undefined): boolean {
|
|
83
|
+
if (!value) return false
|
|
84
|
+
const v = value.toLowerCase()
|
|
85
|
+
return v === 'true' || v === '1'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseLimit(raw: string | undefined): number {
|
|
89
|
+
if (raw === undefined || raw === '') return 20
|
|
90
|
+
const n = Number(raw)
|
|
91
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
92
|
+
throw new ListQueryError(
|
|
93
|
+
`limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return Math.min(n, MAX_LIST_LIMIT)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseOffset(raw: string | undefined): number {
|
|
100
|
+
if (raw === undefined || raw === '') return 0
|
|
101
|
+
const n = Number(raw)
|
|
102
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
103
|
+
throw new ListQueryError('offset must be a non-negative integer')
|
|
104
|
+
}
|
|
105
|
+
return n
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseOrder(raw: string | undefined): SortOrder {
|
|
109
|
+
if (!raw || raw === 'asc') return 'asc'
|
|
110
|
+
if (raw === 'desc') return 'desc'
|
|
111
|
+
throw new ListQueryError('order must be "asc" or "desc"')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseListParams(
|
|
115
|
+
url: URL,
|
|
116
|
+
access: ResolvedTableAccess,
|
|
117
|
+
): ParsedListParams {
|
|
118
|
+
const params = url.searchParams
|
|
119
|
+
const limit = parseLimit(params.get('limit') ?? undefined)
|
|
120
|
+
const cursor = params.get('cursor')?.trim() || undefined
|
|
121
|
+
const hasOffset = params.has('offset') && params.get('offset') !== ''
|
|
122
|
+
const offset = hasOffset
|
|
123
|
+
? parseOffset(params.get('offset') ?? undefined)
|
|
124
|
+
: undefined
|
|
125
|
+
|
|
126
|
+
if (cursor && hasOffset) {
|
|
127
|
+
throw new ListQueryError('cursor and offset cannot be used together')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sort = params.get('sort')?.trim() || access.defaultSort.column
|
|
131
|
+
const order = params.has('order')
|
|
132
|
+
? parseOrder(params.get('order') ?? undefined)
|
|
133
|
+
: params.has('sort')
|
|
134
|
+
? 'asc'
|
|
135
|
+
: access.defaultSort.order
|
|
136
|
+
|
|
137
|
+
if (!access.sortableColumns.includes(sort)) {
|
|
138
|
+
throw new ListQueryError(`sort column "${sort}" is not allowed`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const filters: Record<string, unknown> = {}
|
|
142
|
+
for (const [key, value] of params.entries()) {
|
|
143
|
+
if (RESERVED_LIST_PARAMS.has(key)) continue
|
|
144
|
+
if (!access.filterableColumns.includes(key)) {
|
|
145
|
+
throw new ListQueryError(`filter column "${key}" is not allowed`)
|
|
146
|
+
}
|
|
147
|
+
filters[key] = value === 'null' ? null : value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const q = params.get('q')?.trim().slice(0, 100) ?? ''
|
|
151
|
+
const count = parseBoolean(params.get('count') ?? undefined)
|
|
152
|
+
|
|
153
|
+
if (cursor) {
|
|
154
|
+
const decoded = decodeCursor(cursor)
|
|
155
|
+
if (decoded.sort !== sort || decoded.order !== order) {
|
|
156
|
+
throw new ListQueryError(
|
|
157
|
+
'cursor does not match sort and order parameters',
|
|
158
|
+
ErrorCode.INVALID_CURSOR,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
limit,
|
|
165
|
+
offset: cursor ? undefined : (offset ?? 0),
|
|
166
|
+
sort,
|
|
167
|
+
order,
|
|
168
|
+
q,
|
|
169
|
+
cursor,
|
|
170
|
+
count,
|
|
171
|
+
filters,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildSearchWhere(
|
|
176
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
177
|
+
searchableColumns: string[] | undefined,
|
|
178
|
+
q: string,
|
|
179
|
+
): SQL | undefined {
|
|
180
|
+
if (!q || !searchableColumns?.length) return undefined
|
|
181
|
+
const columns = getTableColumns(table)
|
|
182
|
+
const pattern = `%${q.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`
|
|
183
|
+
const conditions = searchableColumns
|
|
184
|
+
.filter((name) => name in columns)
|
|
185
|
+
.map((name) => like(columns[name]!, pattern))
|
|
186
|
+
return conditions.length ? or(...conditions) : undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function coerceFilterValue(
|
|
190
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
191
|
+
columnName: string,
|
|
192
|
+
raw: unknown,
|
|
193
|
+
): unknown {
|
|
194
|
+
if (raw === null) return null
|
|
195
|
+
const col = getTableColumns(table)[columnName]
|
|
196
|
+
if (!col) return raw
|
|
197
|
+
|
|
198
|
+
const dataType = col.dataType
|
|
199
|
+
if (
|
|
200
|
+
dataType === 'number' ||
|
|
201
|
+
dataType === 'integer' ||
|
|
202
|
+
dataType === 'bigint'
|
|
203
|
+
) {
|
|
204
|
+
const n = Number(raw)
|
|
205
|
+
if (Number.isNaN(n)) {
|
|
206
|
+
throw new ListQueryError(`filter "${columnName}" must be a number`)
|
|
207
|
+
}
|
|
208
|
+
return n
|
|
209
|
+
}
|
|
210
|
+
if (dataType === 'boolean') {
|
|
211
|
+
const s = String(raw).toLowerCase()
|
|
212
|
+
if (s === 'true' || s === '1') return true
|
|
213
|
+
if (s === 'false' || s === '0') return false
|
|
214
|
+
throw new ListQueryError(`filter "${columnName}" must be a boolean`)
|
|
215
|
+
}
|
|
216
|
+
if (dataType === 'date') {
|
|
217
|
+
const d = new Date(raw as string | number)
|
|
218
|
+
if (Number.isNaN(d.getTime())) {
|
|
219
|
+
throw new ListQueryError(`filter "${columnName}" must be a valid date`)
|
|
220
|
+
}
|
|
221
|
+
return d
|
|
222
|
+
}
|
|
223
|
+
return String(raw)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildFilterWhere(
|
|
227
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
228
|
+
filters: Record<string, unknown>,
|
|
229
|
+
): SQL | undefined {
|
|
230
|
+
const columns = getTableColumns(table)
|
|
231
|
+
const conditions: SQL[] = []
|
|
232
|
+
|
|
233
|
+
for (const [name, raw] of Object.entries(filters)) {
|
|
234
|
+
const col = columns[name]
|
|
235
|
+
if (!col) continue
|
|
236
|
+
|
|
237
|
+
if (raw === null) {
|
|
238
|
+
conditions.push(sql`${col} IS NULL`)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// `?column=a,b,c` — TypeIDs and other filterable values never contain
|
|
243
|
+
// commas, so this is a safe, zero-syntax way to do `column IN (...)`.
|
|
244
|
+
if (typeof raw === 'string' && raw.includes(',')) {
|
|
245
|
+
const parts = raw.split(',').filter((p) => p.length > 0)
|
|
246
|
+
if (parts.length > MAX_LIST_LIMIT) {
|
|
247
|
+
throw new ListQueryError(
|
|
248
|
+
`filter "${name}" accepts at most ${MAX_LIST_LIMIT} comma-separated values`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
const values = parts.map((p) => coerceFilterValue(table, name, p))
|
|
252
|
+
conditions.push(inArray(col, values))
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const value = coerceFilterValue(table, name, raw)
|
|
257
|
+
if (value === null) {
|
|
258
|
+
conditions.push(sql`${col} IS NULL`)
|
|
259
|
+
} else {
|
|
260
|
+
conditions.push(eq(col, value))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return conditions.length ? and(...conditions) : undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function serializeCursorValue(value: unknown): string | number | null {
|
|
268
|
+
if (value == null) return null
|
|
269
|
+
if (value instanceof Date) return value.toISOString()
|
|
270
|
+
if (typeof value === 'number' || typeof value === 'string') return value
|
|
271
|
+
return String(value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function encodeCursor(payload: CursorPayload): string {
|
|
275
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function decodeCursor(cursor: string): CursorPayload {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(
|
|
281
|
+
Buffer.from(cursor, 'base64url').toString('utf8'),
|
|
282
|
+
)
|
|
283
|
+
if (!isCursorPayload(parsed)) {
|
|
284
|
+
throw new Error('invalid cursor shape')
|
|
285
|
+
}
|
|
286
|
+
return parsed
|
|
287
|
+
} catch {
|
|
288
|
+
throw new ListQueryError('invalid cursor', ErrorCode.INVALID_CURSOR)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function buildCursorWhere(
|
|
293
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
294
|
+
sortColName: string,
|
|
295
|
+
order: SortOrder,
|
|
296
|
+
cursor: CursorPayload,
|
|
297
|
+
idCol: unknown,
|
|
298
|
+
): SQL {
|
|
299
|
+
const columns = getTableColumns(table)
|
|
300
|
+
const sortCol = columns[sortColName]!
|
|
301
|
+
const sortValue = coerceFilterValue(table, sortColName, cursor.v)
|
|
302
|
+
|
|
303
|
+
if (order === 'desc') {
|
|
304
|
+
return or(
|
|
305
|
+
lt(sortCol, sortValue),
|
|
306
|
+
and(eq(sortCol, sortValue), lt(idCol as never, cursor.id)),
|
|
307
|
+
)!
|
|
308
|
+
}
|
|
309
|
+
return or(
|
|
310
|
+
gt(sortCol, sortValue),
|
|
311
|
+
and(eq(sortCol, sortValue), gt(idCol as never, cursor.id)),
|
|
312
|
+
)!
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildOrderBy(
|
|
316
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
317
|
+
sortColName: string,
|
|
318
|
+
order: SortOrder,
|
|
319
|
+
idCol: unknown,
|
|
320
|
+
) {
|
|
321
|
+
const columns = getTableColumns(table)
|
|
322
|
+
const sortCol = columns[sortColName]!
|
|
323
|
+
const idOrder = order === 'asc' ? asc(idCol as never) : desc(idCol as never)
|
|
324
|
+
return order === 'asc' ? [asc(sortCol), idOrder] : [desc(sortCol), idOrder]
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function executeList<T extends Record<string, unknown>>(
|
|
328
|
+
db: LibSQLDatabase<Record<string, unknown>>,
|
|
329
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
330
|
+
access: ResolvedTableAccess,
|
|
331
|
+
params: ParsedListParams,
|
|
332
|
+
idCol: unknown,
|
|
333
|
+
scopeWhere?: SQL,
|
|
334
|
+
): Promise<ListResult<T>> {
|
|
335
|
+
const searchWhere = buildSearchWhere(
|
|
336
|
+
table,
|
|
337
|
+
access.searchableColumns,
|
|
338
|
+
params.q,
|
|
339
|
+
)
|
|
340
|
+
const filterWhere = buildFilterWhere(table, params.filters)
|
|
341
|
+
let where = and(
|
|
342
|
+
...(searchWhere ? [searchWhere] : []),
|
|
343
|
+
...(filterWhere ? [filterWhere] : []),
|
|
344
|
+
...(scopeWhere ? [scopeWhere] : []),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if (params.cursor) {
|
|
348
|
+
const cursorPayload = decodeCursor(params.cursor)
|
|
349
|
+
const cursorWhere = buildCursorWhere(
|
|
350
|
+
table,
|
|
351
|
+
params.sort,
|
|
352
|
+
params.order,
|
|
353
|
+
cursorPayload,
|
|
354
|
+
idCol,
|
|
355
|
+
)
|
|
356
|
+
where = where ? and(where, cursorWhere) : cursorWhere
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const orderBy = buildOrderBy(table, params.sort, params.order, idCol)
|
|
360
|
+
|
|
361
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
362
|
+
let query = (db as any).select().from(table)
|
|
363
|
+
if (where) query = query.where(where)
|
|
364
|
+
query = query.orderBy(...orderBy)
|
|
365
|
+
|
|
366
|
+
if (params.offset !== undefined) {
|
|
367
|
+
query = query.limit(params.limit).offset(params.offset)
|
|
368
|
+
} else {
|
|
369
|
+
query = query.limit(params.limit)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const items = (await query) as T[]
|
|
373
|
+
|
|
374
|
+
let total: number | undefined
|
|
375
|
+
if (params.count) {
|
|
376
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
377
|
+
let countQuery = (db as any)
|
|
378
|
+
.select({ count: sql<number>`count(*)` })
|
|
379
|
+
.from(table)
|
|
380
|
+
if (where) countQuery = countQuery.where(where)
|
|
381
|
+
const [row] = await countQuery
|
|
382
|
+
total = Number(row?.count ?? 0)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const hasMore =
|
|
386
|
+
total !== undefined && params.offset !== undefined
|
|
387
|
+
? params.offset + items.length < total
|
|
388
|
+
: items.length === params.limit
|
|
389
|
+
|
|
390
|
+
let nextCursor: string | undefined
|
|
391
|
+
if (items.length === params.limit) {
|
|
392
|
+
const last = items[items.length - 1]!
|
|
393
|
+
nextCursor = encodeCursor({
|
|
394
|
+
sort: params.sort,
|
|
395
|
+
order: params.order,
|
|
396
|
+
v: serializeCursorValue(last[params.sort]),
|
|
397
|
+
id: last.id as string | number,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
items,
|
|
403
|
+
limit: params.limit,
|
|
404
|
+
...(params.offset !== undefined ? { offset: params.offset } : {}),
|
|
405
|
+
...(params.cursor ? { cursor: params.cursor } : {}),
|
|
406
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
407
|
+
hasMore,
|
|
408
|
+
...(total !== undefined ? { total } : {}),
|
|
409
|
+
...(params.q ? { q: params.q } : {}),
|
|
410
|
+
sort: params.sort,
|
|
411
|
+
order: params.order,
|
|
412
|
+
}
|
|
413
|
+
}
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
+
|
|
3
|
+
import { mkdir } from 'node:fs/promises'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
async function ensureSqliteFileParent(url: string): Promise<void> {
|
|
7
|
+
const match = /^file:(.+)$/.exec(url)
|
|
8
|
+
if (!match) return
|
|
9
|
+
const filePath = match[1]!
|
|
10
|
+
if (filePath === ':memory:') return
|
|
11
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Push the merged schema to the database via drizzle-kit/api. */
|
|
15
|
+
export async function provisionSchema<TSchema extends Record<string, unknown>>(
|
|
16
|
+
db: LibSQLDatabase<TSchema>,
|
|
17
|
+
schema: TSchema,
|
|
18
|
+
options?: { force?: boolean; databaseUrl?: string },
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
if (options?.databaseUrl) {
|
|
21
|
+
await ensureSqliteFileParent(options.databaseUrl)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { pushSQLiteSchema } = await import('drizzle-kit/api')
|
|
25
|
+
const result = await pushSQLiteSchema(schema, db)
|
|
26
|
+
|
|
27
|
+
if (result.hasDataLoss && !options?.force) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call app.provision({ force: true }).',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (result.warnings.length > 0) {
|
|
34
|
+
for (const warning of result.warnings) {
|
|
35
|
+
console.warn(`[bunderstack] ${warning}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (result.statementsToExecute.length === 0) return
|
|
40
|
+
|
|
41
|
+
await result.apply()
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
`[bunderstack] provisioned ${result.statementsToExecute.length} schema change(s)`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type RateLimitConfig = {
|
|
2
|
+
windowMs?: number
|
|
3
|
+
max?: number
|
|
4
|
+
skip?: (req: Request) => boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type Bucket = {
|
|
8
|
+
count: number
|
|
9
|
+
resetAt: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const buckets = new Map<string, Bucket>()
|
|
13
|
+
|
|
14
|
+
function resolveConfig(
|
|
15
|
+
config: boolean | RateLimitConfig | undefined,
|
|
16
|
+
): RateLimitConfig | null {
|
|
17
|
+
if (!config) return null
|
|
18
|
+
if (config === true) return {}
|
|
19
|
+
return config
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clientKey(req: Request): string {
|
|
23
|
+
const forwarded = req.headers.get('x-forwarded-for')
|
|
24
|
+
if (forwarded) return forwarded.split(',')[0]!.trim()
|
|
25
|
+
return req.headers.get('x-real-ip') ?? 'local'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRateLimiter(
|
|
29
|
+
config: boolean | RateLimitConfig | undefined,
|
|
30
|
+
): (req: Request) => Promise<Response | null> {
|
|
31
|
+
const resolved = resolveConfig(config)
|
|
32
|
+
if (!resolved) {
|
|
33
|
+
return async (_req: Request) => null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const windowMs = resolved.windowMs ?? 60_000
|
|
37
|
+
const max = resolved.max ?? 100
|
|
38
|
+
|
|
39
|
+
return async (req: Request): Promise<Response | null> => {
|
|
40
|
+
if (resolved.skip?.(req)) return null
|
|
41
|
+
|
|
42
|
+
const key = `${clientKey(req)}:${new URL(req.url).pathname}`
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
let bucket = buckets.get(key)
|
|
45
|
+
|
|
46
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
47
|
+
bucket = { count: 0, resetAt: now + windowMs }
|
|
48
|
+
buckets.set(key, bucket)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
bucket.count += 1
|
|
52
|
+
if (bucket.count > max) {
|
|
53
|
+
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000)
|
|
54
|
+
return new Response(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
error: 'Too many requests',
|
|
57
|
+
code: 'RATE_LIMITED',
|
|
58
|
+
}),
|
|
59
|
+
{
|
|
60
|
+
status: 429,
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
'Retry-After': String(retryAfter),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|