自定义npm本地包API:npm i包名安装失败问题求助
问题原因分析
当执行npm i my-package时,npm不会直接请求tarball文件,而是先向registry地址发送GET请求获取包的元数据(JSON格式)——元数据需要包含包的版本、tarball下载地址等核心信息。你的当前接口不管请求路径是什么,直接返回了二进制的tgz文件,npm尝试把二进制数据当作JSON解析,自然会抛出"Unexpected token in JSON"错误。
而npm i http://localhost:3000/tarballs/my-package能成功,是因为你直接指定了tarball的URL,npm会跳过元数据请求环节,直接下载二进制文件完成安装。
解决方案:修改Express接口适配npm规范
需要调整接口逻辑,区分两种请求场景:
- 请求包元数据(路径为
/tarballs/:packageName,不带.tgz后缀):返回符合npm规范的JSON元数据 - 请求tarball文件(路径为
/tarballs/:packageName.tgz):返回二进制包文件
修改后的代码示例:
const express = require('express'); const app = express(); const port = 3000; const fs = require('fs'); const path = require('path'); // 处理包元数据请求(不带.tgz后缀) app.get('/tarballs/:packageName', (req, res) => { const { packageName } = req.params; // 可根据实际包版本信息调整,这里以固定1.0.0版本为例 const packageMetadata = { "name": packageName, "versions": { "1.0.0": { "name": packageName, "version": "1.0.0", "dist": { "tarball": `http://localhost:3000/tarballs/${packageName}.tgz` } } }, "dist-tags": { "latest": "1.0.0" } }; res.json(packageMetadata); }); // 处理tarball文件请求(带.tgz后缀) app.get('/tarballs/:packageName.tgz', (req, res) => { const { packageName } = req.params; const tarballPath = path.join(__dirname, 'packages', `${packageName}.tgz`); // 先检查文件是否存在,避免无效请求 if (!fs.existsSync(tarballPath)) { return res.status(404).json({ error: 'Package not found' }); } res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename=${packageName}.tgz`); const readStream = fs.createReadStream(tarballPath); readStream.pipe(res); }); app.listen(port, () => { console.log(`API running on port ${port}`); });
关键说明
- 元数据结构必须符合npm registry基本规范,至少包含
versions(每个版本的详细信息,其中dist.tarball指向实际tarball的URL)和dist-tags(指定默认安装的版本) - 如果有多个版本,可以维护版本列表,或者读取每个包目录下的
package.json动态生成元数据 - 增加文件存在性检查,避免文件不存在时返回无效的二进制流
测试步骤
- 重启Express服务
- 执行
npm i my-package,此时npm会先请求元数据,再根据元数据中的tarball地址下载安装
不依赖Verdaccio的自制包服务器简化方案
如果上面的方案觉得复杂,可以采用更轻量的实现方式:用express.static托管packages文件夹,同时额外添加接口处理元数据请求,既满足npm i my-package的需求,也不影响npm i my-package.tgz的使用。
简化版代码示例:
const express = require('express'); const app = express(); const port = 3000; const fs = require('fs'); const path = require('path'); // 直接托管packages文件夹,访问/tarballs/xxx.tgz即可下载对应包 app.use('/tarballs', express.static(path.join(__dirname, 'packages'))); // 处理包元数据请求 app.get('/tarballs/:packageName', (req, res) => { const { packageName } = req.params; // 可选:将元数据存在packages目录下的xxx.json文件中,动态读取 const metaPath = path.join(__dirname, 'packages', `${packageName}.json`); if (fs.existsSync(metaPath)) { const meta = fs.readFileSync(metaPath, 'utf8'); res.json(JSON.parse(meta)); } else { // 无自定义元数据时返回默认模板 res.json({ "name": packageName, "versions": { "1.0.0": { "name": packageName, "version": "1.0.0", "dist": { "tarball": `http://localhost:3000/tarballs/${packageName}.tgz` } } }, "dist-tags": { "latest": "1.0.0" } }); } }); app.listen(port, () => { console.log(`Registry server running on port ${port}`); });
内容的提问来源于stack exchange,提问作者Swerk
相关产品推荐
相关产品推荐

