@risingwave/wavelet-server 0.1.4 → 0.2.4

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/webhook.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import type { QueryDef, SqlFragment } from '@risingwave/wavelet'
3
+ import type { ViewDiff } from './cursor-manager.js'
4
+
5
+ const TIMEOUT_MS = 10000
6
+
7
+ export class WebhookFanout {
8
+ private webhooks: Map<string, string> = new Map() // queryName -> url
9
+
10
+ constructor(
11
+ queries: Record<string, QueryDef | SqlFragment>,
12
+ private signingSecret?: string
13
+ ) {
14
+ for (const [name, def] of Object.entries(queries)) {
15
+ if ('_tag' in def && def._tag === 'sql') continue
16
+ const qd = def as QueryDef
17
+ if (qd.webhook) {
18
+ this.webhooks.set(name, qd.webhook)
19
+ }
20
+ }
21
+ }
22
+
23
+ async broadcast(queryName: string, diff: ViewDiff): Promise<void> {
24
+ const url = this.webhooks.get(queryName)
25
+ if (!url) return
26
+
27
+ const body = JSON.stringify({
28
+ query: queryName,
29
+ cursor: diff.cursor,
30
+ inserted: diff.inserted,
31
+ updated: diff.updated,
32
+ deleted: diff.deleted,
33
+ })
34
+
35
+ const headers: Record<string, string> = {
36
+ 'Content-Type': 'application/json',
37
+ 'User-Agent': 'Wavelet-Webhook/1.0',
38
+ }
39
+
40
+ if (this.signingSecret) {
41
+ const signature = createHmac('sha256', this.signingSecret)
42
+ .update(body)
43
+ .digest('hex')
44
+ headers['X-Wavelet-Signature'] = `sha256=${signature}`
45
+ }
46
+
47
+ try {
48
+ const controller = new AbortController()
49
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
50
+
51
+ await fetch(url, {
52
+ method: 'POST',
53
+ headers,
54
+ body,
55
+ signal: controller.signal,
56
+ })
57
+
58
+ clearTimeout(timeout)
59
+ } catch (err: any) {
60
+ if (err.name === 'AbortError') {
61
+ console.error(`[webhook] Timeout sending to ${url} for query ${queryName}`)
62
+ } else {
63
+ console.error(`[webhook] Failed to send to ${url} for query ${queryName}:`, err.message)
64
+ }
65
+ }
66
+ }
67
+ }
package/src/ws-fanout.ts CHANGED
@@ -1,23 +1,25 @@
1
1
  import { WebSocketServer, WebSocket } from 'ws'
2
2
  import type { IncomingMessage, Server } from 'node:http'
3
- import type { ViewDef, SqlFragment } from '@risingwave/wavelet'
3
+ import type { QueryDef, SqlFragment } from '@risingwave/wavelet'
4
4
  import type { CursorManager, ViewDiff } from './cursor-manager.js'
5
5
  import type { JwtVerifier, JwtClaims } from './jwt.js'
6
6
 
7
7
  interface Subscriber {
8
8
  ws: WebSocket
9
- viewName: string
9
+ queryName: string
10
10
  claims: JwtClaims | null
11
+ ready: boolean
12
+ pendingDiffs: ViewDiff[]
11
13
  }
12
14
 
