astro-sst
Version:
Adapter that allows Astro to deploy your site to AWS utilizing SST.
182 lines (181 loc) • 9.29 kB
JavaScript
import ASTRO_PACKAGE from "astro/package.json" with { type: "json" };
import { copyFile, readFile, writeFile } from "fs/promises";
import path, { dirname, join, relative } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const BUILD_META_FILE_NAME = "sst.buildMeta.json";
export class BuildMeta {
static setIntegrationConfig(config) {
this.integrationConfig = config;
}
static setAstroConfig(config) {
this.astroConfig = config;
}
static setRoutes(routes) {
this.routes = routes;
}
static setBuildOutput(output) {
this.buildOutput = output;
}
static async handlePrerendered404InSsr() {
// Note about handling 404 pages. Here is Astro's behavior:
// - when static/prerendered, Astro builds a /404.html file in the client build
// output dir
// - when SSR, Astro server handles /404 route
//
// We could handle the /404.html with CloudFront's custom error response feature,
// but that would not work when routing the Astro app on a base path. This is the case
// when sharing the same CloudFront distribution with an API or another site. It
// does not make sense to have a custom error response shared across all.
// ie. redirecting to Astro's 404 page when API returns a 404 response does not
// make sense.
//
// So here is what we do when a request comes in for an invalid route ie. /garbage:
// - Case 1: static (no server)
// => CF function S3 look up will fail, and uri will be rewritten to /404.html
// - Case 2: prerendered (has server)
// => CF function S3 look up will fail, and request will be sent to the server
// function. Server fails to serve /garbage, and cannot find the route. Server
// tries to serve /404, and cannot find the route. Server finally serves the
// 404.html file manually bundled into it.
// => that's why we copy 404.html into the server output
// - Case 3: SSR (has server)
// => In CF function S3 look up will fail, and request is sent to the server
// function. Server fails to serve /garbage, and cannot find the route. Server
// tries to serve /404, and cannot find the route. Server finally serves the
// 404.html file manually bundled into it.
if (this.buildOutput !== "server")
return;
try {
await copyFile(path.join(fileURLToPath(this.astroConfig.build.client), "404.html"), path.join(fileURLToPath(this.astroConfig.build.server), "404.html"));
}
catch (error) {
// Silently ignore errors, as the 404 page might not exist
}
}
/**
* The main function that exports all build metadata to a JSON file.
* Processes all routes from the build result, handles trailing slash redirects,
* adds asset routes, and writes the complete configuration to the output directory.
*/
static async writeToFile() {
const rootDir = fileURLToPath(this.astroConfig.root);
const clientOutputPath = fileURLToPath(this.astroConfig.build.client);
const serverOutputPath = fileURLToPath(this.astroConfig.build.server);
const metadataPath = join(relative(rootDir, fileURLToPath(this.astroConfig.outDir)), BUILD_META_FILE_NAME);
// Process all routes and create any necessary redirects for trailing slashes
const routes = this.routes.flatMap((route) => {
const trailingSlash = this.astroConfig.trailingSlash;
const isStatic = this.buildOutput === "static";
const routeSet = [
{
route: route.pattern + (trailingSlash === "always" ? "/" : ""),
type: route.type,
// regex for matching request URL
// ie. "[fruit]/about.astro"- pattern is pattern: /^/([^/]+?)/about/?$/ fbanana/about") is "true"
pattern: route.pattern.toString(),
prerender: route.type !== "redirect"
? isStatic || route.isPrerendered
: undefined,
// determine the redirect path based on different possible configurations
redirectPath: route.redirectRoute
? BuildMeta.buildRedirectPath(route.redirectRoute)
: typeof route.redirect === "string"
? route.redirect
: route.redirect?.destination,
// get status code if available
redirectStatus: typeof route.redirect === "object"
? route.redirect.status
: undefined,
},
];
// Add trailing slash redirects for pages (except the root page)
if (route.type === "page" && route.pattern !== "/") {
if (trailingSlash === "never") {
// Add redirect from "/route/" to "/route" at the start
routeSet.unshift({
route: route.pattern + "/",
type: "redirect",
pattern: route.pattern.toString().replace(/\$\/$/, "\\/$/"),
redirectPath: BuildMeta.buildRedirectPath(route),
});
}
else if (trailingSlash === "always") {
// Add redirect from "/route" to "/route/" at the end
routeSet.push({
route: route.pattern.replace(/\/$/, ""),
type: "redirect",
pattern: route.pattern.toString().replace(/\\\/\$\/$/, "$/"),
redirectPath: BuildMeta.buildRedirectPath(route),
});
}
}
return routeSet;
});
// Add a catch-all route for static assets in static output mode
if (this.buildOutput === "static") {
// Find the index of the last asset route to insert after it
const lastAssetIndex = routes.reduce((acc, { route }, index) => route.startsWith(`/${this.astroConfig.build.assets}`) ? index : acc, -1);
// Insert catch-all route for assets
routes.splice(lastAssetIndex + 1, 0, {
route: `/${this.astroConfig.build.assets}/[...slug]`,
type: "endpoint",
pattern: `/^\\/${this.astroConfig.build.assets}\\/.*?\\/?$/`,
prerender: true,
});
}
// Write the build metadata to the output file
await writeFile(metadataPath, JSON.stringify({
astroVersion: ASTRO_PACKAGE.version,
pluginVersion: await this.getAdapterVersion(),
base: this.astroConfig.base,
// Extract domain name from site URL if available
domainName: typeof this.astroConfig.site === "string" &&
this.astroConfig.site.length > 0
? new URL(this.astroConfig.site).hostname
: undefined,
responseMode: this.integrationConfig.responseMode,
outputMode: this.buildOutput,
pageResolution: this.astroConfig.build.format,
trailingSlash: this.astroConfig.trailingSlash,
serverBuildOutputFile: join(relative(rootDir, serverOutputPath), this.astroConfig.build.serverEntry),
clientBuildOutputDir: (() => {
const p = relative(rootDir, clientOutputPath);
// Fix for Astro's behavior with static output mode.
// Astro sets client build paths as if the site was configured for server deployment
// even when it's actually static. We need to adjust the path to be correct.
//
return this.buildOutput === "static" ? join(p, "../") : p;
})(),
clientBuildVersionedSubDir: this.astroConfig.build.assets,
routes,
}));
}
/**
* Creates a redirect path string from route segments.
* Handles dynamic segments by replacing them with ${n} placeholders.
* Takes trailing slash configuration into account.
*
* Example: For "/blog/[id]" route with "always" trailing slash, returns "/blog/${1}/"
*/
static buildRedirectPath({ segments }) {
const trailingSlash = this.astroConfig.trailingSlash;
let i = 0;
return ("/" +
segments
.map((segment) => segment
.map((part) => (part.dynamic ? `\${${++i}}` : part.content))
.join(""))
.join("/") +
(trailingSlash === "always" ? "/" : "")).replace(/\/+/g, "/"); // Clean up any duplicate slashes
}
static async getAdapterVersion() {
// get the astro-sst version
try {
return (JSON.parse(await readFile(join(__dirname, "..", "..", "package.json"), "utf-8")).version ?? "unknown");
}
catch (error) {
throw new Error("Failed to get adapter version", { cause: error });
}
}
}