@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/package.json +11 -2
- package/src/__tests__/http-api.test.ts +23 -23
- package/src/__tests__/integration.test.ts +49 -17
- package/src/__tests__/ws-fanout.test.ts +143 -0
- package/src/cursor-manager.ts +133 -31
- package/src/ddl-manager.ts +72 -71
- package/src/http-api.ts +150 -71
- package/src/server.ts +15 -8
- package/src/webhook.ts +67 -0
- package/src/ws-fanout.ts +128 -42
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 {
|
|
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
|
-
|
|
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() //
|
|
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
|
|
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/{
|
|
58
|
-
`Available
|
|
59
|
+
`Invalid path: ${url.pathname}. Use /subscribe/{queryName}. ` +
|
|
60
|
+
`Available queries: ${Object.keys(this.queries).join(', ')}`
|
|
59
61
|
)
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
const
|
|
64
|
+
const queryName = match[1]
|
|
63
65
|
|
|
64
|
-
if (!this.
|
|
65
|
-
const available = Object.keys(this.
|
|
66
|
+
if (!this.queries[queryName]) {
|
|
67
|
+
const available = Object.keys(this.queries)
|
|
66
68
|
throw new Error(
|
|
67
|
-
`
|
|
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 = {
|
|
85
|
+
const subscriber: Subscriber = {
|
|
86
|
+
ws,
|
|
87
|
+
queryName,
|
|
88
|
+
claims,
|
|
89
|
+
ready: false,
|
|
90
|
+
pendingDiffs: [],
|
|
91
|
+
}
|
|
84
92
|
|
|
85
|
-
if (!this.subscribers.has(
|
|
86
|
-
this.subscribers.set(
|
|
93
|
+
if (!this.subscribers.has(queryName)) {
|
|
94
|
+
this.subscribers.set(queryName, new Set())
|
|
87
95
|
}
|
|
88
|
-
this.subscribers.get(
|
|
96
|
+
this.subscribers.get(queryName)!.add(subscriber)
|
|
89
97
|
|
|
90
98
|
ws.on('close', () => {
|
|
91
|
-
this.subscribers.get(
|
|
99
|
+
this.subscribers.get(queryName)?.delete(subscriber)
|
|
92
100
|
})
|
|
93
101
|
|
|
94
|
-
|
|
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(
|
|
98
|
-
const subs = this.subscribers.get(
|
|
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
|
-
|
|
108
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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(
|
|
147
|
-
if ('_tag' in
|
|
148
|
-
return (
|
|
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 {
|