@go-to-k/cdkd 0.130.0 → 0.132.0

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/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-CuHRHcyW.js";
3
- import { A as AssetPublisher, B as getLegacyStateBucketName, C as applyRoleArnIfSet, Ct as generateResourceNameWithFallback, D as LockManager, E as TemplateParser, F as getDockerCmd, G as resolveStateBucketWithDefaultAndSource, H as resolveCaptureObservedState, I as runDockerForeground, K as warnDeprecatedNoPrefixCliFlag, L as runDockerStreaming, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as formatDockerLoginError, R as Synthesizer, S as IntrinsicFunctionResolver, St as generateResourceName, T as DagBuilder, Tt as withStackName, U as resolveSkipPrefix, V as resolveApp, W as resolveStateBucketWithDefault, Y as resolveBucketRegion, Z as CdkdError, _ as normalizeAwsTagsToCfn, a as withRetry, at as ResourceUpdateNotSupportedError, b as CloudControlProvider, bt as PATTERN_B_NAME_PROPERTIES, c as cyan, ct as StackTerminationProtectionError, d as red, et as LocalInvokeBuildError, f as yellow, g as matchesCdkPath, gt as getLogger, h as CDK_PATH_TAG, i as withResourceDeadline, it as ResourceTimeoutError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, mt as withErrorHandling, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as PartialFailureError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as RouteDiscoveryError, p as IAMRoleProvider, pt as normalizeAwsError, q as AssemblyReader, r as DeployEngine, rt as ProvisioningError, s as bold, st as StackHasActiveImportsError, t as DEFAULT_RESOURCE_TIMEOUT_MS, u as green, v as resolveExplicitPhysicalId, vt as runStackBuffered, w as DiffCalculator, wt as withSkipPrefix, x as assertRegionMatch, xt as PATTERN_B_RESOURCE_TYPES, y as ProviderRegistry, yt as getLiveRenderer, z as getDefaultStateBucketName } from "./deploy-engine-B-w4C_7O.js";
2
+ import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
3
+ import { $ as PartialFailureError, A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalStartServiceError, _ as normalizeAwsTagsToCfn, a as withRetry, at as StackTerminationProtectionError, b as CloudControlProvider, c as cyan, d as red, dt as withErrorHandling, et as ProvisioningError, f as yellow, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as StackHasActiveImportsError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ResourceUpdateNotSupportedError, o as IMPLICIT_DELETE_DEPENDENCIES, p as IAMRoleProvider, q as CdkdError, r as DeployEngine, rt as RouteDiscoveryError, s as bold, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as ResourceTimeoutError, u as green, ut as normalizeAwsError, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-Dn7oV5rA.js";
4
+ import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
4
5
  import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
6
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
6
7
  import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
@@ -36541,14 +36542,14 @@ function parseTarget(target) {
36541
36542
  function resolveLambdaTarget(target, stacks) {
36542
36543
  if (stacks.length === 0) throw new LocalInvokeResolutionError("No stacks found in the synthesized assembly.");
36543
36544
  const parsed = parseTarget(target);
36544
- const stack = pickStack$1(parsed, stacks);
36545
+ const stack = pickStack$2(parsed, stacks);
36545
36546
  const template = stack.template;
36546
36547
  const resources = template.Resources ?? {};
36547
36548
  let match;
36548
36549
  if (parsed.isPath) {
36549
36550
  const index = buildCdkPathIndex(template);
36550
36551
  const lambdaMatches = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => resources[logicalId]?.Type === "AWS::Lambda::Function");
36551
- if (lambdaMatches.length === 0) throw notFoundError$1(target, stack, resources);
36552
+ if (lambdaMatches.length === 0) throw notFoundError$2(target, stack, resources);
36552
36553
  if (lambdaMatches.length > 1) throw new LocalInvokeResolutionError(`Target '${target}' matches ${lambdaMatches.length} Lambda functions in ${stack.stackName}: ` + lambdaMatches.map((m) => m.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
36553
36554
  const m = lambdaMatches[0];
36554
36555
  match = {
@@ -36557,7 +36558,7 @@ function resolveLambdaTarget(target, stacks) {
36557
36558
  };
36558
36559
  } else {
36559
36560
  const resource = resources[parsed.pathOrId];
36560
- if (!resource) throw notFoundError$1(target, stack, resources);
36561
+ if (!resource) throw notFoundError$2(target, stack, resources);
36561
36562
  match = {
36562
36563
  logicalId: parsed.pathOrId,
36563
36564
  resource
@@ -36575,7 +36576,7 @@ function resolveLambdaTarget(target, stacks) {
36575
36576
  * user may omit the stack prefix. Otherwise an explicit stack pattern is
36576
36577
  * required.
36577
36578
  */
36578
- function pickStack$1(parsed, stacks) {
36579
+ function pickStack$2(parsed, stacks) {
36579
36580
  if (parsed.stackPattern === null) {
36580
36581
  if (stacks.length === 1) return stacks[0];
36581
36582
  throw new LocalInvokeResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
@@ -36912,7 +36913,7 @@ function describeLayerEntry(entry) {
36912
36913
  * the resolved stack so the user can copy/paste a valid target. Mirrors
36913
36914
  * the format the issue spec calls out.
36914
36915
  */
36915
- function notFoundError$1(target, stack, resources) {
36916
+ function notFoundError$2(target, stack, resources) {
36916
36917
  const lambdas = [];
36917
36918
  for (const [logicalId, resource] of Object.entries(resources)) {
36918
36919
  if (resource.Type !== "AWS::Lambda::Function") continue;
@@ -37936,28 +37937,28 @@ function parseEcsTarget(target) {
37936
37937
  function resolveEcsTaskTarget(target, stacks, context) {
37937
37938
  if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
37938
37939
  const parsed = parseEcsTarget(target);
37939
- const stack = pickStack(parsed, stacks);
37940
+ const stack = pickStack$1(parsed, stacks);
37940
37941
  const resources = stack.template.Resources ?? {};
37941
37942
  let logicalId;
37942
37943
  let resource;
37943
37944
  if (parsed.isPath) {
37944
37945
  const index = buildCdkPathIndex(stack.template);
37945
37946
  const taskDefs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::TaskDefinition");
37946
- if (taskDefs.length === 0) throw notFoundError(target, stack, resources);
37947
+ if (taskDefs.length === 0) throw notFoundError$1(target, stack, resources);
37947
37948
  if (taskDefs.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${taskDefs.length} task definitions in ${stack.stackName}: ` + taskDefs.map((t) => t.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
37948
37949
  logicalId = taskDefs[0].logicalId;
37949
37950
  resource = resources[logicalId];
37950
37951
  } else {
37951
37952
  resource = resources[parsed.pathOrId];
37952
- if (!resource) throw notFoundError(target, stack, resources);
37953
+ if (!resource) throw notFoundError$1(target, stack, resources);
37953
37954
  logicalId = parsed.pathOrId;
37954
37955
  }
37955
- if (!logicalId || !resource) throw notFoundError(target, stack, resources);
37956
+ if (!logicalId || !resource) throw notFoundError$1(target, stack, resources);
37956
37957
  if (resource.Type === "AWS::Lambda::Function") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is a Lambda function, not an ECS task definition. Use \`cdkd local invoke\` for Lambda; \`cdkd local run-task\` is ECS only.`);
37957
37958
  if (resource.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is ${resource.Type}, not an AWS::ECS::TaskDefinition.`);
37958
37959
  return extractTaskDefinitionProperties(stack, logicalId, resource, context);
37959
37960
  }
37960
- function pickStack(parsed, stacks) {
37961
+ function pickStack$1(parsed, stacks) {
37961
37962
  if (parsed.stackPattern === null) {
37962
37963
  if (stacks.length === 1) return stacks[0];
37963
37964
  throw new EcsTaskResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
@@ -38480,7 +38481,7 @@ function pickStringArray$1(value) {
38480
38481
  for (const v of value) if (typeof v === "string") out.push(v);
38481
38482
  return out;
38482
38483
  }
38483
- function notFoundError(target, stack, resources) {
38484
+ function notFoundError$1(target, stack, resources) {
38484
38485
  const tasks = [];
38485
38486
  for (const [logicalId, resource] of Object.entries(resources)) {
38486
38487
  if (resource.Type !== "AWS::ECS::TaskDefinition") continue;
@@ -40432,20 +40433,74 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
40432
40433
  };
40433
40434
  const integrationType = integration["Type"];
40434
40435
  if (integrationType === "MOCK") {
40435
- const preflight = httpMethod === "OPTIONS" ? extractRestV1MockCorsConfig(integration) : void 0;
40436
- if (preflight) return [{
40436
+ if (httpMethod === "OPTIONS") {
40437
+ const preflight = extractRestV1MockCorsConfig(integration);
40438
+ if (preflight) return [{
40439
+ ...baseRoute,
40440
+ method: "OPTIONS",
40441
+ pathPattern: path,
40442
+ lambdaLogicalId: "",
40443
+ mockCors: preflight
40444
+ }];
40445
+ }
40446
+ const config = buildMockIntegrationConfig(integration);
40447
+ return [{
40437
40448
  ...baseRoute,
40438
- method: "OPTIONS",
40449
+ method: httpMethod,
40439
40450
  pathPattern: path,
40440
40451
  lambdaLogicalId: "",
40441
- mockCors: preflight
40452
+ restV1Integration: config
40453
+ }];
40454
+ }
40455
+ if (integrationType === "HTTP_PROXY") {
40456
+ const config = buildHttpProxyIntegrationConfig(integration, stackName, logicalId);
40457
+ if (config.kind === "unsupported") return [{
40458
+ ...baseRoute,
40459
+ method: httpMethod,
40460
+ pathPattern: path,
40461
+ lambdaLogicalId: "",
40462
+ unsupported: { reason: config.reason }
40463
+ }];
40464
+ return [{
40465
+ ...baseRoute,
40466
+ method: httpMethod,
40467
+ pathPattern: path,
40468
+ lambdaLogicalId: "",
40469
+ restV1Integration: config.config
40470
+ }];
40471
+ }
40472
+ if (integrationType === "HTTP") {
40473
+ const config = buildHttpIntegrationConfig(integration, stackName, logicalId);
40474
+ if (config.kind === "unsupported") return [{
40475
+ ...baseRoute,
40476
+ method: httpMethod,
40477
+ pathPattern: path,
40478
+ lambdaLogicalId: "",
40479
+ unsupported: { reason: config.reason }
40442
40480
  }];
40443
40481
  return [{
40444
40482
  ...baseRoute,
40445
40483
  method: httpMethod,
40446
40484
  pathPattern: path,
40447
40485
  lambdaLogicalId: "",
40448
- unsupported: { reason: `${stackName}/${logicalId}: MOCK integration is not emulated (only the CORS preflight subset, where HttpMethod=OPTIONS and IntegrationResponses carry literal method.response.header.* values, is supported).` }
40486
+ restV1Integration: config.config
40487
+ }];
40488
+ }
40489
+ if (integrationType === "AWS") {
40490
+ const config = buildAwsIntegrationConfig(integration, stackName, logicalId);
40491
+ if (config.kind === "unsupported") return [{
40492
+ ...baseRoute,
40493
+ method: httpMethod,
40494
+ pathPattern: path,
40495
+ lambdaLogicalId: "",
40496
+ unsupported: { reason: config.reason }
40497
+ }];
40498
+ return [{
40499
+ ...baseRoute,
40500
+ method: httpMethod,
40501
+ pathPattern: path,
40502
+ lambdaLogicalId: config.config.lambdaLogicalId,
40503
+ restV1Integration: config.config
40449
40504
  }];
40450
40505
  }
40451
40506
  if (integrationType !== "AWS_PROXY") return [{
@@ -40453,7 +40508,7 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
40453
40508
  method: httpMethod,
40454
40509
  pathPattern: path,
40455
40510
  lambdaLogicalId: "",
40456
- unsupported: { reason: `${stackName}/${logicalId}: REST v1 integration type '${String(integrationType)}' is not supported (only AWS_PROXY and the MOCK CORS preflight subset).` }
40511
+ unsupported: { reason: `${stackName}/${logicalId}: unknown REST v1 integration type '${String(integrationType)}' (expected AWS_PROXY / AWS / HTTP / HTTP_PROXY / MOCK).` }
40457
40512
  }];
40458
40513
  const integrationUri = integration["Uri"];
40459
40514
  const arnOutcome = resolveLambdaArnOutcome(integrationUri);
@@ -40523,6 +40578,188 @@ function extractRestV1MockCorsConfig(integration) {
40523
40578
  };
40524
40579
  }
40525
40580
  /**
40581
+ * Marker sequence on a Lambda invoke ARN — used to tell apart REST v1
40582
+ * `AWS` integrations whose backend is Lambda (`functions/<arn>/invocations`)
40583
+ * from other AWS-service integrations (`:s3:path/...`, `:sqs:path/...`).
40584
+ * Closes #457's AWS-vs-Lambda discrimination.
40585
+ */
40586
+ const LAMBDA_INVOKE_PATH = ":lambda:path/2015-03-31/functions/";
40587
+ /**
40588
+ * Build a MOCK integration config for `cdkd local start-api` dispatch.
40589
+ *
40590
+ * Pulls `Integration.RequestTemplates['application/json']` (drives MOCK
40591
+ * status-code selection — AWS reads `{"statusCode": N}` from the rendered
40592
+ * template) and `Integration.IntegrationResponses[]` (drives the shaped
40593
+ * response).
40594
+ */
40595
+ function buildMockIntegrationConfig(integration) {
40596
+ const requestTemplate = pickStringFromRecord(integration["RequestTemplates"], "application/json");
40597
+ const responses = readIntegrationResponses(integration);
40598
+ return {
40599
+ kind: "mock",
40600
+ requestTemplate: requestTemplate ?? void 0,
40601
+ responses
40602
+ };
40603
+ }
40604
+ /**
40605
+ * Build a HTTP_PROXY integration config. The Uri must be a literal
40606
+ * string at template-author time — `Fn::Sub` shapes with literal
40607
+ * placeholders are rare and unsupported in v1 (surfaces as a 501 with
40608
+ * a clear reason).
40609
+ */
40610
+ function buildHttpProxyIntegrationConfig(integration, stackName, logicalId) {
40611
+ const uri = integration["Uri"];
40612
+ if (typeof uri !== "string" || uri.length === 0) return {
40613
+ kind: "unsupported",
40614
+ reason: `${stackName}/${logicalId}: HTTP_PROXY Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP_PROXY Uris); got ${shortJson$1(uri)}.`
40615
+ };
40616
+ const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
40617
+ const requestParameters = pickStringRecord(integration["RequestParameters"]);
40618
+ const responses = readIntegrationResponses(integration);
40619
+ return {
40620
+ kind: "config",
40621
+ config: {
40622
+ kind: "http-proxy",
40623
+ uri,
40624
+ ...integrationHttpMethod !== void 0 && { integrationHttpMethod },
40625
+ ...requestParameters !== void 0 && { requestParameters },
40626
+ responses
40627
+ }
40628
+ };
40629
+ }
40630
+ /**
40631
+ * Build an HTTP (non-proxy) integration config. Like HTTP_PROXY but with
40632
+ * `RequestTemplates` for VTL transformation.
40633
+ */
40634
+ function buildHttpIntegrationConfig(integration, stackName, logicalId) {
40635
+ const uri = integration["Uri"];
40636
+ if (typeof uri !== "string" || uri.length === 0) return {
40637
+ kind: "unsupported",
40638
+ reason: `${stackName}/${logicalId}: HTTP Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP Uris); got ${shortJson$1(uri)}.`
40639
+ };
40640
+ const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
40641
+ const requestParameters = pickStringRecord(integration["RequestParameters"]);
40642
+ const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
40643
+ const responses = readIntegrationResponses(integration);
40644
+ return {
40645
+ kind: "config",
40646
+ config: {
40647
+ kind: "http",
40648
+ uri,
40649
+ ...integrationHttpMethod !== void 0 && { integrationHttpMethod },
40650
+ ...requestParameters !== void 0 && { requestParameters },
40651
+ ...requestTemplates !== void 0 && { requestTemplates },
40652
+ responses
40653
+ }
40654
+ };
40655
+ }
40656
+ /**
40657
+ * Build an AWS integration config. Branches on whether the integration
40658
+ * targets a Lambda (`:lambda:path/2015-03-31/functions/<arn>/invocations`)
40659
+ * or a non-Lambda AWS service (`:s3:path/...` / `:sqs:action/...` etc.).
40660
+ *
40661
+ * cdkd v1 supports Lambda non-proxy AWS integrations end-to-end. Non-
40662
+ * Lambda AWS service integrations surface as deferred-501 unsupported
40663
+ * routes — they would require an AWS SDK client per service, IAM
40664
+ * credential threading, and a sizable per-service unit-test matrix.
40665
+ * See [docs/local-emulation.md](docs/local-emulation.md) for the deferred
40666
+ * AWS-service-action list.
40667
+ */
40668
+ function buildAwsIntegrationConfig(integration, stackName, logicalId) {
40669
+ const uri = integration["Uri"];
40670
+ if (!uriContainsLambdaMarker(uri)) return {
40671
+ kind: "unsupported",
40672
+ reason: `${stackName}/${logicalId}: REST v1 AWS integration targeting a non-Lambda service (Uri ${shortJson$1(uri)}) is not emulated locally in cdkd v1. Lambda non-proxy AWS integrations are supported; direct AWS service integrations (S3 / SQS / SNS / DynamoDB) require deploying to AWS. See docs/local-emulation.md.`
40673
+ };
40674
+ const arnOutcome = resolveLambdaArnOutcome(uri);
40675
+ if (arnOutcome.kind === "unsupported") return {
40676
+ kind: "unsupported",
40677
+ reason: `${stackName}/${logicalId}.Integration.Uri: ${arnOutcome.detail} (got ${shortJson$1(uri)}). Lambda Arn intrinsics on cross-stack / imported references are not resolvable locally.`
40678
+ };
40679
+ const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
40680
+ const responses = readIntegrationResponses(integration);
40681
+ return {
40682
+ kind: "config",
40683
+ config: {
40684
+ kind: "aws-lambda",
40685
+ lambdaLogicalId: arnOutcome.logicalId,
40686
+ ...requestTemplates !== void 0 && { requestTemplates },
40687
+ responses
40688
+ }
40689
+ };
40690
+ }
40691
+ /**
40692
+ * Determine whether an `Integration.Uri` references a Lambda invoke path.
40693
+ * Recognises the canonical Lambda invoke ARN shape across the same
40694
+ * intrinsic forms `intrinsic-lambda-arn.ts` accepts (the shared resolver
40695
+ * never produces this Boolean directly, so we walk the shape here).
40696
+ */
40697
+ function uriContainsLambdaMarker(uri) {
40698
+ if (typeof uri === "string") return uri.includes(LAMBDA_INVOKE_PATH);
40699
+ if (uri && typeof uri === "object" && !Array.isArray(uri)) {
40700
+ const obj = uri;
40701
+ if ("Fn::Sub" in obj) {
40702
+ const v = obj["Fn::Sub"];
40703
+ if (typeof v === "string") return v.includes(LAMBDA_INVOKE_PATH);
40704
+ if (Array.isArray(v) && typeof v[0] === "string") return v[0].includes(LAMBDA_INVOKE_PATH);
40705
+ }
40706
+ if ("Fn::Join" in obj) {
40707
+ const join = obj["Fn::Join"];
40708
+ if (Array.isArray(join) && join.length === 2 && Array.isArray(join[1])) {
40709
+ for (const piece of join[1]) if (typeof piece === "string" && piece.includes(LAMBDA_INVOKE_PATH)) return true;
40710
+ }
40711
+ }
40712
+ if ("Ref" in obj || "Fn::GetAtt" in obj) return true;
40713
+ }
40714
+ return false;
40715
+ }
40716
+ /**
40717
+ * Read `Integration.IntegrationResponses[]` from a Method's Integration
40718
+ * sub-object and return the entries cdkd's dispatchers consume.
40719
+ *
40720
+ * Defensive: rejects non-object entries with a clear inline warning.
40721
+ */
40722
+ function readIntegrationResponses(integration) {
40723
+ const raw = integration["IntegrationResponses"];
40724
+ if (!Array.isArray(raw)) return [];
40725
+ const out = [];
40726
+ for (const entry of raw) {
40727
+ if (!entry || typeof entry !== "object") continue;
40728
+ const obj = entry;
40729
+ const statusCode = obj["StatusCode"];
40730
+ if (statusCode === void 0) continue;
40731
+ if (typeof statusCode !== "string" && typeof statusCode !== "number") continue;
40732
+ const e = { StatusCode: String(statusCode) };
40733
+ if (typeof obj["SelectionPattern"] === "string") e.SelectionPattern = obj["SelectionPattern"];
40734
+ const responseParameters = pickStringRecord(obj["ResponseParameters"]);
40735
+ if (responseParameters !== void 0) e.ResponseParameters = responseParameters;
40736
+ const responseTemplates = pickStringRecord(obj["ResponseTemplates"]);
40737
+ if (responseTemplates !== void 0) e.ResponseTemplates = responseTemplates;
40738
+ if (typeof obj["ContentHandling"] === "string") e.ContentHandling = obj["ContentHandling"];
40739
+ out.push(e);
40740
+ }
40741
+ return out;
40742
+ }
40743
+ function pickStringField(props, key) {
40744
+ const v = props[key];
40745
+ return typeof v === "string" ? v : void 0;
40746
+ }
40747
+ function pickStringRecord(value) {
40748
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
40749
+ const out = {};
40750
+ let any = false;
40751
+ for (const [k, v] of Object.entries(value)) if (typeof v === "string") {
40752
+ out[k] = v;
40753
+ any = true;
40754
+ }
40755
+ return any ? out : void 0;
40756
+ }
40757
+ function pickStringFromRecord(value, key) {
40758
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
40759
+ const v = value[key];
40760
+ return typeof v === "string" ? v : void 0;
40761
+ }
40762
+ /**
40526
40763
  * Walk a chain of `AWS::ApiGateway::Resource` parent pointers up to the
40527
40764
  * `RestApi` root to build the full path. Each `Resource` contributes a
40528
40765
  * `PathPart` segment; the `RestApi` itself contributes the leading `/`.
@@ -40890,6 +41127,1354 @@ function shortJson$1(value) {
40890
41127
  }
40891
41128
  }
40892
41129
 
41130
+ //#endregion
41131
+ //#region src/local/vtl-engine.ts
41132
+ /** Error thrown when a template references an unsupported VTL feature. */
41133
+ var VtlEvaluationError = class VtlEvaluationError extends Error {
41134
+ constructor(message) {
41135
+ super(message);
41136
+ this.name = "VtlEvaluationError";
41137
+ Object.setPrototypeOf(this, VtlEvaluationError.prototype);
41138
+ }
41139
+ };
41140
+ /** Built-in `$util` implementation. */
41141
+ function buildDefaultUtil() {
41142
+ const coerce = (v) => {
41143
+ if (v == null) return "";
41144
+ if (typeof v === "string") return v;
41145
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
41146
+ try {
41147
+ return JSON.stringify(v);
41148
+ } catch {
41149
+ return "";
41150
+ }
41151
+ };
41152
+ return {
41153
+ escapeJavaScript(input) {
41154
+ return coerce(input).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
41155
+ },
41156
+ base64Encode(input) {
41157
+ return Buffer.from(coerce(input), "utf-8").toString("base64");
41158
+ },
41159
+ base64Decode(input) {
41160
+ return Buffer.from(coerce(input), "base64").toString("utf-8");
41161
+ },
41162
+ urlEncode(input) {
41163
+ return encodeURIComponent(coerce(input));
41164
+ },
41165
+ urlDecode(input) {
41166
+ try {
41167
+ return decodeURIComponent(coerce(input));
41168
+ } catch {
41169
+ return coerce(input);
41170
+ }
41171
+ },
41172
+ parseJson(input) {
41173
+ const s = coerce(input);
41174
+ try {
41175
+ return JSON.parse(s);
41176
+ } catch (err) {
41177
+ throw new VtlEvaluationError(`$util.parseJson: invalid JSON input: ${err instanceof Error ? err.message : String(err)}`);
41178
+ }
41179
+ }
41180
+ };
41181
+ }
41182
+ /**
41183
+ * Public entry point — evaluate a VTL template against a context and
41184
+ * return the rendered string. Throws {@link VtlEvaluationError} on any
41185
+ * unsupported syntax or runtime failure.
41186
+ *
41187
+ * Empty / undefined templates short-circuit to an empty string, matching
41188
+ * AWS API Gateway behavior when `RequestTemplates` / `ResponseTemplates`
41189
+ * is absent for the selected content type.
41190
+ */
41191
+ function evaluateVtl(template, ctx) {
41192
+ if (template === void 0 || template.length === 0) return "";
41193
+ return new VtlEvaluator(ctx).evaluate(template);
41194
+ }
41195
+ /**
41196
+ * Stateful evaluator. Tokenizes + parses + renders in one pass — minimal
41197
+ * subset, so a recursive-descent walk over the template suffices. Tracks
41198
+ * a per-template scope chain for `#set` and `#foreach` bindings.
41199
+ */
41200
+ var VtlEvaluator = class {
41201
+ ctx;
41202
+ scopes;
41203
+ output = [];
41204
+ constructor(ctx) {
41205
+ this.ctx = ctx;
41206
+ this.scopes = [/* @__PURE__ */ new Map()];
41207
+ }
41208
+ evaluate(template) {
41209
+ this.renderBlock(template);
41210
+ return this.output.join("");
41211
+ }
41212
+ /**
41213
+ * Render a block — walks the template, interpolating `${var}` /
41214
+ * `$var.field.method(args)` and handling `#set` / `#if` / `#foreach`
41215
+ * directives.
41216
+ *
41217
+ * The walk is line-aware for directives: every `#directive` MUST start
41218
+ * a line (after whitespace) per Velocity convention, but for ergonomics
41219
+ * we also accept directives at the start of the template. Inline `$var`
41220
+ * references are handled anywhere.
41221
+ */
41222
+ renderBlock(block) {
41223
+ let i = 0;
41224
+ while (i < block.length) {
41225
+ const ch = block[i];
41226
+ if (ch === "#" && this.isDirectiveStart(block, i)) {
41227
+ i = this.handleDirective(block, i);
41228
+ continue;
41229
+ }
41230
+ if (ch === "$") {
41231
+ const consumed = this.handleVariable(block, i);
41232
+ if (consumed > 0) {
41233
+ i += consumed;
41234
+ continue;
41235
+ }
41236
+ }
41237
+ if (ch === "\\" && i + 1 < block.length && block[i + 1] === "$") {
41238
+ this.output.push("$");
41239
+ i += 2;
41240
+ continue;
41241
+ }
41242
+ this.output.push(ch ?? "");
41243
+ i++;
41244
+ }
41245
+ }
41246
+ isDirectiveStart(block, i) {
41247
+ if (i + 1 >= block.length) return false;
41248
+ const next = block[i + 1];
41249
+ if (next === "#") return true;
41250
+ return next !== void 0 && /[a-zA-Z]/.test(next);
41251
+ }
41252
+ /**
41253
+ * Handle one directive (`#set`, `#if`, `#foreach`, etc.) — returns the
41254
+ * NEW index in `block` (i.e. how far we consumed past the directive).
41255
+ */
41256
+ handleDirective(block, start) {
41257
+ if (block[start + 1] === "#") {
41258
+ const eol = block.indexOf("\n", start);
41259
+ return eol === -1 ? block.length : eol + 1;
41260
+ }
41261
+ const directiveMatch = /^#([a-zA-Z]+)/.exec(block.slice(start));
41262
+ if (!directiveMatch) {
41263
+ this.output.push("#");
41264
+ return start + 1;
41265
+ }
41266
+ const name = directiveMatch[1];
41267
+ const afterDirective = start + 1 + name.length;
41268
+ switch (name) {
41269
+ case "set": return this.handleSetDirective(block, afterDirective);
41270
+ case "if": return this.handleIfDirective(block, afterDirective);
41271
+ case "foreach": return this.handleForeachDirective(block, afterDirective);
41272
+ case "else":
41273
+ case "elseif":
41274
+ case "end": throw new VtlEvaluationError(`Unexpected #${name} outside of a #if / #foreach block`);
41275
+ default: throw new VtlEvaluationError(`Unsupported VTL directive #${name} (cdkd local start-api supports #set / #if / #elseif / #else / #foreach / #end / ##)`);
41276
+ }
41277
+ }
41278
+ /**
41279
+ * `#set($var = expression)` — assigns to the innermost scope.
41280
+ */
41281
+ handleSetDirective(block, after) {
41282
+ const { args, end } = this.readParenArgs(block, after);
41283
+ const eq = args.indexOf("=");
41284
+ if (eq === -1) throw new VtlEvaluationError(`#set requires '=': got #set(${args})`);
41285
+ const left = args.slice(0, eq).trim();
41286
+ const right = args.slice(eq + 1).trim();
41287
+ if (!left.startsWith("$")) throw new VtlEvaluationError(`#set left side must be a $var reference (got '${left}')`);
41288
+ const varName = left.slice(1).replace(/^\{/, "").replace(/\}$/, "");
41289
+ const value = this.evaluateExpression(right);
41290
+ this.scopes[this.scopes.length - 1].set(varName, value);
41291
+ return this.skipDirectiveTrailingNewline(block, end);
41292
+ }
41293
+ /**
41294
+ * `#if (cond) ... #elseif (cond) ... #else ... #end`. Renders the
41295
+ * first true branch only; the rest are skipped (their text NOT emitted).
41296
+ */
41297
+ handleIfDirective(block, after) {
41298
+ const { args: condExpr, end } = this.readParenArgs(block, after);
41299
+ let rendered = false;
41300
+ let renderedAny = false;
41301
+ const branches = [{
41302
+ condition: condExpr,
41303
+ bodyStart: this.skipDirectiveTrailingNewline(block, end),
41304
+ bodyEnd: -1
41305
+ }];
41306
+ let cursor = branches[0].bodyStart;
41307
+ let depth = 1;
41308
+ while (cursor < block.length && depth > 0) {
41309
+ if (block[cursor] !== "#") {
41310
+ cursor++;
41311
+ continue;
41312
+ }
41313
+ const m = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
41314
+ if (!m) {
41315
+ cursor++;
41316
+ continue;
41317
+ }
41318
+ const tag = m[1];
41319
+ const tagAfter = cursor + 1 + tag.length;
41320
+ if (tag === "if" || tag === "foreach") {
41321
+ depth++;
41322
+ cursor = tagAfter;
41323
+ continue;
41324
+ }
41325
+ if (tag === "end") {
41326
+ depth--;
41327
+ if (depth === 0) {
41328
+ branches[branches.length - 1].bodyEnd = cursor;
41329
+ const endIdx = this.skipDirectiveTrailingNewline(block, tagAfter);
41330
+ for (const branch of branches) {
41331
+ if (rendered) break;
41332
+ const truthy = branch.condition === null ? !renderedAny : this.evaluateCondition(branch.condition);
41333
+ if (truthy) {
41334
+ this.renderBlock(block.slice(branch.bodyStart, branch.bodyEnd));
41335
+ rendered = true;
41336
+ }
41337
+ renderedAny = renderedAny || truthy;
41338
+ }
41339
+ return endIdx;
41340
+ }
41341
+ cursor = tagAfter;
41342
+ continue;
41343
+ }
41344
+ if (depth === 1 && (tag === "elseif" || tag === "else")) {
41345
+ branches[branches.length - 1].bodyEnd = cursor;
41346
+ if (tag === "elseif") {
41347
+ const { args, end: elseifEnd } = this.readParenArgs(block, tagAfter);
41348
+ branches.push({
41349
+ condition: args,
41350
+ bodyStart: this.skipDirectiveTrailingNewline(block, elseifEnd),
41351
+ bodyEnd: -1
41352
+ });
41353
+ cursor = branches[branches.length - 1].bodyStart;
41354
+ } else {
41355
+ branches.push({
41356
+ condition: null,
41357
+ bodyStart: this.skipDirectiveTrailingNewline(block, tagAfter),
41358
+ bodyEnd: -1
41359
+ });
41360
+ cursor = branches[branches.length - 1].bodyStart;
41361
+ }
41362
+ continue;
41363
+ }
41364
+ cursor = tagAfter;
41365
+ }
41366
+ throw new VtlEvaluationError("#if without matching #end");
41367
+ }
41368
+ /**
41369
+ * `#foreach($x in $list) ... #end` — iterates a list / object's values.
41370
+ */
41371
+ handleForeachDirective(block, after) {
41372
+ const { args, end } = this.readParenArgs(block, after);
41373
+ const m = /^\s*\$([a-zA-Z_][a-zA-Z_0-9]*)\s+in\s+(.+)$/.exec(args);
41374
+ if (!m) throw new VtlEvaluationError(`Invalid #foreach syntax: ${args}`);
41375
+ const varName = m[1];
41376
+ const listExpr = m[2];
41377
+ const listValue = this.evaluateExpression(listExpr);
41378
+ let depth = 1;
41379
+ let cursor = this.skipDirectiveTrailingNewline(block, end);
41380
+ const bodyStart = cursor;
41381
+ while (cursor < block.length && depth > 0) {
41382
+ if (block[cursor] !== "#") {
41383
+ cursor++;
41384
+ continue;
41385
+ }
41386
+ const tm = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
41387
+ if (!tm) {
41388
+ cursor++;
41389
+ continue;
41390
+ }
41391
+ const tag = tm[1];
41392
+ if (tag === "if" || tag === "foreach") {
41393
+ depth++;
41394
+ cursor += 1 + tag.length;
41395
+ continue;
41396
+ }
41397
+ if (tag === "end") {
41398
+ depth--;
41399
+ if (depth === 0) {
41400
+ const bodyEnd = cursor;
41401
+ const endIdx = this.skipDirectiveTrailingNewline(block, cursor + 1 + tag.length);
41402
+ const items = this.coerceToIterable(listValue);
41403
+ for (const item of items) {
41404
+ this.scopes.push(new Map([[varName, item]]));
41405
+ try {
41406
+ this.renderBlock(block.slice(bodyStart, bodyEnd));
41407
+ } finally {
41408
+ this.scopes.pop();
41409
+ }
41410
+ }
41411
+ return endIdx;
41412
+ }
41413
+ }
41414
+ cursor += 1 + tag.length;
41415
+ }
41416
+ throw new VtlEvaluationError("#foreach without matching #end");
41417
+ }
41418
+ /** Convert a value into an iterable sequence for `#foreach`. */
41419
+ coerceToIterable(value) {
41420
+ if (Array.isArray(value)) return value;
41421
+ if (value && typeof value === "object") return Object.values(value);
41422
+ if (value == null) return [];
41423
+ return [value];
41424
+ }
41425
+ /**
41426
+ * Skip whitespace and a single trailing newline immediately after a
41427
+ * directive — matches Velocity's "directive eats its own newline"
41428
+ * convention. Without this rule, every `#set(...)` line in a template
41429
+ * would leave a blank line in the output.
41430
+ */
41431
+ skipDirectiveTrailingNewline(block, after) {
41432
+ let i = after;
41433
+ while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
41434
+ if (block[i] === "\r") i++;
41435
+ if (block[i] === "\n") i++;
41436
+ return i;
41437
+ }
41438
+ /**
41439
+ * Read `(...)` arguments after a directive name. Returns the inner
41440
+ * string + the index AFTER the closing paren. Handles nested parens
41441
+ * inside string literals / method calls.
41442
+ */
41443
+ readParenArgs(block, after) {
41444
+ let i = after;
41445
+ while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
41446
+ if (block[i] !== "(") throw new VtlEvaluationError(`Expected '(' after directive at offset ${after}`);
41447
+ i++;
41448
+ let depth = 1;
41449
+ const start = i;
41450
+ let inString = null;
41451
+ while (i < block.length && depth > 0) {
41452
+ const c = block[i];
41453
+ if (inString) {
41454
+ if (c === "\\" && i + 1 < block.length) {
41455
+ i += 2;
41456
+ continue;
41457
+ }
41458
+ if (c === inString) inString = null;
41459
+ i++;
41460
+ continue;
41461
+ }
41462
+ if (c === "\"" || c === "'") {
41463
+ inString = c;
41464
+ i++;
41465
+ continue;
41466
+ }
41467
+ if (c === "(") depth++;
41468
+ else if (c === ")") depth--;
41469
+ if (depth === 0) break;
41470
+ i++;
41471
+ }
41472
+ if (depth !== 0) throw new VtlEvaluationError(`Unterminated parenthesised argument at offset ${after}`);
41473
+ return {
41474
+ args: block.slice(start, i),
41475
+ end: i + 1
41476
+ };
41477
+ }
41478
+ /**
41479
+ * Handle a `$var` / `${var}` / `$obj.field.method(args)` reference.
41480
+ * Returns the number of characters consumed (0 if not a reference —
41481
+ * caller emits the literal `$`).
41482
+ */
41483
+ handleVariable(block, start) {
41484
+ const m = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)/.exec(block.slice(start));
41485
+ if (!m) return 0;
41486
+ const ref = m[1];
41487
+ const refStr = ref.startsWith("{") ? ref.slice(1, -1) : ref;
41488
+ let consumed = m[0].length;
41489
+ let value = this.resolveReference(refStr);
41490
+ let pos = start + consumed;
41491
+ while (pos < block.length) {
41492
+ if (block[pos] === "(") {
41493
+ const { args, end } = this.readParenArgs(block, pos);
41494
+ value = this.callValueAsMethod(value, args, refStr);
41495
+ consumed = end - start;
41496
+ pos = end;
41497
+ if (pos < block.length && block[pos] === ".") {
41498
+ const tailMatch = /^\.([a-zA-Z_][a-zA-Z_0-9]*)/.exec(block.slice(pos));
41499
+ if (tailMatch) {
41500
+ const field = tailMatch[1];
41501
+ value = lookupField(value, field);
41502
+ consumed += tailMatch[0].length;
41503
+ pos += tailMatch[0].length;
41504
+ continue;
41505
+ }
41506
+ }
41507
+ break;
41508
+ }
41509
+ break;
41510
+ }
41511
+ this.output.push(this.stringifyForOutput(value));
41512
+ return consumed;
41513
+ }
41514
+ /**
41515
+ * Resolve a dotted reference path against context + scopes. The first
41516
+ * segment is matched against built-in roots (`input` / `context` / `util`
41517
+ * / `inputRoot`) and the scope chain in order.
41518
+ */
41519
+ resolveReference(path) {
41520
+ const parts = path.split(".");
41521
+ const first = parts[0];
41522
+ const rest = parts.slice(1);
41523
+ let base;
41524
+ if (first === "input") base = this.ctx.input;
41525
+ else if (first === "context") base = this.ctx.context;
41526
+ else if (first === "util") base = this.ctx.util;
41527
+ else if (first === "inputRoot") base = this.ctx.inputRoot;
41528
+ else {
41529
+ let found = false;
41530
+ for (let i = this.scopes.length - 1; i >= 0; i--) {
41531
+ const scope = this.scopes[i];
41532
+ if (scope.has(first)) {
41533
+ base = scope.get(first);
41534
+ found = true;
41535
+ break;
41536
+ }
41537
+ }
41538
+ if (!found) return null;
41539
+ }
41540
+ return rest.reduce((acc, seg) => lookupField(acc, seg), base);
41541
+ }
41542
+ /**
41543
+ * Invoke a value as a method — used after a `$ref(args)` shape. The
41544
+ * value must be a function or a special-cased built-in.
41545
+ */
41546
+ callValueAsMethod(value, argsRaw, refPath) {
41547
+ if (typeof value !== "function") throw new VtlEvaluationError(`Reference '$${refPath}' is not callable (got ${typeof value}). cdkd supports calling $input / $util / $context method-style references only.`);
41548
+ return value(...this.parseArgList(argsRaw));
41549
+ }
41550
+ /**
41551
+ * Parse a comma-separated argument list — recursively evaluates each
41552
+ * expression. Handles string literals, numbers, booleans, and nested
41553
+ * `$var` refs.
41554
+ */
41555
+ parseArgList(raw) {
41556
+ const trimmed = raw.trim();
41557
+ if (trimmed.length === 0) return [];
41558
+ const parts = [];
41559
+ let depth = 0;
41560
+ let inString = null;
41561
+ let start = 0;
41562
+ for (let i = 0; i < trimmed.length; i++) {
41563
+ const c = trimmed[i];
41564
+ if (inString) {
41565
+ if (c === "\\" && i + 1 < trimmed.length) {
41566
+ i++;
41567
+ continue;
41568
+ }
41569
+ if (c === inString) inString = null;
41570
+ continue;
41571
+ }
41572
+ if (c === "\"" || c === "'") {
41573
+ inString = c;
41574
+ continue;
41575
+ }
41576
+ if (c === "(" || c === "[") depth++;
41577
+ else if (c === ")" || c === "]") depth--;
41578
+ else if (c === "," && depth === 0) {
41579
+ parts.push(trimmed.slice(start, i));
41580
+ start = i + 1;
41581
+ }
41582
+ }
41583
+ parts.push(trimmed.slice(start));
41584
+ return parts.map((p) => this.evaluateExpression(p.trim()));
41585
+ }
41586
+ /**
41587
+ * Evaluate a sub-expression (string literal / number / boolean / null /
41588
+ * `$ref` / `$ref.field`). Tiny grammar — no arithmetic operators.
41589
+ */
41590
+ evaluateExpression(expr) {
41591
+ const trimmed = expr.trim();
41592
+ if (trimmed.length === 0) return null;
41593
+ if (trimmed === "true") return true;
41594
+ if (trimmed === "false") return false;
41595
+ if (trimmed === "null") return null;
41596
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return this.unescapeStringLiteral(trimmed.slice(1, -1));
41597
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
41598
+ if (trimmed.startsWith("$")) {
41599
+ const refMatch = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)$/.exec(trimmed);
41600
+ if (refMatch) {
41601
+ const refStr = refMatch[1];
41602
+ const refPath = refStr.startsWith("{") ? refStr.slice(1, -1) : refStr;
41603
+ return this.resolveReference(refPath);
41604
+ }
41605
+ const callMatch = /^\$([a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)\((.*)\)$/.exec(trimmed);
41606
+ if (callMatch) {
41607
+ const refPath = callMatch[1];
41608
+ const argsRaw = callMatch[2];
41609
+ const value = this.resolveReference(refPath);
41610
+ return this.callValueAsMethod(value, argsRaw, refPath);
41611
+ }
41612
+ }
41613
+ throw new VtlEvaluationError(`Could not evaluate VTL sub-expression: '${trimmed}'`);
41614
+ }
41615
+ unescapeStringLiteral(s) {
41616
+ return s.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\\\/g, "\\").replace(/\\"/g, "\"").replace(/\\'/g, "'");
41617
+ }
41618
+ /**
41619
+ * Evaluate a `#if` / `#elseif` condition expression. Supports `&&`,
41620
+ * `||`, `!`, comparison ops, and bare value tests (truthy/falsy).
41621
+ */
41622
+ evaluateCondition(expr) {
41623
+ const trimmed = expr.trim();
41624
+ const orParts = splitTopLevel(trimmed, "||");
41625
+ if (orParts.length > 1) return orParts.some((p) => this.evaluateCondition(p));
41626
+ const andParts = splitTopLevel(trimmed, "&&");
41627
+ if (andParts.length > 1) return andParts.every((p) => this.evaluateCondition(p));
41628
+ if (trimmed.startsWith("!")) return !this.evaluateCondition(trimmed.slice(1).trim());
41629
+ if (trimmed.startsWith("(") && trimmed.endsWith(")")) return this.evaluateCondition(trimmed.slice(1, -1));
41630
+ for (const op of [
41631
+ "==",
41632
+ "!=",
41633
+ "<=",
41634
+ ">=",
41635
+ "<",
41636
+ ">"
41637
+ ]) {
41638
+ const parts = splitTopLevel(trimmed, op);
41639
+ if (parts.length === 2) return compareValues(this.evaluateExpression(parts[0]), this.evaluateExpression(parts[1]), op);
41640
+ }
41641
+ return isTruthy(this.evaluateExpression(trimmed));
41642
+ }
41643
+ /**
41644
+ * Convert a value to its template output form. Mirrors Velocity's
41645
+ * `toString` convention: `null` → empty string; objects → JSON; numbers
41646
+ * / booleans → standard.
41647
+ */
41648
+ stringifyForOutput(value) {
41649
+ if (value === null || value === void 0) return "";
41650
+ if (typeof value === "string") return value;
41651
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
41652
+ return JSON.stringify(value);
41653
+ }
41654
+ };
41655
+ /**
41656
+ * Look up `field` on `obj`. Returns `null` for missing fields (Velocity
41657
+ * silent-undefined convention).
41658
+ */
41659
+ function lookupField(obj, field) {
41660
+ if (obj == null) return null;
41661
+ if (typeof obj === "object") {
41662
+ const rec = obj;
41663
+ if (Object.prototype.hasOwnProperty.call(rec, field)) return rec[field];
41664
+ return null;
41665
+ }
41666
+ return null;
41667
+ }
41668
+ function splitTopLevel(s, sep) {
41669
+ const out = [];
41670
+ let depth = 0;
41671
+ let inString = null;
41672
+ let start = 0;
41673
+ for (let i = 0; i < s.length; i++) {
41674
+ const c = s[i];
41675
+ if (inString) {
41676
+ if (c === "\\" && i + 1 < s.length) {
41677
+ i++;
41678
+ continue;
41679
+ }
41680
+ if (c === inString) inString = null;
41681
+ continue;
41682
+ }
41683
+ if (c === "\"" || c === "'") {
41684
+ inString = c;
41685
+ continue;
41686
+ }
41687
+ if (c === "(" || c === "[") depth++;
41688
+ else if (c === ")" || c === "]") depth--;
41689
+ else if (depth === 0 && s.startsWith(sep, i)) {
41690
+ out.push(s.slice(start, i));
41691
+ start = i + sep.length;
41692
+ i += sep.length - 1;
41693
+ }
41694
+ }
41695
+ out.push(s.slice(start));
41696
+ return out;
41697
+ }
41698
+ function compareValues(lhs, rhs, op) {
41699
+ if (op === "==") return looseEqual(lhs, rhs);
41700
+ if (op === "!=") return !looseEqual(lhs, rhs);
41701
+ const a = typeof lhs === "number" ? lhs : Number(lhs);
41702
+ const b = typeof rhs === "number" ? rhs : Number(rhs);
41703
+ if (Number.isFinite(a) && Number.isFinite(b)) switch (op) {
41704
+ case "<": return a < b;
41705
+ case "<=": return a <= b;
41706
+ case ">": return a > b;
41707
+ case ">=": return a >= b;
41708
+ }
41709
+ const sa = String(lhs);
41710
+ const sb = String(rhs);
41711
+ switch (op) {
41712
+ case "<": return sa < sb;
41713
+ case "<=": return sa <= sb;
41714
+ case ">": return sa > sb;
41715
+ case ">=": return sa >= sb;
41716
+ }
41717
+ }
41718
+ function looseEqual(a, b) {
41719
+ if (a === b) return true;
41720
+ if (a == null || b == null) return a == null && b == null;
41721
+ if (typeof a === typeof b) {
41722
+ if (typeof a === "object") return JSON.stringify(a) === JSON.stringify(b);
41723
+ return false;
41724
+ }
41725
+ return safeStringify(a) === safeStringify(b);
41726
+ }
41727
+ function safeStringify(v) {
41728
+ if (v == null) return "";
41729
+ if (typeof v === "string") return v;
41730
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
41731
+ try {
41732
+ return JSON.stringify(v);
41733
+ } catch {
41734
+ return "";
41735
+ }
41736
+ }
41737
+ function isTruthy(v) {
41738
+ if (v == null) return false;
41739
+ if (typeof v === "boolean") return v;
41740
+ if (typeof v === "number") return v !== 0;
41741
+ if (typeof v === "string") return v.length > 0;
41742
+ if (Array.isArray(v)) return v.length > 0;
41743
+ if (typeof v === "object") return Object.keys(v).length > 0;
41744
+ return true;
41745
+ }
41746
+ /**
41747
+ * Build a `VtlInput` binding from an HTTP request snapshot + matched
41748
+ * route context. `$input` exposes the body + parameter accessors used by
41749
+ * AWS API Gateway's VTL templates.
41750
+ *
41751
+ * `params()` returns the union of header / querystring / path maps;
41752
+ * `params(name)` resolves first against path, then querystring, then
41753
+ * header (matches AWS-deployed precedence).
41754
+ *
41755
+ * `json(jsonPath)` returns a JSON-stringified slice of the parsed body;
41756
+ * `path(jsonPath)` returns the raw native value (primitives unquoted).
41757
+ *
41758
+ * JSONPath support is minimal: supports `$` (root), `$.field`,
41759
+ * `$.field.subField`, `$.array[0]`. AWS supports more (filter
41760
+ * expressions, recursive descent); cdkd surfaces a clear error on
41761
+ * unsupported expressions rather than silently producing wrong output.
41762
+ */
41763
+ function buildVtlInput(body, headers, querystring, pathParams) {
41764
+ let jsonBodyCache;
41765
+ let jsonBodyParsed = false;
41766
+ function lazyJson() {
41767
+ if (!jsonBodyParsed) {
41768
+ jsonBodyParsed = true;
41769
+ try {
41770
+ jsonBodyCache = body.length === 0 ? null : JSON.parse(body);
41771
+ } catch {
41772
+ jsonBodyCache = null;
41773
+ }
41774
+ }
41775
+ return jsonBodyCache;
41776
+ }
41777
+ function jsonFn(...args) {
41778
+ const expr = args.length > 0 ? String(args[0]) : "$";
41779
+ const val = applyJsonPath(lazyJson(), expr);
41780
+ return JSON.stringify(val ?? null);
41781
+ }
41782
+ function pathFn(...args) {
41783
+ const expr = args.length > 0 ? String(args[0]) : "$";
41784
+ return applyJsonPath(lazyJson(), expr);
41785
+ }
41786
+ function paramsFn(...args) {
41787
+ if (args.length === 0) return {
41788
+ header: headers,
41789
+ querystring,
41790
+ path: pathParams
41791
+ };
41792
+ const arg = String(args[0]);
41793
+ if (arg === "header") return headers;
41794
+ if (arg === "querystring") return querystring;
41795
+ if (arg === "path") return pathParams;
41796
+ if (Object.prototype.hasOwnProperty.call(pathParams, arg)) return pathParams[arg];
41797
+ if (Object.prototype.hasOwnProperty.call(querystring, arg)) return querystring[arg];
41798
+ if (Object.prototype.hasOwnProperty.call(headers, arg)) return headers[arg];
41799
+ const lowerArg = arg.toLowerCase();
41800
+ for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === lowerArg) return v;
41801
+ return null;
41802
+ }
41803
+ return {
41804
+ body,
41805
+ get jsonBody() {
41806
+ return lazyJson();
41807
+ },
41808
+ headers,
41809
+ querystring,
41810
+ path: pathParams,
41811
+ json: jsonFn,
41812
+ path: pathFn,
41813
+ params: paramsFn
41814
+ };
41815
+ }
41816
+ /**
41817
+ * Minimal JSONPath evaluator. Supports `$`, `$.field`, `$.field.sub`,
41818
+ * `$.array[index]`. Unsupported syntax throws so the user sees a clear
41819
+ * pointer to the gap.
41820
+ */
41821
+ function applyJsonPath(root, expr) {
41822
+ const trimmed = expr.trim();
41823
+ if (trimmed === "$" || trimmed.length === 0) return root;
41824
+ if (!trimmed.startsWith("$")) throw new VtlEvaluationError(`JSONPath must start with '$': got '${trimmed}'`);
41825
+ let cursor = root;
41826
+ let i = 1;
41827
+ while (i < trimmed.length) {
41828
+ const c = trimmed[i];
41829
+ if (c === ".") {
41830
+ i++;
41831
+ const m = /^[a-zA-Z_][a-zA-Z_0-9]*/.exec(trimmed.slice(i));
41832
+ if (!m) throw new VtlEvaluationError(`Unsupported JSONPath syntax at position ${i}: '${trimmed}' (cdkd supports $, $.field, $.field.sub, $.array[index] only).`);
41833
+ cursor = lookupField(cursor, m[0]);
41834
+ i += m[0].length;
41835
+ continue;
41836
+ }
41837
+ if (c === "[") {
41838
+ const close = trimmed.indexOf("]", i);
41839
+ if (close === -1) throw new VtlEvaluationError(`Unterminated [ in JSONPath: '${trimmed}'`);
41840
+ const inside = trimmed.slice(i + 1, close).trim();
41841
+ if (/^-?\d+$/.test(inside)) {
41842
+ const idx = Number(inside);
41843
+ if (Array.isArray(cursor)) cursor = cursor[idx];
41844
+ else cursor = null;
41845
+ } else if (inside.startsWith("\"") && inside.endsWith("\"") || inside.startsWith("'") && inside.endsWith("'")) cursor = lookupField(cursor, inside.slice(1, -1));
41846
+ else throw new VtlEvaluationError(`Unsupported JSONPath bracket expression: '${inside}' (cdkd supports integer indices and quoted string keys only).`);
41847
+ i = close + 1;
41848
+ continue;
41849
+ }
41850
+ throw new VtlEvaluationError(`Unexpected character in JSONPath at position ${i}: '${trimmed}'`);
41851
+ }
41852
+ return cursor;
41853
+ }
41854
+ /**
41855
+ * Build a `$context` binding from a request snapshot + matched route.
41856
+ * The mapping mirrors what AWS API Gateway exposes (see AWS docs:
41857
+ * https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html).
41858
+ */
41859
+ function buildVtlRequestContext(args) {
41860
+ return {
41861
+ requestId: args.requestId,
41862
+ httpMethod: args.httpMethod,
41863
+ resourcePath: args.resourcePath,
41864
+ stage: args.stage,
41865
+ identity: {
41866
+ sourceIp: args.sourceIp,
41867
+ userAgent: args.userAgent
41868
+ }
41869
+ };
41870
+ }
41871
+
41872
+ //#endregion
41873
+ //#region src/local/integration-response-selector.ts
41874
+ /**
41875
+ * Pick the right `IntegrationResponses[]` entry for the given outcome.
41876
+ *
41877
+ * Per AWS docs, `SelectionPattern` is matched against the backend
41878
+ * outcome regardless of whether the backend returned success or error —
41879
+ * a `SelectionPattern: '200'` entry IS expected to match an HTTP 200
41880
+ * upstream response. cdkd ALWAYS runs the regex loop first and only
41881
+ * falls to the default entry when no pattern matches; pre-#505-review
41882
+ * the success branch short-circuited to the default entry without
41883
+ * running the regex loop, which silently dropped success-side selection.
41884
+ *
41885
+ * @param entries - The `IntegrationResponses[]` array from the template
41886
+ * (already extracted from the route's Integration property).
41887
+ * @param matchTarget - The string AWS would match `SelectionPattern`
41888
+ * against. For HTTP / HTTP_PROXY this is `String(upstream.status)`;
41889
+ * for Lambda this is the `errorMessage` field on the parsed payload,
41890
+ * or the sentinel `'success'` when the payload has no `errorMessage`.
41891
+ * For MOCK this is unused (MOCK dispatch picks by `StatusCode`).
41892
+ * @param fallbackStatusCode - Status code to use when `entries` is empty
41893
+ * or no entry matches AND no default entry exists. HTTP / HTTP_PROXY
41894
+ * pass the upstream status; Lambda passes 200 on success / 500 on
41895
+ * error.
41896
+ */
41897
+ function selectIntegrationResponse(entries, matchTarget, fallbackStatusCode = 200) {
41898
+ if (!entries || entries.length === 0) return {
41899
+ entry: null,
41900
+ statusCode: fallbackStatusCode
41901
+ };
41902
+ const defaultEntry = entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "");
41903
+ for (const entry of entries) {
41904
+ if (entry.SelectionPattern === void 0 || entry.SelectionPattern === "") continue;
41905
+ try {
41906
+ if (new RegExp(`^${entry.SelectionPattern}$`).test(matchTarget)) return {
41907
+ entry,
41908
+ statusCode: parseStatus$1(entry.StatusCode, fallbackStatusCode)
41909
+ };
41910
+ } catch {}
41911
+ }
41912
+ const entry = defaultEntry ?? null;
41913
+ return {
41914
+ entry,
41915
+ statusCode: entry !== null ? parseStatus$1(entry.StatusCode, fallbackStatusCode) : fallbackStatusCode
41916
+ };
41917
+ }
41918
+ function parseStatus$1(raw, fallback) {
41919
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
41920
+ if (typeof raw === "string") {
41921
+ const parsed = Number.parseInt(raw, 10);
41922
+ if (Number.isFinite(parsed)) return parsed;
41923
+ }
41924
+ return fallback;
41925
+ }
41926
+ /**
41927
+ * Evaluate `IntegrationResponse.ResponseParameters` — header literals
41928
+ * mapped onto the HTTP response. Returns `{name: value}` for every entry
41929
+ * we could resolve; unresolvable entries (non-literal / mapping
41930
+ * expression) get a warning via `onUnsupported` and are skipped.
41931
+ *
41932
+ * AWS format: keys are `method.response.header.<HeaderName>`; values
41933
+ * are `'literal'` (with single quotes) or mapping expressions
41934
+ * (`integration.response.body.X` / `integration.response.header.X` /
41935
+ * `context.X`). cdkd v1 supports the literal form only.
41936
+ */
41937
+ function evaluateResponseParameters(responseParameters, opts = {}) {
41938
+ if (!responseParameters) return {};
41939
+ const out = {};
41940
+ for (const [key, value] of Object.entries(responseParameters)) {
41941
+ const headerMatch = /^method\.response\.header\.(.+)$/.exec(key);
41942
+ if (!headerMatch) {
41943
+ opts.onUnsupported?.(key, value, `Only method.response.header.<name> keys are supported on REST v1 ResponseParameters; cdkd cannot map ${key}.`);
41944
+ continue;
41945
+ }
41946
+ const headerName = headerMatch[1];
41947
+ if (typeof value !== "string") {
41948
+ opts.onUnsupported?.(key, String(value), `non-string ResponseParameter value`);
41949
+ continue;
41950
+ }
41951
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
41952
+ out[headerName] = value.slice(1, -1);
41953
+ continue;
41954
+ }
41955
+ opts.onUnsupported?.(key, value, `ResponseParameter value '${value}' is a mapping expression (integration.response.* / context.*) which cdkd local start-api does not emulate. Only single-quoted literals are honored.`);
41956
+ }
41957
+ return out;
41958
+ }
41959
+ /**
41960
+ * Pick the response template AWS would render for the given Accept
41961
+ * header. AWS uses content negotiation; cdkd picks `application/json`
41962
+ * first, then any other entry. Returns `undefined` when no template is
41963
+ * configured (caller emits the backend body verbatim).
41964
+ *
41965
+ * The chosen template's content-type is also returned so the dispatcher
41966
+ * can emit a matching `Content-Type` header (matches AWS-deployed
41967
+ * behavior).
41968
+ */
41969
+ function pickResponseTemplate(responseTemplates, accept) {
41970
+ if (!responseTemplates) return void 0;
41971
+ const entries = Object.entries(responseTemplates);
41972
+ if (entries.length === 0) return void 0;
41973
+ if (accept) {
41974
+ const acceptTypes = accept.split(",").map((s) => s.split(";")[0].trim()).filter(Boolean);
41975
+ for (const acceptType of acceptTypes) for (const [ct, template] of entries) if (ct === acceptType) return {
41976
+ template,
41977
+ contentType: ct
41978
+ };
41979
+ }
41980
+ const jsonEntry = responseTemplates["application/json"];
41981
+ if (jsonEntry !== void 0) return {
41982
+ template: jsonEntry,
41983
+ contentType: "application/json"
41984
+ };
41985
+ const first = entries[0];
41986
+ return {
41987
+ template: first[1],
41988
+ contentType: first[0]
41989
+ };
41990
+ }
41991
+
41992
+ //#endregion
41993
+ //#region src/local/rest-v1-integrations.ts
41994
+ /**
41995
+ * Dispatch a MOCK integration. AWS MOCK semantics:
41996
+ *
41997
+ * 1. Render `RequestTemplates['application/json']` (VTL) against the
41998
+ * request — yields a JSON object like `{"statusCode": 200}`.
41999
+ * 2. Parse the rendered JSON; pick the `IntegrationResponses[]` entry
42000
+ * whose `StatusCode` equals the parsed `statusCode` (string compare,
42001
+ * mirroring AWS).
42002
+ * 3. Render the picked entry's `ResponseTemplates[<content-type>]`
42003
+ * against an empty body context and emit it.
42004
+ * 4. Apply `ResponseParameters` header literals.
42005
+ *
42006
+ * When no request template is configured AWS defaults to picking the
42007
+ * `IntegrationResponses[]` entry with `SelectionPattern === ''` (or the
42008
+ * first entry).
42009
+ */
42010
+ function dispatchMockIntegration(config, req) {
42011
+ const logger = getLogger().child("start-api");
42012
+ const ctx = buildVtlContextFromRequest(req, "");
42013
+ let pickedStatus;
42014
+ if (config.requestTemplate !== void 0 && config.requestTemplate.trim().length > 0) try {
42015
+ pickedStatus = extractStatusCodeFromRendered(evaluateVtl(config.requestTemplate, ctx));
42016
+ } catch (err) {
42017
+ return vtlFailure("request", err, config.requestTemplate);
42018
+ }
42019
+ let entry = null;
42020
+ if (pickedStatus !== void 0) entry = config.responses.find((e) => parseStatus(e.StatusCode) === pickedStatus) ?? defaultResponseEntry(config.responses);
42021
+ else entry = defaultResponseEntry(config.responses);
42022
+ if (!entry) return {
42023
+ statusCode: pickedStatus ?? 200,
42024
+ headers: { "content-type": "application/json" },
42025
+ body: ""
42026
+ };
42027
+ const accept = req.headers["accept"];
42028
+ const picked = pickResponseTemplate(entry.ResponseTemplates, accept);
42029
+ const respCtx = buildVtlContextFromRequest(req, "", null);
42030
+ let body = "";
42031
+ let contentType = "application/json";
42032
+ if (picked) {
42033
+ try {
42034
+ body = evaluateVtl(picked.template, respCtx);
42035
+ } catch (err) {
42036
+ return vtlFailure("response", err, picked.template);
42037
+ }
42038
+ contentType = picked.contentType;
42039
+ }
42040
+ const headers = { "content-type": contentType };
42041
+ Object.assign(headers, evaluateResponseParameters(entry.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`MOCK response: ${reason}`) }));
42042
+ return {
42043
+ statusCode: parseStatus(entry.StatusCode) ?? 200,
42044
+ headers,
42045
+ body
42046
+ };
42047
+ }
42048
+ /**
42049
+ * Dispatch an HTTP_PROXY integration. The request is forwarded verbatim
42050
+ * with `RequestParameters` mappings applied; the response is also
42051
+ * forwarded verbatim (AWS does NOT apply ResponseTemplates on HTTP_PROXY,
42052
+ * only IntegrationResponses[].SelectionPattern routes the status code).
42053
+ */
42054
+ async function dispatchHttpProxyIntegration(config, req, deps) {
42055
+ const url = substituteUriPlaceholders(config.uri, req);
42056
+ const method = config.integrationHttpMethod ?? req.method;
42057
+ const outHeaders = { ...req.headers };
42058
+ for (const drop of [
42059
+ "host",
42060
+ "connection",
42061
+ "content-length",
42062
+ "transfer-encoding"
42063
+ ]) delete outHeaders[drop];
42064
+ applyRequestParameters(config.requestParameters, req, {
42065
+ headers: outHeaders,
42066
+ urlObj: void 0
42067
+ });
42068
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
42069
+ const fetchInit = {
42070
+ method,
42071
+ headers: outHeaders
42072
+ };
42073
+ if (req.body.length > 0) fetchInit.body = new Uint8Array(req.body);
42074
+ let upstream;
42075
+ try {
42076
+ upstream = await fetchImpl(url, fetchInit);
42077
+ } catch (err) {
42078
+ return {
42079
+ statusCode: 502,
42080
+ headers: { "content-type": "application/json" },
42081
+ body: JSON.stringify({
42082
+ message: "HTTP_PROXY upstream unreachable",
42083
+ url,
42084
+ reason: err instanceof Error ? err.message : String(err)
42085
+ })
42086
+ };
42087
+ }
42088
+ const upstreamBody = Buffer.from(await upstream.arrayBuffer());
42089
+ const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
42090
+ const headers = {};
42091
+ upstream.headers.forEach((value, name) => {
42092
+ headers[name.toLowerCase()] = value;
42093
+ });
42094
+ delete headers["content-encoding"];
42095
+ delete headers["content-length"];
42096
+ const logger = getLogger().child("start-api");
42097
+ Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP_PROXY response: ${reason}`) }));
42098
+ return {
42099
+ statusCode: selected.entry ? selected.statusCode : upstream.status,
42100
+ headers,
42101
+ body: upstreamBody
42102
+ };
42103
+ }
42104
+ /**
42105
+ * Dispatch an HTTP (non-proxy) integration: HTTP_PROXY + VTL on both
42106
+ * directions. Same upstream-call shape; the request body is transformed
42107
+ * via VTL, and the response body is transformed via VTL too.
42108
+ */
42109
+ async function dispatchHttpIntegration(config, req, deps) {
42110
+ const logger = getLogger().child("start-api");
42111
+ const url = substituteUriPlaceholders(config.uri, req);
42112
+ const method = config.integrationHttpMethod ?? req.method;
42113
+ const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
42114
+ const reqTemplate = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
42115
+ let outBody;
42116
+ let outContentType = req.headers["content-type"] ?? "application/json";
42117
+ if (reqTemplate) {
42118
+ try {
42119
+ outBody = evaluateVtl(reqTemplate.template, ctx);
42120
+ } catch (err) {
42121
+ return vtlFailure("request", err, reqTemplate.template);
42122
+ }
42123
+ outContentType = reqTemplate.contentType;
42124
+ } else outBody = req.body.toString("utf-8");
42125
+ const outHeaders = {
42126
+ ...req.headers,
42127
+ "content-type": outContentType
42128
+ };
42129
+ for (const drop of [
42130
+ "host",
42131
+ "connection",
42132
+ "content-length",
42133
+ "transfer-encoding"
42134
+ ]) delete outHeaders[drop];
42135
+ applyRequestParameters(config.requestParameters, req, {
42136
+ headers: outHeaders,
42137
+ urlObj: void 0
42138
+ });
42139
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
42140
+ const fetchInit = {
42141
+ method,
42142
+ headers: outHeaders
42143
+ };
42144
+ if (outBody !== void 0 && outBody.length > 0) fetchInit.body = outBody;
42145
+ let upstream;
42146
+ try {
42147
+ upstream = await fetchImpl(url, fetchInit);
42148
+ } catch (err) {
42149
+ return {
42150
+ statusCode: 502,
42151
+ headers: { "content-type": "application/json" },
42152
+ body: JSON.stringify({
42153
+ message: "HTTP upstream unreachable",
42154
+ url,
42155
+ reason: err instanceof Error ? err.message : String(err)
42156
+ })
42157
+ };
42158
+ }
42159
+ const upstreamContentType = upstream.headers.get("content-type") ?? "application/octet-stream";
42160
+ const isUpstreamTextLike = isTextLikeContentType(upstreamContentType);
42161
+ let upstreamText;
42162
+ let upstreamBinary;
42163
+ if (isUpstreamTextLike) upstreamText = await upstream.text();
42164
+ else upstreamBinary = Buffer.from(await upstream.arrayBuffer());
42165
+ const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
42166
+ let body;
42167
+ let contentType = upstreamContentType;
42168
+ if (selected.entry) {
42169
+ const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
42170
+ if (picked) if (upstreamText === void 0) {
42171
+ logger.warn(`HTTP response: ResponseTemplates set but upstream Content-Type '${upstreamContentType}' is binary; passing body through unchanged.`);
42172
+ body = upstreamBinary;
42173
+ } else {
42174
+ const respCtx = buildVtlContextFromRequest(req, upstreamText, safeJsonParse(upstreamText));
42175
+ try {
42176
+ body = evaluateVtl(picked.template, respCtx);
42177
+ } catch (err) {
42178
+ return vtlFailure("response", err, picked.template);
42179
+ }
42180
+ contentType = picked.contentType;
42181
+ }
42182
+ else body = upstreamText ?? upstreamBinary;
42183
+ } else body = upstreamText ?? upstreamBinary;
42184
+ const headers = { "content-type": contentType };
42185
+ Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP response: ${reason}`) }));
42186
+ return {
42187
+ statusCode: selected.statusCode,
42188
+ headers,
42189
+ body
42190
+ };
42191
+ }
42192
+ /**
42193
+ * Dispatch an AWS (Lambda non-proxy) integration. The request body is
42194
+ * transformed via VTL into the Lambda event; the Lambda is invoked via
42195
+ * RIE; the return value is transformed via ResponseTemplates.
42196
+ *
42197
+ * AWS error routing: when the Lambda returns an object with an
42198
+ * `errorMessage` field (Node Lambda runtime convention), AWS treats it
42199
+ * as an error and matches `SelectionPattern` against the
42200
+ * `errorMessage`. Otherwise success.
42201
+ */
42202
+ async function dispatchAwsLambdaIntegration(config, req, deps) {
42203
+ const logger = getLogger().child("start-api");
42204
+ const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
42205
+ const template = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
42206
+ let eventPayload;
42207
+ if (template) {
42208
+ let rendered;
42209
+ try {
42210
+ rendered = evaluateVtl(template.template, ctx);
42211
+ } catch (err) {
42212
+ return vtlFailure("request", err, template.template);
42213
+ }
42214
+ try {
42215
+ eventPayload = JSON.parse(rendered);
42216
+ } catch {
42217
+ eventPayload = rendered;
42218
+ }
42219
+ } else eventPayload = safeJsonParse(req.body.toString("utf-8")) ?? req.body.toString("utf-8");
42220
+ let handle;
42221
+ try {
42222
+ handle = await deps.pool.acquire(config.lambdaLogicalId);
42223
+ } catch (err) {
42224
+ return {
42225
+ statusCode: 502,
42226
+ headers: { "content-type": "application/json" },
42227
+ body: JSON.stringify({
42228
+ message: "Failed to acquire RIE container for AWS Lambda non-proxy integration",
42229
+ reason: err instanceof Error ? err.message : String(err)
42230
+ })
42231
+ };
42232
+ }
42233
+ let invokeOutcome;
42234
+ try {
42235
+ invokeOutcome = await invokeRie(handle.containerHost, handle.hostPort, eventPayload, deps.rieTimeoutMs);
42236
+ } catch (err) {
42237
+ deps.pool.release(handle);
42238
+ return {
42239
+ statusCode: 502,
42240
+ headers: { "content-type": "application/json" },
42241
+ body: JSON.stringify({
42242
+ message: "AWS Lambda non-proxy invocation failed",
42243
+ reason: err instanceof Error ? err.message : String(err)
42244
+ })
42245
+ };
42246
+ }
42247
+ deps.pool.release(handle);
42248
+ const payload = invokeOutcome.payload;
42249
+ const isError = payload !== null && typeof payload === "object" && "errorMessage" in payload;
42250
+ const matchTarget = isError ? String(payload["errorMessage"]) : "success";
42251
+ const selected = selectIntegrationResponse(config.responses, matchTarget, isError ? 500 : 200);
42252
+ const respCtx = buildVtlContextFromRequest(req, JSON.stringify(payload ?? null), payload);
42253
+ let body = "";
42254
+ let contentType = "application/json";
42255
+ if (selected.entry) {
42256
+ const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
42257
+ if (picked) {
42258
+ try {
42259
+ body = evaluateVtl(picked.template, respCtx);
42260
+ } catch (err) {
42261
+ return vtlFailure("response", err, picked.template);
42262
+ }
42263
+ contentType = picked.contentType;
42264
+ } else body = JSON.stringify(payload ?? null);
42265
+ } else body = JSON.stringify(payload ?? null);
42266
+ const headers = { "content-type": contentType };
42267
+ Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`AWS Lambda non-proxy response: ${reason}`) }));
42268
+ return {
42269
+ statusCode: selected.statusCode,
42270
+ headers,
42271
+ body
42272
+ };
42273
+ }
42274
+ function buildVtlContextFromRequest(req, body, inputRoot) {
42275
+ return {
42276
+ input: buildVtlInput(body, req.headers, req.querystring, req.pathParameters),
42277
+ context: buildVtlRequestContext({
42278
+ requestId: req.requestId,
42279
+ httpMethod: req.method,
42280
+ resourcePath: req.resourcePath,
42281
+ stage: req.stage,
42282
+ sourceIp: req.sourceIp,
42283
+ userAgent: req.userAgent
42284
+ }),
42285
+ util: buildDefaultUtil(),
42286
+ ...inputRoot !== void 0 && { inputRoot }
42287
+ };
42288
+ }
42289
+ function pickRequestTemplate(requestTemplates, contentType) {
42290
+ if (!requestTemplates) return void 0;
42291
+ const entries = Object.entries(requestTemplates);
42292
+ if (entries.length === 0) return void 0;
42293
+ if (contentType) {
42294
+ const primary = contentType.split(";")[0].trim();
42295
+ if (requestTemplates[primary] !== void 0) return {
42296
+ template: requestTemplates[primary],
42297
+ contentType: primary
42298
+ };
42299
+ }
42300
+ if (requestTemplates["application/json"] !== void 0) return {
42301
+ template: requestTemplates["application/json"],
42302
+ contentType: "application/json"
42303
+ };
42304
+ const first = entries[0];
42305
+ return {
42306
+ template: first[1],
42307
+ contentType: first[0]
42308
+ };
42309
+ }
42310
+ /**
42311
+ * Extract `{"statusCode": <N>}` from a rendered MOCK request template.
42312
+ * AWS uses this single key to drive `IntegrationResponses[]` selection.
42313
+ */
42314
+ function extractStatusCodeFromRendered(rendered) {
42315
+ try {
42316
+ const parsed = JSON.parse(rendered);
42317
+ if (parsed && typeof parsed === "object" && "statusCode" in parsed) {
42318
+ const val = parsed["statusCode"];
42319
+ if (typeof val === "number") return val;
42320
+ if (typeof val === "string") {
42321
+ const n = Number.parseInt(val, 10);
42322
+ if (Number.isFinite(n)) return n;
42323
+ }
42324
+ }
42325
+ } catch {}
42326
+ }
42327
+ function defaultResponseEntry(entries) {
42328
+ return entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "") ?? null;
42329
+ }
42330
+ function parseStatus(raw) {
42331
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
42332
+ if (typeof raw === "string") {
42333
+ const n = Number.parseInt(raw, 10);
42334
+ if (Number.isFinite(n)) return n;
42335
+ }
42336
+ }
42337
+ /**
42338
+ * Heuristic: is the given HTTP `Content-Type` header value likely to
42339
+ * carry text content that VTL ResponseTemplates can safely render
42340
+ * against? Used by `dispatchHttpIntegration` to branch the upstream
42341
+ * body read between `.text()` (text-like) and `.arrayBuffer()` (binary
42342
+ * pass-through). Charset parameters are stripped before matching.
42343
+ *
42344
+ * Exported for unit testing.
42345
+ */
42346
+ function isTextLikeContentType(contentType) {
42347
+ const primary = contentType.split(";")[0].trim().toLowerCase();
42348
+ if (primary.startsWith("text/")) return true;
42349
+ if (primary === "application/json" || primary === "application/xml" || primary === "application/x-www-form-urlencoded" || primary === "application/javascript" || primary === "application/ld+json") return true;
42350
+ if (primary.startsWith("application/") && (primary.endsWith("+json") || primary.endsWith("+xml"))) return true;
42351
+ return false;
42352
+ }
42353
+ /**
42354
+ * Classify a hostname or IP literal against well-known internal address
42355
+ * spaces. Used by `warnSsrfRiskyUri` at server boot to surface a warn
42356
+ * line per HTTP / HTTP_PROXY integration whose URI points at a
42357
+ * potentially-sensitive destination. Best-effort; does NOT do DNS
42358
+ * resolution — only matches hostname literals that are already an IP.
42359
+ *
42360
+ * Returns `undefined` when the host appears safe (public DNS name) OR
42361
+ * cannot be classified (DNS name that may resolve to an internal IP
42362
+ * the helper cannot see without async DNS).
42363
+ *
42364
+ * Exported for unit testing.
42365
+ */
42366
+ function classifyInternalHost(host) {
42367
+ const h = host.replace(/^\[|\]$/g, "");
42368
+ if (h === "169.254.169.254" || h === "[fd00:ec2::254]" || h === "fd00:ec2::254") return "AWS IMDS (169.254.169.254) — credentials exfiltration risk";
42369
+ if (/^127\.\d+\.\d+\.\d+$/.test(h)) return "IPv4 loopback (127.0.0.0/8)";
42370
+ if (h === "::1") return "IPv6 loopback (::1)";
42371
+ if (/^169\.254\.\d+\.\d+$/.test(h)) return "IPv4 link-local (169.254.0.0/16)";
42372
+ if (/^fe[89ab][0-9a-f]?:/i.test(h)) return "IPv6 link-local (fe80::/10)";
42373
+ if (/^10\.\d+\.\d+\.\d+$/.test(h)) return "RFC1918 private (10.0.0.0/8)";
42374
+ if (/^192\.168\.\d+\.\d+$/.test(h)) return "RFC1918 private (192.168.0.0/16)";
42375
+ const m = /^172\.(\d+)\.\d+\.\d+$/.exec(h);
42376
+ if (m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return "RFC1918 private (172.16.0.0/12)";
42377
+ }
42378
+ /**
42379
+ * Emit a `logger.warn` line for each HTTP / HTTP_PROXY integration
42380
+ * whose `Integration.Uri` parses to a hostname classified as internal
42381
+ * by `classifyInternalHost`. Called once at server boot from
42382
+ * `cdkd local start-api`'s discovery pass; per-route deduplicated.
42383
+ *
42384
+ * cdkd does NOT block the URI — this is a developer-loop tool, not a
42385
+ * security boundary, and warn-and-proceed matches the precedent set by
42386
+ * the cognito JWKS pass-through fallback. The right v2 follow-up is an
42387
+ * `--allow-internal-uri` flag (and an opposite default block) once the
42388
+ * surface is well-understood.
42389
+ */
42390
+ function warnSsrfRiskyUri(uri, routeLabel, warn) {
42391
+ let host;
42392
+ try {
42393
+ const sanitized = uri.replace(/\{[^/{}]+\}/g, "x");
42394
+ host = new URL(sanitized).hostname;
42395
+ } catch {
42396
+ return;
42397
+ }
42398
+ const classification = classifyInternalHost(host);
42399
+ if (classification !== void 0) warn(`Integration URI for ${routeLabel} points at ${host} — ${classification}. cdkd does NOT block this; ensure the upstream is intentional.`);
42400
+ }
42401
+ function safeJsonParse(s) {
42402
+ try {
42403
+ return JSON.parse(s);
42404
+ } catch {
42405
+ return null;
42406
+ }
42407
+ }
42408
+ /**
42409
+ * Apply `Integration.RequestParameters` mappings — header / query / path
42410
+ * rewrites that copy from `method.request.X` to `integration.request.Y`.
42411
+ *
42412
+ * Supported key shapes:
42413
+ * - `integration.request.header.<name>` → outgoing header
42414
+ * - `integration.request.querystring.<name>` → query string param
42415
+ * - `integration.request.path.<name>` → path placeholder substitution
42416
+ *
42417
+ * Supported value shapes:
42418
+ * - `method.request.header.<name>` → read incoming header
42419
+ * - `method.request.querystring.<name>` → read incoming query param
42420
+ * - `method.request.path.<name>` → read path parameter
42421
+ * - `'literal'` → single-quoted literal
42422
+ *
42423
+ * Unsupported mapping expressions are logged at warn and skipped (matches
42424
+ * the ResponseParameters handling in `integration-response-selector.ts`).
42425
+ */
42426
+ function applyRequestParameters(requestParameters, req, out) {
42427
+ if (!requestParameters) return;
42428
+ const logger = getLogger().child("start-api");
42429
+ for (const [key, value] of Object.entries(requestParameters)) {
42430
+ const resolved = resolveRequestParameterValue(value, req);
42431
+ if (resolved === void 0) {
42432
+ logger.warn(`RequestParameter '${key}' value '${value}' is not a recognized mapping; skipping.`);
42433
+ continue;
42434
+ }
42435
+ const headerMatch = /^integration\.request\.header\.(.+)$/.exec(key);
42436
+ const queryMatch = /^integration\.request\.querystring\.(.+)$/.exec(key);
42437
+ const pathMatch = /^integration\.request\.path\.(.+)$/.exec(key);
42438
+ if (headerMatch) out.headers[headerMatch[1].toLowerCase()] = resolved;
42439
+ else if (queryMatch) logger.warn(`RequestParameter '${key}' (querystring rewrite) is recognized but cdkd applies querystring rewrites only via URI placeholder substitution; ignoring.`);
42440
+ else if (pathMatch) logger.warn(`RequestParameter '${key}' (path rewrite) is recognized but cdkd substitutes path placeholders via {param} in the URI; ignoring.`);
42441
+ else logger.warn(`Unsupported RequestParameter key '${key}'; skipping.`);
42442
+ }
42443
+ }
42444
+ function resolveRequestParameterValue(raw, req) {
42445
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
42446
+ const headerMatch = /^method\.request\.header\.(.+)$/.exec(raw);
42447
+ if (headerMatch) return req.headers[headerMatch[1].toLowerCase()];
42448
+ const queryMatch = /^method\.request\.querystring\.(.+)$/.exec(raw);
42449
+ if (queryMatch) return req.querystring[queryMatch[1]];
42450
+ const pathMatch = /^method\.request\.path\.(.+)$/.exec(raw);
42451
+ if (pathMatch) return req.pathParameters[pathMatch[1]];
42452
+ }
42453
+ /**
42454
+ * Substitute `{paramName}` placeholders in a URI string with the value
42455
+ * of the matching path parameter on the request. Used by HTTP_PROXY /
42456
+ * HTTP integrations whose `Integration.Uri` may contain such
42457
+ * placeholders (e.g. `https://upstream.example.com/users/{userId}`).
42458
+ */
42459
+ function substituteUriPlaceholders(uri, req) {
42460
+ return uri.replace(/\{([^/{}]+)\}/g, (_, name) => {
42461
+ const val = req.pathParameters[name];
42462
+ return val !== void 0 ? encodeURIComponent(val) : "";
42463
+ });
42464
+ }
42465
+ function vtlFailure(direction, err, template) {
42466
+ const reason = err instanceof VtlEvaluationError ? err.message : err instanceof Error ? err.message : String(err);
42467
+ return {
42468
+ statusCode: 502,
42469
+ headers: { "content-type": "application/json" },
42470
+ body: JSON.stringify({
42471
+ message: `VTL ${direction}-template evaluation failed`,
42472
+ reason,
42473
+ template: template.length > 200 ? template.slice(0, 200) + "..." : template
42474
+ })
42475
+ };
42476
+ }
42477
+
40893
42478
  //#endregion
40894
42479
  //#region src/local/container-pool.ts
40895
42480
  const DEFAULT_IDLE_MS = 6e4;
@@ -43870,6 +45455,16 @@ async function handleRequest(req, res, state, opts) {
43870
45455
  const overlay = buildOverlay(authorizer, authResult);
43871
45456
  if (overlay) baseEvent = applyAuthorizerOverlay(baseEvent, overlay);
43872
45457
  }
45458
+ if (match.route.restV1Integration) {
45459
+ try {
45460
+ writeIntegrationOutcome(res, await dispatchRestV1Integration(match.route.restV1Integration, snapshot, matchCtx, state, opts));
45461
+ } catch (err) {
45462
+ logger.error(`REST v1 ${match.route.restV1Integration.kind} dispatch failed for ${match.route.declaredAt}: ${err instanceof Error ? err.message : String(err)}`);
45463
+ if (!res.headersSent) writeError(res, 502);
45464
+ else res.end();
45465
+ }
45466
+ return;
45467
+ }
43873
45468
  let handle;
43874
45469
  try {
43875
45470
  handle = await state.pool.acquire(match.route.lambdaLogicalId);
@@ -43949,6 +45544,50 @@ function writeStreamingResponse(res, result, releasePool) {
43949
45544
  body.pipe(res);
43950
45545
  }
43951
45546
  /**
45547
+ * Dispatch a REST v1 non-AWS_PROXY integration to the matching handler.
45548
+ * Built once per request from the matched route + request snapshot.
45549
+ *
45550
+ * Returns a {@link RestV1IntegrationOutcome} — the caller writes it
45551
+ * onto the `ServerResponse` via {@link writeIntegrationOutcome}.
45552
+ */
45553
+ async function dispatchRestV1Integration(integration, snapshot, matchCtx, state, opts) {
45554
+ const headers = lowercaseSingularHeaders(snapshot.headers);
45555
+ const querystring = parseQueryStringSingular(snapshot.rawUrl);
45556
+ const sourceIp = snapshot.sourceIp ?? "127.0.0.1";
45557
+ const userAgent = headers["user-agent"] ?? "";
45558
+ const req = {
45559
+ method: snapshot.method.toUpperCase(),
45560
+ matchedPath: matchCtx.matchedPath,
45561
+ pathParameters: matchCtx.pathParameters,
45562
+ querystring,
45563
+ headers,
45564
+ body: snapshot.body,
45565
+ sourceIp,
45566
+ userAgent,
45567
+ stage: matchCtx.route.stage,
45568
+ resourcePath: matchCtx.route.pathPattern,
45569
+ requestId: randomUUID()
45570
+ };
45571
+ const deps = {
45572
+ pool: state.pool,
45573
+ rieTimeoutMs: opts.rieTimeoutMs
45574
+ };
45575
+ switch (integration.kind) {
45576
+ case "mock": return dispatchMockIntegration(integration, req);
45577
+ case "http-proxy": return await dispatchHttpProxyIntegration(integration, req, deps);
45578
+ case "http": return await dispatchHttpIntegration(integration, req, deps);
45579
+ case "aws-lambda": return await dispatchAwsLambdaIntegration(integration, req, deps);
45580
+ }
45581
+ }
45582
+ /**
45583
+ * Write a {@link RestV1IntegrationOutcome} to the HTTP response.
45584
+ */
45585
+ function writeIntegrationOutcome(res, outcome) {
45586
+ res.statusCode = outcome.statusCode;
45587
+ for (const [name, value] of Object.entries(outcome.headers)) res.setHeader(name, value);
45588
+ res.end(outcome.body);
45589
+ }
45590
+ /**
43952
45591
  * Attempt CORS preflight interception. Returns `true` when the
43953
45592
  * preflight response was written (caller must NOT continue to route
43954
45593
  * dispatch); `false` when no preflight match (caller falls through to
@@ -45074,7 +46713,7 @@ async function localStartApiCommand(target, options) {
45074
46713
  await ensureDockerAvailable();
45075
46714
  const appCmd = resolveApp(options.app);
45076
46715
  if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
45077
- const overrides = readEnvOverridesFile$2(options.envVars);
46716
+ const overrides = readEnvOverridesFile$3(options.envVars);
45078
46717
  const debugPortBase = options.debugPortBase ? parseDebugPort(options.debugPortBase) : void 0;
45079
46718
  const perLambdaConcurrency = parsePerLambdaConcurrency(options.perLambdaConcurrency);
45080
46719
  const inlineTmpDirs = /* @__PURE__ */ new Set();
@@ -45264,7 +46903,9 @@ async function localStartApiCommand(target, options) {
45264
46903
  if (basePort !== 0) nextPort += 1;
45265
46904
  }
45266
46905
  printPerServerRouteTables(servers);
45267
- warnUnsupportedRoutes(servers.flatMap((s) => s.group.routes.map((r) => r.route)), logger);
46906
+ const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
46907
+ warnUnsupportedRoutes(allRoutes, logger);
46908
+ warnSsrfRiskyIntegrations(allRoutes, logger);
45268
46909
  logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
45269
46910
  for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
45270
46911
  process.stdout.write("^C to stop and clean up containers.\n");
@@ -45813,12 +47454,26 @@ function printRouteTable(routes) {
45813
47454
  process.stdout.write("Discovered routes:\n");
45814
47455
  for (const r of sorted) {
45815
47456
  const sourceLabel = r.source === "http-api" ? "HTTP API" : r.source === "rest-v1" ? `REST v1, stage '${r.stage}'` : "Function URL";
45816
- const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.lambdaLogicalId;
47457
+ const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.restV1Integration ? formatRestV1IntegrationLabel(r.restV1Integration) : r.lambdaLogicalId;
45817
47458
  process.stdout.write(` ${r.method.padEnd(methodWidth)} ${r.pathPattern.padEnd(pathWidth)} -> ${target} (${sourceLabel})\n`);
45818
47459
  }
45819
47460
  process.stdout.write("\n");
45820
47461
  }
45821
47462
  /**
47463
+ * Format the route-table label for a REST v1 non-AWS_PROXY integration.
47464
+ * `MOCK` / `HTTP` / `HTTP_PROXY` show their integration kind directly;
47465
+ * `AWS` (Lambda non-proxy) shows the Lambda logical id with an `[AWS]`
47466
+ * suffix so it's distinguishable from AWS_PROXY rows. Closes #457.
47467
+ */
47468
+ function formatRestV1IntegrationLabel(integration) {
47469
+ switch (integration.kind) {
47470
+ case "mock": return "[MOCK]";
47471
+ case "http-proxy": return `[HTTP_PROXY ${integration.uri}]`;
47472
+ case "http": return `[HTTP ${integration.uri}]`;
47473
+ case "aws-lambda": return `${integration.lambdaLogicalId} [AWS]`;
47474
+ }
47475
+ }
47476
+ /**
45822
47477
  * Materialize an inline Lambda body (`Code.ZipFile`) to a tmpdir and
45823
47478
  * return the directory the container should mount at /var/task.
45824
47479
  * Mirrors `cdkd local invoke`'s implementation; the only divergence is
@@ -45847,7 +47502,7 @@ function getTemplateEnv$1(resource) {
45847
47502
  return vars;
45848
47503
  }
45849
47504
  /** Read the SAM-shape `--env-vars` JSON file. */
45850
- function readEnvOverridesFile$2(filePath) {
47505
+ function readEnvOverridesFile$3(filePath) {
45851
47506
  if (!filePath) return void 0;
45852
47507
  let raw;
45853
47508
  try {
@@ -45972,6 +47627,26 @@ function warnUnsupportedRoutes(routes, logger) {
45972
47627
  return unsupported.length;
45973
47628
  }
45974
47629
  /**
47630
+ * Surface a one-line warn per HTTP / HTTP_PROXY integration whose
47631
+ * `Integration.Uri` points at a well-known internal address space
47632
+ * (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
47633
+ * follow-up: cdkd does NOT block these — warn-and-proceed matches the
47634
+ * cognito JWKS pass-through pattern — but the user should see the
47635
+ * destination at boot so a malicious / typo'd template Uri does not
47636
+ * silently exfiltrate credentials in CI. Deduplicated per-Uri.
47637
+ */
47638
+ function warnSsrfRiskyIntegrations(routes, logger) {
47639
+ const seen = /* @__PURE__ */ new Set();
47640
+ for (const r of routes) {
47641
+ const integ = r.restV1Integration;
47642
+ if (!integ) continue;
47643
+ if (integ.kind !== "http" && integ.kind !== "http-proxy") continue;
47644
+ if (seen.has(integ.uri)) continue;
47645
+ seen.add(integ.uri);
47646
+ warnSsrfRiskyUri(integ.uri, `${r.method} ${r.pathPattern}`, (msg) => logger.warn(msg));
47647
+ }
47648
+ }
47649
+ /**
45975
47650
  * One reload cycle for the multi-server topology (issue #260). The
45976
47651
  * watcher serializes calls via a chain promise; this function:
45977
47652
  *
@@ -46020,7 +47695,9 @@ async function reloadAllServers(args) {
46020
47695
  lastAssetPaths.value = computeAssetPaths(material.specs);
46021
47696
  if (watcher) watcher.update([output, ...lastAssetPaths.value]);
46022
47697
  printPerServerRouteTables(servers);
46023
- warnUnsupportedRoutes(servers.flatMap((s) => s.group.routes.map((r) => r.route)), logger);
47698
+ const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
47699
+ warnUnsupportedRoutes(allRoutes, logger);
47700
+ warnSsrfRiskyIntegrations(allRoutes, logger);
46024
47701
  }
46025
47702
  /**
46026
47703
  * Returns true when any value in the function's template env map is a
@@ -46196,14 +47873,38 @@ const execFileAsync$1 = promisify(execFile);
46196
47873
  /** AWS-published sidecar image (latest tag). amd64 is the only image AWS ships. */
46197
47874
  const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:latest-amd64";
46198
47875
  /**
46199
- * Well-known IP for the ECS local-container-endpoints sidecar — matches
46200
- * the documented AWS task-metadata endpoint address. Containers inject
46201
- * `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<container-id>`
46202
- * to reach it.
47876
+ * Default well-known IP for the ECS local-container-endpoints sidecar —
47877
+ * matches the documented AWS task-metadata endpoint address. Containers
47878
+ * inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
47879
+ * to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
47880
+ * start-service` allocates a per-replica subnet (via `subnetOctet`) so
47881
+ * concurrent replicas don't collide on a single docker network range.
46203
47882
  */
46204
47883
  const METADATA_ENDPOINT_IP = "169.254.170.2";
46205
- /** Subnet handed to `docker network create` so the well-known IP is routable. */
46206
- const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
47884
+ /** Default subnet used when no `subnetOctet` override is supplied. */
47885
+ const DEFAULT_METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
47886
+ /**
47887
+ * Pure-functional subnet allocator. `cdkd local run-task` uses the
47888
+ * default subnet; `cdkd local start-service` walks `subnetOctet=170,
47889
+ * 171, 172, ...` (one per replica) to keep parallel docker networks
47890
+ * from clashing. The link-local 169.254.0.0/16 space is reserved AWS-
47891
+ * wide for cloud metadata so collisions with user workloads are
47892
+ * unlikely, but each replica still gets its own /24 to ensure
47893
+ * docker's `--subnet` allocator does not reject "Pool overlaps".
47894
+ *
47895
+ * `subnetOctet` is the second-from-last byte of the network: 170 →
47896
+ * 169.254.170.0/24 (default), 171 → 169.254.171.0/24, etc. Valid
47897
+ * range is 1..254; the runner clamps to `(170 + replicaIndex) % 84`
47898
+ * + 170 in practice (rolling window) — exported here so the runner
47899
+ * keeps the allocation logic in one place.
47900
+ */
47901
+ function buildEndpointSubnet(subnetOctet) {
47902
+ if (subnetOctet < 1 || subnetOctet > 254 || !Number.isInteger(subnetOctet)) throw new Error(`buildEndpointSubnet: subnetOctet must be an integer in 1..254 (got ${subnetOctet}).`);
47903
+ return {
47904
+ cidr: `169.254.${subnetOctet}.0/24`,
47905
+ sidecarIp: `169.254.${subnetOctet}.2`
47906
+ };
47907
+ }
46207
47908
  /**
46208
47909
  * Create the per-task docker network + start the metadata-endpoints
46209
47910
  * sidecar. The sidecar must come up at the well-known address BEFORE any
@@ -46213,8 +47914,13 @@ const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
46213
47914
  async function createTaskNetwork(options = {}) {
46214
47915
  const logger = getLogger().child("ecs-network");
46215
47916
  const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
47917
+ const subnetOctet = options.subnetOctet ?? 170;
47918
+ const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
47919
+ cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
47920
+ sidecarIp: METADATA_ENDPOINT_IP
47921
+ } : buildEndpointSubnet(subnetOctet);
46216
47922
  await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
46217
- logger.info(`Creating docker network ${networkName} (subnet ${METADATA_ENDPOINT_SUBNET})...`);
47923
+ logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
46218
47924
  try {
46219
47925
  await execFileAsync$1(getDockerCmd(), [
46220
47926
  "network",
@@ -46222,12 +47928,12 @@ async function createTaskNetwork(options = {}) {
46222
47928
  "--driver",
46223
47929
  "bridge",
46224
47930
  "--subnet",
46225
- METADATA_ENDPOINT_SUBNET,
47931
+ cidr,
46226
47932
  networkName
46227
47933
  ]);
46228
47934
  } catch (err) {
46229
47935
  const e = err;
46230
- throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${METADATA_ENDPOINT_SUBNET}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`.`);
47936
+ throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
46231
47937
  }
46232
47938
  const sidecarArgs = [
46233
47939
  "run",
@@ -46238,7 +47944,7 @@ async function createTaskNetwork(options = {}) {
46238
47944
  "--network",
46239
47945
  networkName,
46240
47946
  "--ip",
46241
- METADATA_ENDPOINT_IP
47947
+ sidecarIp
46242
47948
  ];
46243
47949
  const sidecarEnv = {};
46244
47950
  if (options.credentials) {
@@ -46249,7 +47955,7 @@ async function createTaskNetwork(options = {}) {
46249
47955
  if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
46250
47956
  for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
46251
47957
  sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
46252
- logger.info("Starting ECS local-container-endpoints sidecar at 169.254.170.2...");
47958
+ logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
46253
47959
  let sidecarContainerId;
46254
47960
  try {
46255
47961
  const { stdout } = await execFileAsync$1(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
@@ -46261,7 +47967,8 @@ async function createTaskNetwork(options = {}) {
46261
47967
  }
46262
47968
  return {
46263
47969
  networkName,
46264
- sidecarContainerId
47970
+ sidecarContainerId,
47971
+ sidecarIp
46265
47972
  };
46266
47973
  }
46267
47974
  /**
@@ -46276,9 +47983,10 @@ async function createTaskNetwork(options = {}) {
46276
47983
  * to whichever credentials AWS SDK chains find).
46277
47984
  */
46278
47985
  function buildMetadataEnv(opts) {
47986
+ const ip = opts.sidecarIp ?? "169.254.170.2";
46279
47987
  const env = {
46280
- ECS_CONTAINER_METADATA_URI_V4: `http://${METADATA_ENDPOINT_IP}/v4/${opts.containerName}`,
46281
- ECS_CONTAINER_METADATA_URI: `http://${METADATA_ENDPOINT_IP}/v3/${opts.containerName}`
47988
+ ECS_CONTAINER_METADATA_URI_V4: `http://${ip}/v4/${opts.containerName}`,
47989
+ ECS_CONTAINER_METADATA_URI: `http://${ip}/v3/${opts.containerName}`
46282
47990
  };
46283
47991
  if (opts.roleArn) env["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] = `/role/${encodeURIComponent(opts.roleArn)}`;
46284
47992
  if (opts.region) env["AWS_REGION"] = opts.region;
@@ -46527,6 +48235,7 @@ async function runEcsTask(task, options, state) {
46527
48235
  };
46528
48236
  if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
46529
48237
  if (options.cluster) netCreateOpts.cluster = options.cluster;
48238
+ if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
46530
48239
  state.network = await createTaskNetwork(netCreateOpts);
46531
48240
  const volumeByName = await realizeDockerVolumes(task.volumes, state);
46532
48241
  const dockerCmds = /* @__PURE__ */ new Map();
@@ -46544,7 +48253,8 @@ async function runEcsTask(task, options, state) {
46544
48253
  containerHost: options.containerHost,
46545
48254
  roleArn: options.taskRoleArn,
46546
48255
  platformOverride: options.platformOverride,
46547
- region: options.region
48256
+ region: options.region,
48257
+ sidecarIp: state.network.sidecarIp
46548
48258
  }));
46549
48259
  }
46550
48260
  const startedByName = /* @__PURE__ */ new Map();
@@ -46681,7 +48391,7 @@ async function waitForContainerHealthy(containerId, displayName) {
46681
48391
  if (err instanceof EcsTaskRunnerError) throw err;
46682
48392
  logger.debug(`docker inspect on '${displayName}' failed: ${err instanceof Error ? err.message : String(err)}`);
46683
48393
  }
46684
- await sleep(1e3);
48394
+ await sleep$1(1e3);
46685
48395
  }
46686
48396
  throw new EcsTaskRunnerError(`Container '${displayName}' did not become healthy within 5 minutes.`);
46687
48397
  }
@@ -46705,7 +48415,7 @@ async function stopContainer(containerId, graceSeconds) {
46705
48415
  ]);
46706
48416
  } catch {}
46707
48417
  }
46708
- function sleep(ms) {
48418
+ function sleep$1(ms) {
46709
48419
  return new Promise((res) => setTimeout(res, ms));
46710
48420
  }
46711
48421
  /**
@@ -46885,7 +48595,8 @@ function buildDockerRunArgs(opts) {
46885
48595
  const metaEnv = buildMetadataEnv({
46886
48596
  containerName: container.name,
46887
48597
  ...roleArn !== void 0 && { roleArn },
46888
- ...opts.region !== void 0 && { region: opts.region }
48598
+ ...opts.region !== void 0 && { region: opts.region },
48599
+ ...opts.sidecarIp !== void 0 && { sidecarIp: opts.sidecarIp }
46889
48600
  });
46890
48601
  Object.assign(finalEnv, metaEnv);
46891
48602
  Object.assign(finalEnv, container.environment);
@@ -46986,7 +48697,7 @@ async function localRunTaskCommand(target, options) {
46986
48697
  ...Object.keys(context).length > 0 && { context }
46987
48698
  };
46988
48699
  const { stacks } = await synthesizer.synthesize(synthOpts);
46989
- const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
48700
+ const imageContext = await buildEcsImageResolutionContext$1(target, stacks, options);
46990
48701
  const task = resolveEcsTaskTarget(target, stacks, imageContext);
46991
48702
  logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
46992
48703
  const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
@@ -47028,13 +48739,13 @@ async function localRunTaskCommand(target, options) {
47028
48739
  let resolvedRoleArn;
47029
48740
  if (options.assumeTaskRole === true) {
47030
48741
  if (!task.taskRoleArn) throw new Error("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Either the task definition does not set TaskRoleArn, or it points at a resource cdkd cannot resolve to an IAM Role at synth time. Pass the ARN explicitly: --assume-task-role <arn>");
47031
- resolvedRoleArn = await resolvePlaceholderAccount(task.taskRoleArn, options.region);
47032
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
48742
+ resolvedRoleArn = await resolvePlaceholderAccount$1(task.taskRoleArn, options.region);
48743
+ assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
47033
48744
  } else if (typeof options.assumeTaskRole === "string") {
47034
48745
  resolvedRoleArn = options.assumeTaskRole;
47035
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
48746
+ assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
47036
48747
  }
47037
- const envOverrides = readEnvOverridesFile$1(options.envVars);
48748
+ const envOverrides = readEnvOverridesFile$2(options.envVars);
47038
48749
  const runOpts = {
47039
48750
  cluster: options.cluster,
47040
48751
  containerHost: options.containerHost,
@@ -47069,7 +48780,7 @@ async function localRunTaskCommand(target, options) {
47069
48780
  * Lazy: callers should only invoke this when the resolved ARN is actually
47070
48781
  * going to be used (i.e. on the bare `--assume-task-role` path).
47071
48782
  */
47072
- async function resolvePlaceholderAccount(arn, region) {
48783
+ async function resolvePlaceholderAccount$1(arn, region) {
47073
48784
  if (!arn.includes("${AWS::AccountId}")) return arn;
47074
48785
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47075
48786
  const sts = new STSClient({ ...region && { region } });
@@ -47085,7 +48796,7 @@ async function resolvePlaceholderAccount(arn, region) {
47085
48796
  * Assume `roleArn` and return temp credentials. Mirrors the same flow
47086
48797
  * `cdkd local invoke --assume-role` uses.
47087
48798
  */
47088
- async function assumeTaskRole(roleArn, region) {
48799
+ async function assumeTaskRole$1(roleArn, region) {
47089
48800
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
47090
48801
  const sts = new STSClient({ ...region && { region } });
47091
48802
  try {
@@ -47116,9 +48827,9 @@ async function assumeTaskRole(roleArn, region) {
47116
48827
  * for the candidate stack — same warn-and-fall-back error policy as
47117
48828
  * `cdkd local invoke --from-state`.
47118
48829
  */
47119
- async function buildEcsImageResolutionContext(target, stacks, options) {
48830
+ async function buildEcsImageResolutionContext$1(target, stacks, options) {
47120
48831
  const logger = getLogger();
47121
- const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
48832
+ const candidate = pickCandidateStack$1(parseEcsTarget(target).stackPattern, stacks);
47122
48833
  if (!candidate) return void 0;
47123
48834
  const needs = detectEcsImageResolutionNeeds(candidate);
47124
48835
  if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
@@ -47129,7 +48840,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
47129
48840
  if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
47130
48841
  let accountId;
47131
48842
  try {
47132
- accountId = await resolveCallerAccountId(region);
48843
+ accountId = await resolveCallerAccountId$1(region);
47133
48844
  } catch (err) {
47134
48845
  logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
47135
48846
  }
@@ -47157,7 +48868,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
47157
48868
  else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics (Ref / Fn::GetAtt / Fn::Sub / Fn::Join). Pass --from-state to substitute them against the deployed cdkd state. Without --from-state these entries are dropped (per-key warnings will follow).");
47158
48869
  return ctx;
47159
48870
  }
47160
- function pickCandidateStack(stackPattern, stacks) {
48871
+ function pickCandidateStack$1(stackPattern, stacks) {
47161
48872
  if (stackPattern === null) {
47162
48873
  if (stacks.length === 1) return stacks[0];
47163
48874
  return;
@@ -47165,7 +48876,7 @@ function pickCandidateStack(stackPattern, stacks) {
47165
48876
  const matched = matchStacks(stacks, [stackPattern]);
47166
48877
  if (matched.length === 1) return matched[0];
47167
48878
  }
47168
- async function resolveCallerAccountId(region) {
48879
+ async function resolveCallerAccountId$1(region) {
47169
48880
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47170
48881
  const sts = new STSClient({ ...region && { region } });
47171
48882
  try {
@@ -47179,7 +48890,7 @@ async function resolveCallerAccountId(region) {
47179
48890
  * `cdkd local invoke --env-vars`: top-level keys are container names, with
47180
48891
  * `Parameters` reserved for global entries.
47181
48892
  */
47182
- function readEnvOverridesFile$1(filePath) {
48893
+ function readEnvOverridesFile$2(filePath) {
47183
48894
  if (!filePath) return void 0;
47184
48895
  let raw;
47185
48896
  try {
@@ -47208,6 +48919,690 @@ function createLocalRunTaskCommand() {
47208
48919
  return cmd;
47209
48920
  }
47210
48921
 
48922
+ //#endregion
48923
+ //#region src/local/ecs-service-resolver.ts
48924
+ /**
48925
+ * Walk the synth template to locate an `AWS::ECS::Service` by display
48926
+ * path or stack-qualified logical id, resolve its `TaskDefinition`
48927
+ * reference, and chain into the existing `resolveEcsTaskTarget` machinery
48928
+ * to produce a `ResolvedEcsService` carrying both the service knobs and
48929
+ * the underlying task descriptor.
48930
+ *
48931
+ * Target shape mirrors `cdkd local run-task`: `<Stack>/<DisplayPath>` or
48932
+ * `<Stack>:<LogicalId>`; single-stack apps may omit the stack prefix.
48933
+ *
48934
+ * Optional `context` (same as the task resolver) carries the ECR image
48935
+ * substitution data — pseudo parameters (Tier 1) + state-recorded
48936
+ * resources (Tier 2). The CLI builds it lazily when the candidate
48937
+ * service's task definition actually needs substitution.
48938
+ */
48939
+ function resolveEcsServiceTarget(target, stacks, context) {
48940
+ if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
48941
+ const parsed = parseEcsTarget(target);
48942
+ const stack = pickStack(parsed, stacks);
48943
+ const resources = stack.template.Resources ?? {};
48944
+ let serviceLogicalId;
48945
+ let serviceResource;
48946
+ if (parsed.isPath) {
48947
+ const index = buildCdkPathIndex(stack.template);
48948
+ const services = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::Service");
48949
+ if (services.length === 0) throw notFoundError(target, stack, resources);
48950
+ if (services.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${services.length} ECS services in ${stack.stackName}: ` + services.map((s) => s.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
48951
+ serviceLogicalId = services[0].logicalId;
48952
+ serviceResource = resources[serviceLogicalId];
48953
+ } else {
48954
+ serviceResource = resources[parsed.pathOrId];
48955
+ if (!serviceResource) throw notFoundError(target, stack, resources);
48956
+ serviceLogicalId = parsed.pathOrId;
48957
+ }
48958
+ if (!serviceLogicalId || !serviceResource) throw notFoundError(target, stack, resources);
48959
+ if (serviceResource.Type === "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is an ECS TaskDefinition, not a Service. Use \`cdkd local run-task\` for one-shot tasks; \`cdkd local start-service\` is Service-only.`);
48960
+ if (serviceResource.Type !== "AWS::ECS::Service") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is ${serviceResource.Type}, not an AWS::ECS::Service.`);
48961
+ return extractServiceProperties(stack, serviceLogicalId, serviceResource, stacks, context);
48962
+ }
48963
+ /**
48964
+ * Pure-functional extraction from the synth resource. Exposed for unit
48965
+ * testing the per-field resolution rules (DesiredCount default, missing
48966
+ * TaskDefinition, intrinsic shapes).
48967
+ */
48968
+ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, context) {
48969
+ const props = resource.Properties ?? {};
48970
+ const warnings = [];
48971
+ const taskDefRef = props["TaskDefinition"];
48972
+ if (taskDefRef === void 0 || taskDefRef === null) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' in ${stack.stackName} has no TaskDefinition property.`);
48973
+ const taskDefLogicalId = resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId);
48974
+ const task = resolveEcsTaskTarget(`${stack.stackName}:${taskDefLogicalId}`, stacks, context);
48975
+ const desiredCount = parseDesiredCount(props["DesiredCount"], serviceLogicalId);
48976
+ const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
48977
+ const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
48978
+ if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
48979
+ if (props["ServiceConnectConfiguration"]) warnings.push(`ECS Service '${serviceLogicalId}' declares ServiceConnectConfiguration, but Service Connect / Cloud Map emulation is deferred (tracked in #460). Cross-service discovery between locally-run services is not provided.`);
48980
+ return {
48981
+ stack,
48982
+ serviceLogicalId,
48983
+ resource,
48984
+ serviceName,
48985
+ desiredCount,
48986
+ healthCheckGracePeriodSeconds,
48987
+ task,
48988
+ warnings
48989
+ };
48990
+ }
48991
+ /**
48992
+ * Resolve `Properties.TaskDefinition` to a logical id in the same stack.
48993
+ * Accepted shapes — verified against real CDK 2.x `cdk synth` output on
48994
+ * 2026-05-22 (per `feedback_verify_cdk_synth_shape_before_resolver.md`):
48995
+ * - `{Ref: '<TaskDefLogicalId>'}` — the CDK-canonical shape emitted by
48996
+ * `new ecs.FargateService({ taskDefinition })`.
48997
+ * - flat string `'<TaskDefLogicalId>'` — accepted defensively but CDK
48998
+ * rarely emits this for cross-resource refs.
48999
+ * Other intrinsic shapes (`Fn::ImportValue` / `Fn::GetAtt` / etc.) are
49000
+ * rejected — cross-stack task definitions and `Fn::GetAtt` shapes have
49001
+ * no clean local resolution and would land here only as user errors.
49002
+ */
49003
+ function resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId) {
49004
+ if (typeof taskDefRef === "string") return taskDefRef;
49005
+ if (taskDefRef && typeof taskDefRef === "object" && !Array.isArray(taskDefRef)) {
49006
+ const refValue = taskDefRef["Ref"];
49007
+ if (typeof refValue === "string") {
49008
+ const target = (stack.template.Resources ?? {})[refValue];
49009
+ if (!target) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references TaskDefinition '${refValue}' but no such resource exists in ${stack.stackName}.`);
49010
+ if (target.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references '${refValue}' as TaskDefinition but it is of type ${target.Type}, not AWS::ECS::TaskDefinition.`);
49011
+ return refValue;
49012
+ }
49013
+ }
49014
+ throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported TaskDefinition reference shape: ${JSON.stringify(taskDefRef)}. cdkd local start-service v1 supports only Ref to a same-stack AWS::ECS::TaskDefinition; cross-stack TaskDefinitions are deferred.`);
49015
+ }
49016
+ function parseDesiredCount(raw, serviceLogicalId) {
49017
+ if (raw === void 0 || raw === null) return 1;
49018
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
49019
+ if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
49020
+ throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported DesiredCount value: ${JSON.stringify(raw)}. Must be a non-negative integer.`);
49021
+ }
49022
+ function parseHealthCheckGrace(raw, _serviceLogicalId) {
49023
+ if (raw === void 0 || raw === null) return 30;
49024
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
49025
+ if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
49026
+ return 30;
49027
+ }
49028
+ function parseServiceName(raw, serviceLogicalId) {
49029
+ if (typeof raw === "string" && raw.length > 0) return raw;
49030
+ return serviceLogicalId;
49031
+ }
49032
+ /**
49033
+ * Local copy of the same `pickStack` helper used by the task resolver.
49034
+ * Kept in-file rather than exported from `ecs-task-resolver.ts` so future
49035
+ * service-specific extensions (e.g. cross-stack service-to-task refs)
49036
+ * can diverge without breaking the run-task code path.
49037
+ */
49038
+ function pickStack(parsed, stacks) {
49039
+ if (parsed.stackPattern === null) {
49040
+ if (stacks.length === 1) return stacks[0];
49041
+ throw new EcsTaskResolutionError(`Target has no stack prefix, and the assembly contains ${stacks.length} stacks: ${stacks.map((s) => s.stackName).join(", ")}. Pass the target as 'Stack/Path' or 'Stack:LogicalId'.`);
49042
+ }
49043
+ const matched = matchStacks(stacks, [parsed.stackPattern]);
49044
+ if (matched.length === 0) throw new EcsTaskResolutionError(`No stack matches '${parsed.stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
49045
+ if (matched.length > 1) throw new EcsTaskResolutionError(`Multiple stacks match '${parsed.stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
49046
+ return matched[0];
49047
+ }
49048
+ function notFoundError(target, stack, resources) {
49049
+ const services = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ECS::Service").map(([id]) => id);
49050
+ if (services.length === 0) return new EcsTaskResolutionError(`Target '${target}' did not match any resource in ${stack.stackName}, and the stack declares no AWS::ECS::Service resources at all.`);
49051
+ return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
49052
+ }
49053
+
49054
+ //#endregion
49055
+ //#region src/local/ecs-service-runner.ts
49056
+ /**
49057
+ * Phase 2 of #262 — long-running ECS Service emulator. Wraps the existing
49058
+ * `ecs-task-runner` machinery in a replica pool: N concurrent task
49059
+ * instances per `DesiredCount`, each with its own docker network +
49060
+ * metadata sidecar + container set. Tasks that exit non-zero AFTER the
49061
+ * health-check grace period are restarted with exponential backoff so a
49062
+ * crash-looping container does not hammer docker.
49063
+ *
49064
+ * v1 scope (per the issue's PR-split recommendation):
49065
+ * - Replica pool sizing via `DesiredCount` clamped by `--max-tasks`.
49066
+ * - Restart-on-exit with exponential backoff (1s → 30s, capped) +
49067
+ * a per-instance retry counter so a permanently-broken container
49068
+ * stops compounding cleanup work.
49069
+ * - Long-running lifecycle (returns only on shutdown).
49070
+ *
49071
+ * Deferred to follow-up PRs:
49072
+ * - Local load-balancer emulation (LB listener + target-group health
49073
+ * check + round-robin) — separate PR per the issue's PR-split.
49074
+ * - Service Connect / Cloud Map (tracked in #460).
49075
+ * - Rolling deployment (`--reload` / `--watch`).
49076
+ */
49077
+ var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
49078
+ constructor(message) {
49079
+ super(message);
49080
+ this.name = "EcsServiceRunnerError";
49081
+ Object.setPrototypeOf(this, EcsServiceRunnerError.prototype);
49082
+ }
49083
+ };
49084
+ function createServiceRunState() {
49085
+ return {
49086
+ replicas: [],
49087
+ shuttingDown: false
49088
+ };
49089
+ }
49090
+ /**
49091
+ * Compute the effective replica count for a service: the smaller of
49092
+ * `service.desiredCount` and `--max-tasks`, floored at 1. Pure-
49093
+ * functional so the CLI can show the user what cdkd is about to do
49094
+ * before any docker calls fire.
49095
+ */
49096
+ function computeReplicaCount(desiredCount, maxTasks) {
49097
+ if (maxTasks < 1) throw new EcsServiceRunnerError(`--max-tasks must be >= 1 (got ${maxTasks}); local dev needs at least one running replica.`);
49098
+ if (desiredCount <= 0) return 1;
49099
+ return Math.min(desiredCount, maxTasks);
49100
+ }
49101
+ /**
49102
+ * Exponential backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... Used
49103
+ * between restarts of a crash-looping replica so docker is not hammered
49104
+ * by the watcher loop. Exposed for unit testing.
49105
+ */
49106
+ function backoffDelayMs(restartCount) {
49107
+ return Math.min(1e3 * Math.pow(2, Math.min(restartCount, 10)), 3e4);
49108
+ }
49109
+ /**
49110
+ * Decide whether a replica that just exited should restart. Pure-
49111
+ * functional so the watcher loop's policy is easy to unit-test.
49112
+ */
49113
+ function shouldRestart(exitCode, policy) {
49114
+ if (policy === "none") return false;
49115
+ if (policy === "always") return true;
49116
+ return exitCode !== 0;
49117
+ }
49118
+ /**
49119
+ * Long-running entry point. Boots `replicaCount` instances of the
49120
+ * service's task descriptor, returns a controller object the CLI uses
49121
+ * to (1) wait for the first failure that gives up restarting and (2)
49122
+ * shut every replica down on SIGINT / SIGTERM.
49123
+ *
49124
+ * The returned `shutdown()` is idempotent and safe to call from
49125
+ * multiple SIGINT handlers (CLI's single-flight pattern wraps it
49126
+ * anyway).
49127
+ */
49128
+ async function startEcsService(service, options, runState) {
49129
+ const logger = getLogger().child("ecs-service");
49130
+ for (const w of service.warnings) logger.warn(w);
49131
+ const replicaCount = computeReplicaCount(service.desiredCount, options.maxTasks);
49132
+ if (replicaCount < service.desiredCount) logger.warn(`Service '${service.serviceName}' template DesiredCount=${service.desiredCount} exceeds --max-tasks=${options.maxTasks}; running ${replicaCount} replica(s) locally. Raise --max-tasks to lift the cap, or accept the reduced concurrency for local dev.`);
49133
+ logger.info(`Starting ECS service '${service.serviceName}' with ${replicaCount} replica(s) (restartPolicy=${options.restartPolicy})`);
49134
+ for (let i = 0; i < replicaCount; i++) {
49135
+ const instance = {
49136
+ index: i,
49137
+ state: createEcsRunState(),
49138
+ restartCount: 0,
49139
+ shuttingDown: false,
49140
+ inFlightBoot: void 0
49141
+ };
49142
+ runState.replicas.push(instance);
49143
+ const bootPromise = bootReplica(service, options, instance);
49144
+ instance.inFlightBoot = bootPromise;
49145
+ try {
49146
+ await bootPromise;
49147
+ } catch (err) {
49148
+ instance.lastError = err instanceof Error ? err : new Error(String(err));
49149
+ throw new EcsServiceRunnerError(`Failed to boot replica ${i} of service '${service.serviceName}': ${instance.lastError.message}`);
49150
+ } finally {
49151
+ instance.inFlightBoot = void 0;
49152
+ }
49153
+ }
49154
+ for (const instance of runState.replicas) watchReplica(service, options, instance, runState);
49155
+ return new ServiceController(service, runState, options);
49156
+ }
49157
+ /**
49158
+ * Public controller surface. The CLI awaits `controller.waitForShutdown()`
49159
+ * to block until the user ^Cs. `controller.shutdown()` is wired into the
49160
+ * SIGINT / SIGTERM handlers.
49161
+ */
49162
+ var ServiceController = class {
49163
+ service;
49164
+ runState;
49165
+ options;
49166
+ shutdownResolve;
49167
+ shutdownPromise;
49168
+ /**
49169
+ * Single-flight wrapper for `shutdown()` so the fan-out cleanup runs
49170
+ * exactly once even when SIGINT and the CLI's outer `finally` both
49171
+ * fire (the canonical pattern documented in
49172
+ * `feedback_sigint_finally_cleanup_singleflight.md`). Built in the
49173
+ * constructor so every call to `shutdown()` resolves against the same
49174
+ * underlying promise.
49175
+ */
49176
+ runShutdown;
49177
+ constructor(service, runState, options) {
49178
+ this.service = service;
49179
+ this.runState = runState;
49180
+ this.options = options;
49181
+ this.shutdownPromise = new Promise((resolve) => {
49182
+ this.shutdownResolve = resolve;
49183
+ });
49184
+ this.runShutdown = singleFlight(() => this.doShutdown());
49185
+ }
49186
+ /**
49187
+ * Returns the count of currently-active (non-shutting-down) replicas.
49188
+ * Exposed so the CLI can surface a one-line "service is degraded"
49189
+ * banner when restarts stop firing.
49190
+ */
49191
+ activeReplicaCount() {
49192
+ return this.runState.replicas.filter((r) => !r.shuttingDown).length;
49193
+ }
49194
+ /**
49195
+ * Block until `shutdown()` is called. Used by the CLI as the
49196
+ * long-running blocking point — the SIGINT handler resolves it.
49197
+ */
49198
+ waitForShutdown() {
49199
+ return this.shutdownPromise;
49200
+ }
49201
+ /**
49202
+ * Idempotent fan-out shutdown across every active replica. Wired into
49203
+ * both SIGINT and the outer `finally` of the CLI command; the
49204
+ * `singleFlight`-wrapped `runShutdown` collapses concurrent / repeated
49205
+ * callers to one underlying invocation.
49206
+ */
49207
+ async shutdown() {
49208
+ await this.runShutdown();
49209
+ return this.shutdownPromise;
49210
+ }
49211
+ async doShutdown() {
49212
+ this.runState.shuttingDown = true;
49213
+ const logger = getLogger().child("ecs-service");
49214
+ logger.info(`Shutting down service '${this.service.serviceName}'...`);
49215
+ for (const r of this.runState.replicas) r.shuttingDown = true;
49216
+ const inFlightBoots = this.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0);
49217
+ if (inFlightBoots.length > 0) {
49218
+ logger.debug(`Awaiting ${inFlightBoots.length} in-flight bootReplica() call(s) before cleanup...`);
49219
+ await Promise.allSettled(inFlightBoots);
49220
+ }
49221
+ await Promise.allSettled(this.runState.replicas.map(async (instance) => {
49222
+ try {
49223
+ await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
49224
+ } catch (err) {
49225
+ logger.debug(`Replica ${instance.index} cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
49226
+ }
49227
+ }));
49228
+ this.shutdownResolve?.();
49229
+ }
49230
+ };
49231
+ /**
49232
+ * Boot a single replica. Mutates the supplied `instance.state` so the
49233
+ * shutdown path's `cleanupEcsRun(instance.state)` covers every partial
49234
+ * side effect. Network names are suffixed with the replica index so
49235
+ * docker doesn't collide on shared per-task network names when N > 1.
49236
+ */
49237
+ async function bootReplica(service, options, instance) {
49238
+ const logger = getLogger().child("ecs-service");
49239
+ const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
49240
+ const perReplicaSubnetOctet = 170 + instance.index % 84;
49241
+ const perReplicaTaskOptions = {
49242
+ ...options.taskOptions,
49243
+ cluster: perReplicaCluster,
49244
+ subnetOctet: perReplicaSubnetOctet,
49245
+ detach: true
49246
+ };
49247
+ logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
49248
+ await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
49249
+ }
49250
+ /**
49251
+ * Long-running watcher loop for one replica. Polls the essential
49252
+ * container's exit code via `docker wait`; on exit, decides whether to
49253
+ * restart per `restartPolicy` + applies exponential backoff. The loop
49254
+ * exits only when the replica's `shuttingDown` flag is set.
49255
+ */
49256
+ async function watchReplica(service, options, instance, runState) {
49257
+ const logger = getLogger().child("ecs-service");
49258
+ while (!instance.shuttingDown && !runState.shuttingDown) {
49259
+ const essentialId = pickEssentialContainerId(instance, service);
49260
+ if (!essentialId) {
49261
+ await sleep(500);
49262
+ continue;
49263
+ }
49264
+ let exitCode;
49265
+ try {
49266
+ exitCode = await waitForExitImpl(essentialId);
49267
+ } catch (err) {
49268
+ logger.debug(`docker wait failed for replica ${instance.index}: ${err instanceof Error ? err.message : String(err)}`);
49269
+ exitCode = -1;
49270
+ }
49271
+ if (instance.shuttingDown || runState.shuttingDown) return;
49272
+ logger.warn(`Replica ${instance.index} essential container exited with code ${exitCode} (restartCount=${instance.restartCount}).`);
49273
+ if (!shouldRestart(exitCode, options.restartPolicy)) {
49274
+ logger.warn(`Replica ${instance.index} not restarting (policy=${options.restartPolicy}, exit=${exitCode}). Service running in degraded mode.`);
49275
+ instance.shuttingDown = true;
49276
+ return;
49277
+ }
49278
+ const delay = backoffDelayMs(instance.restartCount);
49279
+ logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
49280
+ await sleep(delay);
49281
+ if (instance.shuttingDown || runState.shuttingDown) return;
49282
+ try {
49283
+ await cleanupEcsRun(instance.state, { keepRunning: false });
49284
+ } catch (err) {
49285
+ logger.debug(`Replica ${instance.index} pre-restart cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
49286
+ }
49287
+ instance.state = createEcsRunState();
49288
+ instance.restartCount += 1;
49289
+ const bootPromise = bootReplica(service, options, instance);
49290
+ instance.inFlightBoot = bootPromise;
49291
+ try {
49292
+ await bootPromise;
49293
+ } catch (err) {
49294
+ instance.lastError = err instanceof Error ? err : new Error(String(err));
49295
+ logger.error(`Replica ${instance.index} restart failed: ${instance.lastError.message}. Service running in degraded mode.`);
49296
+ instance.shuttingDown = true;
49297
+ return;
49298
+ } finally {
49299
+ instance.inFlightBoot = void 0;
49300
+ }
49301
+ }
49302
+ }
49303
+ function pickEssentialContainerId(instance, service) {
49304
+ if (service) {
49305
+ const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
49306
+ if (essential) {
49307
+ const started = instance.state.startedContainers.find((c) => c.name === essential.name);
49308
+ if (started) return started.id;
49309
+ }
49310
+ }
49311
+ return instance.state.startedContainers[0]?.id;
49312
+ }
49313
+ /**
49314
+ * Production `docker wait <id>` implementation. Captured once so the
49315
+ * test override can restore it without duplicating the body.
49316
+ */
49317
+ const defaultWaitForExitImpl = async (containerId) => {
49318
+ const { execFile } = await import("node:child_process");
49319
+ const { promisify } = await import("node:util");
49320
+ const { getDockerCmd } = await import("./docker-cmd-EtWSTAje.js").then((n) => n.t);
49321
+ const { stdout } = await promisify(execFile)(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
49322
+ const code = parseInt(stdout.trim(), 10);
49323
+ return Number.isFinite(code) ? code : -1;
49324
+ };
49325
+ /**
49326
+ * `docker wait <id>` returns the exit code on stdout. Extracted as a
49327
+ * test-overridable function so unit tests do not need a real container.
49328
+ */
49329
+ let waitForExitImpl = defaultWaitForExitImpl;
49330
+ function sleep(ms) {
49331
+ return new Promise((resolve) => setTimeout(resolve, ms));
49332
+ }
49333
+
49334
+ //#endregion
49335
+ //#region src/cli/commands/local-start-service.ts
49336
+ /**
49337
+ * `cdkd local start-service <Stack/Service>` — Phase 2 of #262. Spins up
49338
+ * `DesiredCount` task replicas locally (clamped by `--max-tasks`) using
49339
+ * the existing `ecs-task-runner` per replica. Long-running; ^C cleans
49340
+ * every replica + sidecar + per-task network.
49341
+ *
49342
+ * Deferred to follow-up PRs (matches the issue's PR-split):
49343
+ * - Local LB emulator (listener + round-robin + target-group health
49344
+ * check) — PR C of #466.
49345
+ * - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
49346
+ * - Service Connect / Cloud Map — tracked separately in #460.
49347
+ */
49348
+ async function localStartServiceCommand(target, options) {
49349
+ const logger = getLogger();
49350
+ if (options.verbose) logger.setLevel("debug");
49351
+ warnIfDeprecatedRegion(options);
49352
+ const runState = createServiceRunState();
49353
+ let sigintHandler;
49354
+ let sigintCount = 0;
49355
+ let controller;
49356
+ const cleanup = singleFlight(async () => {
49357
+ if (controller) await controller.shutdown();
49358
+ else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
49359
+ }, (err) => getLogger().debug(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
49360
+ try {
49361
+ await applyRoleArnIfSet({
49362
+ roleArn: options.roleArn,
49363
+ region: options.region
49364
+ });
49365
+ await ensureDockerAvailable();
49366
+ const appCmd = resolveApp(options.app);
49367
+ if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
49368
+ logger.info("Synthesizing CDK app...");
49369
+ const synthesizer = new Synthesizer();
49370
+ const context = parseContextOptions(options.context);
49371
+ const synthOpts = {
49372
+ app: appCmd,
49373
+ output: options.output,
49374
+ ...options.region && { region: options.region },
49375
+ ...options.profile && { profile: options.profile },
49376
+ ...Object.keys(context).length > 0 && { context }
49377
+ };
49378
+ const { stacks } = await synthesizer.synthesize(synthOpts);
49379
+ const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
49380
+ const service = resolveEcsServiceTarget(target, stacks, imageContext);
49381
+ logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
49382
+ const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
49383
+ if (options.fromState && taskNeeds.needsCrossStackResolver) {
49384
+ const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
49385
+ const built = await buildCrossStackResolver(consumerRegion, {
49386
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
49387
+ statePrefix: options.statePrefix,
49388
+ ...options.region !== void 0 && { region: options.region },
49389
+ ...options.profile !== void 0 && { profile: options.profile }
49390
+ });
49391
+ if (built) try {
49392
+ const subContext = {
49393
+ resources: imageContext?.stateResources ?? {},
49394
+ ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
49395
+ consumerRegion,
49396
+ crossStackResolver: built.resolver
49397
+ };
49398
+ await applyCrossStackResolverToTask(service.task, subContext);
49399
+ } finally {
49400
+ built.dispose();
49401
+ }
49402
+ } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
49403
+ sigintHandler = () => {
49404
+ sigintCount += 1;
49405
+ if (sigintCount >= 2) {
49406
+ process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
49407
+ process.exit(130);
49408
+ }
49409
+ logger.info("Stopping service...");
49410
+ cleanup().then(() => process.exit(130));
49411
+ };
49412
+ process.on("SIGINT", sigintHandler);
49413
+ process.on("SIGTERM", sigintHandler);
49414
+ let assumedCredentials;
49415
+ let resolvedRoleArn;
49416
+ if (options.assumeTaskRole === true) {
49417
+ if (!service.task.taskRoleArn) throw new LocalStartServiceError("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>");
49418
+ resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
49419
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
49420
+ } else if (typeof options.assumeTaskRole === "string") {
49421
+ resolvedRoleArn = options.assumeTaskRole;
49422
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
49423
+ }
49424
+ const envOverrides = readEnvOverridesFile$1(options.envVars);
49425
+ const taskOpts = {
49426
+ cluster: options.cluster,
49427
+ containerHost: options.containerHost,
49428
+ skipPull: options.pull === false,
49429
+ keepRunning: false,
49430
+ detach: true
49431
+ };
49432
+ if (envOverrides) taskOpts.envOverrides = envOverrides;
49433
+ if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
49434
+ if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
49435
+ if (options.platform) taskOpts.platformOverride = options.platform;
49436
+ if (options.region) taskOpts.region = options.region;
49437
+ if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
49438
+ controller = await startEcsService(service, {
49439
+ maxTasks: options.maxTasks,
49440
+ restartPolicy: options.restartPolicy,
49441
+ taskOptions: taskOpts
49442
+ }, runState);
49443
+ logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
49444
+ await controller.waitForShutdown();
49445
+ } finally {
49446
+ if (sigintHandler) {
49447
+ process.off("SIGINT", sigintHandler);
49448
+ process.off("SIGTERM", sigintHandler);
49449
+ }
49450
+ await cleanup();
49451
+ }
49452
+ }
49453
+ async function resolvePlaceholderAccount(arn, region) {
49454
+ if (!arn.includes("${AWS::AccountId}")) return arn;
49455
+ const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
49456
+ const sts = new STSClient({ ...region && { region } });
49457
+ try {
49458
+ const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
49459
+ if (!account) throw new LocalStartServiceError(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN '${arn}'.`);
49460
+ return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
49461
+ } finally {
49462
+ sts.destroy();
49463
+ }
49464
+ }
49465
+ async function assumeTaskRole(roleArn, region) {
49466
+ const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
49467
+ const sts = new STSClient({ ...region && { region } });
49468
+ try {
49469
+ const creds = (await sts.send(new AssumeRoleCommand({
49470
+ RoleArn: roleArn,
49471
+ RoleSessionName: `cdkd-local-start-service-${Date.now()}`,
49472
+ DurationSeconds: 3600
49473
+ }))).Credentials;
49474
+ if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalStartServiceError(`AssumeRole(${roleArn}) returned no usable credentials.`);
49475
+ return {
49476
+ accessKeyId: creds.AccessKeyId,
49477
+ secretAccessKey: creds.SecretAccessKey,
49478
+ sessionToken: creds.SessionToken
49479
+ };
49480
+ } finally {
49481
+ sts.destroy();
49482
+ }
49483
+ }
49484
+ /**
49485
+ * Build the substitution context the ECS resolver consumes. Identical
49486
+ * shape to `local-run-task.ts:buildEcsImageResolutionContext` — only
49487
+ * the candidate stack picker differs because services and tasks share
49488
+ * the same stack-pattern grammar.
49489
+ */
49490
+ async function buildEcsImageResolutionContext(target, stacks, options) {
49491
+ const logger = getLogger();
49492
+ const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
49493
+ if (!candidate) return void 0;
49494
+ const needs = detectEcsImageResolutionNeeds(candidate);
49495
+ if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
49496
+ const ctx = {};
49497
+ const wantsPseudoForEnvOrSecret = options.fromState && needs.needsEnvOrSecretSubstitution;
49498
+ if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
49499
+ const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
49500
+ if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
49501
+ let accountId;
49502
+ try {
49503
+ accountId = await resolveCallerAccountId(region);
49504
+ } catch (err) {
49505
+ logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
49506
+ }
49507
+ const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
49508
+ ctx.pseudoParameters = {
49509
+ ...accountId !== void 0 && { accountId },
49510
+ ...region !== void 0 && { region },
49511
+ ...partitionAndSuffix && {
49512
+ partition: partitionAndSuffix.partition,
49513
+ urlSuffix: partitionAndSuffix.urlSuffix
49514
+ }
49515
+ };
49516
+ }
49517
+ const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
49518
+ if (options.fromState && wantsState) {
49519
+ const loaded = await loadStateForStack(candidate.stackName, candidate.region, {
49520
+ ...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
49521
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
49522
+ statePrefix: options.statePrefix,
49523
+ ...options.region !== void 0 && { region: options.region },
49524
+ ...options.profile !== void 0 && { profile: options.profile }
49525
+ });
49526
+ if (loaded) ctx.stateResources = loaded.state.resources;
49527
+ } else if (!options.fromState && needs.needsStateResources) logger.warn("Container Image references a same-stack AWS::ECR::Repository. Pass --from-state to substitute the deployed repository URI.");
49528
+ else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics. Pass --from-state to substitute them against the deployed cdkd state.");
49529
+ return ctx;
49530
+ }
49531
+ function pickCandidateStack(stackPattern, stacks) {
49532
+ if (stackPattern === null) {
49533
+ if (stacks.length === 1) return stacks[0];
49534
+ return;
49535
+ }
49536
+ const matched = matchStacks(stacks, [stackPattern]);
49537
+ if (matched.length === 1) return matched[0];
49538
+ }
49539
+ async function resolveCallerAccountId(region) {
49540
+ const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
49541
+ const sts = new STSClient({ ...region && { region } });
49542
+ try {
49543
+ return (await sts.send(new GetCallerIdentityCommand({}))).Account;
49544
+ } finally {
49545
+ sts.destroy();
49546
+ }
49547
+ }
49548
+ function readEnvOverridesFile$1(filePath) {
49549
+ if (!filePath) return void 0;
49550
+ let raw;
49551
+ try {
49552
+ raw = readFileSync(filePath, "utf-8");
49553
+ } catch (err) {
49554
+ throw new LocalStartServiceError(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
49555
+ }
49556
+ let parsed;
49557
+ try {
49558
+ parsed = JSON.parse(raw);
49559
+ } catch (err) {
49560
+ throw new LocalStartServiceError(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
49561
+ }
49562
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new LocalStartServiceError(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
49563
+ return parsed;
49564
+ }
49565
+ function parsePositiveInt(raw, flagName) {
49566
+ const parsed = parseInt(raw, 10);
49567
+ if (!Number.isFinite(parsed) || parsed < 1) throw new LocalStartServiceError(`${flagName} must be a positive integer (got '${raw}').`);
49568
+ return parsed;
49569
+ }
49570
+ /**
49571
+ * Hard cap on `--max-tasks` driven by the per-replica subnet allocator
49572
+ * in `ecs-service-runner.ts:bootReplica` (`170 + (index % 84)`). The
49573
+ * `% 84` modulo wraps at index 84, collapsing replica 84's `/24` onto
49574
+ * replica 0's allocation. Docker rejects the duplicate-subnet network
49575
+ * creation with a cryptic "Pool overlaps with other one on this address
49576
+ * space" error 30s into the boot — by which time some early replicas
49577
+ * may have spent docker-run budget. Reject at parse time so the user
49578
+ * gets an actionable error before any boot work fires.
49579
+ *
49580
+ * 84 is the count of usable link-local /24 octets in the range
49581
+ * `169.254.170.0..169.254.253.0` (255 reserved for broadcast). Raising
49582
+ * this requires extending the allocator to walk a different IP range.
49583
+ */
49584
+ const MAX_TASKS_SUBNET_RANGE_CAP = 84;
49585
+ function parseMaxTasks(raw) {
49586
+ const parsed = parsePositiveInt(raw, "--max-tasks");
49587
+ if (parsed > 84) throw new LocalStartServiceError(`--max-tasks ${parsed} exceeds the per-replica link-local /24 subnet allocator's range (${84}). The allocator in ecs-service-runner.ts assigns each replica its own 169.254.x.0/24 from the range 169.254.170.0..169.254.253.0; replica indices >= ${84} would collide with earlier replicas via modulo wrap. Lower --max-tasks to <= ${84}, or accept reduced local concurrency for high-DesiredCount services.`);
49588
+ return parsed;
49589
+ }
49590
+ function parseRestartPolicy(raw) {
49591
+ if (raw === "on-failure" || raw === "always" || raw === "none") return raw;
49592
+ throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
49593
+ }
49594
+ function createLocalStartServiceCommand() {
49595
+ const cmd = new Command("start-service").description("Run an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
49596
+ [
49597
+ ...commonOptions,
49598
+ ...appOptions,
49599
+ ...contextOptions,
49600
+ ...stateOptions
49601
+ ].forEach((opt) => cmd.addOption(opt));
49602
+ cmd.addOption(deprecatedRegionOption);
49603
+ return cmd;
49604
+ }
49605
+
47211
49606
  //#endregion
47212
49607
  //#region src/cli/commands/local-invoke.ts
47213
49608
  /**
@@ -47939,6 +50334,7 @@ function createLocalCommand() {
47939
50334
  local.addCommand(invoke);
47940
50335
  local.addCommand(createLocalStartApiCommand());
47941
50336
  local.addCommand(createLocalRunTaskCommand());
50337
+ local.addCommand(createLocalStartServiceCommand());
47942
50338
  return local;
47943
50339
  }
47944
50340
 
@@ -49311,7 +51707,7 @@ function reorderArgs(argv) {
49311
51707
  */
49312
51708
  async function main() {
49313
51709
  const program = new Command();
49314
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.130.0");
51710
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.132.0");
49315
51711
  program.addCommand(createBootstrapCommand());
49316
51712
  program.addCommand(createSynthCommand());
49317
51713
  program.addCommand(createListCommand());