bunderstack 0.8.0 → 0.9.1

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 CHANGED
@@ -82,6 +82,32 @@ import { app } from './bunderstack'
82
82
  await app.runWorker()
83
83
  ```
84
84
 
85
+ If a queue handler calls `ctx.realtime.publish()`, the web and worker processes
86
+ must share a realtime transport. Configure `REDIS_URL` (or
87
+ `realtime: { redis: "redis://..." }`). `realtime: true` without Redis uses a
88
+ process-local memory broker and is suitable only when the worker is embedded
89
+ with `app.startWorker()`.
90
+
91
+ Since 0.9.0, `app.runWorker()` rejects that unsafe combination by default. If
92
+ queue handlers never publish realtime events, acknowledge the process-local
93
+ behavior with `app.runWorker({ allowProcessLocalRealtime: true })`.
94
+
95
+ Inspect the active runtime with `app.realtime.transport` (`'disabled'`,
96
+ `'memory'`, or `'redis'`). Deploy tooling can read the configured transport
97
+ from `app.manifest.realtimeTransport`.
98
+
99
+ ```ts
100
+ const app = await createBunderstack({
101
+ // ...
102
+ realtime: { redis: process.env.REDIS_URL! },
103
+ })
104
+ ```
105
+
106
+ ```bash
107
+ REDIS_URL=redis://localhost:6379 bun src/server.ts
108
+ REDIS_URL=redis://localhost:6379 bun src/worker.ts
109
+ ```
110
+
85
111
  Cron tasks (`j.cron()`) are delivered by the host to
86
112
  `POST /api/_bunderstack/cron/:name`; storage maintenance uses
87
113
  `POST /api/_bunderstack/maintenance/storage-sweep`. Production requires the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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/index.ts CHANGED
@@ -50,7 +50,11 @@ import {
50
50
  PROVISION_INTERNALS,
51
51
  type WithProvisionInternals,
52
52
  } from './provision-internals'
53
- import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
53
+ import {
54
+ createRealtimeFacade,
55
+ type RealtimeFacade,
56
+ type RealtimeTransport,
57
+ } from './realtime/facade'
54
58
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
55
59
  import { createRedisRealtimeBroker } from './realtime/redis'
56
60
  import { deleteFileWithDerivatives } from './storage/delete'
@@ -93,9 +97,9 @@ const DEFAULT_PENDING_TTL_MS = 30 * 60_000
93
97
  * must also clean the file-meta row.
94
98
  */
95
99
  export interface StorageFacade {
96
- /** Delete an object, its transform derivatives, and its file-meta row. `fileId` is `<bucket>/<id>`. */
100
+ /** Delete a file row and purge all underlying storage derivatives. */
97
101
  delete(fileId: string): Promise<void>
98
- /** Get the raw adapter for a bucket, or `undefined` if it isn't declared. */
102
+ /** Low-level access to the underlying storage adapter for a bucket. */
99
103
  bucket(name: string): StorageAdapter | undefined
100
104
  /**
101
105
  * Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
@@ -106,7 +110,15 @@ export interface StorageFacade {
106
110
  }
107
111
 
108
112
  export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
109
- export type AppRunWorkerOptions = AppStartWorkerOptions
113
+ export type AppRunWorkerOptions = AppStartWorkerOptions & {
114
+ /**
115
+ * Permit process-local realtime in a standalone worker.
116
+ *
117
+ * Use only when job handlers never call ctx.realtime.publish(). Publications
118
+ * made through the memory broker cannot reach SSE clients in another process.
119
+ */
120
+ allowProcessLocalRealtime?: boolean
121
+ }
110
122
  export type AppStartCronSchedulerOptions = Pick<
111
123
  LocalCronSchedulerOptions,
112
124
  'onError'
@@ -362,10 +374,15 @@ export async function createBunderstack<
362
374
  typeof config.realtime === 'object'
363
375
  ? config.realtime.bufferSize
364
376
  : undefined
365
- const redisUrl =
366
- config.realtime && !introspect
367
- ? resolveRealtimeRedisUrl(config.realtime, env)
368
- : undefined
377
+ const configuredRedisUrl = config.realtime
378
+ ? resolveRealtimeRedisUrl(config.realtime, env)
379
+ : undefined
380
+ const configuredRealtimeTransport: RealtimeTransport = !config.realtime
381
+ ? 'disabled'
382
+ : configuredRedisUrl
383
+ ? 'redis'
384
+ : 'memory'
385
+ const redisUrl = introspect ? undefined : configuredRedisUrl
369
386
  const broker = config.realtime
370
387
  ? redisUrl
371
388
  ? createRedisRealtimeBroker({
@@ -400,7 +417,15 @@ export async function createBunderstack<
400
417
  bufferSize: realtimeBufferSize,
401
418
  })
402
419
  : undefined
403
- const realtime = createRealtimeFacade<TSchema>(broker)
420
+ const runtimeRealtimeTransport: RealtimeTransport = !broker
421
+ ? 'disabled'
422
+ : redisUrl
423
+ ? 'redis'
424
+ : 'memory'
425
+ const realtime = createRealtimeFacade<TSchema>(
426
+ broker,
427
+ runtimeRealtimeTransport,
428
+ )
404
429
  const crudRouter = buildCrudRouter(options.schema, userDb, {
405
430
  auth: authResolver,
406
431
  access: resolvedAccess,
@@ -526,12 +551,24 @@ export async function createBunderstack<
526
551
  const runWorker = async (
527
552
  options: AppRunWorkerOptions = {},
528
553
  ): Promise<void> => {
529
- const handle = await startWorker(options)
554
+ if (
555
+ realtime.transport === 'memory' &&
556
+ !options.allowProcessLocalRealtime
557
+ ) {
558
+ throw new Error(
559
+ '[bunderstack] runWorker() cannot deliver realtime events through the in-memory broker. Configure REDIS_URL or realtime.redis, embed the worker with startWorker(), or pass allowProcessLocalRealtime: true only when jobs never publish realtime.',
560
+ )
561
+ }
562
+ const {
563
+ allowProcessLocalRealtime: _allowProcessLocalRealtime,
564
+ ...workerOptions
565
+ } = options
566
+ const handle = await startWorker(workerOptions)
530
567
  try {
531
- const signal = options.signal
532
- ? AbortSignal.any([lifecycle.signal, options.signal])
568
+ const signal = workerOptions.signal
569
+ ? AbortSignal.any([lifecycle.signal, workerOptions.signal])
533
570
  : lifecycle.signal
534
- await waitForWorkerShutdown(signal, !options.signal)
571
+ await waitForWorkerShutdown(signal, !workerOptions.signal)
535
572
  } finally {
536
573
  await handle.close()
537
574
  await lifecycle.close()
@@ -613,6 +650,7 @@ export async function createBunderstack<
613
650
  storage: config.storage,
614
651
  envConfig: options.env as EnvConfigInput | undefined,
615
652
  realtime: Boolean(config.realtime),
653
+ realtimeTransport: configuredRealtimeTransport,
616
654
  jobs: jobsDefs,
617
655
  }),
618
656
  }
@@ -724,4 +762,9 @@ export type {
724
762
  export type { TransformSpec } from './storage/thumbnails'
725
763
 
726
764
  export type { RealtimeAction } from './realtime/index'
727
- export type { RealtimeFacade, SchemaTable } from './realtime/facade'
765
+ export { createRealtimeFacade } from './realtime/facade'
766
+ export type {
767
+ RealtimeFacade,
768
+ RealtimeTransport,
769
+ SchemaTable,
770
+ } from './realtime/facade'
package/src/manifest.ts CHANGED
@@ -8,6 +8,7 @@ import { getTableName, isTable } from 'drizzle-orm'
8
8
  import type { Dialect } from './dialect'
9
9
  import type { EnvConfigInput } from './env'
10
10
  import type { JobsDefs } from './jobs/define'
11
+ import type { RealtimeTransport } from './realtime/facade'
11
12
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
12
13
  import {
13
14
  bunderstackCronRuns,
@@ -29,6 +30,7 @@ export type BunderstackManifest = {
29
30
  defaultBucket: string
30
31
  buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
31
32
  realtime: boolean
33
+ realtimeTransport: RealtimeTransport
32
34
  env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
33
35
  background: {
34
36
  jobs: { name: string }[]
@@ -60,6 +62,7 @@ export function buildManifest(args: {
60
62
  storage: ResolvedStorageBuckets
61
63
  envConfig: EnvConfigInput | undefined
62
64
  realtime: boolean
65
+ realtimeTransport: RealtimeTransport
63
66
  jobs: JobsDefs | undefined
64
67
  }): BunderstackManifest {
65
68
  return {
@@ -78,6 +81,7 @@ export function buildManifest(args: {
78
81
  visibility: bucket.visibility,
79
82
  })),
80
83
  realtime: args.realtime,
84
+ realtimeTransport: args.realtimeTransport,
81
85
  env: {
82
86
  server: describeSection(args.envConfig?.server),
83
87
  client: describeSection(args.envConfig?.client),
@@ -2,6 +2,8 @@ import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
2
2
 
3
3
  import type { RealtimeAction, RealtimeBroker } from './index'
4
4
 
5
+ export type RealtimeTransport = 'disabled' | 'memory' | 'redis'
6
+
5
7
  export type SchemaTable<TSchema extends Record<string, unknown>> = Extract<
6
8
  TSchema[keyof TSchema],
7
9
  Table
@@ -11,6 +13,7 @@ export interface RealtimeFacade<
11
13
  TSchema extends Record<string, unknown> = Record<string, unknown>,
12
14
  > {
13
15
  readonly enabled: boolean
16
+ readonly transport: RealtimeTransport
14
17
 
15
18
  publish<TTable extends SchemaTable<TSchema>>(
16
19
  table: TTable,
@@ -21,9 +24,22 @@ export interface RealtimeFacade<
21
24
 
22
25
  export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
23
26
  broker?: RealtimeBroker,
27
+ transport: RealtimeTransport = broker ? 'memory' : 'disabled',
24
28
  ): RealtimeFacade<TSchema> {
29
+ if (!broker && transport !== 'disabled') {
30
+ throw new Error(
31
+ '[bunderstack] an enabled realtime transport requires a broker',
32
+ )
33
+ }
34
+ if (broker && transport === 'disabled') {
35
+ throw new Error(
36
+ '[bunderstack] a realtime broker cannot use the disabled transport',
37
+ )
38
+ }
39
+
25
40
  return {
26
41
  enabled: broker !== undefined,
42
+ transport,
27
43
  async publish(table, action, record) {
28
44
  if (!broker) return
29
45
  await broker.publish(