@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 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?: "PriceClass_100" | "PriceClass_200" | "PriceClass_All";
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
- * site.getBucket().getBucket().addLifecycleRule({ ... });
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 createBucketAndDeployment;
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.createBucketAndDeployment();
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
- createBucketAndDeployment() {
113
- const deploymentSource = resolve(this.props.source, this.props.build.outputDir);
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
- priceClass: "PriceClass_100",
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 IBucket, type EventType, type IBucketNotificationDestination, type NotificationKeyFilter } from "aws-cdk-lib/aws-s3";
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 readonly bucketDeployment?;
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
- getBucket(): IBucket;
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(id, props.deployment);
91
+ this.bucketDeployment = this.createDeployment(props.deployment);
92
92
  }
93
93
  this.addOutputs(id);
94
94
  }
95
- createDeployment(id, config) {
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
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
27
- * — emitted by every `CloudFrontDistribution` given an `appName` (the
28
- * `CdnFactory.build` path fills it from the App). Naming an app with no such
29
- * export fails at CloudFormation execution, exactly as `fjallApp` documents.
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
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
29
- * — emitted by every `CloudFrontDistribution` given an `appName` (the
30
- * `CdnFactory.build` path fills it from the App). Naming an app with no such
31
- * export fails at CloudFormation execution, exactly as `fjallApp` documents.
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?: "PriceClass_100" | "PriceClass_200" | "PriceClass_All";
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(props.appName ?? id),
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": "24.0.0",
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": "^24.0.0",
84
- "@fjall/util": "^24.0.0",
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"