UNPKG

@astrojs/netlify

Version:
495 lines (491 loc) 17.1 kB
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 };