@go-to-k/cdkd 0.136.0 → 0.137.1
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 +1165 -32
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -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";
|
|
@@ -38712,6 +38713,7 @@ async function runDetached(opts) {
|
|
|
38712
38713
|
if (opts.name) args.push("--name", opts.name);
|
|
38713
38714
|
if (opts.network) args.push("--network", opts.network);
|
|
38714
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}`);
|
|
38715
38717
|
const host = opts.host ?? "127.0.0.1";
|
|
38716
38718
|
args.push("-p", `${host}:${opts.hostPort}:8080`);
|
|
38717
38719
|
if (opts.debugPort !== void 0) args.push("-p", `${host}:${opts.debugPort}:${opts.debugPort}`);
|
|
@@ -40361,13 +40363,13 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40361
40363
|
const integration = props["Integration"];
|
|
40362
40364
|
if (!integration) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): missing Integration property`);
|
|
40363
40365
|
const restApiId = props["RestApiId"];
|
|
40364
|
-
const restApiLogicalId = pickRefLogicalId$
|
|
40366
|
+
const restApiLogicalId = pickRefLogicalId$3(restApiId);
|
|
40365
40367
|
if (!restApiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): RestApiId must be a { Ref: '...' } reference (got ${shortJson$1(restApiId)}).`);
|
|
40366
40368
|
const resourceId = props["ResourceId"];
|
|
40367
40369
|
const path = buildRestV1Path(resourceId, restApiLogicalId, template, stackName, logicalId);
|
|
40368
40370
|
const httpMethod = stringifyValue(props["HttpMethod"] ?? "ANY");
|
|
40369
40371
|
const stage = pickRestV1Stage(restApiLogicalId, template);
|
|
40370
|
-
const restApiCdkPath = readApiCdkPath(restApiLogicalId, template);
|
|
40372
|
+
const restApiCdkPath = readApiCdkPath$1(restApiLogicalId, template);
|
|
40371
40373
|
const baseRoute = {
|
|
40372
40374
|
source: "rest-v1",
|
|
40373
40375
|
apiVersion: "v1",
|
|
@@ -40721,7 +40723,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40721
40723
|
if (Array.isArray(arg) && arg.length === 2 && arg[1] === "RootResourceId") return "/";
|
|
40722
40724
|
}
|
|
40723
40725
|
}
|
|
40724
|
-
const resourceLogicalId = pickRefLogicalId$
|
|
40726
|
+
const resourceLogicalId = pickRefLogicalId$3(resourceIdIntrinsic);
|
|
40725
40727
|
if (!resourceLogicalId) throw new Error(`${stackName}/${methodLogicalId}: ResourceId must be { Ref: '...' } or { 'Fn::GetAtt': [..., 'RootResourceId'] } (got ${shortJson$1(resourceIdIntrinsic)}).`);
|
|
40726
40728
|
const segments = [];
|
|
40727
40729
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -40741,7 +40743,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40741
40743
|
const arg = parentId["Fn::GetAtt"];
|
|
40742
40744
|
if (Array.isArray(arg) && arg[1] === "RootResourceId") break;
|
|
40743
40745
|
}
|
|
40744
|
-
cursor = pickRefLogicalId$
|
|
40746
|
+
cursor = pickRefLogicalId$3(parentId) ?? void 0;
|
|
40745
40747
|
}
|
|
40746
40748
|
return "/" + segments.join("/");
|
|
40747
40749
|
}
|
|
@@ -40756,7 +40758,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40756
40758
|
for (const [, resource] of Object.entries(resources)) {
|
|
40757
40759
|
if (resource.Type !== "AWS::ApiGateway::Stage") continue;
|
|
40758
40760
|
const props = resource.Properties ?? {};
|
|
40759
|
-
if (pickRefLogicalId$
|
|
40761
|
+
if (pickRefLogicalId$3(props["RestApiId"]) === restApiLogicalId) {
|
|
40760
40762
|
const stageName = props["StageName"];
|
|
40761
40763
|
if (typeof stageName === "string") return stageName;
|
|
40762
40764
|
}
|
|
@@ -40775,26 +40777,14 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40775
40777
|
function discoverHttpApiRoute(logicalId, resource, template, stackName) {
|
|
40776
40778
|
const props = resource.Properties ?? {};
|
|
40777
40779
|
const apiId = props["ApiId"];
|
|
40778
|
-
const apiLogicalId = pickRefLogicalId$
|
|
40780
|
+
const apiLogicalId = pickRefLogicalId$3(apiId);
|
|
40779
40781
|
if (!apiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): ApiId must be { Ref: '...' } (got ${shortJson$1(apiId)}).`);
|
|
40780
40782
|
const routeKey = props["RouteKey"];
|
|
40781
40783
|
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): RouteKey must be a string`);
|
|
40782
|
-
const apiCdkPath = readApiCdkPath(apiLogicalId, template);
|
|
40784
|
+
const apiCdkPath = readApiCdkPath$1(apiLogicalId, template);
|
|
40783
40785
|
const apiResource = template.Resources?.[apiLogicalId];
|
|
40784
40786
|
if (apiResource?.Type === "AWS::ApiGatewayV2::Api") {
|
|
40785
|
-
if ((apiResource.Properties ?? {})["ProtocolType"] === "WEBSOCKET") return [
|
|
40786
|
-
method: "ANY",
|
|
40787
|
-
pathPattern: routeKey,
|
|
40788
|
-
lambdaLogicalId: "",
|
|
40789
|
-
source: "http-api",
|
|
40790
|
-
apiVersion: "v2",
|
|
40791
|
-
stage: "$default",
|
|
40792
|
-
apiLogicalId,
|
|
40793
|
-
apiStackName: stackName,
|
|
40794
|
-
...apiCdkPath !== void 0 && { apiCdkPath },
|
|
40795
|
-
declaredAt: `${stackName}/${logicalId}`,
|
|
40796
|
-
unsupported: { reason: `${stackName}/${logicalId}: WebSocket APIs are not supported in cdkd local start-api.` }
|
|
40797
|
-
}];
|
|
40787
|
+
if ((apiResource.Properties ?? {})["ProtocolType"] === "WEBSOCKET") return [];
|
|
40798
40788
|
}
|
|
40799
40789
|
const { method, pathPattern } = parseRouteKey(routeKey);
|
|
40800
40790
|
const baseRoute = {
|
|
@@ -40896,7 +40886,7 @@ function discoverFunctionUrl(logicalId, resource, template, stackName) {
|
|
|
40896
40886
|
const arnOutcome = resolveLambdaArnOutcome(targetArn);
|
|
40897
40887
|
if (arnOutcome.kind === "unsupported") throw new Error(`${stackName}/${logicalId}.TargetFunctionArn: ${arnOutcome.detail} (got ${shortJson$1(targetArn)}).`);
|
|
40898
40888
|
const lambdaLogicalId = arnOutcome.logicalId;
|
|
40899
|
-
const lambdaCdkPath = readApiCdkPath(lambdaLogicalId, template);
|
|
40889
|
+
const lambdaCdkPath = readApiCdkPath$1(lambdaLogicalId, template);
|
|
40900
40890
|
const baseRoute = {
|
|
40901
40891
|
method: "ANY",
|
|
40902
40892
|
pathPattern: "/{proxy+}",
|
|
@@ -40933,7 +40923,7 @@ function discoverFunctionUrl(logicalId, resource, template, stackName) {
|
|
|
40933
40923
|
* metadata isn't set. Hides the "may be missing for a hand-rolled
|
|
40934
40924
|
* `cfn.Resource`" branch from every call site.
|
|
40935
40925
|
*/
|
|
40936
|
-
function readApiCdkPath(logicalId, template) {
|
|
40926
|
+
function readApiCdkPath$1(logicalId, template) {
|
|
40937
40927
|
const resource = template.Resources?.[logicalId];
|
|
40938
40928
|
if (!resource) return void 0;
|
|
40939
40929
|
return readCdkPath(resource) || void 0;
|
|
@@ -41000,11 +40990,11 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
41000
40990
|
const sep = join[0];
|
|
41001
40991
|
const parts = join[1];
|
|
41002
40992
|
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
41003
|
-
const ref = pickRefLogicalId$
|
|
40993
|
+
const ref = pickRefLogicalId$3(parts[1]);
|
|
41004
40994
|
if (ref) return ref;
|
|
41005
40995
|
}
|
|
41006
40996
|
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
41007
|
-
const ref = pickRefLogicalId$
|
|
40997
|
+
const ref = pickRefLogicalId$3(parts[1]);
|
|
41008
40998
|
if (ref) return ref;
|
|
41009
40999
|
}
|
|
41010
41000
|
}
|
|
@@ -41024,7 +41014,7 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
41024
41014
|
if (m) {
|
|
41025
41015
|
const bound = bindings[m[1]];
|
|
41026
41016
|
if (bound !== void 0) {
|
|
41027
|
-
const ref = pickRefLogicalId$
|
|
41017
|
+
const ref = pickRefLogicalId$3(bound);
|
|
41028
41018
|
if (ref) return ref;
|
|
41029
41019
|
}
|
|
41030
41020
|
}
|
|
@@ -41053,7 +41043,7 @@ function parseRouteKey(routeKey) {
|
|
|
41053
41043
|
* If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
|
|
41054
41044
|
* logical ID. Otherwise return `null`.
|
|
41055
41045
|
*/
|
|
41056
|
-
function pickRefLogicalId$
|
|
41046
|
+
function pickRefLogicalId$3(value) {
|
|
41057
41047
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41058
41048
|
const ref = value["Ref"];
|
|
41059
41049
|
if (typeof ref === "string") return ref;
|
|
@@ -41073,6 +41063,1017 @@ function shortJson$1(value) {
|
|
|
41073
41063
|
}
|
|
41074
41064
|
}
|
|
41075
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 (authType === void 0) continue;
|
|
41148
|
+
const routeKey = props["RouteKey"];
|
|
41149
|
+
const routeKeyForReport = typeof routeKey === "string" ? routeKey : "<unknown>";
|
|
41150
|
+
if (typeof authType !== "string" || authType.length === 0) {
|
|
41151
|
+
result.push({
|
|
41152
|
+
routeKey: routeKeyForReport,
|
|
41153
|
+
authorizationType: "<intrinsic-or-malformed>"
|
|
41154
|
+
});
|
|
41155
|
+
continue;
|
|
41156
|
+
}
|
|
41157
|
+
if (authType === "NONE") continue;
|
|
41158
|
+
result.push({
|
|
41159
|
+
routeKey: routeKeyForReport,
|
|
41160
|
+
authorizationType: authType
|
|
41161
|
+
});
|
|
41162
|
+
}
|
|
41163
|
+
return result;
|
|
41164
|
+
}
|
|
41165
|
+
/**
|
|
41166
|
+
* `$request.body.<key>` is the AWS-canonical shape and the only one
|
|
41167
|
+
* v1 supports. Allow nested dot access (`$request.body.action.subKey`)
|
|
41168
|
+
* — real CDK chat apps sometimes use this for protocol versioning.
|
|
41169
|
+
*
|
|
41170
|
+
* Reject array-index access (`$request.body.items[0]`), filter
|
|
41171
|
+
* expressions, header / context selections — these would require a
|
|
41172
|
+
* fuller JSONPath / VTL evaluator and are out of scope for v1.
|
|
41173
|
+
*/
|
|
41174
|
+
function assertSupportedSelectionExpression(expr, declaredAt) {
|
|
41175
|
+
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.`);
|
|
41176
|
+
}
|
|
41177
|
+
/**
|
|
41178
|
+
* Parse a `$request.body.x.y` selection expression into the JSON-path
|
|
41179
|
+
* tokens after `$request.body`. Returns `['x', 'y']` for the example
|
|
41180
|
+
* above. Used at message-dispatch time to walk the parsed message body.
|
|
41181
|
+
*/
|
|
41182
|
+
function parseSelectionExpressionPath(expr) {
|
|
41183
|
+
const m = /^\$request\.body\.(.+)$/.exec(expr);
|
|
41184
|
+
if (!m) return [];
|
|
41185
|
+
return m[1].split(".");
|
|
41186
|
+
}
|
|
41187
|
+
/**
|
|
41188
|
+
* Pick the first `AWS::ApiGatewayV2::Stage` referencing the API. CDK's
|
|
41189
|
+
* `apigatewayv2.WebSocketStage` always emits one; the fallback to
|
|
41190
|
+
* `'local'` handles hand-rolled templates without a Stage.
|
|
41191
|
+
*/
|
|
41192
|
+
function pickStage$1(apiLogicalId, template) {
|
|
41193
|
+
const resources = template.Resources ?? {};
|
|
41194
|
+
for (const [, resource] of Object.entries(resources)) {
|
|
41195
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Stage") continue;
|
|
41196
|
+
const props = resource.Properties ?? {};
|
|
41197
|
+
if (pickRefLogicalId$2(props["ApiId"]) === apiLogicalId) {
|
|
41198
|
+
const stageName = props["StageName"];
|
|
41199
|
+
if (typeof stageName === "string" && stageName.length > 0) return stageName;
|
|
41200
|
+
}
|
|
41201
|
+
}
|
|
41202
|
+
return DEFAULT_STAGE;
|
|
41203
|
+
}
|
|
41204
|
+
/**
|
|
41205
|
+
* Walk every `AWS::ApiGatewayV2::Route` and resolve each one whose
|
|
41206
|
+
* parent `ApiId` Ref matches the WebSocket API. Per-route failures
|
|
41207
|
+
* abort the API's discovery (a partial route map would silently
|
|
41208
|
+
* disable some routes — better to fail fast and let the user fix the
|
|
41209
|
+
* template).
|
|
41210
|
+
*/
|
|
41211
|
+
function collectRoutesForApi(apiLogicalId, template, stackName) {
|
|
41212
|
+
const resources = template.Resources ?? {};
|
|
41213
|
+
const result = [];
|
|
41214
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
41215
|
+
for (const [routeLogicalId, resource] of Object.entries(resources)) {
|
|
41216
|
+
if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
|
|
41217
|
+
const props = resource.Properties ?? {};
|
|
41218
|
+
if (pickRefLogicalId$2(props["ApiId"]) !== apiLogicalId) continue;
|
|
41219
|
+
const declaredAt = `${stackName}/${routeLogicalId}`;
|
|
41220
|
+
const routeKey = props["RouteKey"];
|
|
41221
|
+
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${declaredAt}: RouteKey must be a non-empty string.`);
|
|
41222
|
+
if (seenKeys.has(routeKey)) throw new Error(`${declaredAt}: WebSocket API has duplicate RouteKey '${routeKey}' — each RouteKey may appear at most once per API.`);
|
|
41223
|
+
seenKeys.add(routeKey);
|
|
41224
|
+
const targetLogicalId = parseRouteTarget(props["Target"], declaredAt);
|
|
41225
|
+
const integration = resources[targetLogicalId];
|
|
41226
|
+
if (!integration || integration.Type !== "AWS::ApiGatewayV2::Integration") throw new Error(`${declaredAt}: Target points at '${targetLogicalId}' which is not an AWS::ApiGatewayV2::Integration.`);
|
|
41227
|
+
const integrationProps = integration.Properties ?? {};
|
|
41228
|
+
const integrationType = integrationProps["IntegrationType"];
|
|
41229
|
+
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.`);
|
|
41230
|
+
const arnOutcome = resolveLambdaArnIntrinsic(integrationProps["IntegrationUri"]);
|
|
41231
|
+
if (arnOutcome.kind === "unsupported") throw new Error(`${stackName}/${targetLogicalId}.IntegrationUri: ${arnOutcome.detail} — WebSocket routes must point at a same-template Lambda.`);
|
|
41232
|
+
result.push({
|
|
41233
|
+
routeKey,
|
|
41234
|
+
targetLambdaLogicalId: arnOutcome.logicalId,
|
|
41235
|
+
lambdaStackName: stackName,
|
|
41236
|
+
declaredAt
|
|
41237
|
+
});
|
|
41238
|
+
}
|
|
41239
|
+
return result;
|
|
41240
|
+
}
|
|
41241
|
+
/**
|
|
41242
|
+
* WebSocket Routes use the same `Target: 'integrations/<id>'` shape as
|
|
41243
|
+
* HTTP API v2 Routes. We accept the same five forms documented in
|
|
41244
|
+
* `route-discovery.ts:parseHttpApiTargetIntegration` — literal string,
|
|
41245
|
+
* two `Fn::Join` shapes, two `Fn::Sub` shapes.
|
|
41246
|
+
*
|
|
41247
|
+
* Implementation note: we intentionally duplicate the parser rather
|
|
41248
|
+
* than reach into `route-discovery.ts` because that module is in flux
|
|
41249
|
+
* (`unsupported` / `mockCors` shapes; HTTP-specific). When the two
|
|
41250
|
+
* parsers grow apart, the WebSocket one only needs to track the AWS
|
|
41251
|
+
* WebSocket-side shape — which has been stable since 2018.
|
|
41252
|
+
*/
|
|
41253
|
+
function parseRouteTarget(target, location) {
|
|
41254
|
+
if (typeof target === "string") {
|
|
41255
|
+
const m = /^integrations\/(.+)$/.exec(target);
|
|
41256
|
+
if (m) return m[1];
|
|
41257
|
+
throw new Error(`${location}: literal Target '${target}' must start with 'integrations/'.`);
|
|
41258
|
+
}
|
|
41259
|
+
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
41260
|
+
const obj = target;
|
|
41261
|
+
const join = obj["Fn::Join"];
|
|
41262
|
+
if (Array.isArray(join) && join.length === 2 && Array.isArray(join[1])) {
|
|
41263
|
+
const sep = join[0];
|
|
41264
|
+
const parts = join[1];
|
|
41265
|
+
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
41266
|
+
const ref = pickRefLogicalId$2(parts[1]);
|
|
41267
|
+
if (ref) return ref;
|
|
41268
|
+
}
|
|
41269
|
+
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
41270
|
+
const ref = pickRefLogicalId$2(parts[1]);
|
|
41271
|
+
if (ref) return ref;
|
|
41272
|
+
}
|
|
41273
|
+
}
|
|
41274
|
+
if ("Fn::Sub" in obj) {
|
|
41275
|
+
const sub = obj["Fn::Sub"];
|
|
41276
|
+
const prefix = "integrations/";
|
|
41277
|
+
if (typeof sub === "string") {
|
|
41278
|
+
const m = new RegExp(`^${prefix}\\$\\{([^}]+)\\}$`).exec(sub);
|
|
41279
|
+
if (m) {
|
|
41280
|
+
const placeholder = m[1];
|
|
41281
|
+
if (!placeholder.includes(".")) return placeholder;
|
|
41282
|
+
}
|
|
41283
|
+
}
|
|
41284
|
+
if (Array.isArray(sub) && sub.length === 2 && typeof sub[0] === "string" && sub[1] !== null && typeof sub[1] === "object" && !Array.isArray(sub[1])) {
|
|
41285
|
+
const template = sub[0];
|
|
41286
|
+
const bindings = sub[1];
|
|
41287
|
+
const m = new RegExp(`^${prefix}\\$\\{([^}]+)\\}$`).exec(template);
|
|
41288
|
+
if (m) {
|
|
41289
|
+
const bound = bindings[m[1]];
|
|
41290
|
+
if (bound !== void 0) {
|
|
41291
|
+
const ref = pickRefLogicalId$2(bound);
|
|
41292
|
+
if (ref) return ref;
|
|
41293
|
+
}
|
|
41294
|
+
}
|
|
41295
|
+
}
|
|
41296
|
+
}
|
|
41297
|
+
}
|
|
41298
|
+
throw new Error(`${location}: Target must be 'integrations/<id>' literal, Fn::Join with the documented shapes, or Fn::Sub with an 'integrations/\${...}' template.`);
|
|
41299
|
+
}
|
|
41300
|
+
function pickRefLogicalId$2(value) {
|
|
41301
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41302
|
+
const ref = value["Ref"];
|
|
41303
|
+
if (typeof ref === "string") return ref;
|
|
41304
|
+
}
|
|
41305
|
+
return null;
|
|
41306
|
+
}
|
|
41307
|
+
function readApiCdkPath(logicalId, template) {
|
|
41308
|
+
const resource = template.Resources?.[logicalId];
|
|
41309
|
+
if (!resource) return "";
|
|
41310
|
+
return readCdkPath(resource);
|
|
41311
|
+
}
|
|
41312
|
+
|
|
41313
|
+
//#endregion
|
|
41314
|
+
//#region src/local/websocket-event.ts
|
|
41315
|
+
const MOCK_DOMAIN_NAME$1 = "localhost";
|
|
41316
|
+
const MOCK_API_ID$1 = "local";
|
|
41317
|
+
/**
|
|
41318
|
+
* Build a request-context block shared across all three event types.
|
|
41319
|
+
* `eventType` / `routeKey` are passed in by the per-event caller; the
|
|
41320
|
+
* shared block produces fresh `requestId` / `extendedRequestId` per
|
|
41321
|
+
* event (matching AWS-deployed behavior — these are NOT stable across
|
|
41322
|
+
* events on the same connection).
|
|
41323
|
+
*/
|
|
41324
|
+
function buildRequestContext(routeKey, eventType, connectionId, connectedAt, stage, snapshot) {
|
|
41325
|
+
const now = Date.now();
|
|
41326
|
+
return {
|
|
41327
|
+
routeKey,
|
|
41328
|
+
eventType,
|
|
41329
|
+
connectionId,
|
|
41330
|
+
extendedRequestId: randomUUID(),
|
|
41331
|
+
requestTime: formatRequestTime$1(now),
|
|
41332
|
+
requestTimeEpoch: now,
|
|
41333
|
+
messageDirection: "IN",
|
|
41334
|
+
stage,
|
|
41335
|
+
connectedAt,
|
|
41336
|
+
requestId: randomUUID(),
|
|
41337
|
+
domainName: MOCK_DOMAIN_NAME$1,
|
|
41338
|
+
apiId: MOCK_API_ID$1,
|
|
41339
|
+
authorizer: null,
|
|
41340
|
+
identity: {
|
|
41341
|
+
sourceIp: snapshot.sourceIp ?? "127.0.0.1",
|
|
41342
|
+
userAgent: snapshot.userAgent ?? ""
|
|
41343
|
+
}
|
|
41344
|
+
};
|
|
41345
|
+
}
|
|
41346
|
+
/**
|
|
41347
|
+
* Build the `$connect` event. AWS WebSocket APIs fire `$connect` ONCE
|
|
41348
|
+
* per client connection. Handler returns `{statusCode: 200}` to allow
|
|
41349
|
+
* the connection, anything else (or throws) to deny — cdkd matches the
|
|
41350
|
+
* deployed behavior by checking the response in the caller.
|
|
41351
|
+
*/
|
|
41352
|
+
function buildConnectEvent(opts) {
|
|
41353
|
+
const headers = normalizeHeaders(opts.snapshot.headers);
|
|
41354
|
+
const multiValueHeaders = lowercaseMultiValueHeaders(opts.snapshot.headers);
|
|
41355
|
+
return {
|
|
41356
|
+
...headers !== void 0 && { headers },
|
|
41357
|
+
...multiValueHeaders !== void 0 && { multiValueHeaders },
|
|
41358
|
+
queryStringParameters: opts.snapshot.queryStringParameters ?? null,
|
|
41359
|
+
multiValueQueryStringParameters: opts.snapshot.multiValueQueryStringParameters ?? null,
|
|
41360
|
+
requestContext: { ...buildRequestContext("$connect", "CONNECT", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot) },
|
|
41361
|
+
isBase64Encoded: false,
|
|
41362
|
+
body: ""
|
|
41363
|
+
};
|
|
41364
|
+
}
|
|
41365
|
+
/**
|
|
41366
|
+
* Build a MESSAGE event. Fires for every frame the client sends. The
|
|
41367
|
+
* route the API dispatches to is resolved upstream by the route
|
|
41368
|
+
* selection-expression layer; the resolved `routeKey` (`$default` or
|
|
41369
|
+
* a custom string) lands on `requestContext.routeKey`.
|
|
41370
|
+
*/
|
|
41371
|
+
function buildMessageEvent(opts) {
|
|
41372
|
+
return {
|
|
41373
|
+
requestContext: {
|
|
41374
|
+
...buildRequestContext(opts.routeKey, "MESSAGE", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot),
|
|
41375
|
+
messageId: randomUUID()
|
|
41376
|
+
},
|
|
41377
|
+
isBase64Encoded: opts.isBase64Encoded,
|
|
41378
|
+
body: opts.body
|
|
41379
|
+
};
|
|
41380
|
+
}
|
|
41381
|
+
/**
|
|
41382
|
+
* Build the `$disconnect` event. Fires when the WebSocket closes from
|
|
41383
|
+
* either side (client / server / abnormal). The Lambda's response is
|
|
41384
|
+
* ignored (the socket is already gone); AWS still invokes the handler
|
|
41385
|
+
* for cleanup / logging side effects.
|
|
41386
|
+
*
|
|
41387
|
+
* `disconnectStatusCode` / `disconnectReason` are taken from the
|
|
41388
|
+
* WebSocket close frame (RFC 6455 §7.1.5 — close codes such as 1000
|
|
41389
|
+
* normal / 1001 going-away / 1008 policy-violation).
|
|
41390
|
+
*/
|
|
41391
|
+
function buildDisconnectEvent(opts) {
|
|
41392
|
+
return {
|
|
41393
|
+
requestContext: {
|
|
41394
|
+
...buildRequestContext("$disconnect", "DISCONNECT", opts.connectionId, opts.connectedAt, opts.stage, opts.snapshot),
|
|
41395
|
+
...opts.disconnectStatusCode !== void 0 && { disconnectStatusCode: opts.disconnectStatusCode },
|
|
41396
|
+
...opts.disconnectReason !== void 0 && { disconnectReason: opts.disconnectReason }
|
|
41397
|
+
},
|
|
41398
|
+
isBase64Encoded: false,
|
|
41399
|
+
body: ""
|
|
41400
|
+
};
|
|
41401
|
+
}
|
|
41402
|
+
/**
|
|
41403
|
+
* Format a timestamp in the AWS-canonical `dd/MMM/yyyy:HH:mm:ss +0000`
|
|
41404
|
+
* shape that AWS API Gateway emits on `requestContext.requestTime`.
|
|
41405
|
+
* Always UTC (matches AWS-deployed behavior, which is region-independent).
|
|
41406
|
+
*/
|
|
41407
|
+
function formatRequestTime$1(epochMs) {
|
|
41408
|
+
const d = new Date(epochMs);
|
|
41409
|
+
return `${String(d.getUTCDate()).padStart(2, "0")}/${[
|
|
41410
|
+
"Jan",
|
|
41411
|
+
"Feb",
|
|
41412
|
+
"Mar",
|
|
41413
|
+
"Apr",
|
|
41414
|
+
"May",
|
|
41415
|
+
"Jun",
|
|
41416
|
+
"Jul",
|
|
41417
|
+
"Aug",
|
|
41418
|
+
"Sep",
|
|
41419
|
+
"Oct",
|
|
41420
|
+
"Nov",
|
|
41421
|
+
"Dec"
|
|
41422
|
+
][d.getUTCMonth()]}/${d.getUTCFullYear()}:${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} +0000`;
|
|
41423
|
+
}
|
|
41424
|
+
/**
|
|
41425
|
+
* Lowercase header keys, comma-join duplicates per AWS spec.
|
|
41426
|
+
*/
|
|
41427
|
+
function normalizeHeaders(headers) {
|
|
41428
|
+
const out = {};
|
|
41429
|
+
let any = false;
|
|
41430
|
+
for (const [name, values] of Object.entries(headers)) {
|
|
41431
|
+
if (values.length === 0) continue;
|
|
41432
|
+
out[name.toLowerCase()] = values.join(",");
|
|
41433
|
+
any = true;
|
|
41434
|
+
}
|
|
41435
|
+
return any ? out : void 0;
|
|
41436
|
+
}
|
|
41437
|
+
/**
|
|
41438
|
+
* Lowercase header keys, preserve multi-value array shape.
|
|
41439
|
+
*/
|
|
41440
|
+
function lowercaseMultiValueHeaders(headers) {
|
|
41441
|
+
const out = {};
|
|
41442
|
+
let any = false;
|
|
41443
|
+
for (const [name, values] of Object.entries(headers)) {
|
|
41444
|
+
if (values.length === 0) continue;
|
|
41445
|
+
out[name.toLowerCase()] = [...values];
|
|
41446
|
+
any = true;
|
|
41447
|
+
}
|
|
41448
|
+
return any ? out : void 0;
|
|
41449
|
+
}
|
|
41450
|
+
|
|
41451
|
+
//#endregion
|
|
41452
|
+
//#region src/local/websocket-mgmt-api.ts
|
|
41453
|
+
/**
|
|
41454
|
+
* `Map<connectionId, ConnectionRegistryEntry>` wrapper with type-safe
|
|
41455
|
+
* accessors. Lookups by connectionId stay O(1).
|
|
41456
|
+
*/
|
|
41457
|
+
var ConnectionRegistry = class {
|
|
41458
|
+
entries = /* @__PURE__ */ new Map();
|
|
41459
|
+
register(entry) {
|
|
41460
|
+
this.entries.set(entry.connectionId, entry);
|
|
41461
|
+
}
|
|
41462
|
+
unregister(connectionId) {
|
|
41463
|
+
const entry = this.entries.get(connectionId);
|
|
41464
|
+
if (entry) this.entries.delete(connectionId);
|
|
41465
|
+
return entry;
|
|
41466
|
+
}
|
|
41467
|
+
get(connectionId) {
|
|
41468
|
+
return this.entries.get(connectionId);
|
|
41469
|
+
}
|
|
41470
|
+
size() {
|
|
41471
|
+
return this.entries.size;
|
|
41472
|
+
}
|
|
41473
|
+
/**
|
|
41474
|
+
* Snapshot the live entries (for diagnostics / shutdown drain).
|
|
41475
|
+
* Returns a fresh array so the caller can iterate without ownership
|
|
41476
|
+
* concerns over the underlying Map.
|
|
41477
|
+
*/
|
|
41478
|
+
list() {
|
|
41479
|
+
return Array.from(this.entries.values());
|
|
41480
|
+
}
|
|
41481
|
+
clear() {
|
|
41482
|
+
this.entries.clear();
|
|
41483
|
+
}
|
|
41484
|
+
};
|
|
41485
|
+
/**
|
|
41486
|
+
* Match the request URL against the `@connections` endpoint family.
|
|
41487
|
+
* Returns the parsed connectionId on match, `null` otherwise.
|
|
41488
|
+
*
|
|
41489
|
+
* AWS reserves `$` / `@` for control planes so the path prefix
|
|
41490
|
+
* `/@connections/` can never collide with user-declared routes.
|
|
41491
|
+
*
|
|
41492
|
+
* Accepted shapes:
|
|
41493
|
+
* - `/@connections/<id>` (AWS-deployed historical form — supported
|
|
41494
|
+
* for backward compatibility with handlers that construct URLs
|
|
41495
|
+
* manually).
|
|
41496
|
+
* - `/<stage>/@connections/<id>` (AWS-docs-canonical form — the
|
|
41497
|
+
* deployed apigatewaymanagementapi endpoint URL is
|
|
41498
|
+
* `https://<api-id>.execute-api.<region>.amazonaws.com/<stage>`,
|
|
41499
|
+
* so SDK-built clients call `POST /<stage>/@connections/<id>`).
|
|
41500
|
+
*
|
|
41501
|
+
* The optional `<stage>` segment matches AWS's deployed URL exactly —
|
|
41502
|
+
* any non-slash sequence preceding `/@connections/`. The stage value
|
|
41503
|
+
* itself is intentionally NOT validated against the per-API configured
|
|
41504
|
+
* stage name: cdkd is a local-dev tool, not a security boundary, and
|
|
41505
|
+
* an aggressive stage check would just trip on misconfigured handlers
|
|
41506
|
+
* without adding any real protection.
|
|
41507
|
+
*/
|
|
41508
|
+
function parseConnectionsPath(url) {
|
|
41509
|
+
const pathOnly = url.split("?", 1)[0];
|
|
41510
|
+
const m = /^\/(?:[^/]+\/)?@connections\/([^/]+)\/?$/.exec(pathOnly);
|
|
41511
|
+
if (!m) return null;
|
|
41512
|
+
const decoded = safeDecodeURIComponent(m[1]);
|
|
41513
|
+
if (decoded === null) return null;
|
|
41514
|
+
return { connectionId: decoded };
|
|
41515
|
+
}
|
|
41516
|
+
/**
|
|
41517
|
+
* Build the per-Lambda env-var URL the cdkd local server injects as
|
|
41518
|
+
* `AWS_ENDPOINT_URL_APIGATEWAYMANAGEMENTAPI`. The URL MUST include the
|
|
41519
|
+
* `/<stage>` segment to mirror the AWS-deployed apigatewaymanagementapi
|
|
41520
|
+
* endpoint `https://<api-id>.execute-api.<region>.amazonaws.com/<stage>`:
|
|
41521
|
+
* SDK clients built from `domainName + stage` produce
|
|
41522
|
+
* `POST /<stage>/@connections/<id>`, and the local `parseConnectionsPath`
|
|
41523
|
+
* regex above requires the matching prefix. Without `/<stage>` the
|
|
41524
|
+
* deployed-shape SDK call hits a 404 against the local parser
|
|
41525
|
+
* (BLOCKER B1, #526).
|
|
41526
|
+
*
|
|
41527
|
+
* Lives next to `parseConnectionsPath` so the producer + consumer of
|
|
41528
|
+
* the URL shape stay in lockstep — change one, the other's tests fail.
|
|
41529
|
+
* Issue #537 item 7.
|
|
41530
|
+
*/
|
|
41531
|
+
function buildMgmtEndpointEnvUrl(host, port, stage) {
|
|
41532
|
+
return `http://${host}:${port}/${stage}`;
|
|
41533
|
+
}
|
|
41534
|
+
/**
|
|
41535
|
+
* `decodeURIComponent` throws `URIError` on malformed input
|
|
41536
|
+
* (`%`-escape with non-hex tail). We treat that as a not-found rather
|
|
41537
|
+
* than a server error — symmetric with AWS-deployed behavior, which
|
|
41538
|
+
* returns `GoneException` (HTTP 410) for any connection id it can't
|
|
41539
|
+
* look up.
|
|
41540
|
+
*/
|
|
41541
|
+
function safeDecodeURIComponent(s) {
|
|
41542
|
+
try {
|
|
41543
|
+
return decodeURIComponent(s);
|
|
41544
|
+
} catch {
|
|
41545
|
+
return null;
|
|
41546
|
+
}
|
|
41547
|
+
}
|
|
41548
|
+
/**
|
|
41549
|
+
* Read the full request body into a Buffer. Mirrors `node:http`'s
|
|
41550
|
+
* `IncomingMessage` consume pattern — collect chunks, resolve on `end`.
|
|
41551
|
+
*
|
|
41552
|
+
* The body is what the user's handler passed to
|
|
41553
|
+
* `apigatewaymanagementapi.PostToConnection({Data: <bytes>})`. AWS docs
|
|
41554
|
+
* say the body is raw bytes (treated as opaque by the API plane); we
|
|
41555
|
+
* forward the buffer through to `WebSocket.send` so binary frames work
|
|
41556
|
+
* end to end.
|
|
41557
|
+
*/
|
|
41558
|
+
function readRequestBody(req) {
|
|
41559
|
+
return new Promise((resolve, reject) => {
|
|
41560
|
+
const chunks = [];
|
|
41561
|
+
req.on("data", (chunk) => {
|
|
41562
|
+
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
|
|
41563
|
+
else chunks.push(Buffer.from(chunk, "utf-8"));
|
|
41564
|
+
});
|
|
41565
|
+
req.on("end", () => {
|
|
41566
|
+
resolve(Buffer.concat(chunks));
|
|
41567
|
+
});
|
|
41568
|
+
req.on("error", reject);
|
|
41569
|
+
});
|
|
41570
|
+
}
|
|
41571
|
+
/**
|
|
41572
|
+
* Handle a `@connections/<id>` HTTP request. Dispatches by method:
|
|
41573
|
+
* - `POST` → push the request body to the matching open WebSocket.
|
|
41574
|
+
* - `DELETE` → force-close the WebSocket (1000 normal close).
|
|
41575
|
+
* - `GET` → return synthetic metadata for the connection.
|
|
41576
|
+
* - anything else → 405.
|
|
41577
|
+
*
|
|
41578
|
+
* Returns `true` when the request was handled (caller short-circuits),
|
|
41579
|
+
* `false` when the URL didn't match (caller continues normal HTTP
|
|
41580
|
+
* route dispatch).
|
|
41581
|
+
*
|
|
41582
|
+
* AWS-correct status codes:
|
|
41583
|
+
* - Connection not in registry → `410 Gone` (matches AWS's
|
|
41584
|
+
* `GoneException` for closed connections).
|
|
41585
|
+
* - Send succeeded → `200 OK` (body empty).
|
|
41586
|
+
* - Send failed (socket not OPEN) → `410 Gone` — the connection has
|
|
41587
|
+
* started closing on the WebSocket side but the registry entry
|
|
41588
|
+
* hasn't been removed yet (the `close` event clean-up is async).
|
|
41589
|
+
*
|
|
41590
|
+
* NOTE: The body buffer can include arbitrary binary; `ws.send` handles
|
|
41591
|
+
* both string and Buffer inputs (the recipient receives the same bytes
|
|
41592
|
+
* the sender wrote).
|
|
41593
|
+
*/
|
|
41594
|
+
async function handleConnectionsRequest(opts) {
|
|
41595
|
+
const { req, res, registry } = opts;
|
|
41596
|
+
const parsed = parseConnectionsPath(req.url ?? "");
|
|
41597
|
+
if (!parsed) {
|
|
41598
|
+
writeJson(res, 404, { message: "Not Found" });
|
|
41599
|
+
return;
|
|
41600
|
+
}
|
|
41601
|
+
const { connectionId } = parsed;
|
|
41602
|
+
const entry = registry.get(connectionId);
|
|
41603
|
+
const method = (req.method ?? "").toUpperCase();
|
|
41604
|
+
if (!entry) {
|
|
41605
|
+
writeJson(res, 410, { message: "GoneException" });
|
|
41606
|
+
return;
|
|
41607
|
+
}
|
|
41608
|
+
if (method === "POST") {
|
|
41609
|
+
let body;
|
|
41610
|
+
try {
|
|
41611
|
+
body = await readRequestBody(req);
|
|
41612
|
+
} catch (err) {
|
|
41613
|
+
writeJson(res, 500, { message: `Failed to read request body: ${err instanceof Error ? err.message : String(err)}` });
|
|
41614
|
+
return;
|
|
41615
|
+
}
|
|
41616
|
+
if (entry.socket.readyState !== entry.socket.OPEN) {
|
|
41617
|
+
writeJson(res, 410, { message: "GoneException" });
|
|
41618
|
+
return;
|
|
41619
|
+
}
|
|
41620
|
+
try {
|
|
41621
|
+
entry.socket.send(body);
|
|
41622
|
+
} catch (err) {
|
|
41623
|
+
writeJson(res, 500, { message: `Failed to deliver to socket: ${err instanceof Error ? err.message : String(err)}` });
|
|
41624
|
+
return;
|
|
41625
|
+
}
|
|
41626
|
+
res.writeHead(200);
|
|
41627
|
+
res.end();
|
|
41628
|
+
return;
|
|
41629
|
+
}
|
|
41630
|
+
if (method === "DELETE") {
|
|
41631
|
+
try {
|
|
41632
|
+
entry.socket.close(1e3, "DeleteConnection");
|
|
41633
|
+
} catch (err) {
|
|
41634
|
+
writeJson(res, 500, { message: `Failed to close socket: ${err instanceof Error ? err.message : String(err)}` });
|
|
41635
|
+
return;
|
|
41636
|
+
}
|
|
41637
|
+
res.writeHead(204);
|
|
41638
|
+
res.end();
|
|
41639
|
+
return;
|
|
41640
|
+
}
|
|
41641
|
+
if (method === "GET") {
|
|
41642
|
+
writeJson(res, 200, {
|
|
41643
|
+
ConnectedAt: new Date(entry.connectedAt).toISOString(),
|
|
41644
|
+
Identity: { SourceIp: "127.0.0.1" },
|
|
41645
|
+
LastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
41646
|
+
});
|
|
41647
|
+
return;
|
|
41648
|
+
}
|
|
41649
|
+
res.setHeader("Allow", "POST, GET, DELETE");
|
|
41650
|
+
writeJson(res, 405, { message: "MethodNotAllowedException" });
|
|
41651
|
+
}
|
|
41652
|
+
function writeJson(res, status, body) {
|
|
41653
|
+
const json = JSON.stringify(body);
|
|
41654
|
+
res.writeHead(status, {
|
|
41655
|
+
"Content-Type": "application/json",
|
|
41656
|
+
"Content-Length": Buffer.byteLength(json)
|
|
41657
|
+
});
|
|
41658
|
+
res.end(json);
|
|
41659
|
+
}
|
|
41660
|
+
|
|
41661
|
+
//#endregion
|
|
41662
|
+
//#region src/local/websocket-body.ts
|
|
41663
|
+
/**
|
|
41664
|
+
* Convert a ws-emitted message buffer into the AWS-canonical event
|
|
41665
|
+
* body + `isBase64Encoded` discriminator. Text frames (opcode 0x1) pass
|
|
41666
|
+
* through as UTF-8 with `isBase64Encoded: false`; binary frames
|
|
41667
|
+
* (opcode 0x2) are base64-encoded with `isBase64Encoded: true`. Matches
|
|
41668
|
+
* AWS-deployed WebSocket API event shape exactly — handlers decode via
|
|
41669
|
+
* `Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')`.
|
|
41670
|
+
*
|
|
41671
|
+
* Closes the data-integrity bug where every byte > 0x7F on a binary
|
|
41672
|
+
* frame was silently corrupted by handlers that trusted the previously
|
|
41673
|
+
* hardcoded `isBase64Encoded: false` flag and UTF-8-decoded the
|
|
41674
|
+
* base64-encoded body.
|
|
41675
|
+
*
|
|
41676
|
+
* Lives in its own module so the B4 regression test can install a
|
|
41677
|
+
* `vi.fn()` spy that intercepts EVERY call — same-module references in
|
|
41678
|
+
* `websocket-server.ts` would bypass the export-binding spy. See
|
|
41679
|
+
* Issue #537 item 6.
|
|
41680
|
+
*/
|
|
41681
|
+
function bufferToBody(raw, isBinary) {
|
|
41682
|
+
const buf = Array.isArray(raw) ? Buffer.concat(raw) : Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
41683
|
+
if (isBinary) return {
|
|
41684
|
+
body: buf.toString("base64"),
|
|
41685
|
+
isBase64Encoded: true
|
|
41686
|
+
};
|
|
41687
|
+
return {
|
|
41688
|
+
body: buf.toString("utf-8"),
|
|
41689
|
+
isBase64Encoded: false
|
|
41690
|
+
};
|
|
41691
|
+
}
|
|
41692
|
+
|
|
41693
|
+
//#endregion
|
|
41694
|
+
//#region src/local/websocket-server.ts
|
|
41695
|
+
/**
|
|
41696
|
+
* Wire a WebSocket API into a long-lived `node:http`'s `upgrade`
|
|
41697
|
+
* pipeline. The same server already serves HTTP API v2 / REST v1 /
|
|
41698
|
+
* Function URL routes via the `request` listener; this module adds a
|
|
41699
|
+
* sibling `upgrade` listener that handles WebSocket handshakes.
|
|
41700
|
+
*
|
|
41701
|
+
* Architecture (mirrors design doc §2 / §8):
|
|
41702
|
+
* - One {@link WebSocketServer} per cdkd local-start-api server.
|
|
41703
|
+
* - `noServer: true` mode — cdkd owns the upgrade-event dispatch.
|
|
41704
|
+
* - Per-connection lifecycle: handshake -> $connect Lambda ->
|
|
41705
|
+
* (allow/deny) -> message loop -> close -> $disconnect Lambda.
|
|
41706
|
+
* - Outbound `@connections/<id>` POST from a handler-side AWS SDK
|
|
41707
|
+
* call routes to the WebSocket via the shared
|
|
41708
|
+
* {@link ConnectionRegistry}.
|
|
41709
|
+
*
|
|
41710
|
+
* The container pool is the SAME instance the HTTP-side server uses for
|
|
41711
|
+
* REST/HTTP API/Function URL routes — WebSocket dispatch is just
|
|
41712
|
+
* another consumer; per-Lambda concurrency caps still apply.
|
|
41713
|
+
*/
|
|
41714
|
+
const DEFAULT_INVOKE_TIMEOUT_MS = 6e4;
|
|
41715
|
+
/**
|
|
41716
|
+
* Attach a WebSocket server to the parent HTTP listener. Returns an
|
|
41717
|
+
* {@link AttachedWebSocketServer} the CLI uses for graceful shutdown +
|
|
41718
|
+
* to expose the connection registry to the management-API
|
|
41719
|
+
* pre-pass.
|
|
41720
|
+
*
|
|
41721
|
+
* Implementation:
|
|
41722
|
+
* - One shared {@link ws.WebSocketServer} in `noServer` mode.
|
|
41723
|
+
* - One `upgrade` listener that routes by `req.url`'s pathname; an
|
|
41724
|
+
* unrecognized upgrade target is destroyed (RFC 6455 §4.3.2 —
|
|
41725
|
+
* server SHOULD respond with HTTP 404 or 426).
|
|
41726
|
+
* - Per-connection state held in a {@link ConnectionRegistry} the
|
|
41727
|
+
* `@connections` HTTP handler reads to push messages back.
|
|
41728
|
+
*
|
|
41729
|
+
* Returns synchronously — the underlying ws server is fully bound by
|
|
41730
|
+
* the time this function returns.
|
|
41731
|
+
*/
|
|
41732
|
+
function attachWebSocketServer(opts) {
|
|
41733
|
+
const logger = getLogger().child("start-api/ws");
|
|
41734
|
+
const rieTimeoutMs = opts.rieTimeoutMs ?? DEFAULT_INVOKE_TIMEOUT_MS;
|
|
41735
|
+
const registry = new ConnectionRegistry();
|
|
41736
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
41737
|
+
const apisByPath = /* @__PURE__ */ new Map();
|
|
41738
|
+
const apiPaths = [];
|
|
41739
|
+
for (const cfg of opts.apis) {
|
|
41740
|
+
apisByPath.set(cfg.apiPath, cfg);
|
|
41741
|
+
apiPaths.push(cfg.apiPath);
|
|
41742
|
+
}
|
|
41743
|
+
const upgradeListener = (req, socket, head) => {
|
|
41744
|
+
const pathOnly = (req.url ?? "/").split("?", 1)[0];
|
|
41745
|
+
const cfg = apisByPath.get(pathOnly);
|
|
41746
|
+
if (!cfg) {
|
|
41747
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
41748
|
+
socket.destroy();
|
|
41749
|
+
return;
|
|
41750
|
+
}
|
|
41751
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
41752
|
+
onConnect(ws, req, cfg).catch((err) => {
|
|
41753
|
+
logger.error(`WebSocket $connect dispatch failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41754
|
+
try {
|
|
41755
|
+
ws.close(1011, "internal error");
|
|
41756
|
+
} catch {}
|
|
41757
|
+
});
|
|
41758
|
+
});
|
|
41759
|
+
};
|
|
41760
|
+
opts.httpServer.on("upgrade", upgradeListener);
|
|
41761
|
+
const MAX_PRE_VERDICT_FRAMES = 100;
|
|
41762
|
+
const onConnect = async (ws, req, cfg) => {
|
|
41763
|
+
const connectionId = randomUUID();
|
|
41764
|
+
const connectedAt = Date.now();
|
|
41765
|
+
const handshakeSnapshot = buildHandshakeSnapshot(req);
|
|
41766
|
+
const connectEvent = buildConnectEvent({
|
|
41767
|
+
connectionId,
|
|
41768
|
+
connectedAt,
|
|
41769
|
+
stage: cfg.api.stage,
|
|
41770
|
+
snapshot: handshakeSnapshot
|
|
41771
|
+
});
|
|
41772
|
+
const preVerdictFrames = [];
|
|
41773
|
+
let preVerdictOverflow = false;
|
|
41774
|
+
let preVerdictClosed = false;
|
|
41775
|
+
const preListener = (raw, isBinary) => {
|
|
41776
|
+
if (preVerdictClosed) return;
|
|
41777
|
+
if (preVerdictFrames.length >= MAX_PRE_VERDICT_FRAMES) {
|
|
41778
|
+
if (!preVerdictOverflow) {
|
|
41779
|
+
preVerdictOverflow = true;
|
|
41780
|
+
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. (api=${cfg.api.declaredAt})`);
|
|
41781
|
+
}
|
|
41782
|
+
return;
|
|
41783
|
+
}
|
|
41784
|
+
preVerdictFrames.push({
|
|
41785
|
+
raw,
|
|
41786
|
+
isBinary
|
|
41787
|
+
});
|
|
41788
|
+
};
|
|
41789
|
+
ws.on("message", preListener);
|
|
41790
|
+
const connectRoute = cfg.api.routes.find((r) => r.routeKey === "$connect");
|
|
41791
|
+
if (connectRoute) {
|
|
41792
|
+
if (!await invokeRouteAndDecideAuth(connectRoute.targetLambdaLogicalId, connectEvent, opts.pool, rieTimeoutMs)) {
|
|
41793
|
+
preVerdictClosed = true;
|
|
41794
|
+
preVerdictFrames.length = 0;
|
|
41795
|
+
ws.off("message", preListener);
|
|
41796
|
+
try {
|
|
41797
|
+
ws.close(1008, "Forbidden");
|
|
41798
|
+
} catch {}
|
|
41799
|
+
logger.debug(`WebSocket $connect denied for connection ${connectionId} on ${cfg.api.declaredAt}`);
|
|
41800
|
+
return;
|
|
41801
|
+
}
|
|
41802
|
+
}
|
|
41803
|
+
preVerdictClosed = true;
|
|
41804
|
+
ws.off("message", preListener);
|
|
41805
|
+
if (ws.readyState !== ws.OPEN) {
|
|
41806
|
+
preVerdictFrames.length = 0;
|
|
41807
|
+
logger.debug(`WebSocket connection ${connectionId} closed during $connect await (readyState=${ws.readyState}) — skipping registration`);
|
|
41808
|
+
return;
|
|
41809
|
+
}
|
|
41810
|
+
const entry = {
|
|
41811
|
+
connectionId,
|
|
41812
|
+
socket: ws,
|
|
41813
|
+
connectedAt,
|
|
41814
|
+
apiLogicalId: cfg.api.apiLogicalId,
|
|
41815
|
+
stage: cfg.api.stage
|
|
41816
|
+
};
|
|
41817
|
+
registry.register(entry);
|
|
41818
|
+
logger.debug(`WebSocket connected: ${connectionId} (${cfg.api.declaredAt}, stage=${cfg.api.stage})`);
|
|
41819
|
+
ws.on("message", (raw, isBinary) => {
|
|
41820
|
+
const { body, isBase64Encoded } = bufferToBody(raw, isBinary);
|
|
41821
|
+
logger.debug(`WebSocket message received for connection ${connectionId}: ${body.slice(0, 200)}`);
|
|
41822
|
+
dispatchMessage(connectionId, cfg, body, isBase64Encoded, handshakeSnapshot).catch((err) => {
|
|
41823
|
+
logger.error(`WebSocket message dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41824
|
+
try {
|
|
41825
|
+
ws.send(JSON.stringify({
|
|
41826
|
+
message: "Internal server error",
|
|
41827
|
+
connectionId,
|
|
41828
|
+
requestId: randomUUID()
|
|
41829
|
+
}));
|
|
41830
|
+
} catch {}
|
|
41831
|
+
});
|
|
41832
|
+
});
|
|
41833
|
+
ws.on("close", (code, reason) => {
|
|
41834
|
+
onDisconnect(connectionId, cfg, handshakeSnapshot, code, reason.toString("utf-8")).catch((err) => {
|
|
41835
|
+
logger.warn(`WebSocket $disconnect dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
41836
|
+
});
|
|
41837
|
+
});
|
|
41838
|
+
ws.on("error", (err) => {
|
|
41839
|
+
logger.debug(`WebSocket error for connection ${connectionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
41840
|
+
});
|
|
41841
|
+
for (const frame of preVerdictFrames) {
|
|
41842
|
+
const { body, isBase64Encoded } = bufferToBody(frame.raw, frame.isBinary);
|
|
41843
|
+
dispatchMessage(connectionId, cfg, body, isBase64Encoded, handshakeSnapshot).catch((err) => {
|
|
41844
|
+
logger.error(`WebSocket buffered-message dispatch failed (connection ${connectionId}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
41845
|
+
});
|
|
41846
|
+
}
|
|
41847
|
+
preVerdictFrames.length = 0;
|
|
41848
|
+
};
|
|
41849
|
+
const dispatchMessage = async (connectionId, cfg, body, isBase64Encoded, snapshot) => {
|
|
41850
|
+
const entry = registry.get(connectionId);
|
|
41851
|
+
if (!entry) return;
|
|
41852
|
+
const routeKey = selectRouteKey(cfg.api, body);
|
|
41853
|
+
const route = cfg.api.routes.find((r) => r.routeKey === routeKey);
|
|
41854
|
+
if (!route) {
|
|
41855
|
+
try {
|
|
41856
|
+
entry.socket.send(JSON.stringify({
|
|
41857
|
+
message: "Internal server error",
|
|
41858
|
+
connectionId,
|
|
41859
|
+
requestId: randomUUID()
|
|
41860
|
+
}));
|
|
41861
|
+
} catch {}
|
|
41862
|
+
return;
|
|
41863
|
+
}
|
|
41864
|
+
const event = buildMessageEvent({
|
|
41865
|
+
connectionId,
|
|
41866
|
+
connectedAt: entry.connectedAt,
|
|
41867
|
+
stage: entry.stage,
|
|
41868
|
+
snapshot,
|
|
41869
|
+
routeKey,
|
|
41870
|
+
body,
|
|
41871
|
+
isBase64Encoded
|
|
41872
|
+
});
|
|
41873
|
+
await invokeRoute(route.targetLambdaLogicalId, event, opts.pool, rieTimeoutMs);
|
|
41874
|
+
};
|
|
41875
|
+
const onDisconnect = async (connectionId, cfg, snapshot, code, reason) => {
|
|
41876
|
+
const entry = registry.unregister(connectionId);
|
|
41877
|
+
if (!entry) return;
|
|
41878
|
+
logger.debug(`WebSocket disconnected: ${connectionId} (code=${code}, reason=${reason || "<none>"})`);
|
|
41879
|
+
const disconnectRoute = cfg.api.routes.find((r) => r.routeKey === "$disconnect");
|
|
41880
|
+
if (!disconnectRoute) return;
|
|
41881
|
+
const event = buildDisconnectEvent({
|
|
41882
|
+
connectionId,
|
|
41883
|
+
connectedAt: entry.connectedAt,
|
|
41884
|
+
stage: entry.stage,
|
|
41885
|
+
snapshot,
|
|
41886
|
+
disconnectStatusCode: code,
|
|
41887
|
+
disconnectReason: reason
|
|
41888
|
+
});
|
|
41889
|
+
await invokeRoute(disconnectRoute.targetLambdaLogicalId, event, opts.pool, rieTimeoutMs);
|
|
41890
|
+
};
|
|
41891
|
+
let closed = false;
|
|
41892
|
+
return {
|
|
41893
|
+
registry,
|
|
41894
|
+
apiPaths,
|
|
41895
|
+
close: async () => {
|
|
41896
|
+
if (closed) return;
|
|
41897
|
+
closed = true;
|
|
41898
|
+
opts.httpServer.off("upgrade", upgradeListener);
|
|
41899
|
+
const closes = Array.from(wss.clients).map((ws) => new Promise((resolve) => {
|
|
41900
|
+
const onClose = () => resolve();
|
|
41901
|
+
ws.once("close", onClose);
|
|
41902
|
+
try {
|
|
41903
|
+
ws.close(1001, "going away");
|
|
41904
|
+
} catch {
|
|
41905
|
+
resolve();
|
|
41906
|
+
}
|
|
41907
|
+
setTimeout(() => {
|
|
41908
|
+
ws.off("close", onClose);
|
|
41909
|
+
resolve();
|
|
41910
|
+
}, 5e3).unref();
|
|
41911
|
+
}));
|
|
41912
|
+
await Promise.all(closes);
|
|
41913
|
+
await new Promise((resolve) => {
|
|
41914
|
+
wss.close(() => resolve());
|
|
41915
|
+
});
|
|
41916
|
+
}
|
|
41917
|
+
};
|
|
41918
|
+
}
|
|
41919
|
+
/**
|
|
41920
|
+
* Pre-pass for the HTTP `request` listener: intercept `POST/GET/DELETE
|
|
41921
|
+
* /@connections/<id>` calls and route them to the connection registry.
|
|
41922
|
+
*
|
|
41923
|
+
* Returns `true` when the request was handled (caller short-circuits
|
|
41924
|
+
* the normal HTTP dispatch path), `false` when the URL didn't match.
|
|
41925
|
+
*
|
|
41926
|
+
* The CLI installs this BEFORE the existing http-server pipeline so a
|
|
41927
|
+
* Lambda inside a container can call
|
|
41928
|
+
* `apigatewaymanagementapi:PostToConnection` and have cdkd deliver the
|
|
41929
|
+
* message back to the open WebSocket without the request hitting the
|
|
41930
|
+
* route table.
|
|
41931
|
+
*/
|
|
41932
|
+
async function handleManagementRequest(req, res, registry) {
|
|
41933
|
+
if (parseConnectionsPath(req.url ?? "") === null) return false;
|
|
41934
|
+
await handleConnectionsRequest({
|
|
41935
|
+
req,
|
|
41936
|
+
res,
|
|
41937
|
+
registry
|
|
41938
|
+
});
|
|
41939
|
+
return true;
|
|
41940
|
+
}
|
|
41941
|
+
/**
|
|
41942
|
+
* Select the route the client message dispatches to.
|
|
41943
|
+
*
|
|
41944
|
+
* Algorithm (matches AWS docs §"Selection expressions"):
|
|
41945
|
+
* 1. Try to parse the body as JSON. Non-JSON → `$default`.
|
|
41946
|
+
* 2. Walk the selection-expression's JSON-path tokens against the
|
|
41947
|
+
* parsed body. Missing intermediate keys → `$default`.
|
|
41948
|
+
* 3. The final value's `String()` representation is the route key.
|
|
41949
|
+
* 4. When that key has no matching route, fall back to `$default`.
|
|
41950
|
+
*
|
|
41951
|
+
* v1's selection-expression grammar is `$request.body.<key>` (with
|
|
41952
|
+
* optional nested dot access). Other shapes were rejected upstream at
|
|
41953
|
+
* discovery time.
|
|
41954
|
+
*/
|
|
41955
|
+
function selectRouteKey(api, body) {
|
|
41956
|
+
let parsed;
|
|
41957
|
+
try {
|
|
41958
|
+
parsed = JSON.parse(body);
|
|
41959
|
+
} catch {
|
|
41960
|
+
return "$default";
|
|
41961
|
+
}
|
|
41962
|
+
const tokens = parseSelectionExpressionPath(api.routeSelectionExpression);
|
|
41963
|
+
let cursor = parsed;
|
|
41964
|
+
for (const token of tokens) {
|
|
41965
|
+
if (cursor === null || typeof cursor !== "object") return "$default";
|
|
41966
|
+
cursor = cursor[token];
|
|
41967
|
+
if (cursor === void 0) return "$default";
|
|
41968
|
+
}
|
|
41969
|
+
const candidate = String(cursor);
|
|
41970
|
+
if (api.routes.some((r) => r.routeKey === candidate)) return candidate;
|
|
41971
|
+
return "$default";
|
|
41972
|
+
}
|
|
41973
|
+
/**
|
|
41974
|
+
* Invoke a route's Lambda for side effects only (MESSAGE / DISCONNECT
|
|
41975
|
+
* paths). The Lambda's response is intentionally discarded — AWS-deployed
|
|
41976
|
+
* WebSocket APIs do the same; handlers reply via `PostToConnection`.
|
|
41977
|
+
*/
|
|
41978
|
+
async function invokeRoute(lambdaLogicalId, event, pool, rieTimeoutMs) {
|
|
41979
|
+
const handle = await pool.acquire(lambdaLogicalId);
|
|
41980
|
+
try {
|
|
41981
|
+
await invokeRie(handle.containerHost, handle.hostPort, event, rieTimeoutMs);
|
|
41982
|
+
} finally {
|
|
41983
|
+
pool.release(handle);
|
|
41984
|
+
}
|
|
41985
|
+
}
|
|
41986
|
+
/**
|
|
41987
|
+
* Invoke the `$connect` Lambda and decide whether to accept the
|
|
41988
|
+
* connection. AWS-deployed behavior: handler returns `{statusCode:
|
|
41989
|
+
* 200}` (or any 2xx) → allow; anything else (non-2xx, error envelope,
|
|
41990
|
+
* throw, timeout) → deny.
|
|
41991
|
+
*/
|
|
41992
|
+
async function invokeRouteAndDecideAuth(lambdaLogicalId, event, pool, rieTimeoutMs) {
|
|
41993
|
+
let result;
|
|
41994
|
+
try {
|
|
41995
|
+
const handle = await pool.acquire(lambdaLogicalId);
|
|
41996
|
+
try {
|
|
41997
|
+
result = await invokeRie(handle.containerHost, handle.hostPort, event, rieTimeoutMs);
|
|
41998
|
+
} finally {
|
|
41999
|
+
pool.release(handle);
|
|
42000
|
+
}
|
|
42001
|
+
} catch {
|
|
42002
|
+
return false;
|
|
42003
|
+
}
|
|
42004
|
+
if (result.payload && typeof result.payload === "object") {
|
|
42005
|
+
const obj = result.payload;
|
|
42006
|
+
if (typeof obj["errorMessage"] === "string" && typeof obj["statusCode"] !== "number") return false;
|
|
42007
|
+
const status = obj["statusCode"];
|
|
42008
|
+
if (typeof status === "number") return status >= 200 && status < 300;
|
|
42009
|
+
}
|
|
42010
|
+
return true;
|
|
42011
|
+
}
|
|
42012
|
+
/**
|
|
42013
|
+
* Snapshot the upgrade-request data the event-builders need. We capture
|
|
42014
|
+
* this ONCE at `$connect` and reuse it for every event on the same
|
|
42015
|
+
* connection — `requestContext.identity.sourceIp` etc. must stay
|
|
42016
|
+
* consistent across CONNECT / MESSAGE / DISCONNECT (matches AWS).
|
|
42017
|
+
*/
|
|
42018
|
+
function buildHandshakeSnapshot(req) {
|
|
42019
|
+
const headers = {};
|
|
42020
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
42021
|
+
if (value === void 0) continue;
|
|
42022
|
+
headers[name] = Array.isArray(value) ? [...value] : [value];
|
|
42023
|
+
}
|
|
42024
|
+
const url = req.url ?? "/";
|
|
42025
|
+
const queryIdx = url.indexOf("?");
|
|
42026
|
+
const rawQueryString = queryIdx >= 0 ? url.slice(queryIdx + 1) : "";
|
|
42027
|
+
const { single, multi } = parseQueryString(rawQueryString);
|
|
42028
|
+
const userAgent = typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : void 0;
|
|
42029
|
+
const sourceIp = req.socket.remoteAddress;
|
|
42030
|
+
return {
|
|
42031
|
+
headers,
|
|
42032
|
+
rawQueryString,
|
|
42033
|
+
...Object.keys(single).length > 0 && { queryStringParameters: single },
|
|
42034
|
+
...Object.keys(multi).length > 0 && { multiValueQueryStringParameters: multi },
|
|
42035
|
+
...sourceIp !== void 0 && { sourceIp },
|
|
42036
|
+
...userAgent !== void 0 && { userAgent }
|
|
42037
|
+
};
|
|
42038
|
+
}
|
|
42039
|
+
/**
|
|
42040
|
+
* Parse a raw query string into single-value (last-wins per AWS) and
|
|
42041
|
+
* multi-value maps. Mirrors `route-discovery.ts:parseQueryStringSingular`'s
|
|
42042
|
+
* convention — duplicated locally rather than reaching across modules
|
|
42043
|
+
* because the WebSocket path is a thin slice that does not need the
|
|
42044
|
+
* full HTTP-API parser.
|
|
42045
|
+
*/
|
|
42046
|
+
function parseQueryString(qs) {
|
|
42047
|
+
const single = {};
|
|
42048
|
+
const multi = {};
|
|
42049
|
+
if (qs.length === 0) return {
|
|
42050
|
+
single,
|
|
42051
|
+
multi
|
|
42052
|
+
};
|
|
42053
|
+
for (const pair of qs.split("&")) {
|
|
42054
|
+
if (pair.length === 0) continue;
|
|
42055
|
+
const eq = pair.indexOf("=");
|
|
42056
|
+
const rawKey = eq >= 0 ? pair.slice(0, eq) : pair;
|
|
42057
|
+
const rawVal = eq >= 0 ? pair.slice(eq + 1) : "";
|
|
42058
|
+
const key = safeDecode$1(rawKey);
|
|
42059
|
+
const val = safeDecode$1(rawVal);
|
|
42060
|
+
if (key === null) continue;
|
|
42061
|
+
single[key] = val ?? "";
|
|
42062
|
+
(multi[key] ??= []).push(val ?? "");
|
|
42063
|
+
}
|
|
42064
|
+
return {
|
|
42065
|
+
single,
|
|
42066
|
+
multi
|
|
42067
|
+
};
|
|
42068
|
+
}
|
|
42069
|
+
function safeDecode$1(s) {
|
|
42070
|
+
try {
|
|
42071
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
42072
|
+
} catch {
|
|
42073
|
+
return null;
|
|
42074
|
+
}
|
|
42075
|
+
}
|
|
42076
|
+
|
|
41076
42077
|
//#endregion
|
|
41077
42078
|
//#region src/local/vtl-engine.ts
|
|
41078
42079
|
/** Error thrown when a template references an unsupported VTL feature. */
|
|
@@ -42610,7 +43611,8 @@ function createContainerPool(specs, options) {
|
|
|
42610
43611
|
host: spec.containerHost,
|
|
42611
43612
|
name,
|
|
42612
43613
|
...spec.debugPort !== void 0 && { debugPort: spec.debugPort },
|
|
42613
|
-
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs }
|
|
43614
|
+
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs },
|
|
43615
|
+
...spec.extraHosts !== void 0 && { extraHosts: spec.extraHosts }
|
|
42614
43616
|
});
|
|
42615
43617
|
} else containerId = await runDetached({
|
|
42616
43618
|
image: spec.image,
|
|
@@ -42624,7 +43626,8 @@ function createContainerPool(specs, options) {
|
|
|
42624
43626
|
...spec.entryPoint !== void 0 && { entryPoint: spec.entryPoint },
|
|
42625
43627
|
...spec.workingDir !== void 0 && { workingDir: spec.workingDir },
|
|
42626
43628
|
...spec.debugPort !== void 0 && { debugPort: spec.debugPort },
|
|
42627
|
-
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs }
|
|
43629
|
+
...spec.tmpfs !== void 0 && { tmpfs: spec.tmpfs },
|
|
43630
|
+
...spec.extraHosts !== void 0 && { extraHosts: spec.extraHosts }
|
|
42628
43631
|
});
|
|
42629
43632
|
const stopLogStream = streamingEnabled ? streamLogs(containerId) : () => void 0;
|
|
42630
43633
|
try {
|
|
@@ -45480,6 +46483,13 @@ async function startApiServer(opts) {
|
|
|
45480
46483
|
*/
|
|
45481
46484
|
async function handleRequest(req, res, state, opts) {
|
|
45482
46485
|
const logger = getLogger().child("start-api");
|
|
46486
|
+
if (opts.preDispatch) try {
|
|
46487
|
+
if (await opts.preDispatch(req, res)) return;
|
|
46488
|
+
} catch (err) {
|
|
46489
|
+
logger.error(`preDispatch hook threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
46490
|
+
if (!res.headersSent) writeError(res, 502);
|
|
46491
|
+
return;
|
|
46492
|
+
}
|
|
45483
46493
|
const bodyBuf = await readBody(req);
|
|
45484
46494
|
const rawUrl = req.url ?? "/";
|
|
45485
46495
|
const method = (req.method ?? "GET").toUpperCase();
|
|
@@ -46873,7 +47883,10 @@ async function localStartApiCommand(target, options) {
|
|
|
46873
47883
|
const targetStacks = pickTargetStacks(stacks, options.stack);
|
|
46874
47884
|
if (targetStacks.length === 0) throw new Error("No stacks matched. Pass --stack <name> or run from a single-stack app.");
|
|
46875
47885
|
const routes = discoverRoutes(targetStacks);
|
|
46876
|
-
|
|
47886
|
+
const wsDiscovery = discoverWebSocketApis(targetStacks);
|
|
47887
|
+
if (wsDiscovery.errors.length > 0) for (const e of wsDiscovery.errors) logger.warn(`WebSocket discovery: ${e}`);
|
|
47888
|
+
const webSocketApis = wsDiscovery.apis;
|
|
47889
|
+
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.");
|
|
46877
47890
|
const stageMap = /* @__PURE__ */ new Map();
|
|
46878
47891
|
for (const stack of targetStacks) {
|
|
46879
47892
|
const m = buildStageMap(stack.template, options.stage);
|
|
@@ -46904,7 +47917,7 @@ async function localStartApiCommand(target, options) {
|
|
|
46904
47917
|
for (const [k, v] of m) corsConfigByApiId.set(k, v);
|
|
46905
47918
|
}
|
|
46906
47919
|
const stateByStack = options.fromState ? await loadStateForRoutedStacks(targetStacks, routes, routesWithAuth, options) : /* @__PURE__ */ new Map();
|
|
46907
|
-
const lambdaIds = uniqueLambdaIds(routes, routesWithAuth);
|
|
47920
|
+
const lambdaIds = uniqueLambdaIds(routes, routesWithAuth, webSocketApis);
|
|
46908
47921
|
const specs = /* @__PURE__ */ new Map();
|
|
46909
47922
|
for (let i = 0; i < lambdaIds.length; i++) {
|
|
46910
47923
|
const logicalId = lambdaIds[i];
|
|
@@ -46931,6 +47944,7 @@ async function localStartApiCommand(target, options) {
|
|
|
46931
47944
|
routes: routesWithAuth,
|
|
46932
47945
|
specs,
|
|
46933
47946
|
corsConfigByApiId,
|
|
47947
|
+
webSocketApis,
|
|
46934
47948
|
stacks: targetStacks
|
|
46935
47949
|
};
|
|
46936
47950
|
};
|
|
@@ -47024,12 +48038,88 @@ async function localStartApiCommand(target, options) {
|
|
|
47024
48038
|
});
|
|
47025
48039
|
if (basePort !== 0) nextPort += 1;
|
|
47026
48040
|
}
|
|
48041
|
+
const wsServers = [];
|
|
48042
|
+
const initialWsApis = initialMaterial.webSocketApis ?? [];
|
|
48043
|
+
warnUnsupportedWebSocketApis(initialWsApis, logger);
|
|
48044
|
+
for (const api of initialWsApis) {
|
|
48045
|
+
if (api.unsupported) continue;
|
|
48046
|
+
const wsLambdaIds = new Set(api.routes.map((r) => r.targetLambdaLogicalId));
|
|
48047
|
+
const wsSpecs = /* @__PURE__ */ new Map();
|
|
48048
|
+
for (const id of wsLambdaIds) {
|
|
48049
|
+
const spec = initialMaterial.specs.get(id);
|
|
48050
|
+
if (spec) wsSpecs.set(id, spec);
|
|
48051
|
+
}
|
|
48052
|
+
if (wsSpecs.size === 0) {
|
|
48053
|
+
logger.warn(`WebSocket API ${api.declaredAt}: no resolvable Lambda backing routes; skipping.`);
|
|
48054
|
+
continue;
|
|
48055
|
+
}
|
|
48056
|
+
const wsPool = buildPool(wsSpecs);
|
|
48057
|
+
const wsState = {
|
|
48058
|
+
routes: [],
|
|
48059
|
+
pool: wsPool,
|
|
48060
|
+
corsConfigByApiId: /* @__PURE__ */ new Map()
|
|
48061
|
+
};
|
|
48062
|
+
const wsApiPath = `/${api.stage}`;
|
|
48063
|
+
let registryRef;
|
|
48064
|
+
const started = await startApiServer({
|
|
48065
|
+
state: wsState,
|
|
48066
|
+
rieTimeoutMs,
|
|
48067
|
+
host: options.host,
|
|
48068
|
+
port: basePort === 0 ? 0 : nextPort,
|
|
48069
|
+
authorizerCache,
|
|
48070
|
+
jwksCache,
|
|
48071
|
+
jwksWarnedUrls,
|
|
48072
|
+
sigV4WarnedForeignIds,
|
|
48073
|
+
sigV4AllowUnverified: options.allowUnverifiedSigv4 === true,
|
|
48074
|
+
preDispatch: async (req, res) => {
|
|
48075
|
+
if (!registryRef) return false;
|
|
48076
|
+
return handleManagementRequest(req, res, registryRef.registry);
|
|
48077
|
+
}
|
|
48078
|
+
});
|
|
48079
|
+
const attached = attachWebSocketServer({
|
|
48080
|
+
httpServer: started.server,
|
|
48081
|
+
pool: wsPool,
|
|
48082
|
+
rieTimeoutMs,
|
|
48083
|
+
apis: [{
|
|
48084
|
+
api,
|
|
48085
|
+
apiPath: wsApiPath
|
|
48086
|
+
}]
|
|
48087
|
+
});
|
|
48088
|
+
registryRef = attached;
|
|
48089
|
+
const mgmtEndpoint = buildMgmtEndpointEnvUrl("host.docker.internal", started.port, api.stage);
|
|
48090
|
+
const hostGatewayMapping = [{
|
|
48091
|
+
host: "host.docker.internal",
|
|
48092
|
+
ip: "host-gateway"
|
|
48093
|
+
}];
|
|
48094
|
+
for (const id of wsLambdaIds) {
|
|
48095
|
+
const spec = initialMaterial.specs.get(id);
|
|
48096
|
+
if (!spec) continue;
|
|
48097
|
+
spec.env["AWS_ENDPOINT_URL_APIGATEWAYMANAGEMENTAPI"] = mgmtEndpoint;
|
|
48098
|
+
if (!spec.env["AWS_ACCESS_KEY_ID"]) {
|
|
48099
|
+
spec.env["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE";
|
|
48100
|
+
spec.env["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
|
48101
|
+
}
|
|
48102
|
+
if (!spec.env["AWS_REGION"]) spec.env["AWS_REGION"] = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? "us-east-1";
|
|
48103
|
+
spec.extraHosts = [...spec.extraHosts ?? [], ...hostGatewayMapping];
|
|
48104
|
+
}
|
|
48105
|
+
wsServers.push({
|
|
48106
|
+
api,
|
|
48107
|
+
server: started,
|
|
48108
|
+
attached,
|
|
48109
|
+
apiPath: wsApiPath
|
|
48110
|
+
});
|
|
48111
|
+
if (basePort !== 0) nextPort += 1;
|
|
48112
|
+
}
|
|
47027
48113
|
printPerServerRouteTables(servers);
|
|
47028
48114
|
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
47029
48115
|
warnUnsupportedRoutes(allRoutes, logger);
|
|
47030
48116
|
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
47031
48117
|
logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
|
|
47032
48118
|
for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
|
|
48119
|
+
for (const ws of wsServers) {
|
|
48120
|
+
const scheme = ws.server.scheme === "https" ? "wss" : "ws";
|
|
48121
|
+
process.stdout.write(`Server listening on ${scheme}://${ws.server.host}:${ws.server.port}${ws.apiPath} (${ws.api.apiLogicalId} (WebSocket API))\n`);
|
|
48122
|
+
}
|
|
47033
48123
|
process.stdout.write("^C to stop and clean up containers.\n");
|
|
47034
48124
|
let watcher;
|
|
47035
48125
|
let reloadChain = Promise.resolve();
|
|
@@ -47063,6 +48153,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47063
48153
|
} catch (err) {
|
|
47064
48154
|
logger.warn(`watcher.close() failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47065
48155
|
}
|
|
48156
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48157
|
+
try {
|
|
48158
|
+
await ws.attached.close();
|
|
48159
|
+
} catch (err) {
|
|
48160
|
+
logger.warn(`WebSocket close() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48161
|
+
}
|
|
48162
|
+
}));
|
|
47066
48163
|
await Promise.allSettled(servers.map(async ({ server, group }) => {
|
|
47067
48164
|
try {
|
|
47068
48165
|
await server.close();
|
|
@@ -47070,6 +48167,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47070
48167
|
logger.warn(`server.close() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47071
48168
|
}
|
|
47072
48169
|
}));
|
|
48170
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48171
|
+
try {
|
|
48172
|
+
await ws.server.close();
|
|
48173
|
+
} catch (err) {
|
|
48174
|
+
logger.warn(`WebSocket server.close() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48175
|
+
}
|
|
48176
|
+
}));
|
|
47073
48177
|
await Promise.allSettled(servers.map(async ({ server, group }) => {
|
|
47074
48178
|
try {
|
|
47075
48179
|
await server.getServerState().pool.dispose();
|
|
@@ -47077,6 +48181,13 @@ async function localStartApiCommand(target, options) {
|
|
|
47077
48181
|
logger.warn(`pool.dispose() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47078
48182
|
}
|
|
47079
48183
|
}));
|
|
48184
|
+
await Promise.allSettled(wsServers.map(async (ws) => {
|
|
48185
|
+
try {
|
|
48186
|
+
await ws.server.getServerState().pool.dispose();
|
|
48187
|
+
} catch (err) {
|
|
48188
|
+
logger.warn(`WebSocket pool.dispose() failed for ${ws.api.apiLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48189
|
+
}
|
|
48190
|
+
}));
|
|
47080
48191
|
for (const dir of inlineTmpDirs) try {
|
|
47081
48192
|
rmSync(dir, {
|
|
47082
48193
|
recursive: true,
|
|
@@ -47143,7 +48254,7 @@ function pickTargetStacks(stacks, pattern) {
|
|
|
47143
48254
|
* list, then any newly-introduced authorizer Lambdas, which keeps the
|
|
47144
48255
|
* route-table output deterministic.
|
|
47145
48256
|
*/
|
|
47146
|
-
function uniqueLambdaIds(routes, routesWithAuth) {
|
|
48257
|
+
function uniqueLambdaIds(routes, routesWithAuth, webSocketApis = []) {
|
|
47147
48258
|
const seen = /* @__PURE__ */ new Set();
|
|
47148
48259
|
const out = [];
|
|
47149
48260
|
for (const r of routes) {
|
|
@@ -47165,6 +48276,10 @@ function uniqueLambdaIds(routes, routesWithAuth) {
|
|
|
47165
48276
|
}
|
|
47166
48277
|
}
|
|
47167
48278
|
}
|
|
48279
|
+
for (const api of webSocketApis) for (const r of api.routes) if (!seen.has(r.targetLambdaLogicalId)) {
|
|
48280
|
+
seen.add(r.targetLambdaLogicalId);
|
|
48281
|
+
out.push(r.targetLambdaLogicalId);
|
|
48282
|
+
}
|
|
47168
48283
|
return out;
|
|
47169
48284
|
}
|
|
47170
48285
|
/**
|
|
@@ -47728,6 +48843,24 @@ function warnUnsupportedRoutes(routes, logger) {
|
|
|
47728
48843
|
return unsupported.length;
|
|
47729
48844
|
}
|
|
47730
48845
|
/**
|
|
48846
|
+
* Surface every WebSocket API tagged as unsupported at discovery as a
|
|
48847
|
+
* startup warn. The boot loop above skips attaching the server for
|
|
48848
|
+
* these APIs, so no upgrade requests are ever accepted on them —
|
|
48849
|
+
* mirrors `warnUnsupportedRoutes`'s shape but for the WebSocket axis.
|
|
48850
|
+
* Typical trigger: a Route declaring `AuthorizationType !== 'NONE'` on
|
|
48851
|
+
* `$connect` (cdkd v1 does not emulate WebSocket authorizers; closing
|
|
48852
|
+
* this gap structurally rather than silently admitting
|
|
48853
|
+
* unauthenticated clients matches the security-by-default precedent
|
|
48854
|
+
* PR #514 set for HTTP API v2 service integrations).
|
|
48855
|
+
*/
|
|
48856
|
+
function warnUnsupportedWebSocketApis(apis, logger) {
|
|
48857
|
+
const unsupported = apis.filter((api) => api.unsupported);
|
|
48858
|
+
if (unsupported.length === 0) return 0;
|
|
48859
|
+
logger.warn(`${unsupported.length} WebSocket API(s) will NOT accept upgrade requests (boot continued):`);
|
|
48860
|
+
for (const api of unsupported) logger.warn(` - ${api.declaredAt}: ${api.unsupported.reason}`);
|
|
48861
|
+
return unsupported.length;
|
|
48862
|
+
}
|
|
48863
|
+
/**
|
|
47731
48864
|
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
47732
48865
|
* `Integration.Uri` points at a well-known internal address space
|
|
47733
48866
|
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
@@ -52882,7 +54015,7 @@ function reorderArgs(argv) {
|
|
|
52882
54015
|
*/
|
|
52883
54016
|
async function main() {
|
|
52884
54017
|
const program = new Command();
|
|
52885
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
54018
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.137.1");
|
|
52886
54019
|
program.addCommand(createBootstrapCommand());
|
|
52887
54020
|
program.addCommand(createSynthCommand());
|
|
52888
54021
|
program.addCommand(createListCommand());
|