@astrojs/netlify
Version:
Deploy your site to Netlify
495 lines (491 loc) • 17.1 kB
JavaScript
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { emptyDir } from "@astrojs/internal-helpers/fs";
import { createRedirectsFromAstroRoutes, printAsRedirects } from "@astrojs/underscore-redirects";
import netlifyVitePlugin, {} from "@netlify/vite-plugin";
import { build } from "esbuild";
import { glob, globSync } from "tinyglobby";
import { copyDependenciesToFunction } from "./lib/nft.js";
const { version: packageVersion } = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8")
);
function remotePatternToRegex(pattern, logger) {
let { protocol, hostname, port, pathname } = pattern;
let regexStr = "";
if (protocol) {
regexStr += `${protocol}://`;
} else {
regexStr += "[a-z]+://";
}
if (hostname) {
if (hostname.startsWith("**.")) {
regexStr += "([a-z0-9-]+\\.)*";
hostname = hostname.substring(3);
} else if (hostname.startsWith("*.")) {
regexStr += "([a-z0-9-]+\\.)?";
hostname = hostname.substring(2);
}
regexStr += hostname.replace(/\./g, "\\.");
} else {
regexStr += "[a-z0-9.-]+";
}
if (port) {
regexStr += `:${port}`;
} else {
regexStr += "(:[0-9]+)?";
}
if (pathname) {
if (pathname.endsWith("/**")) {
regexStr += `(\\${pathname.replace("/**", "")}.*)`;
}
if (pathname.endsWith("/*")) {
regexStr += `(\\${pathname.replace("/*", "")}/[^/?#]+)/?`;
} else {
regexStr += `(\\${pathname})`;
}
} else {
regexStr += "(\\/[^?#]*)?";
}
if (!regexStr.endsWith(".*)")) {
regexStr += "([?][^#]*)?";
}
try {
new RegExp(regexStr);
} catch {
logger.warn(
`Could not generate a valid regex from the remotePattern "${JSON.stringify(
pattern
)}". Please check the syntax.`
);
return void 0;
}
return regexStr;
}
function remoteImagesFromAstroConfig(config, logger) {
const remoteImages = [];
remoteImages.push(
...config.image.domains.map((domain) => `https?://${domain.replaceAll(".", "\\.")}/.*`)
);
remoteImages.push(
...config.image.remotePatterns.map((pattern) => remotePatternToRegex(pattern, logger)).filter(Boolean)
);
return remoteImages;
}
async function writeNetlifyFrameworkConfig(config, staticHeaders, logger) {
const remoteImages = remoteImagesFromAstroConfig(config, logger);
const headers = [];
if (!config.build.assetsPrefix) {
headers.push({
for: `${config.base}${config.base.endsWith("/") ? "" : "/"}${config.build.assets}/*`,
values: {
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
if (staticHeaders && staticHeaders.size > 0) {
for (const [pathname, { headers: routeHeaders }] of staticHeaders.entries()) {
if (config.experimental.csp) {
const csp = routeHeaders.get("Content-Security-Policy");
if (csp) {
headers.push({
for: pathname,
values: {
"Content-Security-Policy": csp
}
});
}
}
}
}
const deployConfigDir = new URL(".netlify/v1/", config.root);
await mkdir(deployConfigDir, { recursive: true });
await writeFile(
new URL("./config.json", deployConfigDir),
JSON.stringify({
images: { remote_images: remoteImages },
headers
})
);
}
function netlifyIntegration(integrationConfig) {
const isRunningInNetlify = Boolean(
process.env.NETLIFY || process.env.NETLIFY_LOCAL || process.env.NETLIFY_DEV
);
let _config;
let outDir;
let rootDir;
let astroMiddlewareEntryPoint = void 0;
let staticHeadersMap = void 0;
const extraFilesToInclude = [];
const middlewareSecret = randomUUID();
let finalBuildOutput;
const TRACE_CACHE = {};
const ssrBuildDir = () => new URL("./.netlify/build/", rootDir);
const ssrOutputDir = () => new URL("./.netlify/v1/functions/ssr/", rootDir);
const middlewareOutputDir = () => new URL(".netlify/v1/edge-functions/middleware/", rootDir);
const cleanFunctions = async () => await Promise.all([
emptyDir(middlewareOutputDir()),
emptyDir(ssrOutputDir()),
emptyDir(ssrBuildDir())
]);
async function writeRedirects(routes2, dir, buildOutput, assets) {
const staticRedirects = routes2.filter(
(route) => route.type === "redirect" && (route.redirect || route.redirectRoute)
);
for (const { pattern, redirectRoute } of staticRedirects) {
const distURL = assets.get(pattern);
if (!distURL && redirectRoute) {
const redirectDistURL = assets.get(redirectRoute.pattern);
if (redirectDistURL) {
assets.set(pattern, redirectDistURL);
}
}
}
const fallback = finalBuildOutput === "static" ? "/.netlify/static" : "/.netlify/functions/ssr";
const redirects = createRedirectsFromAstroRoutes({
config: _config,
dir,
routeToDynamicTargetMap: new Map(staticRedirects.map((route) => [route, fallback])),
buildOutput,
assets
});
if (!redirects.empty()) {
await appendFile(new URL("_redirects", outDir), `
${printAsRedirects(redirects)}
`);
}
}
async function getFilesByGlob(include = [], exclude = []) {
const files = await glob(include, {
cwd: fileURLToPath(rootDir),
absolute: true,
ignore: exclude,
expandDirectories: false
});
return files.map((file) => pathToFileURL(file));
}
async function writeSSRFunction({
notFoundContent,
logger,
root
}) {
const entry = new URL("./entry.mjs", ssrBuildDir());
const _includeFiles = integrationConfig?.includeFiles || [];
const _excludeFiles = integrationConfig?.excludeFiles || [];
if (finalBuildOutput === "server") {
if (_config.vite.assetsInclude) {
const mergeGlobbedIncludes = (globPattern) => {
if (typeof globPattern === "string") {
const entries = globSync(globPattern).map((p) => pathToFileURL(p));
extraFilesToInclude.push(...entries);
} else if (Array.isArray(globPattern)) {
for (const pattern of globPattern) {
mergeGlobbedIncludes(pattern);
}
}
};
mergeGlobbedIncludes(_config.vite.assetsInclude);
}
}
const includeFiles = (await getFilesByGlob(_includeFiles, _excludeFiles)).concat(
extraFilesToInclude
);
const excludeFiles = await getFilesByGlob(_excludeFiles);
const { handler } = await copyDependenciesToFunction(
{
entry,
outDir: ssrOutputDir(),
includeFiles,
excludeFiles,
logger,
root
},
TRACE_CACHE
);
await writeFile(
new URL("./ssr.mjs", ssrOutputDir()),
`
import createSSRHandler from './${handler}';
export default createSSRHandler(${JSON.stringify({
cacheOnDemandPages: Boolean(integrationConfig?.cacheOnDemandPages),
notFoundContent
})});
export const config = {
includedFiles: ['**/*'],
name: 'Astro SSR',
nodeBundler: 'none',
generator: '@astrojs/netlify@${packageVersion}',
path: '/*',
preferStatic: true,
};
`
);
}
async function writeMiddleware(entrypoint) {
await mkdir(middlewareOutputDir(), { recursive: true });
await writeFile(
new URL("./entry.mjs", middlewareOutputDir()),
/* ts */
`
import { onRequest } from "${fileURLToPath(entrypoint).replaceAll("\\", "/")}";
import { createContext, trySerializeLocals } from 'astro/middleware';
export default async (request, context) => {
const ctx = createContext({
request,
params: {},
locals: { netlify: { context } }
});
// https://docs.netlify.com/edge-functions/api/#return-a-rewrite
ctx.rewrite = (target) => {
if(target instanceof Request) {
// We can only mutate headers, so if anything else is different, we need to fetch
// the target URL instead.
if(target.method !== request.method || target.body || target.url.origin !== request.url.origin) {
return fetch(target);
}
// We can't replace the headers object, so we need to delete all headers and set them again
request.headers.forEach((_value, key) => {
request.headers.delete(key);
});
target.headers.forEach((value, key) => {
request.headers.set(key, value);
});
return new URL(target.url);
}
return new URL(target, request.url);
};
const next = () => {
const { netlify, ...otherLocals } = ctx.locals;
request.headers.set("x-astro-locals", trySerializeLocals(otherLocals));
request.headers.set("x-astro-middleware-secret", "${middlewareSecret}");
return context.next();
};
return onRequest(ctx, next);
}
export const config = {
name: "Astro Middleware",
generator: "@astrojs/netlify@${packageVersion}",
path: "/*", excludedPath: ["/_astro/*", "/.netlify/images/*"]
};
`
);
await build({
entryPoints: [fileURLToPath(new URL("./entry.mjs", middlewareOutputDir()))],
// allow `node:` prefixed imports, which are valid in netlify's deno edge runtime
plugins: [
{
name: "allowNodePrefixedImports",
setup(puglinBuild) {
puglinBuild.onResolve({ filter: /^node:.*$/ }, (args) => ({
path: args.path,
external: true
}));
}
}
],
target: "es2022",
platform: "neutral",
mainFields: ["module", "main"],
outfile: fileURLToPath(new URL("./middleware.mjs", middlewareOutputDir())),
allowOverwrite: true,
format: "esm",
bundle: true,
minify: false,
external: ["sharp"],
banner: {
// Import Deno polyfill for `process.env` at the top of the file
js: 'import process from "node:process";'
}
});
}
function getLocalDevNetlifyContext(req) {
const isHttps = req.headers["x-forwarded-proto"] === "https";
const parseBase64JSON = (header) => {
if (typeof req.headers[header] === "string") {
try {
return JSON.parse(Buffer.from(req.headers[header], "base64").toString("utf8"));
} catch {
}
}
};
const context = {
account: parseBase64JSON("x-nf-account-info") ?? {
id: "mock-netlify-account-id"
},
// TODO: this has type conflicts with @netlify/functions ^2.8.1
// @ts-expect-error: this has type conflicts with @netlify/functions ^2.8.1
deploy: {
id: typeof req.headers["x-nf-deploy-id"] === "string" ? req.headers["x-nf-deploy-id"] : "mock-netlify-deploy-id"
},
site: parseBase64JSON("x-nf-site-info") ?? {
id: "mock-netlify-site-id",
name: "mock-netlify-site.netlify.app",
url: `${isHttps ? "https" : "http"}://localhost:${isRunningInNetlify ? 8888 : 4321}`
},
geo: parseBase64JSON("x-nf-geo") ?? {
city: "Mock City",
country: { code: "mock", name: "Mock Country" },
subdivision: { code: "SD", name: "Mock Subdivision" },
timezone: "UTC",
longitude: 0,
latitude: 0
},
ip: typeof req.headers["x-nf-client-connection-ip"] === "string" ? req.headers["x-nf-client-connection-ip"] : req.socket.remoteAddress ?? "127.0.0.1",
server: {
region: "local-dev"
},
requestId: typeof req.headers["x-nf-request-id"] === "string" ? req.headers["x-nf-request-id"] : "mock-netlify-request-id",
get cookies() {
throw new Error("Please use Astro.cookies instead.");
},
flags: {
get: () => void 0,
evaluations: /* @__PURE__ */ new Set()
},
json: (input) => Response.json(input),
log: console.info,
next: () => {
throw new Error("`context.next` is not implemented for serverless functions");
},
get params() {
throw new Error("context.params don't contain any usable content in Astro.");
},
rewrite() {
throw new Error("context.rewrite is not available in Astro.");
}
};
return context;
}
let routes;
return {
name: "@astrojs/netlify",
hooks: {
"astro:config:setup": async ({ config, updateConfig, logger, command }) => {
rootDir = config.root;
await cleanFunctions();
outDir = new URL(config.outDir, rootDir);
let session = config.session;
if (!session?.driver) {
logger.info("Enabling sessions with Netlify Blobs");
session = {
...session,
driver: "netlify-blobs",
options: {
name: "astro-sessions",
consistency: "strong",
...session?.options
}
};
}
const features = integrationConfig?.devFeatures;
const vitePluginOptions = {
images: {
// We don't need to disable the feature, because if the user disables it
// we'll disable the whole image service.
remoteURLPatterns: remoteImagesFromAstroConfig(config, logger)
},
environmentVariables: {
// If features is an object, use the `environmentVariables` property
// Otherwise, use the boolean value of `features`, defaulting to false
enabled: typeof features === "object" ? features.environmentVariables ?? false : features === true
}
};
updateConfig({
outDir,
build: {
redirects: false,
client: outDir,
server: ssrBuildDir()
},
session,
vite: {
plugins: [netlifyVitePlugin(vitePluginOptions)],
server: {
watch: {
ignored: [fileURLToPath(new URL("./.netlify/**", rootDir))]
}
}
},
image: {
service: {
// defaults to true, so should only be disabled if the user has
// explicitly set false
entrypoint: command === "build" && integrationConfig?.imageCDN === false || command === "dev" && vitePluginOptions?.images?.enabled === false ? void 0 : "@astrojs/netlify/image-service.js"
}
}
});
},
"astro:routes:resolved": (params) => {
routes = params.routes;
},
"astro:config:done": async ({ config, setAdapter, buildOutput }) => {
rootDir = config.root;
_config = config;
finalBuildOutput = buildOutput;
const useEdgeMiddleware = integrationConfig?.edgeMiddleware ?? false;
const useStaticHeaders = integrationConfig?.experimentalStaticHeaders ?? false;
setAdapter({
name: "@astrojs/netlify",
serverEntrypoint: "@astrojs/netlify/ssr-function.js",
exports: ["default"],
adapterFeatures: {
edgeMiddleware: useEdgeMiddleware,
experimentalStaticHeaders: useStaticHeaders
},
args: { middlewareSecret },
supportedAstroFeatures: {
hybridOutput: "stable",
staticOutput: "stable",
serverOutput: "stable",
sharpImageService: "stable",
envGetSecret: "stable"
}
});
},
"astro:build:generated": ({ experimentalRouteToHeaders }) => {
staticHeadersMap = experimentalRouteToHeaders;
},
"astro:build:ssr": async ({ middlewareEntryPoint }) => {
astroMiddlewareEntryPoint = middlewareEntryPoint;
},
"astro:build:done": async ({ assets, dir, logger }) => {
await writeRedirects(routes, dir, finalBuildOutput, assets);
logger.info("Emitted _redirects");
if (finalBuildOutput !== "static") {
let notFoundContent = void 0;
try {
notFoundContent = await readFile(new URL("./404.html", dir), "utf8");
} catch {
}
await writeSSRFunction({ notFoundContent, logger, root: _config.root });
logger.info("Generated SSR Function");
}
if (astroMiddlewareEntryPoint) {
await writeMiddleware(astroMiddlewareEntryPoint);
logger.info("Generated Middleware Edge Function");
}
await writeNetlifyFrameworkConfig(_config, staticHeadersMap, logger);
},
// local dev
"astro:server:setup": async ({ server }) => {
const existingSessionModule = server.moduleGraph.getModuleById("astro:sessions");
if (existingSessionModule) {
server.moduleGraph.invalidateModule(existingSessionModule);
}
server.middlewares.use((req, _res, next) => {
const locals = Symbol.for("astro.locals");
Reflect.set(req, locals, {
...Reflect.get(req, locals),
netlify: { context: getLocalDevNetlifyContext(req) }
});
next();
});
}
}
};
}
export {
netlifyIntegration as default,
remotePatternToRegex
};