com.jimuwd.xian.registry-proxy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD ADDED
@@ -0,0 +1,202 @@
1
+
2
+ # README.md
3
+
4
+ ## 项目简介
5
+
6
+ `com.jimuwd.xian.registry-proxy` 是一个轻量级的 npm 代理服务器,旨在为 Node.js 项目提供多 registry 的代理和 fallback 机制。它通过读取 Yarn 的配置文件(`.yarnrc.yml`),将多个 npm registry(如公共 registry、私有仓库等)代理到一个本地端口(默认 `4873`),并支持在本地 token 缺失时从全局配置文件回退获取认证信息。
7
+ 主要功能:
8
+ - **多 registry 代理**:将多个 npm registry 统一代理到本地端口,提供单一访问入口。
9
+ - **Fallback 机制**:按配置顺序尝试多个 registry,直到找到可用包。
10
+ - **灵活的 token 管理**:优先使用本地配置文件中的 `npmAuthToken`,缺失时从全局配置文件读取。
11
+ - **优雅关闭**:支持通过 SIGTERM 信号优雅停止服务。
12
+
13
+ 这个工具特别适合需要同时访问多个 npm 源(例如公司私有仓库和公共 npm registry)的开发场景,尤其在 CI/CD 或本地开发中,能显著提高依赖安装的稳定性和效率。
14
+
15
+ ---
16
+
17
+ ## 快速上手指南
18
+
19
+ ### 安装
20
+ 在你的业务项目中,将 `com.jimuwd.xian.registry-proxy` 添加为开发依赖。假设你的私有 Yarn 仓库地址为 `https://your-private-registry.example.com/`:
21
+
22
+ ```bash
23
+ yarn add --dev com.jimuwd.xian.registry-proxy --registry https://your-private-registry.example.com/
24
+ ```
25
+
26
+ ### 配置
27
+ 1. **本地 `.yarnrc.yml`**
28
+ 在业务项目根目录创建或编辑 `.yarnrc.yml`,指定需要代理的 registry 和本地代理地址。对于需要认证的 registry,建议添加 `npmAlwaysAuth: true`:
29
+ ```yaml
30
+ npmRegistries:
31
+ "http://localhost:4873/":
32
+ npmAuthToken: "local-token" # 可选
33
+ "https://registry.npmjs.org/":
34
+ # token 可省略,从全局读取
35
+ "https://your-private-registry.example.com/":
36
+ npmAuthToken: "private-token" # 可选
37
+ npmAlwaysAuth: true # 强制要求认证
38
+
39
+ npmRegistryServer: "http://localhost:4873/"
40
+ unsafeHttpWhitelist:
41
+ - "localhost"
42
+ ```
43
+
44
+ 2. **全局 `~/.yarnrc.yml`(可选)**
45
+ 如果本地未提供某些 registry 的 token,可以在用户主目录下的 `.yarnrc.yml` 配置回退 token:
46
+ ```yaml
47
+ npmRegistries:
48
+ "https://registry.npmjs.org/":
49
+ npmAuthToken: "global-npm-token"
50
+ "https://your-private-registry.example.com/":
51
+ npmAuthToken: "global-private-token"
52
+ npmAlwaysAuth: true
53
+ ```
54
+
55
+ ### 使用
56
+ 1. **创建启动脚本 `start-proxy.sh`**
57
+ 在项目根目录添加以下脚本,用于启动代理并安装依赖:
58
+ ```bash
59
+ #!/bin/bash
60
+
61
+ # 启动代理服务器并记录 PID
62
+ yarn run registry-proxy .yarnrc.yml ~/.yarnrc.yml &
63
+ PROXY_PID=$!
64
+
65
+ # 等待代理服务器启动,最多 10 秒
66
+ echo "Waiting for proxy server to start on port 4873..."
67
+ for i in {1..100}; do
68
+ if nc -z localhost 4873 2>/dev/null; then
69
+ echo "Proxy server is ready!"
70
+ break
71
+ fi
72
+ sleep 0.1
73
+ done
74
+
75
+ # 检查是否成功启动
76
+ if ! nc -z localhost 4873 2>/dev/null; then
77
+ echo "Error: Proxy server failed to start on port 4873"
78
+ kill $PROXY_PID
79
+ exit 1
80
+ fi
81
+
82
+ # 使用本地代理运行 yarn install
83
+ yarn install --registry http://localhost:4873/
84
+
85
+ # 停止代理服务器
86
+ echo "Stopping proxy server..."
87
+ kill -TERM $PROXY_PID
88
+ wait $PROXY_PID 2>/dev/null
89
+ echo "Proxy server stopped."
90
+ ```
91
+
92
+ 2. **添加 Yarn 脚本**
93
+ 在 `package.json` 中定义快捷命令:
94
+ ```json
95
+ {
96
+ "scripts": {
97
+ "start-proxy": "registry-proxy .yarnrc.yml ~/.yarnrc.yml",
98
+ "install": "bash start-proxy.sh"
99
+ }
100
+ }
101
+ ```
102
+
103
+ 3. **运行**
104
+ 执行以下命令启动代理并安装依赖:
105
+ ```bash
106
+ yarn install
107
+ ```
108
+ - 代理会在安装完成后自动停止。
109
+
110
+ ### 输出示例
111
+ 运行后,你会看到类似以下输出:
112
+ ```
113
+ Waiting for proxy server to start on port 4873...
114
+ Proxy server started at http://localhost:4873
115
+ Proxy server is ready!
116
+ [yarn install 输出]
117
+ Stopping proxy server...
118
+ Proxy server stopped.
119
+ ```
120
+
121
+ ---
122
+
123
+ ## 技术说明
124
+
125
+ ### 项目结构
126
+ ```
127
+ com.jimuwd.xian.registry-proxy/
128
+ ├── src/
129
+ │ └── index.ts # 主逻辑:代理服务器实现
130
+ ├── package.json # 项目配置和依赖
131
+ ├── tsconfig.json # TypeScript 配置
132
+ └── dist/ # 编译后的输出目录
133
+ ```
134
+
135
+ ### 功能实现
136
+ 1. **配置加载(`loadRegistries`)**:
137
+ - **本地配置文件**:从指定路径(默认 `./.yarnrc.yml`)读取 `npmRegistries`,提取 `registryUrl` 和 `npmAuthToken`。
138
+ - **全局配置文件**:如果本地 token 缺失,从指定路径(默认 `~/.yarnrc.yml`)读取对应 `registryUrl` 的 token。
139
+ - **安全设计**:本地 token 缺失时回退到全局 token 的设计,是为了避免将敏感的 `npmAuthToken` 写入项目配置文件并提交到代码仓库,从而降低安全隐患。全局配置文件(如 `~/.yarnrc.yml`)通常存储在用户主目录,不会被版本控制系统追踪。
140
+ - **优先级**:本地 token > 全局 token > 无 token。
141
+ - **错误处理**:本地配置文件必须存在且包含 `npmRegistries`,否则退出。
142
+
143
+ 2. **代理逻辑**:
144
+ - **服务器**:使用 Node.js 的 `http.createServer` 创建本地 HTTP 服务,默认监听 `4873` 端口。
145
+ - **请求转发**:将所有请求按配置顺序转发到目标 registry,附带对应的 `Authorization: Bearer <token>`(如果存在)。
146
+ - **Fallback**:依次尝试每个 registry,直到返回成功响应(`response.ok`)或全部失败(返回 404)。
147
+
148
+ 3. **进程管理**:
149
+ - **优雅关闭**:监听 `SIGTERM` 信号,关闭服务器并退出进程。
150
+ - **脚本集成**:通过 shell 脚本记录 PID,安装完成后发送 SIGTERM 停止服务。
151
+
152
+ ### 技术栈
153
+ - **语言**:TypeScript(ES Modules)。
154
+ - **模块系统**:`"module": "nodenext"`,兼容 Node.js v20+。
155
+ - **依赖**:
156
+ - `node-fetch@^3.3.2`:发起 HTTP 请求。
157
+ - `js-yaml@^4.1.0`:解析 `.yarnrc.yml` 文件。
158
+ - **Node.js 版本**:推荐 v14+,测试于 v20.17.0。
159
+
160
+ ### CLI 参数
161
+ ```bash
162
+ registry-proxy [localConfigPath] [globalConfigPath] [port]
163
+ ```
164
+ - `localConfigPath`:本地 `.yarnrc.yml` 路径,默认 `./.yarnrc.yml`。
165
+ - `globalConfigPath`:全局 `.yarnrc.yml` 路径,默认 `~/.yarnrc.yml`。
166
+ - `port`:代理服务器端口,默认 `4873`。
167
+
168
+ 示例:
169
+ ```bash
170
+ yarn run registry-proxy ./custom.yml ~/.custom.yml 54321
171
+ ```
172
+
173
+ ### 配置说明
174
+ - **`npmAlwaysAuth: true`**:
175
+ - 在 `.yarnrc.yml` 中为需要认证的 registry 添加此配置,表示对该 registry 的所有请求都必须携带 `npmAuthToken`。
176
+ - **原因**:某些私有 npm 仓库(例如 Nexus 或 Artifactory)要求即使访问公共包,也需要认证。设置 `npmAlwaysAuth: true` 确保代理在转发请求时始终附带 token,避免因缺少认证导致的 401 错误。
177
+ - 如果不设置此项,Yarn 可能只在下载受限包时发送 token,而对公开包跳过认证,可能导致请求失败。
178
+
179
+ ### 注意事项
180
+ 1. **端口冲突**:
181
+ - 默认端口 `4873` 是 Verdaccio 的惯用端口,可能与其他工具冲突。
182
+ - 检查端口占用:`lsof -i :4873`。
183
+ - 可通过参数指定其他端口(如 `54321`)。
184
+ 2. **配置文件格式**:
185
+ - 确保 `.yarnrc.yml` 遵循 Yarn 的格式,`npmRegistries` 为必填项。
186
+ 3. **日志**:
187
+ - 当前通过 `console.log` 输出启动信息,可扩展为文件日志。
188
+ 4. **安全性**:
189
+ - 代理运行于本地,未开放外部访问,确保 `unsafeHttpWhitelist` 配置正确。
190
+ - 避免将 token 写入本地 `.yarnrc.yml`,优先使用全局配置或环境变量。
191
+
192
+ ### 开发与发布
193
+ 1. **构建**:
194
+ ```bash
195
+ yarn build
196
+ ```
197
+ 2. **发布到私有仓库**:
198
+ ```bash
199
+ yarn publish --registry https://your-private-registry.example.com/
200
+ ```
201
+
202
+ ---
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from 'http';
3
+ import { readFile } from 'fs/promises';
4
+ import { load } from 'js-yaml';
5
+ import fetch from 'node-fetch';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ async function loadRegistries(localConfigPath = './.yarnrc.yml', globalConfigPath = join(homedir(), '.yarnrc.yml')) {
9
+ let localConfig = {};
10
+ try {
11
+ const localYamlContent = await readFile(localConfigPath, 'utf8');
12
+ localConfig = load(localYamlContent);
13
+ console.log(`Loaded local config from ${localConfigPath}`);
14
+ }
15
+ catch (e) {
16
+ console.error(`Failed to load ${localConfigPath}: ${e.message}`);
17
+ process.exit(1);
18
+ }
19
+ if (!localConfig.npmRegistries || !Object.keys(localConfig.npmRegistries).length) {
20
+ console.error(`No npmRegistries found in ${localConfigPath}`);
21
+ process.exit(1);
22
+ }
23
+ let globalConfig = {};
24
+ try {
25
+ const globalYamlContent = await readFile(globalConfigPath, 'utf8');
26
+ globalConfig = load(globalYamlContent);
27
+ console.log(`Loaded global config from ${globalConfigPath}`);
28
+ }
29
+ catch (e) {
30
+ console.warn(`Failed to load ${globalConfigPath}: ${e.message}`);
31
+ }
32
+ const registries = Object.entries(localConfig.npmRegistries).map(([url, localRegConfig]) => {
33
+ let token = localRegConfig.npmAuthToken?.replace(/\${(.+)}/, (_, key) => process.env[key] || '') || localRegConfig.npmAuthToken;
34
+ if (!token && globalConfig.npmRegistries && globalConfig.npmRegistries[url]) {
35
+ token = globalConfig.npmRegistries[url].npmAuthToken?.replace(/\${(.+)}/, (_, key) => process.env[key] || '') || globalConfig.npmRegistries[url].npmAuthToken;
36
+ console.log(`Token for ${url} not found in local config, using global token`);
37
+ }
38
+ console.log(`Registry ${url}: token=${token ? 'present' : 'missing'}`);
39
+ return { url, token };
40
+ });
41
+ return registries;
42
+ }
43
+ export async function startProxyServer(localConfigPath, globalConfigPath, port = 4873) {
44
+ console.log('Starting proxy server...');
45
+ const registries = await loadRegistries(localConfigPath, globalConfigPath);
46
+ const server = createServer(async (req, res) => {
47
+ if (!req.url || req.method !== 'GET') {
48
+ res.writeHead(400);
49
+ res.end('Bad Request');
50
+ return;
51
+ }
52
+ const pathname = new URL(req.url, `http://${req.headers.host}`).pathname;
53
+ const fetchPromises = registries.map(async ({ url: registry, token }) => {
54
+ const targetUrl = `${registry}${pathname}`;
55
+ try {
56
+ const response = await fetch(targetUrl, {
57
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
58
+ });
59
+ if (response.ok)
60
+ return response;
61
+ throw new Error(`Failed: ${response.status}`);
62
+ }
63
+ catch (e) {
64
+ console.error(`Fetch failed for ${targetUrl}: ${e.message}`);
65
+ return null;
66
+ }
67
+ });
68
+ const responses = await Promise.all(fetchPromises);
69
+ const successResponse = responses.find((r) => r !== null);
70
+ if (successResponse) {
71
+ res.writeHead(successResponse.status, {
72
+ 'Content-Type': successResponse.headers.get('Content-Type') || 'application/octet-stream',
73
+ });
74
+ successResponse.body?.pipe(res);
75
+ }
76
+ else {
77
+ res.writeHead(404);
78
+ res.end('Package not found');
79
+ }
80
+ });
81
+ server.listen(port, () => console.log(`Proxy server started at http://localhost:${port}`));
82
+ process.on('SIGTERM', () => {
83
+ console.log('Received SIGTERM, shutting down...');
84
+ server.close(() => process.exit(0));
85
+ });
86
+ return server;
87
+ }
88
+ if (import.meta.url === `file://${process.argv[1]}`) {
89
+ const localConfigPath = process.argv[2];
90
+ const globalConfigPath = process.argv[3];
91
+ const port = parseInt(process.argv[4], 10) || 4873;
92
+ console.log(`CLI: localConfigPath=${localConfigPath || './.yarnrc.yml'}, globalConfigPath=${globalConfigPath || join(homedir(), '.yarnrc.yml')}, port=${port}`);
93
+ startProxyServer(localConfigPath, globalConfigPath, port).catch(err => {
94
+ console.error('Startup failed:', err.message);
95
+ process.exit(1);
96
+ });
97
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "com.jimuwd.xian.registry-proxy",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "A lightweight npm registry proxy with fallback support",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "registry-proxy": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "deploy": "yarn build && yarn npm publish"
13
+ },
14
+ "dependencies": {
15
+ "js-yaml": "latest",
16
+ "node-fetch": "latest"
17
+ },
18
+ "devDependencies": {
19
+ "@types/js-yaml": "latest",
20
+ "@types/node": "latest",
21
+ "typescript": "latest"
22
+ },
23
+ "packageManager": "yarn@4.6.0"
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import { createServer, Server } from 'http';
3
+ import { readFile } from 'fs/promises';
4
+ import { load } from 'js-yaml';
5
+ import fetch, { Response } from 'node-fetch';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+
9
+ interface RegistryConfig { npmAuthToken?: string; }
10
+ interface YarnConfig { npmRegistries?: Record<string, RegistryConfig>; }
11
+
12
+ async function loadRegistries(localConfigPath = './.yarnrc.yml', globalConfigPath = join(homedir(), '.yarnrc.yml')): Promise<{ url: string; token?: string }[]> {
13
+ let localConfig: YarnConfig = {};
14
+ try {
15
+ const localYamlContent = await readFile(localConfigPath, 'utf8');
16
+ localConfig = load(localYamlContent) as YarnConfig;
17
+ console.log(`Loaded local config from ${localConfigPath}`);
18
+ } catch (e) {
19
+ console.error(`Failed to load ${localConfigPath}: ${(e as Error).message}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ if (!localConfig.npmRegistries || !Object.keys(localConfig.npmRegistries).length) {
24
+ console.error(`No npmRegistries found in ${localConfigPath}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ let globalConfig: YarnConfig = {};
29
+ try {
30
+ const globalYamlContent = await readFile(globalConfigPath, 'utf8');
31
+ globalConfig = load(globalYamlContent) as YarnConfig;
32
+ console.log(`Loaded global config from ${globalConfigPath}`);
33
+ } catch (e) {
34
+ console.warn(`Failed to load ${globalConfigPath}: ${(e as Error).message}`);
35
+ }
36
+
37
+ const registries = Object.entries(localConfig.npmRegistries).map(([url, localRegConfig]) => {
38
+ let token = localRegConfig.npmAuthToken?.replace(/\${(.+)}/, (_, key) => process.env[key] || '') || localRegConfig.npmAuthToken;
39
+ if (!token && globalConfig.npmRegistries && globalConfig.npmRegistries[url]) {
40
+ token = globalConfig.npmRegistries[url].npmAuthToken?.replace(/\${(.+)}/, (_, key) => process.env[key] || '') || globalConfig.npmRegistries[url].npmAuthToken;
41
+ console.log(`Token for ${url} not found in local config, using global token`);
42
+ }
43
+ console.log(`Registry ${url}: token=${token ? 'present' : 'missing'}`);
44
+ return { url, token };
45
+ });
46
+
47
+ return registries;
48
+ }
49
+
50
+ export async function startProxyServer(localConfigPath?: string, globalConfigPath?: string, port: number = 4873): Promise<Server> {
51
+ console.log('Starting proxy server...');
52
+ const registries = await loadRegistries(localConfigPath, globalConfigPath);
53
+
54
+ const server = createServer(async (req, res) => {
55
+ if (!req.url || req.method !== 'GET') {
56
+ res.writeHead(400);
57
+ res.end('Bad Request');
58
+ return;
59
+ }
60
+
61
+ const pathname = new URL(req.url, `http://${req.headers.host}`).pathname;
62
+
63
+ const fetchPromises = registries.map(async ({ url: registry, token }) => {
64
+ const targetUrl = `${registry}${pathname}`;
65
+ try {
66
+ const response = await fetch(targetUrl, {
67
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
68
+ });
69
+ if (response.ok) return response;
70
+ throw new Error(`Failed: ${response.status}`);
71
+ } catch (e) {
72
+ console.error(`Fetch failed for ${targetUrl}: ${(e as Error).message}`);
73
+ return null;
74
+ }
75
+ });
76
+
77
+ const responses = await Promise.all(fetchPromises);
78
+ const successResponse = responses.find((r: Response | null) => r !== null);
79
+
80
+ if (successResponse) {
81
+ res.writeHead(successResponse.status, {
82
+ 'Content-Type': successResponse.headers.get('Content-Type') || 'application/octet-stream',
83
+ });
84
+ successResponse.body?.pipe(res);
85
+ } else {
86
+ res.writeHead(404);
87
+ res.end('Package not found');
88
+ }
89
+ });
90
+
91
+ server.listen(port, () => console.log(`Proxy server started at http://localhost:${port}`));
92
+
93
+ process.on('SIGTERM', () => {
94
+ console.log('Received SIGTERM, shutting down...');
95
+ server.close(() => process.exit(0));
96
+ });
97
+
98
+ return server;
99
+ }
100
+
101
+ if (import.meta.url === `file://${process.argv[1]}`) {
102
+ const localConfigPath = process.argv[2];
103
+ const globalConfigPath = process.argv[3];
104
+ const port = parseInt(process.argv[4], 10) || 4873;
105
+ console.log(`CLI: localConfigPath=${localConfigPath || './.yarnrc.yml'}, globalConfigPath=${globalConfigPath || join(homedir(), '.yarnrc.yml')}, port=${port}`);
106
+ startProxyServer(localConfigPath, globalConfigPath, port).catch(err => {
107
+ console.error('Startup failed:', (err as Error).message);
108
+ process.exit(1);
109
+ });
110
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "node",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }