@mettlecast/domain-cdk-packer 0.2.92 → 0.2.93

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.
@@ -9,7 +9,7 @@ import * as iam from 'aws-cdk-lib/aws-iam';
9
9
  import * as scheduler from 'aws-cdk-lib/aws-scheduler';
10
10
  import * as ec2 from 'aws-cdk-lib/aws-ec2';
11
11
  import * as s3 from 'aws-cdk-lib/aws-s3';
12
- import { createGroupedLambdas } from './grouped-lambda-factory.js';
12
+ import { createGroupedLambdas, createGroupedApiActionLambda } from './grouped-lambda-factory.js';
13
13
  import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
14
14
  import { HealthConstruct } from './constructs/health-construct.js';
15
15
  import { DashboardConstruct } from './constructs/dashboard-construct.js';
@@ -207,17 +207,77 @@ export class DomainStack extends cdk.Stack {
207
207
  const lambdas = [];
208
208
  const byId = new Map();
209
209
  for (const group of splitByOutboundAccess(entries)) {
210
- // Force dedicated mode for action groups that contain any API-exposed
211
- // action the per-entry adapter override only takes effect in
212
- // dedicated mode (grouped mode uses a single TIB_HANDLER_MAP dispatcher
213
- // which assumes a uniform adapter across all entries). We detect
214
- // api-exposed actions by their `adapter` override (set by the caller
215
- // in the action block below), since the entry shape is intentionally
216
- // primitive-agnostic.
217
- const hasApiExposedAction = primitiveType === 'action'
218
- && group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
219
- const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
220
- || hasApiExposedAction;
210
+ if (primitiveType === 'action') {
211
+ // Split API-exposed actions from internal actions so they can be
212
+ // grouped into a single Lambda each (instead of one Lambda per action).
213
+ const apiExposed = group.entries.filter(e => e.adapter === 'createExposedActionApiHandler' && e.apiMethod && e.apiRoutePath);
214
+ const internal = group.entries.filter(e => !(e.adapter === 'createExposedActionApiHandler' && e.apiMethod && e.apiRoutePath));
215
+ // API-exposed actions ONE grouped Lambda with route-key dispatch
216
+ if (apiExposed.length > 0) {
217
+ const apiEntries = apiExposed.map(e => ({
218
+ id: e.id,
219
+ handlerFile: e.handlerFile,
220
+ method: e.apiMethod,
221
+ routePath: e.apiRoutePath,
222
+ }));
223
+ const apiLambda = createGroupedApiActionLambda(this, {
224
+ domainId,
225
+ domainRoot: registry.domainRoot,
226
+ apiEntries,
227
+ environment,
228
+ eventBusArn,
229
+ reservedConcurrency,
230
+ logRetentionDays: logRetentionDays ?? 30,
231
+ lambdaGroupId: group.outboundAccess,
232
+ ...(vpc ? {
233
+ vpc,
234
+ securityGroups: lambdaSg ? [lambdaSg] : undefined,
235
+ subnetSelection: resolveSubnetSelection(group.outboundAccess),
236
+ } : {}),
237
+ });
238
+ lambdas.push(apiLambda);
239
+ apiExposed.forEach(entry => byId.set(entry.id, apiLambda));
240
+ }
241
+ // Internal actions → existing grouped mode (one Lambda for all)
242
+ if (internal.length > 0) {
243
+ const internalDedicated = internal.some(entry => entry.deployment?.isolation === 'dedicated');
244
+ const internalLambdas = createGroupedLambdas(this, {
245
+ domainId,
246
+ domainRoot: registry.domainRoot,
247
+ primitiveType,
248
+ handlerEntries: internal.map(entry => {
249
+ const handlerEntry = {
250
+ id: entry.id,
251
+ handlerFile: entry.handlerFile,
252
+ };
253
+ if (entry.adapter)
254
+ handlerEntry.adapter = entry.adapter;
255
+ return handlerEntry;
256
+ }),
257
+ environment,
258
+ eventBusArn,
259
+ dedicated: internalDedicated,
260
+ reservedConcurrency,
261
+ logRetentionDays: logRetentionDays ?? 30,
262
+ lambdaGroupId: group.outboundAccess,
263
+ ...(vpc ? {
264
+ vpc,
265
+ securityGroups: lambdaSg ? [lambdaSg] : undefined,
266
+ subnetSelection: resolveSubnetSelection(group.outboundAccess),
267
+ } : {}),
268
+ });
269
+ lambdas.push(...internalLambdas);
270
+ if (internalDedicated) {
271
+ internal.forEach((entry, index) => byId.set(entry.id, internalLambdas[index]));
272
+ }
273
+ else {
274
+ internal.forEach(entry => byId.set(entry.id, internalLambdas[0]));
275
+ }
276
+ }
277
+ continue;
278
+ }
279
+ // Non-action primitives: existing logic
280
+ const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated');
221
281
  const groupLambdas = createGroupedLambdas(this, {
222
282
  domainId,
223
283
  domainRoot: registry.domainRoot,
@@ -495,6 +555,8 @@ export class DomainStack extends cdk.Stack {
495
555
  entry.deployment = action.deployment;
496
556
  if (action.exposure?.type === 'api') {
497
557
  entry.adapter = 'createExposedActionApiHandler';
558
+ entry.apiMethod = action.exposure.method;
559
+ entry.apiRoutePath = `/${domainId}${action.exposure.path}`;
498
560
  }
499
561
  return entry;
500
562
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER, } from '../grouped-lambda-factory.js';
2
+ import { buildDedicatedEntryContent, buildGroupedApiActionEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER, } from '../grouped-lambda-factory.js';
3
3
  /**
4
4
  * Wave 7 Task 7.3 (#4619) — narrow, pure tests for the dedicated-mode
5
5
  * Lambda entry-content generator.
@@ -143,4 +143,25 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
143
143
  expect(EXPOSED_ACTION_ADAPTER).toBe('createExposedActionApiHandler');
144
144
  });
145
145
  });
146
+ describe('buildGroupedApiActionEntryContent', () => {
147
+ it('generates a single entry that imports all API actions and dispatches by routeKey', () => {
148
+ const entries = [
149
+ { id: 'whoami', handlerFile: 'actions/whoami.ts', method: 'GET', routePath: '/auth/v1/auth/whoami' },
150
+ { id: 'register-sys-admin', handlerFile: 'actions/register-sys-admin.ts', method: 'POST', routePath: '/auth/v1/auth/register-sys-admin' },
151
+ ];
152
+ const content = buildGroupedApiActionEntryContent('auth', entries, '/tmp/entry', '/repo/domains/auth');
153
+ // Imports createExposedActionApiHandler from the runtime
154
+ expect(content).toContain("import { createExposedActionApiHandler } from '@mettlecast/domain-runtime'");
155
+ // Imports each action
156
+ expect(content).toContain('import { whoami } from');
157
+ expect(content).toContain('import { registerSysAdmin } from');
158
+ // Route handlers keyed by routeKey
159
+ expect(content).toContain('"GET /auth/v1/auth/whoami"');
160
+ expect(content).toContain('"POST /auth/v1/auth/register-sys-admin"');
161
+ // Dispatcher reads routeKey from event
162
+ expect(content).toContain('event.requestContext?.routeKey');
163
+ // 404 fallback
164
+ expect(content).toContain('statusCode: 404');
165
+ });
166
+ });
146
167
  });
@@ -1,4 +1,5 @@
1
1
  import * as lambda from 'aws-cdk-lib/aws-lambda';
2
+ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
2
3
  import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
4
  import { Construct } from 'constructs';
4
5
  /** Primitive types that can be grouped into a single Lambda. */
@@ -115,3 +116,32 @@ export declare function buildDedicatedEntryContent(handlerImportPath: string, en
115
116
  * Grouped mode retains Code.fromAsset for backward compatibility.
116
117
  */
117
118
  export declare function createGroupedLambdas(scope: Construct, props: GroupedLambdaProps): lambda.Function[];
119
+ /** Entry for a single API-exposed action within a grouped Lambda. */
120
+ export interface ApiActionRouteEntry {
121
+ id: string;
122
+ handlerFile: string;
123
+ method: string;
124
+ /** Full HTTP route path including the domain prefix (e.g. /auth/v1/auth/whoami). */
125
+ routePath: string;
126
+ }
127
+ /**
128
+ * Pure entry-content generator for the grouped API-action Lambda.
129
+ *
130
+ * Generates a single TypeScript entry that imports all API-exposed actions,
131
+ * pre-creates per-action HTTP handlers via `createExposedActionApiHandler`,
132
+ * and dispatches by `event.requestContext.routeKey`.
133
+ *
134
+ * Exported so unit tests can assert on the generated content without
135
+ * instantiating CDK or esbuild.
136
+ */
137
+ export declare function buildGroupedApiActionEntryContent(domainId: string, entries: ApiActionRouteEntry[], entryDir: string, domainRoot: string): string;
138
+ /**
139
+ * Creates a single grouped Lambda for all API-exposed actions in a domain.
140
+ *
141
+ * Uses NodejsFunction (esbuild on-the-fly) with a generated multi-action entry
142
+ * that dispatches by `event.requestContext.routeKey`. All HTTP API routes for
143
+ * the domain's API actions point to this one Lambda.
144
+ */
145
+ export declare function createGroupedApiActionLambda(scope: Construct, props: Omit<GroupedLambdaProps, 'handlerEntries' | 'primitiveType' | 'dedicated'> & {
146
+ apiEntries: ApiActionRouteEntry[];
147
+ }): lambdaNode.NodejsFunction;
@@ -226,3 +226,96 @@ function toLogRetention(days) {
226
226
  };
227
227
  return map[days] ?? logs.RetentionDays.ONE_MONTH;
228
228
  }
229
+ /**
230
+ * Pure entry-content generator for the grouped API-action Lambda.
231
+ *
232
+ * Generates a single TypeScript entry that imports all API-exposed actions,
233
+ * pre-creates per-action HTTP handlers via `createExposedActionApiHandler`,
234
+ * and dispatches by `event.requestContext.routeKey`.
235
+ *
236
+ * Exported so unit tests can assert on the generated content without
237
+ * instantiating CDK or esbuild.
238
+ */
239
+ export function buildGroupedApiActionEntryContent(domainId, entries, entryDir, domainRoot) {
240
+ const lines = [
241
+ `import { createExposedActionApiHandler } from '@mettlecast/domain-runtime';`,
242
+ ];
243
+ // Import each action handler
244
+ for (const entry of entries) {
245
+ const exportName = camelCase(entry.id);
246
+ const absPath = join(domainRoot, entry.handlerFile);
247
+ let rel = relative(entryDir, absPath).replace(/\\/g, '/');
248
+ if (!rel.startsWith('.'))
249
+ rel = `./${rel}`;
250
+ lines.push(`import { ${exportName} } from '${rel}';`);
251
+ }
252
+ lines.push('');
253
+ // Build the action registry (for ctx.actions cross-domain calls)
254
+ const registryEntries = entries
255
+ .map(e => `${JSON.stringify(e.id)}: ${camelCase(e.id)}`)
256
+ .join(', ');
257
+ lines.push(`const domainId = ${JSON.stringify(domainId)};`);
258
+ lines.push(`const actionRegistry = { [domainId]: { ${registryEntries} } };`);
259
+ lines.push('');
260
+ // Pre-create per-route handlers
261
+ lines.push('const routeHandlers: Record<string, (event: any) => Promise<any>> = {');
262
+ for (const entry of entries) {
263
+ const exportName = camelCase(entry.id);
264
+ const routeKey = `${entry.method} ${entry.routePath}`;
265
+ lines.push(` ${JSON.stringify(routeKey)}: createExposedActionApiHandler(${exportName}, { definingDomain: domainId, domainId, actionRegistry, callerDomainId: domainId }),`);
266
+ }
267
+ lines.push('};');
268
+ lines.push('');
269
+ // Dispatcher
270
+ lines.push('export const handler = async (event: any) => {', ' const routeKey = event.requestContext?.routeKey;', ' const matched = routeHandlers[routeKey];', ' if (!matched) {', ' return { statusCode: 404, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: "Not Found" }) };', ' }', ' return matched(event);', '};');
271
+ return lines.join('\n');
272
+ }
273
+ /**
274
+ * Creates a single grouped Lambda for all API-exposed actions in a domain.
275
+ *
276
+ * Uses NodejsFunction (esbuild on-the-fly) with a generated multi-action entry
277
+ * that dispatches by `event.requestContext.routeKey`. All HTTP API routes for
278
+ * the domain's API actions point to this one Lambda.
279
+ */
280
+ export function createGroupedApiActionLambda(scope, props) {
281
+ if (!props.domainRoot)
282
+ throw new Error('domainRoot is required for createGroupedApiActionLambda');
283
+ const vpcConfig = props.vpc
284
+ ? {
285
+ vpc: props.vpc,
286
+ securityGroups: props.securityGroups,
287
+ ...(props.subnetSelection ? { vpcSubnets: props.subnetSelection } : {}),
288
+ }
289
+ : {};
290
+ const logRetention = toLogRetention(props.logRetentionDays ?? 30);
291
+ const powertoolsLayerArn = `arn:aws:lambda:${cdk.Stack.of(scope).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:26`;
292
+ const serviceName = `${props.domainId}-action-api`;
293
+ const entryDir = createTempEntryDir();
294
+ const entryPath = join(entryDir, 'entry.ts');
295
+ const entryContent = buildGroupedApiActionEntryContent(props.domainId, props.apiEntries, entryDir, props.domainRoot);
296
+ writeFileSync(entryPath, entryContent, 'utf8');
297
+ const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-action-api`, powertoolsLayerArn);
298
+ return new lambdaNode.NodejsFunction(scope, `${props.domainId}-action-api`, {
299
+ runtime: lambda.Runtime.NODEJS_22_X,
300
+ architecture: lambda.Architecture.ARM_64,
301
+ entry: entryPath,
302
+ handler: 'handler',
303
+ layers: [powertoolsLayer],
304
+ bundling: {
305
+ minify: true,
306
+ sourceMap: true,
307
+ externalModules: ['@aws-sdk/*'],
308
+ },
309
+ environment: {
310
+ ...props.environment,
311
+ POWERTOOLS_SERVICE_NAME: serviceName,
312
+ POWERTOOLS_LOG_LEVEL: 'INFO',
313
+ TIB_EVENT_BUS_ARN: props.eventBusArn,
314
+ },
315
+ timeout: cdk.Duration.seconds(30),
316
+ memorySize: 256,
317
+ reservedConcurrentExecutions: props.reservedConcurrency,
318
+ logRetention,
319
+ ...vpcConfig,
320
+ });
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.92",
3
+ "version": "0.2.93",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",