Skip to content

Commit f77e6a0

Browse files
gmoonclaude
andcommitted
Add trailing-slash redirects, 404 page, and CloudFront error config
- CloudFront function now returns 301 redirect for trailing-slash URLs instead of silently rewriting (fixes duplicate content issue) - Generate static 404.html at build time with proper noscript fallback and noindex meta (fixes soft 404s returning 200) - Deploy workflow uploads 404.html to S3 with appropriate caching - Updated setup-cloudfront.sh with custom error response instructions (404 → /404.html with HTTP 404 status) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6749cfc commit f77e6a0

4 files changed

Lines changed: 79 additions & 7 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ jobs:
5858
--include "*.html" \
5959
--cache-control "public, max-age=300, s-maxage=86400"
6060
61+
# 404 page: short cache like HTML
62+
aws s3 cp dist/404.html s3://forkzero-web-prod/404.html \
63+
--cache-control "public, max-age=300, s-maxage=86400"
64+
6165
# Invalidate CloudFront
6266
aws cloudfront create-invalidation \
6367
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \

infra/cloudfront-url-rewrite.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/**
2-
* CloudFront Function (viewer-request): rewrite clean URLs to /index.html.
2+
* CloudFront Function (viewer-request): rewrite clean URLs to /index.html
3+
* and redirect trailing slashes to their canonical non-trailing form.
34
*
45
* Converts paths like /blog/my-post → /blog/my-post/index.html so that
56
* S3 REST API origin resolves pre-rendered HTML files.
67
*
8+
* - Redirects trailing slashes: /blog/ → 301 → /blog
79
* - Skips URIs that already have a file extension (.js, .css, .xml, etc.)
810
* - Skips root "/" (handled by CloudFront Default Root Object)
9-
* - Non-pre-rendered routes still work: S3 returns 404 → CloudFront custom
10-
* error response serves the SPA shell (/index.html with 200).
11+
* - Non-pre-rendered routes: S3 returns 404 → CloudFront custom error
12+
* response serves /404.html with HTTP 404.
1113
*
1214
* Runtime: cloudfront-js-2.0
1315
*/
@@ -25,10 +27,19 @@ function handler(event) {
2527
return request;
2628
}
2729

28-
// Strip trailing slash, then append /index.html
30+
// Redirect trailing slash to non-trailing (301)
2931
if (uri.endsWith('/')) {
30-
uri = uri.slice(0, -1);
32+
return {
33+
statusCode: 301,
34+
statusDescription: 'Moved Permanently',
35+
headers: {
36+
location: { value: uri.slice(0, -1) },
37+
'cache-control': { value: 'public, max-age=86400' },
38+
},
39+
};
3140
}
41+
42+
// Rewrite clean URL to /index.html for S3 lookup
3243
request.uri = uri + '/index.html';
3344

3445
return request;

infra/setup-cloudfront.sh

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ fi
121121

122122
echo " Policy ID: ${POLICY_ID}"
123123

