diff options
Diffstat (limited to 'packages/integrations/netlify/src/index.ts')
-rw-r--r-- | packages/integrations/netlify/src/index.ts | 539 |
1 files changed, 539 insertions, 0 deletions
diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts new file mode 100644 index 000000000..a43325cd1 --- /dev/null +++ b/packages/integrations/netlify/src/index.ts @@ -0,0 +1,539 @@ +import { randomUUID } from 'node:crypto'; +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { emptyDir } from '@astrojs/internal-helpers/fs'; +import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; +import type { Context } from '@netlify/functions'; +import type { + AstroConfig, + AstroIntegration, + AstroIntegrationLogger, + HookParameters, + IntegrationResolvedRoute, +} from 'astro'; +import { build } from 'esbuild'; +import { copyDependenciesToFunction } from './lib/nft.js'; +import type { Args } from './ssr-function.js'; + +const { version: packageVersion } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); + +export interface NetlifyLocals { + netlify: { + context: Context; + }; +} + +type RemotePattern = AstroConfig['image']['remotePatterns'][number]; + +/** + * Convert a remote pattern object to a regex string + */ +export function remotePatternToRegex( + pattern: RemotePattern, + logger: AstroIntegrationLogger, +): string | undefined { + let { protocol, hostname, port, pathname } = pattern; + + let regexStr = ''; + + if (protocol) { + regexStr += `${protocol}://`; + } else { + // Default to matching any protocol + regexStr += '[a-z]+://'; + } + + if (hostname) { + if (hostname.startsWith('**.')) { + // match any number of subdomains + regexStr += '([a-z0-9-]+\\.)*'; + hostname = hostname.substring(3); + } else if (hostname.startsWith('*.')) { + // match one subdomain + regexStr += '([a-z0-9-]+\\.)?'; + hostname = hostname.substring(2); // Remove '*.' from the beginning + } + // Escape dots in the hostname + regexStr += hostname.replace(/\./g, '\\.'); + } else { + regexStr += '[a-z0-9.-]+'; + } + + if (port) { + regexStr += `:${port}`; + } else { + // Default to matching any port + regexStr += '(:[0-9]+)?'; + } + + if (pathname) { + if (pathname.endsWith('/**')) { + // Match any path. + regexStr += `(\\${pathname.replace('/**', '')}.*)`; + } + if (pathname.endsWith('/*')) { + // Match one level of path + regexStr += `(\\${pathname.replace('/*', '')}\/[^/?#]+)\/?`; + } else { + // Exact match + regexStr += `(\\${pathname})`; + } + } else { + // Default to matching any path + regexStr += '(\\/[^?#]*)?'; + } + if (!regexStr.endsWith('.*)')) { + // Match query, but only if it's not already matched by the pathname + regexStr += '([?][^#]*)?'; + } + try { + new RegExp(regexStr); + } catch (e) { + logger.warn( + `Could not generate a valid regex from the remotePattern "${JSON.stringify( + pattern, + )}". Please check the syntax.`, + ); + return undefined; + } + return regexStr; +} + +async function writeNetlifyFrameworkConfig(config: AstroConfig, logger: AstroIntegrationLogger) { + const remoteImages: Array<string> = []; + // Domains get a simple regex match + remoteImages.push( + ...config.image.domains.map((domain) => `https?:\/\/${domain.replaceAll('.', '\\.')}\/.*`), + ); + // Remote patterns need to be converted to regexes + remoteImages.push( + ...config.image.remotePatterns + .map((pattern) => remotePatternToRegex(pattern, logger)) + .filter(Boolean as unknown as (pattern?: string) => pattern is string), + ); + + const headers = config.build.assetsPrefix + ? undefined + : [ + { + for: `${config.base}${config.base.endsWith('/') ? '' : '/'}${config.build.assets}/*`, + values: { + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }, + ]; + + // See https://docs.netlify.com/image-cdn/create-integration/ + 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, + }), + ); +} + +export interface NetlifyIntegrationConfig { + /** + * If enabled, On-Demand-Rendered pages are cached for up to a year. + * This is useful for pages that are not updated often, like a blog post, + * but that you have too many of to pre-render at build time. + * + * You can override this behavior on a per-page basis + * by setting the `Cache-Control`, `CDN-Cache-Control` or `Netlify-CDN-Cache-Control` header + * from within the Page: + * + * ```astro + * // src/pages/cached-clock.astro + * Astro.response.headers.set('CDN-Cache-Control', "public, max-age=45, must-revalidate"); + * --- + * <p>{Date.now()}</p> + * ``` + */ + cacheOnDemandPages?: boolean; + + /** + * If disabled, Middleware is applied to prerendered pages at build-time, and to on-demand-rendered pages at runtime. + * Only disable when your Middleware does not need to run on prerendered pages. + * If you use Middleware to implement authentication, redirects or similar things, you should should likely enabled it. + * + * If enabled, Astro Middleware is deployed as an Edge Function and applies to all routes. + * Caveat: Locals set in Middleware are not applied to prerendered pages, because they've been rendered at build-time and are served from the CDN. + * + * @default {false} + */ + edgeMiddleware?: boolean; + + /** + * If enabled, Netlify Image CDN is used for image optimization. + * This transforms images on-the-fly without impacting build times. + * + * If disabled, Astro's built-in image optimization is run at build-time instead. + * + * @default {true} + */ + imageCDN?: boolean; +} + +export default function netlifyIntegration( + integrationConfig?: NetlifyIntegrationConfig, +): AstroIntegration { + const isRunningInNetlify = Boolean( + process.env.NETLIFY || process.env.NETLIFY_LOCAL || process.env.NETLIFY_DEV, + ); + + let _config: AstroConfig; + let outDir: URL; + let rootDir: URL; + let astroMiddlewareEntryPoint: URL | undefined = undefined; + // Secret used to verify that the caller is the astro-generated edge middleware and not a third-party + const middlewareSecret = randomUUID(); + + let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput']; + + 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( + routes: IntegrationResolvedRoute[], + dir: URL, + buildOutput: HookParameters<'astro:config:done'>['buildOutput'], + assets: HookParameters<'astro:build:done'>['assets'], + ) { + // all other routes are handled by SSR + const staticRedirects = routes.filter( + (route) => route.type === 'redirect' && (route.redirect || route.redirectRoute), + ); + + // this is needed to support redirects to dynamic routes + // on static. not sure why this is needed, but it works. + 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), `\n${redirects.print()}\n`); + } + } + + async function writeSSRFunction({ + notFoundContent, + logger, + root, + }: { + notFoundContent?: string; + logger: AstroIntegrationLogger; + root: URL; + }) { + const entry = new URL('./entry.mjs', ssrBuildDir()); + + 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: URL) { + 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/*"] + }; + `, + ); + + // taking over bundling, because Netlify bundling trips over NPM modules + 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: IncomingMessage): Context { + const isHttps = req.headers['x-forwarded-proto'] === 'https'; + const parseBase64JSON = <T = unknown>(header: string): T | undefined => { + if (typeof req.headers[header] === 'string') { + try { + return JSON.parse(Buffer.from(req.headers[header] as string, 'base64').toString('utf8')); + } catch {} + } + }; + + const context: 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(): never { + throw new Error('Please use Astro.cookies instead.'); + }, + // @ts-expect-error This is not currently included in the public Netlify types + flags: undefined, + json: (input) => Response.json(input), + log: console.info, + next: () => { + throw new Error('`context.next` is not implemented for serverless functions'); + }, + get params(): never { + 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: IntegrationResolvedRoute[]; + + return { + name: '@astrojs/netlify', + hooks: { + 'astro:config:setup': async ({ config, updateConfig }) => { + rootDir = config.root; + await cleanFunctions(); + + outDir = new URL('./dist/', rootDir); + + const enableImageCDN = isRunningInNetlify && (integrationConfig?.imageCDN ?? true); + + updateConfig({ + outDir, + build: { + redirects: false, + client: outDir, + server: ssrBuildDir(), + }, + vite: { + server: { + watch: { + ignored: [fileURLToPath(new URL('./.netlify/**', rootDir))], + }, + }, + }, + image: { + service: { + entrypoint: enableImageCDN ? '@astrojs/netlify/image-service.js' : undefined, + }, + }, + }); + }, + 'astro:routes:resolved': (params) => { + routes = params.routes; + }, + 'astro:config:done': async ({ config, setAdapter, logger, buildOutput }) => { + rootDir = config.root; + _config = config; + + finalBuildOutput = buildOutput; + + await writeNetlifyFrameworkConfig(config, logger); + + const edgeMiddleware = integrationConfig?.edgeMiddleware ?? false; + + setAdapter({ + name: '@astrojs/netlify', + serverEntrypoint: '@astrojs/netlify/ssr-function.js', + exports: ['default'], + adapterFeatures: { + edgeMiddleware, + }, + args: { middlewareSecret } satisfies Args, + supportedAstroFeatures: { + hybridOutput: 'stable', + staticOutput: 'stable', + serverOutput: 'stable', + sharpImageService: 'stable', + envGetSecret: 'stable', + }, + }); + }, + '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 = undefined; + 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'); + } + }, + + // local dev + 'astro:server:setup': async ({ server }) => { + 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(); + }); + }, + }, + }; +} |