@friggframework/serverless-plugin 2.0.0-next.80 → 2.0.0-next.82

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.js CHANGED
@@ -66,12 +66,37 @@ class FriggServerlessPlugin {
66
66
  }
67
67
 
68
68
  extractQueueDefinitions() {
69
+ // Each custom.*Queue entry is the resolved QueueName. The matching
70
+ // CloudFormation resource (under resources.Resources) has the same
71
+ // logical ID and carries the Properties block we want to mirror onto
72
+ // LocalStack (VisibilityTimeout, MessageRetentionPeriod,
73
+ // RedrivePolicy, …). Deployed AWS applies those via CloudFormation;
74
+ // locally they'd be silently dropped and LocalStack would fall back
75
+ // to AWS defaults — notably a 30s VisibilityTimeout which
76
+ // re-delivers in-flight messages while a long-running queue worker
77
+ // is still processing them.
78
+ const resources =
79
+ this.serverless.service.resources &&
80
+ this.serverless.service.resources.Resources
81
+ ? this.serverless.service.resources.Resources
82
+ : {};
83
+
69
84
  return Object.keys(this.serverless.service.custom)
70
85
  .filter((key) => key.endsWith('Queue'))
71
- .map((key) => ({
72
- key,
73
- name: this.serverless.service.custom[key],
74
- }));
86
+ .map((key) => {
87
+ const resource = resources[key];
88
+ const properties =
89
+ resource &&
90
+ resource.Type === 'AWS::SQS::Queue' &&
91
+ resource.Properties
92
+ ? resource.Properties
93
+ : undefined;
94
+ return {
95
+ key,
96
+ name: this.serverless.service.custom[key],
97
+ ...(properties ? { properties } : {}),
98
+ };
99
+ });
75
100
  }
76
101
 
77
102
  createLocalStackSQSClient() {
package/index.test.js CHANGED
@@ -183,6 +183,75 @@ describe('FriggServerlessPlugin', () => {
183
183
 
184
184
  expect(queues).toEqual([]);
185
185
  });
186
+
187
+ it('should attach Properties from matching CloudFormation resources', () => {
188
+ plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
189
+ mockServerless.service.custom = {
190
+ HubspotQueue: 'svc--dev-HubspotQueue',
191
+ };
192
+ mockServerless.service.resources = {
193
+ Resources: {
194
+ HubspotQueue: {
195
+ Type: 'AWS::SQS::Queue',
196
+ Properties: {
197
+ QueueName: 'svc--dev-HubspotQueue',
198
+ VisibilityTimeout: 1800,
199
+ MessageRetentionPeriod: 345600,
200
+ RedrivePolicy: {
201
+ maxReceiveCount: 3,
202
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
203
+ },
204
+ },
205
+ },
206
+ },
207
+ };
208
+
209
+ const queues = plugin.extractQueueDefinitions();
210
+
211
+ expect(queues).toHaveLength(1);
212
+ expect(queues[0]).toEqual({
213
+ key: 'HubspotQueue',
214
+ name: 'svc--dev-HubspotQueue',
215
+ properties: {
216
+ QueueName: 'svc--dev-HubspotQueue',
217
+ VisibilityTimeout: 1800,
218
+ MessageRetentionPeriod: 345600,
219
+ RedrivePolicy: {
220
+ maxReceiveCount: 3,
221
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
222
+ },
223
+ },
224
+ });
225
+ });
226
+
227
+ it('should omit properties when resources.Resources is absent', () => {
228
+ plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
229
+ mockServerless.service.custom = { LegacyQueue: 'legacy-queue' };
230
+ mockServerless.service.resources = undefined;
231
+
232
+ const queues = plugin.extractQueueDefinitions();
233
+
234
+ expect(queues).toEqual([{ key: 'LegacyQueue', name: 'legacy-queue' }]);
235
+ });
236
+
237
+ it('should ignore non-SQS CloudFormation resources with matching logical IDs', () => {
238
+ plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
239
+ mockServerless.service.custom = { MisnamedQueue: 'misnamed-queue' };
240
+ mockServerless.service.resources = {
241
+ Resources: {
242
+ MisnamedQueue: {
243
+ Type: 'AWS::SNS::Topic',
244
+ Properties: { TopicName: 'not-an-sqs-queue' },
245
+ },
246
+ },
247
+ };
248
+
249
+ const queues = plugin.extractQueueDefinitions();
250
+
251
+ expect(queues).toEqual([
252
+ { key: 'MisnamedQueue', name: 'misnamed-queue' },
253
+ ]);
254
+ });
186
255
  });
