Skip to content

How to speed up pnpm postinstall

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:

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"
]
}
}
}

After running pnpm install, you will see the following output:

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.

It took nearly 4 minutes, but the installation still failed. After multiple tests, this situation often occurs when the network conditions are poor.

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:

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();

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:

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

Execute pnpm patch [email protected] in the terminal.

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]'

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.

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();

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.

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

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.

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/
    • Directorynode_modules/
    • Directorypatches/
    • Directorysrc/
    • package.json

We also change the patchedDependencies configuration in package.json.

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": {
}
}
}

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.

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

This article introduces how to speed up the pnpm postinstall script through the pnpm patch command. I hope it is useful to you.