dzql 0.1.0-alpha.1

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,251 @@
1
+ import { sql } from './db.js';
2
+
3
+ export function metaRoute() {
4
+ return async (req) => {
5
+ try {
6
+ // Get entity metadata from dzql.entities
7
+ const entities = await sql`
8
+ SELECT table_name, label_field, searchable_fields,
9
+ fk_includes, notification_paths, permission_paths
10
+ FROM dzql.entities
11
+ ORDER BY table_name
12
+ `;
13
+
14
+ // Analyze foreign key relationships to determine relationship types
15
+ const foreignKeys = await sql`
16
+ SELECT
17
+ tc.table_name,
18
+ kcu.column_name,
19
+ ccu.table_name AS foreign_table_name,
20
+ ccu.column_name AS foreign_column_name
21
+ FROM
22
+ information_schema.table_constraints AS tc
23
+ JOIN information_schema.key_column_usage AS kcu
24
+ ON tc.constraint_name = kcu.constraint_name
25
+ AND tc.table_schema = kcu.table_schema
26
+ JOIN information_schema.constraint_column_usage AS ccu
27
+ ON ccu.constraint_name = tc.constraint_name
28
+ AND ccu.table_schema = tc.table_schema
29
+ WHERE tc.constraint_type = 'FOREIGN KEY'
30
+ AND tc.table_schema = 'public'
31
+ ORDER BY tc.table_name, kcu.column_name
32
+ `;
33
+
34
+ // Get complete table schema information
35
+ const schemaInfo = await sql`
36
+ SELECT
37
+ table_name,
38
+ column_name,
39
+ data_type,
40
+ is_nullable,
41
+ column_default,
42
+ character_maximum_length,
43
+ numeric_precision,
44
+ numeric_scale,
45
+ ordinal_position
46
+ FROM information_schema.columns
47
+ WHERE table_schema = 'public'
48
+ AND table_name = ANY(${entities.map(e => e.table_name)})
49
+ ORDER BY table_name, ordinal_position
50
+ `;
51
+
52
+ // Find junction tables for many-to-many relationships
53
+ const junctionTables = new Set();
54
+ const entityNames = new Set(entities.map(e => e.table_name));
55
+
56
+ // Group foreign keys by table to identify junction tables
57
+ const fksByTable = {};
58
+ foreignKeys.forEach(fk => {
59
+ if (!entityNames.has(fk.table_name)) return;
60
+ if (!fksByTable[fk.table_name]) fksByTable[fk.table_name] = [];
61
+ fksByTable[fk.table_name].push(fk);
62
+ });
63
+
64
+ // Identify junction tables (2+ foreign keys, minimal other fields)
65
+ Object.keys(fksByTable).forEach(tableName => {
66
+ const fks = fksByTable[tableName];
67
+ if (fks.length >= 2) {
68
+ const entity = entities.find(e => e.table_name === tableName);
69
+ const searchableFields = entity?.searchable_fields || [];
70
+ const nonFkFields = searchableFields.filter(field =>
71
+ !fks.some(fk => fk.column_name === field)
72
+ );
73
+ if (nonFkFields.length <= 1) {
74
+ junctionTables.add(tableName);
75
+ }
76
+ }
77
+ });
78
+
79
+ // Build relations array
80
+ const relations = [];
81
+
82
+ // Add foreign key relationships (both directions)
83
+ foreignKeys.forEach(fk => {
84
+ if (!entityNames.has(fk.table_name) || !entityNames.has(fk.foreign_table_name)) return;
85
+
86
+ // Skip junction tables - they'll be handled as many-to-many
87
+ if (junctionTables.has(fk.table_name)) return;
88
+
89
+ // Many-to-one: child.foreign_key → parent.primary_key
90
+ relations.push({
91
+ type: 'many_to_one',
92
+ from: `${fk.table_name}.${fk.column_name}`,
93
+ to: `${fk.foreign_table_name}.${fk.foreign_column_name}`
94
+ });
95
+
96
+ // One-to-many: parent.primary_key ← child.foreign_key
97
+ relations.push({
98
+ type: 'one_to_many',
99
+ from: `${fk.foreign_table_name}.${fk.foreign_column_name}`,
100
+ to: `${fk.table_name}.${fk.column_name}`
101
+ });
102
+ });
103
+
104
+ // Add many-to-many relationships through junction tables
105
+ junctionTables.forEach(tableName => {
106
+ const fks = fksByTable[tableName];
107
+ // For each pair of foreign keys in the junction table
108
+ for (let i = 0; i < fks.length; i++) {
109
+ for (let j = i + 1; j < fks.length; j++) {
110
+ const fk1 = fks[i];
111
+ const fk2 = fks[j];
112
+
113
+ // Both directions of many-to-many
114
+ relations.push({
115
+ type: 'many_to_many',
116
+ from: `${fk1.foreign_table_name}.${fk1.foreign_column_name}`,
117
+ to: `${fk2.foreign_table_name}.${fk2.foreign_column_name}`,
118
+ via: `${tableName}.${fk1.column_name}.${fk2.column_name}`
119
+ });
120
+
121
+ relations.push({
122
+ type: 'many_to_many',
123
+ from: `${fk2.foreign_table_name}.${fk2.foreign_column_name}`,
124
+ to: `${fk1.foreign_table_name}.${fk1.foreign_column_name}`,
125
+ via: `${tableName}.${fk2.column_name}.${fk1.column_name}`
126
+ });
127
+ }
128
+ }
129
+ });
130
+
131
+ // Build schema object grouped by table
132
+ const schema = {};
133
+ schemaInfo.forEach(col => {
134
+ if (!schema[col.table_name]) {
135
+ schema[col.table_name] = [];
136
+ }
137
+ schema[col.table_name].push({
138
+ column_name: col.column_name,
139
+ data_type: col.data_type,
140
+ is_nullable: col.is_nullable === 'YES',
141
+ column_default: col.column_default,
142
+ character_maximum_length: col.character_maximum_length,
143
+ numeric_precision: col.numeric_precision,
144
+ numeric_scale: col.numeric_scale,
145
+ ordinal_position: col.ordinal_position
146
+ });
147
+ });
148
+
149
+ // Build navigation graph from user starting point
150
+ const navigationGraph = buildNavigationGraph(entities, relations, schema);
151
+
152
+ const metadata = {
153
+ entities: entities,
154
+ relations: relations,
155
+ schema: schema,
156
+ navigationGraph: navigationGraph,
157
+ operations: ['get', 'save', 'delete', 'lookup', 'search'],
158
+ timestamp: new Date().toISOString()
159
+ };
160
+
161
+ return new Response(JSON.stringify(metadata, null, 2), {
162
+ headers: { 'Content-Type': 'application/json' }
163
+ });
164
+ } catch (error) {
165
+ return new Response(JSON.stringify({ error: error.message }), {
166
+ status: 500,
167
+ headers: { 'Content-Type': 'application/json' }
168
+ });
169
+ }
170
+ };
171
+ }
172
+
173
+ function buildNavigationGraph(entities, relations, schema) {
174
+ const graph = {};
175
+
176
+ // Helper to detect UI patterns from schema
177
+ function getUiHints(entityName) {
178
+ const entitySchema = schema[entityName] || [];
179
+ const hints = { primary_view: 'table', alternate_view: 'form', temporal_fields: [], geo_fields: [] };
180
+
181
+ entitySchema.forEach(col => {
182
+ if (col.data_type.includes('date') || col.data_type.includes('time')) {
183
+ hints.temporal_fields.push(col.column_name);
184
+ }
185
+ if (col.column_name.toLowerCase().includes('address') ||
186
+ col.column_name.toLowerCase().includes('location')) {
187
+ hints.geo_fields.push(col.column_name);
188
+ hints.primary_view = 'map';
189
+ }
190
+ });
191
+
192
+ if (hints.temporal_fields.length > 0) {
193
+ hints.alternate_view = 'calendar';
194
+ }
195
+
196
+ return hints;
197
+ }
198
+
199
+ // Build navigation paths starting from user
200
+ function buildPathsFrom(currentEntity, currentPath, visited, maxDepth) {
201
+ if (maxDepth <= 0 || visited.has(currentEntity)) return;
202
+
203
+ visited.add(currentEntity);
204
+ const pathKey = currentPath.join('→');
205
+
206
+ if (!graph[pathKey]) {
207
+ const entity = entities.find(e => e.table_name === currentEntity);
208
+ graph[pathKey] = {
209
+ path: pathKey,
210
+ current_entity: currentEntity,
211
+ available_actions: entity ? Object.keys(entity.permission_paths) : [],
212
+ navigation_options: [],
213
+ ui_hints: getUiHints(currentEntity),
214
+ breadcrumb: currentPath.map(p => p.split('.')[0])
215
+ };
216
+ }
217
+
218
+ // Find all outgoing relations from current entity
219
+ relations.forEach(rel => {
220
+ const fromEntity = rel.from.split('.')[0];
221
+ const toEntity = rel.to.split('.')[0];
222
+
223
+ if (fromEntity === currentEntity && !visited.has(toEntity)) {
224
+ graph[pathKey].navigation_options.push({
225
+ to: toEntity,
226
+ via: `${rel.from}→${rel.to}`,
227
+ relationship: rel.type
228
+ });
229
+
230
+ // Recursively build paths
231
+ const newPath = [...currentPath, rel.to];
232
+ buildPathsFrom(toEntity, newPath, new Set(visited), maxDepth - 1);
233
+ }
234
+ });
235
+
236
+ visited.delete(currentEntity);
237
+ }
238
+
239
+ // Start building from user
240
+ const userRelations = relations.filter(rel => rel.from.startsWith('users.') || rel.to.startsWith('users.'));
241
+ if (userRelations.length === 0) {
242
+ // If no direct user relations, start from all entities
243
+ entities.forEach(entity => {
244
+ buildPathsFrom(entity.table_name, [entity.table_name + '.id'], new Set(), 3);
245
+ });
246
+ } else {
247
+ buildPathsFrom('users', ['users.id'], new Set(), 4);
248
+ }
249
+
250
+ return graph;
251
+ }
@@ -0,0 +1,464 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+ import {
3
+ callAuthFunction,
4
+ callUserFunction,
5
+ getUserProfile,
6
+ db,
7
+ } from "./db.js";
8
+ import { wsLogger, authLogger } from "./logger.js";
9
+
10
+ // Environment configuration
11
+ const JWT_SECRET_STRING = process.env.JWT_SECRET;
12
+ const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
13
+
14
+ // WebSocket ping/pong configuration (important for Heroku and other platforms)
15
+ // Heroku terminates WebSocket connections after 55 seconds of inactivity (H15 error)
16
+ // Default 30s interval keeps connections alive well within that limit
17
+ const WS_PING_INTERVAL = parseInt(process.env.WS_PING_INTERVAL || "30000", 10); // 30 seconds default
18
+ const WS_PING_TIMEOUT = parseInt(process.env.WS_PING_TIMEOUT || "5000", 10); // 5 seconds default
19
+ const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
20
+
21
+ // Validate JWT_SECRET in production
22
+ if (process.env.NODE_ENV === "production" && !JWT_SECRET_STRING) {
23
+ throw new Error(
24
+ "JWT_SECRET environment variable is required in production. Generate one with: openssl rand -base64 32"
25
+ );
26
+ }
27
+
28
+ // Warn if using default secret in development
29
+ if (!JWT_SECRET_STRING && process.env.NODE_ENV !== "test") {
30
+ console.warn(
31
+ "⚠️ WARNING: Using default JWT secret. Set JWT_SECRET environment variable for security."
32
+ );
33
+ }
34
+
35
+ const JWT_SECRET = new TextEncoder().encode(
36
+ JWT_SECRET_STRING || "dev-secret-at-least-32-chars-long!!"
37
+ );
38
+
39
+ // JWT helpers
40
+ export async function create_jwt(payload) {
41
+ return await new SignJWT(payload)
42
+ .setProtectedHeader({ alg: "HS256" })
43
+ .setIssuedAt()
44
+ .setExpirationTime(JWT_EXPIRES_IN)
45
+ .sign(JWT_SECRET);
46
+ }
47
+
48
+ export async function verify_jwt_token(token) {
49
+ try {
50
+ const { payload } = await jwtVerify(token, JWT_SECRET);
51
+ return payload;
52
+ } catch (error) {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // JSON-RPC helpers
58
+ export function create_rpc_response(id, result) {
59
+ return JSON.stringify({
60
+ jsonrpc: "2.0",
61
+ result,
62
+ id,
63
+ });
64
+ }
65
+
66
+ export function create_rpc_error(id, code, message, data = null) {
67
+ return JSON.stringify({
68
+ jsonrpc: "2.0",
69
+ error: { code, message, data },
70
+ id,
71
+ });
72
+ }
73
+
74
+ // SID (Session ID) Promise Management for bidirectional client-server communication
75
+ // Used for async operations where server requests data from client
76
+ const sidPromises = new Map();
77
+
78
+ /**
79
+ * Create a promise with a unique SID that can be resolved/rejected later
80
+ * @param {number} timeout - Timeout in milliseconds (default: 30000)
81
+ * @returns {Object} - { sid, promise }
82
+ */
83
+ export function createSIDPromise(timeout = 30000) {
84
+ const sid = crypto.randomUUID();
85
+
86
+ const promise = new Promise((resolve, reject) => {
87
+ // Store resolve/reject functions
88
+ sidPromises.set(sid, { resolve, reject });
89
+
90
+ // Set timeout
91
+ const timer = setTimeout(() => {
92
+ if (sidPromises.has(sid)) {
93
+ sidPromises.delete(sid);
94
+ reject(new Error(`SID request timeout after ${timeout}ms`));
95
+ }
96
+ }, timeout);
97
+
98
+ // Store timer so it can be cleared on resolution
99
+ const entry = sidPromises.get(sid);
100
+ if (entry) {
101
+ entry.timer = timer;
102
+ }
103
+ });
104
+
105
+ return { sid, promise };
106
+ }
107
+
108
+ /**
109
+ * Resolve a pending SID promise with a result
110
+ * @param {string} sid - The session ID
111
+ * @param {any} result - The result to resolve with
112
+ * @returns {boolean} - True if SID was found and resolved
113
+ */
114
+ export function resolveSID(sid, result) {
115
+ const entry = sidPromises.get(sid);
116
+ if (!entry) {
117
+ wsLogger.warn(`Attempted to resolve unknown SID: ${sid}`);
118
+ return false;
119
+ }
120
+
121
+ clearTimeout(entry.timer);
122
+ sidPromises.delete(sid);
123
+ entry.resolve(result);
124
+ wsLogger.debug(`SID resolved: ${sid}`);
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Reject a pending SID promise with an error
130
+ * @param {string} sid - The session ID
131
+ * @param {Error|string} error - The error to reject with
132
+ * @returns {boolean} - True if SID was found and rejected
133
+ */
134
+ export function rejectSID(sid, error) {
135
+ const entry = sidPromises.get(sid);
136
+ if (!entry) {
137
+ wsLogger.warn(`Attempted to reject unknown SID: ${sid}`);
138
+ return false;
139
+ }
140
+
141
+ clearTimeout(entry.timer);
142
+ sidPromises.delete(sid);
143
+ entry.reject(typeof error === 'string' ? new Error(error) : error);
144
+ wsLogger.debug(`SID rejected: ${sid}`);
145
+ return true;
146
+ }
147
+
148
+ // Create RPC handler function
149
+ export function createRPCHandler(customHandlers = {}) {
150
+ return async function handle_rpc(ws, message) {
151
+ let id = null;
152
+ let method = null;
153
+ const startTime = Date.now();
154
+
155
+ try {
156
+ const parsed = JSON.parse(message);
157
+ method = parsed.method;
158
+ const params = parsed.params;
159
+ id = parsed.id;
160
+
161
+ // Log incoming request
162
+ wsLogger.request(method, params);
163
+
164
+ // Handle SID responses from client (special internal method)
165
+ if (method === "_sid_response") {
166
+ const { sid, result, error } = params || {};
167
+ if (!sid) {
168
+ return create_rpc_error(id, -32602, "Missing sid parameter");
169
+ }
170
+
171
+ if (error) {
172
+ rejectSID(sid, error);
173
+ } else {
174
+ resolveSID(sid, result);
175
+ }
176
+
177
+ return create_rpc_response(id, { success: true });
178
+ }
179
+
180
+ // Validate method doesn't start with underscore (private)
181
+ if (method.startsWith("_")) {
182
+ wsLogger.warn(`Blocked private function call: ${method}`);
183
+ return create_rpc_error(id, -32601, "Cannot call private functions");
184
+ }
185
+
186
+ // Handle DZQL operations (require auth, identifiable by signature)
187
+ if (method.startsWith("dzql.")) {
188
+ if (!ws.data.user_id) {
189
+ return create_rpc_error(id, -32603, "Not authenticated");
190
+ }
191
+
192
+ const [, operation, entity] = method.split(".");
193
+ if (!operation || !entity) {
194
+ return create_rpc_error(
195
+ id,
196
+ -32602,
197
+ "Invalid DZQL method format. Use: dzql.operation.entity",
198
+ );
199
+ }
200
+
201
+ if (
202
+ !["get", "save", "delete", "lookup", "search"].includes(operation)
203
+ ) {
204
+ return create_rpc_error(
205
+ id,
206
+ -32602,
207
+ `Unknown DZQL operation: ${operation}`,
208
+ );
209
+ }
210
+
211
+ wsLogger.debug(`DZQL: Calling ${operation}.${entity} with params:`, JSON.stringify(params));
212
+ const result = await db.api[operation][entity](
213
+ params || {},
214
+ ws.data.user_id,
215
+ );
216
+ wsLogger.debug(`DZQL: ${operation}.${entity} returned successfully`);
217
+ return create_rpc_response(id, result);
218
+ }
219
+
220
+ // Local API functions that don't require auth
221
+ if (method === "login_user") {
222
+ authLogger.debug(`Login attempt for: ${params.email}`);
223
+ const data = await callAuthFunction(
224
+ "login_user",
225
+ params.email,
226
+ params.password,
227
+ );
228
+
229
+ // On successful auth, set user_id on WebSocket connection
230
+ if (data && data.user_id) {
231
+ ws.data.user_id = data.user_id;
232
+ authLogger.info(`User logged in: ${params.email} (id: ${data.user_id})`);
233
+
234
+ // Create JWT token for client storage
235
+ const token = await create_jwt({
236
+ user_id: data.user_id,
237
+ email: data.email,
238
+ });
239
+
240
+ // Get full profile
241
+ const profile = await getUserProfile(data.user_id);
242
+
243
+ const result = {
244
+ user_id: data.user_id,
245
+ email: data.email,
246
+ token,
247
+ profile,
248
+ };
249
+ wsLogger.response(method, result, Date.now() - startTime);
250
+ return create_rpc_response(id, result);
251
+ }
252
+
253
+ authLogger.warn(`Login failed for: ${params.email}`);
254
+ wsLogger.response(method, data, Date.now() - startTime);
255
+ return create_rpc_response(id, data);
256
+ }
257
+
258
+ if (method === "register_user") {
259
+ authLogger.debug(`Registration attempt for: ${params.email}`);
260
+ const data = await callAuthFunction(
261
+ "register_user",
262
+ params.email,
263
+ params.password,
264
+ );
265
+
266
+ // On successful registration, set user_id on WebSocket connection
267
+ if (data && data.user_id) {
268
+ ws.data.user_id = data.user_id;
269
+ authLogger.info(`User registered: ${params.email} (id: ${data.user_id})`);
270
+
271
+ // Create JWT token for client storage
272
+ const token = await create_jwt({
273
+ user_id: data.user_id,
274
+ email: data.email,
275
+ });
276
+
277
+ const result = {
278
+ user_id: data.user_id,
279
+ email: data.email,
280
+ token,
281
+ profile: data,
282
+ };
283
+ wsLogger.response(method, result, Date.now() - startTime);
284
+ return create_rpc_response(id, result);
285
+ }
286
+
287
+ authLogger.warn(`Registration failed for: ${params.email}`);
288
+ wsLogger.response(method, data, Date.now() - startTime);
289
+ return create_rpc_response(id, data);
290
+ }
291
+
292
+ // Everything else requires authentication
293
+ if (!ws.data.user_id) {
294
+ wsLogger.warn(`Unauthenticated request to: ${method}`);
295
+ return create_rpc_error(id, -32603, "Not authenticated");
296
+ }
297
+
298
+ // Authenticated-only local functions
299
+ if (method === "logout") {
300
+ authLogger.info(`User logged out (id: ${ws.data.user_id})`);
301
+ ws.data.user_id = null;
302
+ const result = { success: true };
303
+ wsLogger.response(method, result, Date.now() - startTime);
304
+ return create_rpc_response(id, result);
305
+ }
306
+
307
+ // Check for custom handlers
308
+ if (customHandlers[method]) {
309
+ wsLogger.debug(`Calling custom handler: ${method}`);
310
+ const result = await customHandlers[method](ws.data.user_id, params);
311
+ wsLogger.response(method, result, Date.now() - startTime);
312
+ return create_rpc_response(id, result);
313
+ }
314
+
315
+ // Call stored function with user_id as first parameter
316
+ wsLogger.debug(`Calling database function: ${method}`);
317
+ const result = await callUserFunction(method, ws.data.user_id, params);
318
+ wsLogger.response(method, result, Date.now() - startTime);
319
+ return create_rpc_response(id, result);
320
+ } catch (error) {
321
+ wsLogger.error(`RPC error in ${method}:`, error.message);
322
+ wsLogger.debug(`RPC error stack:`, error.stack);
323
+
324
+ // PostgreSQL error codes
325
+ if (error.code) {
326
+ wsLogger.debug(`Returning PostgreSQL error for id=${id}`);
327
+ return create_rpc_error(id, -32603, String(error), {
328
+ code: error.code,
329
+ });
330
+ }
331
+
332
+ // Generic error
333
+ wsLogger.debug(`Returning generic error for id=${id}: ${error.message}`);
334
+ return create_rpc_error(id, -32603, "Internal error", {
335
+ message: error.message,
336
+ });
337
+ }
338
+ };
339
+ }
340
+
341
+ // Create WebSocket event handlers
342
+ export function createWebSocketHandlers(options = {}) {
343
+ const {
344
+ rpcHandler = null,
345
+ customHandlers = {},
346
+ onConnection = null,
347
+ onDisconnection = null,
348
+ } = options;
349
+
350
+ // Active WebSocket connections
351
+ const connections = new Map();
352
+
353
+ // Create RPC handler if not provided
354
+ const handler = rpcHandler || createRPCHandler(customHandlers);
355
+
356
+ // Create broadcaster function
357
+ const broadcast = createBroadcaster(connections);
358
+
359
+ return {
360
+ connections,
361
+ broadcast,
362
+
363
+ // WebSocket configuration for Bun.serve
364
+ // These properties are required for proper ping/pong support (especially on Heroku)
365
+ perMessageDeflate: true,
366
+ maxPayloadLength: WS_MAX_MESSAGE_SIZE,
367
+ idleTimeout: WS_PING_INTERVAL / 1000, // Convert to seconds for Bun
368
+ closeOnBackpressureLimit: true, // Close connection if backpressure limit exceeded
369
+
370
+ // Connection opened
371
+ async open(ws) {
372
+ const id = crypto.randomUUID();
373
+ ws.data.connection_id = id;
374
+ connections.set(id, ws);
375
+
376
+ wsLogger.info(
377
+ `Connection opened: ${id.slice(0, 8)}...`,
378
+ ws.data.user_id ? `(user: ${ws.data.user_id})` : "(anonymous)",
379
+ );
380
+
381
+ // Get full profile if authenticated
382
+ let profile = null;
383
+ if (ws.data.user_id) {
384
+ try {
385
+ profile = await getUserProfile(ws.data.user_id);
386
+ } catch (error) {
387
+ wsLogger.error("Failed to load profile:", error.message);
388
+ }
389
+ }
390
+
391
+ // Send welcome message as JSON-RPC method call
392
+ ws.send(
393
+ JSON.stringify({
394
+ jsonrpc: "2.0",
395
+ method: "connected",
396
+ params: {
397
+ connection_id: id,
398
+ authenticated: !!ws.data.user_id,
399
+ profile,
400
+ },
401
+ }),
402
+ );
403
+
404
+ // Call custom connection handler
405
+ if (onConnection) {
406
+ onConnection(ws, id);
407
+ }
408
+ },
409
+
410
+ // Message received
411
+ async message(ws, message) {
412
+ const response = await handler(ws, message);
413
+ ws.send(response);
414
+ },
415
+
416
+ // Connection closed
417
+ close(ws) {
418
+ const id = ws.data.connection_id;
419
+ connections.delete(id);
420
+ wsLogger.info(`Connection closed: ${id?.slice(0, 8)}...`);
421
+
422
+ // Call custom disconnection handler
423
+ if (onDisconnection) {
424
+ onDisconnection(ws, id);
425
+ }
426
+ },
427
+
428
+ // Error occurred
429
+ error(ws, error) {
430
+ wsLogger.error(`WebSocket error for ${ws.data.connection_id?.slice(0, 8)}...:`, error.message);
431
+ },
432
+ };
433
+ }
434
+
435
+ // Broadcast message to all authenticated connections or specific client_ids
436
+ export function createBroadcaster(connections) {
437
+ return function broadcastToConnections(message, client_ids = null) {
438
+ if (client_ids && Array.isArray(client_ids)) {
439
+ // Send to specific user_ids
440
+ for (const [id, ws] of connections) {
441
+ if (ws.data.user_id && client_ids.includes(ws.data.user_id)) {
442
+ ws.send(message);
443
+ }
444
+ }
445
+ } else {
446
+ // Send to all authenticated connections
447
+ for (const [id, ws] of connections) {
448
+ if (ws.data.user_id) {
449
+ ws.send(message);
450
+ }
451
+ }
452
+ }
453
+ };
454
+ }
455
+
456
+ // Legacy export for backward compatibility
457
+ export function broadcastToConnections(connections, message) {
458
+ // Send to all authenticated connections
459
+ for (const [id, ws] of connections) {
460
+ if (ws.data.user_id) {
461
+ ws.send(message);
462
+ }
463
+ }
464
+ }