canopycms-cdk 0.0.67 → 0.0.68-int.100
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/README.md +3 -1
- package/dist/constructs/asset-support.d.ts +80 -81
- package/dist/constructs/asset-support.js +173 -195
- package/dist/constructs/cms-distribution.d.ts +7 -7
- package/dist/constructs/cms-distribution.js +19 -67
- package/dist/constructs/cms-service.d.ts +55 -8
- package/dist/constructs/cms-service.js +67 -15
- package/dist/constructs/editor-routing.d.ts +131 -0
- package/dist/constructs/editor-routing.js +313 -0
- package/dist/index.d.ts +1 -0
- package/lambda/asset-transform/dist/handler.js +1021 -459
- package/package.json +4 -4
- package/worker/dist/index.js +1689 -792
|
@@ -42,6 +42,12 @@ const PREFIXES = {
|
|
|
42
42
|
public: 'assets',
|
|
43
43
|
transform: 'assets/t',
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* The tag on every object the transform Lambda writes, copied as literals for
|
|
47
|
+
* the same reason as `PREFIXES`. Source of truth is `LAZY_TRANSFORM_TAG` in
|
|
48
|
+
* `packages/canopycms/src/assets/materialize.ts`.
|
|
49
|
+
*/
|
|
50
|
+
const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
|
|
45
51
|
/**
|
|
46
52
|
* CORS preflight cache duration for presigned-POST uploads from the editor.
|
|
47
53
|
* Used for the bucket's own CORS rule and, when `uploadBehavior` is on, for
|
|
@@ -73,55 +79,39 @@ function toFunctionLiteral(values) {
|
|
|
73
79
|
}
|
|
74
80
|
/** Matches packages/canopycms/src/assets/store-s3.ts's `DEFAULT_MAX_UPLOAD_BYTES`. */
|
|
75
81
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
76
|
-
|
|
82
|
+
/** Fits a full-size transform at `MAX_INPUT_PIXELS`; see that constant in canopycms. */
|
|
83
|
+
const TRANSFORM_LAMBDA_MEMORY_MB = 2048;
|
|
77
84
|
const TRANSFORM_LAMBDA_TIMEOUT = Duration.seconds(30);
|
|
78
85
|
/**
|
|
79
|
-
* Concurrency cap on the transform Lambda.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* Width and quality are allowlisted precisely to bound how many cache keys one
|
|
84
|
-
* asset can have, but `crop` is a 4-decimal float rect (~10^16 values), so a
|
|
85
|
-
* scripted loop of unique crops is an unbounded stream of guaranteed
|
|
86
|
-
* CloudFront+S3 misses, each a sharp transform on a 1536MB Lambda plus a
|
|
87
|
-
* permanently stored S3 object.
|
|
88
|
-
*
|
|
89
|
-
* A reservation is a CAP carved from the account's concurrency pool, not
|
|
90
|
-
* pre-warmed capacity, so it costs nothing when idle (that is
|
|
91
|
-
* `provisionedConcurrentExecutions`, which this is not). 10 mirrors the CMS
|
|
92
|
-
* Lambda's own reservation: genuine demand is first-render misses only, since
|
|
93
|
-
* every already-generated derivative is served by the S3 primary origin without
|
|
94
|
-
* invoking this function at all.
|
|
86
|
+
* Concurrency cap on the lazy transform Lambda. `hash32` is public and `crop`
|
|
87
|
+
* allows ~10^16 rects per asset, so a loop of unique crops is an unbounded
|
|
88
|
+
* stream of misses, each a sharp run plus a stored object. A reservation is a
|
|
89
|
+
* cap, not provisioned capacity, so it costs nothing idle.
|
|
95
90
|
*/
|
|
96
91
|
const TRANSFORM_LAMBDA_RESERVED_CONCURRENCY = 10;
|
|
97
92
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `asset-originals/` and are never touched by this rule. Without it the bucket
|
|
102
|
-
* keeps every derivative forever, including every object minted by the crop
|
|
103
|
-
* amplifier above.
|
|
104
|
-
*
|
|
105
|
-
* S3 lifecycle is prefix+age based; there is no "expire if not recently read"
|
|
106
|
-
* mode. Expiry is self-healing anyway: the next request for an expired
|
|
107
|
-
* derivative misses S3, fails over to the transform Lambda, regenerates and
|
|
108
|
-
* re-stores it. 180 days is long enough that only genuinely cold or abusive
|
|
109
|
-
* objects age out.
|
|
93
|
+
* Lazy mode's expiry for the Lambda's tagged `assets/t/` outputs, which bounds
|
|
94
|
+
* how long the crop loop above keeps what it stores. The next request
|
|
95
|
+
* regenerates an expired object while the Lambda runs.
|
|
110
96
|
*/
|
|
111
97
|
const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
|
|
112
98
|
/**
|
|
113
|
-
* `/assets/t
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* ANY nonzero min TTL caches that redirect regardless of the origin's
|
|
118
|
-
* `no-store`, and re-serving it while CloudFront's hit for the canonical key
|
|
119
|
-
* still 403s/404s off S3 falls back to the Lambda again - a self-sustaining
|
|
120
|
-
* redirect loop. `minTtl: 0` honors the origin's own `Cache-Control`
|
|
121
|
-
* immediately; `maxTtl`/`defaultTtl` stay generous so the normal case (an
|
|
122
|
-
* immutable 200 with a real `max-age`) still caches well.
|
|
99
|
+
* Lazy mode's `/assets/t/*` cache TTLs. The Lambda answers an oversized output
|
|
100
|
+
* with a `no-store` 302 to the canonical key, and any nonzero min TTL caches
|
|
101
|
+
* that redirect regardless, looping it back to the Lambda while the key still
|
|
102
|
+
* misses. `minTtl: 0` honors the origin's own `Cache-Control`.
|
|
123
103
|
*/
|
|
124
104
|
const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
|
|
105
|
+
/** Props that configure only the transform Lambda, refused without `lazyPublicTransforms`. */
|
|
106
|
+
const LAZY_ONLY_PROPS = [
|
|
107
|
+
'transformOutputRetention',
|
|
108
|
+
'transformReservedConcurrency',
|
|
109
|
+
'transformRole',
|
|
110
|
+
'transformLogGroupName',
|
|
111
|
+
'transformLogRetention',
|
|
112
|
+
];
|
|
113
|
+
/** S3 statuses that send a read to `replicaBucket`. A miss (403) never does. */
|
|
114
|
+
const REPLICA_FAILOVER_STATUS_CODES = [500, 502, 503, 504];
|
|
125
115
|
const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
|
|
126
116
|
const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
|
|
127
117
|
/**
|
|
@@ -457,15 +447,8 @@ function buildUploadBehavior(scope, options, label) {
|
|
|
457
447
|
* ```
|
|
458
448
|
*
|
|
459
449
|
* Separate from `AssetSupport.uploadBehavior()` because the upload route needs
|
|
460
|
-
* the bucket and nothing else
|
|
461
|
-
*
|
|
462
|
-
* Those grants never reach the BUCKET policy: `Grant.addToPrincipalOrResource`
|
|
463
|
-
* stops after the identity statement for a same-account grantee, and
|
|
464
|
-
* `addToResourcePolicy` on a bucket CDK does not own is a no-op - an owned
|
|
465
|
-
* bucket, one imported by name, and one imported by ARN in another account all
|
|
466
|
-
* emit zero `AWS::S3::BucketPolicy`, so a cross-account adopter writes the
|
|
467
|
-
* resource half on the bucket's own side. Prefer the method when you already
|
|
468
|
-
* have an `AssetSupport`; both funnel into the same builder and cannot drift.
|
|
450
|
+
* the bucket and nothing else. Prefer the method when you already have an
|
|
451
|
+
* `AssetSupport`; both funnel into the same builder and cannot drift.
|
|
469
452
|
*
|
|
470
453
|
* No bucket CORS rule is written and none is needed: the edge supplies
|
|
471
454
|
* `Access-Control-Allow-Origin`, and S3's ACCEPTANCE of a presigned POST is
|
|
@@ -494,12 +477,12 @@ export function assetUploadBehavior(scope, options) {
|
|
|
494
477
|
* Wires:
|
|
495
478
|
*
|
|
496
479
|
* - The bucket's asset-prefix lifecycle rule + CORS (standalone mode only).
|
|
497
|
-
* - The
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* OAC-locked Function URL
|
|
480
|
+
* - The public read path: `/assets/*` and `/assets/t/*` served from S3 (plus
|
|
481
|
+
* `replicaBucket` on 5xx). It computes nothing; derivatives come from
|
|
482
|
+
* `canopycms materialize-assets`.
|
|
483
|
+
* - With `lazyPublicTransforms`, the transform Lambda
|
|
484
|
+
* (`../../lambda/asset-transform/handler.ts`, built by `build:lambda`), its
|
|
485
|
+
* log group and its OAC-locked Function URL, behind `/assets/t/*`.
|
|
503
486
|
* - `attachTo(distribution)`, which attaches the two CloudFront behaviors a
|
|
504
487
|
* consuming distribution needs in the only safe order (see its doc comment
|
|
505
488
|
* for why the order is the whole point); `assetBehaviors()` is the escape
|
|
@@ -514,15 +497,33 @@ export function assetUploadBehavior(scope, options) {
|
|
|
514
497
|
export class AssetSupport extends Construct {
|
|
515
498
|
constructor(scope, id, props) {
|
|
516
499
|
super(scope, id);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
500
|
+
const lazy = props.lazyPublicTransforms ?? false;
|
|
501
|
+
if (!lazy) {
|
|
502
|
+
// A retention silently ignored here is the dangerous case: materialized derivatives are
|
|
503
|
+
// kept forever, so the adopter would believe in an expiry that does not exist.
|
|
504
|
+
const lambdaOnly = LAZY_ONLY_PROPS.filter((name) => props[name] !== undefined);
|
|
505
|
+
if (lambdaOnly.length > 0) {
|
|
506
|
+
throw new Error(`AssetSupport: ${lambdaOnly.join(', ')} only appl${lambdaOnly.length === 1 ? 'ies' : 'y'} ` +
|
|
507
|
+
'with `lazyPublicTransforms: true`. Without it there is no transform Lambda and ' +
|
|
508
|
+
'materialized derivatives under assets/t/ are kept forever. Remove the prop(s), or ' +
|
|
509
|
+
'set `lazyPublicTransforms: true` if you mean to compute derivatives on request.');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else if (props.bucket && props.transformOutputRetention === undefined) {
|
|
513
|
+
throw new Error('AssetSupport: `lazyPublicTransforms` on a BYO `bucket` needs an explicit ' +
|
|
514
|
+
'`transformOutputRetention`. This construct cannot write lifecycle rules on a bucket ' +
|
|
515
|
+
'it did not create, and anonymous callers can mint objects under assets/t/ in lazy ' +
|
|
516
|
+
'mode. Add an expiry rule for the assets/t/ prefix and the canopy-transform=lazy tag ' +
|
|
517
|
+
'(on a versioned bucket, also expiring noncurrent versions) to your bucket and pass ' +
|
|
518
|
+
'its duration as `transformOutputRetention`, or drop ' +
|
|
519
|
+
'`lazyPublicTransforms` and run `canopycms materialize-assets` in your release ' +
|
|
520
|
+
'pipeline instead.');
|
|
521
|
+
}
|
|
522
|
+
// Fail closed: a bundle without the marker has no linux/arm64 sharp and
|
|
523
|
+
// throws at cold start on the first image request, far from the cause.
|
|
524
|
+
// `pnpm test` leaves exactly such a fixture on disk.
|
|
525
|
+
if (lazy &&
|
|
526
|
+
(props.requireDeployableBundle ?? true) &&
|
|
526
527
|
!existsSync(path.join(transformAssetDir, DEPLOYABLE_MARKER))) {
|
|
527
528
|
throw new Error(`The transform Lambda code asset at ${transformAssetDir} carries no ${DEPLOYABLE_MARKER} ` +
|
|
528
529
|
'marker, so it was not built with its native sharp binary and must not be deployed. ' +
|
|
@@ -568,23 +569,30 @@ export class AssetSupport extends Construct {
|
|
|
568
569
|
versioned: props.versioned ?? false,
|
|
569
570
|
removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN,
|
|
570
571
|
autoDeleteObjects: props.autoDeleteObjects ?? false,
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
572
|
+
// Everything else is content-addressed and kept forever, materialized
|
|
573
|
+
// derivatives included; only the Lambda's tagged outputs expire. On a
|
|
574
|
+
// versioned bucket an expiry only adds a delete marker, so each rule
|
|
575
|
+
// also expires noncurrent versions or it would free nothing.
|
|
575
576
|
lifecycleRules: [
|
|
576
577
|
{
|
|
577
578
|
id: 'expire-asset-staging',
|
|
578
579
|
enabled: true,
|
|
579
580
|
prefix: `${PREFIXES.staging}/`,
|
|
580
581
|
expiration: Duration.days(1),
|
|
582
|
+
noncurrentVersionExpiration: Duration.days(1),
|
|
581
583
|
},
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
584
|
+
...(lazy
|
|
585
|
+
? [
|
|
586
|
+
{
|
|
587
|
+
id: 'expire-transform-outputs',
|
|
588
|
+
enabled: true,
|
|
589
|
+
prefix: `${PREFIXES.transform}/`,
|
|
590
|
+
tagFilters: { [LAZY_TRANSFORM_TAG.key]: LAZY_TRANSFORM_TAG.value },
|
|
591
|
+
expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
|
|
592
|
+
noncurrentVersionExpiration: Duration.days(1),
|
|
593
|
+
},
|
|
594
|
+
]
|
|
595
|
+
: []),
|
|
588
596
|
],
|
|
589
597
|
// Omitted entirely when `editorOrigins` is absent - the upload is then
|
|
590
598
|
// going through `uploadBehavior`, which supplies ACAO at the edge, and
|
|
@@ -602,81 +610,13 @@ export class AssetSupport extends Construct {
|
|
|
602
610
|
: undefined,
|
|
603
611
|
});
|
|
604
612
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// `LogGroup` using that exact name fails `CreateLogGroup` with "already
|
|
611
|
-
// exists" and blocks every future `cdk deploy`. Same convention as
|
|
612
|
-
// `CanopyCmsService`'s log groups (cms-service.ts).
|
|
613
|
-
this.transformLogGroup = new logs.LogGroup(this, 'TransformFunctionLogs', {
|
|
614
|
-
logGroupName: props.transformLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/transform`,
|
|
615
|
-
retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
|
|
616
|
-
removalPolicy: RemovalPolicy.DESTROY,
|
|
617
|
-
});
|
|
618
|
-
// Re-attach what CDK silently drops for a caller-supplied role. MUST run
|
|
619
|
-
// for every passed role - see that function's doc comment. This function is
|
|
620
|
-
// not VPC-attached, so it takes basic execution only and must NOT be
|
|
621
|
-
// collapsed into the CMS Lambda's `vpc: true` call site.
|
|
622
|
-
if (props.transformRole) {
|
|
623
|
-
attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
|
|
613
|
+
if (lazy) {
|
|
614
|
+
const transform = this.buildTransformLambda(props);
|
|
615
|
+
this.transformFunction = transform.fn;
|
|
616
|
+
this.transformLogGroup = transform.logGroup;
|
|
617
|
+
this.transformFunctionUrl = transform.url;
|
|
624
618
|
}
|
|
625
|
-
this.
|
|
626
|
-
// Default (unset) leaves CDK to create the execution role, with its own
|
|
627
|
-
// managed policies intact. See `transformRole`'s doc comment.
|
|
628
|
-
role: props.transformRole,
|
|
629
|
-
// Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
|
|
630
|
-
// esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
|
|
631
|
-
// no Docker. `cdk synth`/`deploy` need that script run first; it is NOT
|
|
632
|
-
// run automatically here.
|
|
633
|
-
code: lambda.Code.fromAsset(transformAssetDir),
|
|
634
|
-
handler: 'handler.handler',
|
|
635
|
-
// nodejs20.x is deprecated and CDK's CloudFormation validation fails synth
|
|
636
|
-
// on it. The esbuild bundle (lambda/asset-transform/build.mjs) targets
|
|
637
|
-
// node22 to match; move the two together.
|
|
638
|
-
runtime: lambda.Runtime.NODEJS_22_X,
|
|
639
|
-
architecture: lambda.Architecture.ARM_64,
|
|
640
|
-
memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
|
|
641
|
-
timeout: TRANSFORM_LAMBDA_TIMEOUT,
|
|
642
|
-
// See the constant's doc comment: this is an anonymous, uncapped compute
|
|
643
|
-
// and storage amplifier without it.
|
|
644
|
-
reservedConcurrentExecutions: props.transformReservedConcurrency ?? TRANSFORM_LAMBDA_RESERVED_CONCURRENCY,
|
|
645
|
-
// Pass the pre-created group via `logGroup`, NOT `logRetention` (CDK
|
|
646
|
-
// throws LogRetentionLogGroupConflict/ConflictingLogPolicyOptions if
|
|
647
|
-
// both are set on the same function) - the removal policy lives on the
|
|
648
|
-
// LogGroup construct above instead.
|
|
649
|
-
logGroup: this.transformLogGroup,
|
|
650
|
-
environment: {
|
|
651
|
-
ASSET_BUCKET: this.bucket.bucketName,
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
// Explicit, scoped grant - NOT a reliance on the auto-created execution
|
|
655
|
-
// role's AWSLambdaBasicExecutionRole managed policy, which CDK attaches
|
|
656
|
-
// regardless of `logGroup` and never adjusts for it (passing `logGroup`
|
|
657
|
-
// only points the function's LoggingConfig at this group; it grants no
|
|
658
|
-
// IAM). That policy's logs:CreateLogStream/logs:PutLogEvents statement is
|
|
659
|
-
// scoped to `arn:aws:logs:*:*:log-group:/aws/lambda/*:*`, so it grants
|
|
660
|
-
// nothing for a custom-named group. Without this grantWrite, log delivery
|
|
661
|
-
// fails its permission check with no error surfaced anywhere - logs simply
|
|
662
|
-
// vanish.
|
|
663
|
-
this.transformLogGroup.grantWrite(this.transformFunction);
|
|
664
|
-
// Originals are what it transforms; `asset-meta/{hash32}.json` carries the
|
|
665
|
-
// kind/ext it must look up before it can transform anything, so meta is
|
|
666
|
-
// read too.
|
|
667
|
-
this.bucket.grantRead(this.transformFunction, `${PREFIXES.originals}/*`);
|
|
668
|
-
this.bucket.grantRead(this.transformFunction, `${PREFIXES.meta}/*`);
|
|
669
|
-
// Write access to `assets/*` covers both the public prefix and
|
|
670
|
-
// `assets/t/*` (transform outputs nest under it) in one grant.
|
|
671
|
-
this.bucket.grantPut(this.transformFunction, `${PREFIXES.public}/*`);
|
|
672
|
-
// AWS_IAM (not NONE): only reachable through CloudFront's Origin Access
|
|
673
|
-
// Control, matching CanopyCmsService/CanopyCmsDistribution's existing
|
|
674
|
-
// Function URL pattern (see cms-service.ts / cms-distribution.ts) -
|
|
675
|
-
// direct hits to the Function URL are rejected.
|
|
676
|
-
this.transformFunctionUrl = this.transformFunction.addFunctionUrl({
|
|
677
|
-
authType: lambda.FunctionUrlAuthType.AWS_IAM,
|
|
678
|
-
});
|
|
679
|
-
this.behaviors = this.buildBehaviors();
|
|
619
|
+
this.behaviors = this.buildBehaviors(props.replicaBucket);
|
|
680
620
|
// Validated eagerly, built lazily (see `upload`'s comment) - the accessor
|
|
681
621
|
// may never be called, and a bad `allowedOrigins` should not wait for it
|
|
682
622
|
// to find out. The guards themselves are shared with
|
|
@@ -735,25 +675,79 @@ export class AssetSupport extends Construct {
|
|
|
735
675
|
},
|
|
736
676
|
});
|
|
737
677
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
678
|
+
buildTransformLambda(props) {
|
|
679
|
+
// Custom-named, never `/aws/lambda/<function name>`: Lambda auto-creates that
|
|
680
|
+
// group outside CloudFormation on first invoke, after which a CDK `LogGroup`
|
|
681
|
+
// of the same name fails every deploy with "already exists". Same convention
|
|
682
|
+
// as `CanopyCmsService`'s log groups.
|
|
683
|
+
const logGroup = new logs.LogGroup(this, 'TransformFunctionLogs', {
|
|
684
|
+
logGroupName: props.transformLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/transform`,
|
|
685
|
+
retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
|
|
686
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
687
|
+
});
|
|
688
|
+
// Re-attach what CDK silently drops for a caller-supplied role. This function
|
|
689
|
+
// is not VPC-attached, so it takes basic execution only.
|
|
690
|
+
if (props.transformRole) {
|
|
691
|
+
attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
|
|
692
|
+
}
|
|
693
|
+
const fn = new lambda.Function(this, 'TransformFunction', {
|
|
694
|
+
role: props.transformRole,
|
|
695
|
+
// Built by `build:lambda` (lambda/asset-transform/build.mjs), not here.
|
|
696
|
+
code: lambda.Code.fromAsset(transformAssetDir),
|
|
697
|
+
handler: 'handler.handler',
|
|
698
|
+
// build.mjs targets node22 to match; move the two together.
|
|
699
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
700
|
+
architecture: lambda.Architecture.ARM_64,
|
|
701
|
+
memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
|
|
702
|
+
timeout: TRANSFORM_LAMBDA_TIMEOUT,
|
|
703
|
+
reservedConcurrentExecutions: props.transformReservedConcurrency ?? TRANSFORM_LAMBDA_RESERVED_CONCURRENCY,
|
|
704
|
+
// `logGroup`, not `logRetention`: CDK refuses both on one function.
|
|
705
|
+
logGroup,
|
|
706
|
+
environment: {
|
|
707
|
+
ASSET_BUCKET: this.bucket.bucketName,
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
// The managed basic-execution policy's log statement covers only
|
|
711
|
+
// `/aws/lambda/*`, so without this a custom-named group silently gets no logs.
|
|
712
|
+
logGroup.grantWrite(fn);
|
|
713
|
+
// `grantRead` includes s3:ListBucket, so an unknown hash is a 404 rather than
|
|
714
|
+
// a 403 the store throws on, and `readOriginal` can list when the meta's ext
|
|
715
|
+
// misses. `assets/*` covers `assets/t/*`, and `grantPut` includes the
|
|
716
|
+
// s3:PutObjectTagging a tagged PutObject needs.
|
|
717
|
+
this.bucket.grantRead(fn, `${PREFIXES.originals}/*`);
|
|
718
|
+
this.bucket.grantRead(fn, `${PREFIXES.meta}/*`);
|
|
719
|
+
this.bucket.grantPut(fn, `${PREFIXES.public}/*`);
|
|
720
|
+
// AWS_IAM: reachable only through CloudFront's OAC.
|
|
721
|
+
const url = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM });
|
|
722
|
+
return { fn, logGroup, url };
|
|
723
|
+
}
|
|
724
|
+
buildBehaviors(replicaBucket) {
|
|
725
|
+
// READ only, so a miss is 403: without s3:ListBucket, S3 does not reveal
|
|
726
|
+
// whether a key exists. LIST would make it 404 but lets any behavior that
|
|
727
|
+
// reaches the bucket root list it, and CDK warns on every synth.
|
|
728
|
+
const readOrigin = (bucket) => origins.S3BucketOrigin.withOriginAccessControl(bucket);
|
|
729
|
+
const primary = readOrigin(this.bucket);
|
|
730
|
+
// Materialized mode gives both behaviors this one object, so the distribution
|
|
731
|
+
// emits one origin (or one origin group), not two. Lazy mode's `/assets/t/*`
|
|
732
|
+
// group binds `primary` again, as a second origin.
|
|
733
|
+
const read = replicaBucket
|
|
734
|
+
? new origins.OriginGroup({
|
|
735
|
+
primaryOrigin: primary,
|
|
736
|
+
fallbackOrigin: readOrigin(replicaBucket),
|
|
737
|
+
fallbackStatusCodes: REPLICA_FAILOVER_STATUS_CODES,
|
|
738
|
+
})
|
|
739
|
+
: primary;
|
|
746
740
|
const assets = {
|
|
747
|
-
origin:
|
|
741
|
+
origin: read,
|
|
748
742
|
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
749
743
|
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
|
750
744
|
compress: true,
|
|
751
745
|
};
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
// response
|
|
756
|
-
//
|
|
746
|
+
if (!this.transformFunctionUrl) {
|
|
747
|
+
return { assets, assetsTransform: { ...assets } };
|
|
748
|
+
}
|
|
749
|
+
// Directives live in the path and the response varies by nothing else, so
|
|
750
|
+
// nothing enters the cache key.
|
|
757
751
|
const transformCachePolicy = new cloudfront.CachePolicy(this, 'AssetsTransformCachePolicy', {
|
|
758
752
|
minTtl: TRANSFORM_CACHE_MIN_TTL,
|
|
759
753
|
defaultTtl: TRANSFORM_CACHE_DEFAULT_TTL,
|
|
@@ -765,14 +759,13 @@ export class AssetSupport extends Construct {
|
|
|
765
759
|
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
|
|
766
760
|
});
|
|
767
761
|
const assetsTransform = {
|
|
762
|
+
// The group's one fallback slot is the Lambda, so no replica here. An
|
|
763
|
+
// OAC miss is 403; 404 is listed too for a bucket whose policy grants list.
|
|
768
764
|
origin: new origins.OriginGroup({
|
|
769
|
-
primaryOrigin:
|
|
770
|
-
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
// handling can still surface 404 in some paths, so both are
|
|
774
|
-
// configured. Confirmed working end-to-end by the sandbox spike
|
|
775
|
-
// (.claude/future-tasks/resolved/assets-media-system.md's SPIKE RESULT).
|
|
765
|
+
primaryOrigin: primary,
|
|
766
|
+
// readTimeout explicit: CloudFront's 30s default only happens to equal
|
|
767
|
+
// the Lambda's timeout, and raising one alone would 504 slow transforms.
|
|
768
|
+
fallbackOrigin: origins.FunctionUrlOrigin.withOriginAccessControl(this.transformFunctionUrl, { readTimeout: TRANSFORM_LAMBDA_TIMEOUT }),
|
|
776
769
|
fallbackStatusCodes: [403, 404],
|
|
777
770
|
}),
|
|
778
771
|
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
@@ -817,9 +810,8 @@ export class AssetSupport extends Construct {
|
|
|
817
810
|
* site's cookies and any cached basic-auth credential are live hazards
|
|
818
811
|
* there, stripped by policy and function rather than never sent at all.
|
|
819
812
|
* - NOT one shared distribution serving asset reads AND writes for every
|
|
820
|
-
* environment. That means one `AssetSupport`,
|
|
821
|
-
*
|
|
822
|
-
* every environment's asset pipeline at once. Only the upload route moves;
|
|
813
|
+
* environment. That means one `AssetSupport`, so a `canopycms-cdk` bump
|
|
814
|
+
* would move every environment's asset pipeline at once. Only the upload route moves;
|
|
823
815
|
* it depends on the bucket and nothing else, the same property that lets
|
|
824
816
|
* `assetUploadBehavior` build it from a bucket alone.
|
|
825
817
|
*/
|
|
@@ -853,17 +845,12 @@ export class AssetSupport extends Construct {
|
|
|
853
845
|
* Attach both asset behaviors to a concrete CloudFront distribution, in the
|
|
854
846
|
* only safe order.
|
|
855
847
|
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
* keep working. `'/assets/*'` also sorts BEFORE `'/assets/t/*'`
|
|
863
|
-
* lexicographically (`*` = 0x2A, `t` = 0x74), so alphabetizing the keys
|
|
864
|
-
* reproduces exactly this failure with no synth or deploy error to catch it -
|
|
865
|
-
* and `assetBehaviors()` attaches no path pattern, so nothing there stops a
|
|
866
|
-
* caller getting it wrong.
|
|
848
|
+
* CloudFront stops at the first matching pattern, and `/assets/*` also
|
|
849
|
+
* matches every `/assets/t/*` request. With `lazyPublicTransforms`, attaching
|
|
850
|
+
* it first means no miss ever reaches the transform Lambda, with no synth or
|
|
851
|
+
* deploy error; `'/assets/*'` also sorts first alphabetically. The two
|
|
852
|
+
* behaviors are identical otherwise, and the order is kept so that switching
|
|
853
|
+
* modes changes nothing else.
|
|
867
854
|
*
|
|
868
855
|
* `overrides` is merged into BOTH behaviors. The motivating case is a
|
|
869
856
|
* distribution running a viewer-request function on every behavior - tier
|
|
@@ -895,19 +882,10 @@ export class AssetSupport extends Construct {
|
|
|
895
882
|
`attachTo() call -- keep one.`);
|
|
896
883
|
}
|
|
897
884
|
new Construct(distribution, ATTACHED_MARKER_ID);
|
|
898
|
-
// Drop explicitly-`undefined` keys
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
// `cachePolicy: undefined` swaps this behavior's custom policy
|
|
903
|
-
// (TRANSFORM_CACHE_MIN_TTL = 0, which exists solely to stop the
|
|
904
|
-
// oversized-output redirect loop) for the managed CACHING_OPTIMIZED and its
|
|
905
|
-
// 1-second min TTL, and `viewerProtocolPolicy: undefined` downgrades BOTH
|
|
906
|
-
// behaviors from redirect-to-https to allow-all, serving assets over plain
|
|
907
|
-
// HTTP. Not a contrived input: `attachTo(dist, { cachePolicy:
|
|
908
|
-
// props.maybePolicy })` with the prop unset typechecks (every
|
|
909
|
-
// AddBehaviorOptions field is already optional), synthesizes and deploys
|
|
910
|
-
// clean.
|
|
885
|
+
// Drop explicitly-`undefined` keys: a spread copies them, so forwarding an
|
|
886
|
+
// unset optional prop would replace the construct's choice with CDK's
|
|
887
|
+
// default - lazy mode's minTtl-0 policy with CACHING_OPTIMIZED, and
|
|
888
|
+
// redirect-to-https with allow-all on both behaviors.
|
|
911
889
|
const definedOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined));
|
|
912
890
|
const { origin: transformOrigin, ...transformRest } = this.behaviors.assetsTransform;
|
|
913
891
|
const { origin: assetsOrigin, ...assetsRest } = this.behaviors.assets;
|
|
@@ -37,9 +37,8 @@ export interface CanopyCmsDistributionProps {
|
|
|
37
37
|
*
|
|
38
38
|
* ORDER MATTERS. CloudFront matches path patterns in the order given, so a
|
|
39
39
|
* more specific pattern must be listed before a more general one that also
|
|
40
|
-
* matches: `/assets/t/*` before `/assets/*`, or
|
|
41
|
-
*
|
|
42
|
-
* Lambda.
|
|
40
|
+
* matches: `/assets/t/*` before `/assets/*`, or lazy mode's misses never
|
|
41
|
+
* reach the transform Lambda.
|
|
43
42
|
*
|
|
44
43
|
* Keys here override this construct's own behaviors on collision, which is
|
|
45
44
|
* deliberate — the caller is more specific than the default — and an
|
|
@@ -98,17 +97,18 @@ export interface CanopyCmsDistributionProps {
|
|
|
98
97
|
/**
|
|
99
98
|
* Optional CDK construct for CanopyCMS CloudFront distribution.
|
|
100
99
|
*
|
|
101
|
-
* Use this
|
|
102
|
-
*
|
|
103
|
-
* and wire it into your own CloudFront setup.
|
|
100
|
+
* Use this when the CMS gets a domain of its own. To serve the editor from a
|
|
101
|
+
* distribution you already own, use `CanopyCmsService.attachTo` instead.
|
|
104
102
|
*
|
|
105
103
|
* Creates:
|
|
106
104
|
* - ACM certificate (DNS validated) — unless provided
|
|
107
105
|
* - CloudFront distribution with Function URL origin
|
|
108
106
|
* - Route53 A/AAAA alias records
|
|
109
|
-
* - Cache policies: no-cache
|
|
107
|
+
* - Cache policies: no-cache default behavior, cache /_next/static/*
|
|
110
108
|
* - Viewer-request CloudFront Function setting x-forwarded-host (redirect-URL
|
|
111
109
|
* derivation behind the Host-stripping OAC origin)
|
|
110
|
+
* - The editor's response headers policy (framing protection, `noindex`) on
|
|
111
|
+
* both of its own behaviors
|
|
112
112
|
* - `AssetSupport`'s `/assets/*` and `/assets/t/*` behaviors, in the required
|
|
113
113
|
* order, when the `assetSupport` prop is passed
|
|
114
114
|
*/
|