@dbos-inc/sqs-receive 3.0.10-preview

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/index.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { DBOS, Error as DBOSError, DBOSLifecycleCallback } from '@dbos-inc/dbos-sdk';
2
+
3
+ import {
4
+ DeleteMessageCommand,
5
+ GetQueueAttributesCommand,
6
+ GetQueueAttributesCommandInput,
7
+ Message,
8
+ ReceiveMessageCommand,
9
+ ReceiveMessageCommandOutput,
10
+ SQSClient,
11
+ } from '@aws-sdk/client-sqs';
12
+
13
+ interface SQSConfig {
14
+ client?: SQSClient | (() => SQSClient);
15
+ queueUrl?: string;
16
+ getWorkflowKey?: (m: Message) => string;
17
+ workflowQueueName?: string;
18
+ }
19
+
20
+ interface SQSRecvClassConfig {
21
+ config?: SQSConfig;
22
+ }
23
+
24
+ interface SQSRecvMethodConfig {
25
+ config?: SQSConfig;
26
+ }
27
+
28
+ class SQSReceiver extends DBOSLifecycleCallback {
29
+ config?: SQSConfig;
30
+
31
+ constructor(config?: SQSConfig) {
32
+ super();
33
+ this.config = config;
34
+ }
35
+
36
+ listeners: Promise<void>[] = [];
37
+ isShuttingDown = false;
38
+
39
+ override async destroy() {
40
+ this.isShuttingDown = true;
41
+ try {
42
+ await Promise.allSettled(this.listeners);
43
+ } catch (e) {
44
+ // yawn
45
+ }
46
+ this.listeners = [];
47
+ }
48
+
49
+ // async function that uses .then/.catch to handle potentially unreliable library calls
50
+ static async sendReceiveMessageCommandSafe(
51
+ sqs: SQSClient,
52
+ params: ReceiveMessageCommand,
53
+ ): Promise<ReceiveMessageCommandOutput> {
54
+ return new Promise((resolve, reject) => {
55
+ sqs
56
+ .send(params)
57
+ .then((response) => resolve(response))
58
+ .catch((error) => reject(error as Error));
59
+ });
60
+ }
61
+
62
+ static async validateConnection(client: SQSClient, url: string) {
63
+ const params: GetQueueAttributesCommandInput = {
64
+ QueueUrl: url,
65
+ AttributeNames: ['All'],
66
+ };
67
+
68
+ const _validateSQSConfiguration = await client.send(new GetQueueAttributesCommand(params));
69
+ }
70
+
71
+ override async initialize() {
72
+ const regops = DBOS.getAssociatedInfo(this);
73
+
74
+ for (const regop of regops) {
75
+ const { methodConfig, classConfig, methodReg } = regop;
76
+ const sqsclass = classConfig as SQSRecvClassConfig;
77
+ const sqsmethod = methodConfig as SQSRecvMethodConfig;
78
+ if (!sqsmethod) continue;
79
+
80
+ const url = sqsclass.config?.queueUrl ?? sqsmethod.config?.queueUrl ?? this.config?.queueUrl;
81
+ if (!url) continue;
82
+
83
+ const cname = methodReg.className;
84
+ const mname = methodReg.name;
85
+
86
+ if (!methodReg.workflowConfig) {
87
+ throw new DBOSError.DBOSError(
88
+ `Error registering method ${cname}.${mname}: An SQS decorator can only be assigned to a workflow!`,
89
+ );
90
+ }
91
+
92
+ let sqs: SQSClient | undefined = undefined;
93
+ if (sqsmethod.config?.client) {
94
+ sqs = typeof sqsmethod.config.client === 'function' ? sqsmethod.config.client() : sqsmethod.config.client;
95
+ } else if (sqsclass.config?.client) {
96
+ sqs = typeof sqsclass.config.client === 'function' ? sqsclass.config.client() : sqsclass.config.client;
97
+ } else if (this.config?.client) {
98
+ sqs = typeof this.config.client === 'function' ? this.config.client() : this.config.client;
99
+ }
100
+
101
+ if (!sqs) {
102
+ throw new DBOSError.DBOSError(`Error initializing SQS method ${cname}.${mname}: No SQSClient provided`);
103
+ }
104
+ try {
105
+ await SQSReceiver.validateConnection(sqs, url);
106
+ DBOS.logger.info(`Successfully connected to SQS queue ${url} for ${cname}.${mname}`);
107
+ } catch (e) {
108
+ const err = e as Error;
109
+ DBOS.logger.error(err);
110
+ throw new DBOSError.DBOSError(
111
+ `SQS Receiver for ${cname}.${mname} was unable to connect to ${url}: ${err.message}`,
112
+ );
113
+ }
114
+
115
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
116
+ const sqsr = this;
117
+ this.listeners.push(
118
+ (async (client: SQSClient, url: string) => {
119
+ while (!this.isShuttingDown) {
120
+ // Get message
121
+ try {
122
+ const response = await SQSReceiver.sendReceiveMessageCommandSafe(
123
+ client,
124
+ new ReceiveMessageCommand({
125
+ QueueUrl: url,
126
+ MaxNumberOfMessages: 1,
127
+ WaitTimeSeconds: 5,
128
+ }),
129
+ );
130
+
131
+ if (!response.Messages || response.Messages.length === 0) {
132
+ DBOS.logger.debug(`No messages for ${url} - `);
133
+ continue;
134
+ }
135
+
136
+ const message = response.Messages[0];
137
+
138
+ // Start workflow
139
+ let wfid = sqsmethod.config?.getWorkflowKey ? sqsmethod.config.getWorkflowKey(message) : undefined;
140
+ if (!wfid) {
141
+ wfid = sqsclass.config?.getWorkflowKey ? sqsclass.config.getWorkflowKey(message) : undefined;
142
+ }
143
+ if (!wfid) {
144
+ wfid = sqsr.config?.getWorkflowKey ? sqsr.config.getWorkflowKey(message) : undefined;
145
+ }
146
+ if (!wfid) wfid = message.MessageId;
147
+ await DBOS.startWorkflow(methodReg.registeredFunction as (...args: unknown[]) => Promise<unknown>, {
148
+ workflowID: wfid,
149
+ queueName:
150
+ sqsmethod.config?.workflowQueueName ??
151
+ sqsclass.config?.workflowQueueName ??
152
+ sqsr.config?.workflowQueueName,
153
+ })(message);
154
+
155
+ // Delete the message from the queue after starting workflow (which will take over retries)
156
+ await client.send(
157
+ new DeleteMessageCommand({
158
+ QueueUrl: url,
159
+ ReceiptHandle: message.ReceiptHandle,
160
+ }),
161
+ );
162
+ } catch (e) {
163
+ DBOS.logger.error(e);
164
+ }
165
+ }
166
+ })(sqs, url),
167
+ );
168
+ }
169
+ }
170
+
171
+ override logRegisteredEndpoints() {
172
+ DBOS.logger.info('SQS receiver endpoints:');
173
+
174
+ const eps = DBOS.getAssociatedInfo(this);
175
+
176
+ for (const e of eps) {
177
+ const { methodConfig, classConfig, methodReg } = e;
178
+ const sqsclass = classConfig as SQSRecvClassConfig;
179
+ const sqsmethod = methodConfig as SQSRecvMethodConfig;
180
+ if (sqsmethod) {
181
+ const url = sqsclass.config?.queueUrl ?? sqsmethod.config?.queueUrl ?? this.config?.queueUrl;
182
+ if (url) {
183
+ const cname = methodReg.className;
184
+ const mname = methodReg.name;
185
+ DBOS.logger.info(` ${url} -> ${cname}.${mname}`);
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ // Decorator - class
192
+ configuration(config: SQSConfig) {
193
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
194
+ const er = this;
195
+
196
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
197
+ const erInfo = DBOS.associateClassWithInfo(er, ctor) as SQSRecvClassConfig;
198
+ erInfo.config = config;
199
+ }
200
+ return clsdec;
201
+ }
202
+
203
+ // Decorators - method
204
+ messageConsumer(config?: SQSConfig) {
205
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
206
+ const er = this;
207
+ function mtddec<This, Args extends [Message], Return>(
208
+ target: object,
209
+ propertyKey: string,
210
+ inDescriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
211
+ ) {
212
+ const { regInfo: receiverInfo } = DBOS.associateFunctionWithInfo(er, inDescriptor.value!, {
213
+ classOrInst: target,
214
+ name: propertyKey,
215
+ });
216
+
217
+ const mRegistration = receiverInfo as SQSRecvMethodConfig;
218
+ mRegistration.config = config;
219
+
220
+ return inDescriptor;
221
+ }
222
+ return mtddec;
223
+ }
224
+ }
225
+
226
+ export { SQSConfig, SQSReceiver };
package/jest.config.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ testRegex: '((\\.|/)(test|spec))\\.ts?$',
6
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7
+ modulePaths: ['./'],
8
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@dbos-inc/sqs-receive",
3
+ "version": "3.0.10-preview",
4
+ "description": "DBOS event receiver for AWS SQS queues",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dbos-inc/dbos-transact-ts",
9
+ "directory": "packages/sqs-receive"
10
+ },
11
+ "homepage": "https://docs.dbos.dev/",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "scripts": {
15
+ "build": "tsc --project tsconfig.json",
16
+ "test": "echo 'no tests'",
17
+ "testsqs": "npm run build && jest --detectOpenHandles"
18
+ },
19
+ "devDependencies": {
20
+ "@types/jest": "^29.5.12",
21
+ "@types/supertest": "^6.0.2",
22
+ "jest": "^29.7.0",
23
+ "supertest": "^7.0.0",
24
+ "ts-jest": "^29.1.4",
25
+ "typescript": "^5.3.3"
26
+ },
27
+ "peerDependencies": {
28
+ "@dbos-inc/dbos-sdk": "*",
29
+ "@dbos-inc/aws-config": "*"
30
+ },
31
+ "dependencies": {
32
+ "@aws-sdk/client-sqs": "^3.596.0"
33
+ }
34
+ }
@@ -0,0 +1,10 @@
1
+ # To enable auto-completion and validation for this file in VSCode, install the RedHat YAML extension
2
+ # https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml
3
+
4
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/dbos-inc/dbos-transact/main/dbos-config.schema.json
5
+
6
+ database:
7
+ hostname: 'localhost'
8
+ port: 5432
9
+ username: 'postgres'
10
+ password: ${PGPASSWORD}
package/sqs.test.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { Message, SendMessageCommand, SendMessageCommandInput, SQSClient } from '@aws-sdk/client-sqs';
2
+ import { SQSReceiver } from './index';
3
+ import { DBOS } from '@dbos-inc/dbos-sdk';
4
+
5
+ const sleepms = (ms: number) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ interface ValueObj {
8
+ val: number;
9
+ }
10
+
11
+ function createSQS() {
12
+ return new SQSClient({
13
+ region: process.env['AWS_REGION'] ?? '',
14
+ endpoint: process.env['AWS_ENDPOINT_URL_SQS'],
15
+ credentials: {
16
+ accessKeyId: process.env['AWS_ACCESS_KEY_ID'] ?? '',
17
+ secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'] ?? '',
18
+ },
19
+ //logger: console,
20
+ });
21
+ }
22
+
23
+ // Create a new type that omits the QueueUrl property
24
+ type MessageWithoutQueueUrl = Omit<SendMessageCommandInput, 'QueueUrl'>;
25
+
26
+ // Create a new type that allows QueueUrl to be added later
27
+ type MessageWithOptionalQueueUrl = MessageWithoutQueueUrl & { QueueUrl?: string };
28
+
29
+ async function sendMessageInternal(msg: MessageWithOptionalQueueUrl) {
30
+ try {
31
+ const smsg = { ...msg, QueueUrl: msg.QueueUrl || process.env['SQS_QUEUE_URL']! };
32
+ return await createSQS().send(new SendMessageCommand(smsg));
33
+ } catch (e) {
34
+ DBOS.logger.error(e);
35
+ throw e;
36
+ }
37
+ }
38
+
39
+ const sendMessageStep = DBOS.registerStep(sendMessageInternal, { name: 'Send SQS Message' });
40
+
41
+ const sqsReceiver = new SQSReceiver({
42
+ client: createSQS,
43
+ });
44
+
45
+ class SQSRcv {
46
+ static msgRcvCount: number = 0;
47
+ static msgValueSum: number = 0;
48
+ @sqsReceiver.messageConsumer({ queueUrl: process.env['SQS_QUEUE_URL'] })
49
+ @DBOS.workflow()
50
+ static async recvMessage(msg: Message) {
51
+ const ms = msg.Body!;
52
+ const res = JSON.parse(ms) as ValueObj;
53
+ SQSRcv.msgRcvCount++;
54
+ SQSRcv.msgValueSum += res.val;
55
+ return Promise.resolve();
56
+ }
57
+ }
58
+
59
+ describe('sqs-tests', () => {
60
+ let sqsIsAvailable = true;
61
+
62
+ beforeAll(() => {
63
+ // Check if SES is available and update app config, skip the test if it's not
64
+ if (!process.env['AWS_REGION'] || !process.env['SQS_QUEUE_URL']) {
65
+ sqsIsAvailable = false;
66
+ } else {
67
+ // This would normally be a global or static or something
68
+ DBOS.setConfig({ name: 'dbossqstest' });
69
+ }
70
+ });
71
+
72
+ beforeEach(async () => {
73
+ if (sqsIsAvailable) {
74
+ DBOS.registerLifecycleCallback(sqsReceiver);
75
+ await DBOS.launch();
76
+ } else {
77
+ console.log(
78
+ 'SQS Test is not configured. To run, set AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and SQS_QUEUE_URL',
79
+ );
80
+ }
81
+ });
82
+
83
+ afterEach(async () => {
84
+ if (sqsIsAvailable) {
85
+ await DBOS.shutdown();
86
+ }
87
+ }, 10000);
88
+
89
+ // This tests receive also; which is already wired up
90
+ test('sqs-send', async () => {
91
+ if (!sqsIsAvailable) {
92
+ console.log('SQS unavailable, skipping SQS tests');
93
+ return;
94
+ }
95
+ const sv: ValueObj = {
96
+ val: 10,
97
+ };
98
+ const ser = await sendMessageStep({
99
+ MessageBody: JSON.stringify(sv),
100
+ });
101
+ expect(ser.MessageId).toBeDefined();
102
+
103
+ // Wait for receipt
104
+ for (let i = 0; i < 100; ++i) {
105
+ if (SQSRcv.msgRcvCount === 1) break;
106
+ await sleepms(100);
107
+ }
108
+ expect(SQSRcv.msgRcvCount).toBe(1);
109
+ expect(SQSRcv.msgValueSum).toBe(10);
110
+ });
111
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ /* Visit https://aka.ms/tsconfig to read more about this file */
2
+ {
3
+ "extends": "../../tsconfig.shared.json",
4
+ "compilerOptions": {
5
+ "outDir": "./dist"
6
+ },
7
+ "include": [/* Specifies an array of filenames or patterns to include in the program. */ "."],
8
+ "exclude": ["dist"]
9
+ }