@fjall/components-infrastructure 24.0.0 → 26.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/cdn.d.ts +2 -2
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +6 -0
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +67 -2
- package/dist/lib/patterns/aws/staticSite.d.ts +29 -2
- package/dist/lib/patterns/aws/staticSite.js +88 -10
- package/dist/lib/patterns/aws/storage.d.ts +29 -4
- package/dist/lib/patterns/aws/storage.js +40 -3
- 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.d.ts +2 -1
- 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 +6 -7
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);
|
|
@@ -6,7 +6,7 @@ import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-
|
|
|
6
6
|
import type App from "../../app.js";
|
|
7
7
|
import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
|
|
8
8
|
import { type ICdn } from "./interfaces/cdn.js";
|
|
9
|
-
import { type StaticSiteRouting } from "@fjall/util";
|
|
9
|
+
import { type StaticSiteRouting, type CloudFrontPriceClass } from "@fjall/util";
|
|
10
10
|
import { type ManagedDomainBinding, type ManagedDomainExports } from "../../utils/domainTypes.js";
|
|
11
11
|
import { type Storage } from "./storage.js";
|
|
12
12
|
import { type AnyCompute } from "./compute.js";
|
|
@@ -83,7 +83,7 @@ interface BaseCdnProps {
|
|
|
83
83
|
comment?: string;
|
|
84
84
|
enableLogging?: boolean;
|
|
85
85
|
logBucket?: IBucket;
|
|
86
|
-
priceClass?:
|
|
86
|
+
priceClass?: CloudFrontPriceClass;
|
|
87
87
|
forwardHostHeader?: boolean;
|
|
88
88
|
accessGate?: false | AccessGateConfig;
|
|
89
89
|
}
|
|
@@ -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,14 +17,16 @@
|
|
|
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";
|
|
23
24
|
import { type QueueMessaging } from "../messaging.js";
|
|
24
25
|
import { type Cdn, type SmartCdnBehaviour, type StaticSiteRouting } from "../cdn.js";
|
|
25
26
|
import { type LambdaFunction } from "../../../resources/aws/compute/index.js";
|
|
27
|
+
import type { AccessGateConfig, SecurityHeadersPolicy } from "../../../resources/aws/cdn/index.js";
|
|
26
28
|
import type { ManagedDomainBinding, ManagedDomainExports } from "../../../utils/domainTypes.js";
|
|
27
|
-
import type { PatternType } from "@fjall/util";
|
|
29
|
+
import type { PatternType, CloudFrontPriceClass, STATIC_SITE_CONFIG_KEYS, STATIC_SITE_CDN_CONFIG_KEYS } from "@fjall/util";
|
|
28
30
|
export type { ProxyConfig, ReadReplicaConfig, CredentialsConfig, EncryptionConfig, AuroraEncryptionConfig, AuroraWriterConfig, AuroraReadersConfig, DatabaseInsightsConfig };
|
|
29
31
|
/**
|
|
30
32
|
* Full database configuration for patterns.
|
|
@@ -384,10 +386,25 @@ export interface StaticSiteBuildConfig {
|
|
|
384
386
|
export interface StaticSiteSecurityConfig {
|
|
385
387
|
/** Ship the four safe headers (HSTS/nosniff/frame/referrer). */
|
|
386
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;
|
|
387
396
|
/** Opt-in Content-Security-Policy. Absent ⇒ no CSP header (a wrong policy
|
|
388
397
|
* silently breaks inline hydration, so it is never a shipped default). */
|
|
389
398
|
contentSecurityPolicy?: string;
|
|
390
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;
|
|
391
408
|
/**
|
|
392
409
|
* Contact-form configuration for a static site. Requires a custom `domain`: SES
|
|
393
410
|
* sends only from a verified identity, and the site's domain is the identity
|
|
@@ -422,6 +439,23 @@ export interface StaticSiteFormsConfig {
|
|
|
422
439
|
export interface StaticSiteCdnConfig {
|
|
423
440
|
/** Additional CDN behaviours (per-path overrides). */
|
|
424
441
|
behaviours?: SmartCdnBehaviour[];
|
|
442
|
+
/**
|
|
443
|
+
* CloudFront price class — which edge locations serve the site. Default
|
|
444
|
+
* `"PriceClass_100"` (NA + Europe only). At small-site traffic the price
|
|
445
|
+
* difference between classes is ~zero, so treat this as a latency knob:
|
|
446
|
+
* an audience outside NA/Europe (e.g. Australia) wants `"PriceClass_All"`,
|
|
447
|
+
* or every request crosses an ocean to the nearest POP.
|
|
448
|
+
*/
|
|
449
|
+
priceClass?: CloudFrontPriceClass;
|
|
450
|
+
/**
|
|
451
|
+
* Invalidate the distribution (`/*`) when a deploy uploads new assets.
|
|
452
|
+
* Default `true`: without it CloudFront serves stale edge copies until the
|
|
453
|
+
* cache policy's TTL (up to 24h) expires, which a non-expert cannot tell
|
|
454
|
+
* apart from a failed deploy. AWS grants 1,000 free invalidation paths per
|
|
455
|
+
* month and `/*` counts as one path per deploy. Set `false` to keep pure
|
|
456
|
+
* TTL semantics (e.g. very frequent deploys where a stale window is fine).
|
|
457
|
+
*/
|
|
458
|
+
invalidateOnDeploy?: boolean;
|
|
425
459
|
}
|
|
426
460
|
/**
|
|
427
461
|
* Static-site pattern props.
|
|
@@ -485,7 +519,30 @@ export interface IStaticSiteProps {
|
|
|
485
519
|
forms?: StaticSiteFormsConfig;
|
|
486
520
|
/** CDN configuration - for advanced per-path overrides. */
|
|
487
521
|
cdn?: StaticSiteCdnConfig;
|
|
522
|
+
/**
|
|
523
|
+
* Gate every request behind HTTP Basic auth at the edge (a CloudFront
|
|
524
|
+
* viewer function; the gate runs BEFORE the routing rewrite). This is an
|
|
525
|
+
* obscurity gate for staging sign-off — keeping a not-yet-launched site
|
|
526
|
+
* out of casual view and search indexes — NOT a security boundary: the
|
|
527
|
+
* credentials sit in plain text here and in the distribution's function
|
|
528
|
+
* code, and the origin objects are unchanged. Remove it at launch.
|
|
529
|
+
*/
|
|
530
|
+
accessGate?: AccessGateConfig;
|
|
488
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Field-parity witnesses (compile-time, zero runtime cost).
|
|
534
|
+
*
|
|
535
|
+
* `STATIC_SITE_CONFIG_KEYS` in `@fjall/util` is the manifest every
|
|
536
|
+
* field-enumerating surface is held to (generator schema, AST parser,
|
|
537
|
+
* emitter — see its JSDoc). These aliases fail to compile the moment
|
|
538
|
+
* `IStaticSiteProps` or `StaticSiteCdnConfig` gains or loses a key the
|
|
539
|
+
* manifest does not list, so the interface cannot drift from the manifest —
|
|
540
|
+
* and via the generator's parity tests, from the other surfaces.
|
|
541
|
+
*/
|
|
542
|
+
type MutuallyAssignable<A, B> = [A] extends [B] ? [B] extends [A] ? true : false : false;
|
|
543
|
+
type AssertTrue<T extends true> = T;
|
|
544
|
+
export type StaticSitePropsParityWitness = AssertTrue<MutuallyAssignable<keyof IStaticSiteProps, (typeof STATIC_SITE_CONFIG_KEYS)[number]>>;
|
|
545
|
+
export type StaticSiteCdnConfigParityWitness = AssertTrue<MutuallyAssignable<keyof StaticSiteCdnConfig, (typeof STATIC_SITE_CDN_CONFIG_KEYS)[number]>>;
|
|
489
546
|
/**
|
|
490
547
|
* Union of all pattern props.
|
|
491
548
|
* Extend this when adding new patterns (e.g., INextjsProps, IRemixProps).
|
|
@@ -554,7 +611,10 @@ export interface IPayload extends IPattern {
|
|
|
554
611
|
* Provides access to the underlying resources for escape hatches.
|
|
555
612
|
*
|
|
556
613
|
* @example
|
|
557
|
-
*
|
|
614
|
+
* // Expire old objects: the concrete S3 Bucket is two getters away.
|
|
615
|
+
* site.getBucket().getBucket().addLifecycleRule({
|
|
616
|
+
* expiration: Duration.days(90)
|
|
617
|
+
* });
|
|
558
618
|
* site.getCdn().getDistribution().addBehavior("/custom/*", customOrigin);
|
|
559
619
|
*/
|
|
560
620
|
export interface IStaticSite extends IPattern {
|
|
@@ -565,6 +625,11 @@ export interface IStaticSite extends IPattern {
|
|
|
565
625
|
getCdn(): Cdn;
|
|
566
626
|
/** Get the contact-form Lambda (undefined when `forms` is not configured) */
|
|
567
627
|
getFormsFunction(): LambdaFunction | undefined;
|
|
628
|
+
/**
|
|
629
|
+
* Get the security-headers ResponseHeadersPolicy (undefined unless
|
|
630
|
+
* `security.headers` is set) — e.g. to attach it to extra behaviours.
|
|
631
|
+
*/
|
|
632
|
+
getResponseHeadersPolicy(): SecurityHeadersPolicy | undefined;
|
|
568
633
|
}
|
|
569
634
|
/**
|
|
570
635
|
* Union type representing any pattern interface.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* optional contact-form endpoint (Lambda Function URL → SES, CloudFront-fronted).
|
|
7
7
|
*
|
|
8
8
|
* Resources created:
|
|
9
|
-
* - Private S3 bucket + BucketDeployment (asset upload
|
|
9
|
+
* - Private S3 bucket + BucketDeployment (asset upload + post-upload
|
|
10
|
+
* invalidation of the distribution, unless `cdn.invalidateOnDeploy: false`)
|
|
10
11
|
* - CloudFront distribution (OAC, routing-mode viewer function, security-headers policy)
|
|
11
12
|
* - ACM certificate + Route53 alias record (when `domain` is set)
|
|
12
13
|
* - Contact-form Lambda + Function URL (when `forms` is set)
|
|
@@ -29,6 +30,7 @@ import type App from "../../app.js";
|
|
|
29
30
|
import { type IStaticSiteProps, type IStaticSite } from "./interfaces/pattern.js";
|
|
30
31
|
import { type Storage } from "./storage.js";
|
|
31
32
|
import { type Cdn } from "./cdn.js";
|
|
33
|
+
import { SecurityHeadersPolicy } from "../../resources/aws/cdn/index.js";
|
|
32
34
|
import { LambdaFunction } from "../../resources/aws/compute/index.js";
|
|
33
35
|
/**
|
|
34
36
|
* Static-site pattern implementation.
|
|
@@ -46,7 +48,31 @@ export declare class StaticSite extends Construct implements IStaticSite {
|
|
|
46
48
|
constructor(scope: Construct, id: string, app: App, props: IStaticSiteProps);
|
|
47
49
|
private registerManifest;
|
|
48
50
|
private validateProps;
|
|
49
|
-
private
|
|
51
|
+
private createBucket;
|
|
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;
|
|
50
76
|
private createFormsEndpoint;
|
|
51
77
|
private createCdn;
|
|
52
78
|
private buildBehaviours;
|
|
@@ -57,4 +83,5 @@ export declare class StaticSite extends Construct implements IStaticSite {
|
|
|
57
83
|
getBucket(): Storage;
|
|
58
84
|
getCdn(): Cdn;
|
|
59
85
|
getFormsFunction(): LambdaFunction | undefined;
|
|
86
|
+
getResponseHeadersPolicy(): SecurityHeadersPolicy | undefined;
|
|
60
87
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* optional contact-form endpoint (Lambda Function URL → SES, CloudFront-fronted).
|
|
7
7
|
*
|
|
8
8
|
* Resources created:
|
|
9
|
-
* - Private S3 bucket + BucketDeployment (asset upload
|
|
9
|
+
* - Private S3 bucket + BucketDeployment (asset upload + post-upload
|
|
10
|
+
* invalidation of the distribution, unless `cdn.invalidateOnDeploy: false`)
|
|
10
11
|
* - CloudFront distribution (OAC, routing-mode viewer function, security-headers policy)
|
|
11
12
|
* - ACM certificate + Route53 alias record (when `domain` is set)
|
|
12
13
|
* - Contact-form Lambda + Function URL (when `forms` is set)
|
|
@@ -26,12 +27,14 @@
|
|
|
26
27
|
*/
|
|
27
28
|
import { Construct } from "constructs";
|
|
28
29
|
import { CfnOutput, Fn, Stack } from "aws-cdk-lib";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
29
31
|
import { dirname, resolve } from "node:path";
|
|
30
32
|
import { fileURLToPath } from "node:url";
|
|
31
33
|
import { Code, Runtime, Architecture, FunctionUrlAuthType, HttpMethod } from "aws-cdk-lib/aws-lambda";
|
|
32
34
|
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
33
35
|
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
34
36
|
import { DNS_APEX, defaultFormsCorsOrigin, defaultFormsFromAddress, isAddressAtDomain } from "@fjall/util";
|
|
37
|
+
import { hasDeployableContent } from "@fjall/util/staticSiteOutput";
|
|
35
38
|
import { StorageFactory } from "./storage.js";
|
|
36
39
|
import { CdnFactory } from "./cdn.js";
|
|
37
40
|
import { SecurityHeadersPolicy } from "../../resources/aws/cdn/index.js";
|
|
@@ -48,6 +51,22 @@ const FORMS_DEFAULTS = {
|
|
|
48
51
|
MEMORY_MB: 256,
|
|
49
52
|
MAX_CONCURRENCY: 5
|
|
50
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
|
+
};
|
|
51
70
|
/** Record label relative to the zone — DNS_APEX for the apex. */
|
|
52
71
|
function recordLabelFor(domain, zoneName) {
|
|
53
72
|
if (domain === zoneName)
|
|
@@ -75,11 +94,15 @@ export class StaticSite extends Construct {
|
|
|
75
94
|
this.pascalName = toPascalCase(props.name);
|
|
76
95
|
this.registerManifest();
|
|
77
96
|
this.validateProps();
|
|
78
|
-
this.
|
|
97
|
+
this.createBucket();
|
|
79
98
|
// Forms must exist before the CDN — createCdn wires the /api/contact*
|
|
80
99
|
// behaviour to the forms Function URL host (§15).
|
|
81
100
|
this.createFormsEndpoint();
|
|
82
101
|
this.createCdn();
|
|
102
|
+
// Assets deploy AFTER the CDN: the deployment invalidates the
|
|
103
|
+
// distribution post-upload, and the distribution needs the bucket first
|
|
104
|
+
// (it is the origin) — so the order is bucket → CDN → deployment.
|
|
105
|
+
this.deploySiteAssets();
|
|
83
106
|
this.createDnsRecord();
|
|
84
107
|
this.exportPatternOutputs();
|
|
85
108
|
}
|
|
@@ -109,16 +132,58 @@ export class StaticSite extends Construct {
|
|
|
109
132
|
"and may be any address.");
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
createBucket() {
|
|
136
|
+
// No `deployment` here — assets deploy via deploySiteAssets() once the
|
|
137
|
+
// distribution exists, so the upload can invalidate it.
|
|
114
138
|
this._bucket = this.app.addStorage(StorageFactory.build(`${this.pascalName}Site`, {
|
|
115
|
-
stackPlacement: "cdn"
|
|
116
|
-
deployment: {
|
|
117
|
-
source: deploymentSource,
|
|
118
|
-
prune: true
|
|
119
|
-
}
|
|
139
|
+
stackPlacement: "cdn"
|
|
120
140
|
}));
|
|
121
141
|
}
|
|
142
|
+
deploySiteAssets() {
|
|
143
|
+
const deploymentSource = resolve(this.props.source, this.props.build.outputDir);
|
|
144
|
+
const invalidate = this.props.cdn?.invalidateOnDeploy ?? true;
|
|
145
|
+
this.assertSourceExists(deploymentSource);
|
|
146
|
+
this.assertSourceHasContent(deploymentSource);
|
|
147
|
+
this._bucket.deployAssets({ source: deploymentSource, prune: true }, invalidate ? this._cdn.getDistribution() : undefined);
|
|
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
|
+
}
|
|
122
187
|
createFormsEndpoint() {
|
|
123
188
|
const forms = this.props.forms;
|
|
124
189
|
if (!forms)
|
|
@@ -171,6 +236,10 @@ export class StaticSite extends Construct {
|
|
|
171
236
|
if (this.props.security?.headers === true) {
|
|
172
237
|
this._headersPolicy = new SecurityHeadersPolicy(this, `${this.pascalName}Headers`, {
|
|
173
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,
|
|
174
243
|
...(this.props.security.contentSecurityPolicy !== undefined && {
|
|
175
244
|
contentSecurityPolicy: this.props.security.contentSecurityPolicy
|
|
176
245
|
})
|
|
@@ -189,9 +258,15 @@ export class StaticSite extends Construct {
|
|
|
189
258
|
// routing: "directory" — the default cannot protect it.
|
|
190
259
|
routing: this.props.routing ?? "multipage",
|
|
191
260
|
cachePolicy: "CACHING_OPTIMIZED",
|
|
192
|
-
|
|
261
|
+
// Default NA+Europe-only: the cheapest class, and right for most
|
|
262
|
+
// sites. An audience elsewhere (e.g. Australia) should set
|
|
263
|
+
// cdn.priceClass — see StaticSiteCdnConfig.
|
|
264
|
+
priceClass: this.props.cdn?.priceClass ?? "PriceClass_100",
|
|
193
265
|
domainNames,
|
|
194
266
|
certificate,
|
|
267
|
+
...(this.props.accessGate !== undefined && {
|
|
268
|
+
accessGate: this.props.accessGate
|
|
269
|
+
}),
|
|
195
270
|
...(this._headersPolicy && {
|
|
196
271
|
responseHeadersPolicy: this._headersPolicy.getPolicy()
|
|
197
272
|
}),
|
|
@@ -304,4 +379,7 @@ export class StaticSite extends Construct {
|
|
|
304
379
|
getFormsFunction() {
|
|
305
380
|
return this._formsFunction;
|
|
306
381
|
}
|
|
382
|
+
getResponseHeadersPolicy() {
|
|
383
|
+
return this._headersPolicy;
|
|
384
|
+
}
|
|
307
385
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { type
|
|
2
|
+
import { type EventType, type IBucketNotificationDestination, type NotificationKeyFilter } from "aws-cdk-lib/aws-s3";
|
|
3
|
+
import { type IDistribution } from "aws-cdk-lib/aws-cloudfront";
|
|
3
4
|
import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
|
|
4
5
|
import type App from "../../app.js";
|
|
5
|
-
import { BucketDeployment, type ResourcePolicyStatement, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
|
|
6
|
+
import { BucketDeployment, S3Bucket, type ResourcePolicyStatement, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
|
|
6
7
|
import { type BackupTier } from "../../utils/backupTierMapping.js";
|
|
7
8
|
import { type IStorage } from "./interfaces/storage.js";
|
|
8
9
|
import { type IStorageConnector } from "./interfaces/connector.js";
|
|
@@ -68,11 +69,35 @@ export declare function validateStorageProps(props: S3Props): void;
|
|
|
68
69
|
export declare class Storage extends Construct implements IStorage, IStorageConnector {
|
|
69
70
|
readonly connectorType: "storage";
|
|
70
71
|
private readonly bucket;
|
|
71
|
-
private
|
|
72
|
+
private bucketDeployment?;
|
|
72
73
|
constructor(scope: Construct, id: string, props: S3Props);
|
|
74
|
+
/**
|
|
75
|
+
* Upload assets to the bucket AFTER construction — for composition orders
|
|
76
|
+
* where the deployment must reference a construct that does not exist yet
|
|
77
|
+
* when the bucket is built. The static-site pattern is the canonical case:
|
|
78
|
+
* the CDN needs the bucket as its origin, and the deployment needs the
|
|
79
|
+
* distribution for its post-upload invalidation, so the pattern builds
|
|
80
|
+
* bucket → CDN → deployment. Hand-composed sites hit the same ordering and
|
|
81
|
+
* can call this directly with `cdn.getDistribution()`.
|
|
82
|
+
*
|
|
83
|
+
* A bucket deploys one asset set: calling this when a deployment already
|
|
84
|
+
* exists (constructor `deployment` config, or a prior call) throws rather
|
|
85
|
+
* than stacking a second BucketDeployment whose prune semantics would
|
|
86
|
+
* fight the first.
|
|
87
|
+
*
|
|
88
|
+
* @param distribution When set, the deployment invalidates it (`/*`) after
|
|
89
|
+
* upload, so the new assets are served immediately instead of after the
|
|
90
|
+
* cache TTL (up to 24h) expires.
|
|
91
|
+
*/
|
|
92
|
+
deployAssets(config: S3DeploymentConfig, distribution?: IDistribution): BucketDeployment;
|
|
73
93
|
private createDeployment;
|
|
74
94
|
private addOutputs;
|
|
75
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The concrete bucket (narrowed from `IStorage`'s `IBucket`), so
|
|
97
|
+
* mutating escape hatches — `addLifecycleRule`, `addCorsRule` — are
|
|
98
|
+
* reachable without a cast.
|
|
99
|
+
*/
|
|
100
|
+
getBucket(): S3Bucket;
|
|
76
101
|
getBucketName(): string;
|
|
77
102
|
getBucketArn(): string;
|
|
78
103
|
getBucketDomainName(): string;
|
|
@@ -88,11 +88,39 @@ export class Storage extends Construct {
|
|
|
88
88
|
...(removalPolicy !== undefined && { removalPolicy })
|
|
89
89
|
});
|
|
90
90
|
if (props.deployment) {
|
|
91
|
-
this.bucketDeployment = this.createDeployment(
|
|
91
|
+
this.bucketDeployment = this.createDeployment(props.deployment);
|
|
92
92
|
}
|
|
93
93
|
this.addOutputs(id);
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Upload assets to the bucket AFTER construction — for composition orders
|
|
97
|
+
* where the deployment must reference a construct that does not exist yet
|
|
98
|
+
* when the bucket is built. The static-site pattern is the canonical case:
|
|
99
|
+
* the CDN needs the bucket as its origin, and the deployment needs the
|
|
100
|
+
* distribution for its post-upload invalidation, so the pattern builds
|
|
101
|
+
* bucket → CDN → deployment. Hand-composed sites hit the same ordering and
|
|
102
|
+
* can call this directly with `cdn.getDistribution()`.
|
|
103
|
+
*
|
|
104
|
+
* A bucket deploys one asset set: calling this when a deployment already
|
|
105
|
+
* exists (constructor `deployment` config, or a prior call) throws rather
|
|
106
|
+
* than stacking a second BucketDeployment whose prune semantics would
|
|
107
|
+
* fight the first.
|
|
108
|
+
*
|
|
109
|
+
* @param distribution When set, the deployment invalidates it (`/*`) after
|
|
110
|
+
* upload, so the new assets are served immediately instead of after the
|
|
111
|
+
* cache TTL (up to 24h) expires.
|
|
112
|
+
*/
|
|
113
|
+
deployAssets(config, distribution) {
|
|
114
|
+
if (this.bucketDeployment !== undefined) {
|
|
115
|
+
throw new Error(`Storage '${this.node.id}' already has a bucket deployment — a ` +
|
|
116
|
+
"bucket deploys one asset set. Pass everything in one " +
|
|
117
|
+
"deployment config instead of calling deployAssets twice (or " +
|
|
118
|
+
"combining it with the constructor's `deployment`).");
|
|
119
|
+
}
|
|
120
|
+
this.bucketDeployment = this.createDeployment(config, distribution);
|
|
121
|
+
return this.bucketDeployment;
|
|
122
|
+
}
|
|
123
|
+
createDeployment(config, distribution) {
|
|
96
124
|
const cacheControlHeaders = [];
|
|
97
125
|
if (config.cacheControl?.maxAge !== undefined) {
|
|
98
126
|
cacheControlHeaders.push(CacheControl.maxAge(Duration.seconds(config.cacheControl.maxAge)));
|
|
@@ -100,12 +128,16 @@ export class Storage extends Construct {
|
|
|
100
128
|
if (config.cacheControl?.immutable) {
|
|
101
129
|
cacheControlHeaders.push(CacheControl.immutable());
|
|
102
130
|
}
|
|
103
|
-
return new BucketDeployment(this, `${id}Deployment`, {
|
|
131
|
+
return new BucketDeployment(this, `${this.node.id}Deployment`, {
|
|
104
132
|
sources: [Source.asset(config.source)],
|
|
105
133
|
destinationBucket: this.bucket,
|
|
106
134
|
prune: config.prune ?? true,
|
|
107
135
|
...(cacheControlHeaders.length > 0 && {
|
|
108
136
|
cacheControl: cacheControlHeaders
|
|
137
|
+
}),
|
|
138
|
+
...(distribution !== undefined && {
|
|
139
|
+
distribution,
|
|
140
|
+
distributionPaths: ["/*"]
|
|
109
141
|
})
|
|
110
142
|
});
|
|
111
143
|
}
|
|
@@ -124,6 +156,11 @@ export class Storage extends Construct {
|
|
|
124
156
|
description: `S3 Bucket Name for ${id}`
|
|
125
157
|
});
|
|
126
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The concrete bucket (narrowed from `IStorage`'s `IBucket`), so
|
|
161
|
+
* mutating escape hatches — `addLifecycleRule`, `addCorsRule` — are
|
|
162
|
+
* reachable without a cast.
|
|
163
|
+
*/
|
|
127
164
|
getBucket() {
|
|
128
165
|
return this.bucket;
|
|
129
166
|
}
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
+
import { type CloudFrontPriceClass } from "@fjall/util";
|
|
2
3
|
import { Distribution, type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
3
4
|
import { type IBucket } from "aws-cdk-lib/aws-s3";
|
|
4
5
|
import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
@@ -59,7 +60,7 @@ export interface CloudFrontDistributionProps {
|
|
|
59
60
|
comment?: string;
|
|
60
61
|
enableLogging?: boolean;
|
|
61
62
|
logBucket?: IBucket;
|
|
62
|
-
priceClass?:
|
|
63
|
+
priceClass?: CloudFrontPriceClass;
|
|
63
64
|
/** Adds a CloudFront Function to copy the viewer Host header into X-Forwarded-Host.
|
|
64
65
|
* Required when the origin is a Lambda Function URL or ALB behind CloudFront,
|
|
65
66
|
* because AllViewerExceptHostHeader replaces Host with the origin domain. */
|
|
@@ -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": "26.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -68,21 +68,20 @@
|
|
|
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": "^
|
|
85
|
-
"constructs": "^10.7.2"
|
|
81
|
+
"@fjall/generator": "^26.0.0",
|
|
82
|
+
"@fjall/util": "^26.0.0",
|
|
83
|
+
"constructs": "^10.7.2",
|
|
84
|
+
"zod": "^4.4.3"
|
|
86
85
|
},
|
|
87
86
|
"overrides": {
|
|
88
87
|
"@smithy/core": "2.5.5"
|