@blokjs/trigger-pubsub 0.6.18 → 0.6.19

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.
@@ -1,322 +0,0 @@
1
- /**
2
- * AWSSNSAdapter - AWS SNS/SQS adapter for PubSubTrigger
3
- *
4
- * Uses AWS SDK v3 for SNS/SQS connectivity.
5
- * SNS topics deliver to SQS queues, which this adapter polls.
6
- *
7
- * Requires: npm install @aws-sdk/client-sns @aws-sdk/client-sqs
8
- *
9
- * Environment variables:
10
- * - AWS_REGION: AWS region (default: us-east-1)
11
- * - AWS_ACCESS_KEY_ID: AWS access key (optional if using IAM roles)
12
- * - AWS_SECRET_ACCESS_KEY: AWS secret key (optional if using IAM roles)
13
- * - SQS_WAIT_TIME_SECONDS: Long polling wait time (default: 20)
14
- * - SQS_MAX_MESSAGES: Max messages per receive (default: 10)
15
- */
16
-
17
- import type { MessageAttributeValue, SQSClient } from "@aws-sdk/client-sqs";
18
- import type { PubSubTriggerOpts } from "@blokjs/helper";
19
- import { v4 as uuid } from "uuid";
20
- import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
21
-
22
- /**
23
- * Shape of an SNS notification when delivered to an SQS subscription.
24
- * SNS-to-SQS subscriptions wrap the publisher's payload inside a JSON
25
- * envelope; the receiver parses `msg.Body` as this envelope and reads
26
- * `Message` (the actual payload) plus the SNS-side attributes.
27
- */
28
- interface SNSNotificationEnvelope {
29
- Type?: string;
30
- MessageId?: string;
31
- TopicArn?: string;
32
- Message?: string;
33
- Subject?: string;
34
- Timestamp?: string;
35
- MessageAttributes?: Record<string, { Type?: string; Value?: string }>;
36
- }
37
-
38
- /**
39
- * AWS SNS/SQS configuration
40
- */
41
- export interface AWSSNSConfig {
42
- region: string;
43
- waitTimeSeconds?: number;
44
- maxNumberOfMessages?: number;
45
- }
46
-
47
- /**
48
- * AWSSNSAdapter - AWS SNS implementation using SQS subscriptions
49
- */
50
- export class AWSSNSAdapter implements PubSubAdapter {
51
- readonly provider = "aws" as const;
52
-
53
- private sqsClient: SQSClient | undefined;
54
- private connected = false;
55
- private config: AWSSNSConfig;
56
- private pollingIntervals: Map<string, NodeJS.Timeout> = new Map();
57
- private shouldStop = false;
58
-
59
- /**
60
- * Type-narrowing accessor for `this.sqsClient`. Field is undefined
61
- * until `connect()` runs.
62
- */
63
- private requireSqsClient(): SQSClient {
64
- if (!this.sqsClient) {
65
- throw new Error("[AWSSNSAdapter] SQS client is not initialised — call connect() first");
66
- }
67
- return this.sqsClient;
68
- }
69
-
70
- constructor(config?: Partial<AWSSNSConfig>) {
71
- this.config = {
72
- region: config?.region || process.env.AWS_REGION || "us-east-1",
73
- waitTimeSeconds: config?.waitTimeSeconds ?? Number.parseInt(process.env.SQS_WAIT_TIME_SECONDS || "20", 10),
74
- maxNumberOfMessages: config?.maxNumberOfMessages ?? Number.parseInt(process.env.SQS_MAX_MESSAGES || "10", 10),
75
- };
76
- }
77
-
78
- /**
79
- * Connect to AWS
80
- */
81
- async connect(): Promise<void> {
82
- if (this.connected) return;
83
-
84
- try {
85
- // Dynamic import of AWS SDK
86
- const { SQSClient } = await import("@aws-sdk/client-sqs");
87
-
88
- this.sqsClient = new SQSClient({
89
- region: this.config.region,
90
- });
91
-
92
- this.connected = true;
93
- this.shouldStop = false;
94
- console.log(`[AWSSNSAdapter] Connected to AWS SNS/SQS: ${this.config.region}`);
95
- } catch (error) {
96
- throw new Error(
97
- `Failed to connect to AWS: ${(error as Error).message}. Make sure @aws-sdk/client-sqs is installed: npm install @aws-sdk/client-sqs`,
98
- );
99
- }
100
- }
101
-
102
- /**
103
- * Disconnect from AWS
104
- */
105
- async disconnect(): Promise<void> {
106
- if (!this.connected) return;
107
-
108
- this.shouldStop = true;
109
-
110
- // Clear all polling intervals
111
- for (const [queueUrl, interval] of this.pollingIntervals) {
112
- clearTimeout(interval);
113
- }
114
- this.pollingIntervals.clear();
115
-
116
- this.connected = false;
117
- console.log("[AWSSNSAdapter] Disconnected from AWS SNS/SQS");
118
- }
119
-
120
- /**
121
- * Subscribe to an SNS topic via SQS queue
122
- * Note: The SQS queue should be pre-configured as an SNS subscription
123
- */
124
- async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
125
- if (!this.connected) {
126
- throw new Error("Not connected to AWS. Call connect() first.");
127
- }
128
-
129
- // In AWS, subscription is the SQS queue URL that's subscribed to the SNS topic.
130
- if (!config.subscription) {
131
- throw new Error("[AWSSNSAdapter] `subscription` is required — must be the SQS queue URL bound to the SNS topic.");
132
- }
133
- const queueUrl = config.subscription;
134
-
135
- // Start polling the SQS queue
136
- this.poll(queueUrl, config, handler);
137
-
138
- console.log(`[AWSSNSAdapter] Subscribed to queue: ${queueUrl} (topic: ${config.topic})`);
139
- }
140
-
141
- /**
142
- * Poll SQS queue for messages (long polling)
143
- */
144
- private async poll(
145
- queueUrl: string,
146
- config: PubSubTriggerOpts,
147
- handler: (message: PubSubMessage) => Promise<void>,
148
- ): Promise<void> {
149
- if (this.shouldStop) return;
150
-
151
- try {
152
- const { ReceiveMessageCommand, DeleteMessageCommand } = await import("@aws-sdk/client-sqs");
153
-
154
- const command = new ReceiveMessageCommand({
155
- QueueUrl: queueUrl,
156
- MaxNumberOfMessages: config.maxMessages || this.config.maxNumberOfMessages,
157
- WaitTimeSeconds: this.config.waitTimeSeconds,
158
- MessageAttributeNames: ["All"],
159
- AttributeNames: ["All"],
160
- });
161
-
162
- const response = await this.requireSqsClient().send(command);
163
-
164
- if (response.Messages && response.Messages.length > 0) {
165
- for (const msg of response.Messages) {
166
- // Parse SNS message wrapper
167
- let snsMessage: SNSNotificationEnvelope;
168
- let body: unknown;
169
-
170
- try {
171
- snsMessage = JSON.parse(msg.Body || "{}") as SNSNotificationEnvelope;
172
- // SNS wraps the actual message in a "Message" field
173
- if (snsMessage.Type === "Notification" && snsMessage.Message) {
174
- try {
175
- body = JSON.parse(snsMessage.Message);
176
- } catch {
177
- body = snsMessage.Message;
178
- }
179
- } else {
180
- body = snsMessage;
181
- }
182
- } catch {
183
- body = msg.Body;
184
- snsMessage = {};
185
- }
186
-
187
- // Extract attributes from both SQS and SNS
188
- const attributes: Record<string, string> = {};
189
-
190
- // SQS message attributes
191
- if (msg.MessageAttributes) {
192
- for (const [key, attr] of Object.entries(msg.MessageAttributes)) {
193
- const sqsAttr = attr as MessageAttributeValue;
194
- attributes[key] = sqsAttr.StringValue || "";
195
- }
196
- }
197
-
198
- // SNS message attributes (if present)
199
- if (snsMessage.MessageAttributes) {
200
- for (const [key, attr] of Object.entries(snsMessage.MessageAttributes)) {
201
- attributes[`sns_${key}`] = attr.Value || "";
202
- }
203
- }
204
-
205
- // Create pub/sub message
206
- const pubsubMessage: PubSubMessage = {
207
- id: snsMessage.MessageId || msg.MessageId || uuid(),
208
- body,
209
- attributes,
210
- raw: msg,
211
- topic: snsMessage.TopicArn || config.topic,
212
- subscription: queueUrl,
213
- publishTime: snsMessage.Timestamp ? new Date(snsMessage.Timestamp) : new Date(),
214
- ack: async () => {
215
- const deleteCommand = new DeleteMessageCommand({
216
- QueueUrl: queueUrl,
217
- ReceiptHandle: msg.ReceiptHandle,
218
- });
219
- await this.requireSqsClient().send(deleteCommand);
220
- },
221
- nack: async () => {
222
- // Let the visibility timeout expire to return the message
223
- // Or change visibility to 0 for immediate retry
224
- const { ChangeMessageVisibilityCommand } = await import("@aws-sdk/client-sqs");
225
- const changeCommand = new ChangeMessageVisibilityCommand({
226
- QueueUrl: queueUrl,
227
- ReceiptHandle: msg.ReceiptHandle,
228
- VisibilityTimeout: 0,
229
- });
230
- await this.requireSqsClient().send(changeCommand);
231
- },
232
- };
233
-
234
- // Process message
235
- try {
236
- await handler(pubsubMessage);
237
- } catch (error) {
238
- console.error(`[AWSSNSAdapter] Error processing message: ${(error as Error).message}`);
239
- }
240
- }
241
- }
242
- } catch (error) {
243
- console.error(`[AWSSNSAdapter] Polling error: ${(error as Error).message}`);
244
- }
245
-
246
- // Continue polling unless stopped
247
- if (!this.shouldStop) {
248
- const timeout = setTimeout(() => this.poll(queueUrl, config, handler), 0);
249
- this.pollingIntervals.set(queueUrl, timeout);
250
- }
251
- }
252
-
253
- /**
254
- * Unsubscribe from a queue (stops polling)
255
- */
256
- async unsubscribe(queueUrl: string): Promise<void> {
257
- const interval = this.pollingIntervals.get(queueUrl);
258
- if (interval) {
259
- clearTimeout(interval);
260
- this.pollingIntervals.delete(queueUrl);
261
- console.log(`[AWSSNSAdapter] Unsubscribed from queue: ${queueUrl}`);
262
- }
263
- }
264
-
265
- /**
266
- * Check if connected to AWS
267
- */
268
- isConnected(): boolean {
269
- return this.connected;
270
- }
271
-
272
- /**
273
- * v0.7 PR 6 — publish to an SNS topic.
274
- *
275
- * `topic` must be the SNS topic ARN. `partitionKey` /
276
- * `orderingKey` map to the FIFO `MessageGroupId` field for
277
- * `.fifo` topics; ignored otherwise.
278
- */
279
- async publish(
280
- topic: string,
281
- payload: unknown,
282
- opts?: { partitionKey?: string; orderingKey?: string },
283
- ): Promise<void> {
284
- if (!this.connected) throw new Error("[blok][pubsub-aws] not connected. Call connect() first.");
285
- const moduleName = "@aws-sdk/client-sns";
286
- // biome-ignore lint/suspicious/noExplicitAny: SDK loaded at runtime as a peer dep.
287
- const sns: any = await import(moduleName);
288
- const client = new sns.SNSClient({ region: this.config.region });
289
- const isFifo = topic.endsWith(".fifo");
290
- const params: Record<string, unknown> = {
291
- TopicArn: topic,
292
- Message: typeof payload === "string" ? payload : JSON.stringify(payload),
293
- };
294
- if (isFifo) {
295
- params.MessageGroupId = opts?.partitionKey ?? opts?.orderingKey ?? "default";
296
- params.MessageDeduplicationId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
297
- }
298
- try {
299
- await client.send(new sns.PublishCommand(params));
300
- } finally {
301
- client.destroy?.();
302
- }
303
- }
304
-
305
- /**
306
- * Health check - verify AWS connectivity
307
- */
308
- async healthCheck(): Promise<boolean> {
309
- if (!this.connected) return false;
310
-
311
- try {
312
- const { ListQueuesCommand } = await import("@aws-sdk/client-sqs");
313
- const command = new ListQueuesCommand({ MaxResults: 1 });
314
- await this.requireSqsClient().send(command);
315
- return true;
316
- } catch {
317
- return false;
318
- }
319
- }
320
- }
321
-
322
- export default AWSSNSAdapter;
@@ -1,263 +0,0 @@
1
- /**
2
- * AzureServiceBusAdapter - Azure Service Bus adapter for PubSubTrigger
3
- *
4
- * Uses @azure/service-bus for Azure Service Bus connectivity.
5
- * Requires: npm install @azure/service-bus
6
- *
7
- * Environment variables:
8
- * - AZURE_SERVICE_BUS_CONNECTION_STRING: Azure Service Bus connection string
9
- * - AZURE_SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE: Fully qualified namespace (if using DefaultAzureCredential)
10
- */
11
-
12
- import type {
13
- ProcessErrorArgs,
14
- ServiceBusClient,
15
- ServiceBusReceivedMessage,
16
- ServiceBusReceiver,
17
- } from "@azure/service-bus";
18
- import type { PubSubTriggerOpts } from "@blokjs/helper";
19
- import { v4 as uuid } from "uuid";
20
- import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
21
-
22
- /**
23
- * Azure Service Bus configuration
24
- */
25
- export interface AzureServiceBusConfig {
26
- connectionString?: string;
27
- fullyQualifiedNamespace?: string;
28
- }
29
-
30
- /**
31
- * AzureServiceBusAdapter - Azure Service Bus implementation
32
- */
33
- export class AzureServiceBusAdapter implements PubSubAdapter {
34
- readonly provider = "azure" as const;
35
-
36
- private client: ServiceBusClient | undefined;
37
- private receivers: Map<string, ServiceBusReceiver> = new Map();
38
- private connected = false;
39
- private config: AzureServiceBusConfig;
40
-
41
- /**
42
- * Type-narrowing accessor for `this.client`. Field is undefined until
43
- * `connect()` runs.
44
- */
45
- private requireClient(): ServiceBusClient {
46
- if (!this.client) {
47
- throw new Error("[AzureServiceBusAdapter] client is not initialised — call connect() first");
48
- }
49
- return this.client;
50
- }
51
-
52
- constructor(config?: AzureServiceBusConfig) {
53
- this.config = {
54
- connectionString: config?.connectionString || process.env.AZURE_SERVICE_BUS_CONNECTION_STRING,
55
- fullyQualifiedNamespace:
56
- config?.fullyQualifiedNamespace || process.env.AZURE_SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE,
57
- };
58
- }
59
-
60
- /**
61
- * Connect to Azure Service Bus
62
- */
63
- async connect(): Promise<void> {
64
- if (this.connected) return;
65
-
66
- try {
67
- // Dynamic import of @azure/service-bus
68
- const { ServiceBusClient } = await import("@azure/service-bus");
69
-
70
- if (this.config.connectionString) {
71
- this.client = new ServiceBusClient(this.config.connectionString);
72
- } else if (this.config.fullyQualifiedNamespace) {
73
- // Would need @azure/identity for DefaultAzureCredential
74
- throw new Error("Managed identity authentication requires @azure/identity package");
75
- } else {
76
- throw new Error("Either connectionString or fullyQualifiedNamespace is required");
77
- }
78
-
79
- this.connected = true;
80
- console.log("[AzureServiceBusAdapter] Connected to Azure Service Bus");
81
- } catch (error) {
82
- throw new Error(
83
- `Failed to connect to Azure Service Bus: ${(error as Error).message}. Make sure @azure/service-bus is installed: npm install @azure/service-bus`,
84
- );
85
- }
86
- }
87
-
88
- /**
89
- * Disconnect from Azure Service Bus
90
- */
91
- async disconnect(): Promise<void> {
92
- if (!this.connected) return;
93
-
94
- try {
95
- // Close all receivers
96
- for (const [name, receiver] of this.receivers) {
97
- await receiver.close();
98
- }
99
- this.receivers.clear();
100
-
101
- await this.requireClient().close();
102
- this.connected = false;
103
- console.log("[AzureServiceBusAdapter] Disconnected from Azure Service Bus");
104
- } catch (error) {
105
- console.error(`[AzureServiceBusAdapter] Error disconnecting: ${(error as Error).message}`);
106
- }
107
- }
108
-
109
- /**
110
- * Subscribe to an Azure Service Bus topic/subscription or queue
111
- */
112
- async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
113
- if (!this.connected) {
114
- throw new Error("Not connected to Azure Service Bus. Call connect() first.");
115
- }
116
-
117
- const client = this.requireClient();
118
- let receiver: ServiceBusReceiver;
119
-
120
- // Determine if this is a topic subscription or a queue
121
- if (config.subscription && config.topic) {
122
- // Topic with subscription
123
- receiver = client.createReceiver(config.topic, config.subscription, {
124
- receiveMode: config.ack !== false ? "peekLock" : "receiveAndDelete",
125
- });
126
- } else {
127
- // Queue
128
- receiver = client.createReceiver(config.subscription || config.topic, {
129
- receiveMode: config.ack !== false ? "peekLock" : "receiveAndDelete",
130
- });
131
- }
132
-
133
- const subscriptionKey = `${config.topic}/${config.subscription}`;
134
-
135
- // Message handler
136
- const processMessage = async (sbMessage: ServiceBusReceivedMessage) => {
137
- // Parse message body
138
- let body: unknown;
139
- try {
140
- if (typeof sbMessage.body === "string") {
141
- body = JSON.parse(sbMessage.body);
142
- } else {
143
- body = sbMessage.body;
144
- }
145
- } catch {
146
- body = sbMessage.body;
147
- }
148
-
149
- // Extract application properties as attributes
150
- const attributes: Record<string, string> = {};
151
- if (sbMessage.applicationProperties) {
152
- for (const [key, value] of Object.entries(sbMessage.applicationProperties)) {
153
- attributes[key] = String(value);
154
- }
155
- }
156
-
157
- // Create pub/sub message
158
- const pubsubMessage: PubSubMessage = {
159
- // `messageId` may be string | number | Buffer per Azure SDK; coerce to string.
160
- id: sbMessage.messageId !== undefined ? String(sbMessage.messageId) : uuid(),
161
- body,
162
- attributes,
163
- raw: sbMessage,
164
- topic: config.topic,
165
- subscription: config.subscription,
166
- publishTime: sbMessage.enqueuedTimeUtc ? new Date(sbMessage.enqueuedTimeUtc) : new Date(),
167
- ack: async () => {
168
- await receiver.completeMessage(sbMessage);
169
- },
170
- nack: async () => {
171
- await receiver.abandonMessage(sbMessage);
172
- },
173
- };
174
-
175
- // Process message
176
- try {
177
- await handler(pubsubMessage);
178
- } catch (error) {
179
- console.error(`[AzureServiceBusAdapter] Error processing message: ${(error as Error).message}`);
180
- }
181
- };
182
-
183
- // Error handler — Azure SDK passes a `ProcessErrorArgs` with the
184
- // underlying error inside `args.error` plus contextual fields.
185
- const processError = async (args: ProcessErrorArgs) => {
186
- console.error(`[AzureServiceBusAdapter] Error: ${args.error.message}`);
187
- };
188
-
189
- // Subscribe to messages
190
- receiver.subscribe({
191
- processMessage,
192
- processError,
193
- });
194
-
195
- this.receivers.set(subscriptionKey, receiver);
196
-
197
- console.log(`[AzureServiceBusAdapter] Subscribed to: ${subscriptionKey}`);
198
- }
199
-
200
- /**
201
- * Unsubscribe from Azure Service Bus
202
- */
203
- async unsubscribe(subscriptionKey: string): Promise<void> {
204
- const receiver = this.receivers.get(subscriptionKey);
205
- if (receiver) {
206
- await receiver.close();
207
- this.receivers.delete(subscriptionKey);
208
- console.log(`[AzureServiceBusAdapter] Unsubscribed from: ${subscriptionKey}`);
209
- }
210
- }
211
-
212
- /**
213
- * Check if connected to Azure Service Bus
214
- */
215
- isConnected(): boolean {
216
- return this.connected;
217
- }
218
-
219
- /**
220
- * v0.7 PR 6 — publish to an Azure Service Bus topic.
221
- *
222
- * `partitionKey` maps to Service Bus's `partitionKey`; `orderingKey`
223
- * maps to `sessionId` (for session-enabled topics).
224
- */
225
- async publish(
226
- topic: string,
227
- payload: unknown,
228
- opts?: { partitionKey?: string; orderingKey?: string },
229
- ): Promise<void> {
230
- if (!this.connected) throw new Error("[blok][pubsub-azure] not connected. Call connect() first.");
231
- const sender = this.requireClient().createSender(topic);
232
- try {
233
- await sender.sendMessages({
234
- body: payload,
235
- partitionKey: opts?.partitionKey,
236
- sessionId: opts?.orderingKey,
237
- });
238
- } finally {
239
- await sender.close();
240
- }
241
- }
242
-
243
- /**
244
- * Health check - verify Azure Service Bus connectivity
245
- */
246
- async healthCheck(): Promise<boolean> {
247
- if (!this.connected) return false;
248
-
249
- try {
250
- // Create a temporary receiver to test connectivity
251
- const testReceiver = this.requireClient().createReceiver("$default", {
252
- receiveMode: "peekLock",
253
- });
254
- await testReceiver.close();
255
- return true;
256
- } catch {
257
- // Queue might not exist but connection is healthy if we got here
258
- return this.connected;
259
- }
260
- }
261
- }
262
-
263
- export default AzureServiceBusAdapter;