@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,418 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import chokidar from 'chokidar'
|
|
4
|
+
import { FunctionRunner } from '../runtime/FunctionRunner.js'
|
|
5
|
+
import { SqliteStore } from './SqliteStore.js'
|
|
6
|
+
import { LogBroadcaster } from './LogBroadcaster.js'
|
|
7
|
+
import { Logger } from './Logger.js'
|
|
8
|
+
import { Application } from '../configs/Application.js'
|
|
9
|
+
import { CLOUD_FUNCTIONS, LOGGING_ENTRIES } from '../db/Tables.js'
|
|
10
|
+
import { FunctionStatuses } from '../enums/FunctionStatuses.js'
|
|
11
|
+
|
|
12
|
+
const { functions } = Application
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Process manager singleton for Cloud Functions.
|
|
16
|
+
* Manages all FunctionRunner instances, port allocation, and SSE broadcasting.
|
|
17
|
+
*/
|
|
18
|
+
class FunctionsServiceClass {
|
|
19
|
+
constructor () {
|
|
20
|
+
this.runners = new Map()
|
|
21
|
+
this.sseClients = new Map()
|
|
22
|
+
this.invocationSseClients = new Map()
|
|
23
|
+
this.fileChangeClients = new Map()
|
|
24
|
+
this.fileWatchers = new Map()
|
|
25
|
+
this.portPool = new Set()
|
|
26
|
+
this.heartbeatId = null
|
|
27
|
+
this.heartbeatIntervalMs = 30000
|
|
28
|
+
const basePort = functions.basePort || 9100
|
|
29
|
+
const maxInstances = functions.maxInstances || 50
|
|
30
|
+
for (let i = 0; i < maxInstances; i++) {
|
|
31
|
+
this.portPool.add(basePort + i)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async initialize () {
|
|
36
|
+
try {
|
|
37
|
+
const enabledFunctions = SqliteStore.find(CLOUD_FUNCTIONS, { enabled: 1 })
|
|
38
|
+
for (const fn of enabledFunctions) {
|
|
39
|
+
if (fn.status === FunctionStatuses.RUNNING || fn.status === FunctionStatuses.STARTING) {
|
|
40
|
+
SqliteStore.update(CLOUD_FUNCTIONS, fn.id, { status: FunctionStatuses.STOPPED, pid: null })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const fn of enabledFunctions) {
|
|
44
|
+
try {
|
|
45
|
+
await this.deployFunction(fn)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
Logger.log({
|
|
48
|
+
level: 'error',
|
|
49
|
+
message: `Failed to start function on initialize: ${fn.name}`,
|
|
50
|
+
data: { error: error.message }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
Logger.log({
|
|
55
|
+
level: 'info',
|
|
56
|
+
message: 'FunctionsService initialized',
|
|
57
|
+
data: { functionCount: enabledFunctions.length, runningCount: this.runners.size }
|
|
58
|
+
})
|
|
59
|
+
} catch (error) {
|
|
60
|
+
Logger.log({
|
|
61
|
+
level: 'error',
|
|
62
|
+
message: 'Failed to initialize FunctionsService',
|
|
63
|
+
data: { error: error.message }
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async shutdown () {
|
|
69
|
+
const stopPromises = []
|
|
70
|
+
for (const [, runner] of this.runners) {
|
|
71
|
+
stopPromises.push(runner.stop().catch(err => {
|
|
72
|
+
Logger.log({
|
|
73
|
+
level: 'warn',
|
|
74
|
+
message: `Error stopping function during shutdown: ${runner.name}`,
|
|
75
|
+
data: { error: err.message }
|
|
76
|
+
})
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
await Promise.all(stopPromises)
|
|
80
|
+
this.runners.clear()
|
|
81
|
+
this.stopHeartbeat()
|
|
82
|
+
this.sseClients.clear()
|
|
83
|
+
this.invocationSseClients.clear()
|
|
84
|
+
Logger.log({ level: 'info', message: 'FunctionsService shut down' })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async deployFunction (config) {
|
|
88
|
+
const port = config.port || this.allocatePort()
|
|
89
|
+
if (!port) {
|
|
90
|
+
throw new Error('No available ports for function deployment')
|
|
91
|
+
}
|
|
92
|
+
const runner = new FunctionRunner({
|
|
93
|
+
name: config.name,
|
|
94
|
+
source: config.source,
|
|
95
|
+
sourcePath: config.sourcePath,
|
|
96
|
+
entryPoint: config.entryPoint,
|
|
97
|
+
port,
|
|
98
|
+
signatureType: config.signatureType || 'cloudevent',
|
|
99
|
+
env: typeof config.environmentVariables === 'string'
|
|
100
|
+
? JSON.parse(config.environmentVariables)
|
|
101
|
+
: config.environmentVariables || {},
|
|
102
|
+
timeoutSeconds: config.timeoutSeconds || 60
|
|
103
|
+
})
|
|
104
|
+
runner.onLog = (name, stream, message) => {
|
|
105
|
+
this.broadcastLog(name, stream, message)
|
|
106
|
+
}
|
|
107
|
+
runner.onStatusChange = (name, status) => {
|
|
108
|
+
this.updateFunctionStatus(name, status)
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await runner.start()
|
|
112
|
+
this.runners.set(config.name, runner)
|
|
113
|
+
this.updateFunctionStatus(config.name, FunctionStatuses.RUNNING, null, port, runner.process?.pid)
|
|
114
|
+
return runner.getStatus()
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.releasePort(port)
|
|
117
|
+
this.updateFunctionStatus(config.name, FunctionStatuses.ERROR, error.message)
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async removeFunction (name) {
|
|
123
|
+
const runner = this.runners.get(name)
|
|
124
|
+
if (runner) {
|
|
125
|
+
const port = runner.port
|
|
126
|
+
await runner.stop()
|
|
127
|
+
this.releasePort(port)
|
|
128
|
+
this.runners.delete(name)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async startFunction (name) {
|
|
133
|
+
const fn = SqliteStore.find(CLOUD_FUNCTIONS, { name }).pop()
|
|
134
|
+
if (!fn) throw new Error(`Function not found: ${name}`)
|
|
135
|
+
if (this.runners.has(name)) {
|
|
136
|
+
const runner = this.runners.get(name)
|
|
137
|
+
if (runner.status === FunctionStatuses.RUNNING) return runner.getStatus()
|
|
138
|
+
}
|
|
139
|
+
return this.deployFunction(fn)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async stopFunction (name) {
|
|
143
|
+
const runner = this.runners.get(name)
|
|
144
|
+
if (!runner) throw new Error(`Function not running: ${name}`)
|
|
145
|
+
const port = runner.port
|
|
146
|
+
await runner.stop()
|
|
147
|
+
this.releasePort(port)
|
|
148
|
+
this.runners.delete(name)
|
|
149
|
+
this.updateFunctionStatus(name, FunctionStatuses.STOPPED)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async restartFunction (name) {
|
|
153
|
+
await this.stopFunction(name)
|
|
154
|
+
return this.startFunction(name)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getEndpointUrl (name) {
|
|
158
|
+
const runner = this.runners.get(name)
|
|
159
|
+
if (!runner || runner.status !== FunctionStatuses.RUNNING) return null
|
|
160
|
+
return `http://localhost:${runner.port}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getAllStatuses () {
|
|
164
|
+
const statuses = []
|
|
165
|
+
for (const [, runner] of this.runners) {
|
|
166
|
+
statuses.push(runner.getStatus())
|
|
167
|
+
}
|
|
168
|
+
return statuses
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
allocatePort () {
|
|
172
|
+
const iterator = this.portPool.values()
|
|
173
|
+
const first = iterator.next()
|
|
174
|
+
if (first.done) return null
|
|
175
|
+
this.portPool.delete(first.value)
|
|
176
|
+
return first.value
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
releasePort (port) {
|
|
180
|
+
if (port) this.portPool.add(port)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
updateFunctionStatus (name, status, errorMessage, port, pid) {
|
|
184
|
+
try {
|
|
185
|
+
const fn = SqliteStore.find(CLOUD_FUNCTIONS, { name }).pop()
|
|
186
|
+
if (!fn) return
|
|
187
|
+
const updates = { status, updatedAt: new Date().toISOString() }
|
|
188
|
+
if (errorMessage !== undefined) updates.errorMessage = errorMessage
|
|
189
|
+
if (port !== undefined) updates.port = port
|
|
190
|
+
if (pid !== undefined) updates.pid = pid
|
|
191
|
+
SqliteStore.update(CLOUD_FUNCTIONS, fn.id, updates)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
Logger.log({
|
|
194
|
+
level: 'warn',
|
|
195
|
+
message: 'Failed to update function status in DB',
|
|
196
|
+
data: { name, status, error: error.message }
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Log SSE ---
|
|
202
|
+
|
|
203
|
+
addLogClient (id, ctx, filters = {}) {
|
|
204
|
+
this.sseClients.set(id, { ctx, filters })
|
|
205
|
+
this.startHeartbeat()
|
|
206
|
+
Logger.log({
|
|
207
|
+
level: 'info',
|
|
208
|
+
message: 'Functions log SSE client connected',
|
|
209
|
+
data: { connectionId: id, totalClients: this.sseClients.size }
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
removeLogClient (id) {
|
|
214
|
+
const removed = this.sseClients.delete(id)
|
|
215
|
+
if (removed && this.sseClients.size === 0 && this.invocationSseClients.size === 0) {
|
|
216
|
+
this.stopHeartbeat()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
broadcastLog (functionName, level, message) {
|
|
221
|
+
const trimmedMessage = message.trimEnd()
|
|
222
|
+
const now = new Date().toISOString()
|
|
223
|
+
// Broadcast to Functions-specific SSE clients
|
|
224
|
+
if (this.sseClients.size > 0) {
|
|
225
|
+
const payload = { functionName, level, message: trimmedMessage, timestamp: now }
|
|
226
|
+
for (const [id, { ctx, filters }] of this.sseClients) {
|
|
227
|
+
if (filters.functionName && filters.functionName !== functionName) continue
|
|
228
|
+
try {
|
|
229
|
+
this.sendSSE(ctx, 'log', payload)
|
|
230
|
+
} catch {
|
|
231
|
+
this.removeLogClient(id)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Also write to the unified logging system
|
|
236
|
+
try {
|
|
237
|
+
const severity = level === 'stderr' ? 'ERROR' : 'INFO'
|
|
238
|
+
let jsonPayload = null
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(trimmedMessage)
|
|
241
|
+
if (parsed && typeof parsed === 'object') {
|
|
242
|
+
jsonPayload = JSON.stringify(parsed)
|
|
243
|
+
}
|
|
244
|
+
} catch {}
|
|
245
|
+
const entry = {
|
|
246
|
+
_id: uuid(),
|
|
247
|
+
logName: 'projects/dev-tools/logs/cloud-functions',
|
|
248
|
+
serviceName: functionName,
|
|
249
|
+
severity,
|
|
250
|
+
textPayload: trimmedMessage,
|
|
251
|
+
jsonPayload,
|
|
252
|
+
timestamp: now,
|
|
253
|
+
receiveTimestamp: now,
|
|
254
|
+
insertId: uuid(),
|
|
255
|
+
source: 'cloud-functions',
|
|
256
|
+
labels: JSON.stringify({ functionName, source: 'cloud-functions' })
|
|
257
|
+
}
|
|
258
|
+
SqliteStore.create(LOGGING_ENTRIES, entry)
|
|
259
|
+
LogBroadcaster.broadcast(entry)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
Logger.log({ level: 'error', message: 'Failed to write function log to logging system', data: { functionName, error: error.message } })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Invocation SSE ---
|
|
266
|
+
|
|
267
|
+
addInvocationClient (id, ctx, filters = {}) {
|
|
268
|
+
this.invocationSseClients.set(id, { ctx, filters })
|
|
269
|
+
this.startHeartbeat()
|
|
270
|
+
Logger.log({
|
|
271
|
+
level: 'info',
|
|
272
|
+
message: 'Functions invocation SSE client connected',
|
|
273
|
+
data: { connectionId: id, totalClients: this.invocationSseClients.size }
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
removeInvocationClient (id) {
|
|
278
|
+
const removed = this.invocationSseClients.delete(id)
|
|
279
|
+
if (removed && this.sseClients.size === 0 && this.invocationSseClients.size === 0) {
|
|
280
|
+
this.stopHeartbeat()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
broadcastInvocation (invocation) {
|
|
285
|
+
if (this.invocationSseClients.size === 0) return
|
|
286
|
+
for (const [id, { ctx, filters }] of this.invocationSseClients) {
|
|
287
|
+
if (filters.functionName && filters.functionName !== invocation.functionName) continue
|
|
288
|
+
try {
|
|
289
|
+
this.sendSSE(ctx, 'invocation', invocation)
|
|
290
|
+
} catch {
|
|
291
|
+
this.removeInvocationClient(id)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- File Change SSE ---
|
|
297
|
+
|
|
298
|
+
addFileChangeClient (id, ctx, functionName) {
|
|
299
|
+
this.fileChangeClients.set(id, { ctx, functionName })
|
|
300
|
+
this.startHeartbeat()
|
|
301
|
+
this.startFileWatcher(functionName)
|
|
302
|
+
Logger.log({ level: 'info', message: 'File change SSE client connected', data: { connectionId: id, functionName } })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
removeFileChangeClient (id) {
|
|
306
|
+
const client = this.fileChangeClients.get(id)
|
|
307
|
+
this.fileChangeClients.delete(id)
|
|
308
|
+
if (client) {
|
|
309
|
+
const stillWatching = Array.from(this.fileChangeClients.values())
|
|
310
|
+
.some(c => c.functionName === client.functionName)
|
|
311
|
+
if (!stillWatching) {
|
|
312
|
+
this.stopFileWatcher(client.functionName)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (this.sseClients.size === 0 && this.invocationSseClients.size === 0 && this.fileChangeClients.size === 0) {
|
|
316
|
+
this.stopHeartbeat()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
startFileWatcher (functionName) {
|
|
321
|
+
if (this.fileWatchers.has(functionName)) return
|
|
322
|
+
const { data } = SqliteStore.list(CLOUD_FUNCTIONS, { filter: { name: functionName }, limit: 1 })
|
|
323
|
+
const fn = data[0]
|
|
324
|
+
if (!fn || !fn.sourcePath) return
|
|
325
|
+
const watchDir = fn.sourcePath
|
|
326
|
+
Logger.log({ level: 'info', message: 'Starting file watcher', data: { functionName, watchDir } })
|
|
327
|
+
const watcher = chokidar.watch(watchDir, {
|
|
328
|
+
ignoreInitial: true,
|
|
329
|
+
ignored: [
|
|
330
|
+
'**/node_modules/**',
|
|
331
|
+
'**/.git/**',
|
|
332
|
+
'**/package-lock.json'
|
|
333
|
+
],
|
|
334
|
+
usePolling: true,
|
|
335
|
+
interval: 1000,
|
|
336
|
+
awaitWriteFinish: {
|
|
337
|
+
stabilityThreshold: 500,
|
|
338
|
+
pollInterval: 200
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
const handleChange = (eventType, filePath) => {
|
|
342
|
+
const relativePath = path.relative(watchDir, filePath)
|
|
343
|
+
if (relativePath.startsWith('node_modules')) return
|
|
344
|
+
Logger.log({
|
|
345
|
+
level: 'debug',
|
|
346
|
+
message: 'File change detected',
|
|
347
|
+
data: { functionName, eventType, relativePath }
|
|
348
|
+
})
|
|
349
|
+
for (const [id, client] of this.fileChangeClients) {
|
|
350
|
+
if (client.functionName !== functionName) continue
|
|
351
|
+
try {
|
|
352
|
+
this.sendSSE(client.ctx, 'fileChange', { functionName, eventType, filePath: relativePath })
|
|
353
|
+
} catch {
|
|
354
|
+
this.removeFileChangeClient(id)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
watcher.on('ready', () => {
|
|
359
|
+
Logger.log({ level: 'info', message: 'File watcher ready', data: { functionName, watchDir } })
|
|
360
|
+
})
|
|
361
|
+
watcher.on('error', (err) => {
|
|
362
|
+
Logger.log({ level: 'error', message: 'File watcher error', data: { functionName, error: err.message } })
|
|
363
|
+
})
|
|
364
|
+
watcher.on('change', (fp) => handleChange('change', fp))
|
|
365
|
+
watcher.on('add', (fp) => handleChange('add', fp))
|
|
366
|
+
watcher.on('unlink', (fp) => handleChange('unlink', fp))
|
|
367
|
+
this.fileWatchers.set(functionName, watcher)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
stopFileWatcher (functionName) {
|
|
371
|
+
const watcher = this.fileWatchers.get(functionName)
|
|
372
|
+
if (watcher) {
|
|
373
|
+
watcher.close()
|
|
374
|
+
this.fileWatchers.delete(functionName)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- SSE Helpers ---
|
|
379
|
+
|
|
380
|
+
sendSSE (ctx, event, data) {
|
|
381
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
382
|
+
ctx.res.write(message)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
startHeartbeat () {
|
|
386
|
+
if (this.heartbeatId) return
|
|
387
|
+
this.heartbeatId = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
stopHeartbeat () {
|
|
391
|
+
if (this.heartbeatId) {
|
|
392
|
+
clearInterval(this.heartbeatId)
|
|
393
|
+
this.heartbeatId = null
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
sendHeartbeat () {
|
|
398
|
+
const allClients = [
|
|
399
|
+
...Array.from(this.sseClients.entries()),
|
|
400
|
+
...Array.from(this.invocationSseClients.entries()),
|
|
401
|
+
...Array.from(this.fileChangeClients.entries())
|
|
402
|
+
]
|
|
403
|
+
for (const [id, { ctx }] of allClients) {
|
|
404
|
+
try {
|
|
405
|
+
ctx.res.write(': heartbeat\n\n')
|
|
406
|
+
} catch {
|
|
407
|
+
this.sseClients.delete(id)
|
|
408
|
+
this.invocationSseClients.delete(id)
|
|
409
|
+
this.fileChangeClients.delete(id)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (this.sseClients.size === 0 && this.invocationSseClients.size === 0 && this.fileChangeClients.size === 0) {
|
|
413
|
+
this.stopHeartbeat()
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export const FunctionsService = new FunctionsServiceClass()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import httpProxy from 'http-proxy'
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
3
|
+
import { SqliteStore } from './SqliteStore.js'
|
|
4
|
+
import { Logger } from './Logger.js'
|
|
5
|
+
import { LogBroadcaster } from './LogBroadcaster.js'
|
|
6
|
+
import { HTTP_TRAFFIC } from '../db/Tables.js'
|
|
7
|
+
import { ServiceNames } from '../enums/ServiceNames.js'
|
|
8
|
+
|
|
9
|
+
const BINARY_CONTENT_TYPES = [
|
|
10
|
+
'image/', 'audio/', 'video/', 'application/octet-stream',
|
|
11
|
+
'application/zip', 'application/gzip', 'application/pdf'
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
function isBinaryContentType (contentType) {
|
|
15
|
+
if (!contentType) return false
|
|
16
|
+
return BINARY_CONTENT_TYPES.some(type => contentType.startsWith(type))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tryParseJson (str) {
|
|
20
|
+
if (!str || typeof str !== 'string') return null
|
|
21
|
+
const trimmed = str.trim()
|
|
22
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
23
|
+
try { return JSON.parse(trimmed) } catch { return null }
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseCookies (cookieHeader) {
|
|
29
|
+
if (!cookieHeader) return null
|
|
30
|
+
const cookies = {}
|
|
31
|
+
cookieHeader.split(';').forEach(part => {
|
|
32
|
+
const [key, ...rest] = part.trim().split('=')
|
|
33
|
+
if (key) cookies[key.trim()] = rest.join('=').trim()
|
|
34
|
+
})
|
|
35
|
+
return Object.keys(cookies).length > 0 ? cookies : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractServiceName (hostname) {
|
|
39
|
+
if (!hostname) return null
|
|
40
|
+
return hostname.split(':')[0]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class HttpProxyClass {
|
|
44
|
+
constructor () {
|
|
45
|
+
this.proxy = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
initialize () {
|
|
49
|
+
this.proxy = httpProxy.createProxyServer({
|
|
50
|
+
changeOrigin: true,
|
|
51
|
+
secure: false,
|
|
52
|
+
xfwd: true,
|
|
53
|
+
proxyTimeout: 30000
|
|
54
|
+
})
|
|
55
|
+
this.proxy.on('error', (err, req, res) => {
|
|
56
|
+
Logger.log({
|
|
57
|
+
level: 'error',
|
|
58
|
+
message: 'HTTP proxy error',
|
|
59
|
+
data: { error: err.message, url: req.url, method: req.method }
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
Logger.log({
|
|
63
|
+
level: 'info',
|
|
64
|
+
message: 'HttpProxy initialized'
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
parseTargetUrl (rawPath) {
|
|
69
|
+
const match = rawPath.match(/^\/proxy\/(.+)$/)
|
|
70
|
+
if (!match) return null
|
|
71
|
+
let rawTarget = match[1]
|
|
72
|
+
if (!rawTarget.match(/^https?:\/\//)) {
|
|
73
|
+
rawTarget = 'http://' + rawTarget
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(rawTarget)
|
|
77
|
+
return {
|
|
78
|
+
origin: url.origin,
|
|
79
|
+
host: url.host,
|
|
80
|
+
hostname: url.hostname,
|
|
81
|
+
path: url.pathname + url.search,
|
|
82
|
+
fullUrl: rawTarget,
|
|
83
|
+
serviceName: extractServiceName(url.hostname)
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logTraffic (entry) {
|
|
91
|
+
const {
|
|
92
|
+
method, targetUrl, targetHost, targetPath,
|
|
93
|
+
queryParams, requestHeaders, requestBody, requestCookies,
|
|
94
|
+
contentType, statusCode, responseHeaders, responseBody,
|
|
95
|
+
responseContentType, responseTimeMs, startedAt, completedAt,
|
|
96
|
+
sourceService, error, traceId
|
|
97
|
+
} = entry
|
|
98
|
+
const id = uuidv4()
|
|
99
|
+
const trafficBody = isBinaryContentType(contentType)
|
|
100
|
+
? `[binary, ${requestBody?.length || 0} bytes]`
|
|
101
|
+
: requestBody || null
|
|
102
|
+
const trafficResponseBody = isBinaryContentType(responseContentType)
|
|
103
|
+
? `[binary, ${responseBody?.length || 0} bytes]`
|
|
104
|
+
: responseBody || null
|
|
105
|
+
try {
|
|
106
|
+
SqliteStore.create(HTTP_TRAFFIC, {
|
|
107
|
+
_id: id,
|
|
108
|
+
method,
|
|
109
|
+
targetUrl,
|
|
110
|
+
targetHost,
|
|
111
|
+
targetPath,
|
|
112
|
+
queryParams: queryParams || null,
|
|
113
|
+
requestHeaders: requestHeaders || null,
|
|
114
|
+
requestBody: trafficBody,
|
|
115
|
+
requestCookies: requestCookies || null,
|
|
116
|
+
contentType: contentType || null,
|
|
117
|
+
statusCode: statusCode || null,
|
|
118
|
+
responseHeaders: responseHeaders || null,
|
|
119
|
+
responseBody: trafficResponseBody,
|
|
120
|
+
responseContentType: responseContentType || null,
|
|
121
|
+
responseTimeMs: responseTimeMs || null,
|
|
122
|
+
startedAt,
|
|
123
|
+
completedAt: completedAt || null,
|
|
124
|
+
sourceService: sourceService || null,
|
|
125
|
+
error: error || null,
|
|
126
|
+
traceId: traceId || null
|
|
127
|
+
})
|
|
128
|
+
} catch (err) {
|
|
129
|
+
Logger.log({ level: 'error', message: 'Failed to store http traffic', data: { error: err.message } })
|
|
130
|
+
}
|
|
131
|
+
const now = new Date().toISOString()
|
|
132
|
+
const severity = error || (statusCode && statusCode >= 400) ? 'ERROR' : 'INFO'
|
|
133
|
+
const statusText = error ? `ERR ${error}` : `${statusCode}`
|
|
134
|
+
const textPayload = `${method} ${targetUrl} → ${statusText} (${responseTimeMs}ms)`
|
|
135
|
+
const logRequestBody = tryParseJson(requestBody) || requestBody || null
|
|
136
|
+
const logResponseBody = tryParseJson(responseBody) || responseBody || null
|
|
137
|
+
const jsonPayload = {
|
|
138
|
+
method,
|
|
139
|
+
targetUrl,
|
|
140
|
+
targetHost,
|
|
141
|
+
statusCode,
|
|
142
|
+
responseTimeMs,
|
|
143
|
+
error: error || null,
|
|
144
|
+
...(queryParams && { queryParams }),
|
|
145
|
+
...(logRequestBody && { requestBody: logRequestBody }),
|
|
146
|
+
...(logResponseBody && { responseBody: logResponseBody })
|
|
147
|
+
}
|
|
148
|
+
const logEntry = {
|
|
149
|
+
_id: uuidv4(),
|
|
150
|
+
logName: 'projects/dev-tools/logs/http',
|
|
151
|
+
source: ServiceNames.HTTP_PROXY,
|
|
152
|
+
serviceName: ServiceNames.HTTP_PROXY,
|
|
153
|
+
severity,
|
|
154
|
+
textPayload,
|
|
155
|
+
jsonPayload,
|
|
156
|
+
timestamp: now,
|
|
157
|
+
receiveTimestamp: now,
|
|
158
|
+
insertId: uuidv4(),
|
|
159
|
+
createdAt: now
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
SqliteStore.create('logging_entries', logEntry)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
Logger.log({ level: 'error', message: 'Failed to store http proxy log', data: { error: err.message } })
|
|
165
|
+
}
|
|
166
|
+
LogBroadcaster.broadcast(logEntry)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
listTraffic (options = {}) {
|
|
170
|
+
const { filter, limit = 50, offset = 0 } = options
|
|
171
|
+
const listOptions = {
|
|
172
|
+
orderBy: 'created_at DESC',
|
|
173
|
+
limit,
|
|
174
|
+
offset
|
|
175
|
+
}
|
|
176
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
177
|
+
listOptions.filter = filter
|
|
178
|
+
}
|
|
179
|
+
return SqliteStore.list(HTTP_TRAFFIC, listOptions)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getTrafficEntry (id) {
|
|
183
|
+
return SqliteStore.get(HTTP_TRAFFIC, id, '_id')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clearTraffic () {
|
|
187
|
+
SqliteStore.clear(HTTP_TRAFFIC)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getStats () {
|
|
191
|
+
try {
|
|
192
|
+
const total = SqliteStore.db.prepare(`SELECT COUNT(*) as count FROM ${HTTP_TRAFFIC}`).get().count
|
|
193
|
+
const byMethod = SqliteStore.db.prepare(`
|
|
194
|
+
SELECT method, COUNT(*) as count FROM ${HTTP_TRAFFIC} GROUP BY method ORDER BY count DESC
|
|
195
|
+
`).all()
|
|
196
|
+
const byHost = SqliteStore.db.prepare(`
|
|
197
|
+
SELECT source_service, COUNT(*) as count FROM ${HTTP_TRAFFIC} GROUP BY source_service ORDER BY count DESC
|
|
198
|
+
`).all()
|
|
199
|
+
const byStatus = SqliteStore.db.prepare(`
|
|
200
|
+
SELECT status_code, COUNT(*) as count FROM ${HTTP_TRAFFIC} GROUP BY status_code ORDER BY count DESC
|
|
201
|
+
`).all()
|
|
202
|
+
const avgResponseTime = SqliteStore.db.prepare(`
|
|
203
|
+
SELECT AVG(response_time_ms) as avg_ms FROM ${HTTP_TRAFFIC} WHERE response_time_ms IS NOT NULL
|
|
204
|
+
`).get()
|
|
205
|
+
const errorCount = SqliteStore.db.prepare(`
|
|
206
|
+
SELECT COUNT(*) as count FROM ${HTTP_TRAFFIC} WHERE status_code >= 400 OR error IS NOT NULL
|
|
207
|
+
`).get().count
|
|
208
|
+
return {
|
|
209
|
+
total,
|
|
210
|
+
errorCount,
|
|
211
|
+
errorRate: total > 0 ? (errorCount / total * 100).toFixed(1) : '0.0',
|
|
212
|
+
avgResponseTimeMs: Math.round(avgResponseTime?.avg_ms || 0),
|
|
213
|
+
byMethod,
|
|
214
|
+
byHost,
|
|
215
|
+
byStatus
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
Logger.log({ level: 'error', message: 'Failed to get http traffic stats', data: { error: err.message } })
|
|
219
|
+
return { total: 0, errorCount: 0, errorRate: '0.0', avgResponseTimeMs: 0, byMethod: [], byHost: [], byStatus: [] }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const HttpProxy = new HttpProxyClass()
|