@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.
Files changed (205) hide show
  1. package/README.md +478 -0
  2. package/bin/goki-dev.js +452 -0
  3. package/bin/mcp-server.js +16 -0
  4. package/bin/secrets-cli.js +302 -0
  5. package/cli/ComposeOverrideGenerator.js +226 -0
  6. package/cli/ComposeParser.js +73 -0
  7. package/cli/ConfigGenerator.js +304 -0
  8. package/cli/ConfigManager.js +46 -0
  9. package/cli/DatabaseManager.js +94 -0
  10. package/cli/DevToolsChecker.js +21 -0
  11. package/cli/DevToolsDir.js +66 -0
  12. package/cli/DevToolsManager.js +451 -0
  13. package/cli/DockerManager.js +138 -0
  14. package/cli/FunctionManager.js +95 -0
  15. package/cli/HttpProxyRewriter.js +91 -0
  16. package/cli/Logger.js +10 -0
  17. package/cli/McpConfigManager.js +123 -0
  18. package/cli/NgrokManager.js +431 -0
  19. package/cli/ProjectCLI.js +2322 -0
  20. package/cli/PubSubManager.js +129 -0
  21. package/cli/SnapshotManager.js +88 -0
  22. package/cli/UiFormatter.js +292 -0
  23. package/cli/WebhookUrlRewriter.js +32 -0
  24. package/cli/secrets/BiometricAuth.js +125 -0
  25. package/cli/secrets/SecretInjector.js +47 -0
  26. package/cli/secrets/SecretsConfig.js +141 -0
  27. package/cli/secrets/SecretsDoctor.js +384 -0
  28. package/cli/secrets/SecretsManager.js +255 -0
  29. package/client/dist/client.d.ts +332 -0
  30. package/client/dist/client.js +507 -0
  31. package/client/dist/helpers.d.ts +62 -0
  32. package/client/dist/helpers.js +122 -0
  33. package/client/dist/index.d.ts +59 -0
  34. package/client/dist/index.js +78 -0
  35. package/client/dist/package.json +1 -0
  36. package/client/dist/types.d.ts +280 -0
  37. package/client/dist/types.js +7 -0
  38. package/config.development +46 -0
  39. package/config.test +18 -0
  40. package/guidelines/CodingStyleGuideline.md +148 -0
  41. package/guidelines/CommentingGuideline.md +10 -0
  42. package/guidelines/HttpApiImplementationGuideline.md +137 -0
  43. package/guidelines/NamingGuideline.md +182 -0
  44. package/package.json +138 -0
  45. package/patterns/api/[collectionName]/Controllers.md +62 -0
  46. package/patterns/api/[collectionName]/Logic.md +154 -0
  47. package/patterns/api/[collectionName]/Permissions.md +81 -0
  48. package/patterns/api/[collectionName]/Router.md +83 -0
  49. package/patterns/api/[collectionName]/Schemas.md +197 -0
  50. package/patterns/configs/Patterns.md +7 -0
  51. package/patterns/enums/Patterns.md +24 -0
  52. package/patterns/errorHandling/Patterns.md +185 -0
  53. package/patterns/testing/Patterns.md +232 -0
  54. package/src/Server.js +238 -0
  55. package/src/api/dashboard/Controllers.js +9 -0
  56. package/src/api/dashboard/Logic.js +76 -0
  57. package/src/api/dashboard/Router.js +11 -0
  58. package/src/api/dashboard/Schemas.js +47 -0
  59. package/src/api/data/Controllers.js +26 -0
  60. package/src/api/data/Logic.js +188 -0
  61. package/src/api/data/Router.js +16 -0
  62. package/src/api/docker/Controllers.js +33 -0
  63. package/src/api/docker/Logic.js +268 -0
  64. package/src/api/docker/Router.js +15 -0
  65. package/src/api/docker/Schemas.js +80 -0
  66. package/src/api/docs/Controllers.js +15 -0
  67. package/src/api/docs/Logic.js +85 -0
  68. package/src/api/docs/Router.js +12 -0
  69. package/src/api/export/Controllers.js +30 -0
  70. package/src/api/export/Logic.js +143 -0
  71. package/src/api/export/Router.js +18 -0
  72. package/src/api/export/Schemas.js +104 -0
  73. package/src/api/firestore/Controllers.js +152 -0
  74. package/src/api/firestore/Logic.js +474 -0
  75. package/src/api/firestore/Router.js +23 -0
  76. package/src/api/functions/Controllers.js +261 -0
  77. package/src/api/functions/Logic.js +710 -0
  78. package/src/api/functions/Router.js +50 -0
  79. package/src/api/functions/Schemas.js +193 -0
  80. package/src/api/gateway/Controllers.js +72 -0
  81. package/src/api/gateway/Logic.js +74 -0
  82. package/src/api/gateway/Router.js +10 -0
  83. package/src/api/gateway/Schemas.js +19 -0
  84. package/src/api/health/Controllers.js +14 -0
  85. package/src/api/health/Logic.js +24 -0
  86. package/src/api/health/Router.js +12 -0
  87. package/src/api/httpTraffic/Controllers.js +29 -0
  88. package/src/api/httpTraffic/Logic.js +33 -0
  89. package/src/api/httpTraffic/Router.js +9 -0
  90. package/src/api/httpTraffic/Schemas.js +23 -0
  91. package/src/api/logging/Controllers.js +80 -0
  92. package/src/api/logging/Logic.js +461 -0
  93. package/src/api/logging/Router.js +24 -0
  94. package/src/api/logging/Schemas.js +43 -0
  95. package/src/api/mqtt/Controllers.js +17 -0
  96. package/src/api/mqtt/Logic.js +66 -0
  97. package/src/api/mqtt/Router.js +12 -0
  98. package/src/api/postgres/Controllers.js +97 -0
  99. package/src/api/postgres/Logic.js +221 -0
  100. package/src/api/postgres/Router.js +21 -0
  101. package/src/api/pubsub/Controllers.js +236 -0
  102. package/src/api/pubsub/Logic.js +732 -0
  103. package/src/api/pubsub/Router.js +41 -0
  104. package/src/api/pubsub/Schemas.js +355 -0
  105. package/src/api/redis/Controllers.js +63 -0
  106. package/src/api/redis/Logic.js +239 -0
  107. package/src/api/redis/Router.js +21 -0
  108. package/src/api/scheduler/Controllers.js +27 -0
  109. package/src/api/scheduler/Logic.js +49 -0
  110. package/src/api/scheduler/Router.js +16 -0
  111. package/src/api/services/Controllers.js +26 -0
  112. package/src/api/services/Logic.js +205 -0
  113. package/src/api/services/Router.js +14 -0
  114. package/src/api/services/Schemas.js +66 -0
  115. package/src/api/snapshots/Controllers.js +37 -0
  116. package/src/api/snapshots/Logic.js +797 -0
  117. package/src/api/snapshots/Router.js +15 -0
  118. package/src/api/snapshots/Schemas.js +23 -0
  119. package/src/api/webhooks/Controllers.js +49 -0
  120. package/src/api/webhooks/Logic.js +137 -0
  121. package/src/api/webhooks/Router.js +12 -0
  122. package/src/api/webhooks/Schemas.js +31 -0
  123. package/src/configs/Application.js +147 -0
  124. package/src/configs/Default.js +13 -0
  125. package/src/consumers/BlackboxLogsConsumer.js +235 -0
  126. package/src/consumers/DockerLogsConsumer.js +687 -0
  127. package/src/db/Tables.js +66 -0
  128. package/src/db/schemas/firestore.js +18 -0
  129. package/src/db/schemas/functions.js +65 -0
  130. package/src/db/schemas/httpTraffic.js +43 -0
  131. package/src/db/schemas/logging.js +74 -0
  132. package/src/db/schemas/migrations.js +64 -0
  133. package/src/db/schemas/mqtt.js +56 -0
  134. package/src/db/schemas/pubsub.js +90 -0
  135. package/src/db/schemas/pubsubRegistry.js +22 -0
  136. package/src/db/schemas/webhooks.js +28 -0
  137. package/src/emulation/awsiot/Controllers.js +91 -0
  138. package/src/emulation/awsiot/Logic.js +70 -0
  139. package/src/emulation/awsiot/Router.js +19 -0
  140. package/src/emulation/awsiot/Server.js +100 -0
  141. package/src/emulation/firestore/Server.js +136 -0
  142. package/src/emulation/logging/Controllers.js +212 -0
  143. package/src/emulation/logging/Logic.js +416 -0
  144. package/src/emulation/logging/Router.js +36 -0
  145. package/src/emulation/logging/Schemas.js +82 -0
  146. package/src/emulation/logging/Server.js +108 -0
  147. package/src/emulation/pubsub/Controllers.js +279 -0
  148. package/src/emulation/pubsub/DefaultTopics.js +162 -0
  149. package/src/emulation/pubsub/Logic.js +427 -0
  150. package/src/emulation/pubsub/README.md +309 -0
  151. package/src/emulation/pubsub/Router.js +33 -0
  152. package/src/emulation/pubsub/Server.js +104 -0
  153. package/src/emulation/pubsub/ShadowPoller.js +276 -0
  154. package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
  155. package/src/enums/ContainerNames.js +106 -0
  156. package/src/enums/ErrorReason.js +28 -0
  157. package/src/enums/FunctionStatuses.js +15 -0
  158. package/src/enums/FunctionTriggerTypes.js +15 -0
  159. package/src/enums/GatewayState.js +7 -0
  160. package/src/enums/ServiceNames.js +68 -0
  161. package/src/jobs/DatabaseMaintenance.js +184 -0
  162. package/src/jobs/MessageHistoryCleanup.js +152 -0
  163. package/src/mcp/ApiClient.js +25 -0
  164. package/src/mcp/Server.js +52 -0
  165. package/src/mcp/prompts/debugging.js +104 -0
  166. package/src/mcp/resources/platform.js +118 -0
  167. package/src/mcp/tools/data.js +84 -0
  168. package/src/mcp/tools/docker.js +166 -0
  169. package/src/mcp/tools/firestore.js +162 -0
  170. package/src/mcp/tools/functions.js +380 -0
  171. package/src/mcp/tools/httpTraffic.js +69 -0
  172. package/src/mcp/tools/logging.js +174 -0
  173. package/src/mcp/tools/mqtt.js +37 -0
  174. package/src/mcp/tools/postgres.js +130 -0
  175. package/src/mcp/tools/pubsub.js +316 -0
  176. package/src/mcp/tools/redis.js +146 -0
  177. package/src/mcp/tools/services.js +169 -0
  178. package/src/mcp/tools/snapshots.js +88 -0
  179. package/src/mcp/tools/webhooks.js +115 -0
  180. package/src/middleware/DevProxy.js +67 -0
  181. package/src/middleware/ErrorCatcher.js +35 -0
  182. package/src/middleware/HttpProxy.js +215 -0
  183. package/src/middleware/Reply.js +24 -0
  184. package/src/middleware/TraceId.js +9 -0
  185. package/src/middleware/WebhookProxy.js +234 -0
  186. package/src/protocols/mqtt/Broker.js +92 -0
  187. package/src/protocols/mqtt/Handlers.js +175 -0
  188. package/src/protocols/mqtt/PubSubBridge.js +162 -0
  189. package/src/protocols/mqtt/Server.js +116 -0
  190. package/src/runtime/FunctionRunner.js +179 -0
  191. package/src/services/AppGatewayService.js +582 -0
  192. package/src/singletons/FirestoreBroadcaster.js +367 -0
  193. package/src/singletons/FunctionTriggerDispatcher.js +456 -0
  194. package/src/singletons/FunctionsService.js +418 -0
  195. package/src/singletons/HttpProxy.js +224 -0
  196. package/src/singletons/LogBroadcaster.js +159 -0
  197. package/src/singletons/Logger.js +49 -0
  198. package/src/singletons/MemoryJsonStore.js +175 -0
  199. package/src/singletons/MessageBroadcaster.js +190 -0
  200. package/src/singletons/PostgresBroadcaster.js +367 -0
  201. package/src/singletons/PostgresClient.js +180 -0
  202. package/src/singletons/RedisClient.js +184 -0
  203. package/src/singletons/SqliteStore.js +480 -0
  204. package/src/singletons/TickService.js +151 -0
  205. package/src/singletons/WebhookProxy.js +223 -0
