cdk-deadly-embrace-resolver
Version:
Unsafe helper for preserving CloudFormation exports during CDK cross‑stack reference removal. Useful for working around the dreaded "deadly embrace" problem.
102 lines (84 loc) • 3.53 kB
text/typescript
// src/index.ts
import { Stack, Token, CfnResource, CfnOutput } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
export interface UnsafeResolveOptions {
properties?: string[];
exportNames?: Record<string, string>;
}
export function unsafeResolveDeadlyEmbrace(
resource: any,
options: UnsafeResolveOptions = {},
): void {
const stack = Stack.of(resource);
// Use only plain strings for naming fragments.
const defaultChild = (resource as any).node?.defaultChild as CfnResource | undefined;
const idBasis =
defaultChild?.node?.path ??
(resource as any).node?.path ??
(resource as any).node?.id ??
'Resource';
const logicalIdLike = sanitizeLogicalId(idBasis);
const propertyNames = options.properties ?? inferExportableProperties(resource);
// If stack.stackName is a Token (rare), fall back to a safe literal.
const cleanStack = Token.isUnresolved((stack as any).stackName)
? 'Stack'
: sanitizeExportFragment(stack.stackName);
for (const propName of propertyNames) {
if (!hasOwn(resource, propName)) continue;
const value: any = (resource as any)[propName];
if (value === undefined || value === null) continue;
const exportName =
options.exportNames?.[propName] ?? defaultExportName(cleanStack, logicalIdLike, propName);
// Construct a *static* CfnOutput id to avoid CDK computing one from Tokens.
const outputId = makeStableOutputId('Preserve', logicalIdLike, propName);
// Final guard: exportName itself must be a plain string.
if (Token.isUnresolved(exportName as any)) {
throw new Error(`Export name for "${propName}" is a Token; expected string.`);
}
new CfnOutput(stack, outputId, {
value, // may be a Token (Ref/GetAtt) — that's fine
exportName, // must be a plain string
});
}
}
function inferExportableProperties(resource: IConstruct): string[] {
const props: string[] = [];
const obj = resource as any;
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'function') continue;
if (key.endsWith('Arn') || key.endsWith('Name')) props.push(key);
}
return props;
}
function defaultExportName(stackName: string, logicalIdLike: string, propName: string): string {
if (propName.endsWith('Arn')) {
return `${stackName}:ExportsOutputFnGetAtt${logicalIdLike}Arn`;
}
if (propName.endsWith('Name')) {
return `${stackName}:ExportsOutputRef${logicalIdLike}`;
}
return `${stackName}:ExportsOutput${logicalIdLike}${propName}`;
}
// Build a safe, deterministic construct id for CfnOutput (no Tokens, CFN-legal chars).
function makeStableOutputId(prefix: string, logicalIdLike: string, propName: string): string {
const suffix = propName.endsWith('Arn')
? 'Arn'
: propName.endsWith('Name')
? 'Name'
: sanitizeLogicalId(propName);
// CFN logical ID limit is 255; keep generous headroom.
const base = `${prefix}Export${logicalIdLike}${suffix}`;
return base.slice(0, 200);
}
function hasOwn(obj: any, propName: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, propName);
}
function sanitizeLogicalId(pathOrId: string): string {
const cleaned = pathOrId.replace(/^\//, '');
const parts = cleaned.split(/[/:]/).filter(Boolean);
const capitalised = parts.map((s) => s.charAt(0).toUpperCase() + s.slice(1));
return capitalised.join('').replace(/[^A-Za-z0-9]/g, '');
}
function sanitizeExportFragment(fragment: string): string {
return String(fragment).replace(/[^A-Za-z0-9:_-]/g, '');
}