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.
@@ -1,219 +0,0 @@
1
- import type { RealtimeAction, RealtimeBroker } from './index'
2
-
3
- // packages/bunderstack/src/realtime-redis.ts
4
- //
5
- // Redis-backed realtime broker: cross-instance fan-out + persistent replay log.
6
- //
7
- // Fan-out model: every instance SUBSCRIBEs one channel. publish() INCRs a global
8
- // counter (monotonic eventId across instances/restarts), LPUSH+LTRIM a capped log
9
- // for replay, then PUBLISHes. Redis delivers the message to ALL subscribers
10
- // including the publisher, so local delivery happens uniformly inside the channel
11
- // listener — never directly in publish() — to avoid double-delivery.
12
- import {
13
- checkAccessSync,
14
- rowMatchesScope,
15
- tableEntryForName,
16
- type AccessUser,
17
- type ResolvedAccess,
18
- } from '../access'
19
-
20
- export type RedisLike = {
21
- incr(key: string): Promise<number>
22
- publish(channel: string, message: string): Promise<unknown>
23
- subscribe(
24
- channel: string,
25
- listener: (message: string) => void,
26
- ): Promise<unknown>
27
- lpush(key: string, value: string): Promise<unknown>
28
- ltrim(key: string, start: number, stop: number): Promise<unknown>
29
- lrange(key: string, start: number, stop: number): Promise<string[]>
30
- close?(): void | Promise<void>
31
- }
32
-
33
- type Subscriber = {
34
- id: string
35
- send: (data: string) => void
36
- user: AccessUser | null
37
- activeOrganizationId: string | null
38
- subscriptions: Set<string>
39
- }
40
-
41
- type WireEvent = {
42
- eventId: number
43
- table: string
44
- action: RealtimeAction
45
- record: Record<string, unknown>
46
- }
47
-
48
- function isRecord(value: unknown): value is Record<string, unknown> {
49
- return value !== null && typeof value === 'object' && !Array.isArray(value)
50
- }
51
-
52
- function isRealtimeAction(value: unknown): value is RealtimeAction {
53
- return value === 'create' || value === 'update' || value === 'delete'
54
- }
55
-
56
- function parseWireEvent(raw: string): WireEvent | null {
57
- try {
58
- const value = JSON.parse(raw)
59
- if (
60
- !isRecord(value) ||
61
- typeof value.eventId !== 'number' ||
62
- typeof value.table !== 'string' ||
63
- !isRealtimeAction(value.action) ||
64
- !isRecord(value.record)
65
- ) {
66
- return null
67
- }
68
- return {
69
- eventId: value.eventId,
70
- table: value.table,
71
- action: value.action,
72
- record: value.record,
73
- }
74
- } catch {
75
- return null
76
- }
77
- }
78
-
79
- export function createRedisRealtimeBroker(opts: {
80
- access: ResolvedAccess
81
- /** A factory keeps Redis completely cold until SSE or a publish needs it. */
82
- redis: RedisLike | (() => RedisLike)
83
- bufferSize?: number
84
- channel?: string
85
- }): RealtimeBroker {
86
- const subscribers = new Map<string, Subscriber>()
87
- const bufferSize = opts.bufferSize ?? 1000
88
- const channel = opts.channel ?? 'bunderstack:realtime'
89
- const logKey = `${channel}:log`
90
- const counterKey = `${channel}:seq`
91
- let redis: RedisLike | undefined
92
-
93
- const getRedis = () => {
94
- redis ??= typeof opts.redis === 'function' ? opts.redis() : opts.redis
95
- return redis
96
- }
97
-
98
- function deliverable(
99
- s: Subscriber,
100
- table: string,
101
- record: Record<string, unknown>,
102
- ): boolean {
103
- const entry = tableEntryForName(opts.access, table)
104
- if (!entry) return false
105
- const id = record['id']
106
- const topicMatch =
107
- s.subscriptions.has(table) ||
108
- (id != null && s.subscriptions.has(`${table}/${String(id)}`))
109
- if (!topicMatch) return false
110
- const ctx = {
111
- user: s.user,
112
- request: new Request('http://realtime.local'),
113
- row: record,
114
- session: { activeOrganizationId: s.activeOrganizationId },
115
- }
116
- if (typeof entry.get === 'function') return false
117
- if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
118
- return false
119
- if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
120
- return false
121
- return true
122
- }
123
-
124
- function fanOut(evt: WireEvent) {
125
- const payload = JSON.stringify(evt)
126
- for (const s of subscribers.values()) {
127
- if (deliverable(s, evt.table, evt.record)) s.send(payload)
128
- }
129
- }
130
-
131
- let started: Promise<void> | undefined
132
- let closed = false
133
-
134
- const start = () => {
135
- if (closed)
136
- return Promise.reject(
137
- new Error('[bunderstack] realtime broker is closed'),
138
- )
139
- started ??= getRedis()
140
- .subscribe(channel, (message) => {
141
- const evt = parseWireEvent(message)
142
- if (evt) fanOut(evt)
143
- })
144
- .then(() => undefined)
145
- return started
146
- }
147
-
148
- return {
149
- start,
150
- async close() {
151
- closed = true
152
- subscribers.clear()
153
- await redis?.close?.()
154
- },
155
- register(send) {
156
- const id = crypto.randomUUID()
157
- subscribers.set(id, {
158
- id,
159
- send,
160
- user: null,
161
- activeOrganizationId: null,
162
- subscriptions: new Set(),
163
- })
164
- return { id }
165
- },
166
- async setContext(id, ctx) {
167
- const s = subscribers.get(id)
168
- if (!s) return { gap: false }
169
- s.user = ctx.user
170
- s.activeOrganizationId = ctx.activeOrganizationId
171
- s.subscriptions = ctx.subscriptions
172
-
173
- const since = ctx.since ?? null
174
- if (since == null) return { gap: false }
175
-
176
- // Log is LPUSH-ed (newest first); read newest->oldest, filter id>since.
177
- // If redis is unavailable, return { gap: true } so the client falls back
178
- // to a full refetch rather than 500-ing the POST handler.
179
- let raw: string[]
180
- try {
181
- raw = await getRedis().lrange(logKey, 0, bufferSize - 1)
182
- } catch {
183
- return { gap: true }
184
- }
185
- const events = raw
186
- .map(parseWireEvent)
187
- .filter((e) => e !== null)
188
- .filter((e) => e.eventId > since)
189
- .sort((a, b) => a.eventId - b.eventId)
190
-
191
- const oldestInLog = events.length ? events[0]!.eventId : since + 1
192
- const gap = oldestInLog > since + 1
193
- for (const e of events) {
194
- if (deliverable(s, e.table, e.record)) s.send(JSON.stringify(e))
195
- }
196
- return { gap }
197
- },
198
- unregister(id) {
199
- subscribers.delete(id)
200
- },
201
- async publish(table, action, record) {
202
- if (!tableEntryForName(opts.access, table)) return
203
- try {
204
- const client = getRedis()
205
- const eventId = await client.incr(counterKey)
206
- const evt: WireEvent = { eventId, table, action, record }
207
- const msg = JSON.stringify(evt)
208
- await client.lpush(logKey, msg)
209
- await client.ltrim(logKey, 0, bufferSize - 1)
210
- await client.publish(channel, msg)
211
- } catch {
212
- // Broadcast is best-effort: a redis blip must not reject the floating
213
- // promise (callers use `void broker?.publish(...)` with no .catch).
214
- // Correctness self-heals: reconnecting clients use the since/gap replay
215
- // path which issues a full refetch when events were missed.
216
- }
217
- },
218
- }
219
- }
package/src/routes.ts DELETED
@@ -1,137 +0,0 @@
1
- // src/routes.ts — mounting user-supplied Hono routes inside the app.
2
-
3
- /** A route as Hono reports it on `app.routes`. */
4
- export type DeclaredRoute = { method: string; path: string }
5
-
6
- const RESERVED_EXACT = ['/health', '/api/health', '/api/realtime'] as const
7
-
8
- const RESERVED_PREFIXES = [
9
- '/api/auth/',
10
- '/api/trpc/',
11
- '/api/files/',
12
- '/files/',
13
- ] as const
14
-
15
- /** The first path segment under `/api/`, or undefined when not under it. */
16
- function apiSegment(path: string): string | undefined {
17
- if (!path.startsWith('/api/')) return undefined
18
- return path.slice('/api/'.length).split('/')[0]
19
- }
20
-
21
- function collisionFor(
22
- route: DeclaredRoute,
23
- tableNames: readonly string[],
24
- ): string | undefined {
25
- const { path } = route
26
- if (RESERVED_EXACT.includes(path as (typeof RESERVED_EXACT)[number])) {
27
- return `it is reserved by bunderstack`
28
- }
29
- for (const prefix of RESERVED_PREFIXES) {
30
- if (path.startsWith(prefix)) {
31
- return `"${prefix}*" is reserved by bunderstack`
32
- }
33
- }
34
- const segment = apiSegment(path)
35
- if (segment === undefined) return undefined
36
- if (segment === '*' || segment.startsWith(':')) {
37
- return `a parameter or wildcard here would shadow every generated CRUD route`
38
- }
39
- if (tableNames.includes(segment)) {
40
- return `it collides with the generated CRUD route for table "${segment}"`
41
- }
42
- return undefined
43
- }
44
-
45
- /**
46
- * Throws when any declared route would collide with a bunderstack route.
47
- *
48
- * Custom routes are registered before the built-ins, so a collision silently
49
- * shadows core behaviour — including authentication. Failing at construction is
50
- * the cheapest place to find out.
51
- */
52
- export function validateCustomRoutes(
53
- routes: readonly DeclaredRoute[],
54
- tableNames: readonly string[],
55
- ): void {
56
- const problems: string[] = []
57
- for (const route of routes) {
58
- const reason = collisionFor(route, tableNames)
59
- if (reason) {
60
- problems.push(` ${route.method} ${route.path} — ${reason}`)
61
- }
62
- }
63
- if (problems.length === 0) return
64
- throw new Error(
65
- `[bunderstack] routes: ${problems.length} route(s) collide with bunderstack's own:\n${problems.join('\n')}\nChoose different paths.`,
66
- )
67
- }
68
-
69
- import type { Hono } from 'hono'
70
-
71
- import type { AccessUser, AuthSessionResolver } from './access'
72
- import type { DbFor } from './db'
73
- import type { EmailFacade } from './email'
74
- import type { JobsRuntimeFacade } from './jobs/define'
75
- import type { RealtimeFacade } from './realtime/facade'
76
- import type { AuthInstance, StorageFacade } from './index'
77
-
78
- import { resolveAccessUser, resolveSession } from './access'
79
-
80
- export type RouteContext<
81
- TSchema extends Record<string, unknown> = Record<string, unknown>,
82
- TEnvResult = Record<string, unknown>,
83
- > = {
84
- db: DbFor<TSchema>
85
- env: TEnvResult
86
- storage: StorageFacade
87
- email: EmailFacade
88
- jobs: JobsRuntimeFacade
89
- realtime: RealtimeFacade<TSchema>
90
- auth: AuthInstance
91
- /** Resolve the caller's session. Costs an auth round-trip; call only when needed. */
92
- getSession(
93
- request: Request,
94
- ): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
95
- /** Convenience wrapper over getSession when the organization is irrelevant. */
96
- getUser(request: Request): Promise<AccessUser | null>
97
- }
98
-
99
- /** Alias mirroring the JobContext / BunderstackJobContext pair. */
100
- export type BunderstackRouteContext<
101
- TSchema extends Record<string, unknown> = Record<string, unknown>,
102
- TEnvResult = Record<string, unknown>,
103
- > = RouteContext<TSchema, TEnvResult>
104
-
105
- export type RoutesBuilder<
106
- TSchema extends Record<string, unknown> = Record<string, unknown>,
107
- TEnvResult = Record<string, unknown>,
108
- > = (ctx: RouteContext<TSchema, TEnvResult>) => Hono
109
-
110
- export function createRouteContext<
111
- TSchema extends Record<string, unknown>,
112
- TEnvResult,
113
- >(deps: {
114
- db: DbFor<TSchema>
115
- env: TEnvResult
116
- storage: StorageFacade
117
- email: EmailFacade
118
- jobs: JobsRuntimeFacade
119
- realtime: RealtimeFacade<TSchema>
120
- auth: AuthInstance
121
- authResolver: AuthSessionResolver | undefined
122
- }): RouteContext<TSchema, TEnvResult> {
123
- return {
124
- db: deps.db,
125
- env: deps.env,
126
- storage: deps.storage,
127
- email: deps.email,
128
- jobs: deps.jobs,
129
- realtime: deps.realtime,
130
- auth: deps.auth,
131
- // Lazy on purpose: a webhook has no session, and resolving one eagerly
132
- // would spend an auth round-trip per request on a value nobody reads.
133
- getSession: (request) => resolveSession(deps.authResolver, request.headers),
134
- getUser: (request) => resolveAccessUser(deps.authResolver, request.headers),
135
- }
136
- }
137
-