@emmett-community/emmett-google-pubsub 0.1.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/LICENSE +21 -0
- package/README.md +438 -0
- package/dist/index.d.mts +413 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.js +802 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +778 -0
- package/dist/index.mjs.map +1 -0
- package/dist/testing/index.d.mts +2 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/index.mjs +3 -0
- package/dist/testing/index.mjs.map +1 -0
- package/package.json +94 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { EmmettError } from '@event-driven-io/emmett';
|
|
3
|
+
|
|
4
|
+
// src/messageBus/serialization.ts
|
|
5
|
+
function transformDatesToMarkers(obj) {
|
|
6
|
+
if (obj instanceof Date) {
|
|
7
|
+
return {
|
|
8
|
+
__type: "Date",
|
|
9
|
+
value: obj.toISOString()
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(obj)) {
|
|
13
|
+
return obj.map(transformDatesToMarkers);
|
|
14
|
+
}
|
|
15
|
+
if (obj !== null && typeof obj === "object") {
|
|
16
|
+
const result = {};
|
|
17
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
18
|
+
result[key] = transformDatesToMarkers(value);
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
return obj;
|
|
23
|
+
}
|
|
24
|
+
function isDateMarker(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && "__type" in value && value.__type === "Date" && "value" in value && typeof value.value === "string";
|
|
26
|
+
}
|
|
27
|
+
function dateReviver(_key, value) {
|
|
28
|
+
if (isDateMarker(value)) {
|
|
29
|
+
return new Date(value.value);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function getMessageKind(message) {
|
|
34
|
+
const typeStr = message.type.toLowerCase();
|
|
35
|
+
return typeStr.includes("command") ? "command" : "event";
|
|
36
|
+
}
|
|
37
|
+
function serialize(message) {
|
|
38
|
+
const envelope = {
|
|
39
|
+
type: message.type,
|
|
40
|
+
kind: getMessageKind(message),
|
|
41
|
+
data: transformDatesToMarkers(message.data),
|
|
42
|
+
metadata: "metadata" in message ? transformDatesToMarkers(message.metadata) : void 0,
|
|
43
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
44
|
+
messageId: randomUUID()
|
|
45
|
+
};
|
|
46
|
+
const json = JSON.stringify(envelope);
|
|
47
|
+
return Buffer.from(json);
|
|
48
|
+
}
|
|
49
|
+
function deserialize(buffer) {
|
|
50
|
+
try {
|
|
51
|
+
const json = buffer.toString("utf-8");
|
|
52
|
+
const envelope = JSON.parse(json, dateReviver);
|
|
53
|
+
const message = {
|
|
54
|
+
type: envelope.type,
|
|
55
|
+
data: envelope.data,
|
|
56
|
+
...envelope.metadata ? { metadata: envelope.metadata } : {}
|
|
57
|
+
};
|
|
58
|
+
return message;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to deserialize message: ${error instanceof Error ? error.message : String(error)}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function attachMessageId(message, messageId) {
|
|
66
|
+
return {
|
|
67
|
+
...message,
|
|
68
|
+
__messageId: messageId
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function extractMessageId(message) {
|
|
72
|
+
return "__messageId" in message ? message.__messageId : void 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/messageBus/topicManager.ts
|
|
76
|
+
function getCommandTopicName(commandType, prefix = "emmett") {
|
|
77
|
+
return `${prefix}-cmd-${commandType}`;
|
|
78
|
+
}
|
|
79
|
+
function getEventTopicName(eventType, prefix = "emmett") {
|
|
80
|
+
return `${prefix}-evt-${eventType}`;
|
|
81
|
+
}
|
|
82
|
+
function getCommandSubscriptionName(commandType, instanceId, prefix = "emmett") {
|
|
83
|
+
return `${prefix}-cmd-${commandType}-${instanceId}`;
|
|
84
|
+
}
|
|
85
|
+
function getEventSubscriptionName(eventType, subscriptionId, prefix = "emmett") {
|
|
86
|
+
return `${prefix}-evt-${eventType}-${subscriptionId}`;
|
|
87
|
+
}
|
|
88
|
+
async function getOrCreateTopic(pubsub, topicName) {
|
|
89
|
+
const topic = pubsub.topic(topicName);
|
|
90
|
+
try {
|
|
91
|
+
const [exists] = await topic.exists();
|
|
92
|
+
if (!exists) {
|
|
93
|
+
try {
|
|
94
|
+
await topic.create();
|
|
95
|
+
} catch (createError) {
|
|
96
|
+
if (createError.code !== 6) {
|
|
97
|
+
throw createError;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return topic;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Failed to get or create topic ${topicName}: ${error instanceof Error ? error.message : String(error)}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function getOrCreateSubscription(topic, subscriptionName, options) {
|
|
109
|
+
const subscription = topic.subscription(subscriptionName);
|
|
110
|
+
try {
|
|
111
|
+
const [exists] = await subscription.exists();
|
|
112
|
+
if (!exists) {
|
|
113
|
+
const config = {
|
|
114
|
+
...options?.ackDeadlineSeconds && {
|
|
115
|
+
ackDeadlineSeconds: options.ackDeadlineSeconds
|
|
116
|
+
},
|
|
117
|
+
...options?.retryPolicy && {
|
|
118
|
+
retryPolicy: {
|
|
119
|
+
...options.retryPolicy.minimumBackoff && {
|
|
120
|
+
minimumBackoff: options.retryPolicy.minimumBackoff
|
|
121
|
+
},
|
|
122
|
+
...options.retryPolicy.maximumBackoff && {
|
|
123
|
+
maximumBackoff: options.retryPolicy.maximumBackoff
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
...options?.deadLetterPolicy && {
|
|
128
|
+
deadLetterPolicy: {
|
|
129
|
+
...options.deadLetterPolicy.deadLetterTopic && {
|
|
130
|
+
deadLetterTopic: options.deadLetterPolicy.deadLetterTopic
|
|
131
|
+
},
|
|
132
|
+
...options.deadLetterPolicy.maxDeliveryAttempts && {
|
|
133
|
+
maxDeliveryAttempts: options.deadLetterPolicy.maxDeliveryAttempts
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
await subscription.create(config);
|
|
140
|
+
} catch (createError) {
|
|
141
|
+
if (createError.code !== 6) {
|
|
142
|
+
throw createError;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return subscription;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Failed to get or create subscription ${subscriptionName}: ${error instanceof Error ? error.message : String(error)}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function deleteSubscription(subscription) {
|
|
154
|
+
try {
|
|
155
|
+
const [exists] = await subscription.exists();
|
|
156
|
+
if (exists) {
|
|
157
|
+
await subscription.delete();
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.warn(
|
|
161
|
+
`Failed to delete subscription: ${error instanceof Error ? error.message : String(error)}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function deleteSubscriptions(subscriptions) {
|
|
166
|
+
await Promise.all(subscriptions.map((sub) => deleteSubscription(sub)));
|
|
167
|
+
}
|
|
168
|
+
function generateUUID() {
|
|
169
|
+
return randomUUID();
|
|
170
|
+
}
|
|
171
|
+
function assertNotEmptyString(value, name) {
|
|
172
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
173
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function assertPositiveNumber(value, name) {
|
|
177
|
+
if (typeof value !== "number" || value <= 0 || Number.isNaN(value)) {
|
|
178
|
+
throw new Error(`${name} must be a positive number`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/messageBus/scheduler.ts
|
|
183
|
+
function calculateScheduledTime(options) {
|
|
184
|
+
if (!options) {
|
|
185
|
+
return /* @__PURE__ */ new Date();
|
|
186
|
+
}
|
|
187
|
+
if ("afterInMs" in options) {
|
|
188
|
+
const now = /* @__PURE__ */ new Date();
|
|
189
|
+
return new Date(now.getTime() + options.afterInMs);
|
|
190
|
+
}
|
|
191
|
+
if ("at" in options) {
|
|
192
|
+
return options.at;
|
|
193
|
+
}
|
|
194
|
+
return /* @__PURE__ */ new Date();
|
|
195
|
+
}
|
|
196
|
+
function filterReadyMessages(pending, now) {
|
|
197
|
+
return pending.filter((msg) => msg.scheduledAt <= now);
|
|
198
|
+
}
|
|
199
|
+
var MessageScheduler = class {
|
|
200
|
+
pendingMessages = [];
|
|
201
|
+
useEmulator;
|
|
202
|
+
pubsub;
|
|
203
|
+
topicPrefix;
|
|
204
|
+
scheduledTopic;
|
|
205
|
+
constructor(config) {
|
|
206
|
+
this.useEmulator = config.useEmulator;
|
|
207
|
+
this.pubsub = config.pubsub;
|
|
208
|
+
this.scheduledTopic = config.scheduledTopic;
|
|
209
|
+
this.topicPrefix = config.topicPrefix ?? "emmett";
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Schedule a message for future delivery
|
|
213
|
+
*
|
|
214
|
+
* In production mode: Publishes to PubSub with publishTime attribute
|
|
215
|
+
* In emulator mode: Stores in memory for later dequeue (emulator doesn't support scheduling)
|
|
216
|
+
*
|
|
217
|
+
* @param message - The message to schedule
|
|
218
|
+
* @param options - When to deliver the message
|
|
219
|
+
*/
|
|
220
|
+
async schedule(message, options) {
|
|
221
|
+
const scheduledAt = calculateScheduledTime(options);
|
|
222
|
+
if (this.useEmulator) {
|
|
223
|
+
this.pendingMessages.push({
|
|
224
|
+
message,
|
|
225
|
+
options,
|
|
226
|
+
scheduledAt
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
await this.publishScheduledMessage(message, scheduledAt);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Dequeue ready scheduled messages (emulator mode only)
|
|
234
|
+
*
|
|
235
|
+
* Returns messages whose scheduled time has passed and removes them from pending queue
|
|
236
|
+
*
|
|
237
|
+
* @returns Array of scheduled messages ready for delivery
|
|
238
|
+
*/
|
|
239
|
+
dequeue() {
|
|
240
|
+
if (!this.useEmulator) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const now = /* @__PURE__ */ new Date();
|
|
244
|
+
const ready = filterReadyMessages(this.pendingMessages, now);
|
|
245
|
+
this.pendingMessages = this.pendingMessages.filter(
|
|
246
|
+
(msg) => msg.scheduledAt > now
|
|
247
|
+
);
|
|
248
|
+
return ready.map((info) => ({
|
|
249
|
+
message: info.message,
|
|
250
|
+
options: info.options
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Publish a scheduled message to PubSub (production mode)
|
|
255
|
+
*
|
|
256
|
+
* @param message - The message to publish
|
|
257
|
+
* @param scheduledAt - When the message should be delivered
|
|
258
|
+
*/
|
|
259
|
+
async publishScheduledMessage(message, scheduledAt) {
|
|
260
|
+
try {
|
|
261
|
+
if (!this.scheduledTopic) {
|
|
262
|
+
const topicName = `${this.topicPrefix}-scheduled-messages`;
|
|
263
|
+
this.scheduledTopic = this.pubsub.topic(topicName);
|
|
264
|
+
const [exists] = await this.scheduledTopic.exists();
|
|
265
|
+
if (!exists) {
|
|
266
|
+
await this.scheduledTopic.create();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const buffer = serialize(message);
|
|
270
|
+
await this.scheduledTopic.publishMessage({
|
|
271
|
+
data: buffer,
|
|
272
|
+
attributes: {
|
|
273
|
+
messageType: message.type,
|
|
274
|
+
publishTime: scheduledAt.toISOString()
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Failed to publish scheduled message ${message.type}: ${error instanceof Error ? error.message : String(error)}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Get count of pending scheduled messages (emulator mode only)
|
|
285
|
+
*
|
|
286
|
+
* @returns Number of pending scheduled messages
|
|
287
|
+
*/
|
|
288
|
+
getPendingCount() {
|
|
289
|
+
return this.useEmulator ? this.pendingMessages.length : 0;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Clear all pending scheduled messages (emulator mode only, useful for testing)
|
|
293
|
+
*/
|
|
294
|
+
clearPending() {
|
|
295
|
+
if (this.useEmulator) {
|
|
296
|
+
this.pendingMessages = [];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function shouldRetry(error) {
|
|
301
|
+
if (!(error instanceof Error)) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const errorMessage = error.message.toLowerCase();
|
|
305
|
+
if (errorMessage.includes("network") || errorMessage.includes("timeout") || errorMessage.includes("econnrefused") || errorMessage.includes("enotfound") || errorMessage.includes("unavailable")) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
if (error instanceof EmmettError) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
if (errorMessage.includes("validation") || errorMessage.includes("invalid") || errorMessage.includes("not found") || errorMessage.includes("already exists")) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
async function handleCommandMessage(message, handlers, commandType) {
|
|
317
|
+
try {
|
|
318
|
+
const commandHandlers = handlers.get(commandType);
|
|
319
|
+
if (!commandHandlers || commandHandlers.length === 0) {
|
|
320
|
+
throw new EmmettError(
|
|
321
|
+
`No handler registered for command ${commandType}!`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (commandHandlers.length > 1) {
|
|
325
|
+
throw new EmmettError(
|
|
326
|
+
`Multiple handlers registered for command ${commandType}. Commands must have exactly one handler.`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const command = deserialize(message.data);
|
|
330
|
+
const handler = commandHandlers[0];
|
|
331
|
+
await handler(command);
|
|
332
|
+
return "ack";
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error(
|
|
335
|
+
`Error handling command ${commandType}:`,
|
|
336
|
+
error instanceof Error ? error.message : String(error)
|
|
337
|
+
);
|
|
338
|
+
if (shouldRetry(error)) {
|
|
339
|
+
console.info(
|
|
340
|
+
`Nacking command ${commandType} for retry (delivery attempt: ${message.deliveryAttempt})`
|
|
341
|
+
);
|
|
342
|
+
return "nack";
|
|
343
|
+
} else {
|
|
344
|
+
console.warn(
|
|
345
|
+
`Acking command ${commandType} despite error (permanent failure)`
|
|
346
|
+
);
|
|
347
|
+
return "ack";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function handleEventMessage(message, handlers, eventType) {
|
|
352
|
+
try {
|
|
353
|
+
const eventHandlers = handlers.get(eventType);
|
|
354
|
+
if (!eventHandlers || eventHandlers.length === 0) {
|
|
355
|
+
console.debug(`No handlers registered for event ${eventType}, skipping`);
|
|
356
|
+
return "ack";
|
|
357
|
+
}
|
|
358
|
+
const event = deserialize(message.data);
|
|
359
|
+
for (const handler of eventHandlers) {
|
|
360
|
+
try {
|
|
361
|
+
await handler(event);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.error(
|
|
364
|
+
`Error in event handler for ${eventType}:`,
|
|
365
|
+
error instanceof Error ? error.message : String(error)
|
|
366
|
+
);
|
|
367
|
+
if (shouldRetry(error)) {
|
|
368
|
+
console.info(
|
|
369
|
+
`Nacking event ${eventType} for retry due to handler failure (delivery attempt: ${message.deliveryAttempt})`
|
|
370
|
+
);
|
|
371
|
+
return "nack";
|
|
372
|
+
}
|
|
373
|
+
console.warn(
|
|
374
|
+
`Continuing event ${eventType} processing despite handler error (permanent failure)`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return "ack";
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error(
|
|
381
|
+
`Error handling event ${eventType}:`,
|
|
382
|
+
error instanceof Error ? error.message : String(error)
|
|
383
|
+
);
|
|
384
|
+
if (shouldRetry(error)) {
|
|
385
|
+
return "nack";
|
|
386
|
+
} else {
|
|
387
|
+
return "ack";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function createMessageListener(subscription, messageType, kind, handlers) {
|
|
392
|
+
subscription.on("message", async (message) => {
|
|
393
|
+
try {
|
|
394
|
+
const result = kind === "command" ? await handleCommandMessage(message, handlers, messageType) : await handleEventMessage(message, handlers, messageType);
|
|
395
|
+
if (result === "ack") {
|
|
396
|
+
message.ack();
|
|
397
|
+
} else {
|
|
398
|
+
message.nack();
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.error(
|
|
402
|
+
`Unexpected error in message listener for ${messageType}:`,
|
|
403
|
+
error instanceof Error ? error.message : String(error)
|
|
404
|
+
);
|
|
405
|
+
message.nack();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
subscription.on("error", (error) => {
|
|
409
|
+
console.error(
|
|
410
|
+
`Subscription error for ${messageType}:`,
|
|
411
|
+
error instanceof Error ? error.message : String(error)
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
function determineMessageKindFallback(messageType) {
|
|
416
|
+
const typeStr = messageType.toLowerCase();
|
|
417
|
+
return typeStr.includes("command") ? "command" : "event";
|
|
418
|
+
}
|
|
419
|
+
function getPubSubMessageBus(config) {
|
|
420
|
+
const instanceId = config.instanceId ?? generateUUID();
|
|
421
|
+
const topicPrefix = config.topicPrefix ?? "emmett";
|
|
422
|
+
const autoCreateResources = config.autoCreateResources ?? true;
|
|
423
|
+
const cleanupOnClose = config.cleanupOnClose ?? false;
|
|
424
|
+
const closePubSubClient = config.closePubSubClient;
|
|
425
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
426
|
+
const subscriptionHandlers = /* @__PURE__ */ new Map();
|
|
427
|
+
const eventSubscriptionIds = /* @__PURE__ */ new Map();
|
|
428
|
+
const commandTypes = /* @__PURE__ */ new Set();
|
|
429
|
+
const eventTypes = /* @__PURE__ */ new Set();
|
|
430
|
+
const subscriptions = [];
|
|
431
|
+
const scheduler = new MessageScheduler({
|
|
432
|
+
useEmulator: config.useEmulator ?? false,
|
|
433
|
+
pubsub: config.pubsub,
|
|
434
|
+
topicPrefix
|
|
435
|
+
});
|
|
436
|
+
let started = false;
|
|
437
|
+
function determineMessageKind(messageType) {
|
|
438
|
+
if (commandTypes.has(messageType)) {
|
|
439
|
+
return "command";
|
|
440
|
+
}
|
|
441
|
+
if (eventTypes.has(messageType)) {
|
|
442
|
+
return "event";
|
|
443
|
+
}
|
|
444
|
+
return determineMessageKindFallback(messageType);
|
|
445
|
+
}
|
|
446
|
+
async function createSubscriptionForType(messageType, kind, subscriptionId) {
|
|
447
|
+
const topicName = kind === "command" ? getCommandTopicName(messageType, topicPrefix) : getEventTopicName(messageType, topicPrefix);
|
|
448
|
+
const topic = await getOrCreateTopic(config.pubsub, topicName);
|
|
449
|
+
const subName = kind === "command" ? getCommandSubscriptionName(messageType, instanceId, topicPrefix) : getEventSubscriptionName(
|
|
450
|
+
messageType,
|
|
451
|
+
subscriptionId ?? instanceId,
|
|
452
|
+
topicPrefix
|
|
453
|
+
);
|
|
454
|
+
const subscription = await getOrCreateSubscription(
|
|
455
|
+
topic,
|
|
456
|
+
subName,
|
|
457
|
+
config.subscriptionOptions
|
|
458
|
+
);
|
|
459
|
+
if (kind === "event" && subscriptionId) {
|
|
460
|
+
const handler = subscriptionHandlers.get(subscriptionId);
|
|
461
|
+
if (handler) {
|
|
462
|
+
const singleHandlerMap = /* @__PURE__ */ new Map();
|
|
463
|
+
singleHandlerMap.set(messageType, [handler]);
|
|
464
|
+
createMessageListener(subscription, messageType, kind, singleHandlerMap);
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
createMessageListener(subscription, messageType, kind, handlers);
|
|
468
|
+
}
|
|
469
|
+
subscriptions.push({
|
|
470
|
+
topic,
|
|
471
|
+
subscription,
|
|
472
|
+
messageType,
|
|
473
|
+
kind
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
async function publishMessage(message, kind) {
|
|
477
|
+
const topicName = kind === "command" ? getCommandTopicName(message.type, topicPrefix) : getEventTopicName(message.type, topicPrefix);
|
|
478
|
+
try {
|
|
479
|
+
const topic = config.pubsub.topic(topicName);
|
|
480
|
+
if (!autoCreateResources) {
|
|
481
|
+
const [exists] = await topic.exists();
|
|
482
|
+
if (!exists) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Topic ${topicName} does not exist and autoCreateResources is disabled`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
const [exists] = await topic.exists();
|
|
489
|
+
if (!exists) {
|
|
490
|
+
await topic.create();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const buffer = serialize(message);
|
|
494
|
+
await topic.publishMessage({
|
|
495
|
+
data: buffer,
|
|
496
|
+
attributes: {
|
|
497
|
+
messageType: message.type,
|
|
498
|
+
messageKind: kind
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`Failed to publish ${kind} ${message.type} to topic ${topicName}: ${error instanceof Error ? error.message : String(error)}`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
// ===== MessageBus Interface =====
|
|
509
|
+
/**
|
|
510
|
+
* Send a command to the message bus
|
|
511
|
+
*
|
|
512
|
+
* Commands are routed to exactly one handler via PubSub topics
|
|
513
|
+
*
|
|
514
|
+
* @param command - The command to send
|
|
515
|
+
*/
|
|
516
|
+
async send(command) {
|
|
517
|
+
await publishMessage(command, "command");
|
|
518
|
+
},
|
|
519
|
+
/**
|
|
520
|
+
* Publish an event to the message bus
|
|
521
|
+
*
|
|
522
|
+
* Events are delivered to all registered subscribers via PubSub topics
|
|
523
|
+
*
|
|
524
|
+
* @param event - The event to publish
|
|
525
|
+
*/
|
|
526
|
+
async publish(event) {
|
|
527
|
+
await publishMessage(event, "event");
|
|
528
|
+
},
|
|
529
|
+
/**
|
|
530
|
+
* Schedule a message for future delivery
|
|
531
|
+
*
|
|
532
|
+
* In production mode: Uses PubSub native scheduling
|
|
533
|
+
* In emulator mode: Stores in memory (emulator doesn't support scheduling)
|
|
534
|
+
*
|
|
535
|
+
* @param message - The message to schedule
|
|
536
|
+
* @param when - When to deliver the message (afterInMs or at)
|
|
537
|
+
*/
|
|
538
|
+
schedule(message, when) {
|
|
539
|
+
scheduler.schedule(message, when);
|
|
540
|
+
},
|
|
541
|
+
// ===== CommandProcessor Interface =====
|
|
542
|
+
/**
|
|
543
|
+
* Register a command handler
|
|
544
|
+
*
|
|
545
|
+
* Commands must have exactly one handler. Attempting to register multiple
|
|
546
|
+
* handlers for the same command will throw an EmmettError.
|
|
547
|
+
*
|
|
548
|
+
* @param commandHandler - The handler function
|
|
549
|
+
* @param commandTypes - Command types this handler processes
|
|
550
|
+
* @throws EmmettError if a handler is already registered for any command type
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```typescript
|
|
554
|
+
* messageBus.handle(
|
|
555
|
+
* async (command: AddProductItemCommand) => {
|
|
556
|
+
* // Handle command
|
|
557
|
+
* },
|
|
558
|
+
* 'AddProductItem'
|
|
559
|
+
* );
|
|
560
|
+
* ```
|
|
561
|
+
*/
|
|
562
|
+
handle(commandHandler, ...commandTypeNames) {
|
|
563
|
+
for (const commandType of commandTypeNames) {
|
|
564
|
+
if (handlers.has(commandType)) {
|
|
565
|
+
throw new EmmettError(
|
|
566
|
+
`Handler already registered for command ${commandType}. Commands must have exactly one handler.`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
commandTypes.add(commandType);
|
|
570
|
+
handlers.set(commandType, [
|
|
571
|
+
commandHandler
|
|
572
|
+
]);
|
|
573
|
+
if (started) {
|
|
574
|
+
createSubscriptionForType(commandType, "command").catch((error) => {
|
|
575
|
+
console.error(
|
|
576
|
+
`Failed to create subscription for command ${commandType}:`,
|
|
577
|
+
error instanceof Error ? error.message : String(error)
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
// ===== EventSubscription Interface =====
|
|
584
|
+
/**
|
|
585
|
+
* Subscribe to events
|
|
586
|
+
*
|
|
587
|
+
* Events can have multiple subscribers. Each subscription gets its own
|
|
588
|
+
* PubSub subscription to ensure all handlers receive all events.
|
|
589
|
+
*
|
|
590
|
+
* @param eventHandler - The handler function
|
|
591
|
+
* @param eventTypes - Event types to subscribe to
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```typescript
|
|
595
|
+
* messageBus.subscribe(
|
|
596
|
+
* async (event: ProductItemAddedEvent) => {
|
|
597
|
+
* // Handle event
|
|
598
|
+
* },
|
|
599
|
+
* 'ProductItemAdded'
|
|
600
|
+
* );
|
|
601
|
+
* ```
|
|
602
|
+
*/
|
|
603
|
+
subscribe(eventHandler, ...eventTypeNames) {
|
|
604
|
+
for (const eventType of eventTypeNames) {
|
|
605
|
+
eventTypes.add(eventType);
|
|
606
|
+
const subscriptionId = generateUUID();
|
|
607
|
+
subscriptionHandlers.set(
|
|
608
|
+
subscriptionId,
|
|
609
|
+
eventHandler
|
|
610
|
+
);
|
|
611
|
+
const existing = handlers.get(eventType) ?? [];
|
|
612
|
+
handlers.set(eventType, [
|
|
613
|
+
...existing,
|
|
614
|
+
eventHandler
|
|
615
|
+
]);
|
|
616
|
+
const existingIds = eventSubscriptionIds.get(eventType) ?? [];
|
|
617
|
+
eventSubscriptionIds.set(eventType, [...existingIds, subscriptionId]);
|
|
618
|
+
if (started) {
|
|
619
|
+
createSubscriptionForType(eventType, "event", subscriptionId).catch(
|
|
620
|
+
(error) => {
|
|
621
|
+
console.error(
|
|
622
|
+
`Failed to create subscription for event ${eventType}:`,
|
|
623
|
+
error instanceof Error ? error.message : String(error)
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
// ===== ScheduledMessageProcessor Interface =====
|
|
631
|
+
/**
|
|
632
|
+
* Dequeue scheduled messages that are ready for delivery
|
|
633
|
+
*
|
|
634
|
+
* Only used in emulator mode. In production, PubSub handles scheduling.
|
|
635
|
+
*
|
|
636
|
+
* @returns Array of scheduled messages ready for delivery
|
|
637
|
+
*
|
|
638
|
+
* @example
|
|
639
|
+
* ```typescript
|
|
640
|
+
* // In emulator mode, periodically call dequeue
|
|
641
|
+
* setInterval(() => {
|
|
642
|
+
* const ready = messageBus.dequeue();
|
|
643
|
+
* for (const { message } of ready) {
|
|
644
|
+
* // Process message
|
|
645
|
+
* }
|
|
646
|
+
* }, 1000);
|
|
647
|
+
* ```
|
|
648
|
+
*/
|
|
649
|
+
dequeue() {
|
|
650
|
+
return scheduler.dequeue();
|
|
651
|
+
},
|
|
652
|
+
// ===== PubSubMessageBusLifecycle Interface =====
|
|
653
|
+
/**
|
|
654
|
+
* Start the message bus
|
|
655
|
+
*
|
|
656
|
+
* Creates topics and subscriptions for all registered handlers and begins
|
|
657
|
+
* listening for messages.
|
|
658
|
+
*
|
|
659
|
+
* This method is idempotent - calling it multiple times is safe.
|
|
660
|
+
*
|
|
661
|
+
* @throws Error if topic/subscription creation fails
|
|
662
|
+
*
|
|
663
|
+
* @example
|
|
664
|
+
* ```typescript
|
|
665
|
+
* // Register all handlers first
|
|
666
|
+
* messageBus.handle(commandHandler, 'MyCommand');
|
|
667
|
+
* messageBus.subscribe(eventHandler, 'MyEvent');
|
|
668
|
+
*
|
|
669
|
+
* // Then start
|
|
670
|
+
* await messageBus.start();
|
|
671
|
+
* ```
|
|
672
|
+
*/
|
|
673
|
+
async start() {
|
|
674
|
+
if (started) {
|
|
675
|
+
console.debug("Message bus already started, skipping");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
console.info("Starting PubSub message bus...");
|
|
679
|
+
try {
|
|
680
|
+
const subscriptionPromises = [];
|
|
681
|
+
for (const [messageType] of handlers.entries()) {
|
|
682
|
+
const kind = determineMessageKind(messageType);
|
|
683
|
+
if (kind === "command") {
|
|
684
|
+
subscriptionPromises.push(
|
|
685
|
+
createSubscriptionForType(messageType, "command")
|
|
686
|
+
);
|
|
687
|
+
} else {
|
|
688
|
+
const subIds = eventSubscriptionIds.get(messageType) ?? [
|
|
689
|
+
instanceId
|
|
690
|
+
];
|
|
691
|
+
for (const subId of subIds) {
|
|
692
|
+
subscriptionPromises.push(
|
|
693
|
+
createSubscriptionForType(messageType, "event", subId)
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
await Promise.all(subscriptionPromises);
|
|
699
|
+
started = true;
|
|
700
|
+
console.info(
|
|
701
|
+
`PubSub message bus started with ${subscriptions.length} subscription(s)`
|
|
702
|
+
);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`Failed to start message bus: ${error instanceof Error ? error.message : String(error)}`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
/**
|
|
710
|
+
* Close the message bus gracefully
|
|
711
|
+
*
|
|
712
|
+
* Stops accepting new messages, waits for in-flight messages to complete,
|
|
713
|
+
* optionally cleans up subscriptions, and closes the PubSub client.
|
|
714
|
+
*
|
|
715
|
+
* @throws Error if cleanup fails
|
|
716
|
+
*
|
|
717
|
+
* @example
|
|
718
|
+
* ```typescript
|
|
719
|
+
* // Graceful shutdown
|
|
720
|
+
* process.on('SIGTERM', async () => {
|
|
721
|
+
* await messageBus.close();
|
|
722
|
+
* process.exit(0);
|
|
723
|
+
* });
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
async close() {
|
|
727
|
+
console.info("Closing PubSub message bus...");
|
|
728
|
+
try {
|
|
729
|
+
if (started) {
|
|
730
|
+
for (const { subscription } of subscriptions) {
|
|
731
|
+
subscription.removeAllListeners("message");
|
|
732
|
+
subscription.removeAllListeners("error");
|
|
733
|
+
}
|
|
734
|
+
const timeout = 3e4;
|
|
735
|
+
const waitStart = Date.now();
|
|
736
|
+
const closePromises = subscriptions.map(
|
|
737
|
+
({ subscription }) => subscription.close()
|
|
738
|
+
);
|
|
739
|
+
await Promise.race([
|
|
740
|
+
Promise.all(closePromises),
|
|
741
|
+
new Promise((resolve) => setTimeout(resolve, timeout))
|
|
742
|
+
]);
|
|
743
|
+
const waitTime = Date.now() - waitStart;
|
|
744
|
+
if (waitTime >= timeout) {
|
|
745
|
+
console.warn(
|
|
746
|
+
`Timeout waiting for in-flight messages after ${timeout}ms`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (cleanupOnClose) {
|
|
750
|
+
console.info("Cleaning up subscriptions...");
|
|
751
|
+
await deleteSubscriptions(subscriptions.map((s) => s.subscription));
|
|
752
|
+
}
|
|
753
|
+
started = false;
|
|
754
|
+
}
|
|
755
|
+
if (closePubSubClient !== false) {
|
|
756
|
+
await config.pubsub.close();
|
|
757
|
+
}
|
|
758
|
+
console.info("PubSub message bus closed");
|
|
759
|
+
} catch (error) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Failed to close message bus: ${error instanceof Error ? error.message : String(error)}`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
/**
|
|
766
|
+
* Check if the message bus is started
|
|
767
|
+
*
|
|
768
|
+
* @returns true if the message bus is started and ready to process messages
|
|
769
|
+
*/
|
|
770
|
+
isStarted() {
|
|
771
|
+
return started;
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export { MessageScheduler, assertNotEmptyString, assertPositiveNumber, attachMessageId, calculateScheduledTime, createMessageListener, deleteSubscription, deleteSubscriptions, deserialize, extractMessageId, filterReadyMessages, generateUUID, getCommandSubscriptionName, getCommandTopicName, getEventSubscriptionName, getEventTopicName, getOrCreateSubscription, getOrCreateTopic, getPubSubMessageBus, handleCommandMessage, handleEventMessage, serialize, shouldRetry };
|
|
777
|
+
//# sourceMappingURL=index.mjs.map
|
|
778
|
+
//# sourceMappingURL=index.mjs.map
|