@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/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