Skip to content

Commit 1f1287e

Browse files
authored
fix(hosting): never serve stale placeholder config.json after deploy (#174)
1 parent b3063a3 commit 1f1287e

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/core": patch
3+
---
4+
5+
Never serve a stale placeholder `config.json` after a deploy. The build-time placeholder (`{"_placeholder":true}`) was uploaded with the 1-year mutable cache-control (`public, s-maxage=31536000, max-age=0, must-revalidate`), and the post-deploy CloudFront invalidation targeted `/.blocks-sandbox/*` — which never matches the real cache key, because the skew-protection viewer-request function rewrites the URI to `/builds/<buildId>/.blocks-sandbox/config.json` before the cache lookup. An edge that cached the placeholder during the deploy window could keep serving it for up to a year, making `getApiUrl()` throw and every client API call fail. The placeholder is now registered as a no-cache path (`no-cache, no-store, must-revalidate`) so edges never cache it long-term, and the config deployment now also invalidates the post-rewrite key `/builds/<buildId>/.blocks-sandbox/*`.

packages/core/src/hosting.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,4 +1507,111 @@ describe('Hosting', () => {
15071507
);
15081508
});
15091509
});
1510+
1511+
describe('config.json stale-placeholder guard (#173)', () => {
1512+
// Helper: pull every BucketDeployment custom resource's Properties.
1513+
const bucketDeployments = (stack: Stack) => {
1514+
const crs = Template.fromStack(stack).findResources(
1515+
'Custom::CDKBucketDeployment',
1516+
);
1517+
return Object.values(crs).map(
1518+
(cr) => (cr as { Properties: Record<string, unknown> }).Properties,
1519+
);
1520+
};
1521+
1522+
it('uploads the placeholder config.json with a no-cache directive, never the 1-year mutable cache-control', () => {
1523+
// The build-time placeholder (`{_placeholder:true}`) is written into the
1524+
// static dir. If it inherits the mutable asset tier's
1525+
// `s-maxage=31536000` and an edge caches it during the deploy window, the
1526+
// edge serves the placeholder for up to a year — breaking every client
1527+
// API call. It must instead be uploaded as a no-cache path so the edge
1528+
// never caches it long-term.
1529+
createSpaBuildOutput(tmpDir);
1530+
const app = new App();
1531+
const stack = new Stack(app, 'PlaceholderCacheStack');
1532+
new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1533+
1534+
const deployments = bucketDeployments(stack);
1535+
const cc = (p: Record<string, unknown>) =>
1536+
(p.SystemMetadata as Record<string, string> | undefined)?.['cache-control'];
1537+
const includes = (p: Record<string, unknown>) =>
1538+
(p.Include as string[] | undefined) ?? [];
1539+
const excludes = (p: Record<string, unknown>) =>
1540+
(p.Exclude as string[] | undefined) ?? [];
1541+
1542+
// A deployment must upload `.blocks-sandbox/config.json` with the
1543+
// no-cache directive (this is the placeholder upload).
1544+
const noCacheDeploy = deployments.find(
1545+
(p) =>
1546+
includes(p).includes('.blocks-sandbox/config.json') &&
1547+
cc(p) === 'no-cache, no-store, must-revalidate',
1548+
);
1549+
assert.ok(
1550+
noCacheDeploy,
1551+
'placeholder .blocks-sandbox/config.json must be uploaded with ' +
1552+
'"no-cache, no-store, must-revalidate"',
1553+
);
1554+
1555+
// No deployment may cover the placeholder with the 1-year mutable
1556+
// cache-control: the mutable/other tier must EXCLUDE it.
1557+
const leaks = deployments.filter((p) => {
1558+
const directive = cc(p);
1559+
return (
1560+
typeof directive === 'string' &&
1561+
directive.includes('s-maxage=31536000') &&
1562+
!excludes(p).includes('.blocks-sandbox/config.json')
1563+
);
1564+
});
1565+
assert.strictEqual(
1566+
leaks.length,
1567+
0,
1568+
'no mutable-tier deployment may apply s-maxage=31536000 to ' +
1569+
'.blocks-sandbox/config.json',
1570+
);
1571+
});
1572+
1573+
it('registers the placeholder as a no-cache path even for a static-only site (no api)', () => {
1574+
// The placeholder is always written (step 5), so its no-cache
1575+
// registration must not depend on `props.api`. This guards against a
1576+
// regression that moves the registration inside an `if (props.api)`
1577+
// block, which would reopen the stale-placeholder window for
1578+
// static-only sites.
1579+
createSpaBuildOutput(tmpDir);
1580+
const app = new App();
1581+
const stack = new Stack(app, 'StaticOnlyPlaceholderStack');
1582+
new Hosting(stack, 'Hosting', { root: tmpDir });
1583+
1584+
Template.fromStack(stack).hasResourceProperties(
1585+
'Custom::CDKBucketDeployment',
1586+
Match.objectLike({
1587+
Include: Match.arrayWith(['.blocks-sandbox/config.json']),
1588+
SystemMetadata: Match.objectLike({
1589+
'cache-control': 'no-cache, no-store, must-revalidate',
1590+
}),
1591+
}),
1592+
);
1593+
});
1594+
1595+
it('invalidates the post-rewrite cache key (/builds/<id>/.blocks-sandbox/*)', () => {
1596+
// The viewer-request skew-protection function rewrites the URI to
1597+
// `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the cache
1598+
// lookup, so the real edge cache key lives under `/builds/<id>/`.
1599+
// Invalidating only `/.blocks-sandbox/*` never matches it.
1600+
createSpaBuildOutput(tmpDir);
1601+
const app = new App();
1602+
const stack = new Stack(app, 'InvalidationPathStack');
1603+
new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1604+
1605+
// Assert via Template + Match so a CDK property rename fails loudly here
1606+
// rather than silently skipping a structural-heuristic lookup.
1607+
Template.fromStack(stack).hasResourceProperties(
1608+
'Custom::CDKBucketDeployment',
1609+
Match.objectLike({
1610+
DistributionPaths: Match.arrayWith([
1611+
Match.stringLikeRegexp('^/builds/.+/\\.blocks-sandbox/\\*$'),
1612+
]),
1613+
}),
1614+
);
1615+
});
1616+
});
15101617
});

