diff options
Diffstat (limited to 'packages/integrations/netlify/src')
-rw-r--r-- | packages/integrations/netlify/src/functions.ts | 9 | ||||
-rw-r--r-- | packages/integrations/netlify/src/image-service.ts | 57 | ||||
-rw-r--r-- | packages/integrations/netlify/src/index.ts | 539 | ||||
-rw-r--r-- | packages/integrations/netlify/src/lib/nft.ts | 83 | ||||
-rw-r--r-- | packages/integrations/netlify/src/polyfill.ts | 3 | ||||
-rw-r--r-- | packages/integrations/netlify/src/ssr-function.ts | 78 | ||||
-rw-r--r-- | packages/integrations/netlify/src/static.ts | 8 |
7 files changed, 777 insertions, 0 deletions
diff --git a/packages/integrations/netlify/src/functions.ts b/packages/integrations/netlify/src/functions.ts new file mode 100644 index 000000000..58428fec0 --- /dev/null +++ b/packages/integrations/netlify/src/functions.ts @@ -0,0 +1,9 @@ +import type { AstroIntegration } from 'astro'; +import netlifyIntegration, { type NetlifyIntegrationConfig } from './index.js'; + +export default function functionsIntegration(config: NetlifyIntegrationConfig): AstroIntegration { + console.warn( + 'The @astrojs/netlify/functions import is deprecated and will be removed in a future release. Please use @astrojs/netlify instead.' + ); + return netlifyIntegration(config); +} diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts new file mode 100644 index 000000000..afdcc3917 --- /dev/null +++ b/packages/integrations/netlify/src/image-service.ts @@ -0,0 +1,57 @@ +import type { ExternalImageService, ImageMetadata } from 'astro'; +import { baseService } from 'astro/assets'; +import { AstroError } from 'astro/errors'; + +const SUPPORTED_FORMATS = ['avif', 'jpg', 'png', 'webp']; +const QUALITY_NAMES: Record<string, number> = { low: 25, mid: 50, high: 90, max: 100 }; + +export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { + return typeof src === 'object'; +} + +function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +const service: ExternalImageService = { + getURL(options) { + const query = new URLSearchParams(); + + const fileSrc = isESMImportedImage(options.src) + ? removeLeadingForwardSlash(options.src.src) + : options.src; + + query.set('url', fileSrc); + + if (options.format) query.set('fm', options.format); + if (options.width) query.set('w', `${options.width}`); + if (options.height) query.set('h', `${options.height}`); + if (options.quality) query.set('q', `${options.quality}`); + + return `/.netlify/images?${query}`; + }, + getHTMLAttributes: baseService.getHTMLAttributes, + getSrcSet: baseService.getSrcSet, + validateOptions(options) { + if (options.format && !SUPPORTED_FORMATS.includes(options.format)) { + throw new AstroError( + `Unsupported image format "${options.format}"`, + `Use one of ${SUPPORTED_FORMATS.join(', ')} instead.` + ); + } + + if (options.quality) { + options.quality = + typeof options.quality === 'string' ? QUALITY_NAMES[options.quality] : options.quality; + if (options.quality < 1 || options.quality > 100) { + throw new AstroError( + `Invalid quality for picture "${options.src}"`, + 'Quality needs to be between 1 and 100.' + ); + } + } + return options; + }, +}; + +export default service; diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts new file mode 100644 index 000000000..2c0fde008 --- /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(); + }); + }, + }, + }; +} diff --git a/packages/integrations/netlify/src/lib/nft.ts b/packages/integrations/netlify/src/lib/nft.ts new file mode 100644 index 000000000..c0584fd92 --- /dev/null +++ b/packages/integrations/netlify/src/lib/nft.ts @@ -0,0 +1,83 @@ +import { posix, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { copyFilesToFolder } from '@astrojs/internal-helpers/fs'; +import { appendForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroIntegrationLogger } from 'astro'; +import { searchForWorkspaceRoot } from 'vite'; + +// Based on the equivalent function in `@astrojs/vercel` +export async function copyDependenciesToFunction( + { + entry, + outDir, + includeFiles, + excludeFiles, + logger, + root, + }: { + entry: URL; + outDir: URL; + includeFiles: URL[]; + excludeFiles: URL[]; + logger: AstroIntegrationLogger; + root: URL; + }, + // we want to pass the caching by reference, and not by value + cache: object +): Promise<{ handler: string }> { + const entryPath = fileURLToPath(entry); + logger.info(`Bundling function ${relative(fileURLToPath(outDir), entryPath)}`); + + // Set the base to the workspace root + const base = pathToFileURL(appendForwardSlash(searchForWorkspaceRoot(fileURLToPath(root)))); + + // The Vite bundle includes an import to `@vercel/nft` for some reason, + // and that trips up `@vercel/nft` itself during the adapter build. Using a + // dynamic import helps prevent the issue. + // TODO: investigate why + const { nodeFileTrace } = await import('@vercel/nft'); + const result = await nodeFileTrace([entryPath], { + base: fileURLToPath(base), + cache, + }); + + for (const error of result.warnings) { + if (error.message.startsWith('Failed to resolve dependency')) { + const [, module, file] = + /Cannot find module '(.+?)' loaded from (.+)/.exec(error.message) || []; + + // The import(astroRemark) sometimes fails to resolve, but it's not a problem + if (module === '@astrojs/') continue; + + // Sharp is always external and won't be able to be resolved, but that's also not a problem + if (module === 'sharp') continue; + + if (entryPath === file) { + logger.debug( + `The module "${module}" couldn't be resolved. This may not be a problem, but it's worth checking.` + ); + } else { + logger.debug( + `The module "${module}" inside the file "${file}" couldn't be resolved. This may not be a problem, but it's worth checking.` + ); + } + } + // parse errors are likely not js and can safely be ignored, + // such as this html file in "main" meant for nw instead of node: + // https://github.com/vercel/nft/issues/311 + else if (!error.message.startsWith('Failed to parse')) { + throw error; + } + } + + const commonAncestor = await copyFilesToFolder( + [...result.fileList].map((file) => new URL(file, base)).concat(includeFiles), + outDir, + excludeFiles + ); + + return { + // serverEntry location inside the outDir, converted to posix + handler: relative(commonAncestor, entryPath).split(sep).join(posix.sep), + }; +} diff --git a/packages/integrations/netlify/src/polyfill.ts b/packages/integrations/netlify/src/polyfill.ts new file mode 100644 index 000000000..dc00f45d7 --- /dev/null +++ b/packages/integrations/netlify/src/polyfill.ts @@ -0,0 +1,3 @@ +import { applyPolyfills } from 'astro/app/node'; + +applyPolyfills(); diff --git a/packages/integrations/netlify/src/ssr-function.ts b/packages/integrations/netlify/src/ssr-function.ts new file mode 100644 index 000000000..5ea2e97f1 --- /dev/null +++ b/packages/integrations/netlify/src/ssr-function.ts @@ -0,0 +1,78 @@ +// Keep at the top +import './polyfill.js'; + +import type { Context } from '@netlify/functions'; +import type { SSRManifest } from 'astro'; +import { App } from 'astro/app'; +import { setGetEnv } from 'astro/env/setup'; + +setGetEnv((key) => process.env[key]); + +export interface Args { + middlewareSecret: string; +} + +const clientAddressSymbol = Symbol.for('astro.clientAddress'); + +export const createExports = (manifest: SSRManifest, { middlewareSecret }: Args) => { + const app = new App(manifest); + + function createHandler(integrationConfig: { + cacheOnDemandPages: boolean; + notFoundContent?: string; + }) { + return async function handler(request: Request, context: Context) { + const routeData = app.match(request); + if (!routeData && typeof integrationConfig.notFoundContent !== 'undefined') { + return new Response(integrationConfig.notFoundContent, { + status: 404, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + Reflect.set(request, clientAddressSymbol, context.ip); + let locals: Record<string, unknown> = {}; + + const astroLocalsHeader = request.headers.get('x-astro-locals'); + const middlewareSecretHeader = request.headers.get('x-astro-middleware-secret'); + if (astroLocalsHeader) { + if (middlewareSecretHeader !== middlewareSecret) { + return new Response('Forbidden', { status: 403 }); + } + // hide the secret from the rest of user and library code + request.headers.delete('x-astro-middleware-secret'); + locals = JSON.parse(astroLocalsHeader); + } + + locals.netlify = { context }; + + const response = await app.render(request, { routeData, locals }); + + if (app.setCookieHeaders) { + for (const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + + if (integrationConfig.cacheOnDemandPages) { + const isCacheableMethod = ['GET', 'HEAD'].includes(request.method); + + // any user-provided Cache-Control headers take precedence + const hasCacheControl = [ + 'Cache-Control', + 'CDN-Cache-Control', + 'Netlify-CDN-Cache-Control', + ].some((header) => response.headers.has(header)); + + if (isCacheableMethod && !hasCacheControl) { + // caches this page for up to a year + response.headers.append('CDN-Cache-Control', 'public, max-age=31536000, must-revalidate'); + } + } + + return response; + }; + } + + return { default: createHandler }; +}; diff --git a/packages/integrations/netlify/src/static.ts b/packages/integrations/netlify/src/static.ts new file mode 100644 index 000000000..1c809ef8f --- /dev/null +++ b/packages/integrations/netlify/src/static.ts @@ -0,0 +1,8 @@ +import netlifyIntegration from './index.js'; + +export default function staticIntegration() { + console.warn( + 'The @astrojs/netlify/static import is deprecated and will be removed in a future release. Please use @astrojs/netlify instead.' + ); + return netlifyIntegration(); +} |