bunderstack 0.16.0 → 0.17.0-beta.2

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/index.ts CHANGED
@@ -1,12 +1,19 @@
1
+ import type { AnyRouter as AnyORPCRouter } from '@orpc/server'
1
2
  // src/index.ts
2
- import type { AnyRouter } from '@trpc/server'
3
- import type { Hono as HonoType } from 'hono'
4
3
 
5
- import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
6
-
7
- import { getTableName, isTable } from 'drizzle-orm'
4
+ import { SmartCoercionHandlerPlugin } from '@orpc/json-schema'
5
+ import { OpenAPIGenerator, OpenAPIGeneratorError } from '@orpc/openapi'
6
+ import { OpenAPIHandler } from '@orpc/openapi/fetch'
7
+ import { RPCHandler } from '@orpc/server/fetch'
8
+ import { ValibotToJsonSchemaConverter } from '@orpc/valibot'
8
9
 
9
10
  import type { TableAccessInput } from './access'
11
+ import type {
12
+ CrudApiRouterFor,
13
+ MergeApiRouterTypes,
14
+ UnifiedApiRouter,
15
+ } from './api/types'
16
+ import type { RealtimeApiRouter } from './api/realtime-router'
10
17
  import type { DbFor } from './db'
11
18
  import type {
12
19
  BunderstackJobsBuilder,
@@ -19,11 +26,18 @@ import type {
19
26
  import type { StorageConfigInput } from './storage/buckets'
20
27
  import type { StorageAdapter } from './storage/index'
21
28
 
29
+ import { validateAndResolveAccess } from './access'
30
+ import { createApiBuilder } from './api/builder'
31
+ import { createApiContext } from './api/context'
32
+ import { buildCrudApiRouter } from './api/crud-router'
33
+ import { mergeOpenAPISpecs } from './api/openapi'
34
+ import { buildRealtimeApiRouter } from './api/realtime-router'
35
+ import { buildApiRouter } from './api/router'
36
+ import { buildStorageApiRouter } from './api/storage-router'
22
37
  import {
23
- resolveAccessUser,
24
- tableEntryForName,
25
- validateAndResolveAccess,
26
- } from './access'
38
+ buildApiRegistry,
39
+ normalizeForeignOpenAPISpec,
40
+ } from './api/registry'
27
41
  import {
28
42
  createAuth,
29
43
  toAuthSessionResolver,
@@ -31,7 +45,6 @@ import {
31
45
  } from './auth'
32
46
  import { resolveConfig, type BunderstackConfig } from './config'
33
47
  import { resolveRealtimeRedisUrl } from './config'
34
- import { buildCrudRouter } from './crud'
35
48
  import { createDb } from './db'
36
49
  import { detectDialect } from './dialect'
37
50
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
@@ -56,19 +69,18 @@ import {
56
69
  type RealtimeFacade,
57
70
  type RealtimeTransport,
58
71
  } from './realtime/facade'
59
- import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
60
- import { createRedisRealtimeBroker } from './realtime/redis'
61
- import { createRouteContext, validateCustomRoutes } from './routes'
72
+ import {
73
+ createMemoryRealtimePublisher,
74
+ createRedisRealtimePublisher,
75
+ } from './realtime/publisher'
62
76
  import { deleteFileWithDerivatives } from './storage/delete'
63
77
  import { deleteFileMetaRow, insertReadyFile } from './storage/file-meta'
64
78
  import { createBucketStorages } from './storage/registry'
65
- import { buildBucketStorageRouter } from './storage/router'
79
+ import { createStorageOperations } from './storage/operations'
66
80
  import { sweepOrphans } from './storage/sweep'
67
- import { createTRPC, type BunderstackTRPC } from './trpc'
68
81
 
69
82
  export type AuthInstance = ReturnType<typeof createAuth>
70
83
 
71
-
72
84
  function waitForWorkerShutdown(
73
85
  signal: AbortSignal,
74
86
  installProcessListeners: boolean,
@@ -153,16 +165,13 @@ export type BunderstackApp<
153
165
  TAccess extends Record<string, TableAccessInput> | undefined = undefined,
154
166
  TBuckets extends string = string,
155
167
  TEnv extends EnvConfigInput | undefined = undefined,
156
- TRouter = undefined,
157
168
  TJobsDefs extends JobsDefs | undefined = undefined,
169
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
158
170
  > = {
159
171
  handler: (req: Request) => Promise<Response>
160
172
  db: DbFor<TSchema>
161
173
  auth: AuthInstance
162
174
  storage: StorageFacade
163
- router: HonoType
164
- /** Raw tRPC router when the config declared one — escape hatch. */
165
- trpcRouter?: AnyRouter
166
175
  /** Validated env: bunderstack's base vars plus the config's `env` extension. */
167
176
  env: ValidatedEnv<TEnv>
168
177
  /** Email facade; always present — send() throws when email isn't configured. */
@@ -191,129 +200,40 @@ export type BunderstackApp<
191
200
  schema: TSchema
192
201
  access: TAccess
193
202
  buckets: TBuckets
194
- trpc: TRouter
203
+ api: MergeApiRouterTypes<
204
+ UnifiedApiRouter<CrudApiRouterFor<TSchema, TAccess>, TCustomApiRouter>,
205
+ RealtimeApiRouter
206
+ >
195
207
  }
196
208
  }
197
209
 
198
- // Overloads: the builder-callback form and the prebuilt-router/none form are
199
- // separate signatures so the callback's `t` parameter gets contextual typing
200
- // and the router type lands on `$inferClient` without conditional-type
201
- // inference (which breaks under contextual return types). `jobs` needs the
202
- // same split against BOTH trpc forms — a union parameter type (`TJobsDefs |
203
- // (callback => TJobsDefs)`) defeats inference (TS widens TJobsDefs to its
204
- // constraint when a function literal could match either union arm) — hence
205
- // four overloads covering the trpc × jobs option cross product.
206
- export function createBunderstack<
207
- TSchema extends Record<string, unknown>,
208
- const TAccess extends Record<string, TableAccessInput> | undefined =
209
- undefined,
210
- const TStorage extends StorageConfigInput | undefined = undefined,
211
- const TEnv extends EnvConfigInput | undefined = undefined,
212
- TRouter extends AnyRouter = AnyRouter,
213
- const TJobsDefs extends JobsDefs | undefined = undefined,
214
- >(
215
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
216
- /** Builder callback receiving the pre-wired `t` instance. */
217
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
218
- /** Builder callback receiving the pre-wired `j` instance. */
219
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
220
- },
221
- ): Promise<
222
- BunderstackApp<
223
- TSchema,
224
- TAccess,
225
- BucketNamesOf<TStorage>,
226
- TEnv,
227
- TRouter,
228
- TJobsDefs
229
- >
230
- >
231
210
  export function createBunderstack<
232
211
  TSchema extends Record<string, unknown>,
233
- const TAccess extends Record<string, TableAccessInput> | undefined =
234
- undefined,
212
+ const TAccess extends Record<string, TableAccessInput> | undefined = undefined,
235
213
  const TStorage extends StorageConfigInput | undefined = undefined,
236
214
  const TEnv extends EnvConfigInput | undefined = undefined,
237
- TRouter extends AnyRouter = AnyRouter,
238
215
  const TJobsDefs extends JobsDefs | undefined = undefined,
216
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
239
217
  >(
240
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
241
- /** Builder callback receiving the pre-wired `t` instance. */
242
- trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
243
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
244
- jobs?: TJobsDefs
218
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv, TCustomApiRouter> & {
219
+ jobs?: TJobsDefs | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs)
245
220
  },
246
- ): Promise<
247
- BunderstackApp<
248
- TSchema,
249
- TAccess,
250
- BucketNamesOf<TStorage>,
251
- TEnv,
252
- TRouter,
253
- TJobsDefs
254
- >
255
- >
256
- export function createBunderstack<
257
- TSchema extends Record<string, unknown>,
258
- const TAccess extends Record<string, TableAccessInput> | undefined =
259
- undefined,
260
- const TStorage extends StorageConfigInput | undefined = undefined,
261
- const TEnv extends EnvConfigInput | undefined = undefined,
262
- TRouter extends AnyRouter | undefined = undefined,
263
- const TJobsDefs extends JobsDefs | undefined = undefined,
264
- >(
265
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
266
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
267
- trpc?: TRouter
268
- /** Builder callback receiving the pre-wired `j` instance. */
269
- jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
270
- },
271
- ): Promise<
272
- BunderstackApp<
273
- TSchema,
274
- TAccess,
275
- BucketNamesOf<TStorage>,
276
- TEnv,
277
- TRouter,
278
- TJobsDefs
279
- >
280
- >
281
- export function createBunderstack<
221
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TJobsDefs, TCustomApiRouter>>
222
+ export async function createBunderstack<
282
223
  TSchema extends Record<string, unknown>,