packages/core/src/hosting.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
FrameworkType,
3636
} from '@aws-blocks/hosting';
3737
import { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
38+
import { BLOCKS_SANDBOX_DIR } from './common/constants.js';
3839
import { registerConfig } from './cdk/config-registry.js';
3940
import { getRegisteredRoutes } from './raw-route.js';
4041

@@ -509,6 +510,19 @@ export class Hosting extends Construct {
509510
JSON.stringify({ _placeholder: true }),
510511
);
511512

513+
// ── 5a. Mark config.json as a no-cache path ─────────────────────
514+
// config.json is a fixed-name, mutable runtime-config file: it must
515+
// NOT inherit the content-hashed mutable-asset cache-control
516+
// (`s-maxage=31536000`) applied to `/assets/<hash>.js`. Registering it
517+
// as a no-cache path uploads the build-time placeholder with
518+
// `no-cache, no-store, must-revalidate` so an edge never caches it
519+
// long-term; the real config is deployed in step 8 with `max-age=60`.
520+
const configNoCachePath = `${BLOCKS_SANDBOX_DIR}/config.json`;
521+
const existingNoCachePaths = manifest.staticAssets.noCachePaths ?? [];
522+
manifest.staticAssets.noCachePaths = existingNoCachePaths.includes(configNoCachePath)
523+
? existingNoCachePaths
524+
: [...existingNoCachePaths, configNoCachePath];
525+
512526
// ── 5b. Inject static route for .blocks-sandbox config ──────────
513527
// Insert a static route for /.blocks-sandbox/* so CloudFront
514528
// serves config.json from S3 instead of routing to compute.
@@ -606,7 +620,18 @@ export class Hosting extends Construct {
606620
destinationKeyPrefix: `builds/${buildId}/.blocks-sandbox`,
607621
prune: false,
608622
distribution: hosting.distribution,
609-
distributionPaths: ['/.blocks-sandbox/*'],
623+
// The skew-protection viewer-request CloudFront function rewrites the
624+
// URI to `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the
625+
// cache lookup, so the real edge cache key lives under `/builds/<id>/`.
626+
// Invalidating only `/.blocks-sandbox/*` never matches that key and is
627+
// a no-op for config.json. Invalidate the post-rewrite key too. (The
628+
// primary guard against staleness is step 5a's no-cache placeholder;
629+
// this is defense-in-depth so a post-deploy invalidation actually
630+
// clears any edge entry at its real key.)
631+
distributionPaths: [
632+
`/builds/${buildId}/.blocks-sandbox/*`,
633+
'/.blocks-sandbox/*',
634+
],
610635
cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
611636
});
612637

0 commit comments

Comments
 (0)