124+
# ---- Custom Error Response: proper 404 page ----
125+
echo ""
126+
echo "==> Configuring custom error response for 404s"
127+
echo ""
128+
echo "NOTE: CloudFront custom error responses must be set via the distribution"
129+
echo "config (not a standalone resource). Run the following to update it:"
130+
echo ""
131+
echo " DIST_ID=your-distribution-id"
132+
echo ""
133+
echo ' # Get current config'
134+
echo ' aws cloudfront get-distribution-config --id $DIST_ID > /tmp/cf-config.json'
135+
echo ' ETAG=$(jq -r .ETag /tmp/cf-config.json)'
136+
echo ""
137+
echo ' # Extract DistributionConfig and add custom error response'
138+
echo ' jq ".DistributionConfig.CustomErrorResponses = {'
139+
echo ' \"Quantity\": 1,'
140+
echo ' \"Items\": [{'
141+
echo ' \"ErrorCode\": 404,'
142+
echo ' \"ResponsePagePath\": \"/404.html\",'
143+
echo ' \"ResponseCode\": \"404\",'
144+
echo ' \"ErrorCachingMinTTL\": 300'
145+
echo ' }]'
146+
echo ' } | .DistributionConfig" /tmp/cf-config.json > /tmp/cf-update.json'
147+
echo ""
148+
echo ' aws cloudfront update-distribution \'
149+
echo ' --id $DIST_ID \'
150+
echo ' --distribution-config file:///tmp/cf-update.json \'
151+
echo ' --if-match $ETAG'
152+
124153
# ---- Instructions ----
125154
echo ""
126155
echo "==========================================================================="
@@ -131,12 +160,19 @@ echo "2. Edit the Default Cache Behavior:"
131160
echo " a. Function associations → Viewer request → CloudFront Functions"
132161
echo " → Select '${FUNCTION_NAME}'"
133162
echo " b. Response headers policy → Select '${HEADERS_POLICY_NAME}'"
134-
echo "3. Save and wait for deployment to complete"
163+
echo "3. Configure Custom Error Response (Error Pages tab):"
164+
echo " a. Error code: 404"
165+
echo " b. Customize error response: Yes"
166+
echo " c. Response page path: /404.html"
167+
echo " d. HTTP response code: 404"
168+
echo " e. Error caching minimum TTL: 300"
169+
echo "4. Save and wait for deployment to complete"
135170
echo ""
136-
echo "Or via CLI (replace DIST_ID and BEHAVIOR_CONFIG):"
171+
echo "Or via CLI (replace DIST_ID):"
137172
echo " aws cloudfront get-distribution-config --id DIST_ID > dist-config.json"
138173
echo " # Edit DefaultCacheBehavior to add:"
139174
echo " # FunctionAssociations with ${FUNCTION_ARN}"
140175
echo " # ResponseHeadersPolicyId: ${POLICY_ID}"
176+
echo " # Edit CustomErrorResponses to add 404 → /404.html mapping"
141177
echo " aws cloudfront update-distribution --id DIST_ID --distribution-config file://dist-config.json --if-match ETAG"
142178
echo "==========================================================================="

scripts/prerender.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ for (const route of routes) {
112112
console.log(`Pre-rendered: ${route.path}`)
113113
}
114114

115+
// --- Generate 404.html ---
116+
{
117+
let html404 = template
118+
html404 = html404.replace(/<title>.*?<\/title>/, '<title>Page not found — Forkzero</title>')
119+
html404 = html404.replace(
120+
/<meta name="description" content=".*?".*?\/?>/,
121+
'<meta name="description" content="The page you are looking for does not exist." />',
122+
)
123+
const seoTags404 = [
124+
'<meta name="robots" content="noindex" />',
125+
'<meta property="og:title" content="Page not found — Forkzero" />',
126+
'<meta property="og:type" content="website" />',
127+
'<meta property="og:site_name" content="Forkzero" />',
128+
].join('\n ')
129+
html404 = html404.replace('</head>', ` ${seoTags404}\n </head>`)
130+
const noscript404 = `<noscript><div style="max-width:800px;margin:0 auto;padding:4rem 2rem;font-family:sans-serif;text-align:center"><h1>Page not found</h1><p>The page you're looking for doesn't exist or has been moved.</p><p><a href="/">Back to homepage</a></p></div></noscript>`
131+
html404 = html404.replace('<div id="root"></div>', `<div id="root"></div>\n ${noscript404}`)
132+
writeFileSync(join(distDir, '404.html'), html404)
133+
console.log('Generated: /404.html')
134+
}
135+
115136
// --- Generate sitemap.xml ---
116137
// Note: <changefreq> and <priority> are ignored by Google and omitted per best practice.
117138
const today = new Date().toISOString().split('T')[0]

0 commit comments

Comments
 (0)