@fjall/components-infrastructure 22.0.0 → 23.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.
@@ -49,9 +49,17 @@ export class Account extends Stack {
49
49
  // so `this.organisationType` here would say "account" for every tier (the
50
50
  // same trap the connector-mode note below documents). By synth the field
51
51
  // is final and the tag renders the true tier.
52
+ //
53
+ // excludeResourceTypes is load-bearing: stack-level tags travel on the
54
+ // CloudFormation API call as literal strings, so a Lazy token there
55
+ // aborts every governance deploy at changeset creation ("Tag
56
+ // [fjall:description] contained invalid characters"). Resources render
57
+ // the resolved value in-template; only the stack's own tag set is
58
+ // skipped — CloudFormation would re-propagate a stack tag to those same
59
+ // resources anyway.
52
60
  Tags.of(this).add("fjall:description", Lazy.string({
53
61
  produce: () => GOVERNANCE_TIER_DESCRIPTIONS[this.organisationType]
54
- }));
62
+ }), { excludeResourceTypes: ["aws:cdk:stack"] });
55
63
  this.resolvedRegion = region ?? this.region;
56
64
  const orgId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
57
65
  if (orgId) {
@@ -98,7 +98,8 @@ export interface S3CdnProps extends BaseCdnProps {
98
98
  originAccess?: "oai" | "oac";
99
99
  /** Object served for `/` (static sites: "index.html"). */
100
100
  defaultRootObject?: string;
101
- /** "multipage" → clean-URL `.html` rewriting; "spa" → 403/404 fallback to index. */
101
+ /** "multipage" (/about /about.html), "directory" (/about
102
+ * /about/index.html) or "spa" (403/404 fall back to /index.html). */
102
103
  routing?: StaticSiteRouting;
103
104
  /** Security-headers policy (built by the caller, e.g. SecurityHeadersPolicy). */
104
105
  responseHeadersPolicy?: IResponseHeadersPolicy;
@@ -193,9 +194,6 @@ export interface SmartCdnBehaviour {
193
194
  */
194
195
  originRecord?: "alias" | "none";
195
196
  }
196
- /**
197
- * CDN wrapper class that extends CloudFrontDistribution with Fjall patterns.
198
- */
199
197
  export declare class Cdn extends CloudFrontDistribution implements ICdn {
200
198
  constructor(scope: Construct, id: string, props: ICdnProps);
201
199
  private static createEcsOriginResolver;
@@ -208,6 +208,19 @@ function validateCdnProps(props) {
208
208
  /**
209
209
  * CDN wrapper class that extends CloudFrontDistribution with Fjall patterns.
210
210
  */
211
+ /**
212
+ * What each routing mode asks of the distribution, total over the routing
213
+ * vocabulary — a mode added to `STATIC_SITE_ROUTING_VALUES` fails to compile
214
+ * here until it declares its CDN behaviour, instead of silently synthesising
215
+ * a distribution that ignores it. `multipage` and `directory` rewrite URIs in
216
+ * a viewer-request function; `spa` skips rewriting and relies on 403/404
217
+ * error responses falling back to `/index.html`.
218
+ */
219
+ const ROUTING_CDN_BEHAVIOUR = {
220
+ multipage: { uriRewrite: "html" },
221
+ directory: { uriRewrite: "directory-index" },
222
+ spa: { spaFallback: true }
223
+ };
211
224
  export class Cdn extends CloudFrontDistribution {
212
225
  constructor(scope, id, props) {
213
226
  validateCdnProps(props);
@@ -429,8 +442,8 @@ export class Cdn extends CloudFrontDistribution {
429
442
  const s3Routing = props.originType === "s3"
430
443
  ? {
431
444
  defaultRootObject: props.defaultRootObject,
432
- cleanUrls: props.routing === "multipage",
433
- spaFallback: props.routing === "spa",
445
+ ...(props.routing !== undefined &&
446
+ ROUTING_CDN_BEHAVIOUR[props.routing]),
434
447
  responseHeadersPolicy: props.responseHeadersPolicy,
435
448
  originRequestPolicy: props.originRequestPolicy
436
449
  }
@@ -452,7 +452,10 @@ export interface IStaticSiteProps {
452
452
  source: string;
453
453
  /** Build command + output directory (single source of truth). */
454
454
  build: StaticSiteBuildConfig;
455
- /** "multipage" (clean-URL `.html` rewriting) or "spa". Default: "multipage". */
455
+ /** "multipage" (/about → /about.html), "directory" (/about
456
+ * /about/index.html — Astro's default layout) or "spa" (unknown paths fall
457
+ * back to /index.html). Match it to the build's emitted layout.
458
+ * Default: "multipage". */
456
459
  routing?: StaticSiteRouting;
457
460
  /** Security-header configuration. */
458
461
  security?: StaticSiteSecurityConfig;
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Resources created:
9
9
  * - Private S3 bucket + BucketDeployment (asset upload)
10
- * - CloudFront distribution (OAC, clean-URL function, security-headers policy)
10
+ * - CloudFront distribution (OAC, routing-mode viewer function, security-headers policy)
11
11
  * - ACM certificate + Route53 alias record (when `domain` is set)
12
12
  * - Contact-form Lambda + Function URL (when `forms` is set)
13
13
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Resources created:
9
9
  * - Private S3 bucket + BucketDeployment (asset upload)
10
- * - CloudFront distribution (OAC, clean-URL function, security-headers policy)
10
+ * - CloudFront distribution (OAC, routing-mode viewer function, security-headers policy)
11
11
  * - ACM certificate + Route53 alias record (when `domain` is set)
12
12
  * - Contact-form Lambda + Function URL (when `forms` is set)
13
13
  *
@@ -182,6 +182,11 @@ export class StaticSite extends Construct {
182
182
  bucket: this._bucket,
183
183
  originAccess: "oac",
184
184
  defaultRootObject: "index.html",
185
+ // The default VALUE stays "multipage" for configs that omit `routing`,
186
+ // but multipage's own semantics changed with the mode split: the old
187
+ // hybrid fragment served trailing-slash links from `…/index.html`. A
188
+ // deployed directory-layout site that relied on that must now state
189
+ // routing: "directory" — the default cannot protect it.
185
190
  routing: this.props.routing ?? "multipage",
186
191
  cachePolicy: "CACHING_OPTIMIZED",
187
192
  priceClass: "PriceClass_100",
@@ -38,6 +38,8 @@ export interface AccessGateConfig {
38
38
  username: string;
39
39
  password: string;
40
40
  }
41
+ /** See {@link CloudFrontDistributionProps.uriRewrite}. */
42
+ export type UriRewriteMode = "html" | "directory-index";
41
43
  export interface CloudFrontDistributionProps {
42
44
  /**
43
45
  * App this CDN fronts. Keys the cross-stack export a `Domain` resolves via
@@ -67,9 +69,22 @@ export interface CloudFrontDistributionProps {
67
69
  accessGate?: false | AccessGateConfig;
68
70
  /** Object served for a bare `/` request (static sites: "index.html"). */
69
71
  defaultRootObject?: string;
70
- /** Rewrites extensionless URLs to `.html`/`/index.html` via a CloudFront
71
- * Function. Dynamic `/api/` paths are carved out so POSTs are not rewritten. */
72
- cleanUrls?: boolean;
72
+ /**
73
+ * Rewrites pretty URLs onto the objects the build actually emitted, via a
74
+ * CloudFront Function. Each mode serves BOTH link shapes (`/about` and
75
+ * `/about/`) from its layout's object:
76
+ *
77
+ * - `"html"` → `/about.html`: builds that emit sibling `.html` files
78
+ * (Astro `build.format: "file"`, Next.js default export).
79
+ * - `"directory-index"` → `/about/index.html`: builds that emit a
80
+ * directory per page (Astro's default `build.format: "directory"`).
81
+ *
82
+ * Dynamic `/api/` paths are carved out so POSTs are not rewritten.
83
+ * Replaces the former `cleanUrls: boolean`, whose single fragment rewrote
84
+ * trailing-slash requests to `…/index.html` and bare requests to `….html`
85
+ * — a hybrid that served neither layout completely.
86
+ */
87
+ uriRewrite?: UriRewriteMode;
73
88
  /** SPA fallback: 403/404 → `/index.html` @200 for client-side routing. */
74
89
  spaFallback?: boolean;
75
90
  /** Security-headers policy applied to the default + every additional behaviour. */
@@ -98,14 +113,14 @@ export declare class CloudFrontDistribution extends Construct {
98
113
  constructor(scope: Construct, id: string, props: CloudFrontDistributionProps);
99
114
  /**
100
115
  * Build a composable CloudFront Function for VIEWER_REQUEST.
101
- * Merges accessGate, forwardHostHeader and cleanUrls into a single function
102
- * (CloudFront allows only one function per event type).
116
+ * Merges accessGate, forwardHostHeader and uriRewrite into a single
117
+ * function (CloudFront allows only one function per event type).
103
118
  *
104
119
  * ORDER IS LOAD-BEARING. The access gate is emitted first because it is the
105
120
  * only fragment that can reject a request, and a fragment that returns early
106
121
  * ahead of it would silently un-gate whatever paths it short-circuits. The
107
- * cleanUrls fragment carves `/api/*` out of the `.html` rewriting — that
108
- * carve-out must therefore be a guard around the rewrite, never an early
122
+ * uriRewrite fragments carve `/api/*` out of the rewriting — that carve-out
123
+ * must therefore be a guard around the rewrite, never an early
109
124
  * `return request`, or the whole `/api/*` surface leaves the gate behind it.
110
125
  */
111
126
  private buildViewerRequestFunction;
@@ -4,6 +4,43 @@ import { toPascalCase } from "../../../utils/capitaliseString.js";
4
4
  import { cdnDomainExportName } from "@fjall/util";
5
5
  import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
6
6
  import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
7
+ /**
8
+ * The viewer-request rewrite for each `UriRewriteMode`, keyed on the mode so
9
+ * a new mode fails to compile until it states its rewrite.
10
+ *
11
+ * Both fragments keep the `/api/*` carve-out as a GUARD around the rewrite,
12
+ * never an early `return request` — see the ordering contract on
13
+ * `buildViewerRequestFunction`: a fragment that returns early would let every
14
+ * path it short-circuits leave later fragments behind it.
15
+ *
16
+ * `"html"`: `/about` and `/about/` both → `/about.html`. The trailing slash
17
+ * is stripped before `.html` is appended — the build emitted a FILE, so the
18
+ * old hybrid's `/about/` → `/about/index.html` named an object that does not
19
+ * exist in this layout. The root `/` becomes `/index.html` (both layouts
20
+ * emit one), so neither mode depends on `defaultRootObject` being set.
21
+ *
22
+ * `"directory-index"`: `/about` and `/about/` both → `/about/index.html`,
23
+ * matching the directory-per-page layout (the root `/` likewise becomes
24
+ * `/index.html`).
25
+ *
26
+ * Requests whose last segment carries an extension (`/assets/app.css`) pass
27
+ * through untouched in both modes, and so does any path containing a
28
+ * dot-directory (`/.well-known/apple-app-site-association` must be served
29
+ * byte-for-byte — dot-paths are literal files, never pretty URLs).
30
+ */
31
+ const URI_REWRITE_FRAGMENTS = {
32
+ html: " var uri = request.uri;" +
33
+ " if (!uri.startsWith('/api/') && uri.indexOf('/.') === -1) {" +
34
+ " if (uri === '/') { request.uri = '/index.html'; }" +
35
+ " else if (uri.endsWith('/')) { request.uri = uri.substring(0, uri.length - 1) + '.html'; }" +
36
+ " else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
37
+ " if (seg.indexOf('.') === -1) { request.uri = uri + '.html'; } } }",
38
+ "directory-index": " var uri = request.uri;" +
39
+ " if (!uri.startsWith('/api/') && uri.indexOf('/.') === -1) {" +
40
+ " if (uri.endsWith('/')) { request.uri = uri + 'index.html'; }" +
41
+ " else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
42
+ " if (seg.indexOf('.') === -1) { request.uri = uri + '/index.html'; } } }"
43
+ };
7
44
  export class CloudFrontDistribution extends Construct {
8
45
  id;
9
46
  distribution;
@@ -20,8 +57,8 @@ export class CloudFrontDistribution extends Construct {
20
57
  const defaultOrigin = this.createOrigin(props.defaultOrigin);
21
58
  const defaultCachePolicy = this.resolveCachePolicy(props.defaultCachePolicy);
22
59
  // Build composable viewer request function (accessGate + forwardHostHeader
23
- // + cleanUrls)
24
- const viewerRequestFunction = this.buildViewerRequestFunction(id, props.forwardHostHeader, props.accessGate, props.cleanUrls);
60
+ // + uriRewrite)
61
+ const viewerRequestFunction = this.buildViewerRequestFunction(id, props.forwardHostHeader, props.accessGate, props.uriRewrite);
25
62
  const functionAssociations = viewerRequestFunction
26
63
  ? [
27
64
  {
@@ -96,18 +133,18 @@ export class CloudFrontDistribution extends Construct {
96
133
  }
97
134
  /**
98
135
  * Build a composable CloudFront Function for VIEWER_REQUEST.
99
- * Merges accessGate, forwardHostHeader and cleanUrls into a single function
100
- * (CloudFront allows only one function per event type).
136
+ * Merges accessGate, forwardHostHeader and uriRewrite into a single
137
+ * function (CloudFront allows only one function per event type).
101
138
  *
102
139
  * ORDER IS LOAD-BEARING. The access gate is emitted first because it is the
103
140
  * only fragment that can reject a request, and a fragment that returns early
104
141
  * ahead of it would silently un-gate whatever paths it short-circuits. The
105
- * cleanUrls fragment carves `/api/*` out of the `.html` rewriting — that
106
- * carve-out must therefore be a guard around the rewrite, never an early
142
+ * uriRewrite fragments carve `/api/*` out of the rewriting — that carve-out
143
+ * must therefore be a guard around the rewrite, never an early
107
144
  * `return request`, or the whole `/api/*` surface leaves the gate behind it.
108
145
  */
109
- buildViewerRequestFunction(id, forwardHostHeader, accessGate, cleanUrls) {
110
- if (!accessGate && !forwardHostHeader && !cleanUrls) {
146
+ buildViewerRequestFunction(id, forwardHostHeader, accessGate, uriRewrite) {
147
+ if (!accessGate && !forwardHostHeader && uriRewrite === undefined) {
111
148
  return undefined;
112
149
  }
113
150
  const fragments = [];
@@ -125,12 +162,8 @@ export class CloudFrontDistribution extends Construct {
125
162
  if (forwardHostHeader) {
126
163
  fragments.push(" request.headers['x-forwarded-host'] = { value: request.headers.host.value };");
127
164
  }
128
- if (cleanUrls) {
129
- fragments.push(" var uri = request.uri;" +
130
- " if (!uri.startsWith('/api/')) {" +
131
- " if (uri.endsWith('/')) { request.uri = uri + 'index.html'; }" +
132
- " else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
133
- " if (seg.indexOf('.') === -1) { request.uri = uri + '.html'; } } }");
165
+ if (uriRewrite !== undefined) {
166
+ fragments.push(URI_REWRITE_FRAGMENTS[uriRewrite]);
134
167
  }
135
168
  const functionBody = "function handler(event) { var request = event.request;" +
136
169
  fragments.join("") +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "22.0.0",
3
+ "version": "23.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^22.0.0",
84
- "@fjall/util": "^22.0.0",
83
+ "@fjall/generator": "^23.0.0",
84
+ "@fjall/util": "^23.0.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {