Skip to content

Commit f06437f

Browse files
gmoonclaude
andcommitted
Pre-render /reader route and fix 404 error responses
- Add /reader to prerender routes with meta tags, noscript fallback, and JSON-LD schema so S3 serves it directly instead of relying on SPA fallback - Change CloudFront error responses: both 403 and 404 from S3 now serve /404.html with HTTP 404 (previously 403 served /index.html with 200, masking genuine 404s as the homepage) - Update setup-cloudfront.sh to auto-detect distribution ID and apply all config (function, headers policy, error responses) in a single update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a3d5a22 commit f06437f

2 files changed

Lines changed: 113 additions & 63 deletions

File tree

infra/setup-cloudfront.sh

Lines changed: 82 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,48 @@
22
set -euo pipefail
33

44
# ===========================================================================
5-
# One-time CloudFront setup script for forkzero.ai
6-
# Creates/updates: URL rewrite function + security response headers policy
5+
# CloudFront setup script for forkzero.ai
6+
#
7+
# Creates/updates and associates:
8+
# 1. CloudFront Function for URL rewriting + trailing-slash redirects
9+
# 2. Response Headers Policy (security headers)
10+
# 3. Custom error responses (404 → /404.html, 403 → SPA fallback)
711
#
812
# Prerequisites:
913
# - AWS CLI v2 configured with appropriate credentials
1014
# - jq installed
1115
#
1216
# Usage:
13-
# chmod +x infra/setup-cloudfront.sh
14-
# ./infra/setup-cloudfront.sh
15-
#
16-
# After running, manually associate the function and headers policy with
17-
# your CloudFront distribution's default cache behavior.
17+
# ./infra/setup-cloudfront.sh # auto-detect distribution
18+
# DIST_ID=E1QZI... ./infra/setup-cloudfront.sh # explicit distribution ID
1819
# ===========================================================================
1920

2021
FUNCTION_NAME="forkzero-url-rewrite"
2122
FUNCTION_FILE="$(dirname "$0")/cloudfront-url-rewrite.js"
2223
HEADERS_POLICY_NAME="forkzero-security-headers"
24+
DOMAIN="forkzero.ai"
25+
26+
# ---- Resolve distribution ID ----
27+
28+
if [ -z "${DIST_ID:-}" ]; then
29+
echo "==> Looking up CloudFront distribution for ${DOMAIN}..."
30+
DIST_ID=$(aws cloudfront list-distributions \
31+
--query "DistributionList.Items[?Aliases.Items[?@=='${DOMAIN}']].Id | [0]" \
32+
--output text)
33+
if [ -z "${DIST_ID}" ] || [ "${DIST_ID}" = "None" ]; then
34+
echo "ERROR: No CloudFront distribution found for ${DOMAIN}" >&2
35+
exit 1
36+
fi
37+
fi
38+
echo " Distribution: ${DIST_ID}"
2339

2440
# ---- CloudFront Function: URL rewrite ----
41+
echo ""
2542
echo "==> Creating/updating CloudFront Function: ${FUNCTION_NAME}"
2643

27-
# Check if the function already exists
28-
EXISTING=$(aws cloudfront list-functions --query "FunctionList.Items[?Name=='${FUNCTION_NAME}'].Name" --output text 2>/dev/null || true)
44+
EXISTING=$(aws cloudfront list-functions \
45+
--query "FunctionList.Items[?Name=='${FUNCTION_NAME}'].Name" \
46+
--output text 2>/dev/null || true)
2947

3048
if [ -z "${EXISTING}" ]; then
3149
echo " Creating new function..."
@@ -49,7 +67,8 @@ aws cloudfront publish-function \
4967
--name "${FUNCTION_NAME}" \
5068
--if-match "${ETAG}"
5169

