import { Buffer } from 'buffer'; import { mkdir, readlink as readLink, rm, symlink, writeFile } from 'fs/promises'; import os from 'os'; import { basename, dirname, extname, join } from 'path'; import { getPath as getV2APIPath } from '@netlify/serverless-functions-api'; import { copyFile } from 'copy-file'; import pMap from 'p-map'; import { addZipContent, addZipFile, ARCHIVE_FORMAT, endZip, startZip, } from '../../../archive.js'; import { cachedLstat, mkdirAndWriteFile } from '../../../utils/fs.js'; import { BOOTSTRAP_FILE_NAME, METADATA_FILE_NAME, conflictsWithEntryFile, getEntryFile, getTelemetryFile, isNamedLikeEntryFile, } from './entry_file.js'; import { getMetadataFile } from './metadata_file.js'; import { normalizeFilePath } from './normalize_path.js'; import { getPackageJsonIfAvailable } from './package_json.js'; // Taken from https://www.npmjs.com/package/cpy. const COPY_FILE_CONCURRENCY = os.cpus().length === 0 ? 2 : os.cpus().length * 2; // Sub-directory to place all user-defined files (i.e. everything other than // the entry file generated by zip-it-and-ship-it). const DEFAULT_USER_SUBDIRECTORY = 'src'; const addBootstrapFile = function (srcFiles, aliases) { // This is the path to the file that contains all the code for the v2 // functions API. We add it to the list of source files and create an // alias so that it's written as `BOOTSTRAP_FILE_NAME` in the ZIP/Directory. const v2APIPath = getV2APIPath(); srcFiles.push(v2APIPath); aliases.set(v2APIPath, BOOTSTRAP_FILE_NAME); return v2APIPath; }; const createDirectory = async function ({ aliases = new Map(), basePath, cache, destFolder, extension, featureFlags, filename, mainFile, moduleFormat, rewrites = new Map(), runtimeAPIVersion, srcFiles, }) { // There is a naming conflict with the entry file if one of the supporting // files (i.e. not the main file) has the path that the entry file needs to // take. const hasEntryFileConflict = conflictsWithEntryFile(srcFiles, { basePath, extension, filename, mainFile, runtimeAPIVersion, }); // If there is a naming conflict, we move all user files (everything other // than the entry file) to its own sub-directory. const userNamespace = hasEntryFileConflict ? DEFAULT_USER_SUBDIRECTORY : ''; const { contents: entryContents, filename: entryFilename } = getEntryFile({ commonPrefix: basePath, featureFlags, filename, mainFile, moduleFormat, userNamespace, runtimeAPIVersion, }); const { contents: telemetryContents, filename: telemetryFilename } = getTelemetryFile(); const functionFolder = join(destFolder, basename(filename, extension)); // Deleting the functions directory in case it exists before creating it. await rm(functionFolder, { recursive: true, force: true, maxRetries: 3 }); await mkdir(functionFolder, { recursive: true }); // Writing entry files. await Promise.all([ writeFile(join(functionFolder, entryFilename), entryContents), featureFlags.zisi_add_instrumentation_loader ? writeFile(join(functionFolder, telemetryFilename), telemetryContents) : Promise.resolve(), ]); if (runtimeAPIVersion === 2) { addBootstrapFile(srcFiles, aliases); } const symlinks = new Map(); // Copying source files. await pMap(srcFiles, async (srcFile) => { const destPath = aliases.get(srcFile) || srcFile; const normalizedDestPath = normalizeFilePath({ commonPrefix: basePath, path: destPath, userNamespace, }); const absoluteDestPath = join(functionFolder, normalizedDestPath); if (rewrites.has(srcFile)) { return mkdirAndWriteFile(absoluteDestPath, rewrites.get(srcFile)); } const stat = await cachedLstat(cache.lstatCache, srcFile); // If the path is a symlink, find the link target and add the link to a // `symlinks` map, which we'll later use to create the symlinks in the // target directory. We can't do that right now because the target path // may not have been copied over yet. if (stat.isSymbolicLink()) { const targetPath = await readLink(srcFile); // Two symlinks may point at the same target path, so keep a list of symlinks to create. symlinks.set(targetPath, (symlinks.get(targetPath) ?? new Set()).add(absoluteDestPath)); return; } return copyFile(srcFile, absoluteDestPath); }, { concurrency: COPY_FILE_CONCURRENCY }); await pMap([...symlinks.entries()], async ([target, paths]) => { for (const path of paths) { await mkdir(dirname(path), { recursive: true }); await symlink(target, path); } }, { concurrency: COPY_FILE_CONCURRENCY, }); return { path: functionFolder, entryFilename }; }; const createZipArchive = async function ({ aliases = new Map(), basePath, branch, cache, destFolder, extension, featureFlags, filename, mainFile, moduleFormat, rewrites, runtimeAPIVersion, srcFiles, generator, }) { const destPath = join(destFolder, `${basename(filename, extension)}.zip`); const { archive, output } = startZip(destPath); // There is a naming conflict with the entry file if one of the supporting // files (i.e. not the main file) has the path that the entry file needs to // take. const hasEntryFileConflict = conflictsWithEntryFile(srcFiles, { basePath, extension, filename, mainFile, runtimeAPIVersion, }); // We don't need an entry file if it would end up with the same path as the // function's main file. Unless we have a file conflict and need to move everything into a subfolder const needsEntryFile = runtimeAPIVersion === 2 || hasEntryFileConflict || !isNamedLikeEntryFile(mainFile, { basePath, filename, runtimeAPIVersion }); // If there is a naming conflict, we move all user files (everything other // than the entry file) to its own sub-directory. const userNamespace = hasEntryFileConflict ? DEFAULT_USER_SUBDIRECTORY : ''; let entryFilename = `${basename(filename, extname(filename))}.js`; let bootstrapVersion; if (needsEntryFile) { const entryFile = getEntryFile({ commonPrefix: basePath, filename, mainFile, moduleFormat, userNamespace, featureFlags, runtimeAPIVersion, }); entryFilename = entryFile.filename; addEntryFileToZip(archive, entryFile); } const telemetryFile = getTelemetryFile(generator); if (featureFlags.zisi_add_instrumentation_loader === true) { addEntryFileToZip(archive, telemetryFile); } if (runtimeAPIVersion === 2) { const bootstrapPath = addBootstrapFile(srcFiles, aliases); const { version } = await getPackageJsonIfAvailable(bootstrapPath); const payload = JSON.stringify(getMetadataFile(version, branch)); bootstrapVersion = version; addZipContent(archive, payload, METADATA_FILE_NAME); } const deduplicatedSrcFiles = [...new Set(srcFiles)]; const srcFilesInfos = await Promise.all(deduplicatedSrcFiles.map((file) => addStat(cache, file))); // We ensure this is not async, so that the archive's checksum is // deterministic. Otherwise it depends on the order the files were added. srcFilesInfos.forEach(({ srcFile, stat }) => { zipJsFile({ aliases, archive, commonPrefix: basePath, rewrites, srcFile, stat, userNamespace, }); }); await endZip(archive, output); return { path: destPath, entryFilename, bootstrapVersion }; }; export const zipNodeJs = function ({ archiveFormat, ...options }) { if (archiveFormat === ARCHIVE_FORMAT.ZIP) { return createZipArchive(options); } return createDirectory(options); }; const addEntryFileToZip = function (archive, { contents, filename }) { const contentBuffer = Buffer.from(contents); addZipContent(archive, contentBuffer, filename); }; const addStat = async function (cache, srcFile) { const stat = await cachedLstat(cache.lstatCache, srcFile); return { srcFile, stat }; }; const zipJsFile = function ({ aliases = new Map(), archive, commonPrefix, rewrites = new Map(), stat, srcFile, userNamespace, }) { const destPath = aliases.get(srcFile) || srcFile; const normalizedDestPath = normalizeFilePath({ commonPrefix, path: destPath, userNamespace }); if (rewrites.has(srcFile)) { addZipContent(archive, rewrites.get(srcFile), normalizedDestPath); } else { addZipFile(archive, srcFile, normalizedDestPath, stat); } };