@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 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 are added automatically
150
- * by CDK when CDN resources reference ALB or S3 bucket resources.
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: (app: App, scope: Construct) => T): T;
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 are added automatically
220
- * by CDK when CDN resources reference ALB or S3 bucket resources.
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 computeStack = this.getDefaultComputeStack();
618
- const pattern = fn(this, computeStack.getStack());
619
- computeStack.addConstruct(pattern);
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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "commonjs",
3
+ "private": true
4
+ }
@@ -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 = "payload";
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 { type IPayloadProps, type IPatternProps, type AnyPattern } from "./interfaces/pattern.js";
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): (app: App, scope: Construct) => Payload;
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): (app: App, scope: Construct) => AnyPattern;
77
+ static build(id: string, props: IPatternProps): PatternFactoryFn;
64
78
  }
65
79
  export { Payload };
66
- export type { IPayloadProps, IPatternProps, AnyPattern, IPayload, PayloadDatabaseConfig, PayloadComputeConfig, PayloadCdnConfig } from "./interfaces/pattern.js";
67
- export { isPayloadPattern, isPattern } from "./interfaces/pattern.js";
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
- return (app, scope) => {
47
- const { type } = props;
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 = type;
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";