@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
138 lines • 5.68 kB
JavaScript
import { relativePath, joinPath, dirname } from './path.js';
import { glob, removeFile } from './fs.js';
import { outputDebug, outputContent, outputToken } from '../../public/node/output.js';
import archiver from 'archiver';
import { createWriteStream, readFileSync, writeFileSync } from 'fs';
import { readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
/**
* It zips a directory and by default normalizes the paths to be forward-slash.
* Even with forward-slash paths, zip files should still be able to be opened on
* Windows.
*
* @param options - ZipOptions.
*/
export async function zip(options) {
const { inputDirectory, outputZipPath, matchFilePattern = '**/*' } = options;
outputDebug(outputContent `Zipping ${outputToken.path(inputDirectory)} into ${outputToken.path(outputZipPath)}`);
const pathsToZip = await glob(matchFilePattern, {
cwd: inputDirectory,
absolute: true,
dot: true,
followSymbolicLinks: false,
});
return new Promise((resolve, reject) => {
const archive = archiver('zip');
const output = createWriteStream(outputZipPath);
output.on('close', () => {
resolve();
});
archive.on('error', (error) => {
reject(error);
});
archive.pipe(output);
const directoriesToAdd = new Set();
for (const filePath of pathsToZip) {
const fileRelativePath = relativePath(inputDirectory, filePath);
collectParentDirectories(fileRelativePath, directoriesToAdd);
}
const sortedDirs = Array.from(directoriesToAdd).sort((left, right) => left.localeCompare(right));
for (const dir of sortedDirs) {
const dirName = dir.endsWith('/') ? dir : `${dir}/`;
archive.append(Buffer.alloc(0), { name: dirName });
}
// Read all files immediately before adding to archive to prevent ENOENT errors
// Using archive.file() causes lazy loading which fails if files are deleted before finalize()
const addFilesPromises = pathsToZip.map(async (filePath) => {
await archiveFile(inputDirectory, filePath, archive);
});
// Wait for all files to be read and added before finalizing
Promise.all(addFilesPromises)
.then(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
archive.finalize();
})
.catch((error) => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error);
});
});
}
function collectParentDirectories(fileRelativePath, accumulator) {
let currentDir = dirname(fileRelativePath);
while (currentDir && currentDir !== '.' && currentDir !== '/') {
accumulator.add(currentDir);
const parent = dirname(currentDir);
if (parent === currentDir)
break;
currentDir = parent;
}
}
async function archiveFile(inputDirectory, filePath, archive) {
const fileRelativePath = relativePath(inputDirectory, filePath);
if (!filePath || !fileRelativePath)
return;
// Read file content immediately to avoid race conditions
const fileContent = await readFile(filePath);
// Use append with Buffer instead of file() to avoid lazy file reading
archive.append(fileContent, { name: fileRelativePath });
}
/**
* It compresses a directory with Brotli.
* First creates a tar archive to preserve directory structure,
* then compresses it with Brotli.
*
* @param options - BrotliOptions.
*/
export async function brotliCompress(options) {
const tempTarPath = joinPath(tmpdir(), `${randomUUID()}.tar`);
try {
// Create tar archive using archiver
await new Promise((resolve, reject) => {
const archive = archiver('tar');
const output = createWriteStream(tempTarPath);
output.on('close', () => resolve());
archive.on('error', (error) => reject(error));
archive.pipe(output);
glob(options.matchFilePattern ?? '**/*', {
cwd: options.inputDirectory,
absolute: true,
dot: true,
followSymbolicLinks: false,
})
.then(async (pathsToZip) => {
// Read all files immediately to prevent ENOENT errors during race conditions
const addFilesPromises = pathsToZip.map(async (filePath) => {
await archiveFile(options.inputDirectory, filePath, archive);
});
await Promise.all(addFilesPromises);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
archive.finalize();
})
.catch((error) => {
reject(error instanceof Error ? error : new Error(String(error)));
});
});
const tarContent = readFileSync(tempTarPath);
const brotli = await import('brotli');
const compressed = brotli.default.compress(tarContent, {
quality: 7,
mode: 0,
});
if (!compressed) {
throw new Error('Brotli compression failed');
}
writeFileSync(options.outputPath, compressed);
}
finally {
try {
await removeFile(tempTarPath);
// eslint-disable-next-line no-catch-all/no-catch-all
}
catch (error) {
outputDebug(outputContent `Failed to clean up temporary file: ${outputToken.path(tempTarPath)}`);
}
}
}
//# sourceMappingURL=archiver.js.map