283
224
  const TAccess extends Record<string, TableAccessInput> | undefined =
284
225
  undefined,
285
226
  const TStorage extends StorageConfigInput | undefined = undefined,
286
227
  const TEnv extends EnvConfigInput | undefined = undefined,
287
- TRouter extends AnyRouter | undefined = undefined,
288
- const TJobsDefs extends JobsDefs | undefined = undefined,
228
+ TCustomApiRouter extends AnyORPCRouter | undefined = undefined,
289
229
  >(
290
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
291
- /** Prebuilt tRPC router (escape hatch for multi-file setups). */
292
- trpc?: TRouter
293
- /** Prebuilt job definitions (escape hatch for multi-file setups). */
294
- jobs?: TJobsDefs
295
- },
296
- ): Promise<
297
- BunderstackApp<
230
+ options: BunderstackConfig<
298
231
  TSchema,
299
232
  TAccess,
300
- BucketNamesOf<TStorage>,
233
+ TStorage,
301
234
  TEnv,
302
- TRouter,
303
- TJobsDefs
304
- >
305
- >
306
- export async function createBunderstack<
307
- TSchema extends Record<string, unknown>,
308
- const TAccess extends Record<string, TableAccessInput> | undefined =
309
- undefined,
310
- const TStorage extends StorageConfigInput | undefined = undefined,
311
- const TEnv extends EnvConfigInput | undefined = undefined,
312
- >(
313
- options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
314
- trpc?:
315
- | AnyRouter
316
- | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
235
+ TCustomApiRouter
236
+ > & {
317
237
  jobs?:
318
238
  | JobsDefs
319
239
  | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => JobsDefs)
@@ -324,8 +244,8 @@ export async function createBunderstack<
324
244
  TAccess,
325
245
  BucketNamesOf<TStorage>,
326
246
  TEnv,
327
- AnyRouter | undefined,
328
- JobsDefs | undefined
247
+ JobsDefs | undefined,
248
+ TCustomApiRouter
329
249
  >
330
250
  > {
331
251
  const dialect = detectDialect(options.schema)
@@ -336,7 +256,7 @@ export async function createBunderstack<
336
256
  : undefined
337
257
  if (jobsDefs) validateJobsDefs(jobsDefs)
338
258
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
339
- // and everything downstream (config, email, trpc ctx) consumes the result.
259
+ // and everything downstream consumes the result.
340
260
  const env = validateEnv(options.env, {
341
261
  emailProvider: emailProviderTag(options.email),
342
262
  defaultDatabaseUrl:
@@ -388,81 +308,52 @@ export async function createBunderstack<
388
308
  typeof config.realtime === 'object'
389
309
  ? config.realtime.bufferSize
390
310
  : undefined
311
+ const realtimeResumeSeconds =
312
+ typeof config.realtime === 'object'
313
+ ? config.realtime.resumeSeconds
314
+ : undefined
391
315
  const configuredRedisUrl = config.realtime
392
316
  ? resolveRealtimeRedisUrl(config.realtime, env)
393
317
  : undefined
394
- const configuredRealtimeTransport: RealtimeTransport = !config.realtime
395
- ? 'disabled'
396
- : configuredRedisUrl
397
- ? 'redis'
398
- : 'memory'
399
318
  const redisUrl = introspect ? undefined : configuredRedisUrl
400
- const broker = config.realtime
319
+ const publisher = config.realtime
401
320
  ? redisUrl
402
- ? createRedisRealtimeBroker({
403
- access: resolvedAccess,
404
- channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
405
- redis: () => {
406
- // Redis pub/sub requires a dedicated connection (subscribe puts the client into
407
- // a restricted state). We use one client for commands and a second for subscribe.
408
- const cmdClient = new Bun.RedisClient(redisUrl)
409
- const subClient = new Bun.RedisClient(redisUrl)
410
- return {
411
- incr: (key: string) => cmdClient.incr(key),
412
- publish: (channel: string, message: string) =>
413
- cmdClient.publish(channel, message),
414
- subscribe: (channel: string, listener: (msg: string) => void) =>
415
- subClient.subscribe(channel, listener),
416
- lpush: (key: string, value: string) =>
417
- cmdClient.lpush(key, value),
418
- ltrim: (key: string, start: number, stop: number) =>
419
- cmdClient.ltrim(key, start, stop),
420
- lrange: (key: string, start: number, stop: number) =>
421
- cmdClient.lrange(key, start, stop),
422
- close: () => {
423
- cmdClient.close()
424
- subClient.close()
425
- },
426
- }
427
- },
428
- bufferSize: realtimeBufferSize,
429
- })
430
- : createRealtimeBroker({
431
- access: resolvedAccess,
432
- bufferSize: realtimeBufferSize,
321
+ ? (() => {
322
+ const redis = new Bun.RedisClient(redisUrl)
323
+ const subscriber = redis.duplicate()
324
+ lifecycle.add(async () => {
325
+ redis.close()
326
+ ;(await subscriber).close()
327
+ })
328
+ return createRedisRealtimePublisher(redis, subscriber, {
329
+ prefix:
330
+ process.env.BUNDERSTACK_REALTIME_PREFIX ?? 'bunderstack:',
331
+ maxBufferedEvents: realtimeBufferSize,
332
+ resumeSeconds: realtimeResumeSeconds,
333
+ })
334
+ })()
335
+ : createMemoryRealtimePublisher({
336
+ maxBufferedEvents: realtimeBufferSize,
337
+ resumeSeconds: realtimeResumeSeconds,
433
338
  })
434
339
  : undefined
435
- const runtimeRealtimeTransport: RealtimeTransport = !broker
340
+ const runtimeRealtimeTransport: RealtimeTransport = !publisher
436
341
  ? 'disabled'
437
342
  : redisUrl
438
343
  ? 'redis'
439
344
  : 'memory'
440
345
  const realtime = createRealtimeFacade<TSchema>(
441
- broker,
346
+ publisher,
442
347
  runtimeRealtimeTransport,
348
+ options.schema,
443
349
  )
444
- const crudRouter = buildCrudRouter(options.schema, userDb, {
445
- auth: authResolver,
446
- access: resolvedAccess,
447
- idempotency: options.idempotency,
448
- realtime,
449
- })
450
- const realtimeRouter = broker
451
- ? buildRealtimeRouter(broker, {
452
- auth: authResolver,
453
- keepaliveMs:
454
- typeof config.realtime === 'object'
455
- ? config.realtime.keepaliveMs
456
- : undefined,
457
- })
458
- : undefined
459
350
  const registry = createBucketStorages(config.storage)
460
- if (broker) lifecycle.add(() => broker.close())
461
- const storageRouter = buildBucketStorageRouter({
351
+ const storageOperations = createStorageOperations({
462
352
  registry,
463
353
  db,
464
- auth: authResolver,
465
354
  })
355
+ const storageApiRouter = buildStorageApiRouter(registry, storageOperations)
356
+ const realtimeApiRouter = buildRealtimeApiRouter(publisher, resolvedAccess)
466
357
  const storage: StorageFacade = {
467
358
  async delete(fileId) {
468
359
  const bucketName = fileId.split('/')[0] ?? ''
@@ -517,7 +408,7 @@ export async function createBunderstack<
517
408
  // ordinary cron now, so it inherits retries, timeout and onFailed.
518
409
  const resolvedDefs: JobsDefs | undefined = storageConfigured
519
410
  ? {
520
- ...(jobsDefs ?? {}),
411
+ ...jobsDefs,
521
412
  'bunderstack:storage-sweep': {
522
413
  kind: 'cron',
523
414
  schedule: '0 4 * * *',
@@ -546,7 +437,9 @@ export async function createBunderstack<
546
437
  return result
547
438
  },
548
439
  tick(now?: number) {
549
- return jobRunner ? jobRunner.tick(now) : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
440
+ return jobRunner
441
+ ? jobRunner.tick(now)
442
+ : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
550
443
  },
551
444
  }
552
445
  if (jobRunner) jobRunner.setJobsFacade(jobs)
@@ -605,58 +498,155 @@ export async function createBunderstack<
605
498
  await lifecycle.close()
606
499
  }
607
500
  }
608
- const trpcRouter: AnyRouter | undefined =
609
- typeof options.trpc === 'function'
610
- ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
611
- : options.trpc
612
- const trpcHandler = trpcRouter
613
- ? (req: Request) =>
614
- fetchRequestHandler({
615
- endpoint: '/api/trpc',
616
- req,
617
- router: trpcRouter,
618
- createContext: async () => ({
619
- db: userDb,
620
- user: await resolveAccessUser(authResolver, req.headers),
621
- env,
622
- email,
623
- jobs,
624
- realtime,
625
- storage,
626
- req,
627
- }),
628
- })
501
+ const crudApiRouter = buildCrudApiRouter(options.schema, userDb, {
502
+ access: resolvedAccess,
503
+ idempotency: options.idempotency,
504
+ realtime,
505
+ })
506
+
507
+ const customApiRouter = options.api
508
+ ? options.api(createApiBuilder<TSchema, ValidatedEnv<TEnv>>())
629
509
  : undefined
630
- const customRouter = options.routes
631
- ? (() => {
632
- const routeCtx = createRouteContext({
633
- db: userDb,
634
- env,
635
- storage,
636
- email,
637
- jobs,
638
- realtime,
639
- auth,
640
- authResolver,
641
- })
642
- const built = (
643
- options.routes as (ctx: unknown) => import('hono').Hono
644
- )(routeCtx)
645
- const enabledTables = Object.values(options.schema)
646
- .filter((table) => isTable(table))
647
- .map((table) => getTableName(table))
648
- .filter((name) => tableEntryForName(resolvedAccess, name)?.enabled)
649
- validateCustomRoutes(built.routes, enabledTables)
650
- return built
651
- })()
510
+
511
+ const nativeRouter = buildApiRouter({
512
+ crud: crudApiRouter as Record<string, unknown>,
513
+ storage: storageApiRouter as Record<string, unknown>,
514
+ realtime: realtimeApiRouter as Record<string, unknown> | undefined,
515
+ custom: customApiRouter as Record<string, unknown> | undefined,
516
+ }) as any
517
+
518
+ const authOpenAPISpecRaw =
519
+ options.openapi &&
520
+ auth.api &&
521
+ 'generateOpenAPISchema' in auth.api &&
522
+ typeof auth.api.generateOpenAPISchema === 'function'
523
+ ? await auth.api.generateOpenAPISchema()
524
+ : undefined
525
+
526
+ const authOpenAPISpec = authOpenAPISpecRaw
527
+ ? normalizeForeignOpenAPISpec(authOpenAPISpecRaw, {
528
+ prefix: '/api/auth',
529
+ source: 'auth',
530
+ })
531
+ : undefined
532
+
533
+ await buildApiRegistry({
534
+ nativeRouter,
535
+ foreignSpecs: authOpenAPISpec ? [authOpenAPISpec] : [],
536
+ reservedCoreHandles: new Set([
537
+ 'health',
538
+ ...(publisher ? ['realtime.changes'] : []),
539
+ ...[...registry.keys()].flatMap((name) =>
540
+ ['prepareUpload', 'upload', 'confirmUpload', 'download', 'delete'].map(
541
+ (operation) => `files.${name}.${operation}`,
542
+ ),
543
+ ),
544
+ ]),
545
+ })
546
+
547
+ const valibotConverter = new ValibotToJsonSchemaConverter()
548
+
549
+ const combinedOpenAPISpec = options.openapi
550
+ ? mergeOpenAPISpecs({
551
+ nativeSpec: await new OpenAPIGenerator({
552
+ converters: [
553
+ valibotConverter,
554
+ {
555
+ condition: (schema: any) =>
556
+ Boolean(
557
+ schema?.['~standard'] &&
558
+ !schema['~standard'].jsonSchema,
559
+ ),
560
+ convert: (schema: any) => {
561
+ const vendor = schema?.['~standard']?.vendor ?? 'unknown'
562
+ throw new OpenAPIGeneratorError(
563
+ `No JSON Schema converter is configured for Standard Schema vendor "${vendor}"`,
564
+ )
565
+ },
566
+ },
567
+ ],
568
+ }).generate(nativeRouter),
569
+ authSpec: authOpenAPISpec,
570
+ })
652
571
  : undefined
653
- const { handler, router } = buildHandler({
654
- customRouter,
655
- crudRouter,
572
+
573
+ const openapiHandler = new OpenAPIHandler(nativeRouter, {
574
+ // Query strings and form bodies are strings; this coerces them to the
575
+ // types each procedure's input schema declares, so schemas stay honest
576
+ // (`v.number()`, not a string-union pipe) and REST matches RPC.
577
+ plugins: [
578
+ new SmartCoercionHandlerPlugin({ converters: [valibotConverter] }),
579
+ ],
580
+ customErrorResponseBodyEncoder: (error: any) => ({
581
+ error: error.message,
582
+ code: error.data?.code ?? error.code,
583
+ // oRPC reports schema failures as `data.issues`; forwarding them tells
584
+ // the client which field was rejected instead of just "invalid".
585
+ ...(error.data?.details !== undefined
586
+ ? { details: error.data.details }
587
+ : error.data?.issues !== undefined
588
+ ? { details: error.data.issues }
589
+ : {}),
590
+ }),
591
+ fetchInterceptors: [
592
+ async (options) => {
593
+ const res = await options.next()
594
+ if (res.matched && options.context.resHeaders) {
595
+ options.context.resHeaders.forEach((v: string, k: string) =>
596
+ res.response.headers.set(k, v),
597
+ )
598
+ }
599
+ return res
600
+ },
601
+ ],
602
+ })
603
+ const rpcHandler = new RPCHandler(nativeRouter)
604
+
605
+ const apiHandler = async (req: Request): Promise<Response | null> => {
606
+ const urlString = typeof req === 'string' ? (req as string) : req.url
607
+ if (!urlString) return null
608
+ const url = new URL(urlString, 'http://localhost')
609
+ if (
610
+ combinedOpenAPISpec &&
611
+ url.pathname === '/api/openapi.json' &&
612
+ req.method === 'GET'
613
+ ) {
614
+ return new Response(JSON.stringify(combinedOpenAPISpec), {
615
+ headers: { 'Content-Type': 'application/json' },
616
+ })
617
+ }
618
+
619
+ const apiCtx = createApiContext(
620
+ {
621
+ db: userDb,
622
+ env,
623
+ storage,
624
+ email,
625
+ jobs,
626
+ realtime,
627
+ auth,
628
+ authResolver,
629
+ },
630
+ req,
631
+ )
632
+
633
+ if (url.pathname.startsWith('/api/rpc')) {
634
+ const res = await rpcHandler.handle(req, {
635
+ prefix: '/api/rpc',
636
+ context: apiCtx,
637
+ })
638
+ if (res.matched) return res.response
639
+ }
640
+
641
+ const openapiRes = await openapiHandler.handle(req, { context: apiCtx })
642
+ if (openapiRes.matched) return openapiRes.response
643
+
644
+ return null
645
+ }
646
+
647
+ const handler = buildHandler({
656
648
  authHandler: (req) => auth.handler(req),
657
- storageRouter,
658
- realtimeRouter,
659
- trpcHandler,
649
+ apiHandler,
660
650
  rateLimit: options.rateLimit,
661
651
  })
662
652
 
@@ -678,15 +668,14 @@ export async function createBunderstack<
678
668
  TAccess,
679
669
  BucketNamesOf<TStorage>,
680
670
  TEnv,
681
- AnyRouter | undefined,
682
- JobsDefs | undefined
671
+ JobsDefs | undefined,
672
+ TCustomApiRouter
683
673
  > = {
684
674
  handler,
685
675
  // Internal tables live on the runtime db but stay out of the public type.
686
676
  db: userDb,
687
677
  auth,
688
678
  storage,
689
- router,
690
679
  env,
691
680
  email,
692
681
  realtime,
@@ -702,7 +691,6 @@ export async function createBunderstack<
702
691
  return lifecycle.status
703
692
  },
704
693
  signal: lifecycle.signal,
705
- trpcRouter,
706
694
  manifest: buildManifest({
707
695
  schema: options.schema,
708
696
  dialect,
@@ -743,6 +731,8 @@ export async function createBunderstack<
743
731
  }
744
732
 
745
733
  export { MAX_LIST_LIMIT } from './list-query'
734
+ export { BunderstackError } from './errors'
735
+ export type { BunderstackErrorCode } from './errors'
746
736
  export { resolveConfig } from './config'
747
737
  export type {
748
738
  BetterAuthConfig,
@@ -760,8 +750,6 @@ export type {
760
750
  EmailConfigInput,
761
751
  EmailFacade,
762
752
  } from './email'
763
- export { createTRPC } from './trpc'
764
- export type { BunderstackTRPC, TRPCContext } from './trpc'
765
753
  export { createJobsBuilder } from './jobs/index'
766
754
  export type {
767
755
  BunderstackJobContext,
@@ -817,7 +805,7 @@ export type {
817
805
  export type { TransformSpec } from './storage/thumbnails'
818
806
  export { mockAuthSession } from './testing'
819
807
 
820
- export type { RealtimeAction } from './realtime/index'
808
+ export type { RealtimeAction } from './realtime/publisher'
821
809
  export { createRealtimeFacade } from './realtime/facade'
822
810
  export type {
823
811
  RealtimeFacade,
@@ -825,9 +813,22 @@ export type {
825
813
  SchemaTable,
826
814
  } from './realtime/facade'
827
815
 
816
+ export { createApiBuilder } from './api/builder'
817
+ export type { BunderstackApiBuilder, ApiFactory } from './api/builder'
818
+ // Needed to declare shared middleware over the app's context, e.g.
819
+ // `os.$context<ApiContext<typeof schema>>().middleware(...)`.
820
+ export type { ApiContext } from './api/context'
828
821
  export type {
829
- BunderstackRouteContext,
830
- RouteContext,
831
- RoutesBuilder,
832
- } from './routes'
833
-
822
+ CrudApiRouterFor,
823
+ ExposedApiTables,
824
+ MergeApiRouterTypes,
825
+ UnifiedApiRouter,
826
+ } from './api/types'
827
+ export type { TableCrudProcedures } from './api/crud-router'
828
+ export {
829
+ buildApiRegistry,
830
+ mergeApiRoutersStrict,
831
+ normalizeApiPath,
832
+ normalizeForeignOpenAPISpec,
833
+ } from './api/registry'
834
+ export { mergeOpenAPISpecs } from './api/openapi'