@go-to-k/cdkd 0.135.1 → 0.137.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 +1163 -181
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-CX1x5ug1.js → deploy-engine-DZYchTh6.js} +663 -3
- package/dist/deploy-engine-DZYchTh6.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -1
- package/dist/deploy-engine-CX1x5ug1.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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 { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as
|
|
3
|
+
import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as MIGRATE_TMP_PREFIX, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, J as AssemblyReader, K as findLargeInlineResources, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as CdkdError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as CFN_TEMPLATE_BODY_LIMIT, V as resolveStateBucketWithDefaultAndSource, W as CFN_TEMPLATE_URL_LIMIT, X as resolveBucketRegion, _ as normalizeAwsTagsToCfn, _t as normalizeAwsError, a as withRetry, at as MissingCdkCliError, b as CloudControlProvider, c as cyan, ct as ResourceTimeoutError, d as red, dt as StackHasActiveImportsError, f as yellow, ft as StackTerminationProtectionError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, j as stringifyValue, k as shouldRetainResource, l as gray, lt as ResourceUpdateNotSupportedError, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as LocalMigrateError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as PartialFailureError, p as IAMRoleProvider, q as uploadCfnTemplate, r as DeployEngine, rt as LocalStartServiceError, s as bold, st as ProvisioningError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as LocalInvokeBuildError, u as green, ut as RouteDiscoveryError, v as resolveExplicitPhysicalId, vt as withErrorHandling, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-DZYchTh6.js";
|
|
4
4
|
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
|
|
5
5
|
import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
|
-
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand,
|
|
6
|
+
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, 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";
|
|
7
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";
|
|
8
8
|
import { CreateQueueCommand, DeleteQueueCommand, GetQueueAttributesCommand, GetQueueUrlCommand, ListQueueTagsCommand, ListQueuesCommand, QueueDoesNotExist, SQSClient, SetQueueAttributesCommand, TagQueueCommand, UntagQueueCommand } from "@aws-sdk/client-sqs";
|
|
9
9
|
import { CreateTopicCommand, DeleteTopicCommand, GetSubscriptionAttributesCommand, GetTopicAttributesCommand, ListTagsForResourceCommand, ListTopicsCommand, NotFoundException, SNSClient, SetTopicAttributesCommand, SubscribeCommand, TagResourceCommand, UnsubscribeCommand, UntagResourceCommand } from "@aws-sdk/client-sns";
|
|
@@ -65,6 +65,7 @@ import { createServer } from "node:net";
|
|
|
65
65
|
import { promisify } from "node:util";
|
|
66
66
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
67
67
|
import { readFile } from "fs/promises";
|
|
68
|
+
import { WebSocketServer } from "ws";
|
|
68
69
|
import { createServer as createServer$1 } from "node:http";
|
|
69
70
|
import { createServer as createServer$2 } from "node:https";
|
|
70
71
|
import * as chokidar from "chokidar";
|
|
@@ -30866,7 +30867,9 @@ async function deployCommand(stacks, options) {
|
|
|
30866
30867
|
output: options.output,
|
|
30867
30868
|
...options.region && { region: options.region },
|
|
30868
30869
|
...options.profile && { profile: options.profile },
|
|
30869
|
-
...Object.keys(context).length > 0 && { context }
|
|
30870
|
+
...Object.keys(context).length > 0 && { context },
|
|
30871
|
+
stateBucket,
|
|
30872
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
30870
30873
|
});
|
|
30871
30874
|
logger.debug(`Found ${allStacks.length} stack(s) in assembly`);
|
|
30872
30875
|
const stackPatterns = stacks.length > 0 ? stacks : options.stack ? [options.stack] : [];
|
|
@@ -31124,7 +31127,9 @@ async function diffCommand(stacks, options) {
|
|
|
31124
31127
|
output: options.output,
|
|
31125
31128
|
...options.region && { region: options.region },
|
|
31126
31129
|
...options.profile && { profile: options.profile },
|
|
31127
|
-
...Object.keys(context).length > 0 && { context }
|
|
31130
|
+
...Object.keys(context).length > 0 && { context },
|
|
31131
|
+
stateBucket,
|
|
31132
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
31128
31133
|
});
|
|
31129
31134
|
logger.info(`Found ${allStacks.length} stack(s) in assembly`);
|
|
31130
31135
|
const stackPatterns = stacks.length > 0 ? stacks : options.stack ? [options.stack] : [];
|
|
@@ -32477,7 +32482,9 @@ async function destroyCommand(stackArgs, options) {
|
|
|
32477
32482
|
appStacks = (await synthesizer.synthesize({
|
|
32478
32483
|
app: appCmd,
|
|
32479
32484
|
output: options.output || "cdk.out",
|
|
32480
|
-
...Object.keys(context).length > 0 && { context }
|
|
32485
|
+
...Object.keys(context).length > 0 && { context },
|
|
32486
|
+
stateBucket,
|
|
32487
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
32481
32488
|
})).stacks.map((s) => ({
|
|
32482
32489
|
stackName: s.stackName,
|
|
32483
32490
|
displayName: s.displayName,
|
|
@@ -33101,7 +33108,9 @@ async function orphanCommand(pathArgs, options) {
|
|
|
33101
33108
|
const resolved = resolveConstructPaths(pathArgs, (await synthesizer.synthesize({
|
|
33102
33109
|
app: appCmd,
|
|
33103
33110
|
output: options.output || "cdk.out",
|
|
33104
|
-
...Object.keys(context).length > 0 && { context }
|
|
33111
|
+
...Object.keys(context).length > 0 && { context },
|
|
33112
|
+
stateBucket,
|
|
33113
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
33105
33114
|
})).stacks);
|
|
33106
33115
|
const stackInfo = resolved.stack;
|
|
33107
33116
|
const orphanLogicalIds = resolved.logicalIds;
|
|
@@ -34775,143 +34784,6 @@ const STABLE_TERMINAL_STATUSES = new Set([
|
|
|
34775
34784
|
"IMPORT_ROLLBACK_COMPLETE"
|
|
34776
34785
|
]);
|
|
34777
34786
|
|
|
34778
|
-
//#endregion
|
|
34779
|
-
//#region src/cli/upload-cfn-template.ts
|
|
34780
|
-
/**
|
|
34781
|
-
* CloudFormation `TemplateBody` hard limit (51,200 bytes). Templates larger
|
|
34782
|
-
* than this cannot be submitted inline and must be uploaded to S3 and
|
|
34783
|
-
* referenced via `TemplateURL` instead — see {@link uploadCfnTemplate}.
|
|
34784
|
-
*/
|
|
34785
|
-
const CFN_TEMPLATE_BODY_LIMIT = 51200;
|
|
34786
|
-
/**
|
|
34787
|
-
* CloudFormation `TemplateURL` hard limit (1 MB / 1,048,576 bytes).
|
|
34788
|
-
* Templates larger than this are structurally unsubmittable through any
|
|
34789
|
-
* CloudFormation API — no S3 indirection helps. The caller surfaces a
|
|
34790
|
-
* pre-flight error pointing the user at template-splitting (nested stacks)
|
|
34791
|
-
* or shrinking inline asset payloads (`lambda.Code.fromAsset`).
|
|
34792
|
-
*/
|
|
34793
|
-
const CFN_TEMPLATE_URL_LIMIT = 1048576;
|
|
34794
|
-
/**
|
|
34795
|
-
* Shared S3 key prefix for transient CFn templates uploaded by `cdkd import
|
|
34796
|
-
* --migrate-from-cloudformation` and `cdkd export`. Kept distinct from
|
|
34797
|
-
* cdkd's `cdkd/` state prefix so `state list` / `state info` never conflate
|
|
34798
|
-
* transient migration artifacts with persisted stack state. The prefix is
|
|
34799
|
-
* intentionally human-grep-able — leftovers (if cleanup fails) point
|
|
34800
|
-
* straight at the offending stack name.
|
|
34801
|
-
*
|
|
34802
|
-
* Re-used by both commands so operator-facing audit trails (CloudTrail
|
|
34803
|
-
* records of the migrate-tmp uploads) stay consistent across the two
|
|
34804
|
-
* flows.
|
|
34805
|
-
*/
|
|
34806
|
-
const MIGRATE_TMP_PREFIX = "cdkd-migrate-tmp";
|
|
34807
|
-
/**
|
|
34808
|
-
* Upload a CFn template body to the cdkd state bucket and return both a
|
|
34809
|
-
* virtual-hosted-style HTTPS URL CloudFormation can fetch via
|
|
34810
|
-
* `TemplateURL` and a `cleanup` callback that deletes the object (and
|
|
34811
|
-
* destroys the S3 client).
|
|
34812
|
-
*
|
|
34813
|
-
* The state bucket's actual region is resolved via `GetBucketLocation`
|
|
34814
|
-
* (cached per-process) so the upload client and the URL match the
|
|
34815
|
-
* bucket's region — the calling CLI's profile region is irrelevant here.
|
|
34816
|
-
*
|
|
34817
|
-
* Cleanup is the caller's responsibility: invoke `cleanup` in a `finally`
|
|
34818
|
-
* around the CFn call. CloudFormation copies the template into its own
|
|
34819
|
-
* internal storage during the synchronous `CreateChangeSet` /
|
|
34820
|
-
* `UpdateStack` API call, so the S3 object is no longer needed after that
|
|
34821
|
-
* call returns (success or failure).
|
|
34822
|
-
*
|
|
34823
|
-
* Shared between `cdkd import --migrate-from-cloudformation` (via
|
|
34824
|
-
* `retire-cfn-stack.ts`) and `cdkd export` (via `commands/export.ts`) so
|
|
34825
|
-
* the upload + cleanup contract is single-sourced.
|
|
34826
|
-
*/
|
|
34827
|
-
async function uploadCfnTemplate(args) {
|
|
34828
|
-
const { bucket, body, stackName, format, s3ClientOpts } = args;
|
|
34829
|
-
const region = await resolveBucketRegion(bucket, {
|
|
34830
|
-
...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
|
|
34831
|
-
...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
|
|
34832
|
-
});
|
|
34833
|
-
const s3 = new S3Client({
|
|
34834
|
-
region,
|
|
34835
|
-
...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
|
|
34836
|
-
...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
|
|
34837
|
-
});
|
|
34838
|
-
const ext = format === "yaml" ? "yaml" : "json";
|
|
34839
|
-
const contentType = format === "yaml" ? "application/x-yaml" : "application/json";
|
|
34840
|
-
const key = `${MIGRATE_TMP_PREFIX}/${stackName}/${Date.now()}.${ext}`;
|
|
34841
|
-
try {
|
|
34842
|
-
await s3.send(new PutObjectCommand({
|
|
34843
|
-
Bucket: bucket,
|
|
34844
|
-
Key: key,
|
|
34845
|
-
Body: body,
|
|
34846
|
-
ContentType: contentType
|
|
34847
|
-
}));
|
|
34848
|
-
} catch (err) {
|
|
34849
|
-
s3.destroy();
|
|
34850
|
-
throw err;
|
|
34851
|
-
}
|
|
34852
|
-
const url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
|
34853
|
-
const cleanup = async () => {
|
|
34854
|
-
try {
|
|
34855
|
-
await s3.send(new DeleteObjectCommand({
|
|
34856
|
-
Bucket: bucket,
|
|
34857
|
-
Key: key
|
|
34858
|
-
}));
|
|
34859
|
-
} finally {
|
|
34860
|
-
s3.destroy();
|
|
34861
|
-
}
|
|
34862
|
-
};
|
|
34863
|
-
return {
|
|
34864
|
-
url,
|
|
34865
|
-
cleanup
|
|
34866
|
-
};
|
|
34867
|
-
}
|
|
34868
|
-
/**
|
|
34869
|
-
* Threshold (in bytes) above which a single resource's serialized
|
|
34870
|
-
* `Properties` block is considered an "inline payload" worth surfacing as
|
|
34871
|
-
* a contributor to a template that exceeds the 1 MB CFn `TemplateURL`
|
|
34872
|
-
* ceiling. 4 KB matches the typical inline `Code.ZipFile` Lambda payload
|
|
34873
|
-
* that pushes a multi-resource CDK app over the wire-format limit.
|
|
34874
|
-
*/
|
|
34875
|
-
const LARGE_INLINE_RESOURCE_THRESHOLD = 4096;
|
|
34876
|
-
/**
|
|
34877
|
-
* Walk a CFn template and surface every resource whose serialized
|
|
34878
|
-
* `Properties` block exceeds {@link LARGE_INLINE_RESOURCE_THRESHOLD}.
|
|
34879
|
-
* Used to build the actionable "offending resources" list in the
|
|
34880
|
-
* pre-flight error when a template exceeds the 1 MB `TemplateURL`
|
|
34881
|
-
* ceiling — typical culprits are inline `Code.ZipFile` Lambdas, inline
|
|
34882
|
-
* StepFunctions definitions, or large `AWS::CloudFormation::Stack`
|
|
34883
|
-
* bodies.
|
|
34884
|
-
*
|
|
34885
|
-
* Returns entries sorted by `approxBytes` descending so the user sees
|
|
34886
|
-
* the biggest contributor first. A non-CFn-template input (no
|
|
34887
|
-
* `Resources` object) returns an empty array.
|
|
34888
|
-
*/
|
|
34889
|
-
function findLargeInlineResources(template, threshold = LARGE_INLINE_RESOURCE_THRESHOLD) {
|
|
34890
|
-
const result = [];
|
|
34891
|
-
const resources = template["Resources"];
|
|
34892
|
-
if (!resources || typeof resources !== "object" || Array.isArray(resources)) return result;
|
|
34893
|
-
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
34894
|
-
if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
|
|
34895
|
-
const r = resource;
|
|
34896
|
-
const resourceType = typeof r["Type"] === "string" ? r["Type"] : "<unknown>";
|
|
34897
|
-
const properties = r["Properties"];
|
|
34898
|
-
if (properties === void 0 || properties === null) continue;
|
|
34899
|
-
let approxBytes;
|
|
34900
|
-
try {
|
|
34901
|
-
approxBytes = JSON.stringify(properties).length;
|
|
34902
|
-
} catch {
|
|
34903
|
-
continue;
|
|
34904
|
-
}
|
|
34905
|
-
if (approxBytes >= threshold) result.push({
|
|
34906
|
-
logicalId,
|
|
34907
|
-
resourceType,
|
|
34908
|
-
approxBytes
|
|
34909
|
-
});
|
|
34910
|
-
}
|
|
34911
|
-
result.sort((a, b) => b.approxBytes - a.approxBytes);
|
|
34912
|
-
return result;
|
|
34913
|
-
}
|
|
34914
|
-
|
|
34915
34787
|
//#endregion
|
|
34916
34788
|
//#region src/cli/yaml-cfn.ts
|
|
34917
34789
|
/**
|
|
@@ -35498,7 +35370,9 @@ async function importCommand(stackArg, options) {
|
|
|
35498
35370
|
const result = await synthesizer.synthesize({
|
|
35499
35371
|
app: appCmd,
|
|
35500
35372
|
output: options.output || "cdk.out",
|
|
35501
|
-
...Object.keys(context).length > 0 && { context }
|
|
35373
|
+
...Object.keys(context).length > 0 && { context },
|
|
35374
|
+
stateBucket,
|
|
35375
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
35502
35376
|
});
|
|
35503
35377
|
let stackInfo;
|
|
35504
35378
|
if (stackArg) {
|
|
@@ -38839,6 +38713,7 @@ async function runDetached(opts) {
|
|
|
38839
38713
|
if (opts.name) args.push("--name", opts.name);
|
|
38840
38714
|
if (opts.network) args.push("--network", opts.network);
|
|
38841
38715
|
if (opts.platform) args.push("--platform", opts.platform);
|
|
38716
|
+
if (opts.extraHosts) for (const entry of opts.extraHosts) args.push("--add-host", `${entry.host}:${entry.ip}`);
|
|
38842
38717
|
const host = opts.host ?? "127.0.0.1";
|
|
38843
38718
|
args.push("-p", `${host}:${opts.hostPort}:8080`);
|
|
38844
38719
|
if (opts.debugPort !== void 0) args.push("-p", `${host}:${opts.debugPort}:${opts.debugPort}`);
|
|
@@ -40488,13 +40363,13 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40488
40363
|
const integration = props["Integration"];
|
|
40489
40364
|
if (!integration) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): missing Integration property`);
|
|
40490
40365
|
const restApiId = props["RestApiId"];
|
|
40491
|
-
const restApiLogicalId = pickRefLogicalId$
|
|
40366
|
+
const restApiLogicalId = pickRefLogicalId$3(restApiId);
|
|
40492
40367
|
if (!restApiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): RestApiId must be a { Ref: '...' } reference (got ${shortJson$1(restApiId)}).`);
|
|
40493
40368
|
const resourceId = props["ResourceId"];
|
|
40494
40369
|
const path = buildRestV1Path(resourceId, restApiLogicalId, template, stackName, logicalId);
|
|
40495
40370
|
const httpMethod = stringifyValue(props["HttpMethod"] ?? "ANY");
|
|
40496
40371
|
const stage = pickRestV1Stage(restApiLogicalId, template);
|
|
40497
|
-
const restApiCdkPath = readApiCdkPath(restApiLogicalId, template);
|
|
40372
|
+
const restApiCdkPath = readApiCdkPath$1(restApiLogicalId, template);
|
|
40498
40373
|
const baseRoute = {
|
|
40499
40374
|
source: "rest-v1",
|
|
40500
40375
|
apiVersion: "v1",
|
|
@@ -40848,7 +40723,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40848
40723
|
if (Array.isArray(arg) && arg.length === 2 && arg[1] === "RootResourceId") return "/";
|
|
40849
40724
|
}
|
|
40850
40725
|
}
|
|
40851
|
-
const resourceLogicalId = pickRefLogicalId$
|
|
40726
|
+
const resourceLogicalId = pickRefLogicalId$3(resourceIdIntrinsic);
|
|
40852
40727
|
if (!resourceLogicalId) throw new Error(`${stackName}/${methodLogicalId}: ResourceId must be { Ref: '...' } or { 'Fn::GetAtt': [..., 'RootResourceId'] } (got ${shortJson$1(resourceIdIntrinsic)}).`);
|
|
40853
40728
|
const segments = [];
|
|
40854
40729
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -40868,7 +40743,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40868
40743
|
const arg = parentId["Fn::GetAtt"];
|
|
40869
40744
|
if (Array.isArray(arg) && arg[1] === "RootResourceId") break;
|
|
40870
40745
|
}
|
|
40871
|
-
cursor = pickRefLogicalId$
|
|
40746
|
+
cursor = pickRefLogicalId$3(parentId) ?? void 0;
|
|
40872
40747
|
}
|
|
40873
40748
|
return "/" + segments.join("/");
|
|
40874
40749
|
}
|
|
@@ -40883,7 +40758,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40883
40758
|
for (const [, resource] of Object.entries(resources)) {
|
|
40884
40759
|
if (resource.Type !== "AWS::ApiGateway::Stage") continue;
|
|
40885
40760
|
const props = resource.Properties ?? {};
|
|
40886
|
-
if (pickRefLogicalId$
|
|
40761
|
+
if (pickRefLogicalId$3(props["RestApiId"]) === restApiLogicalId) {
|
|
40887
40762
|
const stageName = props["StageName"];
|
|
40888
40763
|
if (typeof stageName === "string") return stageName;
|
|
40889
40764
|
}
|
|
@@ -40902,26 +40777,14 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40902
40777
|
function discoverHttpApiRoute(logicalId, resource, template, stackName) {
|
|
40903
40778
|
const props = resource.Properties ?? {};
|
|
40904
40779
|
const apiId = props["ApiId"];
|
|
40905
|
-
const apiLogicalId = pickRefLogicalId$
|
|
40780
|
+
const apiLogicalId = pickRefLogicalId$3(apiId);
|
|
40906
40781
|
if (!apiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): ApiId must be { Ref: '...' } (got ${shortJson$1(apiId)}).`);
|
|
40907
40782
|
const routeKey = props["RouteKey"];
|
|
40908
40783
|
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): RouteKey must be a string`);
|
|
40909
|
-
const apiCdkPath = readApiCdkPath(apiLogicalId, template);
|
|
40784
|
+
const apiCdkPath = readApiCdkPath$1(apiLogicalId, template);
|
|
40910
40785
|
const apiResource = template.Resources?.[apiLogicalId];
|
|
40911
40786
|
if (apiResource?.Type === "AWS::ApiGatewayV2::Api") {
|
|
40912
|
-
if ((apiResource.Properties ?? {})["ProtocolType"] === "WEBSOCKET") return [
|
|
40913
|
-
method: "ANY",
|
|
40914
|
-
pathPattern: routeKey,
|
|
40915
|
-
lambdaLogicalId: "",
|
|
40916
|
-
source: "http-api",
|
|
40917
|
-
apiVersion: "v2",
|
|
40918
|
-
stage: "$default",
|
|
40919
|
-
apiLogicalId,
|
|
40920
|
-
apiStackName: stackName,
|
|
40921
|
-
...apiCdkPath !== void 0 && { apiCdkPath },
|
|
40922
|
-
declaredAt: `${stackName}/${logicalId}`,
|
|
40923
|
-
unsupported: { reason: `${stackName}/${logicalId}: WebSocket APIs are not supported in cdkd local start-api.` }
|
|
40924
|
-
}];
|
|
40787
|
+
if ((apiResource.Properties ?? {})["ProtocolType"] === "WEBSOCKET") return [];
|
|
40925
40788
|
}
|
|
40926
40789
|
const { method, pathPattern } = parseRouteKey(routeKey);
|
|
40927
40790
|
const baseRoute = {
|
|
@@ -41023,7 +40886,7 @@ function discoverFunctionUrl(logicalId, resource, template, stackName) {
|
|
|
41023
40886
|
const arnOutcome = resolveLambdaArnOutcome(targetArn);
|
|
41024
40887
|
if (arnOutcome.kind === "unsupported") throw new Error(`${stackName}/${logicalId}.TargetFunctionArn: ${arnOutcome.detail} (got ${shortJson$1(targetArn)}).`);
|
|
41025
40888
|
const lambdaLogicalId = arnOutcome.logicalId;
|
|
41026
|
-
const lambdaCdkPath = readApiCdkPath(lambdaLogicalId, template);
|
|
40889
|
+
const lambdaCdkPath = readApiCdkPath$1(lambdaLogicalId, template);
|
|
41027
40890
|
const baseRoute = {
|
|
41028
40891
|
method: "ANY",
|
|
41029
40892
|
pathPattern: "/{proxy+}",
|
|
@@ -41060,7 +40923,7 @@ function discoverFunctionUrl(logicalId, resource, template, stackName) {
|
|
|
41060
40923
|
* metadata isn't set. Hides the "may be missing for a hand-rolled
|
|
41061
40924
|
* `cfn.Resource`" branch from every call site.
|
|
41062
40925
|
*/
|
|
41063
|
-
function readApiCdkPath(logicalId, template) {
|
|
40926
|
+
function readApiCdkPath$1(logicalId, template) {
|
|
41064
40927
|
const resource = template.Resources?.[logicalId];
|
|
41065
40928
|
if (!resource) return void 0;
|
|
41066
40929
|
return readCdkPath(resource) || void 0;
|
|
@@ -41127,11 +40990,11 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
41127
40990
|
const sep = join[0];
|
|
41128
40991
|
const parts = join[1];
|
|
41129
40992
|
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
41130
|
-
const ref = pickRefLogicalId$
|
|
40993
|
+
const ref = pickRefLogicalId$3(parts[1]);
|
|
41131
40994
|
if (ref) return ref;
|
|
41132
40995
|
}
|
|
41133
40996
|
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
41134
|
-
const ref = pickRefLogicalId$
|
|
40997
|
+
const ref = pickRefLogicalId$3(parts[1]);
|
|
41135
40998
|
if (ref) return ref;
|
|
41136
40999
|
}
|
|
41137
41000
|
}
|
|
@@ -41151,7 +41014,7 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
41151
41014
|
if (m) {
|
|
41152
41015
|
const bound = bindings[m[1]];
|
|
41153
41016
|
if (bound !== void 0) {
|
|
41154
|
-
const ref = pickRefLogicalId$
|
|
41017
|
+
const ref = pickRefLogicalId$3(bound);
|
|
41155
41018
|
if (ref) return ref;
|
|
41156
41019
|
}
|
|
41157
41020
|
}
|
|
@@ -41180,7 +41043,7 @@ function parseRouteKey(routeKey) {
|
|
|
41180
41043
|
* If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
|
|
41181
41044
|
* logical ID. Otherwise return `null`.
|
|
41182
41045
|
*/
|
|
41183
|
-
function pickRefLogicalId$
|
|
41046
|
+
function pickRefLogicalId$3(value) {
|
|
41184
41047
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41185
41048
|
const ref = value["Ref"];
|
|
41186
41049
|
if (typeof ref === "string") return ref;
|
|
@@ -41200,6 +41063,983 @@ function shortJson$1(value) {
|
|
|
41200
41063
|
}
|
|
41201
41064
|
}
|
|
41202
41065
|
|
|
41066
|
+
//#endregion
|
|
41067
|
+
//#region src/local/websocket-route-discovery.ts
|
|
41068
|
+
const DEFAULT_ROUTE_SELECTION_EXPRESSION = "$request.body.action";
|
|
41069
|
+
const DEFAULT_STAGE = "local";
|
|
41070
|
+
/**
|
|
41071
|
+
* Walk every synthesized stack and produce one {@link DiscoveredWebSocketApi}
|
|
41072
|
+
* per WebSocket API found. Errors per API are aggregated and surfaced
|
|
41073
|
+
* as a single {@link RouteDiscoveryError} (matches the HTTP-side
|
|
41074
|
+
* discovery behavior — a single malformed API shouldn't abort the
|
|
41075
|
+
* server boot for sibling APIs).
|
|
41076
|
+
*
|
|
41077
|
+
* Resolution chain:
|
|
41078
|
+
* 1. Find each `AWS::ApiGatewayV2::Api` with `ProtocolType: WEBSOCKET`.
|
|
41079
|
+
* 2. Validate `RouteSelectionExpression` (only `$request.body.<key>`
|
|
41080
|
+
* forms supported in v1).
|
|
41081
|
+
* 3. Resolve attached Stage (first Stage referencing this API).
|
|
41082
|
+
* 4. Walk every `AWS::ApiGatewayV2::Route` referencing this API,
|
|
41083
|
+
* resolve each route's Target → Integration → IntegrationUri →
|
|
41084
|
+
* Lambda logical ID via the shared `resolveLambdaArnIntrinsic`
|
|
41085
|
+
* helper (handles every CFn intrinsic shape CDK emits).
|
|
41086
|
+
*/
|
|
41087
|
+
function discoverWebSocketApis(stacks) {
|
|
41088
|
+
const apis = [];
|
|
41089
|
+
const errors = [];
|
|
41090
|
+
for (const stack of stacks) {
|
|
41091
|
+
const template = stack.template;
|
|
41092
|
+
const resources = template.Resources ?? {};
|
|
41093
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
41094
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Api") continue;
|
|
41095
|
+
if ((resource.Properties ?? {})["ProtocolType"] !== "WEBSOCKET") continue;
|
|
41096
|
+
try {
|
|
41097
|
+
apis.push(discoverOneApi(logicalId, resource, template, stack.stackName));
|
|
41098
|
+
} catch (err) {
|
|
41099
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
41100
|
+
}
|
|
41101
|
+
}
|
|
41102
|
+
}
|
|
41103
|
+
return {
|
|
41104
|
+
apis,
|
|
41105
|
+
errors
|
|
41106
|
+
};
|
|
41107
|
+
}
|
|
41108
|
+
function discoverOneApi(logicalId, resource, template, stackName) {
|
|
41109
|
+
const props = resource.Properties ?? {};
|
|
41110
|
+
const declaredAt = `${stackName}/${logicalId}`;
|
|
41111
|
+
const rawSelection = props["RouteSelectionExpression"];
|
|
41112
|
+
const routeSelectionExpression = typeof rawSelection === "string" && rawSelection.length > 0 ? rawSelection : DEFAULT_ROUTE_SELECTION_EXPRESSION;
|
|
41113
|
+
assertSupportedSelectionExpression(routeSelectionExpression, declaredAt);
|
|
41114
|
+
const stage = pickStage$1(logicalId, template);
|
|
41115
|
+
const apiCdkPath = readApiCdkPath(logicalId, template);
|
|
41116
|
+
const routes = collectRoutesForApi(logicalId, template, stackName);
|
|
41117
|
+
if (routes.length === 0) throw new Error(`${declaredAt}: WebSocket API has no AWS::ApiGatewayV2::Route children — at least one route (typically '$connect') is required to dispatch.`);
|
|
41118
|
+
const authRoutes = collectAuthRoutesForApi(logicalId, template, stackName);
|
|
41119
|
+
const unsupported = authRoutes.length > 0 ? { reason: `WebSocket API requires authorizer support, which cdkd v1 does not emulate. Affected route(s): ${authRoutes.map((r) => `${r.routeKey} [AuthorizationType=${r.authorizationType}]`).join(", ")}. The API will be discovered but no upgrade requests will be accepted on this server.` } : void 0;
|
|
41120
|
+
return {
|
|
41121
|
+
apiLogicalId: logicalId,
|
|
41122
|
+
apiStackName: stackName,
|
|
41123
|
+
declaredAt,
|
|
41124
|
+
...apiCdkPath !== "" && { apiCdkPath },
|
|
41125
|
+
routeSelectionExpression,
|
|
41126
|
+
stage,
|
|
41127
|
+
routes,
|
|
41128
|
+
...unsupported !== void 0 && { unsupported }
|
|
41129
|
+
};
|
|
41130
|
+
}
|
|
41131
|
+
/**
|
|
41132
|
+
* Scan the synthesized template for every `AWS::ApiGatewayV2::Route`
|
|
41133
|
+
* referencing the given WebSocket API, returning the subset whose
|
|
41134
|
+
* `AuthorizationType` is set to anything other than `NONE` (the
|
|
41135
|
+
* AWS-default when omitted). Used by {@link discoverOneApi} to tag
|
|
41136
|
+
* the parent API as unsupported when v1's no-authorizer emulation
|
|
41137
|
+
* gap would otherwise let unauthenticated clients through.
|
|
41138
|
+
*/
|
|
41139
|
+
function collectAuthRoutesForApi(apiLogicalId, template, _stackName) {
|
|
41140
|
+
const resources = template.Resources ?? {};
|
|
41141
|
+
const result = [];
|
|
41142
|
+
for (const [, resource] of Object.entries(resources)) {
|
|
41143
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
|
|
41144
|
+
const props = resource.Properties ?? {};
|
|
41145
|
+
if (pickRefLogicalId$2(props["ApiId"]) !== apiLogicalId) continue;
|
|
41146
|
+
const authType = props["AuthorizationType"];
|
|
41147
|
+
if (typeof authType !== "string" || authType.length === 0) continue;
|
|
41148
|
+
if (authType === "NONE") continue;
|
|
41149
|
+
const routeKey = props["RouteKey"];
|
|
41150
|
+
result.push({
|
|
41151
|
+
routeKey: typeof routeKey === "string" ? routeKey : "<unknown>",
|
|
41152
|
+
authorizationType: authType
|
|
41153
|
+
});
|
|
41154
|
+
}
|
|
41155
|
+
return result;
|
|
41156
|
+
}
|
|
41157
|
+
/**
|
|
41158
|
+
* `$request.body.<key>` is the AWS-canonical shape and the only one
|
|
41159
|
+
* v1 supports. Allow nested dot access (`$request.body.action.subKey`)
|
|
41160
|
+
* — real CDK chat apps sometimes use this for protocol versioning.
|
|
41161
|
+
*
|
|
41162
|
+
* Reject array-index access (`$request.body.items[0]`), filter
|
|
41163
|
+
* expressions, header / context selections — these would require a
|
|
41164
|
+
* fuller JSONPath / VTL evaluator and are out of scope for v1.
|
|
41165
|
+
*/
|
|
41166
|
+
function assertSupportedSelectionExpression(expr, declaredAt) {
|
|
41167
|
+
if (!/^\$request\.body(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(expr)) throw new Error(`${declaredAt}: RouteSelectionExpression '${expr}' is not supported in cdkd local start-api v1 — only '$request.body.<key>' shapes (optionally nested via dots) are recognized. File a follow-up issue if you need '$request.header.X' / '$context.X' / array-index access.`);
|
|
41168
|
+
}
|
|
41169
|
+
/**
|
|
41170
|
+
* Parse a `$request.body.x.y` selection expression into the JSON-path
|
|
41171
|
+
* tokens after `$request.body`. Returns `['x', 'y']` for the example
|
|
41172
|
+
* above. Used at message-dispatch time to walk the parsed message body.
|
|
41173
|
+
*/
|
|
41174
|
+
function parseSelectionExpressionPath(expr) {
|
|
41175
|
+
const m = /^\$request\.body\.(.+)$/.exec(expr);
|
|
41176
|
+
if (!m) return [];
|
|
41177
|
+
return m[1].split(".");
|
|
41178
|
+
}
|
|
41179
|
+
/**
|
|
41180
|
+
* Pick the first `AWS::ApiGatewayV2::Stage` referencing the API. CDK's
|
|
41181
|
+
* `apigatewayv2.WebSocketStage` always emits one; the fallback to
|
|
41182
|
+
* `'local'` handles hand-rolled templates without a Stage.
|
|
41183
|
+
*/
|
|
41184
|
+
function pickStage$1(apiLogicalId, template) {
|
|
41185
|
+
const resources = template.Resources ?? {};
|
|
41186
|
+
for (const [, resource] of Object.entries(resources)) {
|
|
41187
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Stage") continue;
|
|
41188
|
+
const props = resource.Properties ?? {};
|
|
41189
|
+
if (pickRefLogicalId$2(props["ApiId"]) === apiLogicalId) {
|
|
41190
|
+
const stageName = props["StageName"];
|
|
41191
|
+
if (typeof stageName === "string" && stageName.length > 0) return stageName;
|
|
41192
|
+
}
|
|
41193
|
+
}
|
|
41194
|
+
return DEFAULT_STAGE;
|
|
41195
|
+
}
|
|
41196
|
+
/**
|
|
41197
|
+
* Walk every `AWS::ApiGatewayV2::Route` and resolve each one whose
|
|
41198
|
+
* parent `ApiId` Ref matches the WebSocket API. Per-route failures
|
|
41199
|
+
* abort the API's discovery (a partial route map would silently
|
|
41200
|
+
* disable some routes — better to fail fast and let the user fix the
|
|
41201
|
+
* template).
|
|
41202
|
+
*/
|
|
41203
|
+
function collectRoutesForApi(apiLogicalId, template, stackName) {
|
|
41204
|
+
const resources = template.Resources ?? {};
|
|
41205
|
+
const result = [];
|
|
41206
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
41207
|
+
for (const [routeLogicalId, resource] of Object.entries(resources)) {
|
|
41208
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
|
|
41209
|
+
const props = resource.Properties ?? {};
|
|
41210
|
+
if (pickRefLogicalId$2(props["ApiId"]) !== apiLogicalId) continue;
|
|
41211
|
+
const declaredAt = `${stackName}/${routeLogicalId}`;
|
|
41212
|
+
const routeKey = props["RouteKey"];
|
|
41213
|
+
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${declaredAt}: RouteKey must be a non-empty string.`);
|
|
41214
|
+
if (seenKeys.has(routeKey)) throw new Error(`${declaredAt}: WebSocket API has duplicate RouteKey '${routeKey}' — each RouteKey may appear at most once per API.`);
|
|
41215
|
+
seenKeys.add(routeKey);
|
|
41216
|
+
const targetLogicalId = parseRouteTarget(props["Target"], declaredAt);
|
|
41217
|
+
const integration = resources[targetLogicalId];
|
|
41218
|
+
if (!integration || integration.Type !== "AWS::ApiGatewayV2::Integration") throw new Error(`${declaredAt}: Target points at '${targetLogicalId}' which is not an AWS::ApiGatewayV2::Integration.`);
|
|
41219
|
+
const integrationProps = integration.Properties ?? {};
|
|
41220
|
+
const integrationType = integrationProps["IntegrationType"];
|
|
41221
|
+
if (integrationType !== "AWS_PROXY") throw new Error(`${declaredAt}: WebSocket route IntegrationType '${String(integrationType)}' is not supported in cdkd local start-api v1 — only AWS_PROXY (Lambda) integrations are emulated.`);
|
|
41222
|
+
const arnOutcome = resolveLambdaArnIntrinsic(integrationProps["IntegrationUri"]);
|
|
41223
|
+
if (arnOutcome.kind === "unsupported") throw new Error(`${stackName}/${targetLogicalId}.IntegrationUri: ${arnOutcome.detail} — WebSocket routes must point at a same-template Lambda.`);
|
|
41224
|
+
result.push({
|
|
41225
|
+
routeKey,
|
|
41226
|
+
targetLambdaLogicalId: arnOutcome.logicalId,
|
|
41227
|
+
lambdaStackName: stackName,
|
|
41228
|
+
declaredAt
|
|
41229
|
+
});
|
|
41230
|
+
}
|
|
41231
|
+
return result;
|
|
41232
|
+
}
|
|
41233
|
+
/**
|
|
41234
|
+
* WebSocket Routes use the same `Target: 'integrations/<id>'` shape as
|
|
41235
|
+
* HTTP API v2 Routes. We accept the same five forms documented in
|
|
41236
|
+
* `route-discovery.ts:parseHttpApiTargetIntegration` — literal string,
|
|
41237
|
+
* two `Fn::Join` shapes, two `Fn::Sub` shapes.
|
|
41238
|
+
*
|
|
41239
|
+
* Implementation note: we intentionally duplicate the parser rather
|
|
41240
|
+
* than reach into `route-discovery.ts` because that module is in flux
|
|
41241
|
+
* (`unsupported` / `mockCors` shapes; HTTP-specific). When the two
|
|
41242
|
+
* parsers grow apart, the WebSocket one only needs to track the AWS
|
|
41243
|
+
* WebSocket-side shape — which has been stable since 2018.
|
|
41244
|
+
*/
|
|
41245
|
+
function parseRouteTarget(target, location) {
|
|
41246
|
+
if (typeof target === "string") {
|
|
41247
|
+
const m = /^integrations\/(.+)$/.exec(target);
|
|
41248
|
+
if (m) return m[1];
|
|
41249
|
+
throw new Error(`${location}: literal Target '${target}' must start with 'integrations/'.`);
|
|
41250
|
+
}
|
|
41251
|
+
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
41252
|
+
const obj = target;
|
|
41253
|
+
const join = obj["Fn::Join"];
|
|
41254
|
+
if (Array.isArray(join) && join.length === 2 && Array.isArray(join[1])) {
|
|
41255
|
+
const sep = join[0];
|
|
41256
|
+
const parts = join[1];
|
|
41257
|
+
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
41258
|
+
const ref = pickRefLogicalId$2(parts[1]);
|
|
41259
|
+
if (ref) return ref;
|
|
41260
|
+
}
|
|
41261
|
+
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
41262
|
+
const ref = pickRefLogicalId$2(parts[1]);
|
|
41263
|
+
if (ref) return ref;
|
|
41264
|
+
}
|
|
41265
|
+
}
|
|
41266
|
+
if ("Fn::Sub" in obj) {
|
|
41267
|
+
const sub = obj["Fn::Sub"];
|
|
41268
|
+
const prefix = "integrations/";
|
|
41269
|
+
if (typeof sub === "string") {
|
|
41270
|
+
const m = new RegExp(`^${prefix}\\$\\{([^}]+)\\}$`).exec(sub);
|
|
41271
|
+
if (m) {
|
|
41272
|
+
const placeholder = m[1];
|
|
41273
|
+
if (!placeholder.includes(".")) return placeholder;
|
|
41274
|
+
}
|
|
41275
|
+
}
|
|
41276
|
+
if (Array.isArray(sub) && sub.length === 2 && typeof sub[0] === "string" && sub[1] !== null && typeof sub[1] === "object" && !Array.isArray(sub[1])) {
|
|
41277
|
+
const template = sub[0];
|
|
41278
|
+
const bindings = sub[1];
|
|
41279
|
+
const m = new RegExp(`^${prefix}\\$\\{([^}]+)\\}$`).exec(template);
|
|
41280
|
+
if (m) {
|
|
41281
|
+
const bound = bindings[m[1]];
|
|
41282
|
+
if (bound !== void 0) {
|
|
41283
|
+
const ref = pickRefLogicalId$2(bound);
|
|
41284
|
+
if (ref) return ref;
|
|
41285
|
+
}
|
|
41286
|
+
}
|
|
41287
|
+
}
|
|
41288
|
+
}
|
|
41289
|
+
}
|
|
41290
|
+
throw new Error(`${location}: Target must be 'integrations/<id>' literal, Fn::Join with the documented shapes, or Fn::Sub with an 'integrations/\${...}' template.`);
|
|
41291
|
+
}
|
|
41292
|
+
function pickRefLogicalId$2(value) {
|
|
41293
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41294
|
+
const ref = value["Ref"];
|
|
41295
|
+
if (typeof ref === "string") return ref;
|
|
41296
|
+
}
|
|
41297
|
+
return null;
|
|
41298
|
+
}
|
|
41299
|
+
function readApiCdkPath(logicalId, template) {
|
|
41300
|
+
const resource = template.Resources?.[logicalId];
|
|
41301
|
+
if (!resource) return "";
|
|
41302
|
+
return readCdkPath(resource);
|
|
41303
|
+
}
|
|
41304
|
+
|
|
41305
|
+
//#endregion
|
|
41306
|
+
//#region src/local/websocket-event.ts
|
|
41307
|
+
const MOCK_DOMAIN_NAME$1 = "localhost";
|
|
41308
|
+
const MOCK_API_ID$1 = "local";
|
|
41309
|
+
/**
|
|
41310
|
+
* Build a request-context block shared across all three event types.
|
|
41311
|
+
* `eventType` / `routeKey` are passed in by the per-event caller; the
|
|
41312
|
+
* shared block produces fresh `requestId` / `extendedRequestId` per
|
|
41313
|
+
* event (matching AWS-deployed behavior — these are NOT stable across
|
|
41314
|
+
* events on the same connection).
|
|
41315
|
+
*/
|
|
41316
|
+
function buildRequestContext(routeKey, eventType, connectionId, connectedAt, stage, snapshot) {
|
|
41317
|
+
const now = Date.now();
|
|
41318
|
+
return {
|
|
41319
|
+
routeKey,
|
|
41320
|
+
eventType,
|
|
41321
|
+
connectionId,
|
|
41322
|
+
extendedRequestId: randomUUID(),
|
|
41323
|
+
requestTime: formatRequestTime$1(now),
|
|
41324
|
+
requestTimeEpoch: now,
|
|
41325
|
+
messageDirection: "IN",
|
|
41326
|
+
stage,
|
|
41327
|
+
connectedAt,
|
|
41328
|
+
requestId: randomUUID(),
|
|
41329
|
+
domainName: MOCK_DOMAIN_NAME$1,
|
|
41330
|
+
apiId: MOCK_API_ID$1,
|
|
41331
|
+
authorizer: null,
|
|
41332
|
+
identity: {
|
|
41333
|
+
sourceIp: snapshot.sourceIp ?? "127.0.0.1",
|
|
41334
|
+
userAgent: snapshot.userAgent ?? ""
|
|
41335
|
+
}
|
|
41336
|
+
};
|
|
41337
|
+
}
|
|
41338
|
+
/**
|
|
41339
|
+
* Build the `$connect` event. AWS WebSocket APIs fire `$connect` ONCE
|
|
41340
|
+
* per client connection. Handler returns `{statusCode: 200}` to allow
|
|
41341
|
+
* the connection, anything else (or throws) to deny — cdkd matches the
|
|
41342
|
+
* deployed behavior by checking the response in the caller.
|
|
41343
|
+
*/
|
|
41344
|
+
function buildConnectEvent(opts) {
|
|
41345
|
+
const headers = normalizeHeaders(opts.snapshot.headers);
|
|
41346
|
+
const multiValueHeaders = lowercaseMultiValueHeaders(opts.snapshot.headers);
|
|
41347
|
+
return {
|
|
41348
|
+
...headers !== void 0 && { headers },
|
|
41349
|
+
...multiValueHeaders !== void 0 && { multiValueHeaders },
|
|
41350
|
+
queryStringParameters: opts.snapshot.queryStringParameters ?? null,
|
|
41351
|
+
multiValueQueryStringParameters: opts.snapshot.multiValueQueryStringParameters ?? null,
|
|
41352
|
+
requestContext: { ...buildRequestContext("$connect", "CONNECT", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot) },
|
|
41353
|
+
isBase64Encoded: false,
|
|
41354
|
+
body: ""
|
|
41355
|
+
};
|
|
41356
|
+
}
|
|
41357
|
+
/**
|
|
41358
|
+
* Build a MESSAGE event. Fires for every frame the client sends. The
|
|
41359
|
+
* route the API dispatches to is resolved upstream by the route
|
|
41360
|
+
* selection-expression layer; the resolved `routeKey` (`$default` or
|
|
41361
|
+
* a custom string) lands on `requestContext.routeKey`.
|
|
41362
|
+
*/
|
|
41363
|
+
function buildMessageEvent(opts) {
|
|
41364
|
+
return {
|
|
41365
|
+
requestContext: {
|
|
41366
|
+
...buildRequestContext(opts.routeKey, "MESSAGE", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot),
|
|
41367
|
+
messageId: randomUUID()
|
|
41368
|
+
},
|
|
41369
|
+
isBase64Encoded: opts.isBase64Encoded,
|
|
41370
|
+
body: opts.body
|
|
41371
|
+
};
|
|
41372
|
+
}
|
|
41373
|
+
/**
|
|
41374
|
+
* Build the `$disconnect` event. Fires when the WebSocket closes from
|
|
41375
|
+
* either side (client / server / abnormal). The Lambda's response is
|
|
41376
|
+
* ignored (the socket is already gone); AWS still invokes the handler
|
|
41377
|
+
* for cleanup / logging side effects.
|
|
41378
|
+
*
|
|
41379
|
+
* `disconnectStatusCode` / `disconnectReason` are taken from the
|
|
41380
|
+
* WebSocket close frame (RFC 6455 §7.1.5 — close codes such as 1000
|
|
41381
|
+
* normal / 1001 going-away / 1008 policy-violation).
|
|
41382
|
+
*/
|
|
41383
|
+
function buildDisconnectEvent(opts) {
|
|
41384
|
+
return {
|
|
41385
|
+
requestContext: {
|
|
41386
|
+
...buildRequestContext("$disconnect", "DISCONNECT", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot),
|
|
41387
|
+
...opts.disconnectStatusCode !== void 0 && { disconnectStatusCode: opts.disconnectStatusCode },
|
|
41388
|
+
...opts.disconnectReason !== void 0 && { disconnectReason: opts.disconnectReason }
|
|
41389
|
+
},
|
|
41390
|
+
isBase64Encoded: false,
|
|
41391
|
+
body: ""
|
|
41392
|
+
};
|
|
41393
|
+
}
|
|
41394
|
+
/**
|
|
41395
|
+
* Format a timestamp in the AWS-canonical `dd/MMM/yyyy:HH:mm:ss +0000`
|
|
41396
|
+
* shape that AWS API Gateway emits on `requestContext.requestTime`.
|
|
41397
|
+
* Always UTC (matches AWS-deployed behavior, which is region-independent).
|
|
41398
|
+
*/
|
|
41399
|
+
function formatRequestTime$1(epochMs) {
|
|
41400
|
+
const d = new Date(epochMs);
|
|
41401
|
+
return `${String(d.getUTCDate()).padStart(2, "0")}/${[
|
|
41402
|
+
"Jan",
|
|
41403
|
+
"Feb",
|
|
41404
|
+
"Mar",
|
|
41405
|
+
"Apr",
|
|
41406
|
+
"May",
|
|
41407
|
+
"Jun",
|
|
41408
|
+
"Jul",
|
|
41409
|
+
"Aug",
|
|
41410
|
+
"Sep",
|
|
41411
|
+
"Oct",
|
|
41412
|
+
"Nov",
|
|
41413
|
+
"Dec"
|
|
41414
|
+
][d.getUTCMonth()]}/${d.getUTCFullYear()}:${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} +0000`;
|
|
41415
|
+
}
|
|
41416
|
+
/**
|
|
41417
|
+
* Lowercase header keys, comma-join duplicates per AWS spec.
|
|
41418
|
+
*/
|
|
41419
|
+
function normalizeHeaders(headers) {
|
|
41420
|
+
const out = {};
|
|
41421
|
+
let any = false;
|
|
41422
|
+
for (const [name, values] of Object.entries(headers)) {
|
|
41423
|
+
if (values.length === 0) continue;
|
|
41424
|
+
out[name.toLowerCase()] = values.join(",");
|
|
41425
|
+
any = true;
|
|
41426
|
+
}
|
|
41427
|
+
return any ? out : void 0;
|
|
41428
|
+
}
|
|
41429
|
+
/**
|
|
41430
|
+
* Lowercase header keys, preserve multi-value array shape.
|
|
41431
|
+
*/
|
|
41432
|
+
function lowercaseMultiValueHeaders(headers) {
|
|
41433
|
+
const out = {};
|
|
41434
|
+
let any = false;
|
|
41435
|
+
for (const [name, values] of Object.entries(headers)) {
|
|
41436
|
+
if (values.length === 0) continue;
|
|
41437
|
+
out[name.toLowerCase()] = [...values];
|
|
41438
|
+
any = true;
|
|
41439
|
+
}
|
|
41440
|
+
return any ? out : void 0;
|
|
41441
|
+
}
|
|
41442
|
+
|
|
41443
|
+
//#endregion
|
|
41444
|
+
//#region src/local/websocket-mgmt-api.ts
|
|
41445
|
+
/**
|
|
41446
|
+
* `Map<connectionId, ConnectionRegistryEntry>` wrapper with type-safe
|
|
41447
|
+
* accessors. Lookups by connectionId stay O(1).
|
|
41448
|
+
*/
|
|
41449
|
+
var ConnectionRegistry = class {
|
|
41450
|
+
entries = /* @__PURE__ */ new Map();
|
|
41451
|
+
register(entry) {
|
|
41452
|
+
this.entries.set(entry.connectionId, entry);
|
|
41453
|
+
}
|
|
41454
|
+
unregister(connectionId) {
|
|
41455
|
+
const entry = this.entries.get(connectionId);
|
|
41456
|
+
if (entry) this.entries.delete(connectionId);
|
|
41457
|
+
return entry;
|
|
41458
|
+
}
|
|
41459
|
+
get(connectionId) {
|
|
41460
|
+
return this.entries.get(connectionId);
|
|
41461
|
+
}
|
|
41462
|
+
size() {
|
|
41463
|
+
return this.entries.size;
|
|
41464
|
+
}
|
|
41465
|
+
/**
|
|
41466
|
+
* Snapshot the live entries (for diagnostics / shutdown drain).
|
|
41467
|
+
* Returns a fresh array so the caller can iterate without ownership
|
|
41468
|
+
* concerns over the underlying Map.
|
|
41469
|
+
*/
|
|
41470
|
+
list() {
|
|
41471
|
+
return Array.from(this.entries.values());
|
|
41472
|
+
}
|
|
41473
|
+
clear() {
|
|
41474
|
+
this.entries.clear();
|
|
41475
|
+
}
|
|
41476
|
+
};
|
|
41477
|
+
/**
|
|
41478
|
+
* Match the request URL against the `@connections` endpoint family.
|
|
41479
|
+
* Returns the parsed connectionId on match, `null` otherwise.
|
|
41480
|
+
*
|
|
41481
|
+
* AWS reserves `$` / `@` for control planes so the path prefix
|
|
41482
|
+
* `/@connections/` can never collide with user-declared routes.
|
|
41483
|
+
*
|
|
41484
|
+
* Accepted shapes:
|
|
41485
|
+
* - `/@connections/<id>` (AWS-deployed historical form — supported
|
|
41486
|
+
* for backward compatibility with handlers that construct URLs
|
|
41487
|
+
* manually).
|
|
41488
|
+
* - `/<stage>/@connections/<id>` (AWS-docs-canonical form — the
|
|
41489
|
+
* deployed apigatewaymanagementapi endpoint URL is
|
|
41490
|
+
* `https://<api-id>.execute-api.<region>.amazonaws.com/<stage>`,
|
|
41491
|
+
* so SDK-built clients call `POST /<stage>/@connections/<id>`).
|
|
41492
|
+
*
|
|
41493
|
+
* The optional `<stage>` segment matches AWS's deployed URL exactly —
|
|
41494
|
+
* any non-slash sequence preceding `/@connections/`. The stage value
|
|
41495
|
+
* itself is intentionally NOT validated against the per-API configured
|
|
41496
|
+
* stage name: cdkd is a local-dev tool, not a security boundary, and
|
|
41497
|
+
* an aggressive stage check would just trip on misconfigured handlers
|
|
41498
|
+
* without adding any real protection.
|
|
41499
|
+
*/
|
|
41500
|
+
function parseConnectionsPath(url) {
|
|
41501
|
+
const pathOnly = url.split("?", 1)[0];
|
|
41502
|
+
const m = /^\/(?:[^/]+\/)?@connections\/([^/]+)\/?$/.exec(pathOnly);
|
|
41503
|
+
if (!m) return null;
|
|
41504
|
+
const decoded = safeDecodeURIComponent(m[1]);
|
|
41505
|
+
if (decoded === null) return null;
|
|
41506
|
+
return { connectionId: decoded };
|
|
41507
|
+
}
|
|
41508
|
+
/**
|
|
41509
|
+
* `decodeURIComponent` throws `URIError` on malformed input
|
|
41510
|
+
* (`%`-escape with non-hex tail). We treat that as a not-found rather
|
|
41511
|
+
* than a server error — symmetric with AWS-deployed behavior, which
|
|
41512
|
+
* returns `GoneException` (HTTP 410) for any connection id it can't
|
|
41513
|
+
* look up.
|
|
41514
|
+
*/
|
|
41515
|
+
function safeDecodeURIComponent(s) {
|
|
41516
|
+
try {
|
|
41517
|
+
return decodeURIComponent(s);
|
|
41518
|
+
} catch {
|
|
41519
|
+
return null;
|
|
41520
|
+
}
|
|
41521
|
+
}
|
|
41522
|
+
/**
|
|
41523
|
+
* Read the full request body into a Buffer. Mirrors `node:http`'s
|
|
41524
|
+
* `IncomingMessage` consume pattern — collect chunks, resolve on `end`.
|
|
41525
|
+
*
|
|
41526
|
+
* The body is what the user's handler passed to
|
|
41527
|
+
* `apigatewaymanagementapi.PostToConnection({Data: <bytes>})`. AWS docs
|
|
41528
|
+
* say the body is raw bytes (treated as opaque by the API plane); we
|
|
41529
|
+
* forward the buffer through to `WebSocket.send` so binary frames work
|
|
41530
|
+
* end to end.
|
|
41531
|
+
*/
|
|
41532
|
+
function readRequestBody(req) {
|
|
41533
|
+
return new Promise((resolve, reject) => {
|
|
41534
|
+
const chunks = [];
|
|
41535
|
+
req.on("data", (chunk) => {
|
|
41536
|
+
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
|
|
41537
|
+
else chunks.push(Buffer.from(chunk, "utf-8"));
|
|
41538
|
+
});
|
|
41539
|
+
req.on("end", () => {
|
|
41540
|
+
resolve(Buffer.concat(chunks));
|
|
41541
|
+
});
|
|
41542
|
+
req.on("error", reject);
|
|
41543
|
+
});
|
|
41544
|
+
}
|
|
41545
|
+
/**
|
|
41546
|
+
* Handle a `@connections/<id>` HTTP request. Dispatches by method:
|
|
41547
|
+
* - `POST` → push the request body to the matching open WebSocket.
|
|
41548
|
+
* - `DELETE` → force-close the WebSocket (1000 normal close).
|
|
41549
|
+
* - `GET` → return synthetic metadata for the connection.
|
|
41550
|
+
* - anything else → 405.
|
|
41551
|
+
*
|
|
41552
|
+
* Returns `true` when the request was handled (caller short-circuits),
|
|
41553
|
+
* `false` when the URL didn't match (caller continues normal HTTP
|
|
41554
|
+
* route dispatch).
|
|
41555
|
+
*
|
|
41556
|
+
* AWS-correct status codes:
|
|
41557
|
+
* - Connection not in registry → `410 Gone` (matches AWS's
|
|
41558
|
+
* `GoneException` for closed connections).
|
|
41559
|
+
* - Send succeeded → `200 OK` (body empty).
|
|
41560
|
+
* - Send failed (socket not OPEN) → `410 Gone` — the connection has
|
|
41561
|
+
* started closing on the WebSocket side but the registry entry
|
|
41562
|
+
* hasn't been removed yet (the `close` event clean-up is async).
|
|
41563
|
+
*
|
|
41564
|
+
* NOTE: The body buffer can include arbitrary binary; `ws.send` handles
|
|
41565
|
+
* both string and Buffer inputs (the recipient receives the same bytes
|
|
41566
|
+
* the sender wrote).
|
|
41567
|
+
*/
|
|
41568
|
+
async function handleConnectionsRequest(opts) {
|
|
41569
|
+
const { req, res, registry } = opts;
|
|
41570
|
+
const parsed = parseConnectionsPath(req.url ?? "");
|
|
41571
|
+
if (!parsed) {
|
|
41572
|
+
writeJson(res, 404, { message: "Not Found" });
|
|
41573
|
+
return;
|
|
41574
|
+
}
|
|
41575
|
+
const { connectionId } = parsed;
|
|
41576
|
+
const entry = registry.get(connectionId);
|
|
41577
|
+
const method = (req.method ?? "").toUpperCase();
|
|
41578
|
+
if (!entry) {
|
|
41579
|
+
writeJson(res, 410, { message: "GoneException" });
|
|
41580
|
+
return;
|
|
41581
|
+
}
|
|
41582
|
+
if (method === "POST") {
|
|
41583
|
+
let body;
|
|
41584
|
+
try {
|
|
41585
|
+
body = await readRequestBody(req);
|
|
41586
|
+
} catch (err) {
|
|
41587
|
+
writeJson(res, 500, { message: `Failed to read request body: ${err instanceof Error ? err.message : String(err)}` });
|
|
41588
|
+
return;
|
|
41589
|
+
}
|
|
41590
|
+
if (entry.socket.readyState !== entry.socket.OPEN) {
|
|
41591
|
+
writeJson(res, 410, { message: "GoneException" });
|
|
41592
|
+
return;
|
|
41593
|
+
}
|
|
41594
|
+
try {
|
|
41595
|
+
entry.socket.send(body);
|
|
41596
|
+
} catch (err) {
|
|
41597
|
+
writeJson(res, 500, { message: `Failed to deliver to socket: ${err instanceof Error ? err.message : String(err)}` });
|
|
41598
|
+
return;
|
|
41599
|
+
}
|
|
41600
|
+
res.writeHead(200);
|
|
41601
|
+
res.end();
|
|
41602
|
+
return;
|
|
41603
|
+
}
|
|
41604
|
+
if (method === "DELETE") {
|
|
41605
|
+
try {
|
|
41606
|
+
entry.socket.close(1e3, "DeleteConnection");
|
|
41607
|
+
} catch (err) {
|
|
41608
|
+
writeJson(res, 500, { message: `Failed to close socket: ${err instanceof Error ? err.message : String(err)}` });
|
|
41609
|
+
return;
|
|
41610
|
+
}
|
|
41611
|
+
res.writeHead(204);
|
|
41612
|
+
res.end();
|
|
41613
|
+
return;
|
|
41614
|
+
}
|
|
41615
|
+
if (method === "GET") {
|
|
41616
|
+
writeJson(res, 200, {
|
|
41617
|
+
ConnectedAt: new Date(entry.connectedAt).toISOString(),
|
|
41618
|
+
Identity: { SourceIp: "127.0.0.1" },
|
|
41619
|
+
LastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
41620
|
+
});
|
|
41621
|
+
return;
|
|
41622
|
+
}
|
|
41623
|
+
res.setHeader("Allow", "POST, GET, DELETE");
|
|
41624
|
+
writeJson(res, 405, { message: "MethodNotAllowedException" });
|
|
41625
|
+
}
|
|
41626
|
+
function writeJson(res, status, body) {
|
|
41627
|
+
const json = JSON.stringify(body);
|
|
41628
|
+
res.writeHead(status, {
|
|
41629
|
+
"Content-Type": "application/json",
|
|
41630
|
+
"Content-Length": Buffer.byteLength(json)
|
|
41631
|
+
});
|
|
41632
|
+
res.end(json);
|
|
41633
|
+
}
|
|
41634
|
+
|
|
41635
|
+
//#endregion
|
|
41636
|
+
//#region src/local/websocket-server.ts
|
|
41637
|
+
/**
|
|
41638
|
+
* Wire a WebSocket API into a long-lived `node:http`'s `upgrade`
|
|
41639
|
+
* pipeline. The same server already serves HTTP API v2 / REST v1 /
|
|
41640
|
+
* Function URL routes via the `request` listener; this module adds a
|
|
41641
|
+
* sibling `upgrade` listener that handles WebSocket handshakes.
|
|
41642
|
+
*
|
|
41643
|
+
* Architecture (mirrors design doc §2 / §8):
|
|
41644
|
+
* - One {@link WebSocketServer} per cdkd local-start-api server.
|
|
41645
|
+
* - `noServer: true` mode — cdkd owns the upgrade-event dispatch.
|
|
41646
|
+
* - Per-connection lifecycle: handshake -> $connect Lambda ->
|
|
41647
|
+
* (allow/deny) -> message loop -> close -> $disconnect Lambda.
|
|
41648
|
+
* - Outbound `@connections/<id>` POST from a handler-side AWS SDK
|
|
41649
|
+
* call routes to the WebSocket via the shared
|
|
41650
|
+
* {@link ConnectionRegistry}.
|
|
41651
|
+
*
|
|
41652
|
+
* The container pool is the SAME instance the HTTP-side server uses for
|
|
41653
|
+
* REST/HTTP API/Function URL routes — WebSocket dispatch is just
|
|
41654
|
+
* another consumer; per-Lambda concurrency caps still apply.
|
|
41655
|
+
*/
|
|
41656
|
+
const DEFAULT_INVOKE_TIMEOUT_MS = 6e4;
|
|
41657
|
+
/**
|
|
41658
|
+
* Attach a WebSocket server to the parent HTTP listener. Returns an
|
|
41659
|
+
* {@link AttachedWebSocketServer} the CLI uses for graceful shutdown +
|
|
41660
|
+
* to expose the connection registry to the management-API
|
|
41661
|
+
* pre-pass.
|
|
41662
|
+
*
|
|
41663
|
+
* Implementation:
|
|
41664
|
+
* - One shared {@link ws.WebSocketServer} in `noServer` mode.
|
|
41665
|
+
* - One `upgrade` listener that routes by `req.url`'s pathname; an
|
|
41666
|
+
* unrecognized upgrade target is destroyed (RFC 6455 §4.3.2 —
|
|
41667
|
+
* server SHOULD respond with HTTP 404 or 426).
|
|
41668
|
+
* - Per-connection state held in a {@link ConnectionRegistry} the
|
|
41669
|
+
* `@connections` HTTP handler reads to push messages back.
|
|
41670
|
+
*
|
|
41671
|
+
* Returns synchronously — the underlying ws server is fully bound by
|
|
41672
|
+
* the time this function returns.
|
|
41673
|
+
*/
|
|
41674
|
+
function attachWebSocketServer(opts) {
|
|
41675
|
+
const logger = getLogger().child("start-api/ws");
|
|
41676
|
+
const rieTimeoutMs = opts.rieTimeoutMs ?? DEFAULT_INVOKE_TIMEOUT_MS;
|
|
41677
|
+
const registry = new ConnectionRegistry();
|
|
41678
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
41679
|
+
const apisByPath = /* @__PURE__ */ new Map();
|
|
41680
|
+
const apiPaths = [];
|
|
41681
|
+
for (const cfg of opts.apis) {
|
|
41682
|
+
apisByPath.set(cfg.apiPath, cfg);
|
|
41683
|
+
apiPaths.push(cfg.apiPath);
|
|
41684
|
+
}
|
|
41685
|
+
const upgradeListener = (req, socket, head) => {
|
|
41686
|
+
const pathOnly = (req.url ?? "/").split("?", 1)[0];
|
|
41687
|
+
const cfg = apisByPath.get(pathOnly);
|
|
41688
|
+
if (!cfg) {
|
|
41689
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
41690
|
+
socket.destroy();
|
|
41691
|
+
return;
|
|
41692
|
+
}
|
|
41693
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
41694
|
+
onConnect(ws, req, cfg).catch((err) => {
|
|
41695
|
+
logger.error(`WebSocket $connect dispatch failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41696
|
+
try {
|
|
41697
|
+
ws.close(1011, "internal error");
|
|
41698
|
+
} catch {}
|
|
41699
|
+
});
|
|
41700
|
+
});
|
|
41701
|
+
};
|
|
41702
|
+
opts.httpServer.on("upgrade", upgradeListener);
|
|
41703
|
+
const MAX_PRE_VERDICT_FRAMES = 100;
|
|
41704
|
+
const onConnect = async (ws, req, cfg) => {
|
|
41705
|
+
const connectionId = randomUUID();
|
|
41706
|
+
const connectedAt = Date.now();
|
|
41707
|
+
const handshakeSnapshot = buildHandshakeSnapshot(req);
|
|
41708
|
+
const connectEvent = buildConnectEvent({
|
|
41709
|
+
connectionId,
|
|
41710
|
+
connectedAt,
|
|
41711
|
+
stage: cfg.api.stage,
|
|
41712
|
+
snapshot: handshakeSnapshot
|
|
41713
|
+
});
|
|
41714
|
+
const preVerdictFrames = [];
|
|
41715
|
+
let preVerdictOverflow = false;
|
|
41716
|
+
let preVerdictClosed = false;
|
|
41717
|
+
const preListener = (raw, isBinary) => {
|
|
41718
|
+
if (preVerdictClosed) return;
|
|
41719
|
+
if (preVerdictFrames.length >= MAX_PRE_VERDICT_FRAMES) {
|
|
41720
|
+
if (!preVerdictOverflow) {
|
|
41721
|
+
preVerdictOverflow = true;
|
|
41722
|
+
logger.warn(`WebSocket connection ${connectionId}: pre-verdict message buffer overflowed (>${MAX_PRE_VERDICT_FRAMES} frames). Excess frames dropped — client is sending faster than the $connect handler can resolve.`);
|
|
41723
|
+
}
|
|
41724
|
+
return;
|
|
41725
|
+
}
|
|
41726
|
+
preVerdictFrames.push({
|
|
41727
|
+
raw,
|
|
41728
|
+
isBinary
|
|
41729
|
+
});
|
|
41730
|
+
};
|
|
41731
|
+
ws.on("message", preListener);
|
|
41732
|
+
const connectRoute = cfg.api.routes.find((r) => r.routeKey === "$connect");
|
|
41733
|
+
if (connectRoute) {
|
|
41734
|
+
if (!await invokeRouteAndDecideAuth(connectRoute.targetLambdaLogicalId, connectEvent, opts.pool, rieTimeoutMs)) {
|
|
41735
|
+
preVerdictClosed = true;
|
|
41736
|
+
preVerdictFrames.length = 0;
|
|
41737
|
+
ws.off("message", preListener);
|
|
41738
|
+
try {
|
|
41739
|
+
ws.close(1008, "Forbidden");
|
|
41740
|
+
} catch {}
|
|
41741
|
+
logger.debug(`WebSocket $connect denied for connection ${connectionId} on ${cfg.api.declaredAt}`);
|
|
41742
|
+
return;
|
|
41743
|
+
}
|
|
41744
|
+
}
|
|
41745
|
+
preVerdictClosed = true;
|
|
41746
|
+
ws.off("message", preListener);
|
|
41747
|
+
if (ws.readyState !== ws.OPEN) {
|
|
41748
|
+
preVerdictFrames.length = 0;
|
|
41749
|
+
logger.debug(`WebSocket connection ${connectionId} closed during $connect await (readyState=${ws.readyState}) — skipping registration`);
|
|
41750
|
+
return;
|
|
41751
|
+
}
|
|
41752
|
+
const entry = {
|
|
41753
|
+
connectionId,
|
|
41754
|
+
socket: ws,
|
|
41755
|
+
connectedAt,
|
|
41756
|
+
apiLogicalId: cfg.api.apiLogicalId,
|
|
41757
|
+
stage: cfg.api.stage
|
|
41758
|
+
};
|
|
41759
|
+
registry.register(entry);
|
|
41760
|
+
logger.debug(`WebSocket connected: ${connectionId} (${cfg.api.declaredAt}, stage=${cfg.api.stage})`);
|
|
41761
|
+
ws.on("message", (raw, isBinary) => {
|
|
41762
|
+
const { body, isBase64Encoded } = bufferToBody(raw, isBinary);
|
|
41763
|
+
logger.debug(`WebSocket message received for connection ${connectionId}: ${body.slice(0, 200)}`);
|
|
41764
|
+
dispatchMessage(connectionId, cfg, body, isBase64Encoded, handshakeSnapshot).catch((err) => {
|
|
41765
|
+
logger.error(`WebSocket message dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41766
|
+
try {
|
|
41767
|
+
ws.send(JSON.stringify({
|
|
41768
|
+
message: "Internal server error",
|
|
41769
|
+
connectionId,
|
|
41770
|
+
requestId: randomUUID()
|
|
41771
|
+
}));
|
|
41772
|
+
} catch {}
|
|
41773
|
+
});
|
|
41774
|
+
});
|
|
41775
|
+
ws.on("close", (code, reason) => {
|
|
41776
|
+
onDisconnect(connectionId, cfg, handshakeSnapshot, code, reason.toString("utf-8")).catch((err) => {
|
|
41777
|
+
logger.warn(`WebSocket $disconnect dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
41778
|
+
});
|
|
41779
|
+
});
|
|
41780
|
+
ws.on("error", (err) => {
|
|
41781
|
+
logger.debug(`WebSocket error for connection ${connectionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
41782
|
+
});
|
|
41783
|
+
for (const frame of preVerdictFrames) {
|
|
41784
|
+
const { body, isBase64Encoded } = bufferToBody(frame.raw, frame.isBinary);
|
|
41785
|
+
dispatchMessage(connectionId, cfg, body, isBase64Encoded, handshakeSnapshot).catch((err) => {
|
|
41786
|
+
logger.error(`WebSocket buffered-message dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41787
|
+
});
|
|
41788
|
+
}
|
|
41789
|
+
preVerdictFrames.length = 0;
|
|
41790
|
+
};
|
|
41791
|
+
const dispatchMessage = async (connectionId, cfg, body, isBase64Encoded, snapshot) => {
|
|
41792
|
+
const entry = registry.get(connectionId);
|
|
41793
|
+
if (!entry) return;
|
|
41794
|
+
const routeKey = selectRouteKey(cfg.api, body);
|
|
41795
|
+
const route = cfg.api.routes.find((r) => r.routeKey === routeKey);
|
|
41796
|
+
if (!route) {
|
|
41797
|
+
try {
|
|
41798
|
+
entry.socket.send(JSON.stringify({
|
|
41799
|
+
message: "Internal server error",
|
|
41800
|
+
connectionId,
|
|
41801
|
+
requestId: randomUUID()
|
|
41802
|
+
}));
|
|
41803
|
+
} catch {}
|
|
41804
|
+
return;
|
|
41805
|
+
}
|
|
41806
|
+
const event = buildMessageEvent({
|
|
41807
|
+
connectionId,
|
|
41808
|
+
connectedAt: entry.connectedAt,
|
|
41809
|
+
stage: entry.stage,
|
|
41810
|
+
snapshot,
|
|
41811
|
+
routeKey,
|
|
41812
|
+
body,
|
|
41813
|
+
isBase64Encoded
|
|
41814
|
+
});
|
|
41815
|
+
await invokeRoute(route.targetLambdaLogicalId, event, opts.pool, rieTimeoutMs);
|
|
41816
|
+
};
|
|
41817
|
+
const onDisconnect = async (connectionId, cfg, snapshot, code, reason) => {
|
|
41818
|
+
const entry = registry.unregister(connectionId);
|
|
41819
|
+
if (!entry) return;
|
|
41820
|
+
logger.debug(`WebSocket disconnected: ${connectionId} (code=${code}, reason=${reason || "<none>"})`);
|
|
41821
|
+
const disconnectRoute = cfg.api.routes.find((r) => r.routeKey === "$disconnect");
|
|
41822
|
+
if (!disconnectRoute) return;
|
|
41823
|
+
const event = buildDisconnectEvent({
|
|
41824
|
+
connectionId,
|
|
41825
|
+
connectedAt: entry.connectedAt,
|
|
41826
|
+
stage: entry.stage,
|
|
41827
|
+
snapshot,
|
|
41828
|
+
disconnectStatusCode: code,
|
|
41829
|
+
disconnectReason: reason
|
|
41830
|
+
});
|
|
41831
|
+
await invokeRoute(disconnectRoute.targetLambdaLogicalId, event, opts.pool, rieTimeoutMs);
|
|
41832
|
+
};
|
|
41833
|
+
let closed = false;
|
|
41834
|
+
return {
|
|
41835
|
+
registry,
|
|
41836
|
+
apiPaths,
|
|
41837
|
+
close: async () => {
|
|
41838
|
+
if (closed) return;
|
|
41839
|
+
closed = true;
|
|
41840
|
+
opts.httpServer.off("upgrade", upgradeListener);
|
|
41841
|
+
const closes = Array.from(wss.clients).map((ws) => new Promise((resolve) => {
|
|
41842
|
+
const onClose = () => resolve();
|
|
41843
|
+
ws.once("close", onClose);
|
|
41844
|
+
try {
|
|
41845
|
+
ws.close(1001, "going away");
|
|
41846
|
+
} catch {
|
|
41847
|
+
resolve();
|
|
41848
|
+
}
|
|
41849
|
+
setTimeout(() => {
|
|
41850
|
+
ws.off("close", onClose);
|
|
41851
|
+
resolve();
|
|
41852
|
+
}, 5e3).unref();
|
|
41853
|
+
}));
|
|
41854
|
+
await Promise.all(closes);
|
|
41855
|
+
await new Promise((resolve) => {
|
|
41856
|
+
wss.close(() => resolve());
|
|
41857
|
+
});
|
|
41858
|
+
}
|
|
41859
|
+
};
|
|
41860
|
+
}
|
|
41861
|
+
/**
|
|
41862
|
+
* Pre-pass for the HTTP `request` listener: intercept `POST/GET/DELETE
|
|
41863
|
+
* /@connections/<id>` calls and route them to the connection registry.
|
|
41864
|
+
*
|
|
41865
|
+
* Returns `true` when the request was handled (caller short-circuits
|
|
41866
|
+
* the normal HTTP dispatch path), `false` when the URL didn't match.
|
|
41867
|
+
*
|
|
41868
|
+
* The CLI installs this BEFORE the existing http-server pipeline so a
|
|
41869
|
+
* Lambda inside a container can call
|
|
41870
|
+
* `apigatewaymanagementapi:PostToConnection` and have cdkd deliver the
|
|
41871
|
+
* message back to the open WebSocket without the request hitting the
|
|
41872
|
+
* route table.
|
|
41873
|
+
*/
|
|
41874
|
+
async function handleManagementRequest(req, res, registry) {
|
|
41875
|
+
if (parseConnectionsPath(req.url ?? "") === null) return false;
|
|
41876
|
+
await handleConnectionsRequest({
|
|
41877
|
+
req,
|
|
41878
|
+
res,
|
|
41879
|
+
registry
|
|
41880
|
+
});
|
|
41881
|
+
return true;
|
|
41882
|
+
}
|
|
41883
|
+
/**
|
|
41884
|
+
* Select the route the client message dispatches to.
|
|
41885
|
+
*
|
|
41886
|
+
* Algorithm (matches AWS docs §"Selection expressions"):
|
|
41887
|
+
* 1. Try to parse the body as JSON. Non-JSON → `$default`.
|
|
41888
|
+
* 2. Walk the selection-expression's JSON-path tokens against the
|
|
41889
|
+
* parsed body. Missing intermediate keys → `$default`.
|
|
41890
|
+
* 3. The final value's `String()` representation is the route key.
|
|
41891
|
+
* 4. When that key has no matching route, fall back to `$default`.
|
|
41892
|
+
*
|
|
41893
|
+
* v1's selection-expression grammar is `$request.body.<key>` (with
|
|
41894
|
+
* optional nested dot access). Other shapes were rejected upstream at
|
|
41895
|
+
* discovery time.
|
|
41896
|
+
*/
|
|
41897
|
+
function selectRouteKey(api, body) {
|
|
41898
|
+
let parsed;
|
|
41899
|
+
try {
|
|
41900
|
+
parsed = JSON.parse(body);
|
|
41901
|
+
} catch {
|
|
41902
|
+
return "$default";
|
|
41903
|
+
}
|
|
41904
|
+
const tokens = parseSelectionExpressionPath(api.routeSelectionExpression);
|
|
41905
|
+
let cursor = parsed;
|
|
41906
|
+
for (const token of tokens) {
|
|
41907
|
+
if (cursor === null || typeof cursor !== "object") return "$default";
|
|
41908
|
+
cursor = cursor[token];
|
|
41909
|
+
if (cursor === void 0) return "$default";
|
|
41910
|
+
}
|
|
41911
|
+
const candidate = String(cursor);
|
|
41912
|
+
if (api.routes.some((r) => r.routeKey === candidate)) return candidate;
|
|
41913
|
+
return "$default";
|
|
41914
|
+
}
|
|
41915
|
+
/**
|
|
41916
|
+
* Invoke a route's Lambda for side effects only (MESSAGE / DISCONNECT
|
|
41917
|
+
* paths). The Lambda's response is intentionally discarded — AWS-deployed
|
|
41918
|
+
* WebSocket APIs do the same; handlers reply via `PostToConnection`.
|
|
41919
|
+
*/
|
|
41920
|
+
async function invokeRoute(lambdaLogicalId, event, pool, rieTimeoutMs) {
|
|
41921
|
+
const handle = await pool.acquire(lambdaLogicalId);
|
|
41922
|
+
try {
|
|
41923
|
+
await invokeRie(handle.containerHost, handle.hostPort, event, rieTimeoutMs);
|
|
41924
|
+
} finally {
|
|
41925
|
+
pool.release(handle);
|
|
41926
|
+
}
|
|
41927
|
+
}
|
|
41928
|
+
/**
|
|
41929
|
+
* Invoke the `$connect` Lambda and decide whether to accept the
|
|
41930
|
+
* connection. AWS-deployed behavior: handler returns `{statusCode:
|
|
41931
|
+
* 200}` (or any 2xx) → allow; anything else (non-2xx, error envelope,
|
|
41932
|
+
* throw, timeout) → deny.
|
|
41933
|
+
*/
|
|
41934
|
+
async function invokeRouteAndDecideAuth(lambdaLogicalId, event, pool, rieTimeoutMs) {
|
|
41935
|
+
let result;
|
|
41936
|
+
try {
|
|
41937
|
+
const handle = await pool.acquire(lambdaLogicalId);
|
|
41938
|
+
try {
|
|
41939
|
+
result = await invokeRie(handle.containerHost, handle.hostPort, event, rieTimeoutMs);
|
|
41940
|
+
} finally {
|
|
41941
|
+
pool.release(handle);
|
|
41942
|
+
}
|
|
41943
|
+
} catch {
|
|
41944
|
+
return false;
|
|
41945
|
+
}
|
|
41946
|
+
if (result.payload && typeof result.payload === "object") {
|
|
41947
|
+
const obj = result.payload;
|
|
41948
|
+
if (typeof obj["errorMessage"] === "string" && typeof obj["statusCode"] !== "number") return false;
|
|
41949
|
+
const status = obj["statusCode"];
|
|
41950
|
+
if (typeof status === "number") return status >= 200 && status < 300;
|
|
41951
|
+
}
|
|
41952
|
+
return true;
|
|
41953
|
+
}
|
|
41954
|
+
/**
|
|
41955
|
+
* Snapshot the upgrade-request data the event-builders need. We capture
|
|
41956
|
+
* this ONCE at `$connect` and reuse it for every event on the same
|
|
41957
|
+
* connection — `requestContext.identity.sourceIp` etc. must stay
|
|
41958
|
+
* consistent across CONNECT / MESSAGE / DISCONNECT (matches AWS).
|
|
41959
|
+
*/
|
|
41960
|
+
function buildHandshakeSnapshot(req) {
|
|
41961
|
+
const headers = {};
|
|
41962
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
41963
|
+
if (value === void 0) continue;
|
|
41964
|
+
headers[name] = Array.isArray(value) ? [...value] : [value];
|
|
41965
|
+
}
|
|
41966
|
+
const url = req.url ?? "/";
|
|
41967
|
+
const queryIdx = url.indexOf("?");
|
|
41968
|
+
const rawQueryString = queryIdx >= 0 ? url.slice(queryIdx + 1) : "";
|
|
41969
|
+
const { single, multi } = parseQueryString(rawQueryString);
|
|
41970
|
+
const userAgent = typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : void 0;
|
|
41971
|
+
const sourceIp = req.socket.remoteAddress;
|
|
41972
|
+
return {
|
|
41973
|
+
headers,
|
|
41974
|
+
rawQueryString,
|
|
41975
|
+
...Object.keys(single).length > 0 && { queryStringParameters: single },
|
|
41976
|
+
...Object.keys(multi).length > 0 && { multiValueQueryStringParameters: multi },
|
|
41977
|
+
...sourceIp !== void 0 && { sourceIp },
|
|
41978
|
+
...userAgent !== void 0 && { userAgent }
|
|
41979
|
+
};
|
|
41980
|
+
}
|
|
41981
|
+
/**
|
|
41982
|
+
* Parse a raw query string into single-value (last-wins per AWS) and
|
|
41983
|
+
* multi-value maps. Mirrors `route-discovery.ts:parseQueryStringSingular`'s
|
|
41984
|
+
* convention — duplicated locally rather than reaching across modules
|
|
41985
|
+
* because the WebSocket path is a thin slice that does not need the
|
|
41986
|
+
* full HTTP-API parser.
|
|
41987
|
+
*/
|
|
41988
|
+
function parseQueryString(qs) {
|
|
41989
|
+
const single = {};
|
|
41990
|
+
const multi = {};
|
|
41991
|
+
if (qs.length === 0) return {
|
|
41992
|
+
single,
|
|
41993
|
+
multi
|
|
41994
|
+
};
|
|
41995
|
+
for (const pair of qs.split("&")) {
|
|
41996
|
+
if (pair.length === 0) continue;
|
|
41997
|
+
const eq = pair.indexOf("=");
|
|
41998
|
+
const rawKey = eq >= 0 ? pair.slice(0, eq) : pair;
|
|
41999
|
+
const rawVal = eq >= 0 ? pair.slice(eq + 1) : "";
|
|
42000
|
+
const key = safeDecode$1(rawKey);
|
|
42001
|
+
const val = safeDecode$1(rawVal);
|
|
42002
|
+
if (key === null) continue;
|
|
42003
|
+
single[key] = val ?? "";
|
|
42004
|
+
(multi[key] ??= []).push(val ?? "");
|
|
42005
|
+
}
|
|
42006
|
+
return {
|
|
42007
|
+
single,
|
|
42008
|
+
multi
|
|
42009
|
+
};
|
|
42010
|
+
}
|
|
42011
|
+
function safeDecode$1(s) {
|
|
42012
|
+
try {
|
|
42013
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
42014
|
+
} catch {
|
|
42015
|
+
return null;
|
|
42016
|
+
}
|
|
42017
|
+
}
|
|
42018
|
+
/**
|
|
42019
|
+
* Convert a ws-emitted message buffer into the AWS-canonical event
|
|
42020
|
+
* body + `isBase64Encoded` discriminator. Text frames (opcode 0x1) pass
|
|
42021
|
+
* through as UTF-8 with `isBase64Encoded: false`; binary frames
|
|
42022
|
+
* (opcode 0x2) are base64-encoded with `isBase64Encoded: true`. Matches
|
|
42023
|
+
* AWS-deployed WebSocket API event shape exactly — handlers decode via
|
|
42024
|
+
* `Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')`.
|
|
42025
|
+
*
|
|
42026
|
+
* Closes the data-integrity bug where every byte > 0x7F on a binary
|
|
42027
|
+
* frame was silently corrupted by handlers that trusted the previously
|
|
42028
|
+
* hardcoded `isBase64Encoded: false` flag and UTF-8-decoded the
|
|
42029
|
+
* base64-encoded body.
|
|
42030
|
+
*/
|
|
42031
|
+
function bufferToBody(raw, isBinary) {
|
|
42032
|
+
const buf = Array.isArray(raw) ? Buffer.concat(raw) : Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
42033
|
+
if (isBinary) return {
|
|
42034
|
+
body: buf.toString("base64"),
|
|
42035
|
+
isBase64Encoded: true
|
|
42036
|
+
};
|
|
42037
|
+
return {
|
|
42038
|
+
body: buf.toString("utf-8"),
|
|
42039
|
+
isBase64Encoded: false
|
|
42040
|
+
};
|
|
42041
|
+
}
|
|
42042
|
+
|
|
41203
42043
|
//#endregion
|
|
41204
42044
|
//#region src/local/vtl-engine.ts
|
|
41205
42045
|
/** Error thrown when a template references an unsupported VTL feature. */
|
|
@@ -42737,7 +43577,8 @@ function createContainerPool(specs, options) {
|
|
|
42737
43577
|
host: spec.containerHost,
|
|
42738
43578
|
name,
|
|
42739
43579
|
...spec.debugPort !== void 0 && { debugPort: spec.debugPort },
|
|
42740
|
-
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs }
|
|
43580
|
+
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs },
|
|
43581
|
+
...spec.extraHosts !== void 0 && { extraHosts: spec.extraHosts }
|
|
42741
43582
|
});
|
|
42742
43583
|
} else containerId = await runDetached({
|
|
42743
43584
|
image: spec.image,
|
|
@@ -42751,7 +43592,8 @@ function createContainerPool(specs, options) {
|
|
|
42751
43592
|
...spec.entryPoint !== void 0 && { entryPoint: spec.entryPoint },
|
|
42752
43593
|
...spec.workingDir !== void 0 && { workingDir: spec.workingDir },
|
|
42753
43594
|
...spec.debugPort !== void 0 && { debugPort: spec.debugPort },
|
|
42754
|
-
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs }
|
|
43595
|
+
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs },
|
|
43596
|
+
...spec.extraHosts !== void 0 && { extraHosts: spec.extraHosts }
|
|
42755
43597
|
});
|
|
42756
43598
|
const stopLogStream = streamingEnabled ? streamLogs(containerId) : () => void 0;
|
|
42757
43599
|
try {
|
|
@@ -45607,6 +46449,13 @@ async function startApiServer(opts) {
|
|
|
45607
46449
|
*/
|
|
45608
46450
|
async function handleRequest(req, res, state, opts) {
|
|
45609
46451
|
const logger = getLogger().child("start-api");
|
|
46452
|
+
if (opts.preDispatch) try {
|
|
46453
|
+
if (await opts.preDispatch(req, res)) return;
|
|
46454
|
+
} catch (err) {
|
|
46455
|
+
logger.error(`preDispatch hook threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
46456
|
+
if (!res.headersSent) writeError(res, 502);
|
|
46457
|
+
return;
|
|
46458
|
+
}
|
|
45610
46459
|
const bodyBuf = await readBody(req);
|
|
45611
46460
|
const rawUrl = req.url ?? "/";
|
|
45612
46461
|
const method = (req.method ?? "GET").toUpperCase();
|
|
@@ -46992,13 +47841,18 @@ async function localStartApiCommand(target, options) {
|
|
|
46992
47841
|
output: options.output,
|
|
46993
47842
|
...options.region && { region: options.region },
|
|
46994
47843
|
...options.profile && { profile: options.profile },
|
|
46995
|
-
...Object.keys(context).length > 0 && { context }
|
|
47844
|
+
...Object.keys(context).length > 0 && { context },
|
|
47845
|
+
...options.stateBucket && { stateBucket: options.stateBucket },
|
|
47846
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
46996
47847
|
};
|
|
46997
47848
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
46998
47849
|
const targetStacks = pickTargetStacks(stacks, options.stack);
|
|
46999
47850
|
if (targetStacks.length === 0) throw new Error("No stacks matched. Pass --stack <name> or run from a single-stack app.");
|
|
47000
47851
|
const routes = discoverRoutes(targetStacks);
|
|
47001
|
-
|
|
47852
|
+
const wsDiscovery = discoverWebSocketApis(targetStacks);
|
|
47853
|
+
if (wsDiscovery.errors.length > 0) for (const e of wsDiscovery.errors) logger.warn(`WebSocket discovery: ${e}`);
|
|
47854
|
+
const webSocketApis = wsDiscovery.apis;
|
|
47855
|
+
if (routes.length === 0 && webSocketApis.length === 0) throw new Error("No supported API routes were discovered. cdkd local start-api supports AWS::ApiGateway::* (REST v1), AWS::ApiGatewayV2::* (HTTP + WebSocket), and AWS::Lambda::Url (Function URL) with AWS_PROXY integrations only.");
|
|
47002
47856
|
const stageMap = /* @__PURE__ */ new Map();
|
|
47003
47857
|
for (const stack of targetStacks) {
|
|
47004
47858
|
const m = buildStageMap(stack.template, options.stage);
|
|
@@ -47029,7 +47883,7 @@ async function localStartApiCommand(target, options) {
|
|
|
47029
47883
|
for (const [k, v] of m) corsConfigByApiId.set(k, v);
|
|
47030
47884
|
}
|
|
47031
47885
|
const stateByStack = options.fromState ? await loadStateForRoutedStacks(targetStacks, routes, routesWithAuth, options) : /* @__PURE__ */ new Map();
|
|
47032
|
-
const lambdaIds = uniqueLambdaIds(routes, routesWithAuth);
|
|
47886
|
+
const lambdaIds = uniqueLambdaIds(routes, routesWithAuth, webSocketApis);
|
|
47033
47887
|
const specs = /* @__PURE__ */ new Map();
|
|
47034
47888
|
for (let i = 0; i < lambdaIds.length; i++) {
|
|
47035
47889
|
const logicalId = lambdaIds[i];
|
|
@@ -47056,6 +47910,7 @@ async function localStartApiCommand(target, options) {
|
|
|
47056
47910
|
routes: routesWithAuth,
|
|
47057
47911
|
specs,
|
|
47058
47912
|
corsConfigByApiId,
|
|
47913
|
+
webSocketApis,
|
|
47059
47914
|
stacks: targetStacks
|
|
47060
47915
|
};
|
|
47061
47916
|
};
|
|
@@ -47149,12 +48004,88 @@ async function localStartApiCommand(target, options) {
|
|
|
47149
48004
|
});
|
|
47150
48005
|
if (basePort !== 0) nextPort += 1;
|
|
47151
48006
|
}
|
|
48007
|
+
const wsServers = [];
|
|
48008
|
+
const initialWsApis = initialMaterial.webSocketApis ?? [];
|
|
48009
|
+
warnUnsupportedWebSocketApis(initialWsApis, logger);
|
|
48010
|
+
for (const api of initialWsApis) {
|
|
48011
|
+
if (api.unsupported) continue;
|
|
48012
|
+
const wsLambdaIds = new Set(api.routes.map((r) => r.targetLambdaLogicalId));
|
|
48013
|
+
const wsSpecs = /* @__PURE__ */ new Map();
|
|
48014
|
+
for (const id of wsLambdaIds) {
|
|
48015
|
+
const spec = initialMaterial.specs.get(id);
|
|
48016
|
+
if (spec) wsSpecs.set(id, spec);
|
|
48017
|
+
}
|
|
48018
|
+
if (wsSpecs.size === 0) {
|
|
48019
|
+
logger.warn(`WebSocket API ${api.declaredAt}: no resolvable Lambda backing routes; skipping.`);
|
|
48020
|
+
continue;
|
|
48021
|
+
}
|
|
48022
|
+
const wsPool = buildPool(wsSpecs);
|
|
48023
|
+
const wsState = {
|
|
48024
|
+
routes: [],
|
|
48025
|
+
pool: wsPool,
|
|
48026
|
+
corsConfigByApiId: /* @__PURE__ */ new Map()
|
|
48027
|
+
};
|
|
48028
|
+
const wsApiPath = `/${api.stage}`;
|
|
48029
|
+
let registryRef;
|
|
48030
|
+
const started = await startApiServer({
|
|
48031
|
+
state: wsState,
|
|
48032
|
+
rieTimeoutMs,
|
|
48033
|
+
host: options.host,
|
|
48034
|
+
port: basePort === 0 ? 0 : nextPort,
|
|
48035
|
+
authorizerCache,
|
|
48036
|
+
jwksCache,
|
|
48037
|
+
jwksWarnedUrls,
|
|
48038
|
+
sigV4WarnedForeignIds,
|
|
48039
|
+
sigV4AllowUnverified: options.allowUnverifiedSigv4 === true,
|
|
48040
|
+
preDispatch: async (req, res) => {
|
|
48041
|
+
if (!registryRef) return false;
|
|
48042
|
+
return handleManagementRequest(req, res, registryRef.registry);
|
|
48043
|
+
}
|
|
48044
|
+
});
|
|
48045
|
+
const attached = attachWebSocketServer({
|
|
48046
|
+
httpServer: started.server,
|
|
48047
|
+
pool: wsPool,
|
|
48048
|
+
rieTimeoutMs,
|
|
48049
|
+
apis: [{
|
|
48050
|
+
api,
|
|
48051
|
+
apiPath: wsApiPath
|
|
48052
|
+
}]
|
|
48053
|
+
});
|
|
48054
|
+
registryRef = attached;
|
|
48055
|
+
const mgmtEndpoint = `http://host.docker.internal:${started.port}/${api.stage}`;
|
|
48056
|
+
const hostGatewayMapping = [{
|
|
48057
|
+
host: "host.docker.internal",
|
|
48058
|
+
ip: "host-gateway"
|
|
48059
|
+
}];
|
|
48060
|
+
for (const id of wsLambdaIds) {
|
|
48061
|
+
const spec = initialMaterial.specs.get(id);
|
|
48062
|
+
if (!spec) continue;
|
|
48063
|
+
spec.env["AWS_ENDPOINT_URL_APIGATEWAYMANAGEMENTAPI"] = mgmtEndpoint;
|
|
48064
|
+
if (!spec.env["AWS_ACCESS_KEY_ID"]) {
|
|
48065
|
+
spec.env["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE";
|
|
48066
|
+
spec.env["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
|
48067
|
+
}
|
|
48068
|
+
if (!spec.env["AWS_REGION"]) spec.env["AWS_REGION"] = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? "us-east-1";
|
|
48069
|
+
spec.extraHosts = [...spec.extraHosts ?? [], ...hostGatewayMapping];
|
|
48070
|
+
}
|
|
48071
|
+
wsServers.push({
|
|
48072
|
+
api,
|
|
48073
|
+
server: started,
|
|
48074
|
+
attached,
|
|
48075
|
+
apiPath: wsApiPath
|
|
48076
|
+
});
|
|
48077
|
+
if (basePort !== 0) nextPort += 1;
|
|
48078
|
+
}
|
|
47152
48079
|
printPerServerRouteTables(servers);
|
|
47153
48080
|
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
47154
48081
|
warnUnsupportedRoutes(allRoutes, logger);
|
|
47155
48082
|
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
47156
48083
|
logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
|
|
47157
48084
|
for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
|
|
48085
|
+
for (const ws of wsServers) {
|
|
48086
|
+
const scheme = ws.server.scheme === "https" ? "wss" : "ws";
|
|
48087
|
+
process.stdout.write(`Server listening on ${scheme}://${ws.server.host}:${ws.server.port}${ws.apiPath} (${ws.api.apiLogicalId} (WebSocket API))\n`);
|
|
48088
|
+
}
|
|
47158
48089
|
process.stdout.write("^C to stop and clean up containers.\n");
|
|
47159
48090
|
let watcher;
|
|
47160
48091
|
let reloadChain = Promise.resolve();
|
|
@@ -47188,6 +48119,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47188
48119
|
} catch (err) {
|
|
47189
48120
|
logger.warn(`watcher.close() failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47190
48121
|
}
|
|
48122
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48123
|
+
try {
|
|
48124
|
+
await ws.attached.close();
|
|
48125
|
+
} catch (err) {
|
|
48126
|
+
logger.warn(`WebSocket close() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48127
|
+
}
|
|
48128
|
+
}));
|
|
47191
48129
|
await Promise.allSettled(servers.map(async ({ server, group }) => {
|
|
47192
48130
|
try {
|
|
47193
48131
|
await server.close();
|
|
@@ -47195,6 +48133,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47195
48133
|
logger.warn(`server.close() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47196
48134
|
}
|
|
47197
48135
|
}));
|
|
48136
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48137
|
+
try {
|
|
48138
|
+
await ws.server.close();
|
|
48139
|
+
} catch (err) {
|
|
48140
|
+
logger.warn(`WebSocket server.close() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48141
|
+
}
|
|
48142
|
+
}));
|
|
47198
48143
|
await Promise.allSettled(servers.map(async ({ server, group }) => {
|
|
47199
48144
|
try {
|
|
47200
48145
|
await server.getServerState().pool.dispose();
|
|
@@ -47202,6 +48147,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47202
48147
|
logger.warn(`pool.dispose() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47203
48148
|
}
|
|
47204
48149
|
}));
|
|
48150
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48151
|
+
try {
|
|
48152
|
+
await ws.server.getServerState().pool.dispose();
|
|
48153
|
+
} catch (err) {
|
|
48154
|
+
logger.warn(`WebSocket pool.dispose() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48155
|
+
}
|
|
48156
|
+
}));
|
|
47205
48157
|
for (const dir of inlineTmpDirs) try {
|
|
47206
48158
|
rmSync(dir, {
|
|
47207
48159
|
recursive: true,
|
|
@@ -47268,7 +48220,7 @@ function pickTargetStacks(stacks, pattern) {
|
|
|
47268
48220
|
* list, then any newly-introduced authorizer Lambdas, which keeps the
|
|
47269
48221
|
* route-table output deterministic.
|
|
47270
48222
|
*/
|
|
47271
|
-
function uniqueLambdaIds(routes, routesWithAuth) {
|
|
48223
|
+
function uniqueLambdaIds(routes, routesWithAuth, webSocketApis = []) {
|
|
47272
48224
|
const seen = /* @__PURE__ */ new Set();
|
|
47273
48225
|
const out = [];
|
|
47274
48226
|
for (const r of routes) {
|
|
@@ -47290,6 +48242,10 @@ function uniqueLambdaIds(routes, routesWithAuth) {
|
|
|
47290
48242
|
}
|
|
47291
48243
|
}
|
|
47292
48244
|
}
|
|
48245
|
+
for (const api of webSocketApis) for (const r of api.routes) if (!seen.has(r.targetLambdaLogicalId)) {
|
|
48246
|
+
seen.add(r.targetLambdaLogicalId);
|
|
48247
|
+
out.push(r.targetLambdaLogicalId);
|
|
48248
|
+
}
|
|
47293
48249
|
return out;
|
|
47294
48250
|
}
|
|
47295
48251
|
/**
|
|
@@ -47853,6 +48809,24 @@ function warnUnsupportedRoutes(routes, logger) {
|
|
|
47853
48809
|
return unsupported.length;
|
|
47854
48810
|
}
|
|
47855
48811
|
/**
|
|
48812
|
+
* Surface every WebSocket API tagged as unsupported at discovery as a
|
|
48813
|
+
* startup warn. The boot loop above skips attaching the server for
|
|
48814
|
+
* these APIs, so no upgrade requests are ever accepted on them —
|
|
48815
|
+
* mirrors `warnUnsupportedRoutes`'s shape but for the WebSocket axis.
|
|
48816
|
+
* Typical trigger: a Route declaring `AuthorizationType !== 'NONE'` on
|
|
48817
|
+
* `$connect` (cdkd v1 does not emulate WebSocket authorizers; closing
|
|
48818
|
+
* this gap structurally rather than silently admitting
|
|
48819
|
+
* unauthenticated clients matches the security-by-default precedent
|
|
48820
|
+
* PR #514 set for HTTP API v2 service integrations).
|
|
48821
|
+
*/
|
|
48822
|
+
function warnUnsupportedWebSocketApis(apis, logger) {
|
|
48823
|
+
const unsupported = apis.filter((api) => api.unsupported);
|
|
48824
|
+
if (unsupported.length === 0) return 0;
|
|
48825
|
+
logger.warn(`${unsupported.length} WebSocket API(s) will NOT accept upgrade requests (boot continued):`);
|
|
48826
|
+
for (const api of unsupported) logger.warn(` - ${api.declaredAt}: ${api.unsupported.reason}`);
|
|
48827
|
+
return unsupported.length;
|
|
48828
|
+
}
|
|
48829
|
+
/**
|
|
47856
48830
|
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
47857
48831
|
* `Integration.Uri` points at a well-known internal address space
|
|
47858
48832
|
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
@@ -48920,7 +49894,9 @@ async function localRunTaskCommand(target, options) {
|
|
|
48920
49894
|
output: options.output,
|
|
48921
49895
|
...options.region && { region: options.region },
|
|
48922
49896
|
...options.profile && { profile: options.profile },
|
|
48923
|
-
...Object.keys(context).length > 0 && { context }
|
|
49897
|
+
...Object.keys(context).length > 0 && { context },
|
|
49898
|
+
...options.stateBucket && { stateBucket: options.stateBucket },
|
|
49899
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
48924
49900
|
};
|
|
48925
49901
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
48926
49902
|
const imageContext = await buildEcsImageResolutionContext$1(target, stacks, options);
|
|
@@ -49601,7 +50577,9 @@ async function localStartServiceCommand(target, options) {
|
|
|
49601
50577
|
output: options.output,
|
|
49602
50578
|
...options.region && { region: options.region },
|
|
49603
50579
|
...options.profile && { profile: options.profile },
|
|
49604
|
-
...Object.keys(context).length > 0 && { context }
|
|
50580
|
+
...Object.keys(context).length > 0 && { context },
|
|
50581
|
+
...options.stateBucket && { stateBucket: options.stateBucket },
|
|
50582
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
49605
50583
|
};
|
|
49606
50584
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
49607
50585
|
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
@@ -49922,7 +50900,9 @@ async function localInvokeCommand(target, options) {
|
|
|
49922
50900
|
output: options.output,
|
|
49923
50901
|
...options.region && { region: options.region },
|
|
49924
50902
|
...options.profile && { profile: options.profile },
|
|
49925
|
-
...Object.keys(context).length > 0 && { context }
|
|
50903
|
+
...Object.keys(context).length > 0 && { context },
|
|
50904
|
+
...options.stateBucket && { stateBucket: options.stateBucket },
|
|
50905
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
49926
50906
|
};
|
|
49927
50907
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
49928
50908
|
const lambda = resolveLambdaTarget(target, stacks);
|
|
@@ -50830,7 +51810,9 @@ async function exportCommand(stackArg, options) {
|
|
|
50830
51810
|
const result = await synthesizer.synthesize({
|
|
50831
51811
|
app: appCmd,
|
|
50832
51812
|
output: options.output || "cdk.out",
|
|
50833
|
-
...Object.keys(context).length > 0 && { context }
|
|
51813
|
+
...Object.keys(context).length > 0 && { context },
|
|
51814
|
+
stateBucket,
|
|
51815
|
+
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
50834
51816
|
});
|
|
50835
51817
|
let stackInfo;
|
|
50836
51818
|
if (stackArg) {
|
|
@@ -52999,7 +53981,7 @@ function reorderArgs(argv) {
|
|
|
52999
53981
|
*/
|
|
53000
53982
|
async function main() {
|
|
53001
53983
|
const program = new Command();
|
|
53002
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
53984
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.137.0");
|
|
53003
53985
|
program.addCommand(createBootstrapCommand());
|
|
53004
53986
|
program.addCommand(createSynthCommand());
|
|
53005
53987
|
program.addCommand(createListCommand());
|