13
15
  export class WebSocketFanout {
14
16
  private wss: WebSocketServer | null = null
15
- private subscribers: Map<string, Set<Subscriber>> = new Map() // viewName -> subscribers
17
+ private subscribers: Map<string, Set<Subscriber>> = new Map() // queryName -> subscribers
16
18
 
17
19
  constructor(
18
20
  private cursorManager: CursorManager,
19
21
  private jwt: JwtVerifier,
20
- private views: Record<string, ViewDef | SqlFragment>
22
+ private queries: Record<string, QueryDef | SqlFragment>
21
23
  ) {}
22
24
 
23
25
  attach(server: Server, pathPrefix?: string): void {
@@ -54,17 +56,17 @@ export class WebSocketFanout {
54
56
 
55
57
  if (!match) {
56
58
  throw new Error(
57
- `Invalid path: ${url.pathname}. Use /subscribe/{viewName}. ` +
58
- `Available views: ${Object.keys(this.views).join(', ')}`
59
+ `Invalid path: ${url.pathname}. Use /subscribe/{queryName}. ` +
60
+ `Available queries: ${Object.keys(this.queries).join(', ')}`
59
61
  )
60
62
  }
61
63
 
62
- const viewName = match[1]
64
+ const queryName = match[1]
63
65
 
64
- if (!this.views[viewName]) {
65
- const available = Object.keys(this.views)
66
+ if (!this.queries[queryName]) {
67
+ const available = Object.keys(this.queries)
66
68
  throw new Error(
67
- `View '${viewName}' not found. Available views: ${available.join(', ')}`
69
+ `Query '${queryName}' not found. Available queries: ${available.join(', ')}`
68
70
  )
69
71
  }
70
72
 
@@ -80,57 +82,119 @@ export class WebSocketFanout {
80
82
  claims = await this.jwt.verify(token)
81
83
  }
82
84
 
83
- const subscriber: Subscriber = { ws, viewName, claims }
85
+ const subscriber: Subscriber = {
86
+ ws,
87
+ queryName,
88
+ claims,
89
+ ready: false,
90
+ pendingDiffs: [],
91
+ }
84
92
 
85
- if (!this.subscribers.has(viewName)) {
86
- this.subscribers.set(viewName, new Set())
93
+ if (!this.subscribers.has(queryName)) {
94
+ this.subscribers.set(queryName, new Set())
87
95
  }
88
- this.subscribers.get(viewName)!.add(subscriber)
96
+ this.subscribers.get(queryName)!.add(subscriber)
89
97
 
90
98
  ws.on('close', () => {
91
- this.subscribers.get(viewName)?.delete(subscriber)
99
+ this.subscribers.get(queryName)?.delete(subscriber)
92
100
  })
93
101
 
94
- ws.send(JSON.stringify({ type: 'connected', view: viewName }))
102
+ // Heartbeat: detect dead connections behind proxies/load balancers
103
+ const pingInterval = setInterval(() => {
104
+ if (ws.readyState === WebSocket.OPEN) {
105
+ ws.ping()
106
+ }
107
+ }, 30000)
108
+ ws.on('close', () => clearInterval(pingInterval))
109
+ ws.on('pong', () => { /* connection alive */ })
110
+
111
+ ws.send(JSON.stringify({ type: 'connected', query: queryName }))
112
+
113
+ const bootstrap = await this.cursorManager.bootstrap(queryName)
114
+ const snapshotRows = this.filterSnapshotRows(queryName, bootstrap.snapshotRows, claims)
115
+ ws.send(JSON.stringify({
116
+ type: 'snapshot',
117
+ query: queryName,
118
+ rows: snapshotRows,
119
+ }))
120
+
121
+ for (const diff of bootstrap.diffs) {
122
+ const filteredDiff = this.filterDiffForSubscriber(queryName, diff, claims)
123
+ if (this.isEmptyDiff(filteredDiff)) continue
124
+ if (ws.readyState !== WebSocket.OPEN) break
125
+ ws.send(this.serializeDiffMessage(queryName, filteredDiff))
126
+ }
127
+
128
+ const handoffCursor = bootstrap.lastCursor
129
+ subscriber.ready = true
130
+ for (const diff of subscriber.pendingDiffs) {
131
+ if (ws.readyState !== WebSocket.OPEN) break
132
+ if (handoffCursor && this.compareCursor(diff.cursor, handoffCursor) <= 0) {
133
+ continue
134
+ }
135
+ ws.send(this.serializeDiffMessage(queryName, diff))
136
+ }
137
+ subscriber.pendingDiffs = []
95
138
  }
96
139
 
97
- broadcast(viewName: string, diff: ViewDiff): void {
98
- const subs = this.subscribers.get(viewName)
140
+ broadcast(queryName: string, diff: ViewDiff): void {
141
+ const subs = this.subscribers.get(queryName)
99
142
  if (!subs || subs.size === 0) return
100
143
 
101
- const viewDef = this.views[viewName]
102
- const filterBy = this.getFilterBy(viewDef)
103
-
104
144
  for (const sub of subs) {
105
145
  if (sub.ws.readyState !== WebSocket.OPEN) continue
106
146
 
107
- let filteredDiff = diff
108
- if (filterBy && sub.claims) {
109
- filteredDiff = this.filterDiff(diff, filterBy, sub.claims)
110
- }
147
+ const filteredDiff = this.filterDiffForSubscriber(queryName, diff, sub.claims)
148
+ if (this.isEmptyDiff(filteredDiff)) continue
111
149
 
112
- if (
113
- filteredDiff.inserted.length === 0 &&
114
- filteredDiff.updated.length === 0 &&
115
- filteredDiff.deleted.length === 0
116
- ) {
150
+ if (!sub.ready) {
151
+ sub.pendingDiffs.push(filteredDiff)
117
152
  continue
118
153
  }
119
154
 
120
- sub.ws.send(JSON.stringify({
121
- type: 'diff',
122
- view: viewName,
123
- cursor: filteredDiff.cursor,
124
- inserted: filteredDiff.inserted,
125
- updated: filteredDiff.updated,
126
- deleted: filteredDiff.deleted,
127
- }))
155
+ sub.ws.send(this.serializeDiffMessage(queryName, filteredDiff))
128
156
  }
129
157
  }
130
158
 
159
+ private filterSnapshotRows(
160
+ queryName: string,
161
+ rows: Record<string, unknown>[],
162
+ claims: JwtClaims | null
163
+ ): Record<string, unknown>[] {
164
+ const queryDef = this.queries[queryName]
165
+ const filterBy = this.getFilterBy(queryDef)
166
+
167
+ if (filterBy && claims) {
168
+ const claimValue = claims[filterBy]
169
+ if (claimValue === undefined) return []
170
+
171
+ return rows.filter((row) => String(row[filterBy]) === String(claimValue))
172
+ }
173
+
174
+ return rows
175
+ }
176
+
177
+ private filterDiffForSubscriber(
178
+ queryName: string,
179
+ diff: ViewDiff,
180
+ claims: JwtClaims | null
181
+ ): ViewDiff {
182
+ const queryDef = this.queries[queryName]
183
+ const filterBy = this.getFilterBy(queryDef)
184
+
185
+ if (filterBy && claims) {
186
+ return this.filterDiff(diff, filterBy, claims)
187
+ }
188
+
189
+ return diff
190
+ }
191
+
131
192
  private filterDiff(diff: ViewDiff, filterBy: string, claims: JwtClaims): ViewDiff {
132
193
  const claimValue = claims[filterBy]
133
- if (claimValue === undefined) return diff
194
+ if (claimValue === undefined) {
195
+ // No matching claim -- return empty diff, not all data
196
+ return { cursor: diff.cursor, inserted: [], updated: [], deleted: [] }
197
+ }
134
198
 
135
199
  const filterFn = (row: Record<string, unknown>) =>
136
200
  String(row[filterBy]) === String(claimValue)
@@ -143,9 +207,31 @@ export class WebSocketFanout {
143
207
  }
144
208
  }
145
209
 
146
- private getFilterBy(viewDef: ViewDef | SqlFragment): string | undefined {
147
- if ('_tag' in viewDef && viewDef._tag === 'sql') return undefined
148
- return (viewDef as ViewDef).filterBy
210
+ private getFilterBy(queryDef: QueryDef | SqlFragment): string | undefined {
211
+ if ('_tag' in queryDef && queryDef._tag === 'sql') return undefined
212
+ return (queryDef as QueryDef).filterBy
213
+ }
214
+
215
+ private isEmptyDiff(diff: ViewDiff): boolean {
216
+ return diff.inserted.length === 0 && diff.updated.length === 0 && diff.deleted.length === 0
217
+ }
218
+
219
+ private serializeDiffMessage(queryName: string, diff: ViewDiff): string {
220
+ return JSON.stringify({
221
+ type: 'diff',
222
+ query: queryName,
223
+ cursor: diff.cursor,
224
+ inserted: diff.inserted,
225
+ updated: diff.updated,
226
+ deleted: diff.deleted,
227
+ })
228
+ }
229
+
230
+ private compareCursor(left: string, right: string): number {
231
+ const leftValue = BigInt(left)
232
+ const rightValue = BigInt(right)
233
+ if (leftValue === rightValue) return 0
234
+ return leftValue < rightValue ? -1 : 1
149
235
  }
150
236
 
151
237
  closeAll(): void {