@friggframework/devtools 2.0.0-next.74 → 2.0.0-next.76
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.
|
@@ -145,20 +145,20 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
145
145
|
* Build integration resources based on ownership decisions
|
|
146
146
|
*/
|
|
147
147
|
async buildFromDecisions(decisions, appDefinition, result, usePrismaLayer = true) {
|
|
148
|
+
// Create package config first — needed by all Lambda functions including DLQ processor
|
|
149
|
+
const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
|
|
150
|
+
|
|
148
151
|
// Create InternalErrorQueue if ownership = STACK
|
|
149
152
|
const shouldCreateInternalErrorQueue = decisions.internalErrorQueue.ownership === ResourceOwnership.STACK;
|
|
150
153
|
|
|
151
154
|
if (shouldCreateInternalErrorQueue) {
|
|
152
155
|
console.log(' → Creating InternalErrorQueue in stack');
|
|
153
|
-
this.createInternalErrorQueue(result);
|
|
156
|
+
this.createInternalErrorQueue(result, functionPackageConfig);
|
|
154
157
|
} else {
|
|
155
158
|
console.log(' → Using external InternalErrorQueue');
|
|
156
|
-
this.useExternalInternalErrorQueue(decisions.internalErrorQueue, result);
|
|
159
|
+
this.useExternalInternalErrorQueue(decisions.internalErrorQueue, result, functionPackageConfig);
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
// Create Lambda function definitions and queue resources for each integration
|
|
160
|
-
const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
|
|
161
|
-
|
|
162
162
|
for (const integration of appDefinition.integrations) {
|
|
163
163
|
const integrationName = integration.Definition.name;
|
|
164
164
|
const queueDecision = decisions.integrations[integrationName].queue;
|
|
@@ -316,6 +316,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
316
316
|
sqs: {
|
|
317
317
|
arn: { 'Fn::GetAtt': [`${this.capitalizeFirst(integrationName)}Queue`, 'Arn'] },
|
|
318
318
|
batchSize: 1,
|
|
319
|
+
functionResponseType: 'ReportBatchItemFailures',
|
|
319
320
|
},
|
|
320
321
|
},
|
|
321
322
|
],
|
|
@@ -327,29 +328,87 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
327
328
|
/**
|
|
328
329
|
* Create InternalErrorQueue CloudFormation resource
|
|
329
330
|
*/
|
|
330
|
-
createInternalErrorQueue(result) {
|
|
331
|
+
createInternalErrorQueue(result, functionPackageConfig) {
|
|
331
332
|
result.resources.InternalErrorQueue = {
|
|
332
333
|
Type: 'AWS::SQS::Queue',
|
|
333
334
|
Properties: {
|
|
334
335
|
QueueName: '${self:service}-${self:provider.stage}-InternalErrorQueue',
|
|
335
336
|
MessageRetentionPeriod: 1209600, // 14 days
|
|
336
|
-
VisibilityTimeout: 300, // 5 minutes
|
|
337
|
+
VisibilityTimeout: 300, // 5 minutes — must be >= 6x DLQ processor Lambda timeout (30s × 6 = 180s)
|
|
337
338
|
},
|
|
338
339
|
};
|
|
339
340
|
|
|
341
|
+
this.createDLQObservability(result, functionPackageConfig, {
|
|
342
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
343
|
+
}, {
|
|
344
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'QueueName'],
|
|
345
|
+
});
|
|
346
|
+
|
|
340
347
|
console.log(' ✓ Created InternalErrorQueue resource');
|
|
341
348
|
}
|
|
342
349
|
|
|
343
350
|
/**
|
|
344
351
|
* Use external InternalErrorQueue
|
|
345
352
|
*/
|
|
346
|
-
useExternalInternalErrorQueue(decision, result) {
|
|
353
|
+
useExternalInternalErrorQueue(decision, result, functionPackageConfig) {
|
|
347
354
|
// Add ARN to environment for Lambda functions
|
|
348
355
|
result.environment.INTERNAL_ERROR_QUEUE_ARN = decision.physicalId;
|
|
349
356
|
|
|
357
|
+
// Extract queue name from ARN for CloudWatch dimensions
|
|
358
|
+
const arnParts = decision.physicalId.split(':');
|
|
359
|
+
const queueName = arnParts[arnParts.length - 1];
|
|
360
|
+
|
|
361
|
+
this.createDLQObservability(result, functionPackageConfig, decision.physicalId, queueName);
|
|
362
|
+
|
|
350
363
|
console.log(` ✓ Using external InternalErrorQueue: ${decision.physicalId}`);
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Create DLQ observability resources (alarm + processor Lambda).
|
|
368
|
+
* Called for both stack-owned and external InternalErrorQueues.
|
|
369
|
+
*/
|
|
370
|
+
createDLQObservability(result, functionPackageConfig, queueArn, queueName) {
|
|
371
|
+
// CloudWatch Alarm: fires when any message lands in the DLQ
|
|
372
|
+
result.resources.DLQMessageAlarm = {
|
|
373
|
+
Type: 'AWS::CloudWatch::Alarm',
|
|
374
|
+
Properties: {
|
|
375
|
+
AlarmDescription: 'Messages in dead-letter queue — integration queue processing failures',
|
|
376
|
+
Namespace: 'AWS/SQS',
|
|
377
|
+
MetricName: 'ApproximateNumberOfMessagesVisible',
|
|
378
|
+
Statistic: 'Maximum',
|
|
379
|
+
Threshold: 500,
|
|
380
|
+
ComparisonOperator: 'GreaterThanThreshold',
|
|
381
|
+
EvaluationPeriods: 1,
|
|
382
|
+
Period: 300,
|
|
383
|
+
AlarmActions: [{ Ref: 'InternalErrorBridgeTopic' }],
|
|
384
|
+
Dimensions: [
|
|
385
|
+
{ Name: 'QueueName', Value: queueName },
|
|
386
|
+
],
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// DLQ processor Lambda: logs failed messages with structured context
|
|
391
|
+
result.functions.dlqProcessor = {
|
|
392
|
+
handler: 'node_modules/@friggframework/core/handlers/workers/dlq-processor.dlqProcessor',
|
|
393
|
+
skipEsbuild: true,
|
|
394
|
+
package: functionPackageConfig,
|
|
395
|
+
reservedConcurrency: 1,
|
|
396
|
+
timeout: 30,
|
|
397
|
+
events: [
|
|
398
|
+
{
|
|
399
|
+
sqs: {
|
|
400
|
+
arn: queueArn,
|
|
401
|
+
batchSize: 10,
|
|
402
|
+
functionResponseType: 'ReportBatchItemFailures',
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
console.log(' ✓ Created DLQ CloudWatch alarm');
|
|
409
|
+
console.log(' ✓ Created DLQ processor Lambda');
|
|
410
|
+
}
|
|
411
|
+
|
|
353
412
|
/**
|
|
354
413
|
* Create integration-specific SQS queue CloudFormation resource
|
|
355
414
|
*/
|
|
@@ -361,10 +420,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
361
420
|
Type: 'AWS::SQS::Queue',
|
|
362
421
|
Properties: {
|
|
363
422
|
QueueName: `\${self:custom.${queueReference}}`,
|
|
364
|
-
MessageRetentionPeriod:
|
|
423
|
+
MessageRetentionPeriod: 345600, // 4 days (SQS default)
|
|
365
424
|
VisibilityTimeout: 1800,
|
|
366
425
|
RedrivePolicy: {
|
|
367
|
-
maxReceiveCount:
|
|
426
|
+
maxReceiveCount: 3,
|
|
368
427
|
deadLetterTargetArn: {
|
|
369
428
|
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
370
429
|
},
|
|
@@ -186,7 +186,7 @@ describe('IntegrationBuilder', () => {
|
|
|
186
186
|
|
|
187
187
|
const result = await integrationBuilder.build(appDefinition, {});
|
|
188
188
|
|
|
189
|
-
expect(result.resources.TestQueue.Properties.MessageRetentionPeriod).toBe(
|
|
189
|
+
expect(result.resources.TestQueue.Properties.MessageRetentionPeriod).toBe(345600);
|
|
190
190
|
expect(result.resources.TestQueue.Properties.VisibilityTimeout).toBe(1800);
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -200,7 +200,7 @@ describe('IntegrationBuilder', () => {
|
|
|
200
200
|
const result = await integrationBuilder.build(appDefinition, {});
|
|
201
201
|
|
|
202
202
|
expect(result.resources.TestQueue.Properties.RedrivePolicy).toEqual({
|
|
203
|
-
maxReceiveCount:
|
|
203
|
+
maxReceiveCount: 3,
|
|
204
204
|
deadLetterTargetArn: {
|
|
205
205
|
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
206
206
|
},
|
|
@@ -233,6 +233,7 @@ describe('IntegrationBuilder', () => {
|
|
|
233
233
|
sqs: {
|
|
234
234
|
arn: { 'Fn::GetAtt': ['TestQueue', 'Arn'] },
|
|
235
235
|
batchSize: 1,
|
|
236
|
+
functionResponseType: 'ReportBatchItemFailures',
|
|
236
237
|
},
|
|
237
238
|
},
|
|
238
239
|
]);
|
|
@@ -343,6 +344,112 @@ describe('IntegrationBuilder', () => {
|
|
|
343
344
|
expect(result.functions['my-integration']).toBeDefined();
|
|
344
345
|
expect(result.functions['my-integrationQueueWorker']).toBeDefined();
|
|
345
346
|
});
|
|
347
|
+
|
|
348
|
+
// ============================================================
|
|
349
|
+
// Theory-proving tests: demonstrate current dangerous config
|
|
350
|
+
// These tests document the root cause of the Modern Midstay bug
|
|
351
|
+
// where POST_CREATE_SETUP messages were silently lost.
|
|
352
|
+
// ============================================================
|
|
353
|
+
|
|
354
|
+
it('THEORY: MessageRetentionPeriod is too short for delayed messages', async () => {
|
|
355
|
+
// POST_CREATE_SETUP uses DelaySeconds=35.
|
|
356
|
+
// With MessageRetentionPeriod=60, the message is only visible
|
|
357
|
+
// for 25 seconds before SQS silently deletes it.
|
|
358
|
+
// Messages that expire are NOT sent to DLQ — they vanish.
|
|
359
|
+
const appDefinition = {
|
|
360
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
364
|
+
const retention = result.resources.TestQueue.Properties.MessageRetentionPeriod;
|
|
365
|
+
|
|
366
|
+
// The max SQS DelaySeconds is 900. Retention must comfortably
|
|
367
|
+
// exceed this to ensure delayed messages are never silently lost.
|
|
368
|
+
// Current value (60) fails this check.
|
|
369
|
+
expect(retention).toBeGreaterThan(900);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('THEORY: maxReceiveCount=1 means zero retries on transient failures', async () => {
|
|
373
|
+
// A single transient error (network blip, cold start timeout,
|
|
374
|
+
// rate limit) sends the message straight to DLQ with no retry.
|
|
375
|
+
const appDefinition = {
|
|
376
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
380
|
+
const maxReceiveCount = result.resources.TestQueue.Properties.RedrivePolicy.maxReceiveCount;
|
|
381
|
+
|
|
382
|
+
// Should allow at least 2 retries (maxReceiveCount >= 3)
|
|
383
|
+
expect(maxReceiveCount).toBeGreaterThanOrEqual(3);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('THEORY: SQS event source should enable ReportBatchItemFailures', async () => {
|
|
387
|
+
// Without this, Lambda can't tell SQS which specific messages
|
|
388
|
+
// failed — it's all-or-nothing for the entire invocation.
|
|
389
|
+
const appDefinition = {
|
|
390
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
394
|
+
const sqsEvent = result.functions.testQueueWorker.events[0].sqs;
|
|
395
|
+
|
|
396
|
+
expect(sqsEvent.functionResponseType).toBe('ReportBatchItemFailures');
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('DLQ Observability', () => {
|
|
401
|
+
it('should create a CloudWatch alarm for DLQ message depth', async () => {
|
|
402
|
+
const appDefinition = {
|
|
403
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
407
|
+
|
|
408
|
+
expect(result.resources.DLQMessageAlarm).toBeDefined();
|
|
409
|
+
expect(result.resources.DLQMessageAlarm.Type).toBe('AWS::CloudWatch::Alarm');
|
|
410
|
+
expect(result.resources.DLQMessageAlarm.Properties.MetricName).toBe('ApproximateNumberOfMessagesVisible');
|
|
411
|
+
expect(result.resources.DLQMessageAlarm.Properties.ComparisonOperator).toBe('GreaterThanThreshold');
|
|
412
|
+
expect(result.resources.DLQMessageAlarm.Properties.Threshold).toBe(500);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should wire alarm to InternalErrorBridgeTopic for notifications', async () => {
|
|
416
|
+
const appDefinition = {
|
|
417
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
421
|
+
|
|
422
|
+
expect(result.resources.DLQMessageAlarm.Properties.AlarmActions).toEqual([
|
|
423
|
+
{ Ref: 'InternalErrorBridgeTopic' },
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should create a DLQ processor Lambda triggered by InternalErrorQueue', async () => {
|
|
428
|
+
const appDefinition = {
|
|
429
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
433
|
+
|
|
434
|
+
expect(result.functions.dlqProcessor).toBeDefined();
|
|
435
|
+
expect(result.functions.dlqProcessor.events[0].sqs.arn).toEqual({
|
|
436
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
437
|
+
});
|
|
438
|
+
expect(result.functions.dlqProcessor.events[0].sqs.functionResponseType).toBe('ReportBatchItemFailures');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('DLQ processor should have skipEsbuild, short timeout, and low concurrency', async () => {
|
|
442
|
+
const appDefinition = {
|
|
443
|
+
integrations: [{ Definition: { name: 'test' } }],
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const result = await integrationBuilder.build(appDefinition, {});
|
|
447
|
+
|
|
448
|
+
expect(result.functions.dlqProcessor.skipEsbuild).toBe(true);
|
|
449
|
+
expect(result.functions.dlqProcessor.package).toBeDefined();
|
|
450
|
+
expect(result.functions.dlqProcessor.timeout).toBeLessThanOrEqual(60);
|
|
451
|
+
expect(result.functions.dlqProcessor.reservedConcurrency).toBe(1);
|
|
452
|
+
});
|
|
346
453
|
});
|
|
347
454
|
|
|
348
455
|
describe('getDependencies()', () => {
|
|
@@ -503,8 +610,9 @@ describe('IntegrationBuilder', () => {
|
|
|
503
610
|
|
|
504
611
|
const functionKeys = Object.keys(result.functions);
|
|
505
612
|
|
|
506
|
-
// Expected order: webhook, integration, queueWorker
|
|
613
|
+
// Expected order: dlqProcessor (from InternalErrorQueue), webhook, integration, queueWorker
|
|
507
614
|
expect(functionKeys).toEqual([
|
|
615
|
+
'dlqProcessor',
|
|
508
616
|
'testWebhook',
|
|
509
617
|
'test',
|
|
510
618
|
'testQueueWorker',
|
|
@@ -1148,10 +1148,10 @@ describe('composeServerlessDefinition', () => {
|
|
|
1148
1148
|
Type: 'AWS::SQS::Queue',
|
|
1149
1149
|
Properties: {
|
|
1150
1150
|
QueueName: '${self:custom.TestIntegrationQueue}',
|
|
1151
|
-
MessageRetentionPeriod:
|
|
1151
|
+
MessageRetentionPeriod: 345600,
|
|
1152
1152
|
VisibilityTimeout: 1800,
|
|
1153
1153
|
RedrivePolicy: {
|
|
1154
|
-
maxReceiveCount:
|
|
1154
|
+
maxReceiveCount: 3,
|
|
1155
1155
|
deadLetterTargetArn: {
|
|
1156
1156
|
'Fn::GetAtt': ['InternalErrorQueue', 'Arn']
|
|
1157
1157
|
}
|
|
@@ -1168,7 +1168,8 @@ describe('composeServerlessDefinition', () => {
|
|
|
1168
1168
|
arn: {
|
|
1169
1169
|
'Fn::GetAtt': ['TestIntegrationQueue', 'Arn']
|
|
1170
1170
|
},
|
|
1171
|
-
batchSize: 1
|
|
1171
|
+
batchSize: 1,
|
|
1172
|
+
functionResponseType: 'ReportBatchItemFailures'
|
|
1172
1173
|
}
|
|
1173
1174
|
}],
|
|
1174
1175
|
timeout: 600
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/devtools",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.76",
|
|
5
5
|
"bin": {
|
|
6
6
|
"frigg": "./frigg-cli/index.js"
|
|
7
7
|
},
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"@babel/eslint-parser": "^7.18.9",
|
|
26
26
|
"@babel/parser": "^7.25.3",
|
|
27
27
|
"@babel/traverse": "^7.25.3",
|
|
28
|
-
"@friggframework/core": "2.0.0-next.
|
|
29
|
-
"@friggframework/schemas": "2.0.0-next.
|
|
30
|
-
"@friggframework/test": "2.0.0-next.
|
|
28
|
+
"@friggframework/core": "2.0.0-next.76",
|
|
29
|
+
"@friggframework/schemas": "2.0.0-next.76",
|
|
30
|
+
"@friggframework/test": "2.0.0-next.76",
|
|
31
31
|
"@hapi/boom": "^10.0.1",
|
|
32
32
|
"@inquirer/prompts": "^5.3.8",
|
|
33
33
|
"axios": "^1.7.2",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"validate-npm-package-name": "^5.0.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
59
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
58
|
+
"@friggframework/eslint-config": "2.0.0-next.76",
|
|
59
|
+
"@friggframework/prettier-config": "2.0.0-next.76",
|
|
60
60
|
"aws-sdk-client-mock": "^4.1.0",
|
|
61
61
|
"aws-sdk-client-mock-jest": "^4.1.0",
|
|
62
62
|
"jest": "^30.1.3",
|
|
@@ -88,5 +88,5 @@
|
|
|
88
88
|
"publishConfig": {
|
|
89
89
|
"access": "public"
|
|
90
90
|
},
|
|
91
|
-
"gitHead": "
|
|
91
|
+
"gitHead": "5f03113234df57301443502f2751f1864dd44e73"
|
|
92
92
|
}
|