/**
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
*
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
* `
/index.html`. CloudFront forwards the viewer path to the S3 REST origin
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
* distribution 2026-09-01, before this function existed: `/about/` and
* `/definitely-not-a-page/` both returned **403 with an 111-byte
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
* `/` worked, via the distribution's default root object. That is 22 of the 23
* pages.
*
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
* `/api/intake` has no extension and no trailing slash, so the redirect branch
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
* which would lose the submission body silently. The association is per
* behaviour and `/api/*` gets none.
*
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
*
* /about/ -> rewrite to /about/index.html (the origin has that key)
* /about -> 301 to /about/ (one canonical URL per page)
*
* Anything with a file extension in its last segment is left alone —
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
*
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
* arrow functions, no `String.prototype.endsWith`, no template literals. The
* runtime supports more than this; a viewer-request function runs on every
* request to the site and is the wrong place to be clever.
*/
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
raw and `|` is routine in tracking values. Strip rather than encode — these
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
function safe(part) {
// eslint-disable-next-line no-control-regex
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
}
function handler(event) {
var request = event.request;
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
the same, because the URL Standard maps it to `/` in special schemes.
Redirect rather than rewrite, or `//about/` serves the About page at a second
URL with a 200. Only the leading run: an interior `//` is a key that does not
exist. */
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
var normalised = uri !== request.uri;
var lastSlash = uri.lastIndexOf('/');
var lastSegment = uri.substring(lastSlash + 1);
// A file, not a route.
if (lastSegment.indexOf('.') !== -1) {
if (normalised) return moved(uri, request);
return request;
}
// A directory-style route: hand the origin the key it actually holds.
if (lastSegment === '') {
if (normalised) return moved(uri, request);
request.uri = uri + 'index.html';
return request;
}
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
page has ONE address: serving it at both would put two indexable URLs on the
same content, which `docs/04` treats as its primary concern. */
return moved(uri + '/', request);
}
/**
* 301 to a path on this origin, carrying the query string. `location` is always
* built from an already-normalised path, which is what keeps it same-origin.
*/
function moved(path, request) {
var qs = '';
var names = Object.keys(request.querystring);
for (var i = 0; i < names.length; i++) {
var name = names[i];
var value = request.querystring[name];
if (value.multiValue) {
for (var j = 0; j < value.multiValue.length; j++) {
qs +=
(qs === '' ? '' : '&') +
safe(name) +
'=' +
safe(value.multiValue[j].value);
}
} else {
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
than the function guessing which the viewer meant. */
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
}
}
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: path + (qs === '' ? '' : '?' + qs) },
'cache-control': { value: 'public, max-age=0, must-revalidate' },
},
};
}