@risingwave/wavelet-server 0.2.4 → 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 +7 -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 -249
- package/src/__tests__/jwt.test.ts +0 -62
- package/src/__tests__/ws-fanout.test.ts +0 -143
- package/src/config-loader.ts +0 -28
- package/src/cursor-manager.ts +0 -311
- 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 -245
- package/tsconfig.json +0 -8
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,245 +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
|
-
ready: boolean
|
|
12
|
-
pendingDiffs: ViewDiff[]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class WebSocketFanout {
|
|
16
|
-
private wss: WebSocketServer | null = null
|
|
17
|
-
private subscribers: Map<string, Set<Subscriber>> = new Map() // queryName -> subscribers
|
|
18
|
-
|
|
19
|
-
constructor(
|
|
20
|
-
private cursorManager: CursorManager,
|
|
21
|
-
private jwt: JwtVerifier,
|
|
22
|
-
private queries: Record<string, QueryDef | SqlFragment>
|
|
23
|
-
) {}
|
|
24
|
-
|
|
25
|
-
attach(server: Server, pathPrefix?: string): void {
|
|
26
|
-
this.wss = new WebSocketServer({ noServer: true })
|
|
27
|
-
|
|
28
|
-
server.on('upgrade', (req, socket, head) => {
|
|
29
|
-
const pathname = req.url?.split('?')[0] ?? ''
|
|
30
|
-
const subscribePrefix = (pathPrefix ?? '') + '/subscribe/'
|
|
31
|
-
|
|
32
|
-
if (pathname.startsWith(subscribePrefix)) {
|
|
33
|
-
this.wss!.handleUpgrade(req, socket, head, (ws) => {
|
|
34
|
-
this.wss!.emit('connection', ws, req)
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
this.wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => {
|
|
40
|
-
// Strip path prefix before handling
|
|
41
|
-
if (pathPrefix && req.url?.startsWith(pathPrefix)) {
|
|
42
|
-
req.url = req.url.slice(pathPrefix.length)
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
await this.handleConnection(ws, req)
|
|
46
|
-
} catch (err: any) {
|
|
47
|
-
ws.send(JSON.stringify({ error: err.message }))
|
|
48
|
-
ws.close(4000, err.message)
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private async handleConnection(ws: WebSocket, req: IncomingMessage): Promise<void> {
|
|
54
|
-
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
|
|
55
|
-
const match = url.pathname?.match(/^\/subscribe\/(.+)$/)
|
|
56
|
-
|
|
57
|
-
if (!match) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Invalid path: ${url.pathname}. Use /subscribe/{queryName}. ` +
|
|
60
|
-
`Available queries: ${Object.keys(this.queries).join(', ')}`
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const queryName = match[1]
|
|
65
|
-
|
|
66
|
-
if (!this.queries[queryName]) {
|
|
67
|
-
const available = Object.keys(this.queries)
|
|
68
|
-
throw new Error(
|
|
69
|
-
`Query '${queryName}' not found. Available queries: ${available.join(', ')}`
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Verify JWT if configured
|
|
74
|
-
let claims: JwtClaims | null = null
|
|
75
|
-
const token = url.searchParams.get('token')
|
|
76
|
-
?? req.headers.authorization?.replace('Bearer ', '')
|
|
77
|
-
|
|
78
|
-
if (this.jwt.isConfigured()) {
|
|
79
|
-
if (!token) {
|
|
80
|
-
throw new Error('Authentication required. Pass token as query param or Authorization header.')
|
|
81
|
-
}
|
|
82
|
-
claims = await this.jwt.verify(token)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const subscriber: Subscriber = {
|
|
86
|
-
ws,
|
|
87
|
-
queryName,
|
|
88
|
-
claims,
|
|
89
|
-
ready: false,
|
|
90
|
-
pendingDiffs: [],
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!this.subscribers.has(queryName)) {
|
|
94
|
-
this.subscribers.set(queryName, new Set())
|
|
95
|
-
}
|
|
96
|
-
this.subscribers.get(queryName)!.add(subscriber)
|
|
97
|
-
|
|
98
|
-
ws.on('close', () => {
|
|
99
|
-
this.subscribers.get(queryName)?.delete(subscriber)
|
|
100
|
-
})
|
|
101
|
-
|
|
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 = []
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
broadcast(queryName: string, diff: ViewDiff): void {
|
|
141
|
-
const subs = this.subscribers.get(queryName)
|
|
142
|
-
if (!subs || subs.size === 0) return
|
|
143
|
-
|
|
144
|
-
for (const sub of subs) {
|
|
145
|
-
if (sub.ws.readyState !== WebSocket.OPEN) continue
|
|
146
|
-
|
|
147
|
-
const filteredDiff = this.filterDiffForSubscriber(queryName, diff, sub.claims)
|
|
148
|
-
if (this.isEmptyDiff(filteredDiff)) continue
|
|
149
|
-
|
|
150
|
-
if (!sub.ready) {
|
|
151
|
-
sub.pendingDiffs.push(filteredDiff)
|
|
152
|
-
continue
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
sub.ws.send(this.serializeDiffMessage(queryName, filteredDiff))
|
|
156
|
-
}
|
|
157
|
-
}
|
|
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
|
-
|
|
192
|
-
private filterDiff(diff: ViewDiff, filterBy: string, claims: JwtClaims): ViewDiff {
|
|
193
|
-
const claimValue = claims[filterBy]
|
|
194
|
-
if (claimValue === undefined) {
|
|
195
|
-
// No matching claim -- return empty diff, not all data
|
|
196
|
-
return { cursor: diff.cursor, inserted: [], updated: [], deleted: [] }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const filterFn = (row: Record<string, unknown>) =>
|
|
200
|
-
String(row[filterBy]) === String(claimValue)
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
cursor: diff.cursor,
|
|
204
|
-
inserted: diff.inserted.filter(filterFn),
|
|
205
|
-
updated: diff.updated.filter(filterFn),
|
|
206
|
-
deleted: diff.deleted.filter(filterFn),
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
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
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
closeAll(): void {
|
|
238
|
-
for (const [, subs] of this.subscribers) {
|
|
239
|
-
for (const sub of subs) {
|
|
240
|
-
sub.ws.close(1001, 'Server shutting down')
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
this.wss?.close()
|
|
244
|
-
}
|
|
245
|
-
}
|