52-
FUNCTION_ARN=$(aws cloudfront describe-function --name "${FUNCTION_NAME}" --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
70+
FUNCTION_ARN=$(aws cloudfront describe-function --name "${FUNCTION_NAME}" \
71+
--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
5372
echo " Published: ${FUNCTION_ARN}"
5473

5574
# ---- Response Headers Policy: security headers ----
@@ -98,7 +117,6 @@ POLICY_CONFIG=$(cat <<'POLICY_JSON'
98117
POLICY_JSON
99118
)
100119

101-
# Check if the policy already exists
102120
EXISTING_POLICY_ID=$(aws cloudfront list-response-headers-policies \
103121
--query "ResponseHeadersPolicyList.Items[?ResponseHeadersPolicy.ResponseHeadersPolicyConfig.Name=='${HEADERS_POLICY_NAME}'].ResponseHeadersPolicy.Id" \
104122
--output text 2>/dev/null || true)
@@ -121,58 +139,59 @@ fi
121139

122140
echo " Policy ID: ${POLICY_ID}"
123141

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"
142+
# ---- Associate everything with the distribution ----
132143
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'
144+
echo "==> Updating distribution ${DIST_ID}..."
145+
146+
# Fetch current config
147+
TMPDIR=$(mktemp -d)
148+
trap 'rm -rf "${TMPDIR}"' EXIT
149+
150+
aws cloudfront get-distribution-config --id "${DIST_ID}" > "${TMPDIR}/config.json"
151+
DIST_ETAG=$(jq -r '.ETag' "${TMPDIR}/config.json")
152+
153+
# Build updated config with jq:
154+
# - Set function association on default cache behavior
155+
# - Set response headers policy on default cache behavior
156+
# - Set custom error responses (403 → SPA fallback, 404 → /404.html)
157+
jq --arg fn_arn "${FUNCTION_ARN}" --arg policy_id "${POLICY_ID}" '
158+
.DistributionConfig
159+
| .DefaultCacheBehavior.FunctionAssociations = {
160+
"Quantity": 1,
161+
"Items": [{
162+
"FunctionARN": $fn_arn,
163+
"EventType": "viewer-request"
164+
}]
165+
}
166+
| .DefaultCacheBehavior.ResponseHeadersPolicyId = $policy_id
167+
| .CustomErrorResponses = {
168+
"Quantity": 2,
169+
"Items": [
170+
{
171+
"ErrorCode": 403,
172+
"ResponsePagePath": "/404.html",
173+
"ResponseCode": "404",
174+
"ErrorCachingMinTTL": 300
175+
},
176+
{
177+
"ErrorCode": 404,
178+
"ResponsePagePath": "/404.html",
179+
"ResponseCode": "404",
180+
"ErrorCachingMinTTL": 300
181+
}
182+
]
183+
}
184+
' "${TMPDIR}/config.json" > "${TMPDIR}/update.json"
185+
186+
aws cloudfront update-distribution \
187+
--id "${DIST_ID}" \
188+
--distribution-config "file://${TMPDIR}/update.json" \
189+
--if-match "${DIST_ETAG}" \
190+
--query 'Distribution.Status' \
191+
--output text
152192

153-
# ---- Instructions ----
154-
echo ""
155-
echo "==========================================================================="
156-
echo "NEXT STEPS — Associate with your CloudFront distribution:"
157-
echo ""
158-
echo "1. Open the CloudFront console → Distributions → your distribution"
159-
echo "2. Edit the Default Cache Behavior:"
160-
echo " a. Function associations → Viewer request → CloudFront Functions"
161-
echo " → Select '${FUNCTION_NAME}'"
162-
echo " b. Response headers policy → Select '${HEADERS_POLICY_NAME}'"
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"
170193
echo ""
171-
echo "Or via CLI (replace DIST_ID):"
172-
echo " aws cloudfront get-distribution-config --id DIST_ID > dist-config.json"
173-
echo " # Edit DefaultCacheBehavior to add:"
174-
echo " # FunctionAssociations with ${FUNCTION_ARN}"
175-
echo " # ResponseHeadersPolicyId: ${POLICY_ID}"
176-
echo " # Edit CustomErrorResponses to add 404 → /404.html mapping"
177-
echo " aws cloudfront update-distribution --id DIST_ID --distribution-config file://dist-config.json --if-match ETAG"
178-
echo "==========================================================================="
194+
echo "==> Done. Distribution is deploying — changes propagate in ~2-5 minutes."
195+
echo " Function: ${FUNCTION_ARN}"
196+
echo " Headers Policy: ${POLICY_ID}"
197+
echo " Error Responses: 403→/404.html (404), 404→/404.html (404)"

scripts/prerender.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ const routes: RouteMeta[] = [
4242
description: 'Install Lattice and start building a knowledge-coordinated codebase in under five minutes.',
4343
canonical: 'https://forkzero.ai/getting-started',
4444
},
45+
{
46+
path: '/reader',
47+
title: 'Lattice Dashboard — Forkzero',
48+
description:
49+
'Interactive viewer for Lattice knowledge graphs. Explore sources, theses, requirements, and implementations.',
50+
canonical: 'https://forkzero.ai/reader',
51+
},
4552
{
4653
path: '/privacy',
4754
title: 'Privacy Policy — Forkzero',
@@ -209,6 +216,12 @@ function buildNoscript(route: RouteMeta): string | null {
209216
return wrap(`<h1>Blog</h1>${listHtml}`)
210217
}
211218

219+
if (route.path === '/reader') {
220+
return wrap(
221+
`<h1>Lattice Dashboard</h1><p>Interactive viewer for Lattice knowledge graphs. Requires JavaScript to run.</p><p><a href="/">Back to homepage</a></p>`,
222+
)
223+
}
224+
212225
if (route.path === '/privacy') {
213226
return wrap(
214227
[
@@ -362,6 +375,24 @@ function buildJsonLd(route: RouteMeta): string {
362375
})
363376
}
364377

378+
if (route.path === '/reader') {
379+
schemas.push({
380+
'@context': 'https://schema.org',
381+
'@type': 'WebPage',
382+
name: 'Lattice Dashboard — Forkzero',
383+
description: route.description,
384+
url: route.canonical,
385+
})
386+
schemas.push({
387+
'@context': 'https://schema.org',
388+
'@type': 'BreadcrumbList',
389+
itemListElement: [
390+
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://forkzero.ai/' },
391+
{ '@type': 'ListItem', position: 2, name: 'Dashboard', item: 'https://forkzero.ai/reader' },
392+
],
393+
})
394+
}
395+
365396
if (route.path === '/privacy') {
366397
schemas.push({
367398
'@context': 'https://schema.org',

0 commit comments

Comments
 (0)