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,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export interface TransformSpec {
|
|
4
|
+
w?: number
|
|
5
|
+
h?: number
|
|
6
|
+
fit?: 'fill' | 'inside'
|
|
7
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif'
|
|
8
|
+
quality?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function transformImage(
|
|
12
|
+
input: Buffer,
|
|
13
|
+
spec: TransformSpec,
|
|
14
|
+
): Promise<Buffer> {
|
|
15
|
+
let img = new Bun.Image(input)
|
|
16
|
+
|
|
17
|
+
if (spec.w !== undefined && spec.h !== undefined) {
|
|
18
|
+
img = img.resize(spec.w, spec.h, { fit: spec.fit ?? 'fill' })
|
|
19
|
+
} else if (spec.w !== undefined) {
|
|
20
|
+
img = img.resize(spec.w)
|
|
21
|
+
} else if (spec.h !== undefined) {
|
|
22
|
+
img = img.resize(0, spec.h)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const q = spec.quality
|
|
26
|
+
switch (spec.format) {
|
|
27
|
+
case 'webp':
|
|
28
|
+
return img.webp({ quality: q }).buffer()
|
|
29
|
+
case 'png':
|
|
30
|
+
return img.png().buffer()
|
|
31
|
+
case 'avif':
|
|
32
|
+
return img.avif({ quality: q }).buffer()
|
|
33
|
+
default:
|
|
34
|
+
return img.jpeg({ quality: q }).buffer()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function transformHash(spec: TransformSpec): string {
|
|
39
|
+
return createHash('sha256')
|
|
40
|
+
.update(JSON.stringify(spec))
|
|
41
|
+
.digest('hex')
|
|
42
|
+
.slice(0, 16)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseTransformSpec(
|
|
46
|
+
query: Record<string, string>,
|
|
47
|
+
): TransformSpec | null {
|
|
48
|
+
const { w, h, fit, format, quality } = query
|
|
49
|
+
if (!w && !h && !fit && !format && !quality) return null
|
|
50
|
+
|
|
51
|
+
const spec: TransformSpec = {}
|
|
52
|
+
if (w) spec.w = Number(w)
|
|
53
|
+
if (h) spec.h = Number(h)
|
|
54
|
+
if (fit === 'fill' || fit === 'inside') spec.fit = fit
|
|
55
|
+
if (
|
|
56
|
+
format === 'webp' ||
|
|
57
|
+
format === 'jpeg' ||
|
|
58
|
+
format === 'png' ||
|
|
59
|
+
format === 'avif'
|
|
60
|
+
) {
|
|
61
|
+
spec.format = format
|
|
62
|
+
}
|
|
63
|
+
if (quality) spec.quality = Math.min(100, Math.max(1, Number(quality)))
|
|
64
|
+
return spec
|
|
65
|
+
}
|
package/src/trpc.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/trpc.ts — pre-wired tRPC instance for bunderstack endpoints.
|
|
2
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
3
|
+
|
|
4
|
+
import { initTRPC, TRPCError } from '@trpc/server'
|
|
5
|
+
import superjson from 'superjson'
|
|
6
|
+
|
|
7
|
+
import type { AccessUser } from './access'
|
|
8
|
+
import type { EmailFacade } from './email'
|
|
9
|
+
|
|
10
|
+
export type TRPCContext<
|
|
11
|
+
TSchema extends Record<string, unknown>,
|
|
12
|
+
TEnvResult = Record<string, unknown>,
|
|
13
|
+
> = {
|
|
14
|
+
db: LibSQLDatabase<TSchema>
|
|
15
|
+
user: AccessUser | null
|
|
16
|
+
env: TEnvResult
|
|
17
|
+
email: EmailFacade
|
|
18
|
+
req: Request
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the `t` instance bunderstack hands to the config's `trpc` builder
|
|
23
|
+
* callback (and exports for multi-file router setups). superjson is the
|
|
24
|
+
* transformer, so Dates/Maps/Sets/BigInt/undefined round-trip.
|
|
25
|
+
*/
|
|
26
|
+
export function createTRPC<
|
|
27
|
+
TSchema extends Record<string, unknown>,
|
|
28
|
+
TEnvResult = Record<string, unknown>,
|
|
29
|
+
>() {
|
|
30
|
+
const t = initTRPC.context<TRPCContext<TSchema, TEnvResult>>().create({
|
|
31
|
+
transformer: superjson,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
|
35
|
+
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
|
|
36
|
+
return next({ ctx: { ...ctx, user: ctx.user } })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
router: t.router,
|
|
41
|
+
middleware: t.middleware,
|
|
42
|
+
mergeRouters: t.mergeRouters,
|
|
43
|
+
procedure: t.procedure,
|
|
44
|
+
protectedProcedure,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Type of the `t` instance — for builder callbacks declared in separate files. */
|
|
49
|
+
export type BunderstackTRPC<
|
|
50
|
+
TSchema extends Record<string, unknown>,
|
|
51
|
+
TEnvResult = Record<string, unknown>,
|
|
52
|
+
> = ReturnType<typeof createTRPC<TSchema, TEnvResult>>
|
package/src/typeid.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/typeid.ts
|
|
2
|
+
//
|
|
3
|
+
// Zero-dependency TypeID implementation (https://github.com/jetify-com/typeid).
|
|
4
|
+
// Stripe-style prefixed, k-sortable identifiers: `prefix_<26-char base32 UUIDv7>`.
|
|
5
|
+
// The only runtime primitive we need is the raw UUIDv7 bytes, which Bun gives us
|
|
6
|
+
// natively via `Bun.randomUUIDv7("buffer")` — so we don't pull in the `uuid` dep
|
|
7
|
+
// that the reference `typeid-js` package relies on.
|
|
8
|
+
|
|
9
|
+
import { customType } from 'drizzle-orm/sqlite-core'
|
|
10
|
+
|
|
11
|
+
// Crockford base32 alphabet (lowercase, excludes i/l/o/u), as used by the TypeID spec.
|
|
12
|
+
const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'
|
|
13
|
+
|
|
14
|
+
// Spec prefix rules: lowercase ascii, underscores allowed only internally, 1–63 chars.
|
|
15
|
+
const PREFIX_RE = /^[a-z]([a-z_]{0,61}[a-z])?$/
|
|
16
|
+
// 26 base32 chars from the alphabet above.
|
|
17
|
+
const SUFFIX_RE = /^[0-9a-hjkmnp-tv-z]{26}$/
|
|
18
|
+
|
|
19
|
+
declare const brand: unique symbol
|
|
20
|
+
|
|
21
|
+
/** A branded TypeID string. `TypeId<'post'>` is incompatible with `TypeId<'user'>`. */
|
|
22
|
+
export type TypeId<P extends string> = string & { readonly [brand]: P }
|
|
23
|
+
|
|
24
|
+
/** Encode a 16-byte UUID into the 26-character base32 suffix. */
|
|
25
|
+
export function encode(bytes: Uint8Array): string {
|
|
26
|
+
// First two chars cover byte 0: top 3 bits, then low 5 bits.
|
|
27
|
+
let out = ALPHABET[bytes[0]! >> 5]! + ALPHABET[bytes[0]! & 31]!
|
|
28
|
+
// Remaining 15 bytes (120 bits) → 24 groups of 5 bits.
|
|
29
|
+
let buf = 0n
|
|
30
|
+
for (let i = 1; i < 16; i++) buf = (buf << 8n) | BigInt(bytes[i]!)
|
|
31
|
+
const chars: string[] = []
|
|
32
|
+
for (let i = 0; i < 24; i++) {
|
|
33
|
+
chars.push(ALPHABET[Number((buf >> (BigInt(23 - i) * 5n)) & 31n)]!)
|
|
34
|
+
}
|
|
35
|
+
return out + chars.join('')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Decode a 26-character base32 suffix back into the original 16 UUID bytes. */
|
|
39
|
+
export function decode(suffix: string): Uint8Array {
|
|
40
|
+
const map: Record<string, number> = {}
|
|
41
|
+
for (let i = 0; i < ALPHABET.length; i++) map[ALPHABET[i]!] = i
|
|
42
|
+
const bytes = new Uint8Array(16)
|
|
43
|
+
bytes[0] = (map[suffix[0]!]! << 5) | map[suffix[1]!]!
|
|
44
|
+
let buf = 0n
|
|
45
|
+
for (let i = 2; i < 26; i++) buf = (buf << 5n) | BigInt(map[suffix[i]!]!)
|
|
46
|
+
for (let i = 0; i < 15; i++)
|
|
47
|
+
bytes[15 - i] = Number((buf >> BigInt(i * 8)) & 0xffn)
|
|
48
|
+
return bytes
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a string is a valid, non-empty TypeID prefix. */
|
|
52
|
+
export function isValidPrefix(prefix: string): boolean {
|
|
53
|
+
return PREFIX_RE.test(prefix)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bytesToUuid(bytes: Uint8Array): string {
|
|
57
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
58
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Generate a new, branded TypeID for the given prefix. */
|
|
62
|
+
export function generate<P extends string>(prefix: P): TypeId<P> {
|
|
63
|
+
if (!isValidPrefix(prefix))
|
|
64
|
+
throw new Error(`Invalid typeid prefix: "${prefix}"`)
|
|
65
|
+
const bytes = Bun.randomUUIDv7('buffer')
|
|
66
|
+
return `${prefix}_${encode(bytes)}` as TypeId<P>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a TypeID into its parts and the decoded UUID. Throws if the id is
|
|
71
|
+
* malformed or — when `expectedPrefix` is given — if the prefix does not match.
|
|
72
|
+
*/
|
|
73
|
+
export function parse<P extends string>(
|
|
74
|
+
id: string,
|
|
75
|
+
expectedPrefix?: P,
|
|
76
|
+
): { prefix: string; suffix: string; uuid: string } {
|
|
77
|
+
const sep = id.lastIndexOf('_')
|
|
78
|
+
if (sep <= 0) throw new Error(`Malformed typeid: "${id}"`)
|
|
79
|
+
const prefix = id.slice(0, sep)
|
|
80
|
+
const suffix = id.slice(sep + 1)
|
|
81
|
+
if (!isValidPrefix(prefix))
|
|
82
|
+
throw new Error(`Malformed typeid prefix: "${id}"`)
|
|
83
|
+
if (!SUFFIX_RE.test(suffix))
|
|
84
|
+
throw new Error(`Malformed typeid suffix: "${id}"`)
|
|
85
|
+
if (expectedPrefix !== undefined && prefix !== expectedPrefix) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Expected typeid prefix "${expectedPrefix}" but got "${prefix}"`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
return { prefix, suffix, uuid: bytesToUuid(decode(suffix)) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Escape hatch: validate a raw string against a prefix and brand it as a TypeID.
|
|
95
|
+
* Use at trust boundaries (e.g. a raw URL param) where the string isn't yet typed.
|
|
96
|
+
*/
|
|
97
|
+
export function asTypeId<P extends string>(prefix: P, raw: string): TypeId<P> {
|
|
98
|
+
parse(raw, prefix)
|
|
99
|
+
return raw as TypeId<P>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drizzle column builder for a branded TypeID text value. Stores a plain `text`
|
|
104
|
+
* column so drizzle-kit migrations and `$inferSelect` work unchanged.
|
|
105
|
+
*
|
|
106
|
+
* Use Drizzle's `$defaultFn` when a column should generate IDs on insert:
|
|
107
|
+
*
|
|
108
|
+
* id: typeid('post').primaryKey().$defaultFn(() => generate('post'))
|
|
109
|
+
*/
|
|
110
|
+
export function typeid<P extends string>(prefix: P) {
|
|
111
|
+
if (!isValidPrefix(prefix))
|
|
112
|
+
throw new Error(`Invalid typeid prefix: "${prefix}"`)
|
|
113
|
+
return customType<{ data: TypeId<P>; driverData: string }>({
|
|
114
|
+
dataType: () => 'text',
|
|
115
|
+
})()
|
|
116
|
+
}
|