跳转到内容

如何加速 pnpm 的 postinstall

如果你的项目中有使用过 supabase cli,那么你可能遇到过这样的问题:

  • 文件夹my-project
    • 文件夹node_modules/
    • 文件夹src/
    • package.json

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 后,你会看到类似如下的输出:

Terminal window
(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.2s
node_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/supabase
node_modules/unrs-resolver: Running postinstall script, done in 199ms
ELIFECYCLE  Command failed with exit code 13.

过了将近4分钟,但是依旧安装失败。经过我的多次测试,这种情况在网络条件比较差的时候时有发生。

既然是 supabase clipostinstall 脚本安装失败,那么我们来看看 supabase clipostinstall 脚本做了什么。

进入 node_modules/supabase 目录,结构如下:

  • 文件夹supabase
    • 文件夹bin/
    • 文件夹scripts/
      • postinstall.js
    • LICENSE
    • package.json
    • README.md

postinstall.js 中包含如下内容:

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's
const 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.json
const 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();

简单来讲,就是从 supabasegithub 仓库中下载 supabase 的二进制文件,并将其安装到 node_modules 中。

那么,我们是否可以优化这个过程呢?

答案是肯定的。

我们可以将 supabase 的二进制文件下载到本地,然后将其安装到 node_modules 中。

在项目根目录终端执行下面的命令来下载文件:

Terminal window
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.txt
curl -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]

Terminal window
obiscr@192 my-project % 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:
pnpm patch-commit '/Users/obiscr/projects/my-project/node_modules/.pnpm_patches/[email protected]'

node_modules/.pnpm_patches/[email protected] 目录下,你会看到如下内容:

  • 文件夹node_modules/
    • 文件夹.pnpm_patches
      • 文件夹[email protected]
        • 文件夹scripts
          • postinstall.js
        • LICENSE
        • package.json
        • README.md
      • state.json

然后编辑 postinstall.js 文件。

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's
const 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.json
const 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 下载。

编辑完成以后,我们提交更改。

Terminal window
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 skipped
Packages: +20
++++++++++++++++++++
Progress: resolved 0, reused 45, downloaded 0, added 20, done
node_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 配置。

package.json
{
"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/
    • 文件夹node_modules/
    • 文件夹patches/
    • 文件夹src/
    • package.json

同时我们更改 package.json 中的 patchedDependencies 配置。

package.json
{
"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分钟,有了非常明显的提升。

Terminal window
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 266ms
Done in 1m 25.2s using pnpm v10.10.0

本文介绍了如何通过 pnpm patch 命令来加速 pnpmpostinstall 脚本。希望对你有用。