// The built tree is served by CloudFront in front of a private S3 bucket, // which answers by key alone — so what makes "/features/" a page is // infra/site/viewer-request.js, not the bucket. This test runs that exact // file (read from infra/, never a copy of it) over the addresses the built // pages actually link to, and checks each one lands on a file that exists. // // It is here rather than in infra/ because the thing being checked is a pair: // astro.config.mjs's `trailingSlash` and `build.format` decide what the tree // looks like, and the function decides what a request for it resolves to. // Changing either alone is what breaks a live site while every other test // still passes. import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { createContext, runInContext } from "node:vm"; import { distDir, repoRoot, requireDist, walkHtmlFiles, loadDocument, fail, report, } from "./lib.mjs"; requireDist(); // The deployed source, evaluated as CloudFront evaluates it: a bare script // declaring `handler`, with no module wrapper of its own. const functionPath = join(repoRoot, "infra", "site", "viewer-request.js"); const context = createContext({}); runInContext(readFileSync(functionPath, "utf8"), context, { filename: functionPath }); const handler = runInContext("handler", context); function exists(path) { try { return statSync(path).isFile(); } catch { return false; } } // One request through the function. Returns either the object key it rewrote // to, or the address it redirected to. function serve(uri) { const result = handler({ request: { uri, querystring: {} } }); if (result.statusCode) { return { redirect: result.headers.location.value, status: result.statusCode }; } // The function hands "/" straight back; the distribution's // default_root_object is what turns it into the home page. return { key: result.uri === "/" ? "/index.html" : result.uri }; } // What a reader gets for one address: at most one redirect, then a key. More // than one hop is a loop waiting to happen, so a second redirect is a failure // rather than something to follow. function resolveAddress(uri) { let hop = serve(uri); if (hop.redirect) { const second = serve(hop.redirect); if (second.redirect) { return { error: `${uri} redirects twice (${hop.redirect}, then ${second.redirect})` }; } return { key: second.key, via: hop.redirect }; } return { key: hop.key }; } function checkAddress(uri, source) { const resolved = resolveAddress(uri); if (resolved.error) { fail(`${source}: ${resolved.error}`); return; } if (!exists(join(distDir, resolved.key))) { fail(`${source}: ${uri} resolves to ${resolved.key}, which is not in the built tree`); } return resolved; } // Every page in the tree, asked for at its own address. `dist/features/ // index.html` is the page at `/features/`, and the distribution's // default_root_object covers `/` itself. const pages = walkHtmlFiles().filter((path) => !path.startsWith(`${distDir}/api/`)); for (const page of pages) { const relative = page.slice(distDir.length + 1); if (relative === "index.html") continue; if (!relative.endsWith("/index.html")) { // 404.html is reached by the distribution's custom_error_response, which // names the key directly rather than going through this function. continue; } const address = `/${relative.slice(0, -"index.html".length)}`; const resolved = checkAddress(address, "built page"); if (resolved && resolved.via) { fail(`built page: ${address} should be served, not redirected to ${resolved.via}`); } // The same page without its trailing slash is the address a hand-typed or // pasted link takes; it has to arrive at the canonical one. const withoutSlash = address.slice(0, -1); const bare = serve(withoutSlash); if (bare.redirect !== address || bare.status !== 301) { fail(`built page: ${withoutSlash} should 301 to ${address}, got ${JSON.stringify(bare)}`); } } // Every internal address the pages link to, and every asset they pull in. // The links are the site's own claims about what it serves; an asset that // the function rewrote to a directory index would be a stylesheet arriving // as HTML. for (const page of pages) { const document = loadDocument(page); const source = page.slice(distDir.length + 1); for (const a of document.querySelectorAll("a[href]")) { const href = a.getAttribute("href"); if (!href || !href.startsWith("/")) continue; checkAddress(href.split("#")[0].split("?")[0], source); } for (const el of document.querySelectorAll("img[src], script[src], link[href]")) { const attr = el.tagName === "LINK" ? "href" : "src"; const value = el.getAttribute(attr); if (!value || !value.startsWith("/")) continue; const resolved = checkAddress(value, source); if (resolved && resolved.via) { fail(`${source}: asset ${value} is being redirected to ${resolved.via}`); } } } // The rustdoc mount, whose own pages are named files and pass through // untouched — the directory index is the one thing about it this function // decides. checkAddress("/api/", "the /api/ mount"); report("check-serving");