@fjall/components-infrastructure 2.29.0 → 2.30.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.d.ts +8 -4
- package/dist/lib/app.js +24 -6
- package/dist/lib/lambda-assets/static-site-forms/asset/index.js +175 -0
- package/dist/lib/lambda-assets/static-site-forms/asset/package.json +4 -0
- package/dist/lib/patterns/aws/cdn.d.ts +19 -2
- package/dist/lib/patterns/aws/cdn.js +13 -2
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +134 -5
- package/dist/lib/patterns/aws/interfaces/pattern.js +6 -0
- package/dist/lib/patterns/aws/pattern.d.ts +20 -5
- package/dist/lib/patterns/aws/pattern.js +12 -6
- package/dist/lib/patterns/aws/staticSite.d.ts +59 -0
- package/dist/lib/patterns/aws/staticSite.js +307 -0
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +35 -2
- package/dist/lib/resources/aws/cdn/cloudFront.js +63 -25
- package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
- package/dist/lib/resources/aws/cdn/index.js +1 -0
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.d.ts +56 -0
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.js +92 -0
- package/dist/lib/resources/aws/compute/lambda.d.ts +7 -0
- package/dist/lib/resources/aws/networking/domainCertificate.d.ts +7 -0
- package/dist/lib/resources/aws/networking/domainCertificate.js +9 -7
- package/package.json +7 -5
package/dist/lib/app.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { type ServiceRegistrationProps } from "./resources/aws/networking/servic
|
|
|
17
17
|
import { type IPrivateDnsNamespace, type IService } from "aws-cdk-lib/aws-servicediscovery";
|
|
18
18
|
import { type AnyCompute } from "./patterns/aws/compute.js";
|
|
19
19
|
import { type Storage, type StorageFactoryFn } from "./patterns/aws/storage.js";
|
|
20
|
-
import { type AnyPattern } from "./patterns/aws/pattern.js";
|
|
20
|
+
import { type AnyPattern, type PatternFactoryFn } from "./patterns/aws/pattern.js";
|
|
21
21
|
import { type BackupTier } from "./utils/backupTierMapping.js";
|
|
22
22
|
import { type ResourceInventory } from "./aspects/resourceInventory.js";
|
|
23
23
|
import { type ManifestCollector } from "./utils/manifestWriter.js";
|
|
@@ -146,8 +146,11 @@ export declare class App extends CdkApp {
|
|
|
146
146
|
/**
|
|
147
147
|
* Retrieve default CDN stack - named as `${this.name}Cdn`
|
|
148
148
|
*
|
|
149
|
-
* Depends on Network. Compute/Storage dependencies
|
|
150
|
-
* by CDK when CDN resources reference ALB or S3 bucket
|
|
149
|
+
* Depends on Network when networking is enabled. Compute/Storage dependencies
|
|
150
|
+
* are added automatically by CDK when CDN resources reference ALB or S3 bucket
|
|
151
|
+
* resources. When the app is networkless (a static site: S3 + CloudFront +
|
|
152
|
+
* forms Function URL), the Network dependency is omitted so no empty Network
|
|
153
|
+
* stack is synthesised.
|
|
151
154
|
*/
|
|
152
155
|
getDefaultCdnStack(): AwsStack;
|
|
153
156
|
/**
|
|
@@ -382,7 +385,8 @@ export declare class App extends CdkApp {
|
|
|
382
385
|
* payload.getServer().getLambdaFunction().addEnvironment("CUSTOM", "value");
|
|
383
386
|
* payload.getCdn().getDistribution().addBehavior("/custom/*", customOrigin);
|
|
384
387
|
*/
|
|
385
|
-
addPattern<T extends AnyPattern & Construct>(fn:
|
|
388
|
+
addPattern<T extends AnyPattern & Construct>(fn: PatternFactoryFn<T>): T;
|
|
389
|
+
private resolvePatternStack;
|
|
386
390
|
/**
|
|
387
391
|
* Add an additional network (VPC) to the application.
|
|
388
392
|
*
|
package/dist/lib/app.js
CHANGED
|
@@ -216,11 +216,14 @@ export class App extends CdkApp {
|
|
|
216
216
|
/**
|
|
217
217
|
* Retrieve default CDN stack - named as `${this.name}Cdn`
|
|
218
218
|
*
|
|
219
|
-
* Depends on Network. Compute/Storage dependencies
|
|
220
|
-
* by CDK when CDN resources reference ALB or S3 bucket
|
|
219
|
+
* Depends on Network when networking is enabled. Compute/Storage dependencies
|
|
220
|
+
* are added automatically by CDK when CDN resources reference ALB or S3 bucket
|
|
221
|
+
* resources. When the app is networkless (a static site: S3 + CloudFront +
|
|
222
|
+
* forms Function URL), the Network dependency is omitted so no empty Network
|
|
223
|
+
* stack is synthesised.
|
|
221
224
|
*/
|
|
222
225
|
getDefaultCdnStack() {
|
|
223
|
-
return this.getStack(`${this.stackPrefix}Cdn`, this.getDefaultNetworkStack());
|
|
226
|
+
return this.getStack(`${this.stackPrefix}Cdn`, this.networkDisabled ? undefined : this.getDefaultNetworkStack());
|
|
224
227
|
}
|
|
225
228
|
/**
|
|
226
229
|
* Retrieve default messaging stack - named as `${this.name}Messaging`
|
|
@@ -614,11 +617,23 @@ export class App extends CdkApp {
|
|
|
614
617
|
* payload.getCdn().getDistribution().addBehavior("/custom/*", customOrigin);
|
|
615
618
|
*/
|
|
616
619
|
addPattern(fn) {
|
|
617
|
-
const
|
|
618
|
-
const pattern = fn(this,
|
|
619
|
-
|
|
620
|
+
const stack = this.resolvePatternStack(fn.stackPlacement);
|
|
621
|
+
const pattern = fn(this, stack.getStack());
|
|
622
|
+
stack.addConstruct(pattern);
|
|
620
623
|
return pattern;
|
|
621
624
|
}
|
|
625
|
+
resolvePatternStack(placement) {
|
|
626
|
+
switch (placement) {
|
|
627
|
+
case "cdn":
|
|
628
|
+
return this.getDefaultCdnStack();
|
|
629
|
+
case "compute":
|
|
630
|
+
case undefined:
|
|
631
|
+
return this.getDefaultComputeStack();
|
|
632
|
+
default:
|
|
633
|
+
// Compile error on a new placement — do not let it default to compute.
|
|
634
|
+
return assertNeverPlacement(placement);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
622
637
|
/**
|
|
623
638
|
* Add an additional network (VPC) to the application.
|
|
624
639
|
*
|
|
@@ -715,4 +730,7 @@ export class App extends CdkApp {
|
|
|
715
730
|
return assembly;
|
|
716
731
|
}
|
|
717
732
|
}
|
|
733
|
+
function assertNeverPlacement(value) {
|
|
734
|
+
throw new Error(`Unhandled pattern stack placement: ${String(value)}`);
|
|
735
|
+
}
|
|
718
736
|
export default App;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-site contact-form handler (Lambda Function URL → SES).
|
|
3
|
+
*
|
|
4
|
+
* Authored directly under asset/ as CJS — the only dependency,
|
|
5
|
+
* @aws-sdk/client-sesv2, ships in the Lambda Node 22 runtime, so there is no
|
|
6
|
+
* build step. asset/package.json pins CJS resolution.
|
|
7
|
+
*
|
|
8
|
+
* Security posture (Function URL authType NONE — see staticSite.ts). The
|
|
9
|
+
* endpoint is public and unauthenticated. Be precise about what each control
|
|
10
|
+
* actually buys, because it is easy to mistake this for authentication:
|
|
11
|
+
* - The Origin/Referer check stops a browser on another site from POSTing here
|
|
12
|
+
* (it cannot forge those headers). It does NOT stop a non-browser client,
|
|
13
|
+
* which sets them freely. It is a nuisance filter, not an auth gate.
|
|
14
|
+
* - A populated honeypot field (_gotcha) marks a bot → silent 200, no send.
|
|
15
|
+
* - What actually bounds abuse: the function's reserved concurrency (capped in
|
|
16
|
+
* staticSite.ts, so a flood cannot drain account-wide Lambda concurrency),
|
|
17
|
+
* SES account send limits, and the fact that both sender and recipient are
|
|
18
|
+
* fixed at deploy time — whatever a caller sends, the mail can only ever go
|
|
19
|
+
* from noreply@<domain> to the site owner.
|
|
20
|
+
*
|
|
21
|
+
* Logging discipline: status + requestId + SES error NAME only — never the
|
|
22
|
+
* parsed body or the recipient address (both are submitter PII).
|
|
23
|
+
*/
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
const { SESv2Client, SendEmailCommand } = require("@aws-sdk/client-sesv2");
|
|
27
|
+
|
|
28
|
+
const client = new SESv2Client({});
|
|
29
|
+
|
|
30
|
+
const CONTACT_TO = process.env.CONTACT_TO;
|
|
31
|
+
const CONTACT_FROM = process.env.CONTACT_FROM;
|
|
32
|
+
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN;
|
|
33
|
+
|
|
34
|
+
const MAX_NAME = 200;
|
|
35
|
+
const MAX_EMAIL = 320;
|
|
36
|
+
const MAX_MESSAGE = 5000;
|
|
37
|
+
|
|
38
|
+
exports.handler = async (event) => {
|
|
39
|
+
const requestId = event?.requestContext?.requestId ?? "unknown";
|
|
40
|
+
const method = event?.requestContext?.http?.method ?? "";
|
|
41
|
+
|
|
42
|
+
if (method !== "POST") {
|
|
43
|
+
return respond(405, { error: "Method not allowed" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const headers = normaliseHeaders(event?.headers);
|
|
47
|
+
if (!originAllowed(headers)) {
|
|
48
|
+
log("warn", "origin rejected", requestId);
|
|
49
|
+
return respond(403, { error: "Forbidden" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let fields;
|
|
53
|
+
try {
|
|
54
|
+
fields = parseBody(event);
|
|
55
|
+
} catch {
|
|
56
|
+
log("warn", "invalid body", requestId);
|
|
57
|
+
return respond(400, { error: "Invalid request body" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Populated honeypot ⇒ bot: pretend success so it does not probe further.
|
|
61
|
+
if (typeof fields._gotcha === "string" && fields._gotcha.trim() !== "") {
|
|
62
|
+
return respond(200, { ok: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const name = String(fields.name ?? "").slice(0, MAX_NAME);
|
|
66
|
+
const email = String(fields.email ?? "").slice(0, MAX_EMAIL);
|
|
67
|
+
const message = String(fields.message ?? "").slice(0, MAX_MESSAGE);
|
|
68
|
+
|
|
69
|
+
if (message.trim() === "") {
|
|
70
|
+
return respond(400, { error: "Message is required" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await client.send(
|
|
75
|
+
new SendEmailCommand({
|
|
76
|
+
FromEmailAddress: CONTACT_FROM,
|
|
77
|
+
Destination: { ToAddresses: [CONTACT_TO] },
|
|
78
|
+
...(isValidEmail(email) && { ReplyToAddresses: [email] }),
|
|
79
|
+
Content: {
|
|
80
|
+
Simple: {
|
|
81
|
+
Subject: {
|
|
82
|
+
Data: `Contact form: ${name || "(no name)"}`.slice(0, MAX_NAME)
|
|
83
|
+
},
|
|
84
|
+
Body: { Text: { Data: buildBody(name, email, message) } }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
log("info", "sent", requestId);
|
|
90
|
+
return respond(200, { ok: true });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log("error", "ses send failed", requestId, err?.name);
|
|
93
|
+
return respond(502, { error: "Failed to send message" });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function normaliseHeaders(headers) {
|
|
98
|
+
const out = {};
|
|
99
|
+
if (headers && typeof headers === "object") {
|
|
100
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
101
|
+
out[key.toLowerCase()] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function originAllowed(headers) {
|
|
108
|
+
if (!ALLOWED_ORIGIN) return false;
|
|
109
|
+
const origin = headers.origin;
|
|
110
|
+
if (origin) return origin === ALLOWED_ORIGIN;
|
|
111
|
+
// Some clients omit Origin on same-site POSTs — fall back to the Referer's
|
|
112
|
+
// origin. Compare whole origins: a prefix test would accept a Referer of
|
|
113
|
+
// https://example.com.attacker.test/ for ALLOWED_ORIGIN https://example.com.
|
|
114
|
+
const referer = headers.referer;
|
|
115
|
+
if (referer) return originOf(referer) === ALLOWED_ORIGIN;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function originOf(url) {
|
|
120
|
+
try {
|
|
121
|
+
return new URL(url).origin;
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseBody(event) {
|
|
128
|
+
const raw =
|
|
129
|
+
event?.isBase64Encoded && typeof event.body === "string"
|
|
130
|
+
? Buffer.from(event.body, "base64").toString("utf8")
|
|
131
|
+
: (event?.body ?? "");
|
|
132
|
+
const trimmed = raw.trim();
|
|
133
|
+
if (trimmed.startsWith("{")) {
|
|
134
|
+
return JSON.parse(trimmed);
|
|
135
|
+
}
|
|
136
|
+
// application/x-www-form-urlencoded fallback
|
|
137
|
+
const params = new URLSearchParams(trimmed);
|
|
138
|
+
const fields = {};
|
|
139
|
+
for (const [key, value] of params.entries()) {
|
|
140
|
+
fields[key] = value;
|
|
141
|
+
}
|
|
142
|
+
return fields;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isValidEmail(value) {
|
|
146
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildBody(name, email, message) {
|
|
150
|
+
return [
|
|
151
|
+
`Name: ${name || "(not provided)"}`,
|
|
152
|
+
`Email: ${email || "(not provided)"}`,
|
|
153
|
+
"",
|
|
154
|
+
message
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function respond(statusCode, body) {
|
|
159
|
+
return {
|
|
160
|
+
statusCode,
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify(body)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function log(level, msg, requestId, errorName) {
|
|
167
|
+
console.log(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
level,
|
|
170
|
+
msg,
|
|
171
|
+
requestId,
|
|
172
|
+
...(errorName && { error: errorName })
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -2,10 +2,11 @@ import { type Construct } from "constructs";
|
|
|
2
2
|
import { type IBucket } from "aws-cdk-lib/aws-s3";
|
|
3
3
|
import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
4
4
|
import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
5
|
-
import { type ICachePolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
5
|
+
import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
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
10
|
import { type Storage } from "./storage.js";
|
|
10
11
|
import { type AnyCompute } from "./compute.js";
|
|
11
12
|
/**
|
|
@@ -32,7 +33,24 @@ export interface S3CdnProps extends BaseCdnProps {
|
|
|
32
33
|
originType: "s3";
|
|
33
34
|
bucket: IBucket | Storage;
|
|
34
35
|
originPath?: string;
|
|
36
|
+
/** OAI (default) or OAC (single-stack only — see cloudFront.ts). */
|
|
37
|
+
originAccess?: "oai" | "oac";
|
|
38
|
+
/** Object served for `/` (static sites: "index.html"). */
|
|
39
|
+
defaultRootObject?: string;
|
|
40
|
+
/** "multipage" → clean-URL `.html` rewriting; "spa" → 403/404 fallback to index. */
|
|
41
|
+
routing?: StaticSiteRouting;
|
|
42
|
+
/** Security-headers policy (built by the caller, e.g. SecurityHeadersPolicy). */
|
|
43
|
+
responseHeadersPolicy?: IResponseHeadersPolicy;
|
|
44
|
+
/**
|
|
45
|
+
* Origin-request policy for the default behaviour. Defaults to forwarding
|
|
46
|
+
* viewer headers, as every distribution has since before the static-site
|
|
47
|
+
* pattern. `none` suits an origin that ignores them — S3 serves objects by
|
|
48
|
+
* key — but it is opt-in: flipping the default would strip
|
|
49
|
+
* `OriginRequestPolicyId` from live distributions on their next deploy.
|
|
50
|
+
*/
|
|
51
|
+
originRequestPolicy?: "all-viewer-except-host" | "none";
|
|
35
52
|
}
|
|
53
|
+
export type { StaticSiteRouting };
|
|
36
54
|
/**
|
|
37
55
|
* ALB origin CDN props - use with Application Load Balancer
|
|
38
56
|
*/
|
|
@@ -135,4 +153,3 @@ export declare class CdnFactory {
|
|
|
135
153
|
*/
|
|
136
154
|
static build(id: string, props: ICdnProps): (_app: App, scope: Construct) => Cdn;
|
|
137
155
|
}
|
|
138
|
-
export {};
|
|
@@ -71,6 +71,15 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
71
71
|
(props.certificateArn
|
|
72
72
|
? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
|
|
73
73
|
: undefined);
|
|
74
|
+
const s3Routing = props.originType === "s3"
|
|
75
|
+
? {
|
|
76
|
+
defaultRootObject: props.defaultRootObject,
|
|
77
|
+
cleanUrls: props.routing === "multipage",
|
|
78
|
+
spaFallback: props.routing === "spa",
|
|
79
|
+
responseHeadersPolicy: props.responseHeadersPolicy,
|
|
80
|
+
originRequestPolicy: props.originRequestPolicy
|
|
81
|
+
}
|
|
82
|
+
: {};
|
|
74
83
|
return {
|
|
75
84
|
defaultOrigin,
|
|
76
85
|
defaultCachePolicy: props.cachePolicy,
|
|
@@ -83,7 +92,8 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
83
92
|
logBucket: props.logBucket,
|
|
84
93
|
priceClass: props.priceClass,
|
|
85
94
|
forwardHostHeader: props.forwardHostHeader,
|
|
86
|
-
accessGate: props.accessGate
|
|
95
|
+
accessGate: props.accessGate,
|
|
96
|
+
...s3Routing
|
|
87
97
|
};
|
|
88
98
|
}
|
|
89
99
|
/**
|
|
@@ -112,7 +122,8 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
112
122
|
return {
|
|
113
123
|
type: "s3",
|
|
114
124
|
bucket,
|
|
115
|
-
originPath: props.originPath
|
|
125
|
+
originPath: props.originPath,
|
|
126
|
+
originAccess: props.originAccess
|
|
116
127
|
};
|
|
117
128
|
}
|
|
118
129
|
case "alb": {
|
|
@@ -21,8 +21,10 @@ import { type RelationalDatabase, type DynamoDBDatabase, type ProxyConfig, type
|
|
|
21
21
|
import { type LambdaCompute } from "../compute.js";
|
|
22
22
|
import { type Storage } from "../storage.js";
|
|
23
23
|
import { type QueueMessaging } from "../messaging.js";
|
|
24
|
-
import { type Cdn, type SmartCdnBehaviour } from "../cdn.js";
|
|
24
|
+
import { type Cdn, type SmartCdnBehaviour, type StaticSiteRouting } from "../cdn.js";
|
|
25
|
+
import { type LambdaFunction } from "../../../resources/aws/compute/index.js";
|
|
25
26
|
import type { ManagedDomainExports } from "../../../utils/domainTypes.js";
|
|
27
|
+
import type { PatternType } from "@fjall/util";
|
|
26
28
|
export type { ProxyConfig, ReadReplicaConfig, CredentialsConfig, EncryptionConfig, AuroraEncryptionConfig, AuroraWriterConfig, AuroraReadersConfig, DatabaseInsightsConfig };
|
|
27
29
|
/**
|
|
28
30
|
* Full database configuration for patterns.
|
|
@@ -339,15 +341,121 @@ export interface IPayloadProps {
|
|
|
339
341
|
/** Additional environment variables for server Lambda */
|
|
340
342
|
environment?: Record<string, string>;
|
|
341
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Build configuration for a static site — the single source of truth read by
|
|
346
|
+
* both the CDK construct (`outputDir` is what BucketDeployment uploads) and the
|
|
347
|
+
* deploy-core builder (`command` runs in `source` before synth).
|
|
348
|
+
*/
|
|
349
|
+
export interface StaticSiteBuildConfig {
|
|
350
|
+
/** Build command run in `source` (e.g. "npm run build"). */
|
|
351
|
+
command: string;
|
|
352
|
+
/** Directory (relative to `source`) holding the built site (e.g. "out"). */
|
|
353
|
+
outputDir: string;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Security-header configuration for a static site.
|
|
357
|
+
*/
|
|
358
|
+
export interface StaticSiteSecurityConfig {
|
|
359
|
+
/** Ship the four safe headers (HSTS/nosniff/frame/referrer). */
|
|
360
|
+
headers?: boolean;
|
|
361
|
+
/** Opt-in Content-Security-Policy. Absent ⇒ no CSP header (a wrong policy
|
|
362
|
+
* silently breaks inline hydration, so it is never a shipped default). */
|
|
363
|
+
contentSecurityPolicy?: string;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Contact-form configuration for a static site. Requires a custom `domain`: SES
|
|
367
|
+
* sends only from a verified identity, and the site's domain is the identity
|
|
368
|
+
* this pattern verifies. The endpoint is `https://<domain>/api/contact`.
|
|
369
|
+
*
|
|
370
|
+
* `to` and `from` are separate axes. `to` is where submissions land and is
|
|
371
|
+
* unconstrained — a personal mailbox is the common case. `from` is the envelope
|
|
372
|
+
* sender and must sit at `domain`. The submitter's own address is put in
|
|
373
|
+
* Reply-To at send time, so replying from the inbox reaches them.
|
|
374
|
+
*/
|
|
375
|
+
export interface StaticSiteFormsConfig {
|
|
376
|
+
/** Where submissions are delivered. Any address — not an SES identity. */
|
|
377
|
+
to: string;
|
|
378
|
+
/** Envelope sender; must be an address at `domain`. Default `noreply@<domain>`. */
|
|
379
|
+
from?: string;
|
|
380
|
+
/**
|
|
381
|
+
* Origin allowed to POST the form — the browser CORS allow-origin and the
|
|
382
|
+
* server-side Origin gate. An exact origin (`https://example.com`, no path,
|
|
383
|
+
* no trailing slash). Default `https://<domain>`.
|
|
384
|
+
*/
|
|
385
|
+
corsOrigin?: string;
|
|
386
|
+
/**
|
|
387
|
+
* Concurrent executions the endpoint may hold. The Function URL is public and
|
|
388
|
+
* unauthenticated, so this caps both the SES bill and the account-wide Lambda
|
|
389
|
+
* concurrency a flood can consume. Default 5.
|
|
390
|
+
*/
|
|
391
|
+
maxConcurrency?: number;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* CDN configuration for a static site — advanced per-path override passthrough.
|
|
395
|
+
*/
|
|
396
|
+
export interface StaticSiteCdnConfig {
|
|
397
|
+
/** Additional CDN behaviours (per-path overrides). */
|
|
398
|
+
behaviours?: SmartCdnBehaviour[];
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Static-site pattern props.
|
|
402
|
+
*
|
|
403
|
+
* Serves a pre-built folder of files from a private S3 bucket behind CloudFront
|
|
404
|
+
* with OAC, clean URLs, security headers, an optional custom domain, and an
|
|
405
|
+
* optional contact-form endpoint (Lambda Function URL → SES, CloudFront-fronted).
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* app.addPattern(PatternFactory.build("LbexcSite", {
|
|
409
|
+
* type: "staticsite",
|
|
410
|
+
* name: "lbexc",
|
|
411
|
+
* source: "../../lbexc-static-export",
|
|
412
|
+
* build: { command: "npm run build", outputDir: "out" },
|
|
413
|
+
* routing: "multipage",
|
|
414
|
+
* security: { headers: true },
|
|
415
|
+
* domain: "lbexc.com",
|
|
416
|
+
* forms: { to: "hello@lbexc.com", corsOrigin: "https://lbexc.com" }
|
|
417
|
+
* }));
|
|
418
|
+
*/
|
|
419
|
+
export interface IStaticSiteProps {
|
|
420
|
+
/** Pattern type discriminator */
|
|
421
|
+
type: "staticsite";
|
|
422
|
+
/** Pattern name (used for resource naming) */
|
|
423
|
+
name: string;
|
|
424
|
+
/** Repo root the build runs in and assets are uploaded from. */
|
|
425
|
+
source: string;
|
|
426
|
+
/** Build command + output directory (single source of truth). */
|
|
427
|
+
build: StaticSiteBuildConfig;
|
|
428
|
+
/** "multipage" (clean-URL `.html` rewriting) or "spa". Default: "multipage". */
|
|
429
|
+
routing?: StaticSiteRouting;
|
|
430
|
+
/** Security-header configuration. */
|
|
431
|
+
security?: StaticSiteSecurityConfig;
|
|
432
|
+
/** Custom domain (same machinery as payload). Required when `forms` is set. */
|
|
433
|
+
domain?: string;
|
|
434
|
+
/** Import zone and cert from a managed domain stack instead of creating inline */
|
|
435
|
+
managedDomain?: ManagedDomainExports;
|
|
436
|
+
/** Optional contact-form endpoint (Lambda → SES). Requires `domain`. */
|
|
437
|
+
forms?: StaticSiteFormsConfig;
|
|
438
|
+
/** CDN configuration - for advanced per-path overrides. */
|
|
439
|
+
cdn?: StaticSiteCdnConfig;
|
|
440
|
+
}
|
|
342
441
|
/**
|
|
343
442
|
* Union of all pattern props.
|
|
344
443
|
* Extend this when adding new patterns (e.g., INextjsProps, IRemixProps).
|
|
345
444
|
*/
|
|
346
|
-
export type IPatternProps = IPayloadProps;
|
|
445
|
+
export type IPatternProps = IPayloadProps | IStaticSiteProps;
|
|
347
446
|
/**
|
|
348
|
-
* Pattern type discriminator.
|
|
447
|
+
* Pattern type discriminator. The vocabulary is owned by `@fjall/util` and
|
|
448
|
+
* shared with the generator, the deploy manifest and the CLI — see
|
|
449
|
+
* `PATTERN_REGISTRY` there, which is what forces a new pattern to declare its
|
|
450
|
+
* stack placement and construct id rather than inheriting whichever branch
|
|
451
|
+
* happened to be the `else`.
|
|
452
|
+
*
|
|
453
|
+
* Note the vocabulary is wider than `IPatternProps`: `nextjs` is declared in
|
|
454
|
+
* `PATTERN_REGISTRY` but has no construct, so it is deliberately absent from the
|
|
455
|
+
* union above and `PatternFactory.build` will not accept it — the omission here
|
|
456
|
+
* is the single enforcement of that gap.
|
|
349
457
|
*/
|
|
350
|
-
export type PatternType
|
|
458
|
+
export type { PatternType };
|
|
351
459
|
/**
|
|
352
460
|
* Base pattern interface.
|
|
353
461
|
* All patterns implement this interface.
|
|
@@ -391,15 +499,36 @@ export interface IPayload extends IPattern {
|
|
|
391
499
|
/** Get the CloudFront distribution */
|
|
392
500
|
getCdn(): Cdn;
|
|
393
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Static-site pattern interface.
|
|
504
|
+
* Provides access to the underlying resources for escape hatches.
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* site.getBucket().getBucket().addLifecycleRule({ ... });
|
|
508
|
+
* site.getCdn().getDistribution().addBehavior("/custom/*", customOrigin);
|
|
509
|
+
*/
|
|
510
|
+
export interface IStaticSite extends IPattern {
|
|
511
|
+
readonly patternType: "staticsite";
|
|
512
|
+
/** Get the private S3 origin bucket */
|
|
513
|
+
getBucket(): Storage;
|
|
514
|
+
/** Get the CloudFront distribution */
|
|
515
|
+
getCdn(): Cdn;
|
|
516
|
+
/** Get the contact-form Lambda (undefined when `forms` is not configured) */
|
|
517
|
+
getFormsFunction(): LambdaFunction | undefined;
|
|
518
|
+
}
|
|
394
519
|
/**
|
|
395
520
|
* Union type representing any pattern interface.
|
|
396
521
|
* Use with type guards for generic handling.
|
|
397
522
|
*/
|
|
398
|
-
export type AnyPattern = IPayload;
|
|
523
|
+
export type AnyPattern = IPayload | IStaticSite;
|
|
399
524
|
/**
|
|
400
525
|
* Type guard to check if a pattern is Payload.
|
|
401
526
|
*/
|
|
402
527
|
export declare function isPayloadPattern(pattern: IPattern): pattern is IPayload;
|
|
528
|
+
/**
|
|
529
|
+
* Type guard to check if a pattern is a static site.
|
|
530
|
+
*/
|
|
531
|
+
export declare function isStaticSitePattern(pattern: IPattern): pattern is IStaticSite;
|
|
403
532
|
/**
|
|
404
533
|
* Type guard to check if a resource is any pattern type.
|
|
405
534
|
*/
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
export function isPayloadPattern(pattern) {
|
|
21
21
|
return pattern.patternType === "payload";
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Type guard to check if a pattern is a static site.
|
|
25
|
+
*/
|
|
26
|
+
export function isStaticSitePattern(pattern) {
|
|
27
|
+
return pattern.patternType === "staticsite";
|
|
28
|
+
}
|
|
23
29
|
/**
|
|
24
30
|
* Type guard to check if a resource is any pattern type.
|
|
25
31
|
*/
|
|
@@ -31,9 +31,19 @@
|
|
|
31
31
|
* payload.getServer().getLambdaFunction().addEnvironment("CUSTOM", "value");
|
|
32
32
|
*/
|
|
33
33
|
import { type Construct } from "constructs";
|
|
34
|
+
import { type PatternStackPlacement } from "@fjall/util";
|
|
34
35
|
import type App from "../../app.js";
|
|
35
36
|
import { Payload } from "./payload.js";
|
|
36
|
-
import {
|
|
37
|
+
import { StaticSite } from "./staticSite.js";
|
|
38
|
+
import { type IPayloadProps, type IStaticSiteProps, type IPatternProps, type AnyPattern } from "./interfaces/pattern.js";
|
|
39
|
+
/**
|
|
40
|
+
* A pattern factory thunk. Carries a `stackPlacement` so `addPattern` can route
|
|
41
|
+
* the pattern node to the CDN stack (static site — no VPC or compute) rather
|
|
42
|
+
* than the compute stack (payload). Mirrors `StorageFactoryFn`.
|
|
43
|
+
*/
|
|
44
|
+
export type PatternFactoryFn<T extends AnyPattern = AnyPattern> = ((app: App, scope: Construct) => T) & {
|
|
45
|
+
stackPlacement?: PatternStackPlacement;
|
|
46
|
+
};
|
|
37
47
|
/**
|
|
38
48
|
* Factory for creating high-level infrastructure patterns.
|
|
39
49
|
*
|
|
@@ -56,12 +66,17 @@ export declare class PatternFactory {
|
|
|
56
66
|
* @param props - Payload pattern configuration
|
|
57
67
|
* @returns Factory function to create the pattern
|
|
58
68
|
*/
|
|
59
|
-
static build(id: string, props: IPayloadProps):
|
|
69
|
+
static build(id: string, props: IPayloadProps): PatternFactoryFn<Payload>;
|
|
70
|
+
/**
|
|
71
|
+
* Build a static-site pattern (private S3 + CloudFront/OAC, optional forms).
|
|
72
|
+
*/
|
|
73
|
+
static build(id: string, props: IStaticSiteProps): PatternFactoryFn<StaticSite>;
|
|
60
74
|
/**
|
|
61
75
|
* Generic build method (implementation signature).
|
|
62
76
|
*/
|
|
63
|
-
static build(id: string, props: IPatternProps):
|
|
77
|
+
static build(id: string, props: IPatternProps): PatternFactoryFn;
|
|
64
78
|
}
|
|
65
79
|
export { Payload };
|
|
66
|
-
export
|
|
67
|
-
export {
|
|
80
|
+
export { StaticSite };
|
|
81
|
+
export type { IPayloadProps, IStaticSiteProps, IPatternProps, PatternType, AnyPattern, IPayload, IStaticSite, PayloadDatabaseConfig, PayloadComputeConfig, PayloadCdnConfig, StaticSiteBuildConfig, StaticSiteSecurityConfig, StaticSiteFormsConfig, StaticSiteCdnConfig } from "./interfaces/pattern.js";
|
|
82
|
+
export { isPayloadPattern, isStaticSitePattern, isPattern } from "./interfaces/pattern.js";
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* payload.getDatabase().grantConnect(otherLambda);
|
|
31
31
|
* payload.getServer().getLambdaFunction().addEnvironment("CUSTOM", "value");
|
|
32
32
|
*/
|
|
33
|
+
import { PATTERN_REGISTRY } from "@fjall/util";
|
|
33
34
|
import { Payload } from "./payload.js";
|
|
35
|
+
import { StaticSite } from "./staticSite.js";
|
|
34
36
|
/**
|
|
35
37
|
* Factory for creating high-level infrastructure patterns.
|
|
36
38
|
*
|
|
@@ -43,21 +45,25 @@ export class PatternFactory {
|
|
|
43
45
|
* Routes to specific pattern implementations based on type discriminator.
|
|
44
46
|
*/
|
|
45
47
|
static build(id, props) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
switch (type) {
|
|
48
|
+
const fn = (app, scope) => {
|
|
49
|
+
switch (props.type) {
|
|
49
50
|
case "payload":
|
|
50
51
|
return new Payload(scope, id, app, props);
|
|
52
|
+
case "staticsite":
|
|
53
|
+
return new StaticSite(scope, id, app, props);
|
|
51
54
|
default: {
|
|
52
55
|
// Exhaustive check ensures all pattern types are handled
|
|
53
|
-
const _exhaustive =
|
|
54
|
-
throw new Error(`Unsupported pattern type: ${String(_exhaustive)}`);
|
|
56
|
+
const _exhaustive = props;
|
|
57
|
+
throw new Error(`Unsupported pattern type: ${String(_exhaustive.type)}`);
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
};
|
|
61
|
+
fn.stackPlacement = PATTERN_REGISTRY[props.type].stackPlacement;
|
|
62
|
+
return fn;
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
// Re-export types for convenience
|
|
61
66
|
export { Payload };
|
|
67
|
+
export { StaticSite };
|
|
62
68
|
// Re-export type guards
|
|
63
|
-
export { isPayloadPattern, isPattern } from "./interfaces/pattern.js";
|
|
69
|
+
export { isPayloadPattern, isStaticSitePattern, isPattern } from "./interfaces/pattern.js";
|