import fs, { createWriteStream, existsSync } from 'fs' import { tmpdir } from 'os' import path from 'path' import { pipeline } from 'stream' import { promisify } from 'util' import { HandlerEvent, HandlerResponse } from '@netlify/functions' import { http, https } from 'follow-redirects' import type NextNodeServer from 'next/dist/server/next-server' export type NextServerType = typeof NextNodeServer const streamPipeline = promisify(pipeline) /** * Downloads a file from the CDN to the local aliased filesystem. This is a fallback, because in most cases we'd expect * files required at runtime to not be sent to the CDN. */ export const downloadFile = async (url: string, destination: string): Promise => { console.log(`Downloading ${url} to ${destination}`) const httpx = url.startsWith('https') ? https : http await new Promise((resolve, reject) => { const req = httpx.get(url, { timeout: 10000, maxRedirects: 1 }, (response) => { if (response.statusCode < 200 || response.statusCode > 299) { reject(new Error(`Failed to download ${url}: ${response.statusCode} ${response.statusMessage || ''}`)) return } const fileStream = createWriteStream(destination) streamPipeline(response, fileStream) .then(resolve) .catch((error) => { console.log(`Error downloading ${url}`, error) reject(error) }) }) req.on('error', (error) => { console.log(`Error downloading ${url}`, error) reject(error) }) }) } /** * Parse maxage from a cache-control header */ export const getMaxAge = (header: string): number => { const parts = header.split(',') let maxAge for (const part of parts) { const [key, value] = part.split('=') if (key?.trim() === 's-maxage') { maxAge = value?.trim() } } if (maxAge) { const result = Number.parseInt(maxAge) return Number.isNaN(result) ? 0 : result } return 0 } export const getMultiValueHeaders = ( headers: Record>, ): Record> => { const multiValueHeaders: Record> = {} for (const key of Object.keys(headers)) { const header = headers[key] if (Array.isArray(header)) { multiValueHeaders[key] = header } else { multiValueHeaders[key] = [header] } } return multiValueHeaders } /** * Monkey-patch the fs module to download missing files from the CDN */ export const augmentFsModule = ({ promises, staticManifest, pageRoot, getBase, }: { promises: typeof fs.promises staticManifest: Array<[string, string]> pageRoot: string getBase: () => string }) => { // Only do this if we have some static files moved to the CDN if (staticManifest.length === 0) { return } // These are static page files that have been removed from the function bundle // In most cases these are served from the CDN, but for rewrites Next may try to read them // from disk. We need to intercept these and load them from the CDN instead // Sadly the only way to do this is to monkey-patch fs.promises. Yeah, I know. const staticFiles = new Map(staticManifest) const downloadPromises = new Map>() // Yes, you can cache stuff locally in a Lambda const cacheDir = path.join(tmpdir(), 'next-static-cache') // Grab the real fs.promises.readFile... const readfileOrig = promises.readFile const statsOrig = promises.stat // ...then money-patch it to see if it's requesting a CDN file promises.readFile = (async (file, options) => { const baseUrl = getBase() // We only care about page files if (file.startsWith(pageRoot)) { // We only want the part after `.next/server/` const filePath = file.slice(pageRoot.length + 1) // Is it in the CDN and not local? if (staticFiles.has(filePath) && !existsSync(file)) { // This name is safe to use, because it's one that was already created by Next const cacheFile = path.join(cacheDir, filePath) const url = `${baseUrl}/${staticFiles.get(filePath)}` // If it's already downloading we can wait for it to finish if (downloadPromises.has(url)) { await downloadPromises.get(url) } // Have we already cached it? We download every time if running locally to avoid staleness if ((!existsSync(cacheFile) || process.env.NETLIFY_DEV) && baseUrl) { await promises.mkdir(path.dirname(cacheFile), { recursive: true }) try { // Append the path to our host and we can load it like a regular page const downloadPromise = downloadFile(url, cacheFile) downloadPromises.set(url, downloadPromise) await downloadPromise } finally { downloadPromises.delete(url) } } // Return the cache file return readfileOrig(cacheFile, options) } } return readfileOrig(file, options) }) as typeof promises.readFile promises.stat = ((file, options) => { // We only care about page files if (file.startsWith(pageRoot)) { // We only want the part after `.next/server` const cacheFile = path.join(cacheDir, file.slice(pageRoot.length + 1)) if (existsSync(cacheFile)) { return statsOrig(cacheFile, options) } } return statsOrig(file, options) }) as typeof promises.stat } /** * Next.js has an annoying habit of needing deep imports, but then moving those in patch releases. This is our abstraction. */ export const getNextServer = (): NextServerType => { let NextServer: NextServerType try { // next >= 11.0.1. Yay breaking changes in patch releases! // eslint-disable-next-line @typescript-eslint/no-var-requires NextServer = require('next/dist/server/next-server').default } catch (error) { if (!error.message.includes("Cannot find module 'next/dist/server/next-server'")) { // A different error, so rethrow it throw error } // Probably an old version of next, so fall through and find it elsewhere. } if (!NextServer) { try { // next < 11.0.1 // eslint-disable-next-line n/no-missing-require, import/no-unresolved, @typescript-eslint/no-var-requires NextServer = require('next/dist/next-server/server/next-server').default } catch (error) { if (!error.message.includes("Cannot find module 'next/dist/next-server/server/next-server'")) { throw error } throw new Error('Could not find Next.js server') } } return NextServer } /** * Prefetch requests are used to check for middleware redirects, and shouldn't trigger SSR. */ export const getPrefetchResponse = (event: HandlerEvent, mode: string): HandlerResponse | false => { if (event.headers['x-middleware-prefetch'] && mode === 'ssr') { return { statusCode: 200, body: '{}', headers: { 'Content-Type': 'application/json', 'x-middleware-skip': '1', // https://github.com/vercel/next.js/pull/42936/files#r1027563953 vary: 'x-middleware-prefetch', }, } } return false }