bunderstack 0.2.0 → 0.3.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 +33 -0
- package/package.json +1 -1
- package/src/config.ts +15 -3
- package/src/env.ts +4 -1
- package/src/index.ts +25 -3
- package/src/manifest.ts +52 -0
- package/src/storage/buckets.ts +30 -1
package/README.md
CHANGED
|
@@ -27,6 +27,39 @@ Bun.serve({ fetch: app.handler })
|
|
|
27
27
|
Full documentation and examples:
|
|
28
28
|
[github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
|
|
29
29
|
|
|
30
|
+
## Platform deployment contract
|
|
31
|
+
|
|
32
|
+
Deployment platforms (like Bunderhost) integrate with any bunderstack app
|
|
33
|
+
through env vars alone — no code changes required.
|
|
34
|
+
|
|
35
|
+
### Overrides (beat code-level config)
|
|
36
|
+
|
|
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 |
|
|
46
|
+
|
|
47
|
+
Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
|
|
48
|
+
code-level config wins over.
|
|
49
|
+
|
|
50
|
+
### Introspection
|
|
51
|
+
|
|
52
|
+
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`:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
process.env.BUNDERSTACK_INTROSPECT = '1'
|
|
58
|
+
const { app } = await import('./src/bunderstack')
|
|
59
|
+
console.log(JSON.stringify(app.manifest))
|
|
60
|
+
// { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
|
|
61
|
+
```
|
|
62
|
+
|
|
30
63
|
## Shipping TypeScript source
|
|
31
64
|
|
|
32
65
|
This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
package/src/config.ts
CHANGED
|
@@ -118,6 +118,12 @@ export type ResolvedConfig = {
|
|
|
118
118
|
export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
119
119
|
options: BunderstackConfig<TSchema>,
|
|
120
120
|
env?: BaseEnv,
|
|
121
|
+
// Platform-injected overrides (Bunderhost & co.) beat code-level config so
|
|
122
|
+
// apps with hardcoded local urls deploy unchanged.
|
|
123
|
+
platformSource: Record<string, string | undefined> = process.env as Record<
|
|
124
|
+
string,
|
|
125
|
+
string | undefined
|
|
126
|
+
>,
|
|
121
127
|
): ResolvedConfig {
|
|
122
128
|
const parsed = BunderstackOptionsSchema.parse(options)
|
|
123
129
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
@@ -127,8 +133,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
127
133
|
|
|
128
134
|
return {
|
|
129
135
|
database: {
|
|
130
|
-
url:
|
|
131
|
-
|
|
136
|
+
url:
|
|
137
|
+
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
138
|
+
parsed.database?.url ??
|
|
139
|
+
resolvedEnv.DATABASE_URL,
|
|
140
|
+
authToken:
|
|
141
|
+
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
142
|
+
parsed.database?.authToken ??
|
|
143
|
+
resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
132
144
|
migrations: parsed.database?.migrations ?? './migrations',
|
|
133
145
|
},
|
|
134
146
|
auth: (() => {
|
|
@@ -138,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
138
150
|
secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
|
|
139
151
|
}
|
|
140
152
|
})(),
|
|
141
|
-
storage: resolveBuckets(options.storage),
|
|
153
|
+
storage: resolveBuckets(options.storage, platformSource),
|
|
142
154
|
realtime: parsed.realtime,
|
|
143
155
|
}
|
|
144
156
|
}
|
package/src/env.ts
CHANGED
|
@@ -120,7 +120,10 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
|
|
|
120
120
|
validateSection(envConfig?.server, 'server', source, issues, userVars)
|
|
121
121
|
validateSection(envConfig?.client, 'client', source, issues, userVars)
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
// Introspection (Bunderhost builder) imports the app declaration to read
|
|
124
|
+
// its manifest; missing user env must not kill the boot there.
|
|
125
|
+
const lenient = source.BUNDERSTACK_INTROSPECT === '1'
|
|
126
|
+
if (issues.length > 0 && !lenient) throw new BunderstackEnvError(issues)
|
|
124
127
|
return { ...base, ...userVars } as ValidatedEnv<TEnv>
|
|
125
128
|
}
|
|
126
129
|
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveRealtimeRedisUrl } from './config'
|
|
|
16
16
|
import { detectDialect } from './dialect'
|
|
17
17
|
import { createEmail, emailProviderTag, type EmailFacade } from './email'
|
|
18
18
|
import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
|
|
19
|
+
import { buildManifest, type BunderstackManifest } from './manifest'
|
|
19
20
|
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
20
21
|
import { buildCrudRouter } from './crud'
|
|
21
22
|
import { createDb } from './db'
|
|
@@ -84,6 +85,8 @@ export type BunderstackApp<
|
|
|
84
85
|
env: ValidatedEnv<TEnv>
|
|
85
86
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
86
87
|
email: EmailFacade
|
|
88
|
+
/** Deploy-time introspection: what this app needs provisioned. */
|
|
89
|
+
manifest: BunderstackManifest
|
|
87
90
|
/**
|
|
88
91
|
* Type-only carrier for client inference (`createClient<typeof app>()`).
|
|
89
92
|
* Never assigned at runtime.
|
|
@@ -150,6 +153,15 @@ export async function createBunderstack<
|
|
|
150
153
|
dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
|
|
151
154
|
})
|
|
152
155
|
const config = resolveConfig(options, env)
|
|
156
|
+
// Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
|
|
157
|
+
// the app declaration only to read `app.manifest`. The boot must never touch
|
|
158
|
+
// the outside world — force an in-memory db (':memory:' is valid for both
|
|
159
|
+
// dialects) and skip Redis below. Env validation is already lenient (env.ts).
|
|
160
|
+
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
161
|
+
if (introspect) {
|
|
162
|
+
config.database.url = ':memory:'
|
|
163
|
+
config.database.authToken = undefined
|
|
164
|
+
}
|
|
153
165
|
const email = createEmail(options.email, { env })
|
|
154
166
|
// Merge bunderstack's internal tables (file-meta, idempotency) into the
|
|
155
167
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
@@ -180,9 +192,10 @@ export async function createBunderstack<
|
|
|
180
192
|
)
|
|
181
193
|
const realtimeBufferSize =
|
|
182
194
|
typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
|
|
183
|
-
const redisUrl =
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
const redisUrl =
|
|
196
|
+
config.realtime && !introspect
|
|
197
|
+
? resolveRealtimeRedisUrl(config.realtime, env)
|
|
198
|
+
: undefined
|
|
186
199
|
const broker = config.realtime
|
|
187
200
|
? redisUrl
|
|
188
201
|
? createRedisRealtimeBroker({
|
|
@@ -302,6 +315,13 @@ export async function createBunderstack<
|
|
|
302
315
|
env,
|
|
303
316
|
email,
|
|
304
317
|
trpcRouter,
|
|
318
|
+
manifest: buildManifest({
|
|
319
|
+
schema: options.schema,
|
|
320
|
+
dialect,
|
|
321
|
+
storage: config.storage,
|
|
322
|
+
envConfig: options.env as EnvConfigInput | undefined,
|
|
323
|
+
realtime: Boolean(config.realtime),
|
|
324
|
+
}),
|
|
305
325
|
}
|
|
306
326
|
|
|
307
327
|
// Hidden handle for the optional `bunderstack/provision` entry. Kept off the
|
|
@@ -328,6 +348,8 @@ export type {
|
|
|
328
348
|
} from './config'
|
|
329
349
|
export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
|
|
330
350
|
export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
|
|
351
|
+
export { buildManifest } from './manifest'
|
|
352
|
+
export type { BunderstackManifest, ManifestEnvVar } from './manifest'
|
|
331
353
|
export { createEmail } from './email'
|
|
332
354
|
export type {
|
|
333
355
|
EmailMessage,
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/manifest.ts — deploy-time introspection surface. Pure: consumes already
|
|
2
|
+
// resolved config pieces, never reads process.env or touches the network.
|
|
3
|
+
// Deployment platforms (Bunderhost) import the app declaration with
|
|
4
|
+
// BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
|
|
5
|
+
import type { ZodType } from 'zod'
|
|
6
|
+
|
|
7
|
+
import type { Dialect } from './dialect'
|
|
8
|
+
import type { EnvConfigInput } from './env'
|
|
9
|
+
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
10
|
+
|
|
11
|
+
export type ManifestEnvVar = { key: string; required: boolean }
|
|
12
|
+
|
|
13
|
+
export type BunderstackManifest = {
|
|
14
|
+
dialect: Dialect
|
|
15
|
+
tables: string[]
|
|
16
|
+
defaultBucket: string
|
|
17
|
+
buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
|
|
18
|
+
realtime: boolean
|
|
19
|
+
env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describeSection(
|
|
23
|
+
section: Record<string, ZodType> | undefined,
|
|
24
|
+
): ManifestEnvVar[] {
|
|
25
|
+
return Object.entries(section ?? {}).map(([key, schema]) => ({
|
|
26
|
+
key,
|
|
27
|
+
required: !schema.safeParse(undefined).success,
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildManifest(args: {
|
|
32
|
+
schema: Record<string, unknown>
|
|
33
|
+
dialect: Dialect
|
|
34
|
+
storage: ResolvedStorageBuckets
|
|
35
|
+
envConfig: EnvConfigInput | undefined
|
|
36
|
+
realtime: boolean
|
|
37
|
+
}): BunderstackManifest {
|
|
38
|
+
return {
|
|
39
|
+
dialect: args.dialect,
|
|
40
|
+
tables: Object.keys(args.schema),
|
|
41
|
+
defaultBucket: args.storage.defaultBucket,
|
|
42
|
+
buckets: [...args.storage.buckets.values()].map((bucket) => ({
|
|
43
|
+
name: bucket.name,
|
|
44
|
+
visibility: bucket.visibility,
|
|
45
|
+
})),
|
|
46
|
+
realtime: args.realtime,
|
|
47
|
+
env: {
|
|
48
|
+
server: describeSection(args.envConfig?.server),
|
|
49
|
+
client: describeSection(args.envConfig?.client),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/storage/buckets.ts
CHANGED
|
@@ -108,6 +108,32 @@ export function parseSize(value: string | number): number {
|
|
|
108
108
|
return Math.floor(num * multiplier)
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Platform override (Bunderhost & co.)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A deployment platform that injects BUNDERSTACK_S3_ENDPOINT forces every
|
|
117
|
+
* bucket onto that backend — code-level `local`/per-bucket `s3` blocks are
|
|
118
|
+
* ignored so apps deploy unchanged. Logical buckets already prefix object
|
|
119
|
+
* keys ("<bucket>/<uuid>"), so one physical bucket per environment suffices.
|
|
120
|
+
*/
|
|
121
|
+
function platformS3Backend(
|
|
122
|
+
env: Record<string, string | undefined>,
|
|
123
|
+
): ResolvedBackend | undefined {
|
|
124
|
+
const endpoint = env['BUNDERSTACK_S3_ENDPOINT']
|
|
125
|
+
if (!endpoint) return undefined
|
|
126
|
+
return {
|
|
127
|
+
type: 's3',
|
|
128
|
+
bucket: env['BUNDERSTACK_S3_BUCKET'] ?? '',
|
|
129
|
+
region: env['BUNDERSTACK_S3_REGION'] ?? 'auto',
|
|
130
|
+
endpoint,
|
|
131
|
+
accessKeyId: env['BUNDERSTACK_S3_ACCESS_KEY_ID'] ?? '',
|
|
132
|
+
secretAccessKey: env['BUNDERSTACK_S3_SECRET_ACCESS_KEY'] ?? '',
|
|
133
|
+
publicUrl: env['BUNDERSTACK_S3_PUBLIC_URL'],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
// ---------------------------------------------------------------------------
|
|
112
138
|
// Shared backend resolution
|
|
113
139
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +175,9 @@ function resolveBucketBackend(
|
|
|
149
175
|
sharedBackend: ResolvedBackend,
|
|
150
176
|
env: Record<string, string | undefined>,
|
|
151
177
|
): ResolvedBackend {
|
|
178
|
+
// Platform override active → sharedBackend IS the platform backend and
|
|
179
|
+
// code-level per-bucket backends are ignored.
|
|
180
|
+
if (env['BUNDERSTACK_S3_ENDPOINT']) return sharedBackend
|
|
152
181
|
if ('s3' in bucketInput && bucketInput.s3 !== undefined) {
|
|
153
182
|
const block = bucketInput.s3
|
|
154
183
|
return {
|
|
@@ -232,7 +261,7 @@ export function resolveBuckets(
|
|
|
232
261
|
input: StorageConfigInput | undefined,
|
|
233
262
|
env: Record<string, string | undefined> = process.env,
|
|
234
263
|
): ResolvedStorageBuckets {
|
|
235
|
-
const sharedBackend = resolveSharedBackend(input, env)
|
|
264
|
+
const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
|
|
236
265
|
const bucketsInput = input?.buckets
|
|
237
266
|
|
|
238
267
|
const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
|