@@ -0,0 +1,159 @@
1
+ import { Logger } from './Logger.js'
2
+
3
+ const SEVERITY_ORDER = ['DEFAULT', 'DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']
4
+
5
+ /**
6
+ * Check if a log entry matches a MongoDB-style filter object.
7
+ * Supports: exact match, $in, $nin, $regex, $gte/$lte/$gt/$lt, $ne, $eq,
8
+ * $or, $nor, $not, $search
9
+ */
10
+ const matchesFilter = (entry, filter) => {
11
+ if (!filter || Object.keys(filter).length === 0) return true
12
+ for (const [key, condition] of Object.entries(filter)) {
13
+ if (key === '$or') {
14
+ if (!condition.some(sub => matchesFilter(entry, sub))) return false
15
+ continue
16
+ }
17
+ if (key === '$nor') {
18
+ if (condition.some(sub => matchesFilter(entry, sub))) return false
19
+ continue
20
+ }
21
+ if (key === '$search') {
22
+ const { term, fields } = condition
23
+ const regex = new RegExp(term, 'i')
24
+ if (!fields.some(f => {
25
+ const val = getField(entry, f)
26
+ return val != null && regex.test(String(val))
27
+ })) return false
28
+ continue
29
+ }
30
+ if (!matchCondition(entry, key, condition)) return false
31
+ }
32
+ return true
33
+ }
34
+
35
+ const getField = (entry, field) => {
36
+ const parts = field.split('.')
37
+ let value = entry
38
+ for (const part of parts) {
39
+ if (value == null) return undefined
40
+ if (typeof value[part] === 'string' && (part === 'labels' || part === 'jsonPayload')) {
41
+ try { value = JSON.parse(value[part]) } catch { value = value[part] }
42
+ } else {
43
+ value = value[part]
44
+ }
45
+ }
46
+ return value
47
+ }
48
+
49
+ const severityRank = (s) => {
50
+ const i = SEVERITY_ORDER.indexOf(s)
51
+ return i === -1 ? 0 : i
52
+ }
53
+
54
+ const matchCondition = (entry, field, condition) => {
55
+ const val = getField(entry, field)
56
+ if (typeof condition !== 'object' || condition === null) {
57
+ return String(val) === String(condition)
58
+ }
59
+ if (condition.$eq !== undefined) return String(val) === String(condition.$eq)
60
+ if (condition.$ne !== undefined) return String(val) !== String(condition.$ne)
61
+ if (condition.$gt !== undefined) {
62
+ return field === 'severity' ? severityRank(val) > severityRank(condition.$gt) : val > condition.$gt
63
+ }
64
+ if (condition.$gte !== undefined) {
65
+ return field === 'severity' ? severityRank(val) >= severityRank(condition.$gte) : val >= condition.$gte
66
+ }
67
+ if (condition.$lt !== undefined) {
68
+ return field === 'severity' ? severityRank(val) < severityRank(condition.$lt) : val < condition.$lt
69
+ }
70
+ if (condition.$lte !== undefined) {
71
+ return field === 'severity' ? severityRank(val) <= severityRank(condition.$lte) : val <= condition.$lte
72
+ }
73
+ if (condition.$in !== undefined) return condition.$in.includes(val)
74
+ if (condition.$nin !== undefined) return !condition.$nin.includes(val)
75
+ if (condition.$regex !== undefined) {
76
+ if (val == null) return false
77
+ try { return new RegExp(condition.$regex, condition.$options || '').test(String(val)) } catch { return false }
78
+ }
79
+ if (condition.$not !== undefined) return !matchCondition(entry, field, condition.$not)
80
+ return true
81
+ }
82
+
83
+ class LogBroadcasterClass {
84
+ constructor () {
85
+ this.clients = new Map()
86
+ this.heartbeatId = null
87
+ this.heartbeatIntervalMs = 30000
88
+ }
89
+
90
+ addClient (connectionId, ctx, filter = null) {
91
+ this.clients.set(connectionId, { ctx, filter })
92
+ this.startHeartbeat()
93
+ Logger.log({
94
+ level: 'info',
95
+ message: 'Log SSE client connected',
96
+ data: { connectionId, totalClients: this.clients.size, hasFilter: !!filter }
97
+ })
98
+ }
99
+
100
+ removeClient (connectionId) {
101
+ const removed = this.clients.delete(connectionId)
102
+ if (removed) {
103
+ Logger.log({
104
+ level: 'info',
105
+ message: 'Log SSE client disconnected',
106
+ data: { connectionId, totalClients: this.clients.size }
107
+ })
108
+ if (this.clients.size === 0) {
109
+ this.stopHeartbeat()
110
+ }
111
+ }
112
+ }
113
+
114
+ broadcast (entry) {
115
+ if (this.clients.size === 0) return
116
+ const data = JSON.stringify(entry)
117
+ for (const [connectionId, { ctx, filter }] of this.clients) {
118
+ try {
119
+ if (filter && !matchesFilter(entry, filter)) continue
120
+ ctx.res.write(`event: log\ndata: ${data}\n\n`)
121
+ } catch (error) {
122
+ Logger.log({
123
+ level: 'error',
124
+ message: 'Failed to send log SSE to client',
125
+ data: { connectionId, error: error.message }
126
+ })
127
+ this.removeClient(connectionId)
128
+ }
129
+ }
130
+ }
131
+
132
+ startHeartbeat () {
133
+ if (this.heartbeatId) return
134
+ this.heartbeatId = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
135
+ }
136
+
137
+ stopHeartbeat () {
138
+ if (this.heartbeatId) {
139
+ clearInterval(this.heartbeatId)
140
+ this.heartbeatId = null
141
+ }
142
+ }
143
+
144
+ sendHeartbeat () {
145
+ for (const [connectionId, { ctx }] of this.clients) {
146
+ try {
147
+ ctx.res.write(': heartbeat\n\n')
148
+ } catch (error) {
149
+ this.removeClient(connectionId)
150
+ }
151
+ }
152
+ }
153
+
154
+ getClientCount () {
155
+ return this.clients.size
156
+ }
157
+ }
158
+
159
+ export const LogBroadcaster = new LogBroadcasterClass()
@@ -0,0 +1,49 @@
1
+ import { Application } from '../configs/Application.js'
2
+
3
+ const { logLevel, environment } = Application
4
+
5
+ const logLevels = {
6
+ error: 0,
7
+ warn: 1,
8
+ info: 2,
9
+ debug: 3
10
+ }
11
+
12
+ const currentLogLevel = logLevels[logLevel] || logLevels.info
13
+
14
+ class LoggerClass {
15
+ log ({ level, message, data = {} }) {
16
+ const levelValue = logLevels[level] || logLevels.info
17
+ if (levelValue > currentLogLevel) return
18
+ const timestamp = new Date().toISOString()
19
+ const logEntry = {
20
+ timestamp,
21
+ level,
22
+ message,
23
+ environment,
24
+ ...data
25
+ }
26
+ const output = environment === 'development'
27
+ ? JSON.stringify(logEntry, null, 2)
28
+ : JSON.stringify(logEntry)
29
+ console.log(output)
30
+ }
31
+
32
+ error (message, data) {
33
+ this.log({ level: 'error', message, data })
34
+ }
35
+
36
+ warn (message, data) {
37
+ this.log({ level: 'warn', message, data })
38
+ }
39
+
40
+ info (message, data) {
41
+ this.log({ level: 'info', message, data })
42
+ }
43
+
44
+ debug (message, data) {
45
+ this.log({ level: 'debug', message, data })
46
+ }
47
+ }
48
+
49
+ export const Logger = new LoggerClass()
@@ -0,0 +1,175 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { Application } from '../configs/Application.js'
4
+ import { Default } from '../configs/Default.js'
5
+
6
+ class MemoryJsonStoreClass {
7
+ constructor () {
8
+ this.collections = new Map()
9
+ this.flushTimers = new Map()
10
+ this.dirtyCollections = new Set()
11
+ this.isInitialized = false
12
+ }
13
+
14
+ initialize () {
15
+ if (this.isInitialized) return
16
+ const dataDir = Application.storage.dataDir
17
+ if (!fs.existsSync(dataDir)) {
18
+ fs.mkdirSync(dataDir, { recursive: true })
19
+ }
20
+ this.isInitialized = true
21
+ }
22
+
23
+ registerCollection (collectionName, filePath) {
24
+ if (this.collections.has(collectionName)) {
25
+ throw new Error(`Collection ${collectionName} already registered`)
26
+ }
27
+ const fullPath = path.join(Application.storage.dataDir, filePath)
28
+ const dir = path.dirname(fullPath)
29
+ if (!fs.existsSync(dir)) {
30
+ fs.mkdirSync(dir, { recursive: true })
31
+ }
32
+ let data = []
33
+ if (fs.existsSync(fullPath)) {
34
+ const content = fs.readFileSync(fullPath, 'utf8')
35
+ data = content ? JSON.parse(content) : []
36
+ }
37
+ this.collections.set(collectionName, {
38
+ data,
39
+ filePath: fullPath
40
+ })
41
+ this.startAutoFlush(collectionName)
42
+ }
43
+
44
+ startAutoFlush (collectionName) {
45
+ const intervalMs = Default.storage.autoFlushIntervalMs
46
+ const timer = setInterval(() => {
47
+ if (this.dirtyCollections.has(collectionName)) {
48
+ this.flush(collectionName)
49
+ }
50
+ }, intervalMs)
51
+ this.flushTimers.set(collectionName, timer)
52
+ }
53
+
54
+ markDirty (collectionName) {
55
+ this.dirtyCollections.add(collectionName)
56
+ }
57
+
58
+ flush (collectionName) {
59
+ const collection = this.collections.get(collectionName)
60
+ if (!collection) {
61
+ throw new Error(`Collection ${collectionName} not found`)
62
+ }
63
+ const jsonData = JSON.stringify(collection.data, null, 2)
64
+ fs.writeFileSync(collection.filePath, jsonData, 'utf8')
65
+ this.dirtyCollections.delete(collectionName)
66
+ }
67
+
68
+ flushAll () {
69
+ for (const collectionName of this.dirtyCollections) {
70
+ this.flush(collectionName)
71
+ }
72
+ }
73
+
74
+ get (collectionName, id, idField = 'id') {
75
+ const collection = this.collections.get(collectionName)
76
+ if (!collection) {
77
+ throw new Error(`Collection ${collectionName} not found`)
78
+ }
79
+ return collection.data.find(item => item[idField] === id)
80
+ }
81
+
82
+ find (collectionName, predicate) {
83
+ const collection = this.collections.get(collectionName)
84
+ if (!collection) {
85
+ throw new Error(`Collection ${collectionName} not found`)
86
+ }
87
+ return collection.data.filter(predicate)
88
+ }
89
+
90
+ list (collectionName, options = {}) {
91
+ const collection = this.collections.get(collectionName)
92
+ if (!collection) {
93
+ throw new Error(`Collection ${collectionName} not found`)
94
+ }
95
+ let data = [...collection.data]
96
+ if (options.filter) {
97
+ data = data.filter(options.filter)
98
+ }
99
+ if (options.sort) {
100
+ data = data.sort(options.sort)
101
+ }
102
+ const total = data.length
103
+ if (options.offset !== undefined) {
104
+ data = data.slice(options.offset)
105
+ }
106
+ if (options.limit !== undefined) {
107
+ data = data.slice(0, options.limit)
108
+ }
109
+ return { data, total }
110
+ }
111
+
112
+ create (collectionName, item) {
113
+ const collection = this.collections.get(collectionName)
114
+ if (!collection) {
115
+ throw new Error(`Collection ${collectionName} not found`)
116
+ }
117
+ collection.data.push(item)
118
+ this.markDirty(collectionName)
119
+ return item
120
+ }
121
+
122
+ update (collectionName, id, updates, idField = 'id') {
123
+ const collection = this.collections.get(collectionName)
124
+ if (!collection) {
125
+ throw new Error(`Collection ${collectionName} not found`)
126
+ }
127
+ const index = collection.data.findIndex(item => item[idField] === id)
128
+ if (index === -1) {
129
+ throw new Error(`Item with ${idField}=${id} not found in ${collectionName}`)
130
+ }
131
+ collection.data[index] = { ...collection.data[index], ...updates }
132
+ this.markDirty(collectionName)
133
+ return collection.data[index]
134
+ }
135
+
136
+ delete (collectionName, id, idField = 'id') {
137
+ const collection = this.collections.get(collectionName)
138
+ if (!collection) {
139
+ throw new Error(`Collection ${collectionName} not found`)
140
+ }
141
+ const index = collection.data.findIndex(item => item[idField] === id)
142
+ if (index === -1) {
143
+ throw new Error(`Item with ${idField}=${id} not found in ${collectionName}`)
144
+ }
145
+ const deleted = collection.data.splice(index, 1)[0]
146
+ this.markDirty(collectionName)
147
+ return deleted
148
+ }
149
+
150
+ clear (collectionName) {
151
+ const collection = this.collections.get(collectionName)
152
+ if (!collection) {
153
+ throw new Error(`Collection ${collectionName} not found`)
154
+ }
155
+ collection.data = []
156
+ this.markDirty(collectionName)
157
+ this.flush(collectionName)
158
+ }
159
+
160
+ clearAll () {
161
+ for (const collectionName of this.collections.keys()) {
162
+ this.clear(collectionName)
163
+ }
164
+ }
165
+
166
+ shutdown () {
167
+ this.flushAll()
168
+ for (const timer of this.flushTimers.values()) {
169
+ clearInterval(timer)
170
+ }
171
+ this.flushTimers.clear()
172
+ }
173
+ }
174
+
175
+ export const MemoryJsonStore = new MemoryJsonStoreClass()
@@ -0,0 +1,190 @@
1
+ // MessageBroadcaster singleton for managing SSE connections to pub/sub message streams
2
+ // Handles client registration, filtering, and broadcasting new messages
3
+
4
+ import { Logger } from './Logger.js'
5
+
6
+ class MessageBroadcasterClass {
7
+ constructor () {
8
+ this.clients = new Map() // connectionId → { ctx, filters }
9
+ this.heartbeatId = null
10
+ this.heartbeatIntervalMs = 30000
11
+ }
12
+
13
+ /**
14
+ * Register a new SSE client
15
+ * @param {string} connectionId - Unique identifier for the connection (traceId)
16
+ * @param {Object} ctx - Koa context with res.write for SSE
17
+ * @param {Object} filters - { topic?, sender? }
18
+ */
19
+ addClient (connectionId, ctx, filters = {}) {
20
+ this.clients.set(connectionId, { ctx, filters })
21
+ this.startHeartbeat()
22
+
23
+ Logger.log({
24
+ level: 'info',
25
+ message: 'SSE client connected',
26
+ data: { connectionId, filters, totalClients: this.clients.size }
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Unregister an SSE client
32
+ * @param {string} connectionId - Unique identifier for the connection
33
+ */
34
+ removeClient (connectionId) {
35
+ const removed = this.clients.delete(connectionId)
36
+
37
+ if (removed) {
38
+ Logger.log({
39
+ level: 'info',
40
+ message: 'SSE client disconnected',
41
+ data: { connectionId, totalClients: this.clients.size }
42
+ })
43
+ if (this.clients.size === 0) {
44
+ this.stopHeartbeat()
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Broadcast a message to all connected clients matching filters
51
+ * @param {Object} message - { messageId, topic, data, attributes, publishTime }
52
+ */
53
+ broadcast (message) {
54
+ if (this.clients.size === 0) return
55
+ let sentCount = 0
56
+
57
+ for (const [connectionId, { ctx, filters }] of this.clients) {
58
+ if (this.matchesFilters(message, filters)) {
59
+ try {
60
+ this.sendSSE(ctx, 'message', message)
61
+ sentCount++
62
+ } catch (error) {
63
+ Logger.log({
64
+ level: 'error',
65
+ message: 'Failed to send SSE message to client',
66
+ data: { connectionId, error: error.message }
67
+ })
68
+ // Remove dead connection
69
+ this.removeClient(connectionId)
70
+ }
71
+ }
72
+ }
73
+
74
+ Logger.log({
75
+ level: 'debug',
76
+ message: 'Message broadcasted to SSE clients',
77
+ data: {
78
+ messageId: message.messageId,
79
+ topic: message.topic,
80
+ totalClients: this.clients.size,
81
+ sentToClients: sentCount
82
+ }
83
+ })
84
+ }
85
+
86
+ /**
87
+ * Check if message matches client filters
88
+ * @param {Object} message - The message to check
89
+ * @param {Object} filters - { topic?, sender? }
90
+ * @returns {boolean}
91
+ */
92
+ matchesFilters (message, filters) {
93
+ // No filters = receive all messages
94
+ if (!filters || Object.keys(filters).length === 0) {
95
+ return true
96
+ }
97
+
98
+ // Topic filter
99
+ if (filters.topic && message.topic !== filters.topic) {
100
+ return false
101
+ }
102
+
103
+ // Sender filter (extracted from attributes)
104
+ if (filters.sender) {
105
+ const messageSender = message.attributes?.sender || message.sender
106
+ if (messageSender !== filters.sender) {
107
+ return false
108
+ }
109
+ }
110
+
111
+ return true
112
+ }
113
+
114
+ /**
115
+ * Send SSE formatted message to client
116
+ * @param {Object} ctx - Koa context
117
+ * @param {string} event - Event name
118
+ * @param {Object} data - Data to send
119
+ */
120
+ sendSSE (ctx, event, data) {
121
+ const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
122
+ ctx.res.write(message)
123
+ }
124
+
125
+ /**
126
+ * Start the shared heartbeat interval (one for all clients)
127
+ */
128
+ startHeartbeat () {
129
+ if (this.heartbeatId) return
130
+ this.heartbeatId = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
131
+ }
132
+
133
+ /**
134
+ * Stop the shared heartbeat interval when no clients remain
135
+ */
136
+ stopHeartbeat () {
137
+ if (this.heartbeatId) {
138
+ clearInterval(this.heartbeatId)
139
+ this.heartbeatId = null
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Send heartbeat to all connected clients
145
+ * Used to keep connections alive
146
+ */
147
+ sendHeartbeat () {
148
+ for (const [connectionId, { ctx }] of this.clients) {
149
+ try {
150
+ ctx.res.write(': heartbeat\n\n')
151
+ } catch (error) {
152
+ Logger.log({
153
+ level: 'error',
154
+ message: 'Failed to send heartbeat to client',
155
+ data: { connectionId, error: error.message }
156
+ })
157
+ this.removeClient(connectionId)
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get current client count
164
+ * @returns {number}
165
+ */
166
+ getClientCount () {
167
+ return this.clients.size
168
+ }
169
+
170
+ /**
171
+ * Get client statistics
172
+ * @returns {Object}
173
+ */
174
+ getStats () {
175
+ const stats = {
176
+ totalClients: this.clients.size,
177
+ clientsByFilter: {}
178
+ }
179
+
180
+ for (const [, { filters }] of this.clients) {
181
+ const filterKey = JSON.stringify(filters)
182
+ stats.clientsByFilter[filterKey] = (stats.clientsByFilter[filterKey] || 0) + 1
183
+ }
184
+
185
+ return stats
186
+ }
187
+ }
188
+
189
+ // Export singleton instance
190
+ export const MessageBroadcaster = new MessageBroadcasterClass()