@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,309 @@
1
+ # GCP Pub/Sub Emulation API
2
+
3
+ This module provides a Google Cloud Pub/Sub emulator that's compatible with the official `@google-cloud/pubsub` SDK.
4
+
5
+ ## Overview
6
+
7
+ The Pub/Sub emulator runs on port **8085** and implements the GCP Cloud Pub/Sub REST API v1, allowing you to develop and test applications locally without connecting to actual GCP services.
8
+
9
+ ## Features
10
+
11
+ - **GCP-Compatible REST API**: Implements the official Pub/Sub v1 REST API
12
+ - **Full Topic Management**: Create, get, list, and delete topics
13
+ - **Subscription Support**: Pull subscriptions with acknowledgment
14
+ - **Message Publishing**: Publish single or batch messages
15
+ - **Persistent Storage**: Messages stored in memory with JSON file persistence
16
+ - **SDK Compatible**: Works with `@google-cloud/pubsub` Node.js SDK
17
+
18
+ ## Quick Start
19
+
20
+ ### 1. Start the Server
21
+
22
+ ```bash
23
+ npm run dev
24
+ ```
25
+
26
+ The Pub/Sub emulator will start on port 8085.
27
+
28
+ ### 2. Configure Your Application
29
+
30
+ Set the emulator host environment variable:
31
+
32
+ ```bash
33
+ export PUBSUB_EMULATOR_HOST=localhost:8085
34
+ ```
35
+
36
+ ### 3. Use the Official SDK
37
+
38
+ ```javascript
39
+ import { PubSub } from '@google-cloud/pubsub'
40
+
41
+ const pubsub = new PubSub({
42
+ projectId: 'my-project'
43
+ })
44
+
45
+ // Create a topic
46
+ const [topic] = await pubsub.createTopic('my-topic')
47
+
48
+ // Publish a message
49
+ await topic.publishMessage({ data: Buffer.from('Hello World') })
50
+
51
+ // Create a subscription
52
+ const [subscription] = await topic.createSubscription('my-subscription')
53
+
54
+ // Pull messages
55
+ const [messages] = await subscription.pull({ maxMessages: 10 })
56
+
57
+ // Acknowledge messages
58
+ messages.forEach(message => {
59
+ console.log(message.data.toString())
60
+ message.ack()
61
+ })
62
+ ```
63
+
64
+ ## REST API Endpoints
65
+
66
+ ### Topics
67
+
68
+ #### Create Topic
69
+ ```
70
+ PUT /v1/projects/{project}/topics/{topic}
71
+ ```
72
+
73
+ **Response:**
74
+ ```json
75
+ {
76
+ "name": "projects/my-project/topics/my-topic",
77
+ "labels": {},
78
+ "messageStoragePolicy": {},
79
+ "messageRetentionDuration": "604800s"
80
+ }
81
+ ```
82
+
83
+ #### Get Topic
84
+ ```
85
+ GET /v1/projects/{project}/topics/{topic}
86
+ ```
87
+
88
+ #### List Topics
89
+ ```
90
+ GET /v1/projects/{project}/topics
91
+ ```
92
+
93
+ **Query Parameters:**
94
+ - `pageSize` (optional): Maximum number of topics to return
95
+ - `pageToken` (optional): Token for pagination
96
+
97
+ #### Delete Topic
98
+ ```
99
+ DELETE /v1/projects/{project}/topics/{topic}
100
+ ```
101
+
102
+ #### Publish Messages
103
+ ```
104
+ POST /v1/projects/{project}/topics/{topic}:publish
105
+ ```
106
+
107
+ **Request Body:**
108
+ ```json
109
+ {
110
+ "messages": [
111
+ {
112
+ "data": "SGVsbG8gV29ybGQ=",
113
+ "attributes": {
114
+ "key": "value"
115
+ },
116
+ "orderingKey": ""
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ **Response:**
123
+ ```json
124
+ {
125
+ "messageIds": [
126
+ "550e8400-e29b-41d4-a716-446655440000"
127
+ ]
128
+ }
129
+ ```
130
+
131
+ ### Subscriptions
132
+
133
+ #### Create Subscription
134
+ ```
135
+ PUT /v1/projects/{project}/subscriptions/{subscription}
136
+ ```
137
+
138
+ **Request Body:**
139
+ ```json
140
+ {
141
+ "topic": "projects/my-project/topics/my-topic",
142
+ "ackDeadlineSeconds": 10,
143
+ "pushConfig": {}
144
+ }
145
+ ```
146
+
147
+ #### Get Subscription
148
+ ```
149
+ GET /v1/projects/{project}/subscriptions/{subscription}
150
+ ```
151
+
152
+ #### List Subscriptions
153
+ ```
154
+ GET /v1/projects/{project}/subscriptions
155
+ ```
156
+
157
+ #### Delete Subscription
158
+ ```
159
+ DELETE /v1/projects/{project}/subscriptions/{subscription}
160
+ ```
161
+
162
+ #### Pull Messages
163
+ ```
164
+ POST /v1/projects/{project}/subscriptions/{subscription}:pull
165
+ ```
166
+
167
+ **Request Body:**
168
+ ```json
169
+ {
170
+ "maxMessages": 100,
171
+ "returnImmediately": false
172
+ }
173
+ ```
174
+
175
+ **Response:**
176
+ ```json
177
+ {
178
+ "receivedMessages": [
179
+ {
180
+ "ackId": "550e8400-e29b-41d4-a716-446655440001",
181
+ "message": {
182
+ "data": "SGVsbG8gV29ybGQ=",
183
+ "attributes": {
184
+ "key": "value"
185
+ },
186
+ "messageId": "550e8400-e29b-41d4-a716-446655440000",
187
+ "publishTime": "2026-02-06T10:30:00.000Z",
188
+ "orderingKey": ""
189
+ }
190
+ }
191
+ ]
192
+ }
193
+ ```
194
+
195
+ #### Acknowledge Messages
196
+ ```
197
+ POST /v1/projects/{project}/subscriptions/{subscription}:acknowledge
198
+ ```
199
+
200
+ **Request Body:**
201
+ ```json
202
+ {
203
+ "ackIds": [
204
+ "550e8400-e29b-41d4-a716-446655440001"
205
+ ]
206
+ }
207
+ ```
208
+
209
+ ## Storage
210
+
211
+ The emulator uses a hybrid memory + JSON storage approach:
212
+
213
+ - **In-Memory**: All operations are performed in memory for fast access
214
+ - **Persistent**: Data is automatically saved to JSON files every 5 seconds
215
+ - **Collections**:
216
+ - `data/pubsub/topics.json` - Topic metadata
217
+ - `data/pubsub/subscriptions.json` - Subscription configurations
218
+ - `data/pubsub/messages.json` - Published messages
219
+
220
+ ## Error Responses
221
+
222
+ All errors follow the GCP error format:
223
+
224
+ ```json
225
+ {
226
+ "error": {
227
+ "code": 404,
228
+ "message": "Topic not found",
229
+ "status": "NOT_FOUND"
230
+ }
231
+ }
232
+ ```
233
+
234
+ ### Error Codes
235
+
236
+ - `400 INVALID_ARGUMENT`: Invalid request parameters
237
+ - `404 NOT_FOUND`: Resource not found
238
+ - `409 ALREADY_EXISTS`: Resource already exists
239
+ - `500 INTERNAL`: Internal server error
240
+
241
+ ## Testing
242
+
243
+ ### Unit Tests
244
+
245
+ Run the unit tests (no server required):
246
+
247
+ ```bash
248
+ npm run test:emulation
249
+ ```
250
+
251
+ All business logic tests will pass without the server running.
252
+
253
+ ### Integration Tests
254
+
255
+ To run the HTTP integration tests:
256
+
257
+ 1. Start the server in one terminal:
258
+ ```bash
259
+ npm run dev
260
+ ```
261
+
262
+ 2. Run the tests in another terminal:
263
+ ```bash
264
+ npm run test:emulation
265
+ ```
266
+
267
+ The integration tests verify the HTTP endpoints are working correctly.
268
+
269
+ ## Architecture
270
+
271
+ ```
272
+ src/emulation/pubsub/
273
+ ├── Server.js # Koa server on port 8085
274
+ ├── Router.js # REST API routes
275
+ ├── Controllers.js # Request handlers
276
+ └── Logic.js # Business logic
277
+ ```
278
+
279
+ ## Differences from Production GCP Pub/Sub
280
+
281
+ This emulator implements core Pub/Sub functionality for local development. Some advanced features are not implemented:
282
+
283
+ - Push subscriptions (only pull supported)
284
+ - Dead letter queues
285
+ - Message ordering (accepted but not enforced)
286
+ - Schemas
287
+ - Snapshots
288
+ - Seeking
289
+
290
+ For most development and testing scenarios, the implemented features are sufficient.
291
+
292
+ ## Integration with Goki Services
293
+
294
+ ### device-simulator
295
+
296
+ ```bash
297
+ # config.standalone
298
+ PUBSUB_EMULATOR_HOST=localhost:8085
299
+ ```
300
+
301
+ ### core-key
302
+
303
+ The `@gokiteam/pubsub` package automatically detects the `PUBSUB_EMULATOR_HOST` environment variable and connects to the emulator.
304
+
305
+ ## Related Documentation
306
+
307
+ - [GCP Pub/Sub REST API Reference](https://cloud.google.com/pubsub/docs/reference/rest)
308
+ - [Platform Specification](../../../plans/platform-specification.md)
309
+ - [MemoryJsonStore](../../singletons/MemoryJsonStore.js)
@@ -0,0 +1,33 @@
1
+ import KoaRouter from 'koa-router'
2
+ import {
3
+ createTopicController,
4
+ getTopicController,
5
+ listTopicsController,
6
+ deleteTopicController,
7
+ publishMessagesController,
8
+ createSubscriptionController,
9
+ getSubscriptionController,
10
+ listSubscriptionsController,
11
+ deleteSubscriptionController,
12
+ pullMessagesController,
13
+ acknowledgeMessagesController
14
+ } from './Controllers.js'
15
+
16
+ const router = new KoaRouter()
17
+
18
+ // Topic routes
19
+ router.put('/v1/projects/:project/topics/:topic', createTopicController)
20
+ router.get('/v1/projects/:project/topics/:topic', getTopicController)
21
+ router.get('/v1/projects/:project/topics', listTopicsController)
22
+ router.delete('/v1/projects/:project/topics/:topic', deleteTopicController)
23
+ router.post('/v1/projects/:project/topics/:topic\\:publish', publishMessagesController)
24
+
25
+ // Subscription routes
26
+ router.put('/v1/projects/:project/subscriptions/:subscription', createSubscriptionController)
27
+ router.get('/v1/projects/:project/subscriptions/:subscription', getSubscriptionController)
28
+ router.get('/v1/projects/:project/subscriptions', listSubscriptionsController)
29
+ router.delete('/v1/projects/:project/subscriptions/:subscription', deleteSubscriptionController)
30
+ router.post('/v1/projects/:project/subscriptions/:subscription\\:pull', pullMessagesController)
31
+ router.post('/v1/projects/:project/subscriptions/:subscription\\:acknowledge', acknowledgeMessagesController)
32
+
33
+ export const Router = router
@@ -0,0 +1,104 @@
1
+ import Koa from 'koa'
2
+ import bodyParser from 'koa-bodyparser'
3
+ import { Application } from '../../configs/Application.js'
4
+ import { Logger } from '../../singletons/Logger.js'
5
+ import { SqliteStore } from '../../singletons/SqliteStore.js'
6
+ import { Router } from './Router.js'
7
+ import { shadowSubscriptionManager } from './ShadowSubscriptionManager.js'
8
+ import { shadowPoller } from './ShadowPoller.js'
9
+
10
+ const { environment, runId, ports } = Application
11
+
12
+ /**
13
+ * Initialize Pub/Sub collections (no-op for SQLite - tables created at DB initialization)
14
+ */
15
+ const initializeCollections = () => {
16
+ Logger.log({
17
+ level: 'info',
18
+ message: 'Pub/Sub tables ready (SQLite)',
19
+ data: { tables: ['pubsub_topics', 'pubsub_subscriptions', 'pubsub_messages'] }
20
+ })
21
+ }
22
+
23
+ /**
24
+ * Start Pub/Sub emulation server
25
+ */
26
+ export const startPubSubServer = async () => {
27
+ try {
28
+ // Initialize collections
29
+ initializeCollections()
30
+
31
+ const app = new Koa()
32
+
33
+ // Basic error handler
34
+ app.use(async (ctx, next) => {
35
+ try {
36
+ await next()
37
+ } catch (error) {
38
+ Logger.log({
39
+ level: 'error',
40
+ message: 'Pub/Sub server error',
41
+ data: { error: error.message, stack: error.stack, path: ctx.path }
42
+ })
43
+ ctx.status = error.status || 500
44
+ ctx.body = {
45
+ error: {
46
+ code: ctx.status,
47
+ message: error.message || 'Internal server error',
48
+ status: 'INTERNAL'
49
+ }
50
+ }
51
+ }
52
+ })
53
+
54
+ // Body parser
55
+ app.use(bodyParser())
56
+
57
+ // Logger middleware
58
+ app.use(async (ctx, next) => {
59
+ const start = Date.now()
60
+ await next()
61
+ const ms = Date.now() - start
62
+ Logger.log({
63
+ level: 'debug',
64
+ message: 'Pub/Sub request',
65
+ data: {
66
+ method: ctx.method,
67
+ path: ctx.path,
68
+ status: ctx.status,
69
+ duration: `${ms}ms`
70
+ }
71
+ })
72
+ })
73
+
74
+ // Routes
75
+ app.use(Router.routes())
76
+ app.use(Router.allowedMethods())
77
+
78
+ const server = app.listen(ports.pubsub, () => {
79
+ Logger.log({
80
+ level: 'info',
81
+ message: 'Pub/Sub emulation server started',
82
+ data: {
83
+ environment,
84
+ runId,
85
+ port: ports.pubsub,
86
+ endpoint: `http://localhost:${ports.pubsub}`
87
+ }
88
+ })
89
+ })
90
+
91
+ // Start shadow subscription manager and poller for message capture
92
+ await shadowSubscriptionManager.start()
93
+ await shadowPoller.start()
94
+
95
+ return server
96
+ } catch (error) {
97
+ Logger.log({
98
+ level: 'error',
99
+ message: 'Failed to start Pub/Sub emulation server',
100
+ data: { error: error.message, stack: error.stack }
101
+ })
102
+ throw error
103
+ }
104
+ }
@@ -0,0 +1,276 @@
1
+ import { v4 as uuid } from 'uuid'
2
+ import { Application } from '../../configs/Application.js'
3
+ import { Logger } from '../../singletons/Logger.js'
4
+ import { SqliteStore } from '../../singletons/SqliteStore.js'
5
+ import { MessageBroadcaster } from '../../singletons/MessageBroadcaster.js'
6
+ import { FunctionTriggerDispatcher } from '../../singletons/FunctionTriggerDispatcher.js'
7
+ import { shadowSubscriptionManager } from './ShadowSubscriptionManager.js'
8
+
9
+ const { pubsub } = Application
10
+
11
+ /**
12
+ * Extract sender from message attributes
13
+ */
14
+ const extractSender = attributes => {
15
+ if (!attributes) return null
16
+ return attributes.sender || attributes.source || attributes.userId || null
17
+ }
18
+
19
+ /**
20
+ * Check if a message has meaningful data worth storing in history.
21
+ * Filters out empty payloads like {} or empty strings.
22
+ */
23
+ const hasMeaningfulData = data => {
24
+ if (!data) return false
25
+ try {
26
+ const decoded = Buffer.from(data, 'base64').toString('utf-8')
27
+ const trimmed = decoded.trim()
28
+ if (!trimmed || trimmed === '{}' || trimmed === '""' || trimmed === 'null') return false
29
+ return true
30
+ } catch {
31
+ return !!data
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Polls shadow subscriptions to capture messages for logging and UI display.
37
+ */
38
+ export class ShadowPoller {
39
+ constructor () {
40
+ this.baseUrl = `http://${pubsub.emulatorHost}:${pubsub.emulatorPort}`
41
+ this.projectId = pubsub.projectId
42
+ this.pollIntervalMs = pubsub.shadowPollIntervalMs
43
+ this.timeoutId = null
44
+ this.isRunning = false
45
+ this.consecutiveEmpty = 0
46
+ this.processedMessageIds = new Set()
47
+ this.maxProcessedIds = 10000
48
+ }
49
+
50
+ async request (method, path, body = null) {
51
+ const url = `${this.baseUrl}${path}`
52
+ const options = {
53
+ method,
54
+ headers: { 'Content-Type': 'application/json' }
55
+ }
56
+ if (body) {
57
+ options.body = JSON.stringify(body)
58
+ }
59
+ const response = await fetch(url, options)
60
+ const text = await response.text()
61
+ if (!text) return { success: response.ok, status: response.status }
62
+ try {
63
+ return JSON.parse(text)
64
+ } catch {
65
+ return { success: response.ok, status: response.status, text }
66
+ }
67
+ }
68
+
69
+ async pullMessages (subscriptionName, maxMessages = 100) {
70
+ try {
71
+ const result = await this.request(
72
+ 'POST',
73
+ `/v1/${subscriptionName}:pull`,
74
+ { maxMessages, returnImmediately: true }
75
+ )
76
+ return result.receivedMessages || []
77
+ } catch (error) {
78
+ return []
79
+ }
80
+ }
81
+
82
+ async acknowledgeMessages (subscriptionName, ackIds) {
83
+ if (!ackIds || ackIds.length === 0) return
84
+ try {
85
+ await this.request(
86
+ 'POST',
87
+ `/v1/${subscriptionName}:acknowledge`,
88
+ { ackIds }
89
+ )
90
+ } catch (error) {
91
+ Logger.log({
92
+ level: 'warn',
93
+ message: 'Failed to acknowledge messages',
94
+ data: { subscriptionName, error: error.message }
95
+ })
96
+ }
97
+ }
98
+
99
+ ensureTopicExists (topicName) {
100
+ const existingTopic = SqliteStore.get('pubsub_topics', topicName, 'name')
101
+ if (!existingTopic) {
102
+ SqliteStore.create('pubsub_topics', {
103
+ name: topicName,
104
+ labels: '{}',
105
+ message_storage_policy: '{}',
106
+ kms_key_name: '',
107
+ schema_settings: null,
108
+ satisfies_pzs: 0,
109
+ message_retention_duration: '604800s'
110
+ })
111
+ }
112
+ }
113
+
114
+ storeMessage (message, topic) {
115
+ const { messageId, data, attributes, publishTime } = message
116
+ if (this.processedMessageIds.has(messageId)) {
117
+ return false
118
+ }
119
+ this.processedMessageIds.add(messageId)
120
+ // Skip messages with empty/meaningless data
121
+ if (!hasMeaningfulData(data)) {
122
+ return false
123
+ }
124
+ if (this.processedMessageIds.size > this.maxProcessedIds) {
125
+ const idsArray = Array.from(this.processedMessageIds)
126
+ this.processedMessageIds = new Set(idsArray.slice(-this.maxProcessedIds / 2))
127
+ }
128
+ try {
129
+ this.ensureTopicExists(topic)
130
+ } catch (error) {
131
+ // Topic may already exist, ignore
132
+ }
133
+ // Validate and normalize publishTime
134
+ const validPublishTime = publishTime && !isNaN(new Date(publishTime).getTime())
135
+ ? publishTime
136
+ : new Date().toISOString()
137
+ const historyRecord = {
138
+ id: uuid(),
139
+ message_id: messageId,
140
+ data,
141
+ attributes: attributes || {},
142
+ ordering_key: '',
143
+ publish_time: validPublishTime,
144
+ topic,
145
+ sender: extractSender(attributes),
146
+ view_count: 0,
147
+ last_viewed_at: null
148
+ }
149
+ try {
150
+ SqliteStore.create('pubsub_message_history', historyRecord)
151
+ } catch (error) {
152
+ if (!error.message?.includes('UNIQUE constraint failed')) {
153
+ Logger.log({
154
+ level: 'warn',
155
+ message: 'Failed to store message in history',
156
+ data: { messageId, error: error.message }
157
+ })
158
+ }
159
+ return false
160
+ }
161
+ MessageBroadcaster.broadcast({
162
+ messageId,
163
+ topic,
164
+ data,
165
+ attributes: attributes || {},
166
+ publishTime: validPublishTime,
167
+ sender: extractSender(attributes)
168
+ })
169
+ FunctionTriggerDispatcher.onPubSubMessage(
170
+ { messageId, data, attributes, publishTime: validPublishTime },
171
+ topic
172
+ ).catch(err => {
173
+ Logger.log({ level: 'error', message: 'Cloud Function dispatch error', data: { error: err.message } })
174
+ })
175
+ return true
176
+ }
177
+
178
+ getTopicForSubscription (subscriptionName) {
179
+ // First try to get from the shadow subscription manager's mapping
180
+ const mappedTopic = shadowSubscriptionManager.getTopicForShadow(subscriptionName)
181
+ if (mappedTopic) {
182
+ return mappedTopic
183
+ }
184
+ // Fallback: extract from subscription name (less reliable)
185
+ const subId = subscriptionName.split('/').pop()
186
+ const originalSubId = subId.replace('-devtools-shadow', '')
187
+ const topicId = originalSubId.replace(/-[^-]+-sub$/, '')
188
+ return `projects/${this.projectId}/topics/${topicId}`
189
+ }
190
+
191
+ async pollAllShadowSubscriptions () {
192
+ let totalMessages = 0
193
+ const shadowSubscriptions = shadowSubscriptionManager.getShadowSubscriptions()
194
+ for (const subName of shadowSubscriptions) {
195
+ try {
196
+ const messages = await this.pullMessages(subName)
197
+ if (messages.length === 0) continue
198
+ totalMessages += messages.length
199
+ const ackIds = []
200
+ for (const receivedMessage of messages) {
201
+ const { ackId, message } = receivedMessage
202
+ const topic = this.getTopicForSubscription(subName)
203
+ this.storeMessage(message, topic)
204
+ ackIds.push(ackId)
205
+ }
206
+ if (ackIds.length > 0) {
207
+ await this.acknowledgeMessages(subName, ackIds)
208
+ }
209
+ } catch (error) {
210
+ Logger.log({
211
+ level: 'debug',
212
+ message: 'Error polling shadow subscription',
213
+ data: { subscriptionName: subName, error: error.message }
214
+ })
215
+ }
216
+ }
217
+ return totalMessages
218
+ }
219
+
220
+ getNextPollDelay () {
221
+ const idleThreshold = 5
222
+ if (this.consecutiveEmpty < idleThreshold) {
223
+ return this.pollIntervalMs
224
+ }
225
+ return Math.min(this.pollIntervalMs * 10, 2000)
226
+ }
227
+
228
+ async schedulePoll () {
229
+ if (!this.isRunning) return
230
+ try {
231
+ const found = await this.pollAllShadowSubscriptions()
232
+ if (found > 0) {
233
+ this.consecutiveEmpty = 0
234
+ } else {
235
+ this.consecutiveEmpty++
236
+ }
237
+ } catch (error) {
238
+ Logger.log({
239
+ level: 'error',
240
+ message: 'Error in shadow poll cycle',
241
+ data: { error: error.message }
242
+ })
243
+ }
244
+ if (this.isRunning) {
245
+ this.timeoutId = setTimeout(() => this.schedulePoll(), this.getNextPollDelay())
246
+ }
247
+ }
248
+
249
+ async start () {
250
+ if (this.isRunning) return
251
+ this.isRunning = true
252
+ this.consecutiveEmpty = 0
253
+ Logger.log({
254
+ level: 'info',
255
+ message: 'Starting Shadow Poller',
256
+ data: {
257
+ pollIntervalMs: this.pollIntervalMs
258
+ }
259
+ })
260
+ this.schedulePoll()
261
+ }
262
+
263
+ stop () {
264
+ if (this.timeoutId) {
265
+ clearTimeout(this.timeoutId)
266
+ this.timeoutId = null
267
+ }
268
+ this.isRunning = false
269
+ Logger.log({
270
+ level: 'info',
271
+ message: 'Shadow Poller stopped'
272
+ })
273
+ }
274
+ }
275
+
276
+ export const shadowPoller = new ShadowPoller()