Skip to content

Commit 857d525

Browse files
committed
fix(hosting): invalidate compute-origin HTML on deploy to clear stale-chunk 403
Compute-backed deploys (Next/OpenNext SSG/ISR, Nuxt routeRules swr/isr, Astro SSR) can edge-cache HTML with a long s-maxage that the shared SSR cache policy honors. After a redeploy that HTML still references the previous build's hashed chunks; the router rewrites those asset requests to the current build prefix, which no longer contains them -> 403 (stale-HTML / pruned-chunk). Issue a CloudFront invalidation on every compute deploy, scoped on hasCompute (NOT the framework -- the L3 is framework-blind), gated to run AFTER the KvKeys atomic cutover so it only flushes the previous build's cached pages (the new build's builds/<id>/... objects were never cached, so /* is effectively free). Pure-static deploys serve no-cache HTML from S3 and create no invalidation resource. manifest.invalidationPaths overrides the default (or [] to opt out). Verified live: Next /about redeploy issued invalidation CallerReference blocks-<buildId> path /*, created post-cutover; Nuxt + Astro (both compute) also create the DeployInvalidation resource.
1 parent b9782f7 commit 857d525

6 files changed

Lines changed: 262 additions & 8 deletions

File tree

packages/hosting/src/adapters/nextjs.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ void describe('nextjsAdapter', () => {
105105

106106
// Static assets directory
107107
assert.strictEqual(manifest.staticAssets.directory, assetsDir);
108+
109+
// The adapter does NOT hardcode invalidationPaths — deploy-time
110+
// invalidation is scoped on `hasCompute` in the L3 (it is not
111+
// Next-specific; Nuxt swr/isr and Astro SSR hit the same stale-HTML 403).
112+
// The field is reserved for adapter overrides/opt-out only.
113+
assert.strictEqual(manifest.invalidationPaths, undefined);
108114
});
109115

110116
void it('detects ISR cache when not disabled', () => {

packages/hosting/src/adapters/nextjs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ export const nextjsAdapter = (
237237
{ prefix: '_next/data/', days: 30 },
238238
];
239239

240+
// NOTE: deploy-time CloudFront invalidation (to clear edge-cached SSG/ISR
241+
// HTML that references the previous build's chunk hashes → 403) is NOT set
242+
// here. It is not Next-specific — any compute-backed deploy can edge-cache
243+
// stale HTML (Nuxt `routeRules` swr/isr, Astro SSR) — so the L3 issues the
244+
// invalidation for ANY deploy with a compute origin (gated on `hasCompute`,
245+
// default `['/*']`). Adapters set `manifest.invalidationPaths` only to
246+
// override/opt out. See the field doc in manifest/types.ts.
247+
240248
return manifest;
241249
};
242250

packages/hosting/src/constructs/cdn_construct.test.ts

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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, /createInvalidation/);
3216+
assert.match(blob, /blocks-test-ssr-1/); // CallerReference keyed on buildId
3217+
assert.match(blob, /\\"Items\\":\[\\"\/\*\\"\]/);
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, /\\"Items\\":\[\\"\/blog\/\*\\"\]/);
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+
});

packages/hosting/src/constructs/cdn_construct.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ import {
6262
routeSpecificity,
6363
} from './kvs_router.js';
6464
import { KvKeys } from './kv_keys.js';
65+
import {
66+
AwsCustomResource,
67+
AwsCustomResourcePolicy,
68+
PhysicalResourceId,
69+
} from 'aws-cdk-lib/custom-resources';
6570

6671
// ---- Constants ----
6772

@@ -1052,6 +1057,68 @@ export class CdnConstruct extends Construct {
10521057
});
10531058
}
10541059

