From c2b36673fd410e2a85591a40ba999841b376ce10 Mon Sep 17 00:00:00 2001 From: @permadeath.com Date: Sat, 08 Aug 2026 23:29:31 +0000 Subject: [PATCH] feat: rewrite and redirect trailing slashes at the CloudFront edge Astro links without trailing slashes but builds directory output, so /posts/x had no matching S3 object and /posts/x/ would have been a second address for the same page. A viewer-request function rewrites extensionless URIs to their index.html object and 301s trailing-slash URIs to the canonical no-slash address. Co-Authored-By: Claude Fable 5 --- infra/main.tf | 16 ++++++++++++++++ infra/viewer-request.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 63 insertion(s)(+), 0 deletion(s)(-) diff --git a/infra/main.tf b/infra/main.tf --- a/infra/main.tf +++ b/infra/main.tf @@ -78,6 +78,17 @@ signing_protocol = "sigv4" } +# Astro emits directory output but links without trailing slashes (see +# web/astro.config.mjs): viewer-request.js rewrites /posts/x to the +# posts/x/index.html object and redirects /posts/x/ to the canonical +# /posts/x. +resource "aws_cloudfront_function" "trailing_slash" { + name = "substandard-trailing-slash" + runtime = "cloudfront-js-2.0" + publish = true + code = file("${path.module}/viewer-request.js") +} + resource "aws_cloudfront_distribution" "substandard" { enabled = true aliases = [local.domain] @@ -99,6 +110,11 @@ cached_methods = ["GET", "HEAD"] # AWS managed CachingOptimized policy cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.trailing_slash.arn + } } restrictions { diff --git a/infra/viewer-request.js b/infra/viewer-request.js new file mode 100644 --- /dev/null +++ b/infra/viewer-request.js @@ -0,0 +1,47 @@ +// Viewer-request function for the substandard.blog distribution +// (aws_cloudfront_function.trailing_slash in main.tf). +// +// Astro builds with trailingSlash "never" but emits directory output: the +// page at /posts/x is the S3 object posts/x/index.html. Extensionless URIs +// are rewritten to that object, and trailing-slash URIs are 301-redirected +// to the no-slash address — standard.site document records claim the +// no-slash address as canonical, so /posts/x/ and /posts/x must stay one +// page. + +function handler(event) { + var request = event.request; + var uri = request.uri; + + if (uri === '/') { + // default_root_object serves index.html at the distribution root. + return request; + } + + if (uri.endsWith('/')) { + var canonical = uri.replace(/\/+$/, '') || '/'; + return { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { location: { value: canonical + search(request.querystring) } }, + }; + } + + if (!uri.split('/').pop().includes('.')) { + request.uri = uri + '/index.html'; + } + + return request; +} + +// Rebuild the query string for the redirect Location header; the event +// carries it as an object, not on the uri. +function search(querystring) { + var parts = []; + Object.keys(querystring).forEach(function (key) { + var values = querystring[key].multiValue || [querystring[key]]; + values.forEach(function (entry) { + parts.push(entry.value === '' ? key : key + '=' + entry.value); + }); + }); + return parts.length ? '?' + parts.join('&') : ''; +} -- tangled.sh