@risingwave/wavelet-server 0.1.0
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 +25 -0
- package/src/__tests__/cursor-parsing.test.ts +68 -0
- package/src/__tests__/http-api.test.ts +130 -0
- package/src/__tests__/integration.test.ts +217 -0
- package/src/__tests__/jwt.test.ts +62 -0
- package/src/config-loader.ts +20 -0
- package/src/cursor-manager.ts +209 -0
- package/src/ddl-manager.ts +307 -0
- package/src/http-api.ts +199 -0
- package/src/index.ts +31 -0
- package/src/jwt.ts +56 -0
- package/src/server.ts +82 -0
- package/src/ws-fanout.ts +159 -0
- package/tsconfig.json +8 -0
package/src/http-api.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import pg from 'pg'
|
|
3
|
+
import type { StreamDef, ViewDef, SqlFragment } from '@risingwave/wavelet'
|
|
4
|
+
|
|
5
|
+
const { Client } = pg
|
|
6
|
+
|
|
7
|
+
export class HttpApi {
|
|
8
|
+
private client: InstanceType<typeof Client> | null = null
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private connectionString: string,
|
|
12
|
+
private streams: Record<string, StreamDef>,
|
|
13
|
+
private views: Record<string, ViewDef | SqlFragment>
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
17
|
+
// CORS
|
|
18
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
19
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
20
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
21
|
+
|
|
22
|
+
if (req.method === 'OPTIONS') {
|
|
23
|
+
res.writeHead(204)
|
|
24
|
+
res.end()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// POST /v1/streams/{name}
|
|
32
|
+
const streamMatch = url.pathname.match(/^\/v1\/streams\/([^/]+)$/)
|
|
33
|
+
if (streamMatch && req.method === 'POST') {
|
|
34
|
+
await this.handleWrite(streamMatch[1], req, res)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// POST /v1/streams/{name}/batch
|
|
39
|
+
const batchMatch = url.pathname.match(/^\/v1\/streams\/([^/]+)\/batch$/)
|
|
40
|
+
if (batchMatch && req.method === 'POST') {
|
|
41
|
+
await this.handleBatchWrite(batchMatch[1], req, res)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// GET /v1/views/{name}
|
|
46
|
+
const viewMatch = url.pathname.match(/^\/v1\/views\/([^/]+)$/)
|
|
47
|
+
if (viewMatch && req.method === 'GET') {
|
|
48
|
+
await this.handleRead(viewMatch[1], url, res)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// GET /v1/health
|
|
53
|
+
if (url.pathname === '/v1/health') {
|
|
54
|
+
this.json(res, 200, { status: 'ok' })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// GET /v1/views - list available views
|
|
59
|
+
if (url.pathname === '/v1/views' && req.method === 'GET') {
|
|
60
|
+
this.json(res, 200, { views: Object.keys(this.views) })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// GET /v1/streams - list available streams
|
|
65
|
+
if (url.pathname === '/v1/streams' && req.method === 'GET') {
|
|
66
|
+
this.json(res, 200, { streams: Object.keys(this.streams) })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.json(res, 404, {
|
|
71
|
+
error: 'Not found',
|
|
72
|
+
message: `${req.method} ${url.pathname} does not match any route.`,
|
|
73
|
+
routes: [
|
|
74
|
+
'GET /v1/health',
|
|
75
|
+
'GET /v1/views',
|
|
76
|
+
'GET /v1/views/{name}',
|
|
77
|
+
'GET /v1/streams',
|
|
78
|
+
'POST /v1/streams/{name}',
|
|
79
|
+
'POST /v1/streams/{name}/batch',
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
console.error('HTTP error:', err)
|
|
84
|
+
this.json(res, 500, { error: err.message })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async ensureClient(): Promise<InstanceType<typeof Client>> {
|
|
89
|
+
if (!this.client) {
|
|
90
|
+
this.client = new Client({ connectionString: this.connectionString })
|
|
91
|
+
await this.client.connect()
|
|
92
|
+
}
|
|
93
|
+
return this.client
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async handleWrite(streamName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
97
|
+
const stream = this.streams[streamName]
|
|
98
|
+
if (!stream) {
|
|
99
|
+
const available = Object.keys(this.streams)
|
|
100
|
+
this.json(res, 404, {
|
|
101
|
+
error: `Stream '${streamName}' not found.`,
|
|
102
|
+
available_streams: available,
|
|
103
|
+
})
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body = await this.readBody(req)
|
|
108
|
+
const data = JSON.parse(body)
|
|
109
|
+
|
|
110
|
+
const client = await this.ensureClient()
|
|
111
|
+
const columns = Object.keys(stream.columns)
|
|
112
|
+
const values = columns.map((col) => data[col])
|
|
113
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`)
|
|
114
|
+
|
|
115
|
+
await client.query(
|
|
116
|
+
`INSERT INTO ${streamName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
|
|
117
|
+
values
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
this.json(res, 200, { ok: true })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async handleBatchWrite(streamName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
124
|
+
const stream = this.streams[streamName]
|
|
125
|
+
if (!stream) {
|
|
126
|
+
const available = Object.keys(this.streams)
|
|
127
|
+
this.json(res, 404, {
|
|
128
|
+
error: `Stream '${streamName}' not found.`,
|
|
129
|
+
available_streams: available,
|
|
130
|
+
})
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const body = await this.readBody(req)
|
|
135
|
+
const events: any[] = JSON.parse(body)
|
|
136
|
+
|
|
137
|
+
if (!Array.isArray(events)) {
|
|
138
|
+
this.json(res, 400, { error: 'Batch endpoint expects a JSON array.' })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const client = await this.ensureClient()
|
|
143
|
+
const columns = Object.keys(stream.columns)
|
|
144
|
+
|
|
145
|
+
for (const data of events) {
|
|
146
|
+
const values = columns.map((col) => data[col])
|
|
147
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`)
|
|
148
|
+
await client.query(
|
|
149
|
+
`INSERT INTO ${streamName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
|
|
150
|
+
values
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.json(res, 200, { ok: true, count: events.length })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async handleRead(viewName: string, url: URL, res: ServerResponse): Promise<void> {
|
|
158
|
+
if (!this.views[viewName]) {
|
|
159
|
+
const available = Object.keys(this.views)
|
|
160
|
+
this.json(res, 404, {
|
|
161
|
+
error: `View '${viewName}' not found.`,
|
|
162
|
+
available_views: available,
|
|
163
|
+
})
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const client = await this.ensureClient()
|
|
168
|
+
|
|
169
|
+
// Build WHERE clause from query params
|
|
170
|
+
const params: string[] = []
|
|
171
|
+
const values: unknown[] = []
|
|
172
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
173
|
+
params.push(`${key} = $${params.length + 1}`)
|
|
174
|
+
values.push(value)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let sql = `SELECT * FROM ${viewName}`
|
|
178
|
+
if (params.length > 0) {
|
|
179
|
+
sql += ` WHERE ${params.join(' AND ')}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = await client.query(sql, values)
|
|
183
|
+
this.json(res, 200, { view: viewName, rows: result.rows })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private json(res: ServerResponse, status: number, data: unknown): void {
|
|
187
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
188
|
+
res.end(JSON.stringify(data))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private readBody(req: IncomingMessage): Promise<string> {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let body = ''
|
|
194
|
+
req.on('data', (chunk) => { body += chunk })
|
|
195
|
+
req.on('end', () => resolve(body))
|
|
196
|
+
req.on('error', reject)
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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 { HttpApi } from './http-api.js'
|
|
6
|
+
import { JwtVerifier } from './jwt.js'
|
|
7
|
+
|
|
8
|
+
export class WaveletServer {
|
|
9
|
+
private httpServer: ReturnType<typeof createServer> | null = null
|
|
10
|
+
private cursorManager: CursorManager
|
|
11
|
+
private fanout: WebSocketFanout
|
|
12
|
+
private httpApi: HttpApi
|
|
13
|
+
private jwt: JwtVerifier
|
|
14
|
+
|
|
15
|
+
constructor(private config: WaveletConfig) {
|
|
16
|
+
this.jwt = new JwtVerifier(config.jwt)
|
|
17
|
+
this.cursorManager = new CursorManager(config.database, config.views ?? {})
|
|
18
|
+
this.fanout = new WebSocketFanout(this.cursorManager, this.jwt, config.views ?? {})
|
|
19
|
+
this.httpApi = new HttpApi(config.database, config.streams ?? {}, config.views ?? {})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async start(): Promise<void> {
|
|
23
|
+
const port = this.config.server?.port ?? 8080
|
|
24
|
+
const host = this.config.server?.host ?? '0.0.0.0'
|
|
25
|
+
|
|
26
|
+
this.httpServer = createServer((req, res) => this.httpApi.handle(req, res))
|
|
27
|
+
this.fanout.attach(this.httpServer)
|
|
28
|
+
|
|
29
|
+
await this.cursorManager.initialize()
|
|
30
|
+
this.cursorManager.startPolling((viewName, diffs) => {
|
|
31
|
+
this.fanout.broadcast(viewName, diffs)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
await new Promise<void>((resolve) => {
|
|
35
|
+
this.httpServer!.listen(port, host, () => {
|
|
36
|
+
console.log(`Wavelet server listening on ${host}:${port}`)
|
|
37
|
+
console.log(`WebSocket: ws://${host}:${port}/subscribe/{view}`)
|
|
38
|
+
console.log(`HTTP API: http://${host}:${port}/v1/`)
|
|
39
|
+
resolve()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attach to an existing HTTP server instead of creating one.
|
|
46
|
+
* Used by Wavelet Cloud to run multiple instances on a shared port.
|
|
47
|
+
*/
|
|
48
|
+
async attachTo(server: Server, opts?: { pathPrefix?: string }): Promise<{
|
|
49
|
+
handleHttp: (req: IncomingMessage, res: ServerResponse) => void
|
|
50
|
+
}> {
|
|
51
|
+
const prefix = opts?.pathPrefix ?? ''
|
|
52
|
+
|
|
53
|
+
this.fanout.attach(server, prefix)
|
|
54
|
+
|
|
55
|
+
await this.cursorManager.initialize()
|
|
56
|
+
this.cursorManager.startPolling((viewName, diffs) => {
|
|
57
|
+
this.fanout.broadcast(viewName, diffs)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const handleHttp = (req: IncomingMessage, res: ServerResponse) => {
|
|
61
|
+
if (prefix && req.url?.startsWith(prefix)) {
|
|
62
|
+
req.url = req.url.slice(prefix.length) || '/'
|
|
63
|
+
}
|
|
64
|
+
this.httpApi.handle(req, res)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { handleHttp }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stop(): Promise<void> {
|
|
71
|
+
this.cursorManager.stopPolling()
|
|
72
|
+
this.fanout.closeAll()
|
|
73
|
+
await this.cursorManager.close()
|
|
74
|
+
await new Promise<void>((resolve) => {
|
|
75
|
+
if (this.httpServer) {
|
|
76
|
+
this.httpServer.close(() => resolve())
|
|
77
|
+
} else {
|
|
78
|
+
resolve()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/ws-fanout.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
2
|
+
import type { IncomingMessage, Server } from 'node:http'
|
|
3
|
+
import type { ViewDef, 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
|
+
viewName: 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() // viewName -> subscribers
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private cursorManager: CursorManager,
|
|
19
|
+
private jwt: JwtVerifier,
|
|
20
|
+
private views: Record<string, ViewDef | 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/{viewName}. ` +
|
|
58
|
+
`Available views: ${Object.keys(this.views).join(', ')}`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const viewName = match[1]
|
|
63
|
+
|
|
64
|
+
if (!this.views[viewName]) {
|
|
65
|
+
const available = Object.keys(this.views)
|
|
66
|
+
throw new Error(
|
|
67
|
+
`View '${viewName}' not found. Available views: ${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, viewName, claims }
|
|
84
|
+
|
|
85
|
+
if (!this.subscribers.has(viewName)) {
|
|
86
|
+
this.subscribers.set(viewName, new Set())
|
|
87
|
+
}
|
|
88
|
+
this.subscribers.get(viewName)!.add(subscriber)
|
|
89
|
+
|
|
90
|
+
ws.on('close', () => {
|
|
91
|
+
this.subscribers.get(viewName)?.delete(subscriber)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
ws.send(JSON.stringify({ type: 'connected', view: viewName }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
broadcast(viewName: string, diff: ViewDiff): void {
|
|
98
|
+
const subs = this.subscribers.get(viewName)
|
|
99
|
+
if (!subs || subs.size === 0) return
|
|
100
|
+
|
|
101
|
+
const viewDef = this.views[viewName]
|
|
102
|
+
const filterBy = this.getFilterBy(viewDef)
|
|
103
|
+
|
|
104
|
+
for (const sub of subs) {
|
|
105
|
+
if (sub.ws.readyState !== WebSocket.OPEN) continue
|
|
106
|
+
|
|
107
|
+
let filteredDiff = diff
|
|
108
|
+
if (filterBy && sub.claims) {
|
|
109
|
+
filteredDiff = this.filterDiff(diff, filterBy, sub.claims)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
filteredDiff.inserted.length === 0 &&
|
|
114
|
+
filteredDiff.updated.length === 0 &&
|
|
115
|
+
filteredDiff.deleted.length === 0
|
|
116
|
+
) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
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
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private filterDiff(diff: ViewDiff, filterBy: string, claims: JwtClaims): ViewDiff {
|
|
132
|
+
const claimValue = claims[filterBy]
|
|
133
|
+
if (claimValue === undefined) return diff
|
|
134
|
+
|
|
135
|
+
const filterFn = (row: Record<string, unknown>) =>
|
|
136
|
+
String(row[filterBy]) === String(claimValue)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
cursor: diff.cursor,
|
|
140
|
+
inserted: diff.inserted.filter(filterFn),
|
|
141
|
+
updated: diff.updated.filter(filterFn),
|
|
142
|
+
deleted: diff.deleted.filter(filterFn),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private getFilterBy(viewDef: ViewDef | SqlFragment): string | undefined {
|
|
147
|
+
if ('_tag' in viewDef && viewDef._tag === 'sql') return undefined
|
|
148
|
+
return (viewDef as ViewDef).filterBy
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
closeAll(): void {
|
|
152
|
+
for (const [, subs] of this.subscribers) {
|
|
153
|
+
for (const sub of subs) {
|
|
154
|
+
sub.ws.close(1001, 'Server shutting down')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.wss?.close()
|
|
158
|
+
}
|
|
159
|
+
}
|