How to speed up pnpm postinstall
Problem description
Section titled “Problem description”If you have used supabase cli in your project, you may have encountered the following problem:
Directorymy-project
Directorynode_modules/
- …
Directorysrc/
- …
- package.json
package.json
contains the following content:
{ "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" ] } }}
After running pnpm install
, you will see the following output:
(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.
It took nearly 4 minutes, but the installation still failed. After multiple tests, this situation often occurs when the network conditions are poor.
Solution
Section titled “Solution”This article uses supabase cli as an example, but this method is universal. The essence is to change the postinstall.js
file, so that it uses the local file first when installing, and if the local file does not exist, it executes the default logic to download from github
.
Since the postinstall
script of supabase cli
failed to install, let's take a look at what the postinstall
script of supabase cli
does.
Enter the node_modules/supabase
directory, the structure is as follows:
Directorysupabase
Directorybin/
- …
Directoryscripts/
- postinstall.js
- LICENSE
- package.json
- README.md
postinstall.js
contains the following content:
#!/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();
In short, it downloads the binary file of supabase
from the github
repository of supabase
and installs it into node_modules
.
Then, can we optimize this process?
The answer is yes.
We can download the binary file of supabase
to the local, and then install it into node_modules
.
Execute the following command in the terminal of the project root directory to download the file:
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
Execute pnpm patch [email protected]
in the terminal.
Patch: You can now edit the package at:
/Users/obiscr/projects/my-project/node_modules/.pnpm_patches/[email protected]
To commit your changes, run:
In the node_modules/.pnpm_patches/[email protected]
directory, you will see the following content:
Directorynode_modules/
Directory.pnpm_patches
Directory[email protected]
Directoryscripts
- postinstall.js
- LICENSE
- package.json
- README.md
- state.json
Then edit the postinstall.js
file.
#!/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();
The new part of this logic will use the files in the lib/supabase
directory first, and if the file does not exist, it will execute the default logic to download from github
.
After editing, we submit the changes.
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
This will generate a [email protected]
file in the patches
directory in the project root directory.
Directorymy-project
Directorylib/
Directorysupabase/
- supabase_2.34.3_checksums.txt
- supabase_darwin_arm64.tar.gz
Directorynode_modules/
- …
Directorypatches/
Directorysrc/
- …
- package.json
It will also generate a patchedDependencies
configuration in 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": { } }}
Then we move the [email protected]
file to the lib/supabase
directory.
At this time, the directory structure is as follows:
Directorymy-project
Directorylib/
Directorysupabase/
- supabase_2.34.3_checksums.txt
- supabase_darwin_arm64.tar.gz
- [email protected]
Directorynode_modules/
- …
Directorypatches/
Directorysrc/
- …
- package.json
We also change the patchedDependencies
configuration in 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": { } }}
Test changes
Section titled “Test changes”Delete pnpm-lock.yaml
, node_modules
, and then run pnpm install
, you should be able to see that the installation speed has been significantly accelerated. It only takes 266ms to complete the installation. Compared to the previous nearly 4 minutes, there is a very obvious improvement.
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
Conclusion
Section titled “Conclusion”This article introduces how to speed up the pnpm
postinstall
script through the pnpm patch
command. I hope it is useful to you.