187
256
 
188
257
  describe('Hooks', () => {
@@ -2,19 +2,120 @@
2
2
  * Infrastructure Service - LocalStack Queue Management
3
3
  *
4
4
  * Handles SQS queue creation in LocalStack for offline development.
5
+ *
6
+ * On deployed AWS, CloudFormation applies queue properties
7
+ * (VisibilityTimeout, MessageRetentionPeriod, RedrivePolicy) from the
8
+ * `Resources` block in serverless.yml. LocalStack gets those same
9
+ * properties here so local emulation matches production behavior —
10
+ * otherwise the queue defaults to a 30s VisibilityTimeout which re-
11
+ * delivers in-flight messages while a long-running queue worker is
12
+ * still processing them.
5
13
  */
6
14
  class LocalStackQueueService {
7
15
  constructor(sqsClient) {
8
16
  this.sqs = sqsClient;
9
17
  }
10
18
 
19
+ /**
20
+ * Whitelist of CloudFormation `AWS::SQS::Queue` Properties that map
21
+ * onto SQS `CreateQueue` Attributes. Everything else (tags, inline
22
+ * refs, etc.) is dropped on the way to LocalStack.
23
+ * @private
24
+ */
25
+ static PROPERTY_ATTRIBUTE_KEYS = [
26
+ 'DelaySeconds',
27
+ 'MaximumMessageSize',
28
+ 'MessageRetentionPeriod',
29
+ 'ReceiveMessageWaitTimeSeconds',
30
+ 'VisibilityTimeout',
31
+ 'RedrivePolicy',
32
+ 'RedriveAllowPolicy',
33
+ 'KmsMasterKeyId',
34
+ 'KmsDataKeyReusePeriodSeconds',
35
+ 'SqsManagedSseEnabled',
36
+ 'FifoQueue',
37
+ 'ContentBasedDeduplication',
38
+ 'DeduplicationScope',
39
+ 'FifoThroughputLimit',
40
+ ];
41
+
42
+ /**
43
+ * Serialize a CloudFormation `Properties` object into the
44
+ * `Attributes` shape the SQS `CreateQueue` API accepts (string
45
+ * values only; object values like `RedrivePolicy` get JSON-encoded).
46
+ *
47
+ * Attributes whose value still contains an unresolved CloudFormation
48
+ * intrinsic (`Fn::GetAtt`, `Ref`, `Fn::Sub`, …) are DROPPED rather
49
+ * than stringified. Example: integration-builder.js emits
50
+ * `RedrivePolicy.deadLetterTargetArn: {'Fn::GetAtt': [...]}`, which
51
+ * CloudFormation resolves to a real ARN in AWS but is still a raw
52
+ * intrinsic object at local plugin-time. Forwarding that JSON blob
53
+ * to SQS `CreateQueue` would fail (`deadLetterTargetArn` must be a
54
+ * valid ARN string) or produce malformed config. Dropping the
55
+ * attribute gives local parity on every other queue property
56
+ * (notably `VisibilityTimeout`, which is the main reason this code
57
+ * exists) while leaving the DLQ association intentionally un-wired
58
+ * locally — matching the pre-PR behavior for that one attribute.
59
+ * @private
60
+ */
61
+ _propertiesToAttributes(properties = {}) {
62
+ const attributes = {};
63
+ for (const key of LocalStackQueueService.PROPERTY_ATTRIBUTE_KEYS) {
64
+ const value = properties[key];
65
+ if (value === undefined || value === null) continue;
66
+ if (LocalStackQueueService._containsUnresolvedIntrinsic(value)) {
67
+ console.warn(
68
+ `[frigg-plugin] Skipping queue attribute "${key}" because it contains an unresolved CloudFormation intrinsic. ` +
69
+ `Deployed AWS will apply it via CloudFormation; local emulation will fall back to the AWS default for this attribute.`
70
+ );
71
+ continue;
72
+ }
73
+ attributes[key] =
74
+ typeof value === 'object' ? JSON.stringify(value) : String(value);
75
+ }
76
+ return attributes;
77
+ }
78
+
79
+ /**
80
+ * Recursively checks whether a value still contains a CloudFormation
81
+ * intrinsic function key (`Fn::*` or `Ref`). Such values are unsafe
82
+ * to pass through to SQS `CreateQueue` — AWS's runtime API doesn't
83
+ * understand CloudFormation intrinsics; they're only valid inside
84
+ * serverless.yml / CloudFormation templates.
85
+ * @private
86
+ */
87
+ static _containsUnresolvedIntrinsic(value) {
88
+ if (value === null || value === undefined) return false;
89
+ if (typeof value !== 'object') return false;
90
+ if (Array.isArray(value)) {
91
+ return value.some((v) =>
92
+ LocalStackQueueService._containsUnresolvedIntrinsic(v)
93
+ );
94
+ }
95
+ for (const key of Object.keys(value)) {
96
+ if (key === 'Ref' || key.startsWith('Fn::')) return true;
97
+ if (
98
+ LocalStackQueueService._containsUnresolvedIntrinsic(value[key])
99
+ ) {
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+
11
106
  /**
12
107
  * @param {string} queueName - Name of queue to create
108
+ * @param {Object} [attributes] - SQS CreateQueue Attributes (already
109
+ * stringified); missing/empty attributes fall back to AWS defaults.
13
110
  * @returns {Promise<string>} Queue URL
14
111
  */
15
- async createQueue(queueName) {
112
+ async createQueue(queueName, attributes) {
16
113
  return new Promise((resolve, reject) => {
17
- this.sqs.createQueue({ QueueName: queueName }, (err, data) => {
114
+ const params = { QueueName: queueName };
115
+ if (attributes && Object.keys(attributes).length > 0) {
116
+ params.Attributes = attributes;
117
+ }
118
+ this.sqs.createQueue(params, (err, data) => {
18
119
  if (err) {
19
120
  reject(new Error(`Failed to create queue ${queueName}: ${err.message}`));
20
121
  } else {
@@ -25,13 +126,17 @@ class LocalStackQueueService {
25
126
  }
26
127
 
27
128
  /**
28
- * @param {Array<{key: string, name: string}>} queues - Queue definitions
129
+ * @param {Array<{key: string, name: string, properties?: Object}>} queues
130
+ * Queue definitions. `properties` mirrors the CloudFormation
131
+ * `AWS::SQS::Queue` Properties block; extracted by the plugin from
132
+ * `serverless.service.resources.Resources`.
29
133
  * @returns {Promise<Array<{key: string, url: string}>>} Created queues with URLs
30
134
  */
31
135
  async createQueues(queues) {
32
136
  const results = await Promise.all(
33
137
  queues.map(async (queue) => {
34
- const url = await this.createQueue(queue.name);
138
+ const attributes = this._propertiesToAttributes(queue.properties);
139
+ const url = await this.createQueue(queue.name, attributes);
35
140
  console.log(`Queue ${queue.name} created successfully. URL: ${url}`);
36
141
  return { key: queue.key, url };
37
142
  })
@@ -27,6 +27,41 @@ describe('LocalStackQueueService', () => {
27
27
  );
28
28
  });
29
29
 
30
+ it('should pass Attributes when provided', async () => {
31
+ mockSQS.createQueue.mockImplementation((params, callback) => {
32
+ callback(null, { QueueUrl: 'url' });
33
+ });
34
+
35
+ await service.createQueue('test-queue', {
36
+ VisibilityTimeout: '1800',
37
+ MessageRetentionPeriod: '345600',
38
+ });
39
+
40
+ expect(mockSQS.createQueue).toHaveBeenCalledWith(
41
+ {
42
+ QueueName: 'test-queue',
43
+ Attributes: {
44
+ VisibilityTimeout: '1800',
45
+ MessageRetentionPeriod: '345600',
46
+ },
47
+ },
48
+ expect.any(Function)
49
+ );
50
+ });
51
+
52
+ it('should omit Attributes when empty', async () => {
53
+ mockSQS.createQueue.mockImplementation((params, callback) => {
54
+ callback(null, { QueueUrl: 'url' });
55
+ });
56
+
57
+ await service.createQueue('test-queue', {});
58
+
59
+ expect(mockSQS.createQueue).toHaveBeenCalledWith(
60
+ { QueueName: 'test-queue' },
61
+ expect.any(Function)
62
+ );
63
+ });
64
+
30
65
  it('should reject with error on failure', async () => {
31
66
  const error = new Error('SQS Error');
32
67
  mockSQS.createQueue.mockImplementation((params, callback) => {
@@ -39,6 +74,105 @@ describe('LocalStackQueueService', () => {
39
74
  });
40
75
  });
41
76
 
77
+ describe('_propertiesToAttributes', () => {
78
+ it('maps CloudFormation Properties onto SQS Attributes and stringifies', () => {
79
+ const attrs = service._propertiesToAttributes({
80
+ VisibilityTimeout: 1800,
81
+ MessageRetentionPeriod: 345600,
82
+ RedrivePolicy: {
83
+ maxReceiveCount: 3,
84
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
85
+ },
86
+ QueueName: 'should-be-dropped',
87
+ Tags: [{ Key: 'ignored', Value: 'yes' }],
88
+ });
89
+
90
+ expect(attrs).toEqual({
91
+ VisibilityTimeout: '1800',
92
+ MessageRetentionPeriod: '345600',
93
+ RedrivePolicy: JSON.stringify({
94
+ maxReceiveCount: 3,
95
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
96
+ }),
97
+ });
98
+ });
99
+
100
+ it('skips undefined and null values', () => {
101
+ const attrs = service._propertiesToAttributes({
102
+ VisibilityTimeout: 60,
103
+ MessageRetentionPeriod: undefined,
104
+ KmsMasterKeyId: null,
105
+ });
106
+ expect(attrs).toEqual({ VisibilityTimeout: '60' });
107
+ });
108
+
109
+ it('returns an empty object when properties are missing', () => {
110
+ expect(service._propertiesToAttributes()).toEqual({});
111
+ });
112
+
113
+ it('drops attributes containing unresolved CloudFormation intrinsics', () => {
114
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
115
+ const attrs = service._propertiesToAttributes({
116
+ VisibilityTimeout: 1800,
117
+ RedrivePolicy: {
118
+ maxReceiveCount: 3,
119
+ deadLetterTargetArn: {
120
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
121
+ },
122
+ },
123
+ });
124
+
125
+ // VisibilityTimeout survives; RedrivePolicy is dropped because
126
+ // deadLetterTargetArn is still an unresolved Fn::GetAtt intrinsic
127
+ // (real AWS resolves it via CloudFormation; LocalStack cannot).
128
+ expect(attrs).toEqual({ VisibilityTimeout: '1800' });
129
+ expect(attrs).not.toHaveProperty('RedrivePolicy');
130
+ expect(warnSpy).toHaveBeenCalledWith(
131
+ expect.stringContaining('Skipping queue attribute "RedrivePolicy"')
132
+ );
133
+ warnSpy.mockRestore();
134
+ });
135
+
136
+ it('drops attributes with Ref intrinsics', () => {
137
+ jest.spyOn(console, 'warn').mockImplementation();
138
+ const attrs = service._propertiesToAttributes({
139
+ VisibilityTimeout: 60,
140
+ KmsMasterKeyId: { Ref: 'MyKmsKey' },
141
+ });
142
+ expect(attrs).toEqual({ VisibilityTimeout: '60' });
143
+ });
144
+
145
+ it('detects intrinsics nested deep inside objects and arrays', () => {
146
+ expect(
147
+ LocalStackQueueService._containsUnresolvedIntrinsic({
148
+ a: { b: [{ c: { 'Fn::Sub': '${AWS::Region}' } }] },
149
+ })
150
+ ).toBe(true);
151
+ expect(
152
+ LocalStackQueueService._containsUnresolvedIntrinsic({
153
+ a: { b: [{ c: 'hello' }] },
154
+ })
155
+ ).toBe(false);
156
+ expect(LocalStackQueueService._containsUnresolvedIntrinsic(null)).toBe(false);
157
+ expect(LocalStackQueueService._containsUnresolvedIntrinsic('str')).toBe(false);
158
+ });
159
+
160
+ it('retains resolved RedrivePolicy (ARN already a string)', () => {
161
+ const attrs = service._propertiesToAttributes({
162
+ RedrivePolicy: {
163
+ maxReceiveCount: 3,
164
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
165
+ },
166
+ });
167
+ expect(attrs.RedrivePolicy).toBe(
168
+ JSON.stringify({
169
+ maxReceiveCount: 3,
170
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
171
+ })
172
+ );
173
+ });
174
+ });
175
+
42
176
  describe('createQueues', () => {
43
177
  it('should create multiple queues and return results', async () => {
44
178
  mockSQS.createQueue.mockImplementation((params, callback) => {
@@ -69,6 +203,58 @@ describe('LocalStackQueueService', () => {
69
203
  consoleLogSpy.mockRestore();
70
204
  });
71
205
 
206
+ it('forwards CloudFormation Properties as SQS Attributes', async () => {
207
+ const captured = [];
208
+ mockSQS.createQueue.mockImplementation((params, callback) => {
209
+ captured.push(params);
210
+ callback(null, { QueueUrl: 'url' });
211
+ });
212
+ jest.spyOn(console, 'log').mockImplementation();
213
+
214
+ await service.createQueues([
215
+ {
216
+ key: 'HubspotQueue',
217
+ name: 'my-service--dev-HubspotQueue',
218
+ properties: {
219
+ QueueName: 'my-service--dev-HubspotQueue',
220
+ VisibilityTimeout: 1800,
221
+ MessageRetentionPeriod: 345600,
222
+ RedrivePolicy: {
223
+ maxReceiveCount: 3,
224
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
225
+ },
226
+ },
227
+ },
228
+ ]);
229
+
230
+ expect(captured[0]).toEqual({
231
+ QueueName: 'my-service--dev-HubspotQueue',
232
+ Attributes: {
233
+ VisibilityTimeout: '1800',
234
+ MessageRetentionPeriod: '345600',
235
+ RedrivePolicy: JSON.stringify({
236
+ maxReceiveCount: 3,
237
+ deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
238
+ }),
239
+ },
240
+ });
241
+ });
242
+
243
+ it('creates queues with defaults when properties are missing (back-compat)', async () => {
244
+ const captured = [];
245
+ mockSQS.createQueue.mockImplementation((params, callback) => {
246
+ captured.push(params);
247
+ callback(null, { QueueUrl: 'url' });
248
+ });
249
+ jest.spyOn(console, 'log').mockImplementation();
250
+
251
+ await service.createQueues([
252
+ { key: 'LegacyQueue', name: 'legacy-queue' },
253
+ ]);
254
+
255
+ expect(captured[0]).toEqual({ QueueName: 'legacy-queue' });
256
+ });
257
+
72
258
  it('should handle empty queue array', async () => {
73
259
  const results = await service.createQueues([]);
74
260
  expect(results).toEqual([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friggframework/serverless-plugin",
3
- "version": "2.0.0-next.80",
3
+ "version": "2.0.0-next.82",
4
4
  "description": "Plugin to help dynamically load frigg resources",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -11,5 +11,5 @@
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
14
- "gitHead": "7a66dfcb02143941411ff26aaee866afc1473df8"
14
+ "gitHead": "765f34d4ea2b2861f4a1723f8b319bfef9a5aea8"
15
15
  }