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