如何加速 pnpm 的 postinstall
如果你的项目中有使用过 supabase cli,那么你可能遇到过这样的问题:
文件夹my-project
文件夹node_modules/
- …
文件夹src/
- …
- package.json
package.json
中包含如下内容:
{ "name": "my-project", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "check-all": "npm run lint & npm run type-check & wait" }, "dependencies": { "react": "^18", "react-dom": "^18", "next": "14.2.23", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", "supabase": "^2.34.3", "tailwindcss": "^3.4.1", "eslint": "^8", "eslint-config-next": "14.2.23" }, "pnpm": { "onlyBuiltDependencies": [ "supabase", "unrs-resolver" ], "peerDependencyRules": { "allowAny": [ "supabase" ] } }}
运行 pnpm install
后,你会看到类似如下的输出:
(base) obiscr@192 my-project % pnpm install
╭──────────────────────────────────────────╮ │ │ │ Update available! 10.10.0 → 10.14.0. │ │ Changelog: https://pnpm.io/v/10.14.0 │ │ To update, run: pnpm self-update │ │ │ ╰──────────────────────────────────────────╯
WARN deprecated [email protected]: This version is no longer supported. Please see https://eslint.org/version-support for other options. WARN 6 deprecated subdependencies found: @humanwhocodes/[email protected], @humanwhocodes/[email protected], [email protected], [email protected], [email protected], [email protected]Packages: +417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ WARN Failed to create bin at /Users/obiscr/EasyCodeAIProjects/myblog123/node_modules/.bin/supabase. ENOENT: no such file or directory, chmod '/Users/obiscr/EasyCodeAIProjects/myblog123/node_modules/supabase/bin/supabase'Progress: resolved 433, reused 382, downloaded 20, added 417, done WARN Failed to create bin at /Users/obiscr/EasyCodeAIProjects/myblog123/node_modules/supabase/node_modules/.bin/supabase. ENOENT: no such file or directory, chmod '/Users/obiscr/EasyCodeAIProjects/myblog123/node_modules/supabase/bin/supabase'node_modules/supabase: Running postinstall script, failed in 3m 52.2snode_modules/supabase postinstall$ node scripts/postinstall.js│ Downloading https://github.com/supabase/cli/releases/download/v2.34.3/supabase_2.34.3_checksums.txt│ Downloading https://github.com/supabase/cli/releases/download/v2.34.3/supabase_darwin_arm64.tar.gz│ Warning: Detected unsettled top-level await at file:///Users/obiscr/EasyCodeAIProjects/myblog123/node_modules/su…│ await main();│ ^└─ Failed in 3m 52.2s at /Users/obiscr/projects/my-project/node_modules/supabasenode_modules/unrs-resolver: Running postinstall script, done in 199ms ELIFECYCLE Command failed with exit code 13.
过了将近4分钟,但是依旧安装失败。经过我的多次测试,这种情况在网络条件比较差的时候时有发生。
本文虽然使用 supabase cli 作为示例,但是这个方法具有通用性。本质就是更改 postinstall.js
文件,让其在安装时优先使用本地文件,如果本地文件不存在,则执行默认逻辑从 github
下载。
既然是 supabase cli
的 postinstall
脚本安装失败,那么我们来看看 supabase cli
的 postinstall
脚本做了什么。
进入 node_modules/supabase
目录,结构如下:
文件夹supabase
文件夹bin/
- …
文件夹scripts/
- postinstall.js
- LICENSE
- package.json
- README.md
postinstall.js
中包含如下内容:
#!/usr/bin/env node
// Ref 1: https://github.com/sanathkr/go-npm// Ref 2: https://medium.com/xendit-engineering/how-we-repurposed-npm-to-publish-and-distribute-our-go-binaries-for-internal-cli-23981b80911b"use strict";
import binLinks from "bin-links";import { createHash } from "crypto";import fs from "fs";import fetch from "node-fetch";import { Agent } from "https";import { HttpsProxyAgent } from "https-proxy-agent";import path from "path";import { extract } from "tar";import zlib from "zlib";
59 collapsed lines
// Mapping from Node's `process.arch` to Golang's `$GOARCH`const ARCH_MAPPING = { x64: "amd64", arm64: "arm64",};
// Mapping between Node's `process.platform` to Golang'sconst PLATFORM_MAPPING = { darwin: "darwin", linux: "linux", win32: "windows",};
const arch = ARCH_MAPPING[process.arch];const platform = PLATFORM_MAPPING[process.platform];
// TODO: import pkg from "../package.json" assert { type: "json" };const readPackageJson = async () => { const contents = await fs.promises.readFile("package.json"); return JSON.parse(contents);};
// Build the download url from package.jsonconst getDownloadUrl = (packageJson) => { const pkgName = packageJson.name; const version = packageJson.version; const repo = packageJson.repository; const url = `https://github.com/${repo}/releases/download/v${version}/${pkgName}_${platform}_${arch}.tar.gz`; return url;};
const fetchAndParseCheckSumFile = async (packageJson, agent) => { const version = packageJson.version; const pkgName = packageJson.name; const repo = packageJson.repository; const checksumFileUrl = `https://github.com/${repo}/releases/download/v${version}/${pkgName}_${version}_checksums.txt`;
// Fetch the checksum file console.info("Downloading", checksumFileUrl); const response = await fetch(checksumFileUrl, { agent }); if (response.ok) { const checkSumContent = await response.text(); const lines = checkSumContent.split("\n");
const checksums = {}; for (const line of lines) { const [checksum, packageName] = line.split(/\s+/); checksums[packageName] = checksum; }
return checksums; } else { console.error( "Could not fetch checksum file", response.status, response.statusText ); }};
const errGlobal = `Installing Supabase CLI as a global module is not supported.Please use one of the supported package managers: https://github.com/supabase/cli#install-the-cli`;const errChecksum = "Checksum mismatch. Downloaded data might be corrupted.";const errUnsupported = `Installation is not supported for ${process.platform} ${process.arch}`;
/** * Reads the configuration from application's package.json, * downloads the binary from package url and stores at * ./bin in the package's root. * * See: https://docs.npmjs.com/files/package.json#bin */async function main() { const yarnGlobal = JSON.parse( process.env.npm_config_argv || "{}" ).original?.includes("global"); if (process.env.npm_config_global || yarnGlobal) {76 collapsed lines
throw errGlobal; } if (!arch || !platform) { throw errUnsupported; }
// Read from package.json and prepare for the installation. const pkg = await readPackageJson(); if (platform === "windows") { // Update bin path in package.json pkg.bin[pkg.name] += ".exe"; }
// Prepare the installation path by creating the directory if it doesn't exist. const binPath = pkg.bin[pkg.name]; const binDir = path.dirname(binPath); await fs.promises.mkdir(binDir, { recursive: true });
// Create the agent that will be used for all the fetch requests later. const proxyUrl = process.env.npm_config_https_proxy || process.env.npm_config_http_proxy || process.env.npm_config_proxy; // Keeps the TCP connection alive when sending multiple requests // Ref: https://github.com/node-fetch/node-fetch/issues/1735 const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl, { keepAlive: true }) : new Agent({ keepAlive: true });
// First, fetch the checksum map. const checksumMap = await fetchAndParseCheckSumFile(pkg, agent);
// Then, download the binary. const url = getDownloadUrl(pkg); console.info("Downloading", url); const resp = await fetch(url, { agent }); const hash = createHash("sha256"); const pkgNameWithPlatform = `${pkg.name}_${platform}_${arch}.tar.gz`;
// Then, decompress the binary -- we will first Un-GZip, then we will untar. const ungz = zlib.createGunzip(); const binName = path.basename(binPath); const untar = extract({ cwd: binDir }, [binName]);
// Update the hash with the binary data as it's being downloaded. resp.body .on("data", (chunk) => { hash.update(chunk); }) // Pipe the data to the ungz stream. .pipe(ungz);
// After the ungz stream has ended, verify the checksum. ungz .on("end", () => { const expectedChecksum = checksumMap?.[pkgNameWithPlatform]; // Skip verification if we can't find the file checksum if (!expectedChecksum) { console.warn("Skipping checksum verification"); return; } const calculatedChecksum = hash.digest("hex"); if (calculatedChecksum !== expectedChecksum) { throw errChecksum; } console.info("Checksum verified."); }) // Pipe the data to the untar stream. .pipe(untar);
// Wait for the untar stream to finish. await new Promise((resolve, reject) => { untar.on("error", reject); untar.on("end", () => resolve()); });
// Link the binaries in postinstall to support yarn await binLinks({ path: path.resolve("."), pkg: { ...pkg, bin: { [pkg.name]: binPath } }, });
console.info("Installed Supabase CLI successfully");}
await main();
简单来讲,就是从 supabase
的 github
仓库中下载 supabase
的二进制文件,并将其安装到 node_modules
中。
那么,我们是否可以优化这个过程呢?
答案是肯定的。
我们可以将 supabase
的二进制文件下载到本地,然后将其安装到 node_modules
中。
在项目根目录终端执行下面的命令来下载文件:
curl -L -o lib/supabase/supabase_2.34.3_checksums.txt https://github.com/supabase/cli/releases/download/v2.34.3/supabase_2.34.3_checksums.txtcurl -L -o lib/supabase/supabase_darwin_arm64.tar.gz https://github.com/supabase/cli/releases/download/v2.34.3/supabase_darwin_arm64.tar.gz
在终端执行 pnpm patch [email protected]
。
Patch: You can now edit the package at:
/Users/obiscr/projects/my-project/node_modules/.pnpm_patches/[email protected]
To commit your changes, run:
在 node_modules/.pnpm_patches/[email protected]
目录下,你会看到如下内容:
文件夹node_modules/
文件夹.pnpm_patches
文件夹[email protected]
文件夹scripts
- postinstall.js
- LICENSE
- package.json
- README.md
- state.json
然后编辑 postinstall.js
文件。
#!/usr/bin/env node
// Ref 1: https://github.com/sanathkr/go-npm// Ref 2: https://medium.com/xendit-engineering/how-we-repurposed-npm-to-publish-and-distribute-our-go-binaries-for-internal-cli-23981b80911b"use strict";
import binLinks from "bin-links";import { createHash } from "crypto";import fs from "fs";import fetch from "node-fetch";import { Agent } from "https";99 collapsed lines
import { HttpsProxyAgent } from "https-proxy-agent";import path from "path";import { extract } from "tar";import zlib from "zlib";
// Mapping from Node's `process.arch` to Golang's `$GOARCH`const ARCH_MAPPING = { x64: "amd64", arm64: "arm64",};
// Mapping between Node's `process.platform` to Golang'sconst PLATFORM_MAPPING = { darwin: "darwin", linux: "linux", win32: "windows",};
const arch = ARCH_MAPPING[process.arch];const platform = PLATFORM_MAPPING[process.platform];
// TODO: import pkg from "../package.json" assert { type: "json" };const readPackageJson = async () => { const contents = await fs.promises.readFile("package.json"); return JSON.parse(contents);};
// Build the download url from package.jsonconst getDownloadUrl = (packageJson) => { const pkgName = packageJson.name; const version = packageJson.version; const repo = packageJson.repository; const url = `https://github.com/${repo}/releases/download/v${version}/${pkgName}_${platform}_${arch}.tar.gz`; return url;};
const fetchAndParseCheckSumFile = async (packageJson, agent) => { const version = packageJson.version; const pkgName = packageJson.name; const repo = packageJson.repository; const checksumFileUrl = `https://github.com/${repo}/releases/download/v${version}/${pkgName}_${version}_checksums.txt`;
// Fetch the checksum file console.info("Downloading", checksumFileUrl); const response = await fetch(checksumFileUrl, { agent }); if (response.ok) { const checkSumContent = await response.text(); const lines = checkSumContent.split("\n");
const checksums = {}; for (const line of lines) { const [checksum, packageName] = line.split(/\s+/); checksums[packageName] = checksum; }
return checksums; } else { console.error( "Could not fetch checksum file", response.status, response.statusText ); }};
const errGlobal = `Installing Supabase CLI as a global module is not supported.Please use one of the supported package managers: https://github.com/supabase/cli#install-the-cli`;const errChecksum = "Checksum mismatch. Downloaded data might be corrupted.";const errUnsupported = `Installation is not supported for ${process.platform} ${process.arch}`;
/** * Reads the configuration from application's package.json, * downloads the binary from package url and stores at * ./bin in the package's root. * * See: https://docs.npmjs.com/files/package.json#bin */async function main() { const yarnGlobal = JSON.parse( process.env.npm_config_argv || "{}" ).original?.includes("global"); if (process.env.npm_config_global || yarnGlobal) { throw errGlobal; } if (!arch || !platform) { throw errUnsupported; }
// Read from package.json and prepare for the installation. const pkg = await readPackageJson(); if (platform === "windows") { // Update bin path in package.json pkg.bin[pkg.name] += ".exe"; }
// Prepare the installation path by creating the directory if it doesn't exist. const binPath = pkg.bin[pkg.name]; const binDir = path.dirname(binPath); await fs.promises.mkdir(binDir, { recursive: true });
// Create the agent that will be used for all the fetch requests later.9 collapsed lines
const proxyUrl = process.env.npm_config_https_proxy || process.env.npm_config_http_proxy || process.env.npm_config_proxy; // Keeps the TCP connection alive when sending multiple requests // Ref: https://github.com/node-fetch/node-fetch/issues/1735 const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl, { keepAlive: true }) : new Agent({ keepAlive: true });
// First, fetch the checksum map. const checksumMap = await fetchAndParseCheckSumFile(pkg, agent); // Prefer local resources if present (project root via INIT_CWD) const projectRoot = process.env.INIT_CWD || process.cwd(); const resourcesDir = path.join(projectRoot, "lib", "supabase"); const localChecksumPath = path.join(resourcesDir, `${pkg.name}_${pkg.version}_checksums.txt`); const localTgzPath = path.join(resourcesDir, `${pkg.name}_${platform}_${arch}.tar.gz`);
// First, read checksum map from local file if available; otherwise fetch let checksumMap; try { const checkSumContent = await fs.promises.readFile(localChecksumPath, "utf8"); const lines = checkSumContent.split("\n"); checksumMap = {}; for (const line of lines) { const [checksum, packageName] = line.split(/\s+/); if (packageName) checksumMap[packageName] = checksum; } console.info("Using local checksum file", localChecksumPath); } catch { // fallback to remote checksum checksumMap = await fetchAndParseCheckSumFile(pkg, agent); }
// Then, download the binary. const url = getDownloadUrl(pkg); console.info("Downloading", url); const resp = await fetch(url, { agent }); const hash = createHash("sha256"); const pkgNameWithPlatform = `${pkg.name}_${platform}_${arch}.tar.gz`;
// Then, decompress the binary -- we will first Un-GZip, then we will untar. const ungz = zlib.createGunzip(); const binName = path.basename(binPath); const untar = extract({ cwd: binDir }, [binName]);
// Update the hash with the binary data as it's being downloaded. resp.body let sourceStream; let usingLocal = false; try { await fs.promises.access(localTgzPath, fs.constants.R_OK); usingLocal = true; } catch {} if (usingLocal) { console.info("Using local tgz", localTgzPath); sourceStream = fs.createReadStream(localTgzPath); } else { console.info("Downloading", url); const resp = await fetch(url, { agent }); sourceStream = resp.body; } sourceStream .on("data", (chunk) => { hash.update(chunk); }) // Pipe the data to the ungz stream. .pipe(ungz);
// After the ungz stream has ended, verify the checksum.27 collapsed lines
ungz .on("end", () => { const expectedChecksum = checksumMap?.[pkgNameWithPlatform]; // Skip verification if we can't find the file checksum if (!expectedChecksum) { console.warn("Skipping checksum verification"); return; } const calculatedChecksum = hash.digest("hex"); if (calculatedChecksum !== expectedChecksum) { throw errChecksum; } console.info("Checksum verified."); }) // Pipe the data to the untar stream. .pipe(untar);
// Wait for the untar stream to finish. await new Promise((resolve, reject) => { untar.on("error", reject); untar.on("end", () => resolve()); });
// Link the binaries in postinstall to support yarn await binLinks({ path: path.resolve("."), pkg: { ...pkg, bin: { [pkg.name]: binPath } }, });
console.info("Installed Supabase CLI successfully");}
await main();
新增的这部分逻辑,会优先使用 lib/supabase
目录下的文件,如果文件不存在,则执行默认逻辑从 github
下载。
编辑完成以后,我们提交更改。
obiscr@192 my-project % pnpm patch-commit '/Users/obiscr/projects/my-project/node_modules/.pnpm_patches/[email protected]'Lockfile is up to date, resolution step is skippedPackages: +20++++++++++++++++++++Progress: resolved 0, reused 45, downloaded 0, added 20, donenode_modules/supabase: Running postinstall script, done in 236ms
这将会在项目根目录的 patches
目录下生成 [email protected]
文件。
文件夹my-project
文件夹lib/
文件夹supabase/
- supabase_2.34.3_checksums.txt
- supabase_darwin_arm64.tar.gz
文件夹node_modules/
- …
文件夹patches/
文件夹src/
- …
- package.json
还会在 package.json
生成 patchedDependencies
配置。
{ "name": "my-project", "version": "0.1.0", "private": true,26 collapsed lines
"scripts": { "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "check-all": "npm run lint & npm run type-check & wait" }, "dependencies": { "react": "^18", "react-dom": "^18", "next": "14.2.23", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", "supabase": "^2.34.3", "tailwindcss": "^3.4.1", "eslint": "^8", "eslint-config-next": "14.2.23" }, "pnpm": { "onlyBuiltDependencies": [ "supabase", "unrs-resolver" ], "peerDependencyRules": { "allowAny": [ "supabase" ] } }, "patchedDependencies": { } }}
然后我们把 [email protected]
文件移动到 lib/supabase
目录中。
此时目录结构如下:
文件夹my-project
文件夹lib/
文件夹supabase/
- supabase_2.34.3_checksums.txt
- supabase_darwin_arm64.tar.gz
- [email protected]
文件夹node_modules/
- …
文件夹patches/
文件夹src/
- …
- package.json
同时我们更改 package.json
中的 patchedDependencies
配置。
{ "name": "my-project", "version": "0.1.0", "private": true,26 collapsed lines
"scripts": { "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "check-all": "npm run lint & npm run type-check & wait" }, "dependencies": { "react": "^18", "react-dom": "^18", "next": "14.2.23", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", "supabase": "^2.34.3", "tailwindcss": "^3.4.1", "eslint": "^8", "eslint-config-next": "14.2.23" }, "pnpm": { "onlyBuiltDependencies": [ "supabase", "unrs-resolver" ], "peerDependencyRules": { "allowAny": [ "supabase" ] }, "patchedDependencies": { } }}
删除 pnpm-lock.yaml
, node_modules
,然后运行 pnpm install
,你应该能够看到安装速度明显加快。只需要 266ms 即可完成安装。相较于之前的接近4分钟,有了非常明显的提升。
obiscr@192 my-project % pnpm install WARN deprecated [email protected]: This version is no longer supported. Please see https://eslint.org/version-support for other options. WARN 6 deprecated subdependencies found: @humanwhocodes/[email protected], @humanwhocodes/[email protected], [email protected], [email protected], [email protected], [email protected]Packages: +418++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ WARN Failed to create bin at /Users/obiscr/projects/my-project/node_modules/.bin/supabase. ENOENT: no such file or directory, chmod '/Users/obiscr/projects/my-project/node_modules/supabase/bin/supabase'Progress: resolved 433, reused 402, downloaded 0, added 418, done WARN Failed to create bin at /Users/obiscr/projects/my-project/node_modules/supabase/node_modules/.bin/supabase. ENOENT: no such file or directory, chmod '/Users/obiscr/projects/my-project/node_modules/supabase/bin/supabase'node_modules/supabase: Running postinstall script, done in 266msDone in 1m 25.2s using pnpm v10.10.0
本文介绍了如何通过 pnpm patch
命令来加速 pnpm
的 postinstall
脚本。希望对你有用。