1060+
// ---- Deploy-time CloudFront invalidation (adapter-declared) ----
1061+
// Scoped on `hasCompute`, NOT on the framework. Any compute-backed deploy
1062+
// can edge-cache HTML that goes stale after a redeploy: the shared SSR
1063+
// cache policy honors the origin's `Cache-Control`, so HTML served by the
1064+
// compute origin with a long `s-maxage` is edge-cached keyed on the viewer
1065+
// path (not the build-id prefix) and ends up referencing the previous
1066+
// build's hashed assets → 403. This is not Next-specific — it also hits
1067+
// Nuxt `routeRules` swr/isr and Astro SSR (see the field doc in
1068+
// manifest/types.ts). Pure-static deploys (no compute) serve HTML from S3
1069+
// with `no-cache`, so they need no invalidation and get none.
1070+
//
1071+
// Default: `['/*']` for any deploy with compute; nothing for pure-static.
1072+
// `manifest.invalidationPaths` OVERRIDES the default — set explicit
1073+
// patterns to narrow it, or `[]` to opt out.
1074+
//
1075+
// Ordering: the invalidation MUST run after the KvKeys atomic cutover
1076+
// (which is itself asset-gated via addBuildAssetDependency). That way it
1077+
// only flushes the PREVIOUS build's cached pages; the new build's
1078+
// `builds/<id>/...` objects were never cached, so `/*` is effectively free
1079+
// and cannot evict the not-yet-requested new prefix. `wait: false` is
1080+
// implied — AwsCustomResource does not poll the invalidation to completion,
1081+
// matching SST's non-blocking model (a brief propagation window where a
1082+
// first-time/cookieless visitor may still receive stale HTML is accepted).
1083+
const invalidationPaths = manifest.invalidationPaths ??
1084+
(hasCompute ? ['/*'] : []);
1085+
if (invalidationPaths.length > 0) {
1086+
const invalidation = new AwsCustomResource(this, 'DeployInvalidation', {
1087+
// CallerReference keyed on buildId so a NEW deploy (new buildId) issues
1088+
// a fresh invalidation, while an unchanged buildId is a no-op (CFN sees
1089+
// identical props → no Update → no duplicate invalidation cost).
1090+
onUpdate: {
1091+
service: 'CloudFront',
1092+
action: 'createInvalidation',
1093+
parameters: {
1094+
DistributionId: this.distribution.distributionId,
1095+
InvalidationBatch: {
1096+
CallerReference: `blocks-${buildId}`,
1097+
Paths: {
1098+
Quantity: invalidationPaths.length,
1099+
Items: invalidationPaths,
1100+
},
1101+
},
1102+
},
1103+
physicalResourceId: PhysicalResourceId.of(
1104+
`invalidation-${buildId}`,
1105+
),
1106+
},
1107+
// createInvalidation is not resource-scopable in IAM; the action must
1108+
// be granted on `*` (the distribution ARN is not a valid resource for
1109+
// this action). Scope the policy to the single action.
1110+
policy: AwsCustomResourcePolicy.fromStatements([
1111+
new iam.PolicyStatement({
1112+
actions: ['cloudfront:CreateInvalidation'],
1113+
resources: ['*'],
1114+
}),
1115+
]),
1116+
});
1117+
// Gate AFTER the atomic KVS cutover so we only flush the previous build's
1118+
// pages (see ordering note above).
1119+
invalidation.node.addDependency(kvKeys.resource);
1120+
}
1121+
10551122
// ---- Self-enforcing atomic-deploy guard ----
10561123
// The KVS router rewrites every static request to `/builds/<buildId>/...`
10571124
// where `buildId` comes from the KVS route table. The KvKeys custom

packages/hosting/src/manifest/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export const deployManifestSchema = z
197197
}),
198198
)
199199
.optional(),
200+
invalidationPaths: z.array(z.string().min(1)).optional(),
200201
basePath: z
201202
.string()
202203
.regex(

packages/hosting/src/manifest/types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,47 @@ export type DeployManifest = {
131131
/** Build ID for atomic deployments. */
132132
buildId?: string;
133133

134+
/**
135+
* OVERRIDE for the CloudFront invalidation paths issued on every deploy,
136+
* AFTER the atomic KVS cutover. Most adapters leave this UNSET and let the
137+
* L3 pick the default (see below); set it only to customize the paths or to
138+
* opt out (`[]`).
139+
*
140+
* Why an invalidation is needed at all — the stale-HTML→403 problem:
141+
*
142+
* Atomic deploys write every object under a brand-new immutable
143+
* `builds/<buildId>/` prefix, so there is nothing stale to invalidate, and
144+
* HTML served from S3 carries `no-cache` (the 3-tier Cache-Control split in
145+
* hosting_construct). So a PURE-STATIC deploy (no compute) needs no
146+
* invalidation — Astro/Nuxt static pages prerender to S3 and propagate on
147+
* the next request.
148+
*
149+
* But ANY compute-backed deploy can edge-cache HTML that goes stale. The
150+
* shared SSR cache policy honors the origin's `Cache-Control`, so HTML
151+
* served by the compute origin with a long `s-maxage` is edge-cached keyed
152+
* on the VIEWER path (`/about`), NOT the build-id prefix. After a redeploy
153+
* that HTML still references the previous build's hashed assets
154+
* (`_next/static/*`, `_nuxt/*`, `_astro/*`), and the router rewrites those
155+
* asset requests to the CURRENT build prefix — which no longer contains
156+
* them → 403. This is NOT Next-specific:
157+
* - Next/OpenNext: SSG/ISR HTML from the SSR Lambda → `s-maxage=31536000`.
158+
* - Nuxt/Nitro: `routeRules` `cache.maxAge` / `swr` / `isr` emit
159+
* `s-maxage=N` on compute-origin HTML (see nitro adapter).
160+
* - Astro SSR: pages that set `Cache-Control` on the response.
161+
* The common trigger is "a compute origin serving cacheable HTML that
162+
* references build-prefixed hashed assets" — so the L3 scopes the
163+
* invalidation on `hasCompute`, not on the framework.
164+
*
165+
* Default (when this field is unset): the L3 issues `['/*']` for any deploy
166+
* with a compute origin, and nothing for pure-static deploys. The
167+
* invalidation is gated after the KvKeys cutover, so it only flushes the
168+
* previous build's cached pages — the new build's `builds/<id>/...` objects
169+
* were never cached, making `/*` effectively free. A compute app that emits
170+
* only `no-store` HTML still gets the invalidation, but it is a harmless
171+
* no-op. Set `[]` to opt out; set explicit patterns to narrow it.
172+
*/
173+
invalidationPaths?: string[];
174+
134175
/**
135176
* Adapter-supplied S3 lifecycle rules for orphaned per-build data
136177
* that lives outside the build prefix.

0 commit comments

Comments
 (0)