@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.
@@ -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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }