@gokiteam/goki-dev 0.2.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/README.md +478 -0
- package/bin/goki-dev.js +452 -0
- package/bin/mcp-server.js +16 -0
- package/bin/secrets-cli.js +302 -0
- package/cli/ComposeOverrideGenerator.js +226 -0
- package/cli/ComposeParser.js +73 -0
- package/cli/ConfigGenerator.js +304 -0
- package/cli/ConfigManager.js +46 -0
- package/cli/DatabaseManager.js +94 -0
- package/cli/DevToolsChecker.js +21 -0
- package/cli/DevToolsDir.js +66 -0
- package/cli/DevToolsManager.js +451 -0
- package/cli/DockerManager.js +138 -0
- package/cli/FunctionManager.js +95 -0
- package/cli/HttpProxyRewriter.js +91 -0
- package/cli/Logger.js +10 -0
- package/cli/McpConfigManager.js +123 -0
- package/cli/NgrokManager.js +431 -0
- package/cli/ProjectCLI.js +2322 -0
- package/cli/PubSubManager.js +129 -0
- package/cli/SnapshotManager.js +88 -0
- package/cli/UiFormatter.js +292 -0
- package/cli/WebhookUrlRewriter.js +32 -0
- package/cli/secrets/BiometricAuth.js +125 -0
- package/cli/secrets/SecretInjector.js +47 -0
- package/cli/secrets/SecretsConfig.js +141 -0
- package/cli/secrets/SecretsDoctor.js +384 -0
- package/cli/secrets/SecretsManager.js +255 -0
- package/client/dist/client.d.ts +332 -0
- package/client/dist/client.js +507 -0
- package/client/dist/helpers.d.ts +62 -0
- package/client/dist/helpers.js +122 -0
- package/client/dist/index.d.ts +59 -0
- package/client/dist/index.js +78 -0
- package/client/dist/package.json +1 -0
- package/client/dist/types.d.ts +280 -0
- package/client/dist/types.js +7 -0
- package/config.development +46 -0
- package/config.test +18 -0
- package/guidelines/CodingStyleGuideline.md +148 -0
- package/guidelines/CommentingGuideline.md +10 -0
- package/guidelines/HttpApiImplementationGuideline.md +137 -0
- package/guidelines/NamingGuideline.md +182 -0
- package/package.json +138 -0
- package/patterns/api/[collectionName]/Controllers.md +62 -0
- package/patterns/api/[collectionName]/Logic.md +154 -0
- package/patterns/api/[collectionName]/Permissions.md +81 -0
- package/patterns/api/[collectionName]/Router.md +83 -0
- package/patterns/api/[collectionName]/Schemas.md +197 -0
- package/patterns/configs/Patterns.md +7 -0
- package/patterns/enums/Patterns.md +24 -0
- package/patterns/errorHandling/Patterns.md +185 -0
- package/patterns/testing/Patterns.md +232 -0
- package/src/Server.js +238 -0
- package/src/api/dashboard/Controllers.js +9 -0
- package/src/api/dashboard/Logic.js +76 -0
- package/src/api/dashboard/Router.js +11 -0
- package/src/api/dashboard/Schemas.js +47 -0
- package/src/api/data/Controllers.js +26 -0
- package/src/api/data/Logic.js +188 -0
- package/src/api/data/Router.js +16 -0
- package/src/api/docker/Controllers.js +33 -0
- package/src/api/docker/Logic.js +268 -0
- package/src/api/docker/Router.js +15 -0
- package/src/api/docker/Schemas.js +80 -0
- package/src/api/docs/Controllers.js +15 -0
- package/src/api/docs/Logic.js +85 -0
- package/src/api/docs/Router.js +12 -0
- package/src/api/export/Controllers.js +30 -0
- package/src/api/export/Logic.js +143 -0
- package/src/api/export/Router.js +18 -0
- package/src/api/export/Schemas.js +104 -0
- package/src/api/firestore/Controllers.js +152 -0
- package/src/api/firestore/Logic.js +474 -0
- package/src/api/firestore/Router.js +23 -0
- package/src/api/functions/Controllers.js +261 -0
- package/src/api/functions/Logic.js +710 -0
- package/src/api/functions/Router.js +50 -0
- package/src/api/functions/Schemas.js +193 -0
- package/src/api/gateway/Controllers.js +72 -0
- package/src/api/gateway/Logic.js +74 -0
- package/src/api/gateway/Router.js +10 -0
- package/src/api/gateway/Schemas.js +19 -0
- package/src/api/health/Controllers.js +14 -0
- package/src/api/health/Logic.js +24 -0
- package/src/api/health/Router.js +12 -0
- package/src/api/httpTraffic/Controllers.js +29 -0
- package/src/api/httpTraffic/Logic.js +33 -0
- package/src/api/httpTraffic/Router.js +9 -0
- package/src/api/httpTraffic/Schemas.js +23 -0
- package/src/api/logging/Controllers.js +80 -0
- package/src/api/logging/Logic.js +461 -0
- package/src/api/logging/Router.js +24 -0
- package/src/api/logging/Schemas.js +43 -0
- package/src/api/mqtt/Controllers.js +17 -0
- package/src/api/mqtt/Logic.js +66 -0
- package/src/api/mqtt/Router.js +12 -0
- package/src/api/postgres/Controllers.js +97 -0
- package/src/api/postgres/Logic.js +221 -0
- package/src/api/postgres/Router.js +21 -0
- package/src/api/pubsub/Controllers.js +236 -0
- package/src/api/pubsub/Logic.js +732 -0
- package/src/api/pubsub/Router.js +41 -0
- package/src/api/pubsub/Schemas.js +355 -0
- package/src/api/redis/Controllers.js +63 -0
- package/src/api/redis/Logic.js +239 -0
- package/src/api/redis/Router.js +21 -0
- package/src/api/scheduler/Controllers.js +27 -0
- package/src/api/scheduler/Logic.js +49 -0
- package/src/api/scheduler/Router.js +16 -0
- package/src/api/services/Controllers.js +26 -0
- package/src/api/services/Logic.js +205 -0
- package/src/api/services/Router.js +14 -0
- package/src/api/services/Schemas.js +66 -0
- package/src/api/snapshots/Controllers.js +37 -0
- package/src/api/snapshots/Logic.js +797 -0
- package/src/api/snapshots/Router.js +15 -0
- package/src/api/snapshots/Schemas.js +23 -0
- package/src/api/webhooks/Controllers.js +49 -0
- package/src/api/webhooks/Logic.js +137 -0
- package/src/api/webhooks/Router.js +12 -0
- package/src/api/webhooks/Schemas.js +31 -0
- package/src/configs/Application.js +147 -0
- package/src/configs/Default.js +13 -0
- package/src/consumers/BlackboxLogsConsumer.js +235 -0
- package/src/consumers/DockerLogsConsumer.js +687 -0
- package/src/db/Tables.js +66 -0
- package/src/db/schemas/firestore.js +18 -0
- package/src/db/schemas/functions.js +65 -0
- package/src/db/schemas/httpTraffic.js +43 -0
- package/src/db/schemas/logging.js +74 -0
- package/src/db/schemas/migrations.js +64 -0
- package/src/db/schemas/mqtt.js +56 -0
- package/src/db/schemas/pubsub.js +90 -0
- package/src/db/schemas/pubsubRegistry.js +22 -0
- package/src/db/schemas/webhooks.js +28 -0
- package/src/emulation/awsiot/Controllers.js +91 -0
- package/src/emulation/awsiot/Logic.js +70 -0
- package/src/emulation/awsiot/Router.js +19 -0
- package/src/emulation/awsiot/Server.js +100 -0
- package/src/emulation/firestore/Server.js +136 -0
- package/src/emulation/logging/Controllers.js +212 -0
- package/src/emulation/logging/Logic.js +416 -0
- package/src/emulation/logging/Router.js +36 -0
- package/src/emulation/logging/Schemas.js +82 -0
- package/src/emulation/logging/Server.js +108 -0
- package/src/emulation/pubsub/Controllers.js +279 -0
- package/src/emulation/pubsub/DefaultTopics.js +162 -0
- package/src/emulation/pubsub/Logic.js +427 -0
- package/src/emulation/pubsub/README.md +309 -0
- package/src/emulation/pubsub/Router.js +33 -0
- package/src/emulation/pubsub/Server.js +104 -0
- package/src/emulation/pubsub/ShadowPoller.js +276 -0
- package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
- package/src/enums/ContainerNames.js +106 -0
- package/src/enums/ErrorReason.js +28 -0
- package/src/enums/FunctionStatuses.js +15 -0
- package/src/enums/FunctionTriggerTypes.js +15 -0
- package/src/enums/GatewayState.js +7 -0
- package/src/enums/ServiceNames.js +68 -0
- package/src/jobs/DatabaseMaintenance.js +184 -0
- package/src/jobs/MessageHistoryCleanup.js +152 -0
- package/src/mcp/ApiClient.js +25 -0
- package/src/mcp/Server.js +52 -0
- package/src/mcp/prompts/debugging.js +104 -0
- package/src/mcp/resources/platform.js +118 -0
- package/src/mcp/tools/data.js +84 -0
- package/src/mcp/tools/docker.js +166 -0
- package/src/mcp/tools/firestore.js +162 -0
- package/src/mcp/tools/functions.js +380 -0
- package/src/mcp/tools/httpTraffic.js +69 -0
- package/src/mcp/tools/logging.js +174 -0
- package/src/mcp/tools/mqtt.js +37 -0
- package/src/mcp/tools/postgres.js +130 -0
- package/src/mcp/tools/pubsub.js +316 -0
- package/src/mcp/tools/redis.js +146 -0
- package/src/mcp/tools/services.js +169 -0
- package/src/mcp/tools/snapshots.js +88 -0
- package/src/mcp/tools/webhooks.js +115 -0
- package/src/middleware/DevProxy.js +67 -0
- package/src/middleware/ErrorCatcher.js +35 -0
- package/src/middleware/HttpProxy.js +215 -0
- package/src/middleware/Reply.js +24 -0
- package/src/middleware/TraceId.js +9 -0
- package/src/middleware/WebhookProxy.js +234 -0
- package/src/protocols/mqtt/Broker.js +92 -0
- package/src/protocols/mqtt/Handlers.js +175 -0
- package/src/protocols/mqtt/PubSubBridge.js +162 -0
- package/src/protocols/mqtt/Server.js +116 -0
- package/src/runtime/FunctionRunner.js +179 -0
- package/src/services/AppGatewayService.js +582 -0
- package/src/singletons/FirestoreBroadcaster.js +367 -0
- package/src/singletons/FunctionTriggerDispatcher.js +456 -0
- package/src/singletons/FunctionsService.js +418 -0
- package/src/singletons/HttpProxy.js +224 -0
- package/src/singletons/LogBroadcaster.js +159 -0
- package/src/singletons/Logger.js +49 -0
- package/src/singletons/MemoryJsonStore.js +175 -0
- package/src/singletons/MessageBroadcaster.js +190 -0
- package/src/singletons/PostgresBroadcaster.js +367 -0
- package/src/singletons/PostgresClient.js +180 -0
- package/src/singletons/RedisClient.js +184 -0
- package/src/singletons/SqliteStore.js +480 -0
- package/src/singletons/TickService.js +151 -0
- package/src/singletons/WebhookProxy.js +223 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// FirestoreBroadcaster singleton for managing SSE connections to Firestore change streams
|
|
2
|
+
// Handles client registration, Firebase snapshot listeners with reference counting, and change broadcasting
|
|
3
|
+
|
|
4
|
+
import { Logger } from './Logger.js'
|
|
5
|
+
import { FunctionTriggerDispatcher } from './FunctionTriggerDispatcher.js'
|
|
6
|
+
import { initializeApp, getApps } from 'firebase-admin/app'
|
|
7
|
+
import { getFirestore } from 'firebase-admin/firestore'
|
|
8
|
+
import { Application } from '../configs/Application.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PROJECT_ID = Application.firestore.projectId
|
|
11
|
+
|
|
12
|
+
// Set emulator host environment variable for Firebase Admin SDK
|
|
13
|
+
process.env.FIRESTORE_EMULATOR_HOST = Application.firestore.emulatorHost
|
|
14
|
+
|
|
15
|
+
// Cache Firebase app instances by projectId
|
|
16
|
+
const appCache = new Map()
|
|
17
|
+
|
|
18
|
+
function getFirestoreDb (projectId) {
|
|
19
|
+
const pid = projectId || DEFAULT_PROJECT_ID
|
|
20
|
+
if (appCache.has(pid)) {
|
|
21
|
+
return appCache.get(pid)
|
|
22
|
+
}
|
|
23
|
+
const appName = `firestore-${pid}`
|
|
24
|
+
const existingApp = getApps().find(a => a.name === appName)
|
|
25
|
+
const app = existingApp || initializeApp({ projectId: pid }, appName)
|
|
26
|
+
const db = getFirestore(app)
|
|
27
|
+
appCache.set(pid, db)
|
|
28
|
+
return db
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class FirestoreBroadcasterClass {
|
|
32
|
+
constructor () {
|
|
33
|
+
this.clients = new Map() // connectionId → { ctx, filters: { projectId, collectionPath } }
|
|
34
|
+
this.listeners = new Map() // listenerKey (projectId:collectionPath) → { unsubscribe, refCount }
|
|
35
|
+
this.heartbeatId = null
|
|
36
|
+
this.heartbeatIntervalMs = 30000
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a new SSE client and start collection listener if needed
|
|
41
|
+
* @param {string} connectionId - Unique identifier for the connection (traceId)
|
|
42
|
+
* @param {Object} ctx - Koa context with res.write for SSE
|
|
43
|
+
* @param {Object} filters - { projectId, collectionPath }
|
|
44
|
+
*/
|
|
45
|
+
addClient (connectionId, ctx, filters = {}) {
|
|
46
|
+
this.clients.set(connectionId, { ctx, filters })
|
|
47
|
+
this.startHeartbeat()
|
|
48
|
+
|
|
49
|
+
// Start listening to the collection if filters provided
|
|
50
|
+
if (filters.projectId && filters.collectionPath) {
|
|
51
|
+
this.startCollectionListener(filters.projectId, filters.collectionPath)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Logger.log({
|
|
55
|
+
level: 'info',
|
|
56
|
+
message: 'Firestore SSE client connected',
|
|
57
|
+
data: { connectionId, filters, totalClients: this.clients.size }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Unregister an SSE client and stop listener if no more clients watching
|
|
63
|
+
* @param {string} connectionId - Unique identifier for the connection
|
|
64
|
+
*/
|
|
65
|
+
removeClient (connectionId) {
|
|
66
|
+
const client = this.clients.get(connectionId)
|
|
67
|
+
if (!client) return
|
|
68
|
+
|
|
69
|
+
const removed = this.clients.delete(connectionId)
|
|
70
|
+
|
|
71
|
+
if (removed) {
|
|
72
|
+
// Decrement listener ref count
|
|
73
|
+
if (client.filters.projectId && client.filters.collectionPath) {
|
|
74
|
+
this.decrementListenerRefCount(client.filters.projectId, client.filters.collectionPath)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Logger.log({
|
|
78
|
+
level: 'info',
|
|
79
|
+
message: 'Firestore SSE client disconnected',
|
|
80
|
+
data: { connectionId, totalClients: this.clients.size }
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (this.clients.size === 0) {
|
|
84
|
+
this.stopHeartbeat()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start a Firebase snapshot listener for a collection
|
|
91
|
+
* Uses reference counting to share one listener across multiple clients
|
|
92
|
+
* @param {string} projectId - Firestore project ID
|
|
93
|
+
* @param {string} collectionPath - Collection path
|
|
94
|
+
*/
|
|
95
|
+
startCollectionListener (projectId, collectionPath) {
|
|
96
|
+
const listenerKey = `${projectId}:${collectionPath}`
|
|
97
|
+
|
|
98
|
+
// Increment ref count if listener already exists
|
|
99
|
+
if (this.listeners.has(listenerKey)) {
|
|
100
|
+
const listener = this.listeners.get(listenerKey)
|
|
101
|
+
listener.refCount++
|
|
102
|
+
Logger.log({
|
|
103
|
+
level: 'debug',
|
|
104
|
+
message: 'Firestore listener ref count incremented',
|
|
105
|
+
data: { projectId, collectionPath, refCount: listener.refCount }
|
|
106
|
+
})
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Create new snapshot listener
|
|
111
|
+
try {
|
|
112
|
+
const db = getFirestoreDb(projectId)
|
|
113
|
+
Logger.log({
|
|
114
|
+
level: 'info',
|
|
115
|
+
message: 'Creating Firestore snapshot listener',
|
|
116
|
+
data: { projectId, collectionPath, listenerKey }
|
|
117
|
+
})
|
|
118
|
+
const unsubscribe = db.collection(collectionPath).onSnapshot(
|
|
119
|
+
(snapshot) => {
|
|
120
|
+
const changeCount = snapshot.docChanges().length
|
|
121
|
+
Logger.log({
|
|
122
|
+
level: 'info',
|
|
123
|
+
message: 'Firestore snapshot received',
|
|
124
|
+
data: { projectId, collectionPath, changeCount }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
snapshot.docChanges().forEach((change) => {
|
|
128
|
+
const changeEvent = {
|
|
129
|
+
eventId: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
130
|
+
timestamp: new Date().toISOString(),
|
|
131
|
+
projectId,
|
|
132
|
+
collectionPath,
|
|
133
|
+
documentId: change.doc.id,
|
|
134
|
+
changeType: change.type, // 'added', 'modified', 'removed'
|
|
135
|
+
document: change.type !== 'removed' ? {
|
|
136
|
+
name: change.doc.ref.path,
|
|
137
|
+
fields: change.doc.data(),
|
|
138
|
+
createTime: change.doc.createTime?.toDate().toISOString(),
|
|
139
|
+
updateTime: change.doc.updateTime?.toDate().toISOString()
|
|
140
|
+
} : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Logger.log({
|
|
144
|
+
level: 'info',
|
|
145
|
+
message: 'Broadcasting Firestore change event',
|
|
146
|
+
data: { eventId: changeEvent.eventId, changeType: changeEvent.changeType, documentId: changeEvent.documentId, clientCount: this.clients.size }
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
this.broadcast(changeEvent)
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
(error) => {
|
|
153
|
+
Logger.log({
|
|
154
|
+
level: 'error',
|
|
155
|
+
message: 'Firestore snapshot listener error',
|
|
156
|
+
data: { projectId, collectionPath, error: error.message }
|
|
157
|
+
})
|
|
158
|
+
// Remove listener on error
|
|
159
|
+
this.stopCollectionListener(projectId, collectionPath)
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
this.listeners.set(listenerKey, { unsubscribe, refCount: 1 })
|
|
164
|
+
|
|
165
|
+
Logger.log({
|
|
166
|
+
level: 'info',
|
|
167
|
+
message: 'Firestore snapshot listener started',
|
|
168
|
+
data: { projectId, collectionPath }
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
Logger.log({
|
|
172
|
+
level: 'error',
|
|
173
|
+
message: 'Failed to start Firestore snapshot listener',
|
|
174
|
+
data: { projectId, collectionPath, error: error.message }
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Decrement listener reference count and stop if reaches zero
|
|
181
|
+
* @param {string} projectId - Firestore project ID
|
|
182
|
+
* @param {string} collectionPath - Collection path
|
|
183
|
+
*/
|
|
184
|
+
decrementListenerRefCount (projectId, collectionPath) {
|
|
185
|
+
const listenerKey = `${projectId}:${collectionPath}`
|
|
186
|
+
const listener = this.listeners.get(listenerKey)
|
|
187
|
+
|
|
188
|
+
if (!listener) return
|
|
189
|
+
|
|
190
|
+
listener.refCount--
|
|
191
|
+
|
|
192
|
+
Logger.log({
|
|
193
|
+
level: 'debug',
|
|
194
|
+
message: 'Firestore listener ref count decremented',
|
|
195
|
+
data: { projectId, collectionPath, refCount: listener.refCount }
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (listener.refCount <= 0) {
|
|
199
|
+
this.stopCollectionListener(projectId, collectionPath)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Stop a Firebase snapshot listener
|
|
205
|
+
* @param {string} projectId - Firestore project ID
|
|
206
|
+
* @param {string} collectionPath - Collection path
|
|
207
|
+
*/
|
|
208
|
+
stopCollectionListener (projectId, collectionPath) {
|
|
209
|
+
const listenerKey = `${projectId}:${collectionPath}`
|
|
210
|
+
const listener = this.listeners.get(listenerKey)
|
|
211
|
+
|
|
212
|
+
if (listener && listener.unsubscribe) {
|
|
213
|
+
listener.unsubscribe()
|
|
214
|
+
this.listeners.delete(listenerKey)
|
|
215
|
+
|
|
216
|
+
Logger.log({
|
|
217
|
+
level: 'info',
|
|
218
|
+
message: 'Firestore snapshot listener stopped',
|
|
219
|
+
data: { projectId, collectionPath }
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Broadcast a change event to all connected clients matching filters
|
|
226
|
+
* @param {Object} change - { eventId, timestamp, projectId, collectionPath, documentId, changeType, document }
|
|
227
|
+
*/
|
|
228
|
+
broadcast (change) {
|
|
229
|
+
let sentCount = 0
|
|
230
|
+
for (const [connectionId, { ctx, filters }] of this.clients) {
|
|
231
|
+
if (this.matchesFilters(change, filters)) {
|
|
232
|
+
try {
|
|
233
|
+
this.sendSSE(ctx, 'change', change)
|
|
234
|
+
sentCount++
|
|
235
|
+
} catch (error) {
|
|
236
|
+
Logger.log({
|
|
237
|
+
level: 'error',
|
|
238
|
+
message: 'Failed to send SSE change event to client',
|
|
239
|
+
data: { connectionId, error: error.message }
|
|
240
|
+
})
|
|
241
|
+
this.removeClient(connectionId)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
Logger.log({
|
|
246
|
+
level: 'debug',
|
|
247
|
+
message: 'Firestore change broadcasted to SSE clients',
|
|
248
|
+
data: {
|
|
249
|
+
eventId: change.eventId,
|
|
250
|
+
changeType: change.changeType,
|
|
251
|
+
documentId: change.documentId,
|
|
252
|
+
totalClients: this.clients.size,
|
|
253
|
+
sentToClients: sentCount
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
// Dispatch to Cloud Functions (regardless of SSE client count)
|
|
257
|
+
try {
|
|
258
|
+
if (FunctionTriggerDispatcher && typeof FunctionTriggerDispatcher.onFirestoreChange === 'function') {
|
|
259
|
+
FunctionTriggerDispatcher.onFirestoreChange(change).catch(err => {
|
|
260
|
+
Logger.log({ level: 'error', message: 'Cloud Function Firestore dispatch error', data: { error: err.message } })
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
Logger.log({ level: 'error', message: 'Error calling FunctionTriggerDispatcher', data: { error: err.message } })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check if change matches client filters
|
|
270
|
+
* @param {Object} change - The change event to check
|
|
271
|
+
* @param {Object} filters - { projectId?, collectionPath? }
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
matchesFilters (change, filters) {
|
|
275
|
+
// No filters = receive all changes
|
|
276
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Project filter
|
|
281
|
+
if (filters.projectId && change.projectId !== filters.projectId) {
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Collection filter
|
|
286
|
+
if (filters.collectionPath && change.collectionPath !== filters.collectionPath) {
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Send SSE formatted message to client
|
|
295
|
+
* @param {Object} ctx - Koa context
|
|
296
|
+
* @param {string} event - Event name
|
|
297
|
+
* @param {Object} data - Data to send
|
|
298
|
+
*/
|
|
299
|
+
sendSSE (ctx, event, data) {
|
|
300
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
301
|
+
ctx.res.write(message)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Start the shared heartbeat interval (one for all clients)
|
|
306
|
+
*/
|
|
307
|
+
startHeartbeat () {
|
|
308
|
+
if (this.heartbeatId) return
|
|
309
|
+
this.heartbeatId = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Stop the shared heartbeat interval when no clients remain
|
|
314
|
+
*/
|
|
315
|
+
stopHeartbeat () {
|
|
316
|
+
if (this.heartbeatId) {
|
|
317
|
+
clearInterval(this.heartbeatId)
|
|
318
|
+
this.heartbeatId = null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Send heartbeat to all connected clients
|
|
324
|
+
* Used to keep connections alive
|
|
325
|
+
*/
|
|
326
|
+
sendHeartbeat () {
|
|
327
|
+
for (const [connectionId, { ctx }] of this.clients) {
|
|
328
|
+
try {
|
|
329
|
+
ctx.res.write(': heartbeat\n\n')
|
|
330
|
+
} catch (error) {
|
|
331
|
+
Logger.log({
|
|
332
|
+
level: 'error',
|
|
333
|
+
message: 'Failed to send heartbeat to Firestore SSE client',
|
|
334
|
+
data: { connectionId, error: error.message }
|
|
335
|
+
})
|
|
336
|
+
this.removeClient(connectionId)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get current client and listener statistics
|
|
343
|
+
* @returns {Object}
|
|
344
|
+
*/
|
|
345
|
+
getStats () {
|
|
346
|
+
const stats = {
|
|
347
|
+
totalClients: this.clients.size,
|
|
348
|
+
totalListeners: this.listeners.size,
|
|
349
|
+
clientsByCollection: {},
|
|
350
|
+
listenerRefCounts: {}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [, { filters }] of this.clients) {
|
|
354
|
+
const key = `${filters.projectId}:${filters.collectionPath}`
|
|
355
|
+
stats.clientsByCollection[key] = (stats.clientsByCollection[key] || 0) + 1
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const [listenerKey, { refCount }] of this.listeners) {
|
|
359
|
+
stats.listenerRefCounts[listenerKey] = refCount
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return stats
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Export singleton instance
|
|
367
|
+
export const FirestoreBroadcaster = new FirestoreBroadcasterClass()
|