@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,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()
|