@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.
Files changed (71) hide show
  1. package/dist/__tests__/cursor-parsing.test.d.ts +2 -0
  2. package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
  3. package/dist/__tests__/cursor-parsing.test.js +64 -0
  4. package/dist/__tests__/cursor-parsing.test.js.map +1 -0
  5. package/dist/__tests__/http-api.test.d.ts +2 -0
  6. package/dist/__tests__/http-api.test.d.ts.map +1 -0
  7. package/dist/__tests__/http-api.test.js +135 -0
  8. package/dist/__tests__/http-api.test.js.map +1 -0
  9. package/dist/__tests__/integration.test.d.ts +2 -0
  10. package/dist/__tests__/integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/integration.test.js +229 -0
  12. package/dist/__tests__/integration.test.js.map +1 -0
  13. package/dist/__tests__/jwt.test.d.ts +2 -0
  14. package/dist/__tests__/jwt.test.d.ts.map +1 -0
  15. package/dist/__tests__/jwt.test.js +86 -0
  16. package/dist/__tests__/jwt.test.js.map +1 -0
  17. package/dist/__tests__/ws-fanout.test.d.ts +2 -0
  18. package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
  19. package/dist/__tests__/ws-fanout.test.js +127 -0
  20. package/dist/__tests__/ws-fanout.test.js.map +1 -0
  21. package/dist/config-loader.d.ts +3 -0
  22. package/dist/config-loader.d.ts.map +1 -0
  23. package/dist/config-loader.js +25 -0
  24. package/dist/config-loader.js.map +1 -0
  25. package/dist/cursor-manager.d.ts +54 -0
  26. package/dist/cursor-manager.d.ts.map +1 -0
  27. package/dist/cursor-manager.js +263 -0
  28. package/dist/cursor-manager.js.map +1 -0
  29. package/dist/ddl-manager.d.ts +33 -0
  30. package/dist/ddl-manager.d.ts.map +1 -0
  31. package/dist/ddl-manager.js +364 -0
  32. package/dist/ddl-manager.js.map +1 -0
  33. package/dist/http-api.d.ts +21 -0
  34. package/dist/http-api.d.ts.map +1 -0
  35. package/dist/http-api.js +242 -0
  36. package/dist/http-api.js.map +1 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +32 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/jwt.d.ts +16 -0
  42. package/dist/jwt.d.ts.map +1 -0
  43. package/dist/jwt.js +87 -0
  44. package/dist/jwt.js.map +1 -0
  45. package/dist/server.d.ts +24 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +82 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/webhook.d.ts +9 -0
  50. package/dist/webhook.d.ts.map +1 -0
  51. package/dist/webhook.js +63 -0
  52. package/dist/webhook.js.map +1 -0
  53. package/dist/ws-fanout.d.ts +24 -0
  54. package/dist/ws-fanout.d.ts.map +1 -0
  55. package/dist/ws-fanout.js +198 -0
  56. package/dist/ws-fanout.js.map +1 -0
  57. package/package.json +16 -3
  58. package/src/__tests__/cursor-parsing.test.ts +0 -68
  59. package/src/__tests__/http-api.test.ts +0 -130
  60. package/src/__tests__/integration.test.ts +0 -217
  61. package/src/__tests__/jwt.test.ts +0 -62
  62. package/src/config-loader.ts +0 -28
  63. package/src/cursor-manager.ts +0 -209
  64. package/src/ddl-manager.ts +0 -408
  65. package/src/http-api.ts +0 -278
  66. package/src/index.ts +0 -31
  67. package/src/jwt.ts +0 -56
  68. package/src/server.ts +0 -89
  69. package/src/webhook.ts +0 -67
  70. package/src/ws-fanout.ts +0 -171
  71. 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
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src"]
8
- }