@risingwave/wavelet-server 0.2.1 → 0.2.5
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/dist/__tests__/cursor-parsing.test.d.ts +2 -0
- package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
- package/dist/__tests__/cursor-parsing.test.js +64 -0
- package/dist/__tests__/cursor-parsing.test.js.map +1 -0
- package/dist/__tests__/http-api.test.d.ts +2 -0
- package/dist/__tests__/http-api.test.d.ts.map +1 -0
- package/dist/__tests__/http-api.test.js +135 -0
- package/dist/__tests__/http-api.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +229 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/jwt.test.d.ts +2 -0
- package/dist/__tests__/jwt.test.d.ts.map +1 -0
- package/dist/__tests__/jwt.test.js +86 -0
- package/dist/__tests__/jwt.test.js.map +1 -0
- package/dist/__tests__/ws-fanout.test.d.ts +2 -0
- package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
- package/dist/__tests__/ws-fanout.test.js +127 -0
- package/dist/__tests__/ws-fanout.test.js.map +1 -0
- package/dist/config-loader.d.ts +3 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config-loader.js +25 -0
- package/dist/config-loader.js.map +1 -0
- package/dist/cursor-manager.d.ts +54 -0
- package/dist/cursor-manager.d.ts.map +1 -0
- package/dist/cursor-manager.js +263 -0
- package/dist/cursor-manager.js.map +1 -0
- package/dist/ddl-manager.d.ts +33 -0
- package/dist/ddl-manager.d.ts.map +1 -0
- package/dist/ddl-manager.js +364 -0
- package/dist/ddl-manager.js.map +1 -0
- package/dist/http-api.d.ts +21 -0
- package/dist/http-api.d.ts.map +1 -0
- package/dist/http-api.js +242 -0
- package/dist/http-api.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +16 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +87 -0
- package/dist/jwt.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +82 -0
- package/dist/server.js.map +1 -0
- package/dist/webhook.d.ts +9 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +63 -0
- package/dist/webhook.js.map +1 -0
- package/dist/ws-fanout.d.ts +24 -0
- package/dist/ws-fanout.d.ts.map +1 -0
- package/dist/ws-fanout.js +198 -0
- package/dist/ws-fanout.js.map +1 -0
- package/package.json +16 -3
- package/src/__tests__/cursor-parsing.test.ts +0 -68
- package/src/__tests__/http-api.test.ts +0 -130
- package/src/__tests__/integration.test.ts +0 -217
- package/src/__tests__/jwt.test.ts +0 -62
- package/src/config-loader.ts +0 -28
- package/src/cursor-manager.ts +0 -209
- package/src/ddl-manager.ts +0 -408
- package/src/http-api.ts +0 -278
- package/src/index.ts +0 -31
- package/src/jwt.ts +0 -56
- package/src/server.ts +0 -89
- package/src/webhook.ts +0 -67
- package/src/ws-fanout.ts +0 -171
- package/tsconfig.json +0 -8
package/src/http-api.ts
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
-
import pg from 'pg'
|
|
3
|
-
import type { EventDef, QueryDef, SqlFragment } from '@risingwave/wavelet'
|
|
4
|
-
import type { JwtVerifier, JwtClaims } from './jwt.js'
|
|
5
|
-
|
|
6
|
-
const { Pool } = pg
|
|
7
|
-
|
|
8
|
-
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
|
9
|
-
|
|
10
|
-
export class HttpApi {
|
|
11
|
-
private pool: InstanceType<typeof Pool> | null = null
|
|
12
|
-
|
|
13
|
-
constructor(
|
|
14
|
-
private connectionString: string,
|
|
15
|
-
private events: Record<string, EventDef>,
|
|
16
|
-
private queries: Record<string, QueryDef | SqlFragment>,
|
|
17
|
-
private jwt?: JwtVerifier
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
21
|
-
// CORS
|
|
22
|
-
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
23
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
24
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
25
|
-
|
|
26
|
-
if (req.method === 'OPTIONS') {
|
|
27
|
-
res.writeHead(204)
|
|
28
|
-
res.end()
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
// POST /v1/events/{name}
|
|
36
|
-
const eventMatch = url.pathname.match(/^\/v1\/events\/([^/]+)$/)
|
|
37
|
-
if (eventMatch && req.method === 'POST') {
|
|
38
|
-
await this.handleWrite(eventMatch[1], req, res)
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// POST /v1/events/{name}/batch
|
|
43
|
-
const batchMatch = url.pathname.match(/^\/v1\/events\/([^/]+)\/batch$/)
|
|
44
|
-
if (batchMatch && req.method === 'POST') {
|
|
45
|
-
await this.handleBatchWrite(batchMatch[1], req, res)
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// GET /v1/queries/{name}
|
|
50
|
-
const queryMatch = url.pathname.match(/^\/v1\/queries\/([^/]+)$/)
|
|
51
|
-
if (queryMatch && req.method === 'GET') {
|
|
52
|
-
await this.handleRead(queryMatch[1], url, req, res)
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// GET /v1/health
|
|
57
|
-
if (url.pathname === '/v1/health') {
|
|
58
|
-
this.json(res, 200, { status: 'ok' })
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// GET /v1/queries - list available queries
|
|
63
|
-
if (url.pathname === '/v1/queries' && req.method === 'GET') {
|
|
64
|
-
this.json(res, 200, { queries: Object.keys(this.queries) })
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// GET /v1/events - list available events
|
|
69
|
-
if (url.pathname === '/v1/events' && req.method === 'GET') {
|
|
70
|
-
this.json(res, 200, { events: Object.keys(this.events) })
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
this.json(res, 404, {
|
|
75
|
-
error: 'Not found',
|
|
76
|
-
message: `${req.method} ${url.pathname} does not match any route.`,
|
|
77
|
-
routes: [
|
|
78
|
-
'GET /v1/health',
|
|
79
|
-
'GET /v1/queries',
|
|
80
|
-
'GET /v1/queries/{name}',
|
|
81
|
-
'GET /v1/events',
|
|
82
|
-
'POST /v1/events/{name}',
|
|
83
|
-
'POST /v1/events/{name}/batch',
|
|
84
|
-
],
|
|
85
|
-
})
|
|
86
|
-
} catch (err: any) {
|
|
87
|
-
console.error('HTTP error:', err)
|
|
88
|
-
this.json(res, 500, { error: err.message })
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private ensurePool(): InstanceType<typeof Pool> {
|
|
93
|
-
if (!this.pool) {
|
|
94
|
-
this.pool = new Pool({ connectionString: this.connectionString, max: 10 })
|
|
95
|
-
}
|
|
96
|
-
return this.pool
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private async handleWrite(eventName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
100
|
-
const eventDef = this.events[eventName]
|
|
101
|
-
if (!eventDef) {
|
|
102
|
-
const available = Object.keys(this.events)
|
|
103
|
-
this.json(res, 404, {
|
|
104
|
-
error: `Event '${eventName}' not found.`,
|
|
105
|
-
available_events: available,
|
|
106
|
-
})
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const body = await this.readBody(req)
|
|
111
|
-
const data = JSON.parse(body)
|
|
112
|
-
|
|
113
|
-
const pool = this.ensurePool()
|
|
114
|
-
const columns = Object.keys(eventDef.columns)
|
|
115
|
-
const values = columns.map((col) => data[col])
|
|
116
|
-
const placeholders = columns.map((_, i) => `$${i + 1}`)
|
|
117
|
-
|
|
118
|
-
await pool.query(
|
|
119
|
-
`INSERT INTO ${eventName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
|
|
120
|
-
values
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
this.json(res, 200, { ok: true })
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private async handleBatchWrite(eventName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
127
|
-
const eventDef = this.events[eventName]
|
|
128
|
-
if (!eventDef) {
|
|
129
|
-
const available = Object.keys(this.events)
|
|
130
|
-
this.json(res, 404, {
|
|
131
|
-
error: `Event '${eventName}' not found.`,
|
|
132
|
-
available_events: available,
|
|
133
|
-
})
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const body = await this.readBody(req)
|
|
138
|
-
const items: any[] = JSON.parse(body)
|
|
139
|
-
|
|
140
|
-
if (!Array.isArray(items)) {
|
|
141
|
-
this.json(res, 400, { error: 'Batch endpoint expects a JSON array.' })
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (items.length === 0) {
|
|
146
|
-
this.json(res, 200, { ok: true, count: 0 })
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const pool = this.ensurePool()
|
|
151
|
-
const columns = Object.keys(eventDef.columns)
|
|
152
|
-
|
|
153
|
-
// Build a single INSERT with multiple VALUE rows
|
|
154
|
-
const allValues: unknown[] = []
|
|
155
|
-
const rowPlaceholders: string[] = []
|
|
156
|
-
|
|
157
|
-
for (let i = 0; i < items.length; i++) {
|
|
158
|
-
const row = items[i]
|
|
159
|
-
const offset = i * columns.length
|
|
160
|
-
const ph = columns.map((_, j) => `$${offset + j + 1}`)
|
|
161
|
-
rowPlaceholders.push(`(${ph.join(', ')})`)
|
|
162
|
-
for (const col of columns) {
|
|
163
|
-
allValues.push(row[col])
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await pool.query(
|
|
168
|
-
`INSERT INTO ${eventName} (${columns.join(', ')}) VALUES ${rowPlaceholders.join(', ')}`,
|
|
169
|
-
allValues
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
this.json(res, 200, { ok: true, count: items.length })
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private async handleRead(queryName: string, url: URL, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
176
|
-
if (!this.queries[queryName]) {
|
|
177
|
-
const available = Object.keys(this.queries)
|
|
178
|
-
this.json(res, 404, {
|
|
179
|
-
error: `Query '${queryName}' not found.`,
|
|
180
|
-
available_queries: available,
|
|
181
|
-
})
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// JWT verification for queries with filterBy
|
|
186
|
-
const queryDef = this.queries[queryName]
|
|
187
|
-
const filterBy = this.getFilterBy(queryDef)
|
|
188
|
-
|
|
189
|
-
let claims: JwtClaims | null = null
|
|
190
|
-
if (filterBy && this.jwt?.isConfigured()) {
|
|
191
|
-
const token = url.searchParams.get('token')
|
|
192
|
-
?? req.headers.authorization?.replace('Bearer ', '')
|
|
193
|
-
|
|
194
|
-
if (!token) {
|
|
195
|
-
this.json(res, 401, { error: 'Authentication required for filtered queries.' })
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
claims = await this.jwt.verify(token)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const pool = this.ensurePool()
|
|
203
|
-
|
|
204
|
-
// Build WHERE clause: start with filterBy if applicable
|
|
205
|
-
const params: string[] = []
|
|
206
|
-
const values: unknown[] = []
|
|
207
|
-
|
|
208
|
-
if (filterBy && claims) {
|
|
209
|
-
const claimValue = claims[filterBy]
|
|
210
|
-
if (claimValue === undefined) {
|
|
211
|
-
// No matching claim -- return empty result, not all data
|
|
212
|
-
this.json(res, 200, { query: queryName, rows: [] })
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
values.push(String(claimValue))
|
|
216
|
-
params.push(`${filterBy} = $${values.length}`)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Add query params as additional filters, validating column names
|
|
220
|
-
const knownColumns = this.getQueryColumns(queryDef)
|
|
221
|
-
for (const [key, value] of url.searchParams.entries()) {
|
|
222
|
-
if (key === 'token') continue // skip JWT token param
|
|
223
|
-
if (knownColumns && !knownColumns.includes(key)) {
|
|
224
|
-
this.json(res, 400, { error: `Unknown column '${key}'. Known columns: ${knownColumns.join(', ')}` })
|
|
225
|
-
return
|
|
226
|
-
}
|
|
227
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
228
|
-
this.json(res, 400, { error: `Invalid column name: '${key}'` })
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
values.push(value)
|
|
232
|
-
params.push(`${key} = $${values.length}`)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
let sql = `SELECT * FROM ${queryName}`
|
|
236
|
-
if (params.length > 0) {
|
|
237
|
-
sql += ` WHERE ${params.join(' AND ')}`
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const result = await pool.query(sql, values)
|
|
241
|
-
this.json(res, 200, { query: queryName, rows: result.rows })
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private getFilterBy(queryDef: QueryDef | SqlFragment): string | undefined {
|
|
245
|
-
if ('_tag' in queryDef && queryDef._tag === 'sql') return undefined
|
|
246
|
-
return (queryDef as QueryDef).filterBy
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private getQueryColumns(queryDef: QueryDef | SqlFragment): string[] | null {
|
|
250
|
-
if ('_tag' in queryDef && queryDef._tag === 'sql') return null
|
|
251
|
-
const qd = queryDef as QueryDef
|
|
252
|
-
if (qd.columns) return Object.keys(qd.columns)
|
|
253
|
-
return null
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private json(res: ServerResponse, status: number, data: unknown): void {
|
|
257
|
-
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
258
|
-
res.end(JSON.stringify(data))
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private readBody(req: IncomingMessage): Promise<string> {
|
|
262
|
-
return new Promise((resolve, reject) => {
|
|
263
|
-
let body = ''
|
|
264
|
-
let size = 0
|
|
265
|
-
req.on('data', (chunk) => {
|
|
266
|
-
size += chunk.length
|
|
267
|
-
if (size > MAX_BODY_SIZE) {
|
|
268
|
-
req.destroy()
|
|
269
|
-
reject(new Error(`Request body exceeds ${MAX_BODY_SIZE / 1024 / 1024}MB limit`))
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
body += chunk
|
|
273
|
-
})
|
|
274
|
-
req.on('end', () => resolve(body))
|
|
275
|
-
req.on('error', reject)
|
|
276
|
-
})
|
|
277
|
-
}
|
|
278
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
export { WaveletServer } from './server.js'
|
|
2
|
-
export { loadConfig } from './config-loader.js'
|
|
3
|
-
export { DdlManager } from './ddl-manager.js'
|
|
4
|
-
export type { DdlAction } from './ddl-manager.js'
|
|
5
|
-
|
|
6
|
-
async function main() {
|
|
7
|
-
const { loadConfig } = await import('./config-loader.js')
|
|
8
|
-
const { WaveletServer } = await import('./server.js')
|
|
9
|
-
|
|
10
|
-
const configPath = process.argv[2] || './wavelet.config.ts'
|
|
11
|
-
const config = await loadConfig(configPath)
|
|
12
|
-
|
|
13
|
-
const server = new WaveletServer(config)
|
|
14
|
-
await server.start()
|
|
15
|
-
|
|
16
|
-
const shutdown = async () => {
|
|
17
|
-
console.log('\nShutting down...')
|
|
18
|
-
await server.stop()
|
|
19
|
-
process.exit(0)
|
|
20
|
-
}
|
|
21
|
-
process.on('SIGINT', shutdown)
|
|
22
|
-
process.on('SIGTERM', shutdown)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Only run main() when executed directly, not when imported as a library
|
|
26
|
-
if (require.main === module) {
|
|
27
|
-
main().catch((err) => {
|
|
28
|
-
console.error('Fatal:', err)
|
|
29
|
-
process.exit(1)
|
|
30
|
-
})
|
|
31
|
-
}
|
package/src/jwt.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import * as jose from 'jose'
|
|
2
|
-
|
|
3
|
-
export type JwtClaims = Record<string, unknown>
|
|
4
|
-
|
|
5
|
-
export class JwtVerifier {
|
|
6
|
-
private secret: Uint8Array | null = null
|
|
7
|
-
private jwksUrl: string | null = null
|
|
8
|
-
private issuer: string | undefined
|
|
9
|
-
private audience: string | undefined
|
|
10
|
-
|
|
11
|
-
constructor(config?: { secret?: string; jwksUrl?: string; issuer?: string; audience?: string }) {
|
|
12
|
-
if (config?.secret) {
|
|
13
|
-
this.secret = new TextEncoder().encode(config.secret)
|
|
14
|
-
}
|
|
15
|
-
if (config?.jwksUrl) {
|
|
16
|
-
this.jwksUrl = config.jwksUrl
|
|
17
|
-
}
|
|
18
|
-
this.issuer = config?.issuer
|
|
19
|
-
this.audience = config?.audience
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
isConfigured(): boolean {
|
|
23
|
-
return this.secret !== null || this.jwksUrl !== null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async verify(token: string): Promise<JwtClaims> {
|
|
27
|
-
try {
|
|
28
|
-
if (this.secret) {
|
|
29
|
-
const { payload } = await jose.jwtVerify(token, this.secret, {
|
|
30
|
-
issuer: this.issuer,
|
|
31
|
-
audience: this.audience,
|
|
32
|
-
})
|
|
33
|
-
return payload as JwtClaims
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (this.jwksUrl) {
|
|
37
|
-
const jwks = jose.createRemoteJWKSet(new URL(this.jwksUrl))
|
|
38
|
-
const { payload } = await jose.jwtVerify(token, jwks, {
|
|
39
|
-
issuer: this.issuer,
|
|
40
|
-
audience: this.audience,
|
|
41
|
-
})
|
|
42
|
-
return payload as JwtClaims
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
throw new Error('JWT verification not configured')
|
|
46
|
-
} catch (err: any) {
|
|
47
|
-
if (err.code === 'ERR_JWT_EXPIRED') {
|
|
48
|
-
throw new Error('Token expired. Please refresh your authentication token.')
|
|
49
|
-
}
|
|
50
|
-
if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
|
51
|
-
throw new Error('Invalid token signature. Check your JWT secret configuration.')
|
|
52
|
-
}
|
|
53
|
-
throw new Error(`Authentication failed: ${err.message}`)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
package/src/server.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http'
|
|
2
|
-
import type { WaveletConfig } from '@risingwave/wavelet'
|
|
3
|
-
import { CursorManager } from './cursor-manager.js'
|
|
4
|
-
import { WebSocketFanout } from './ws-fanout.js'
|
|
5
|
-
import { WebhookFanout } from './webhook.js'
|
|
6
|
-
import { HttpApi } from './http-api.js'
|
|
7
|
-
import { JwtVerifier } from './jwt.js'
|
|
8
|
-
|
|
9
|
-
export class WaveletServer {
|
|
10
|
-
private httpServer: ReturnType<typeof createServer> | null = null
|
|
11
|
-
private cursorManager: CursorManager
|
|
12
|
-
private fanout: WebSocketFanout
|
|
13
|
-
private webhooks: WebhookFanout
|
|
14
|
-
private httpApi: HttpApi
|
|
15
|
-
private jwt: JwtVerifier
|
|
16
|
-
|
|
17
|
-
constructor(private config: WaveletConfig) {
|
|
18
|
-
this.jwt = new JwtVerifier(config.jwt)
|
|
19
|
-
const queries = config.queries ?? config.views ?? {}
|
|
20
|
-
const events = config.events ?? config.streams ?? {}
|
|
21
|
-
this.cursorManager = new CursorManager(config.database, queries)
|
|
22
|
-
this.fanout = new WebSocketFanout(this.cursorManager, this.jwt, queries)
|
|
23
|
-
this.webhooks = new WebhookFanout(queries, config.jwt?.secret)
|
|
24
|
-
this.httpApi = new HttpApi(config.database, events, queries, this.jwt)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async start(): Promise<void> {
|
|
28
|
-
const port = this.config.server?.port ?? 8080
|
|
29
|
-
const host = this.config.server?.host ?? '0.0.0.0'
|
|
30
|
-
|
|
31
|
-
this.httpServer = createServer((req, res) => this.httpApi.handle(req, res))
|
|
32
|
-
this.fanout.attach(this.httpServer)
|
|
33
|
-
|
|
34
|
-
await this.cursorManager.initialize()
|
|
35
|
-
this.cursorManager.startPolling((queryName, diffs) => {
|
|
36
|
-
this.fanout.broadcast(queryName, diffs)
|
|
37
|
-
this.webhooks.broadcast(queryName, diffs)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
await new Promise<void>((resolve) => {
|
|
41
|
-
this.httpServer!.listen(port, host, () => {
|
|
42
|
-
console.log(`Wavelet server listening on ${host}:${port}`)
|
|
43
|
-
console.log(`WebSocket: ws://${host}:${port}/subscribe/{query}`)
|
|
44
|
-
console.log(`HTTP API: http://${host}:${port}/v1/`)
|
|
45
|
-
resolve()
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Attach to an existing HTTP server instead of creating one.
|
|
52
|
-
* Used by Wavelet Cloud to run multiple instances on a shared port.
|
|
53
|
-
*/
|
|
54
|
-
async attachTo(server: Server, opts?: { pathPrefix?: string }): Promise<{
|
|
55
|
-
handleHttp: (req: IncomingMessage, res: ServerResponse) => void
|
|
56
|
-
}> {
|
|
57
|
-
const prefix = opts?.pathPrefix ?? ''
|
|
58
|
-
|
|
59
|
-
this.fanout.attach(server, prefix)
|
|
60
|
-
|
|
61
|
-
await this.cursorManager.initialize()
|
|
62
|
-
this.cursorManager.startPolling((queryName, diffs) => {
|
|
63
|
-
this.fanout.broadcast(queryName, diffs)
|
|
64
|
-
this.webhooks.broadcast(queryName, diffs)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const handleHttp = (req: IncomingMessage, res: ServerResponse) => {
|
|
68
|
-
if (prefix && req.url?.startsWith(prefix)) {
|
|
69
|
-
req.url = req.url.slice(prefix.length) || '/'
|
|
70
|
-
}
|
|
71
|
-
this.httpApi.handle(req, res)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return { handleHttp }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async stop(): Promise<void> {
|
|
78
|
-
this.cursorManager.stopPolling()
|
|
79
|
-
this.fanout.closeAll()
|
|
80
|
-
await this.cursorManager.close()
|
|
81
|
-
await new Promise<void>((resolve) => {
|
|
82
|
-
if (this.httpServer) {
|
|
83
|
-
this.httpServer.close(() => resolve())
|
|
84
|
-
} else {
|
|
85
|
-
resolve()
|
|
86
|
-
}
|
|
87
|
-
})
|
|
88
|
-
}
|
|
89
|
-
}
|
package/src/webhook.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer, WebSocket } from 'ws'
|
|
2
|
-
import type { IncomingMessage, Server } from 'node:http'
|
|
3
|
-
import type { QueryDef, SqlFragment } from '@risingwave/wavelet'
|
|
4
|
-
import type { CursorManager, ViewDiff } from './cursor-manager.js'
|
|
5
|
-
import type { JwtVerifier, JwtClaims } from './jwt.js'
|
|
6
|
-
|
|
7
|
-
interface Subscriber {
|
|
8
|
-
ws: WebSocket
|
|
9
|
-
queryName: string
|
|
10
|
-
claims: JwtClaims | null
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export class WebSocketFanout {
|
|
14
|
-
private wss: WebSocketServer | null = null
|
|
15
|
-
private subscribers: Map<string, Set<Subscriber>> = new Map() // queryName -> subscribers
|
|
16
|
-
|
|
17
|
-
constructor(
|
|
18
|
-
private cursorManager: CursorManager,
|
|
19
|
-
private jwt: JwtVerifier,
|
|
20
|
-
private queries: Record<string, QueryDef | SqlFragment>
|
|
21
|
-
) {}
|
|
22
|
-
|
|
23
|
-
attach(server: Server, pathPrefix?: string): void {
|
|
24
|
-
this.wss = new WebSocketServer({ noServer: true })
|
|
25
|
-
|
|
26
|
-
server.on('upgrade', (req, socket, head) => {
|
|
27
|
-
const pathname = req.url?.split('?')[0] ?? ''
|
|
28
|
-
const subscribePrefix = (pathPrefix ?? '') + '/subscribe/'
|
|
29
|
-
|
|
30
|
-
if (pathname.startsWith(subscribePrefix)) {
|
|
31
|
-
this.wss!.handleUpgrade(req, socket, head, (ws) => {
|
|
32
|
-
this.wss!.emit('connection', ws, req)
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
this.wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => {
|
|
38
|
-
// Strip path prefix before handling
|
|
39
|
-
if (pathPrefix && req.url?.startsWith(pathPrefix)) {
|
|
40
|
-
req.url = req.url.slice(pathPrefix.length)
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
await this.handleConnection(ws, req)
|
|
44
|
-
} catch (err: any) {
|
|
45
|
-
ws.send(JSON.stringify({ error: err.message }))
|
|
46
|
-
ws.close(4000, err.message)
|
|
47
|
-
}
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private async handleConnection(ws: WebSocket, req: IncomingMessage): Promise<void> {
|
|
52
|
-
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
|
|
53
|
-
const match = url.pathname?.match(/^\/subscribe\/(.+)$/)
|
|
54
|
-
|
|
55
|
-
if (!match) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Invalid path: ${url.pathname}. Use /subscribe/{queryName}. ` +
|
|
58
|
-
`Available queries: ${Object.keys(this.queries).join(', ')}`
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const queryName = match[1]
|
|
63
|
-
|
|
64
|
-
if (!this.queries[queryName]) {
|
|
65
|
-
const available = Object.keys(this.queries)
|
|
66
|
-
throw new Error(
|
|
67
|
-
`Query '${queryName}' not found. Available queries: ${available.join(', ')}`
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Verify JWT if configured
|
|
72
|
-
let claims: JwtClaims | null = null
|
|
73
|
-
const token = url.searchParams.get('token')
|
|
74
|
-
?? req.headers.authorization?.replace('Bearer ', '')
|
|
75
|
-
|
|
76
|
-
if (this.jwt.isConfigured()) {
|
|
77
|
-
if (!token) {
|
|
78
|
-
throw new Error('Authentication required. Pass token as query param or Authorization header.')
|
|
79
|
-
}
|
|
80
|
-
claims = await this.jwt.verify(token)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const subscriber: Subscriber = { ws, queryName, claims }
|
|
84
|
-
|
|
85
|
-
if (!this.subscribers.has(queryName)) {
|
|
86
|
-
this.subscribers.set(queryName, new Set())
|
|
87
|
-
}
|
|
88
|
-
this.subscribers.get(queryName)!.add(subscriber)
|
|
89
|
-
|
|
90
|
-
ws.on('close', () => {
|
|
91
|
-
this.subscribers.get(queryName)?.delete(subscriber)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
// Heartbeat: detect dead connections behind proxies/load balancers
|
|
95
|
-
const pingInterval = setInterval(() => {
|
|
96
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
97
|
-
ws.ping()
|
|
98
|
-
}
|
|
99
|
-
}, 30000)
|
|
100
|
-
ws.on('close', () => clearInterval(pingInterval))
|
|
101
|
-
ws.on('pong', () => { /* connection alive */ })
|
|
102
|
-
|
|
103
|
-
ws.send(JSON.stringify({ type: 'connected', query: queryName }))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
broadcast(queryName: string, diff: ViewDiff): void {
|
|
107
|
-
const subs = this.subscribers.get(queryName)
|
|
108
|
-
if (!subs || subs.size === 0) return
|
|
109
|
-
|
|
110
|
-
const queryDef = this.queries[queryName]
|
|
111
|
-
const filterBy = this.getFilterBy(queryDef)
|
|
112
|
-
|
|
113
|
-
for (const sub of subs) {
|
|
114
|
-
if (sub.ws.readyState !== WebSocket.OPEN) continue
|
|
115
|
-
|
|
116
|
-
let filteredDiff = diff
|
|
117
|
-
if (filterBy && sub.claims) {
|
|
118
|
-
filteredDiff = this.filterDiff(diff, filterBy, sub.claims)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
filteredDiff.inserted.length === 0 &&
|
|
123
|
-
filteredDiff.updated.length === 0 &&
|
|
124
|
-
filteredDiff.deleted.length === 0
|
|
125
|
-
) {
|
|
126
|
-
continue
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
sub.ws.send(JSON.stringify({
|
|
130
|
-
type: 'diff',
|
|
131
|
-
query: queryName,
|
|
132
|
-
cursor: filteredDiff.cursor,
|
|
133
|
-
inserted: filteredDiff.inserted,
|
|
134
|
-
updated: filteredDiff.updated,
|
|
135
|
-
deleted: filteredDiff.deleted,
|
|
136
|
-
}))
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private filterDiff(diff: ViewDiff, filterBy: string, claims: JwtClaims): ViewDiff {
|
|
141
|
-
const claimValue = claims[filterBy]
|
|
142
|
-
if (claimValue === undefined) {
|
|
143
|
-
// No matching claim -- return empty diff, not all data
|
|
144
|
-
return { cursor: diff.cursor, inserted: [], updated: [], deleted: [] }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const filterFn = (row: Record<string, unknown>) =>
|
|
148
|
-
String(row[filterBy]) === String(claimValue)
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
cursor: diff.cursor,
|
|
152
|
-
inserted: diff.inserted.filter(filterFn),
|
|
153
|
-
updated: diff.updated.filter(filterFn),
|
|
154
|
-
deleted: diff.deleted.filter(filterFn),
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private getFilterBy(queryDef: QueryDef | SqlFragment): string | undefined {
|
|
159
|
-
if ('_tag' in queryDef && queryDef._tag === 'sql') return undefined
|
|
160
|
-
return (queryDef as QueryDef).filterBy
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
closeAll(): void {
|
|
164
|
-
for (const [, subs] of this.subscribers) {
|
|
165
|
-
for (const sub of subs) {
|
|
166
|
-
sub.ws.close(1001, 'Server shutting down')
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
this.wss?.close()
|
|
170
|
-
}
|
|
171
|
-
}
|