@blokjs/trigger-pubsub 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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * PubSubTrigger - Base class for pub/sub-based workflow triggers
3
+ *
4
+ * Extends TriggerBase to support pub/sub triggers:
5
+ * - GCP Pub/Sub
6
+ * - AWS SNS (via SQS subscription)
7
+ * - Azure Service Bus
8
+ *
9
+ * Pattern:
10
+ * 1. loadNodes() - Load available nodes into NodeMap
11
+ * 2. loadWorkflows() - Load workflows with pubsub triggers
12
+ * 3. startSubscriber() - Connect to pub/sub and start receiving messages
13
+ * 4. For each message:
14
+ * - Match workflow by trigger config (topic/subscription)
15
+ * - Create context with this.createContext()
16
+ * - Populate ctx.request with message data
17
+ * - Execute workflow via this.run(ctx)
18
+ * - Ack/nack based on response
19
+ */
20
+
21
+ import type { HelperResponse, PubSubProvider, PubSubTriggerOpts } from "@blokjs/helper";
22
+ import {
23
+ DefaultLogger,
24
+ type GlobalOptions,
25
+ type BlokService,
26
+ NodeMap,
27
+ TriggerBase,
28
+ type TriggerResponse,
29
+ } from "@blokjs/runner";
30
+ import type { Context, RequestContext } from "@blokjs/shared";
31
+ import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
32
+ import { v4 as uuid } from "uuid";
33
+
34
+ /**
35
+ * Message received from pub/sub
36
+ */
37
+ export interface PubSubMessage {
38
+ /** Unique message ID */
39
+ id: string;
40
+ /** Message body (parsed) */
41
+ body: unknown;
42
+ /** Message attributes/metadata */
43
+ attributes: Record<string, string>;
44
+ /** Original raw message from provider */
45
+ raw: unknown;
46
+ /** Topic name */
47
+ topic: string;
48
+ /** Subscription name */
49
+ subscription?: string;
50
+ /** Publish timestamp */
51
+ publishTime?: Date;
52
+ /** Acknowledge the message */
53
+ ack: () => Promise<void>;
54
+ /** Reject/nack the message */
55
+ nack: () => Promise<void>;
56
+ }
57
+
58
+ /**
59
+ * Pub/Sub adapter interface - implemented by each provider
60
+ */
61
+ export interface PubSubAdapter {
62
+ /** Provider name */
63
+ readonly provider: PubSubProvider;
64
+
65
+ /** Connect to the pub/sub system */
66
+ connect(): Promise<void>;
67
+
68
+ /** Disconnect from the pub/sub system */
69
+ disconnect(): Promise<void>;
70
+
71
+ /** Subscribe to a topic and receive messages */
72
+ subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void>;
73
+
74
+ /** Unsubscribe from a topic */
75
+ unsubscribe(subscription: string): Promise<void>;
76
+
77
+ /** Check if connected */
78
+ isConnected(): boolean;
79
+
80
+ /** Health check */
81
+ healthCheck(): Promise<boolean>;
82
+ }
83
+
84
+ /**
85
+ * Workflow model with pub/sub trigger configuration
86
+ */
87
+ interface PubSubWorkflowModel {
88
+ path: string;
89
+ config: {
90
+ name: string;
91
+ version: string;
92
+ trigger?: {
93
+ pubsub?: PubSubTriggerOpts;
94
+ [key: string]: unknown;
95
+ };
96
+ [key: string]: unknown;
97
+ };
98
+ }
99
+
100
+ /**
101
+ * PubSubTrigger - Abstract base class for pub/sub-based triggers
102
+ */
103
+ export abstract class PubSubTrigger extends TriggerBase {
104
+ protected nodeMap: GlobalOptions = {} as GlobalOptions;
105
+ protected readonly tracer = trace.getTracer(
106
+ process.env.PROJECT_NAME || "trigger-pubsub-workflow",
107
+ process.env.PROJECT_VERSION || "0.0.1",
108
+ );
109
+ protected readonly logger = new DefaultLogger();
110
+ protected abstract adapter: PubSubAdapter;
111
+
112
+ // Subclasses provide these
113
+ protected abstract nodes: Record<string, BlokService<unknown>>;
114
+ protected abstract workflows: Record<string, HelperResponse>;
115
+
116
+ constructor() {
117
+ super();
118
+ this.loadNodes();
119
+ this.loadWorkflows();
120
+ }
121
+
122
+ /**
123
+ * Load nodes into the node map
124
+ */
125
+ loadNodes(): void {
126
+ this.nodeMap.nodes = new NodeMap();
127
+ const nodeKeys = Object.keys(this.nodes);
128
+ for (const key of nodeKeys) {
129
+ this.nodeMap.nodes.addNode(key, this.nodes[key]);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Load workflows into the workflow map
135
+ */
136
+ loadWorkflows(): void {
137
+ this.nodeMap.workflows = this.workflows;
138
+ }
139
+
140
+ /**
141
+ * Start the pub/sub subscriber - main entry point
142
+ */
143
+ async listen(): Promise<number> {
144
+ const startTime = this.startCounter();
145
+
146
+ try {
147
+ // Connect to pub/sub system
148
+ await this.adapter.connect();
149
+ this.logger.log(`Connected to ${this.adapter.provider} pub/sub system`);
150
+
151
+ // Find all workflows with pub/sub triggers
152
+ const pubsubWorkflows = this.getPubSubWorkflows();
153
+
154
+ if (pubsubWorkflows.length === 0) {
155
+ this.logger.log("No workflows with pub/sub triggers found");
156
+ return this.endCounter(startTime);
157
+ }
158
+
159
+ // Subscribe to each topic/subscription
160
+ for (const workflow of pubsubWorkflows) {
161
+ const config = workflow.config.trigger?.pubsub as PubSubTriggerOpts;
162
+ this.logger.log(
163
+ `Subscribing to topic: ${config.topic}, subscription: ${config.subscription} for workflow: ${workflow.path}`,
164
+ );
165
+
166
+ await this.adapter.subscribe(config, async (message) => {
167
+ await this.handleMessage(message, workflow, config);
168
+ });
169
+ }
170
+
171
+ this.logger.log(`Pub/Sub trigger started. Listening to ${pubsubWorkflows.length} subscription(s)`);
172
+
173
+ // Enable HMR in development mode
174
+ if (process.env.BLOK_HMR === "true" || process.env.NODE_ENV === "development") {
175
+ await this.enableHotReload();
176
+ }
177
+
178
+ return this.endCounter(startTime);
179
+ } catch (error) {
180
+ this.logger.error(`Failed to start pub/sub trigger: ${(error as Error).message}`);
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Stop the pub/sub subscriber
187
+ */
188
+ async stop(): Promise<void> {
189
+ await this.adapter.disconnect();
190
+ this.logger.log("Pub/Sub trigger stopped");
191
+ }
192
+
193
+ protected override async onHmrWorkflowChange(): Promise<void> {
194
+ this.logger.log("[HMR] Pub/Sub workflow changed, reloading...");
195
+ await this.waitForInFlightRequests();
196
+ await this.stop();
197
+ this.loadWorkflows();
198
+ await this.listen();
199
+ }
200
+
201
+ /**
202
+ * Get all workflows that have pub/sub triggers
203
+ */
204
+ protected getPubSubWorkflows(): PubSubWorkflowModel[] {
205
+ const workflows: PubSubWorkflowModel[] = [];
206
+
207
+ for (const [path, workflow] of Object.entries(this.nodeMap.workflows || {})) {
208
+ // HelperResponse has a protected _config property
209
+ const workflowConfig = (workflow as unknown as { _config: PubSubWorkflowModel["config"] })._config;
210
+
211
+ if (workflowConfig?.trigger) {
212
+ const triggerType = Object.keys(workflowConfig.trigger)[0];
213
+
214
+ if (triggerType === "pubsub" && workflowConfig.trigger.pubsub) {
215
+ workflows.push({
216
+ path,
217
+ config: workflowConfig,
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ return workflows;
224
+ }
225
+
226
+ /**
227
+ * Handle an incoming message
228
+ */
229
+ protected async handleMessage(
230
+ message: PubSubMessage,
231
+ workflow: PubSubWorkflowModel,
232
+ config: PubSubTriggerOpts,
233
+ ): Promise<void> {
234
+ const id = message.id || uuid();
235
+ const defaultMeter = metrics.getMeter("default");
236
+ const pubsubMessages = defaultMeter.createCounter("pubsub_messages", {
237
+ description: "Pub/Sub messages processed",
238
+ });
239
+ const pubsubErrors = defaultMeter.createCounter("pubsub_errors", {
240
+ description: "Pub/Sub message processing errors",
241
+ });
242
+
243
+ await this.tracer.startActiveSpan(`pubsub:${config.topic}`, async (span: Span) => {
244
+ try {
245
+ const start = performance.now();
246
+
247
+ // Initialize configuration for this workflow
248
+ await this.configuration.init(workflow.path, this.nodeMap);
249
+
250
+ // Create context
251
+ const ctx: Context = this.createContext(undefined, workflow.path, id);
252
+
253
+ // Populate request with message data
254
+ ctx.request = {
255
+ body: message.body,
256
+ headers: message.attributes,
257
+ query: {},
258
+ params: {
259
+ topic: message.topic,
260
+ subscription: message.subscription || "",
261
+ messageId: message.id,
262
+ },
263
+ } as unknown as RequestContext;
264
+
265
+ // Store message metadata in context
266
+ if (!ctx.vars) ctx.vars = {};
267
+ ctx.vars["_pubsub_message"] = {
268
+ topic: message.topic,
269
+ subscription: message.subscription || "",
270
+ publishTime: message.publishTime?.toISOString() ?? "",
271
+ attributes: JSON.stringify(message.attributes),
272
+ };
273
+
274
+ ctx.logger.log(`Processing message from ${config.topic}: ${id}`);
275
+
276
+ // Execute workflow
277
+ const response: TriggerResponse = await this.run(ctx);
278
+ const end = performance.now();
279
+
280
+ // Set span attributes
281
+ span.setAttribute("success", true);
282
+ span.setAttribute("message_id", id);
283
+ span.setAttribute("topic", config.topic);
284
+ span.setAttribute("subscription", config.subscription);
285
+ span.setAttribute("provider", config.provider);
286
+ span.setAttribute("elapsed_ms", end - start);
287
+ span.setStatus({ code: SpanStatusCode.OK });
288
+
289
+ // Record metrics
290
+ pubsubMessages.add(1, {
291
+ env: process.env.NODE_ENV,
292
+ topic: config.topic,
293
+ subscription: config.subscription,
294
+ provider: config.provider,
295
+ workflow_name: this.configuration.name,
296
+ success: "true",
297
+ });
298
+
299
+ ctx.logger.log(`Message processed in ${(end - start).toFixed(2)}ms: ${id}`);
300
+
301
+ // Acknowledge message if configured
302
+ if (config.ack !== false) {
303
+ await message.ack();
304
+ ctx.logger.log(`Message acknowledged: ${id}`);
305
+ }
306
+ } catch (error) {
307
+ const errorMessage = (error as Error).message;
308
+
309
+ // Set span error
310
+ span.setAttribute("success", false);
311
+ span.recordException(error as Error);
312
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
313
+
314
+ // Record error metrics
315
+ pubsubErrors.add(1, {
316
+ env: process.env.NODE_ENV,
317
+ topic: config.topic,
318
+ subscription: config.subscription,
319
+ provider: config.provider,
320
+ workflow_name: this.configuration?.name || "unknown",
321
+ });
322
+
323
+ this.logger.error(`Failed to process message ${id}: ${errorMessage}`, (error as Error).stack);
324
+
325
+ // Nack message
326
+ if (config.ack !== false) {
327
+ await message.nack();
328
+ this.logger.log(`Message nacked: ${id}`);
329
+ }
330
+ } finally {
331
+ span.end();
332
+ }
333
+ });
334
+ }
335
+ }
336
+
337
+ export default PubSubTrigger;
@@ -0,0 +1,258 @@
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 { PubSubTriggerOpts } from "@blokjs/helper";
18
+ import { v4 as uuid } from "uuid";
19
+ import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
20
+
21
+ /**
22
+ * AWS SNS/SQS configuration
23
+ */
24
+ export interface AWSSNSConfig {
25
+ region: string;
26
+ waitTimeSeconds?: number;
27
+ maxNumberOfMessages?: number;
28
+ }
29
+
30
+ /**
31
+ * AWSSNSAdapter - AWS SNS implementation using SQS subscriptions
32
+ */
33
+ export class AWSSNSAdapter implements PubSubAdapter {
34
+ readonly provider = "aws" as const;
35
+
36
+ private sqsClient: any;
37
+ private connected = false;
38
+ private config: AWSSNSConfig;
39
+ private pollingIntervals: Map<string, NodeJS.Timeout> = new Map();
40
+ private shouldStop = false;
41
+
42
+ constructor(config?: Partial<AWSSNSConfig>) {
43
+ this.config = {
44
+ region: config?.region || process.env.AWS_REGION || "us-east-1",
45
+ waitTimeSeconds: config?.waitTimeSeconds ?? Number.parseInt(process.env.SQS_WAIT_TIME_SECONDS || "20", 10),
46
+ maxNumberOfMessages: config?.maxNumberOfMessages ?? Number.parseInt(process.env.SQS_MAX_MESSAGES || "10", 10),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Connect to AWS
52
+ */
53
+ async connect(): Promise<void> {
54
+ if (this.connected) return;
55
+
56
+ try {
57
+ // Dynamic import of AWS SDK
58
+ const { SQSClient } = await import("@aws-sdk/client-sqs");
59
+
60
+ this.sqsClient = new SQSClient({
61
+ region: this.config.region,
62
+ });
63
+
64
+ this.connected = true;
65
+ this.shouldStop = false;
66
+ console.log(`[AWSSNSAdapter] Connected to AWS SNS/SQS: ${this.config.region}`);
67
+ } catch (error) {
68
+ throw new Error(
69
+ `Failed to connect to AWS: ${(error as Error).message}. ` +
70
+ `Make sure @aws-sdk/client-sqs is installed: npm install @aws-sdk/client-sqs`,
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Disconnect from AWS
77
+ */
78
+ async disconnect(): Promise<void> {
79
+ if (!this.connected) return;
80
+
81
+ this.shouldStop = true;
82
+
83
+ // Clear all polling intervals
84
+ for (const [queueUrl, interval] of this.pollingIntervals) {
85
+ clearTimeout(interval);
86
+ }
87
+ this.pollingIntervals.clear();
88
+
89
+ this.connected = false;
90
+ console.log("[AWSSNSAdapter] Disconnected from AWS SNS/SQS");
91
+ }
92
+
93
+ /**
94
+ * Subscribe to an SNS topic via SQS queue
95
+ * Note: The SQS queue should be pre-configured as an SNS subscription
96
+ */
97
+ async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
98
+ if (!this.connected) {
99
+ throw new Error("Not connected to AWS. Call connect() first.");
100
+ }
101
+
102
+ // In AWS, subscription is the SQS queue URL that's subscribed to the SNS topic
103
+ const queueUrl = config.subscription;
104
+
105
+ // Start polling the SQS queue
106
+ this.poll(queueUrl, config, handler);
107
+
108
+ console.log(`[AWSSNSAdapter] Subscribed to queue: ${queueUrl} (topic: ${config.topic})`);
109
+ }
110
+
111
+ /**
112
+ * Poll SQS queue for messages (long polling)
113
+ */
114
+ private async poll(
115
+ queueUrl: string,
116
+ config: PubSubTriggerOpts,
117
+ handler: (message: PubSubMessage) => Promise<void>,
118
+ ): Promise<void> {
119
+ if (this.shouldStop) return;
120
+
121
+ try {
122
+ const { ReceiveMessageCommand, DeleteMessageCommand } = await import("@aws-sdk/client-sqs");
123
+
124
+ const command = new ReceiveMessageCommand({
125
+ QueueUrl: queueUrl,
126
+ MaxNumberOfMessages: config.maxMessages || this.config.maxNumberOfMessages,
127
+ WaitTimeSeconds: this.config.waitTimeSeconds,
128
+ MessageAttributeNames: ["All"],
129
+ AttributeNames: ["All"],
130
+ });
131
+
132
+ const response = await this.sqsClient.send(command);
133
+
134
+ if (response.Messages && response.Messages.length > 0) {
135
+ for (const msg of response.Messages) {
136
+ // Parse SNS message wrapper
137
+ let snsMessage: any;
138
+ let body: unknown;
139
+
140
+ try {
141
+ snsMessage = JSON.parse(msg.Body || "{}");
142
+ // SNS wraps the actual message in a "Message" field
143
+ if (snsMessage.Type === "Notification" && snsMessage.Message) {
144
+ try {
145
+ body = JSON.parse(snsMessage.Message);
146
+ } catch {
147
+ body = snsMessage.Message;
148
+ }
149
+ } else {
150
+ body = snsMessage;
151
+ }
152
+ } catch {
153
+ body = msg.Body;
154
+ snsMessage = {};
155
+ }
156
+
157
+ // Extract attributes from both SQS and SNS
158
+ const attributes: Record<string, string> = {};
159
+
160
+ // SQS message attributes
161
+ if (msg.MessageAttributes) {
162
+ for (const [key, attr] of Object.entries(msg.MessageAttributes)) {
163
+ attributes[key] = (attr as any).StringValue || "";
164
+ }
165
+ }
166
+
167
+ // SNS message attributes (if present)
168
+ if (snsMessage.MessageAttributes) {
169
+ for (const [key, attr] of Object.entries(snsMessage.MessageAttributes)) {
170
+ attributes[`sns_${key}`] = (attr as any).Value || "";
171
+ }
172
+ }
173
+
174
+ // Create pub/sub message
175
+ const pubsubMessage: PubSubMessage = {
176
+ id: snsMessage.MessageId || msg.MessageId || uuid(),
177
+ body,
178
+ attributes,
179
+ raw: msg,
180
+ topic: snsMessage.TopicArn || config.topic,
181
+ subscription: queueUrl,
182
+ publishTime: snsMessage.Timestamp ? new Date(snsMessage.Timestamp) : new Date(),
183
+ ack: async () => {
184
+ const deleteCommand = new DeleteMessageCommand({
185
+ QueueUrl: queueUrl,
186
+ ReceiptHandle: msg.ReceiptHandle,
187
+ });
188
+ await this.sqsClient.send(deleteCommand);
189
+ },
190
+ nack: async () => {
191
+ // Let the visibility timeout expire to return the message
192
+ // Or change visibility to 0 for immediate retry
193
+ const { ChangeMessageVisibilityCommand } = await import("@aws-sdk/client-sqs");
194
+ const changeCommand = new ChangeMessageVisibilityCommand({
195
+ QueueUrl: queueUrl,
196
+ ReceiptHandle: msg.ReceiptHandle,
197
+ VisibilityTimeout: 0,
198
+ });
199
+ await this.sqsClient.send(changeCommand);
200
+ },
201
+ };
202
+
203
+ // Process message
204
+ try {
205
+ await handler(pubsubMessage);
206
+ } catch (error) {
207
+ console.error(`[AWSSNSAdapter] Error processing message: ${(error as Error).message}`);
208
+ }
209
+ }
210
+ }
211
+ } catch (error) {
212
+ console.error(`[AWSSNSAdapter] Polling error: ${(error as Error).message}`);
213
+ }
214
+
215
+ // Continue polling unless stopped
216
+ if (!this.shouldStop) {
217
+ const timeout = setTimeout(() => this.poll(queueUrl, config, handler), 0);
218
+ this.pollingIntervals.set(queueUrl, timeout);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Unsubscribe from a queue (stops polling)
224
+ */
225
+ async unsubscribe(queueUrl: string): Promise<void> {
226
+ const interval = this.pollingIntervals.get(queueUrl);
227
+ if (interval) {
228
+ clearTimeout(interval);
229
+ this.pollingIntervals.delete(queueUrl);
230
+ console.log(`[AWSSNSAdapter] Unsubscribed from queue: ${queueUrl}`);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Check if connected to AWS
236
+ */
237
+ isConnected(): boolean {
238
+ return this.connected;
239
+ }
240
+
241
+ /**
242
+ * Health check - verify AWS connectivity
243
+ */
244
+ async healthCheck(): Promise<boolean> {
245
+ if (!this.connected) return false;
246
+
247
+ try {
248
+ const { ListQueuesCommand } = await import("@aws-sdk/client-sqs");
249
+ const command = new ListQueuesCommand({ MaxResults: 1 });
250
+ await this.sqsClient.send(command);
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ }
257
+
258
+ export default AWSSNSAdapter;