@fjall/components-infrastructure 25.0.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/app.js +3 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +5 -3
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +6 -0
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +16 -0
- package/dist/lib/patterns/aws/staticSite.d.ts +23 -0
- package/dist/lib/patterns/aws/staticSite.js +62 -0
- package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +11 -5
- package/dist/lib/patterns/aws/targets/fjallTargets.js +13 -6
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +1 -1
- package/dist/lib/patterns/aws/targets/targetResolution.js +2 -2
- package/dist/lib/resources/aws/cdn/cloudFront.js +13 -1
- package/dist/lib/utils/cdnAliasTargetRegistry.d.ts +48 -0
- package/dist/lib/utils/cdnAliasTargetRegistry.js +63 -0
- package/package.json +4 -6
package/dist/lib/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { BuildkiteFactory } from "./patterns/aws/buildkite.js";
|
|
|
13
13
|
import { DevSubstrate } from "./patterns/aws/devSubstrate.js";
|
|
14
14
|
import { StandardTagsAspect } from "./utils/standardTagsAspect.js";
|
|
15
15
|
import { emitAppLevelAlbAliasExports } from "./utils/albAliasTargetRegistry.js";
|
|
16
|
+
import { emitAppLevelCdnAliasExports } from "./utils/cdnAliasTargetRegistry.js";
|
|
16
17
|
import { ensureFunctionLogGroups } from "./resources/aws/logging/logGroupHygiene.js";
|
|
17
18
|
import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapping.js";
|
|
18
19
|
import { randomBytes } from "crypto";
|
|
@@ -843,8 +844,9 @@ export class App extends CdkApp {
|
|
|
843
844
|
// they synthesise untagged. Idempotent via the aspectApplied guard.
|
|
844
845
|
this.applyTagsAspect();
|
|
845
846
|
// Must precede super.synth(): the tree is still mutable here, and only
|
|
846
|
-
// now is the ALB count per app final.
|
|
847
|
+
// now is the ALB / CloudFront count per app final.
|
|
847
848
|
emitAppLevelAlbAliasExports(this);
|
|
849
|
+
emitAppLevelCdnAliasExports(this);
|
|
848
850
|
// Also pre-super, and before aspects run inside it, so the log groups
|
|
849
851
|
// this creates are still visited by the app-wide tag aspects.
|
|
850
852
|
ensureFunctionLogGroups(this);
|
|
@@ -1070,9 +1070,11 @@ export interface EcsComputeProps {
|
|
|
1070
1070
|
/**
|
|
1071
1071
|
* CloudWatch Container Insights (default off). Enabling it bills
|
|
1072
1072
|
* ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
|
|
1073
|
-
* plus performance-log ingestion
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1073
|
+
* plus performance-log ingestion, and also arms the per-service
|
|
1074
|
+
* `RunningTaskCount` liveness alarms (`ecsAlarms.ts` gates them on this
|
|
1075
|
+
* flag because the metric only exists in `ECS/ContainerInsights`).
|
|
1076
|
+
* Scale-to-zero services should pair it with
|
|
1077
|
+
* `alarms: { runningTasksMinimum: 0 }` or they idle in ALARM.
|
|
1076
1078
|
*/
|
|
1077
1079
|
containerInsights?: true;
|
|
1078
1080
|
/** Application ID for alarm tagging (used by webhook to map alarms to applications). */
|
|
@@ -20,6 +20,12 @@ export type FjallTarget = {
|
|
|
20
20
|
} | {
|
|
21
21
|
readonly kind: "cdn";
|
|
22
22
|
readonly appName: string;
|
|
23
|
+
/**
|
|
24
|
+
* Names one CloudFront distribution when the app fronts several. Absent
|
|
25
|
+
* means the app's sole CDN, which only exists as an export when the app
|
|
26
|
+
* has exactly one (lib/utils/cdnAliasTargetRegistry.ts).
|
|
27
|
+
*/
|
|
28
|
+
readonly cdnName?: string;
|
|
23
29
|
} | {
|
|
24
30
|
readonly kind: "bucket";
|
|
25
31
|
readonly appName: string;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
18
18
|
import { type Duration } from "aws-cdk-lib";
|
|
19
19
|
import { type Construct } from "constructs";
|
|
20
|
+
import type { HstsConfig } from "../../../resources/aws/cdn/responseHeadersPolicy.js";
|
|
20
21
|
import { type RelationalDatabase, type DynamoDBDatabase, type ProxyConfig, type ReadReplicaConfig, type CredentialsConfig, type EncryptionConfig, type AuroraEncryptionConfig, type AuroraWriterConfig, type AuroraReadersConfig, type DatabaseInsightsConfig } from "../database.js";
|
|
21
22
|
import { type LambdaCompute } from "../compute.js";
|
|
22
23
|
import { type Storage } from "../storage.js";
|
|
@@ -385,10 +386,25 @@ export interface StaticSiteBuildConfig {
|
|
|
385
386
|
export interface StaticSiteSecurityConfig {
|
|
386
387
|
/** Ship the four safe headers (HSTS/nosniff/frame/referrer). */
|
|
387
388
|
headers?: boolean;
|
|
389
|
+
/**
|
|
390
|
+
* HSTS tuning. Absent ⇒ the header ships scoped to this host only, for the
|
|
391
|
+
* standard max-age: a site that did not ask for HSTS does not get its
|
|
392
|
+
* subdomains bound or a preload-list entry. Supply the members to widen it,
|
|
393
|
+
* or `false` to drop the header entirely.
|
|
394
|
+
*/
|
|
395
|
+
hsts?: false | StaticSiteHstsConfig;
|
|
388
396
|
/** Opt-in Content-Security-Policy. Absent ⇒ no CSP header (a wrong policy
|
|
389
397
|
* silently breaks inline hydration, so it is never a shipped default). */
|
|
390
398
|
contentSecurityPolicy?: string;
|
|
391
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* HSTS configuration for a static site.
|
|
402
|
+
*
|
|
403
|
+
* Aliased from the response-headers wrapper rather than redeclared: the two
|
|
404
|
+
* describe one shape, and a second declaration is a silent-drift seam — a
|
|
405
|
+
* member added to one would compile clean against the other.
|
|
406
|
+
*/
|
|
407
|
+
export type StaticSiteHstsConfig = HstsConfig;
|
|
392
408
|
/**
|
|
393
409
|
* Contact-form configuration for a static site. Requires a custom `domain`: SES
|
|
394
410
|
* sends only from a verified identity, and the site's domain is the identity
|
|
@@ -50,6 +50,29 @@ export declare class StaticSite extends Construct implements IStaticSite {
|
|
|
50
50
|
private validateProps;
|
|
51
51
|
private createBucket;
|
|
52
52
|
private deploySiteAssets;
|
|
53
|
+
/**
|
|
54
|
+
* Refuse to synthesise a site whose build output directory is not there.
|
|
55
|
+
*
|
|
56
|
+
* Ordered before the emptiness gate because that gate READS the directory:
|
|
57
|
+
* without this, `readdirSync` throws a bare `ENOENT` naming an absolute
|
|
58
|
+
* path, which is the least useful form of exactly the diagnosis its sibling
|
|
59
|
+
* spells out. A mis-set `build.outputDir` is the common cause and the one
|
|
60
|
+
* the message names.
|
|
61
|
+
*/
|
|
62
|
+
private assertSourceExists;
|
|
63
|
+
/**
|
|
64
|
+
* Refuse to synthesise a pruning upload from an empty build output.
|
|
65
|
+
*
|
|
66
|
+
* The upload prunes, so an empty source does not deploy nothing — it
|
|
67
|
+
* deletes every object already in the bucket, taking the live site down.
|
|
68
|
+
* A build that creates its output directory and then fails to write into
|
|
69
|
+
* it exits 0 and looks identical to a successful one, so presence alone is
|
|
70
|
+
* not proof.
|
|
71
|
+
*
|
|
72
|
+
* `staticSiteBuilder` makes the same check at deploy time through the same
|
|
73
|
+
* helper, so the two verdicts cannot drift.
|
|
74
|
+
*/
|
|
75
|
+
private assertSourceHasContent;
|
|
53
76
|
private createFormsEndpoint;
|
|
54
77
|
private createCdn;
|
|
55
78
|
private buildBehaviours;
|
|
@@ -27,12 +27,14 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { Construct } from "constructs";
|
|
29
29
|
import { CfnOutput, Fn, Stack } from "aws-cdk-lib";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
30
31
|
import { dirname, resolve } from "node:path";
|
|
31
32
|
import { fileURLToPath } from "node:url";
|
|
32
33
|
import { Code, Runtime, Architecture, FunctionUrlAuthType, HttpMethod } from "aws-cdk-lib/aws-lambda";
|
|
33
34
|
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
34
35
|
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
35
36
|
import { DNS_APEX, defaultFormsCorsOrigin, defaultFormsFromAddress, isAddressAtDomain } from "@fjall/util";
|
|
37
|
+
import { hasDeployableContent } from "@fjall/util/staticSiteOutput";
|
|
36
38
|
import { StorageFactory } from "./storage.js";
|
|
37
39
|
import { CdnFactory } from "./cdn.js";
|
|
38
40
|
import { SecurityHeadersPolicy } from "../../resources/aws/cdn/index.js";
|
|
@@ -49,6 +51,22 @@ const FORMS_DEFAULTS = {
|
|
|
49
51
|
MEMORY_MB: 256,
|
|
50
52
|
MAX_CONCURRENCY: 5
|
|
51
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* HSTS for a site that never asked for it.
|
|
56
|
+
*
|
|
57
|
+
* `SecurityHeadersPolicy` defaults to includeSubdomains + preload, which is
|
|
58
|
+
* the right posture for an operator who chose it and the wrong one to apply
|
|
59
|
+
* on the user's behalf: both directives have a blast radius beyond this
|
|
60
|
+
* distribution. includeSubdomains binds every subdomain of the apex — a
|
|
61
|
+
* sibling host on plain HTTP becomes unreachable — and preload is effectively
|
|
62
|
+
* one-way, since removal from the browser preload list ships on a release
|
|
63
|
+
* train measured in months. The max-age is unchanged; only the two
|
|
64
|
+
* out-of-scope directives are withheld until asked for.
|
|
65
|
+
*/
|
|
66
|
+
const UNREQUESTED_HSTS = {
|
|
67
|
+
includeSubdomains: false,
|
|
68
|
+
preload: false
|
|
69
|
+
};
|
|
52
70
|
/** Record label relative to the zone — DNS_APEX for the apex. */
|
|
53
71
|
function recordLabelFor(domain, zoneName) {
|
|
54
72
|
if (domain === zoneName)
|
|
@@ -124,8 +142,48 @@ export class StaticSite extends Construct {
|
|
|
124
142
|
deploySiteAssets() {
|
|
125
143
|
const deploymentSource = resolve(this.props.source, this.props.build.outputDir);
|
|
126
144
|
const invalidate = this.props.cdn?.invalidateOnDeploy ?? true;
|
|
145
|
+
this.assertSourceExists(deploymentSource);
|
|
146
|
+
this.assertSourceHasContent(deploymentSource);
|
|
127
147
|
this._bucket.deployAssets({ source: deploymentSource, prune: true }, invalidate ? this._cdn.getDistribution() : undefined);
|
|
128
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Refuse to synthesise a site whose build output directory is not there.
|
|
151
|
+
*
|
|
152
|
+
* Ordered before the emptiness gate because that gate READS the directory:
|
|
153
|
+
* without this, `readdirSync` throws a bare `ENOENT` naming an absolute
|
|
154
|
+
* path, which is the least useful form of exactly the diagnosis its sibling
|
|
155
|
+
* spells out. A mis-set `build.outputDir` is the common cause and the one
|
|
156
|
+
* the message names.
|
|
157
|
+
*/
|
|
158
|
+
assertSourceExists(deploymentSource) {
|
|
159
|
+
if (existsSync(deploymentSource))
|
|
160
|
+
return;
|
|
161
|
+
throw new Error(`Static site '${this.props.name}' has nothing to deploy: the build ` +
|
|
162
|
+
`output directory '${deploymentSource}' does not exist. Run the ` +
|
|
163
|
+
"build before deploying, or check that 'build.outputDir' names the " +
|
|
164
|
+
"directory the build actually writes to.");
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Refuse to synthesise a pruning upload from an empty build output.
|
|
168
|
+
*
|
|
169
|
+
* The upload prunes, so an empty source does not deploy nothing — it
|
|
170
|
+
* deletes every object already in the bucket, taking the live site down.
|
|
171
|
+
* A build that creates its output directory and then fails to write into
|
|
172
|
+
* it exits 0 and looks identical to a successful one, so presence alone is
|
|
173
|
+
* not proof.
|
|
174
|
+
*
|
|
175
|
+
* `staticSiteBuilder` makes the same check at deploy time through the same
|
|
176
|
+
* helper, so the two verdicts cannot drift.
|
|
177
|
+
*/
|
|
178
|
+
assertSourceHasContent(deploymentSource) {
|
|
179
|
+
if (hasDeployableContent(deploymentSource))
|
|
180
|
+
return;
|
|
181
|
+
throw new Error(`Static site '${this.props.name}' has nothing to deploy: the build ` +
|
|
182
|
+
`output directory '${deploymentSource}' is empty. The upload prunes, ` +
|
|
183
|
+
"so deploying it would delete the site's current contents. Run the " +
|
|
184
|
+
"build before deploying, or check that 'build.outputDir' names the " +
|
|
185
|
+
"directory the build actually writes to.");
|
|
186
|
+
}
|
|
129
187
|
createFormsEndpoint() {
|
|
130
188
|
const forms = this.props.forms;
|
|
131
189
|
if (!forms)
|
|
@@ -178,6 +236,10 @@ export class StaticSite extends Construct {
|
|
|
178
236
|
if (this.props.security?.headers === true) {
|
|
179
237
|
this._headersPolicy = new SecurityHeadersPolicy(this, `${this.pascalName}Headers`, {
|
|
180
238
|
comment: `Security headers for ${this.props.name}`,
|
|
239
|
+
// `??`, not a conditional spread: an omitted `hsts` must resolve to
|
|
240
|
+
// the scoped default, and spreading would hand the decision back to
|
|
241
|
+
// the wrapper's aggressive one.
|
|
242
|
+
hsts: this.props.security.hsts ?? UNREQUESTED_HSTS,
|
|
181
243
|
...(this.props.security.contentSecurityPolicy !== undefined && {
|
|
182
244
|
contentSecurityPolicy: this.props.security.contentSecurityPolicy
|
|
183
245
|
})
|
|
@@ -23,12 +23,18 @@ export declare function fjallApp(appName: string, computeName?: string): FjallAl
|
|
|
23
23
|
/**
|
|
24
24
|
* Target helper for a Fjall CloudFront-fronted app.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* With no `cdnName`, requires the CDN stack to publish export
|
|
27
|
+
* `${safeApp}CdnDistributionDomainName` — which `App.synth()` emits only for
|
|
28
|
+
* an app fronting exactly ONE distribution, since "the app's CDN" names
|
|
29
|
+
* nothing otherwise. Pass `cdnName` for an app with several (one per
|
|
30
|
+
* static-site pattern, plus any added by `fjall add cdn`); that name
|
|
31
|
+
* (`${safeApp}-${safeCdn}-CdnDistributionDomainName`) is emitted
|
|
32
|
+
* unconditionally per distribution.
|
|
33
|
+
*
|
|
34
|
+
* Naming an app that has no matching export fails at CloudFormation execution
|
|
35
|
+
* with "No export named … found", after synth and every unit test pass.
|
|
30
36
|
*/
|
|
31
|
-
export declare function fjallCdn(appName: string): FjallAliasTarget;
|
|
37
|
+
export declare function fjallCdn(appName: string, cdnName?: string): FjallAliasTarget;
|
|
32
38
|
/**
|
|
33
39
|
* Target helper for a Fjall S3 static-site bucket.
|
|
34
40
|
*
|
|
@@ -25,16 +25,23 @@ export function fjallApp(appName, computeName) {
|
|
|
25
25
|
/**
|
|
26
26
|
* Target helper for a Fjall CloudFront-fronted app.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
* —
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* With no `cdnName`, requires the CDN stack to publish export
|
|
29
|
+
* `${safeApp}CdnDistributionDomainName` — which `App.synth()` emits only for
|
|
30
|
+
* an app fronting exactly ONE distribution, since "the app's CDN" names
|
|
31
|
+
* nothing otherwise. Pass `cdnName` for an app with several (one per
|
|
32
|
+
* static-site pattern, plus any added by `fjall add cdn`); that name
|
|
33
|
+
* (`${safeApp}-${safeCdn}-CdnDistributionDomainName`) is emitted
|
|
34
|
+
* unconditionally per distribution.
|
|
35
|
+
*
|
|
36
|
+
* Naming an app that has no matching export fails at CloudFormation execution
|
|
37
|
+
* with "No export named … found", after synth and every unit test pass.
|
|
32
38
|
*/
|
|
33
|
-
export function fjallCdn(appName) {
|
|
34
|
-
const resolved = resolveCdnTarget(appName);
|
|
39
|
+
export function fjallCdn(appName, cdnName) {
|
|
40
|
+
const resolved = resolveCdnTarget(appName, cdnName);
|
|
35
41
|
return {
|
|
36
42
|
kind: "cdn",
|
|
37
43
|
appName,
|
|
44
|
+
...(cdnName !== undefined && { cdnName }),
|
|
38
45
|
bind(record, zone) {
|
|
39
46
|
return resolved.bind(record, zone);
|
|
40
47
|
}
|
|
@@ -46,7 +46,7 @@ export declare function resolveEcsTarget(appName: string, computeName?: string):
|
|
|
46
46
|
* defaults that to `<App>Cdn`). The CloudFront hosted-zone id is the globally
|
|
47
47
|
* fixed `Z2FDTNDATAQYW2`.
|
|
48
48
|
*/
|
|
49
|
-
export declare function resolveCdnTarget(appName: string): IAliasRecordTarget;
|
|
49
|
+
export declare function resolveCdnTarget(appName: string, cdnName?: string): IAliasRecordTarget;
|
|
50
50
|
/**
|
|
51
51
|
* Resolve a Fjall S3 static-site bucket to an `IAliasRecordTarget`.
|
|
52
52
|
*
|
|
@@ -62,8 +62,8 @@ export function resolveEcsTarget(appName, computeName) {
|
|
|
62
62
|
* defaults that to `<App>Cdn`). The CloudFront hosted-zone id is the globally
|
|
63
63
|
* fixed `Z2FDTNDATAQYW2`.
|
|
64
64
|
*/
|
|
65
|
-
export function resolveCdnTarget(appName) {
|
|
66
|
-
const dnsName = Fn.importValue(cdnDomainExportName(appName));
|
|
65
|
+
export function resolveCdnTarget(appName, cdnName) {
|
|
66
|
+
const dnsName = Fn.importValue(cdnDomainExportName(appName, cdnName));
|
|
67
67
|
return buildAliasTarget(dnsName, CLOUDFRONT_HOSTED_ZONE_ID);
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
2
|
import { CfnOutput, Names, Stack } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
|
+
import { registerCdnAliasTarget } from "../../../utils/cdnAliasTargetRegistry.js";
|
|
4
5
|
import { cdnDomainExportName } from "@fjall/util";
|
|
5
6
|
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
|
|
6
7
|
import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
|
|
@@ -111,12 +112,23 @@ export class CloudFrontDistribution extends Construct {
|
|
|
111
112
|
logBucket: props.logBucket,
|
|
112
113
|
priceClass: this.resolvePriceClass(props.priceClass)
|
|
113
114
|
});
|
|
115
|
+
// The CDN-qualified export is unconditional and always unique, so an app
|
|
116
|
+
// fronting several distributions stays deployable. The unqualified
|
|
117
|
+
// app-level export is emitted once from `App.synth()`, and only when the
|
|
118
|
+
// app has exactly one CDN — see `cdnAliasTargetRegistry`.
|
|
119
|
+
const aliasAppName = props.appName ?? id;
|
|
114
120
|
new CfnOutput(this, `${outputName}DistributionDomainName`, {
|
|
115
121
|
key: `${outputName}DistributionDomainName`,
|
|
116
|
-
exportName: cdnDomainExportName(
|
|
122
|
+
exportName: cdnDomainExportName(aliasAppName, id),
|
|
117
123
|
value: this.distribution.distributionDomainName,
|
|
118
124
|
description: `CloudFront distribution domain name for ${id}`
|
|
119
125
|
});
|
|
126
|
+
registerCdnAliasTarget({
|
|
127
|
+
appName: aliasAppName,
|
|
128
|
+
cdnName: id,
|
|
129
|
+
scope: this,
|
|
130
|
+
domainName: this.distribution.distributionDomainName
|
|
131
|
+
});
|
|
120
132
|
new CfnOutput(this, `${outputName}DistributionId`, {
|
|
121
133
|
key: `${outputName}DistributionId`,
|
|
122
134
|
value: this.distribution.distributionId,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Construct } from "constructs";
|
|
2
|
+
/**
|
|
3
|
+
* The app-level half of the `Domain` CloudFront alias-target contract.
|
|
4
|
+
*
|
|
5
|
+
* Sibling of {@link ./albAliasTargetRegistry.ts}, and deliberately the same
|
|
6
|
+
* shape: `fjallCdn(appName)` resolves `<App>CdnDistributionDomainName` from a
|
|
7
|
+
* *different* stack, so the name has to be derivable from the app name alone —
|
|
8
|
+
* the Domain stack cannot see how many distributions the app has. But an app
|
|
9
|
+
* may legitimately front several: one per static-site pattern, plus any CDN
|
|
10
|
+
* added directly.
|
|
11
|
+
*
|
|
12
|
+
* Emitting the app-keyed export from each of them produces the identical
|
|
13
|
+
* `Outputs` entry N times and CloudFormation rejects the entire template ("The
|
|
14
|
+
* Outputs section contains duplicate Export names") — the same failure the ALB
|
|
15
|
+
* family hit on 2026-08-09, and the reason two static sites could not live in
|
|
16
|
+
* one app until this registry existed.
|
|
17
|
+
*
|
|
18
|
+
* So the two halves are split by who can answer the question:
|
|
19
|
+
*
|
|
20
|
+
* - Each distribution emits its own `<App>-<Cdn>-CdnDistributionDomainName`
|
|
21
|
+
* unconditionally. Always unique, always present, addressable as
|
|
22
|
+
* `fjallCdn(app, cdn)`.
|
|
23
|
+
* - The App emits the unqualified `<App>CdnDistributionDomainName` at synth
|
|
24
|
+
* time, and ONLY when the app registered exactly one distribution — the
|
|
25
|
+
* sole case where "the app's CDN" names one thing.
|
|
26
|
+
*
|
|
27
|
+
* Two or more and no app-level export is written. A `Domain` using the bare
|
|
28
|
+
* `fjallCdn(app)` then fails at CloudFormation with "No export named
|
|
29
|
+
* <App>CdnDistributionDomainName found", which names the missing export and is
|
|
30
|
+
* fixed by naming the distribution. Picking one of N silently would deploy a
|
|
31
|
+
* domain pointing at an arbitrary distribution, and would keep working right
|
|
32
|
+
* up until the registration order changed.
|
|
33
|
+
*/
|
|
34
|
+
export interface CdnAliasTargetRegistration {
|
|
35
|
+
readonly appName: string;
|
|
36
|
+
readonly cdnName: string;
|
|
37
|
+
/** Stack-attached scope the app-level `CfnOutput` is emitted into. */
|
|
38
|
+
readonly scope: Construct;
|
|
39
|
+
readonly domainName: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function registerCdnAliasTarget(registration: CdnAliasTargetRegistration): void;
|
|
42
|
+
/**
|
|
43
|
+
* Emit the unqualified app-level alias export for every app in this tree that
|
|
44
|
+
* registered exactly one distribution. Call once, from `App.synth()`, before
|
|
45
|
+
* `super.synth()` — the construct tree is still mutable there, and that is the
|
|
46
|
+
* same window `applyTagsAspect()` and `emitAppLevelAlbAliasExports()` use.
|
|
47
|
+
*/
|
|
48
|
+
export declare function emitAppLevelCdnAliasExports(root: Construct): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CfnOutput } from "aws-cdk-lib";
|
|
2
|
+
import { cdnDomainExportName } from "@fjall/util";
|
|
3
|
+
import { toPascalCase } from "./capitaliseString.js";
|
|
4
|
+
import { FjallLogger } from "./validationLogger.js";
|
|
5
|
+
/**
|
|
6
|
+
* Keyed on the construct-tree root (the `App`) so two apps synthesised in one
|
|
7
|
+
* process — the platform and organisation stacks do this — never see each
|
|
8
|
+
* other's registrations. Weak so a discarded tree does not leak.
|
|
9
|
+
*/
|
|
10
|
+
const registryByRoot = new WeakMap();
|
|
11
|
+
export function registerCdnAliasTarget(registration) {
|
|
12
|
+
const root = registration.scope.node.root;
|
|
13
|
+
const existing = registryByRoot.get(root);
|
|
14
|
+
if (existing === undefined) {
|
|
15
|
+
registryByRoot.set(root, [registration]);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
existing.push(registration);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Emit the unqualified app-level alias export for every app in this tree that
|
|
22
|
+
* registered exactly one distribution. Call once, from `App.synth()`, before
|
|
23
|
+
* `super.synth()` — the construct tree is still mutable there, and that is the
|
|
24
|
+
* same window `applyTagsAspect()` and `emitAppLevelAlbAliasExports()` use.
|
|
25
|
+
*/
|
|
26
|
+
export function emitAppLevelCdnAliasExports(root) {
|
|
27
|
+
const registrations = registryByRoot.get(root);
|
|
28
|
+
if (registrations === undefined)
|
|
29
|
+
return;
|
|
30
|
+
// `App.synth()` is re-entrant (CDK caches the assembly and returns it), and
|
|
31
|
+
// a second emission would collide on the output's construct id. Draining
|
|
32
|
+
// makes the call idempotent.
|
|
33
|
+
registryByRoot.delete(root);
|
|
34
|
+
const byApp = new Map();
|
|
35
|
+
for (const registration of registrations) {
|
|
36
|
+
const forApp = byApp.get(registration.appName);
|
|
37
|
+
if (forApp === undefined) {
|
|
38
|
+
byApp.set(registration.appName, [registration]);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
forApp.push(registration);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [appName, forApp] of byApp) {
|
|
45
|
+
const only = forApp.length === 1 ? forApp[0] : undefined;
|
|
46
|
+
if (only === undefined) {
|
|
47
|
+
const cdnNames = forApp.map((r) => r.cdnName).join(", ");
|
|
48
|
+
FjallLogger.warn(`App '${appName}' fronts ${String(forApp.length)} CloudFront distributions (${cdnNames}), ` +
|
|
49
|
+
`so no app-level '${cdnDomainExportName(appName)}' export was emitted — ` +
|
|
50
|
+
`'the app's CDN' would be ambiguous. Target a specific one with ` +
|
|
51
|
+
`fjallCdn('${appName}', '<cdn>') in your Domain records.`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Output logical ids are alphanumeric-only; the app name is not.
|
|
55
|
+
const logicalId = `${toPascalCase(appName)}AppCdn`;
|
|
56
|
+
new CfnOutput(only.scope, `${logicalId}DistributionDomainName`, {
|
|
57
|
+
key: `${logicalId}DistributionDomainName`,
|
|
58
|
+
exportName: cdnDomainExportName(appName),
|
|
59
|
+
value: only.domainName,
|
|
60
|
+
description: `CloudFront distribution domain name for ${appName} (sole CDN; consumed by fjallCdn('${appName}'))`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "27.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -68,20 +68,18 @@
|
|
|
68
68
|
"@aws-sdk/client-identitystore": "^3.1098.0",
|
|
69
69
|
"@aws-sdk/client-sesv2": "^3.1098.0",
|
|
70
70
|
"@peculiar/x509": "2.0.0",
|
|
71
|
-
"@types/aws-lambda": "^8.10.162",
|
|
72
71
|
"@types/node": "^26.1.2",
|
|
73
|
-
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
|
74
|
-
"@typescript-eslint/parser": "^8.65.0",
|
|
75
72
|
"eslint": "^10.8.0",
|
|
76
73
|
"prettier": "^3.9.6",
|
|
77
74
|
"reflect-metadata": "^0.2.2",
|
|
75
|
+
"tsx": "^4.23.1",
|
|
78
76
|
"typescript": "^6.0.3",
|
|
79
77
|
"vitest": "^4.1.10"
|
|
80
78
|
},
|
|
81
79
|
"dependencies": {
|
|
82
80
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
83
|
-
"@fjall/generator": "^
|
|
84
|
-
"@fjall/util": "^
|
|
81
|
+
"@fjall/generator": "^27.0.0",
|
|
82
|
+
"@fjall/util": "^27.0.0",
|
|
85
83
|
"constructs": "^10.7.2",
|
|
86
84
|
"zod": "^4.4.3"
|
|
87
85
|
},
|