@friggframework/devtools 2.0.0-next.75 → 2.0.0-next.77
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.
|
@@ -15,9 +15,15 @@
|
|
|
15
15
|
* and externally-provided SQS queues.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
InfrastructureBuilder,
|
|
20
|
+
ValidationResult,
|
|
21
|
+
} = require('../shared/base-builder');
|
|
19
22
|
const IntegrationResourceResolver = require('./integration-resolver');
|
|
20
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
createEmptyDiscoveryResult,
|
|
25
|
+
ResourceOwnership,
|
|
26
|
+
} = require('../shared/types');
|
|
21
27
|
|
|
22
28
|
class IntegrationBuilder extends InfrastructureBuilder {
|
|
23
29
|
constructor() {
|
|
@@ -26,7 +32,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
shouldExecute(appDefinition) {
|
|
29
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
Array.isArray(appDefinition.integrations) &&
|
|
37
|
+
appDefinition.integrations.length > 0
|
|
38
|
+
);
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
getDependencies() {
|
|
@@ -48,7 +57,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
48
57
|
// Validate each integration
|
|
49
58
|
appDefinition.integrations.forEach((integration, index) => {
|
|
50
59
|
if (!integration?.Definition?.name) {
|
|
51
|
-
result.addError(
|
|
60
|
+
result.addError(
|
|
61
|
+
`Integration at index ${index} is missing Definition or name`
|
|
62
|
+
);
|
|
52
63
|
}
|
|
53
64
|
});
|
|
54
65
|
|
|
@@ -60,7 +71,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
60
71
|
*/
|
|
61
72
|
async build(appDefinition, discoveredResources) {
|
|
62
73
|
console.log(`\n[${this.name}] Configuring integrations...`);
|
|
63
|
-
console.log(
|
|
74
|
+
console.log(
|
|
75
|
+
` Processing ${appDefinition.integrations.length} integrations...`
|
|
76
|
+
);
|
|
64
77
|
|
|
65
78
|
const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false;
|
|
66
79
|
|
|
@@ -73,23 +86,34 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
73
86
|
};
|
|
74
87
|
|
|
75
88
|
// Get structured discovery result
|
|
76
|
-
const discovery =
|
|
89
|
+
const discovery =
|
|
90
|
+
discoveredResources._structured ||
|
|
91
|
+
this.convertFlatDiscoveryToStructured(discoveredResources);
|
|
77
92
|
|
|
78
93
|
// Use IntegrationResourceResolver to make ownership decisions
|
|
79
94
|
const resolver = new IntegrationResourceResolver();
|
|
80
95
|
const decisions = resolver.resolveAll(appDefinition, discovery);
|
|
81
96
|
|
|
82
97
|
console.log('\n 📋 Resource Ownership Decisions:');
|
|
83
|
-
console.log(
|
|
98
|
+
console.log(
|
|
99
|
+
` InternalErrorQueue: ${decisions.internalErrorQueue.ownership} - ${decisions.internalErrorQueue.reason}`
|
|
100
|
+
);
|
|
84
101
|
|
|
85
102
|
// Log per-integration decisions
|
|
86
|
-
Object.keys(decisions.integrations).forEach(integrationName => {
|
|
103
|
+
Object.keys(decisions.integrations).forEach((integrationName) => {
|
|
87
104
|
const queueDecision = decisions.integrations[integrationName].queue;
|
|
88
|
-
console.log(
|
|
105
|
+
console.log(
|
|
106
|
+
` ${integrationName}Queue: ${queueDecision.ownership} - ${queueDecision.reason}`
|
|
107
|
+
);
|
|
89
108
|
});
|
|
90
109
|
|
|
91
110
|
// Build resources based on ownership decisions
|
|
92
|
-
await this.buildFromDecisions(
|
|
111
|
+
await this.buildFromDecisions(
|
|
112
|
+
decisions,
|
|
113
|
+
appDefinition,
|
|
114
|
+
result,
|
|
115
|
+
usePrismaLayer
|
|
116
|
+
);
|
|
93
117
|
|
|
94
118
|
console.log(`[${this.name}] ✅ Integration configuration completed`);
|
|
95
119
|
return result;
|
|
@@ -113,7 +137,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
113
137
|
|
|
114
138
|
// Add stack-managed resources from existingLogicalIds
|
|
115
139
|
const existingLogicalIds = flatDiscovery.existingLogicalIds || [];
|
|
116
|
-
existingLogicalIds.forEach(logicalId => {
|
|
140
|
+
existingLogicalIds.forEach((logicalId) => {
|
|
117
141
|
let resourceType = '';
|
|
118
142
|
let physicalId = '';
|
|
119
143
|
|
|
@@ -124,7 +148,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
124
148
|
} else if (logicalId.endsWith('Queue')) {
|
|
125
149
|
// Integration-specific queue (e.g., SlackQueue, HubspotQueue)
|
|
126
150
|
resourceType = 'AWS::SQS::Queue';
|
|
127
|
-
const integrationName = logicalId
|
|
151
|
+
const integrationName = logicalId
|
|
152
|
+
.replace('Queue', '')
|
|
153
|
+
.toLowerCase();
|
|
128
154
|
physicalId = flatDiscovery[`${integrationName}QueueUrl`];
|
|
129
155
|
}
|
|
130
156
|
|
|
@@ -132,7 +158,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
132
158
|
discovery.stackManaged.push({
|
|
133
159
|
logicalId,
|
|
134
160
|
physicalId,
|
|
135
|
-
resourceType
|
|
161
|
+
resourceType,
|
|
136
162
|
});
|
|
137
163
|
}
|
|
138
164
|
});
|
|
@@ -144,21 +170,32 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
144
170
|
/**
|
|
145
171
|
* Build integration resources based on ownership decisions
|
|
146
172
|
*/
|
|
147
|
-
async buildFromDecisions(
|
|
173
|
+
async buildFromDecisions(
|
|
174
|
+
decisions,
|
|
175
|
+
appDefinition,
|
|
176
|
+
result,
|
|
177
|
+
usePrismaLayer = true
|
|
178
|
+
) {
|
|
179
|
+
// Create package config first — needed by all Lambda functions including DLQ processor
|
|
180
|
+
const functionPackageConfig =
|
|
181
|
+
this.createFunctionPackageConfig(usePrismaLayer);
|
|
182
|
+
|
|
148
183
|
// Create InternalErrorQueue if ownership = STACK
|
|
149
|
-
const shouldCreateInternalErrorQueue =
|
|
184
|
+
const shouldCreateInternalErrorQueue =
|
|
185
|
+
decisions.internalErrorQueue.ownership === ResourceOwnership.STACK;
|
|
150
186
|
|
|
151
187
|
if (shouldCreateInternalErrorQueue) {
|
|
152
188
|
console.log(' → Creating InternalErrorQueue in stack');
|
|
153
|
-
this.createInternalErrorQueue(result);
|
|
189
|
+
this.createInternalErrorQueue(result, functionPackageConfig);
|
|
154
190
|
} else {
|
|
155
191
|
console.log(' → Using external InternalErrorQueue');
|
|
156
|
-
this.useExternalInternalErrorQueue(
|
|
192
|
+
this.useExternalInternalErrorQueue(
|
|
193
|
+
decisions.internalErrorQueue,
|
|
194
|
+
result,
|
|
195
|
+
functionPackageConfig
|
|
196
|
+
);
|
|
157
197
|
}
|
|
158
198
|
|
|
159
|
-
// Create Lambda function definitions and queue resources for each integration
|
|
160
|
-
const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
|
|
161
|
-
|
|
162
199
|
for (const integration of appDefinition.integrations) {
|
|
163
200
|
const integrationName = integration.Definition.name;
|
|
164
201
|
const queueDecision = decisions.integrations[integrationName].queue;
|
|
@@ -166,17 +203,29 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
166
203
|
console.log(`\n Adding integration: ${integrationName}`);
|
|
167
204
|
|
|
168
205
|
// Create Lambda function definitions (serverless template code)
|
|
169
|
-
await this.createFunctionDefinitions(
|
|
206
|
+
await this.createFunctionDefinitions(
|
|
207
|
+
integration,
|
|
208
|
+
functionPackageConfig,
|
|
209
|
+
result,
|
|
210
|
+
usePrismaLayer
|
|
211
|
+
);
|
|
170
212
|
|
|
171
213
|
// Create or reference SQS queue based on ownership decision
|
|
172
|
-
const shouldCreateQueue =
|
|
214
|
+
const shouldCreateQueue =
|
|
215
|
+
queueDecision.ownership === ResourceOwnership.STACK;
|
|
173
216
|
|
|
174
217
|
if (shouldCreateQueue) {
|
|
175
|
-
console.log(
|
|
218
|
+
console.log(
|
|
219
|
+
` ✓ Creating ${integrationName}Queue in stack`
|
|
220
|
+
);
|
|
176
221
|
this.createIntegrationQueue(integrationName, result);
|
|
177
222
|
} else {
|
|
178
223
|
console.log(` ✓ Using external ${integrationName}Queue`);
|
|
179
|
-
this.useExternalIntegrationQueue(
|
|
224
|
+
this.useExternalIntegrationQueue(
|
|
225
|
+
integrationName,
|
|
226
|
+
queueDecision,
|
|
227
|
+
result
|
|
228
|
+
);
|
|
180
229
|
}
|
|
181
230
|
}
|
|
182
231
|
}
|
|
@@ -192,12 +241,14 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
192
241
|
'node_modules/@aws-sdk/**',
|
|
193
242
|
|
|
194
243
|
// Exclude Prisma (provided via Lambda Layer)
|
|
195
|
-
...(usePrismaLayer
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
...(usePrismaLayer
|
|
245
|
+
? [
|
|
246
|
+
'node_modules/@prisma/**',
|
|
247
|
+
'node_modules/.prisma/**',
|
|
248
|
+
'node_modules/prisma/**',
|
|
249
|
+
'node_modules/@friggframework/core/generated/**',
|
|
250
|
+
]
|
|
251
|
+
: []),
|
|
201
252
|
|
|
202
253
|
// Exclude ALL nested node_modules
|
|
203
254
|
'node_modules/**/node_modules/**',
|
|
@@ -253,21 +304,31 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
253
304
|
* Create Lambda function definitions for an integration
|
|
254
305
|
* These are serverless framework template function definitions
|
|
255
306
|
*/
|
|
256
|
-
async createFunctionDefinitions(
|
|
307
|
+
async createFunctionDefinitions(
|
|
308
|
+
integration,
|
|
309
|
+
functionPackageConfig,
|
|
310
|
+
result,
|
|
311
|
+
usePrismaLayer = true
|
|
312
|
+
) {
|
|
257
313
|
const integrationName = integration.Definition.name;
|
|
258
314
|
|
|
259
315
|
// Add webhook handler if enabled (BEFORE catch-all proxy route)
|
|
260
316
|
// CRITICAL: Webhook routes must be defined before the catch-all {proxy+} route
|
|
261
317
|
// to ensure proper route matching in AWS API Gateway/HTTP API
|
|
262
318
|
const webhookConfig = integration.Definition.webhooks;
|
|
263
|
-
if (
|
|
319
|
+
if (
|
|
320
|
+
webhookConfig &&
|
|
321
|
+
(webhookConfig === true || webhookConfig.enabled === true)
|
|
322
|
+
) {
|
|
264
323
|
const webhookFunctionName = `${integrationName}Webhook`;
|
|
265
324
|
|
|
266
325
|
result.functions[webhookFunctionName] = {
|
|
267
326
|
handler: `node_modules/@friggframework/core/handlers/routers/integration-webhook-routers.handlers.${integrationName}Webhook.handler`,
|
|
268
|
-
skipEsbuild: true,
|
|
327
|
+
skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
|
|
269
328
|
package: functionPackageConfig,
|
|
270
|
-
...(usePrismaLayer && {
|
|
329
|
+
...(usePrismaLayer && {
|
|
330
|
+
layers: [{ Ref: 'PrismaLambdaLayer' }],
|
|
331
|
+
}), // Webhook handlers need Prisma for credential lookups
|
|
271
332
|
events: [
|
|
272
333
|
{
|
|
273
334
|
httpApi: {
|
|
@@ -289,9 +350,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
289
350
|
// Create HTTP API handler for integration (catch-all route AFTER webhooks)
|
|
290
351
|
result.functions[integrationName] = {
|
|
291
352
|
handler: `node_modules/@friggframework/core/handlers/routers/integration-defined-routers.handlers.${integrationName}.handler`,
|
|
292
|
-
skipEsbuild: true,
|
|
353
|
+
skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
|
|
293
354
|
package: functionPackageConfig,
|
|
294
|
-
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
355
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // HTTP handlers need Prisma for integration queries
|
|
295
356
|
events: [
|
|
296
357
|
{
|
|
297
358
|
httpApi: {
|
|
@@ -307,19 +368,25 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
307
368
|
const queueWorkerName = `${integrationName}QueueWorker`;
|
|
308
369
|
result.functions[queueWorkerName] = {
|
|
309
370
|
handler: `node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.${integrationName}.queueWorker`,
|
|
310
|
-
skipEsbuild: true,
|
|
371
|
+
skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
|
|
311
372
|
package: functionPackageConfig,
|
|
312
|
-
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
313
|
-
reservedConcurrency:
|
|
373
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // Queue workers need Prisma for database operations
|
|
374
|
+
reservedConcurrency: 20,
|
|
314
375
|
events: [
|
|
315
376
|
{
|
|
316
377
|
sqs: {
|
|
317
|
-
arn: {
|
|
378
|
+
arn: {
|
|
379
|
+
'Fn::GetAtt': [
|
|
380
|
+
`${this.capitalizeFirst(integrationName)}Queue`,
|
|
381
|
+
'Arn',
|
|
382
|
+
],
|
|
383
|
+
},
|
|
318
384
|
batchSize: 1,
|
|
385
|
+
functionResponseType: 'ReportBatchItemFailures',
|
|
319
386
|
},
|
|
320
387
|
},
|
|
321
388
|
],
|
|
322
|
-
timeout: 900,
|
|
389
|
+
timeout: 900, // 15 minutes max for queue workers (Lambda maximum)
|
|
323
390
|
};
|
|
324
391
|
console.log(` ✓ Queue worker function defined`);
|
|
325
392
|
}
|
|
@@ -327,27 +394,102 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
327
394
|
/**
|
|
328
395
|
* Create InternalErrorQueue CloudFormation resource
|
|
329
396
|
*/
|
|
330
|
-
createInternalErrorQueue(result) {
|
|
397
|
+
createInternalErrorQueue(result, functionPackageConfig) {
|
|
398
|
+
const queueName =
|
|
399
|
+
'${self:service}-${self:provider.stage}-InternalErrorQueue';
|
|
400
|
+
|
|
401
|
+
result.custom.InternalErrorQueue = queueName;
|
|
402
|
+
|
|
331
403
|
result.resources.InternalErrorQueue = {
|
|
332
404
|
Type: 'AWS::SQS::Queue',
|
|
333
405
|
Properties: {
|
|
334
|
-
QueueName: '${self:
|
|
406
|
+
QueueName: '${self:custom.InternalErrorQueue}',
|
|
335
407
|
MessageRetentionPeriod: 1209600, // 14 days
|
|
336
|
-
VisibilityTimeout: 300, // 5 minutes
|
|
408
|
+
VisibilityTimeout: 300, // 5 minutes — must be >= 6x DLQ processor Lambda timeout (30s × 6 = 180s)
|
|
337
409
|
},
|
|
338
410
|
};
|
|
339
411
|
|
|
412
|
+
this.createDLQObservability(
|
|
413
|
+
result,
|
|
414
|
+
functionPackageConfig,
|
|
415
|
+
{
|
|
416
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'QueueName'],
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
|
|
340
423
|
console.log(' ✓ Created InternalErrorQueue resource');
|
|
341
424
|
}
|
|
342
425
|
|
|
343
426
|
/**
|
|
344
427
|
* Use external InternalErrorQueue
|
|
345
428
|
*/
|
|
346
|
-
useExternalInternalErrorQueue(decision, result) {
|
|
429
|
+
useExternalInternalErrorQueue(decision, result, functionPackageConfig) {
|
|
347
430
|
// Add ARN to environment for Lambda functions
|
|
348
431
|
result.environment.INTERNAL_ERROR_QUEUE_ARN = decision.physicalId;
|
|
349
432
|
|
|
350
|
-
|
|
433
|
+
// Extract queue name from ARN for CloudWatch dimensions
|
|
434
|
+
const arnParts = decision.physicalId.split(':');
|
|
435
|
+
const queueName = arnParts[arnParts.length - 1];
|
|
436
|
+
|
|
437
|
+
this.createDLQObservability(
|
|
438
|
+
result,
|
|
439
|
+
functionPackageConfig,
|
|
440
|
+
decision.physicalId,
|
|
441
|
+
queueName
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
console.log(
|
|
445
|
+
` ✓ Using external InternalErrorQueue: ${decision.physicalId}`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Create DLQ observability resources (alarm + processor Lambda).
|
|
451
|
+
* Called for both stack-owned and external InternalErrorQueues.
|
|
452
|
+
*/
|
|
453
|
+
createDLQObservability(result, functionPackageConfig, queueArn, queueName) {
|
|
454
|
+
// CloudWatch Alarm: fires when any message lands in the DLQ
|
|
455
|
+
result.resources.DLQMessageAlarm = {
|
|
456
|
+
Type: 'AWS::CloudWatch::Alarm',
|
|
457
|
+
Properties: {
|
|
458
|
+
AlarmDescription:
|
|
459
|
+
'Messages in dead-letter queue — integration queue processing failures',
|
|
460
|
+
Namespace: 'AWS/SQS',
|
|
461
|
+
MetricName: 'ApproximateNumberOfMessagesVisible',
|
|
462
|
+
Statistic: 'Maximum',
|
|
463
|
+
Threshold: 500,
|
|
464
|
+
ComparisonOperator: 'GreaterThanThreshold',
|
|
465
|
+
EvaluationPeriods: 1,
|
|
466
|
+
Period: 300,
|
|
467
|
+
AlarmActions: [{ Ref: 'InternalErrorBridgeTopic' }],
|
|
468
|
+
Dimensions: [{ Name: 'QueueName', Value: queueName }],
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// DLQ processor Lambda: logs failed messages with structured context
|
|
473
|
+
result.functions.dlqProcessor = {
|
|
474
|
+
handler:
|
|
475
|
+
'node_modules/@friggframework/core/handlers/workers/dlq-processor.dlqProcessor',
|
|
476
|
+
skipEsbuild: true,
|
|
477
|
+
package: functionPackageConfig,
|
|
478
|
+
reservedConcurrency: 1,
|
|
479
|
+
timeout: 30,
|
|
480
|
+
events: [
|
|
481
|
+
{
|
|
482
|
+
sqs: {
|
|
483
|
+
arn: queueArn,
|
|
484
|
+
batchSize: 10,
|
|
485
|
+
functionResponseType: 'ReportBatchItemFailures',
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
console.log(' ✓ Created DLQ CloudWatch alarm');
|
|
492
|
+
console.log(' ✓ Created DLQ processor Lambda');
|
|
351
493
|
}
|
|
352
494
|
|
|
353
495
|
/**
|
|
@@ -361,10 +503,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
361
503
|
Type: 'AWS::SQS::Queue',
|
|
362
504
|
Properties: {
|
|
363
505
|
QueueName: `\${self:custom.${queueReference}}`,
|
|
364
|
-
MessageRetentionPeriod:
|
|
506
|
+
MessageRetentionPeriod: 345600, // 4 days (SQS default)
|
|
365
507
|
VisibilityTimeout: 1800,
|
|
366
508
|
RedrivePolicy: {
|
|
367
|
-
maxReceiveCount:
|
|
509
|
+
maxReceiveCount: 3,
|
|
368
510
|
deadLetterTargetArn: {
|
|
369
511
|
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
370
512
|
},
|
|
@@ -388,7 +530,8 @@ class IntegrationBuilder extends InfrastructureBuilder {
|
|
|
388
530
|
*/
|
|
389
531
|
useExternalIntegrationQueue(integrationName, decision, result) {
|
|
390
532
|
// Add queue URL to environment for Lambda functions
|
|
391
|
-
result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] =
|
|
533
|
+
result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] =
|
|
534
|
+
decision.physicalId;
|
|
392
535
|
|
|
393
536
|
console.log(` ✓ Using external queue: ${decision.physicalId}`);
|
|
394
537
|
}
|
|
@@ -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
|
]);
|
|
@@ -259,7 +260,7 @@ describe('IntegrationBuilder', () => {
|
|
|
259
260
|
|
|
260
261
|
const result = await integrationBuilder.build(appDefinition, {});
|
|
261
262
|
|
|
262
|
-
expect(result.functions.testQueueWorker.reservedConcurrency).toBe(
|
|
263
|
+
expect(result.functions.testQueueWorker.reservedConcurrency).toBe(20);
|
|
263
264
|
});
|
|
264
265
|
|
|
265
266
|
it('should add queue URL to environment variables', async () => {
|
|
@@ -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
|
}
|
|
@@ -1162,13 +1162,14 @@ describe('composeServerlessDefinition', () => {
|
|
|
1162
1162
|
// Check Queue Worker
|
|
1163
1163
|
expect(result.functions.testIntegrationQueueWorker).toEqual({
|
|
1164
1164
|
handler: 'node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.testIntegration.queueWorker',
|
|
1165
|
-
reservedConcurrency:
|
|
1165
|
+
reservedConcurrency: 20,
|
|
1166
1166
|
events: [{
|
|
1167
1167
|
sqs: {
|
|
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.77",
|
|
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.77",
|
|
29
|
+
"@friggframework/schemas": "2.0.0-next.77",
|
|
30
|
+
"@friggframework/test": "2.0.0-next.77",
|
|
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.77",
|
|
59
|
+
"@friggframework/prettier-config": "2.0.0-next.77",
|
|
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": "0f451bd5c8493b4ec6f82964a976cf2533aa94d8"
|
|
92
92
|
}
|