@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 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$2(restApiId);
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$2(resourceIdIntrinsic);
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$2(parentId) ?? void 0;
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$2(props["RestApiId"]) === restApiLogicalId) {
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$2(apiId);
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$2(parts[1]);
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$2(parts[1]);
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$2(bound);
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$2(value) {
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
- if (routes.length === 0) throw new Error("No supported API routes were discovered. cdkd local start-api supports AWS::ApiGateway::* (REST v1), AWS::ApiGatewayV2::* (HTTP), and AWS::Lambda::Url (Function URL) with AWS_PROXY integrations only.");
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.136.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());