@@ -965,10 +965,12 @@ void describe('CdnConstruct', () => {
965965 ) ;
966966 } ) ;
967967
968- void it ( 'synthesizes co-located sibling pages without grouping or throwing (static-only)' , ( ) => {
969- // 24 sibling subtree pages under /docs used to overflow the cap and
970- // force a lossy `/docs/*` grouping. Now each is just a KVS row; synth
971- // succeeds with ZERO additional behaviors and no grouping.
968+ void it ( 'coalesces co-located sibling pages into one parent wildcard (static-only)' , ( ) => {
969+ // 24 sibling subtree pages under /docs synth with ZERO additional
970+ // behaviors. Because they share parent /docs and one kind (static), the
971+ // KVS builder coalesces them into a single `/docs/*` row — bounding the
972+ // per-request edge scan so a large SSG fan-out can't trip the CloudFront
973+ // Function instruction limit. All 24 still route to S3 via `/docs/*`.
972974 const stack = createStack ( ) ;
973975 const bucket = new Bucket ( stack , 'Bucket' ) ;
974976 const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
@@ -995,8 +997,8 @@ void describe('CdnConstruct', () => {
995997 ) ;
996998 const template = Template . fromStack ( stack ) ;
997999 assert . deepEqual ( additionalBehaviorPatterns ( template ) , [ ] ) ;
998- // The individual /docs/page-N/* rows are preserved in the KVS table
999- // (no lossy grouping into /docs/*) .
1000+ // The sibling /docs/page-N/* rows are coalesced into a single /docs/*
1001+ // wildcard (same kind), so the route table stays small .
10001002 const rows = routeRows (
10011003 buildKvsEntries ( {
10021004 manifest,
@@ -1006,8 +1008,8 @@ void describe('CdnConstruct', () => {
10061008 } ) ,
10071009 ) ;
10081010 const patterns = rows . map ( ( [ p ] ) => p ) ;
1009- assert . ok ( patterns . includes ( '/docs/page-0/ *' ) ) ;
1010- assert . ok ( ! patterns . includes ( '/docs/*' ) ) ;
1011+ assert . ok ( patterns . includes ( '/docs/*' ) ) ;
1012+ assert . ok ( ! patterns . includes ( '/docs/page-0/ *' ) ) ;
10111013 } ) ;
10121014
10131015 void it ( 'keeps every page on the edge under compute (no demotion to the SSR runtime)' , ( ) => {
@@ -3173,3 +3175,132 @@ void describe('CdnConstruct — sentinel-behavior guard (G21)', () => {
31733175 assert . ok ( ! guard , 'no guard function should exist without a sentinel behavior' ) ;
31743176 } ) ;
31753177} ) ;
3178+
3179+ // ================================================================
3180+ // Deploy-time CloudFront invalidation (hasCompute-scoped)
3181+ // ================================================================
3182+ //
3183+ // Any compute-backed deploy can edge-cache HTML that references the previous
3184+ // build's hashed assets and 403s after a redeploy — Next SSG/ISR, Nuxt
3185+ // routeRules swr/isr, Astro SSR. So the L3 issues `/*` for ANY deploy with a
3186+ // compute origin (default), nothing for pure-static, and honors
3187+ // `manifest.invalidationPaths` as an override/opt-out. The invalidation is an
3188+ // AwsCustomResource that synthesizes as `Custom::AWS`.
3189+ void describe ( 'CdnConstruct — deploy-time invalidation' , ( ) => {
3190+ void it ( 'creates a Custom::AWS createInvalidation by default for a compute deploy' , ( ) => {
3191+ const stack = createStack ( ) ;
3192+ const bucket = new Bucket ( stack , 'Bucket' ) ;
3193+ const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
3194+ const { fn, fnUrl } = createSsrFunction ( stack ) ;
3195+
3196+ new CdnConstruct ( stack , 'Cdn' , {
3197+ bucket,
3198+ manifest : ssrManifest , // compute, no explicit invalidationPaths
3199+ securityHeadersPolicy : policy ,
3200+ computeFunctionUrls : new Map ( [ [ 'default' , fnUrl ] ] ) ,
3201+ computeFunctions : new Map ( [ [ 'default' , fn ] ] ) ,
3202+ } ) ;
3203+
3204+ const template = Template . fromStack ( stack ) ;
3205+ template . resourceCountIs ( 'Custom::AWS' , 1 ) ;
3206+ // The Create/Update payload is an Fn::Join (the DistributionId is a CFN
3207+ // token); JSON.stringify the whole thing to assert the createInvalidation
3208+ // call, the build-id CallerReference, and the default `/*` path.
3209+ const customAws = Object . values ( template . findResources ( 'Custom::AWS' ) ) [ 0 ] as {
3210+ Properties : { Create ?: unknown ; Update ?: unknown } ;
3211+ } ;
3212+ const blob = JSON . stringify (
3213+ customAws . Properties . Update ?? customAws . Properties . Create ?? '' ,
3214+ ) ;
3215+ assert . match ( blob , / c r e a t e I n v a l i d a t i o n / ) ;
3216+ assert . match ( blob , / b l o c k s - t e s t - s s r - 1 / ) ; // CallerReference keyed on buildId
3217+ assert . match ( blob , / \\ " I t e m s \\ " : \[ \\ " \/ \* \\ " \] / ) ;
3218+ } ) ;
3219+
3220+ void it ( 'grants only cloudfront:CreateInvalidation to the invalidation resource' , ( ) => {
3221+ const stack = createStack ( ) ;
3222+ const bucket = new Bucket ( stack , 'Bucket' ) ;
3223+ const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
3224+ const { fn, fnUrl } = createSsrFunction ( stack ) ;
3225+
3226+ new CdnConstruct ( stack , 'Cdn' , {
3227+ bucket,
3228+ manifest : ssrManifest ,
3229+ securityHeadersPolicy : policy ,
3230+ computeFunctionUrls : new Map ( [ [ 'default' , fnUrl ] ] ) ,
3231+ computeFunctions : new Map ( [ [ 'default' , fn ] ] ) ,
3232+ } ) ;
3233+
3234+ const template = Template . fromStack ( stack ) ;
3235+ // The AwsCustomResource provider policy carries exactly the one action.
3236+ template . hasResourceProperties ( 'AWS::IAM::Policy' , {
3237+ PolicyDocument : Match . objectLike ( {
3238+ Statement : Match . arrayWith ( [
3239+ Match . objectLike ( {
3240+ Action : 'cloudfront:CreateInvalidation' ,
3241+ Effect : 'Allow' ,
3242+ Resource : '*' ,
3243+ } ) ,
3244+ ] ) ,
3245+ } ) ,
3246+ } ) ;
3247+ } ) ;
3248+
3249+ void it ( 'honors an explicit invalidationPaths override on a compute deploy' , ( ) => {
3250+ const stack = createStack ( ) ;
3251+ const bucket = new Bucket ( stack , 'Bucket' ) ;
3252+ const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
3253+ const { fn, fnUrl } = createSsrFunction ( stack ) ;
3254+
3255+ new CdnConstruct ( stack , 'Cdn' , {
3256+ bucket,
3257+ manifest : { ...ssrManifest , invalidationPaths : [ '/blog/*' ] } ,
3258+ securityHeadersPolicy : policy ,
3259+ computeFunctionUrls : new Map ( [ [ 'default' , fnUrl ] ] ) ,
3260+ computeFunctions : new Map ( [ [ 'default' , fn ] ] ) ,
3261+ } ) ;
3262+
3263+ const template = Template . fromStack ( stack ) ;
3264+ template . resourceCountIs ( 'Custom::AWS' , 1 ) ;
3265+ const customAws = Object . values ( template . findResources ( 'Custom::AWS' ) ) [ 0 ] as {
3266+ Properties : { Create ?: unknown ; Update ?: unknown } ;
3267+ } ;
3268+ const blob = JSON . stringify (
3269+ customAws . Properties . Update ?? customAws . Properties . Create ?? '' ,
3270+ ) ;
3271+ assert . match ( blob , / \\ " I t e m s \\ " : \[ \\ " \/ b l o g \/ \* \\ " \] / ) ;
3272+ } ) ;
3273+
3274+ void it ( 'does NOT create an invalidation for a pure-static deploy (no compute)' , ( ) => {
3275+ const stack = createStack ( ) ;
3276+ const bucket = new Bucket ( stack , 'Bucket' ) ;
3277+ const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
3278+
3279+ new CdnConstruct ( stack , 'Cdn' , {
3280+ bucket,
3281+ manifest : spaManifest , // static-only: HTML served from S3 with no-cache
3282+ securityHeadersPolicy : policy ,
3283+ } ) ;
3284+
3285+ const template = Template . fromStack ( stack ) ;
3286+ template . resourceCountIs ( 'Custom::AWS' , 0 ) ;
3287+ } ) ;
3288+
3289+ void it ( 'lets a compute deploy opt out with invalidationPaths: []' , ( ) => {
3290+ const stack = createStack ( ) ;
3291+ const bucket = new Bucket ( stack , 'Bucket' ) ;
3292+ const policy = createSecurityHeadersPolicy ( stack , 'SH' , { } ) ;
3293+ const { fn, fnUrl } = createSsrFunction ( stack ) ;
3294+
3295+ new CdnConstruct ( stack , 'Cdn' , {
3296+ bucket,
3297+ manifest : { ...ssrManifest , invalidationPaths : [ ] } ,
3298+ securityHeadersPolicy : policy ,
3299+ computeFunctionUrls : new Map ( [ [ 'default' , fnUrl ] ] ) ,
3300+ computeFunctions : new Map ( [ [ 'default' , fn ] ] ) ,
3301+ } ) ;
3302+
3303+ const template = Template . fromStack ( stack ) ;
3304+ template . resourceCountIs ( 'Custom::AWS' , 0 ) ;
3305+ } ) ;
3306+ } ) ;
0 commit comments