com.jimuwd.xian.registry-proxy 1.0.129 → 1.0.131

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 CHANGED
@@ -95,7 +95,6 @@ PROJECT_ROOT=$(find_project_root)
95
95
 
96
96
  # 定义锁文件和端口文件路径(固定在项目根目录)
97
97
  LOCK_FILE="$PROJECT_ROOT/.registry-proxy-install.lock"
98
- PORT_FILE="$PROJECT_ROOT/.registry-proxy-port"
99
98
 
100
99
  # 检查是否已经在运行(通过锁文件)
101
100
  if [ -f "$LOCK_FILE" ]; then
@@ -123,7 +122,8 @@ cleanup() {
123
122
 
124
123
  # 清理临时文件
125
124
  rm -f "$LOCK_FILE" 2>/dev/null
126
- rm -f "$PORT_FILE" 2>/dev/null
125
+ # PORT_FILE端口临时文件是registry-proxy服务器管理的文件这里不负责清理,服务器退出时会自动清理
126
+ #rm -f "$PORT_FILE" 2>/dev/null
127
127
 
128
128
  # 停止代理服务器
129
129
  if [ -n "${PROXY_PID:-}" ]; then
@@ -134,6 +134,7 @@ cleanup() {
134
134
  fi
135
135
 
136
136
  # 切换到项目根目录
137
+ # shellcheck disable=SC2164
137
138
  cd "$PROJECT_ROOT"
138
139
 
139
140
  # 清理 npmRegistryServer 配置
@@ -148,16 +149,21 @@ cleanup() {
148
149
  trap 'cleanup 1' SIGINT SIGTERM EXIT # 异常退出时调用 cleanup,退出码为 1
149
150
 
150
151
  # 切换到项目根目录
152
+ # shellcheck disable=SC2164
151
153
  cd "$PROJECT_ROOT"
152
154
 
153
- # 使用 yarn dlx 直接运行 registry-proxy,放入后台运行
155
+ # 使用 yarn dlx 直接运行 registry-proxy,可通过环境变量指定registry-proxy版本号,默认是latest,registry-proxy将会被放入后台运行,并在安装结束后自动退出。
154
156
  REGISTRY_PROXY_VERSION="${REGISTRY_PROXY_VERSION:-latest}"
155
157
  echo "Starting registry-proxy@$REGISTRY_PROXY_VERSION in the background (logs will be displayed below)..."
156
- yarn dlx com.jimuwd.xian.registry-proxy@"$REGISTRY_PROXY_VERSION" .registry-proxy.yml .yarnrc.yml ~/.yarnrc.yml &
158
+ # 下载registry-proxy临时可执行程序并运行 因yarn可能会缓存tarball url的缘故(yarn.lock内<package>.resolution值),这里不得已只能写死本地代理端口地址,以便无论是从缓存获取tarball url还是从代理服务提供的元数据获取tarball url地址都能成功下载tarball文件
159
+ # 但是注意 这个端口不能暴露到外部使用,只允许本地使用,避免不必要的安全隐患 事实上registry-proxy server也是只监听着::1本机端口的。
160
+ yarn dlx com.jimuwd.xian.registry-proxy@"$REGISTRY_PROXY_VERSION" .registry-proxy.yml .yarnrc.yml ~/.yarnrc.yml 40061 &
157
161
  PROXY_PID=$!
158
162
 
159
163
  # 等待代理服务器启动并写入端口,最多 30 秒
160
164
  echo "Waiting for proxy server to start (up to 30 seconds)..."
165
+ PORT_FILE="$PROJECT_ROOT/.registry-proxy-port"
166
+ # shellcheck disable=SC2034
161
167
  for i in {1..300}; do # 300 次循环,每次 0.1 秒,总共 30 秒
162
168
  if [ -f "$PORT_FILE" ]; then
163
169
  PROXY_PORT=$(cat "$PORT_FILE")
@@ -186,9 +192,9 @@ if [ -z "${PROXY_PORT:-}" ] || ! nc -z localhost "$PROXY_PORT" 2>/dev/null; then
186
192
  cleanup 1
187
193
  fi
188
194
 
189
- # 动态设置 npmRegistryServer 为代理地址
190
- yarn config set npmRegistryServer "http://localhost:$PROXY_PORT/"
191
- echo "Set npmRegistryServer to http://localhost:$PROXY_PORT/"
195
+ # 动态设置 npmRegistryServer 为代理地址 注意:yarn对“localhost”域名不友好,请直接使用 [::1]
196
+ yarn config set npmRegistryServer "http://[::1]:$PROXY_PORT"
197
+ echo "Set npmRegistryServer to http://[::1]:$PROXY_PORT"
192
198
 
193
199
  # 使用动态代理端口运行 yarn install,并捕获错误
194
200
  if ! yarn install; then
@@ -421,10 +427,10 @@ This project provides a proxy server (`registry-proxy`) that allows Yarn to fetc
421
427
 
422
428
  - **Node.js**: Version 14 or higher.
423
429
  - **Yarn**: Version 1.x or 2.x.
424
- - **netcat (`nc`)**: Required for port availability checks in the install script. Install via:
430
+ - **netcat (`nc`)**: Required for port availability checks in the installation script. Install via:
425
431
  - On macOS: `brew install netcat`
426
432
  - On Ubuntu: `sudo apt-get install netcat`
427
- - **Bash**: The install script requires a Bash-compatible shell.
433
+ - **Bash**: The installation script requires a Bash-compatible shell.
428
434
 
429
435
  ## Setup
430
436
 
@@ -436,7 +442,7 @@ The proxy server is published to your private registry. Install it as a dependen
436
442
  yarn add com.jimuwd.xian.registry-proxy --registry https://repo.jimuwd.com/jimuwd/~npm/
437
443
  ```
438
444
 
439
- Alternatively, the install script uses `npx` to run the proxy server, so you don't need to install it explicitly.
445
+ Alternatively, the installation script uses `npx` to run the proxy server, so you don't need to install it explicitly.
440
446
 
441
447
  ### 2. Configure Registries
442
448
 
@@ -495,7 +501,6 @@ PROJECT_ROOT=$(find_project_root)
495
501
 
496
502
  # 定义锁文件和端口文件路径(固定在项目根目录)
497
503
  LOCK_FILE="$PROJECT_ROOT/.registry-proxy-install.lock"
498
- PORT_FILE="$PROJECT_ROOT/.registry-proxy-port"
499
504
 
500
505
  # 检查是否已经在运行(通过锁文件)
501
506
  if [ -f "$LOCK_FILE" ]; then
@@ -523,7 +528,8 @@ cleanup() {
523
528
 
524
529
  # 清理临时文件
525
530
  rm -f "$LOCK_FILE" 2>/dev/null
526
- rm -f "$PORT_FILE" 2>/dev/null
531
+ # PORT_FILE端口临时文件是registry-proxy服务器管理的文件这里不负责清理,服务器退出时会自动清理
532
+ #rm -f "$PORT_FILE" 2>/dev/null
527
533
 
528
534
  # 停止代理服务器
529
535
  if [ -n "${PROXY_PID:-}" ]; then
@@ -534,6 +540,7 @@ cleanup() {
534
540
  fi
535
541
 
536
542
  # 切换到项目根目录
543
+ # shellcheck disable=SC2164
537
544
  cd "$PROJECT_ROOT"
538
545
 
539
546
  # 清理 npmRegistryServer 配置
@@ -548,16 +555,21 @@ cleanup() {
548
555
  trap 'cleanup 1' SIGINT SIGTERM EXIT # 异常退出时调用 cleanup,退出码为 1
549
556
 
550
557
  # 切换到项目根目录
558
+ # shellcheck disable=SC2164
551
559
  cd "$PROJECT_ROOT"
552
560
 
553
- # 使用 yarn dlx 直接运行 registry-proxy,放入后台运行
561
+ # 使用 yarn dlx 直接运行 registry-proxy,可通过环境变量指定registry-proxy版本号,默认是latest,registry-proxy将会被放入后台运行,并在安装结束后自动退出。
554
562
  REGISTRY_PROXY_VERSION="${REGISTRY_PROXY_VERSION:-latest}"
555
563
  echo "Starting registry-proxy@$REGISTRY_PROXY_VERSION in the background (logs will be displayed below)..."
556
- yarn dlx com.jimuwd.xian.registry-proxy@"$REGISTRY_PROXY_VERSION" .registry-proxy.yml .yarnrc.yml ~/.yarnrc.yml &
564
+ # 下载registry-proxy临时可执行程序并运行 因yarn可能会缓存tarball url的缘故(yarn.lock内<package>.resolution值),这里不得已只能写死本地代理端口地址,以便无论是从缓存获取tarball url还是从代理服务提供的元数据获取tarball url地址都能成功下载tarball文件
565
+ # 但是注意 这个端口不能暴露到外部使用,只允许本地使用,避免不必要的安全隐患 事实上registry-proxy server也是只监听着::1本机端口的。
566
+ yarn dlx com.jimuwd.xian.registry-proxy@"$REGISTRY_PROXY_VERSION" .registry-proxy.yml .yarnrc.yml ~/.yarnrc.yml 40061 &
557
567
  PROXY_PID=$!
558
568
 
559
569
  # 等待代理服务器启动并写入端口,最多 30 秒
560
570
  echo "Waiting for proxy server to start (up to 30 seconds)..."
571
+ PORT_FILE="$PROJECT_ROOT/.registry-proxy-port"
572
+ # shellcheck disable=SC2034
561
573
  for i in {1..300}; do # 300 次循环,每次 0.1 秒,总共 30 秒
562
574
  if [ -f "$PORT_FILE" ]; then
563
575
  PROXY_PORT=$(cat "$PORT_FILE")
@@ -586,9 +598,9 @@ if [ -z "${PROXY_PORT:-}" ] || ! nc -z localhost "$PROXY_PORT" 2>/dev/null; then
586
598
  cleanup 1
587
599
  fi
588
600
 
589
- # 动态设置 npmRegistryServer 为代理地址
590
- yarn config set npmRegistryServer "http://localhost:$PROXY_PORT/"
591
- echo "Set npmRegistryServer to http://localhost:$PROXY_PORT/"
601
+ # 动态设置 npmRegistryServer 为代理地址 注意:yarn对“localhost”域名不友好,请直接使用 [::1]
602
+ yarn config set npmRegistryServer "http://[::1]:$PROXY_PORT"
603
+ echo "Set npmRegistryServer to http://[::1]:$PROXY_PORT"
592
604
 
593
605
  # 使用动态代理端口运行 yarn install,并捕获错误
594
606
  if ! yarn install; then
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+ export {};
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+ // The above shebang allows direct execution with ts-node (install ts-node and typescript first)
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { execa } from 'execa';
6
+ import net from 'node:net';
7
+ import { fileURLToPath } from 'node:url';
8
+ // Constants
9
+ const REGISTRY_PROXY_VERSION = process.env.REGISTRY_PROXY_VERSION || 'latest';
10
+ const LOCK_FILE_NAME = '.registry-proxy-install.lock';
11
+ const PORT_FILE_NAME = '.registry-proxy-port';
12
+ const MAX_WAIT_TIME_MS = 30000; // 30 seconds
13
+ const CHECK_INTERVAL_MS = 100; // 0.1 seconds
14
+ // Global state
15
+ let proxyProcess = null;
16
+ let cleanupHandlers = [];
17
+ let signalHandlers = [];
18
+ // Helper functions
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ async function findProjectRoot(startDir = process.cwd()) {
21
+ let dir = startDir;
22
+ while (dir !== '/') {
23
+ try {
24
+ await fs.access(path.join(dir, 'package.json'));
25
+ return dir;
26
+ }
27
+ catch {
28
+ dir = path.dirname(dir);
29
+ }
30
+ }
31
+ throw new Error('Could not find project root (package.json not found)');
32
+ }
33
+ async function isPortAvailable(port) {
34
+ return new Promise(resolve => {
35
+ const server = net.createServer();
36
+ server.unref();
37
+ server.on('error', () => resolve(false));
38
+ server.listen({ port }, () => {
39
+ server.close(() => resolve(true));
40
+ });
41
+ });
42
+ }
43
+ async function waitForFile(filePath, timeoutMs) {
44
+ const startTime = Date.now();
45
+ while (Date.now() - startTime < timeoutMs) {
46
+ try {
47
+ await fs.access(filePath);
48
+ return true;
49
+ }
50
+ catch {
51
+ await new Promise(r => setTimeout(r, CHECK_INTERVAL_MS));
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ async function readPortFile(filePath) {
57
+ const content = await fs.readFile(filePath, 'utf-8');
58
+ const port = parseInt(content.trim(), 10);
59
+ if (isNaN(port) || port < 1 || port > 65535) {
60
+ throw new Error(`Invalid port number in ${filePath}`);
61
+ }
62
+ return port;
63
+ }
64
+ async function checkPortListening(port) {
65
+ return new Promise(resolve => {
66
+ const socket = new net.Socket();
67
+ socket.on('error', () => resolve(false));
68
+ socket.on('connect', () => {
69
+ socket.destroy();
70
+ resolve(true);
71
+ });
72
+ socket.connect({ port, host: '::1' });
73
+ });
74
+ }
75
+ // Cleanup management
76
+ async function cleanup(exitCode = 1) {
77
+ // Run all cleanup handlers in reverse order
78
+ for (const handler of [...cleanupHandlers].reverse()) {
79
+ try {
80
+ await handler(exitCode);
81
+ }
82
+ catch (err) {
83
+ console.error('Cleanup handler error:', err);
84
+ }
85
+ }
86
+ process.exit(exitCode);
87
+ }
88
+ function registerCleanup(handler) {
89
+ cleanupHandlers.push(handler);
90
+ }
91
+ function registerSignalHandler(handler) {
92
+ signalHandlers.push(handler);
93
+ }
94
+ // Main implementation
95
+ async function main() {
96
+ try {
97
+ // Find project root
98
+ const PROJECT_ROOT = await findProjectRoot();
99
+ const LOCK_FILE = path.join(PROJECT_ROOT, LOCK_FILE_NAME);
100
+ const PORT_FILE = path.join(PROJECT_ROOT, PORT_FILE_NAME);
101
+ // Check for existing lock file
102
+ try {
103
+ await fs.access(LOCK_FILE);
104
+ console.log(`Custom install script is already running (lock file ${LOCK_FILE} exists).`);
105
+ console.log(`If this is unexpected, please remove ${LOCK_FILE} and try again.`);
106
+ return cleanup(0);
107
+ }
108
+ catch {
109
+ }
110
+ // Create lock file
111
+ await fs.writeFile(LOCK_FILE, process.pid.toString());
112
+ registerCleanup(async () => {
113
+ try {
114
+ await fs.unlink(LOCK_FILE);
115
+ }
116
+ catch {
117
+ }
118
+ });
119
+ // Change to project root
120
+ process.chdir(PROJECT_ROOT);
121
+ // Start registry proxy
122
+ console.log(`Starting registry-proxy@${REGISTRY_PROXY_VERSION} in the background...`);
123
+ proxyProcess = execa('yarn', [
124
+ 'dlx',
125
+ `com.jimuwd.xian.registry-proxy@${REGISTRY_PROXY_VERSION}`,
126
+ '.registry-proxy.yml',
127
+ '.yarnrc.yml',
128
+ path.join(process.env.HOME || '', '.yarnrc.yml'),
129
+ '40061'
130
+ ], {
131
+ detached: true,
132
+ stdio: 'inherit'
133
+ });
134
+ registerCleanup(async (exitCode) => {
135
+ if (proxyProcess && !proxyProcess.killed) {
136
+ console.log('Stopping proxy server...');
137
+ try {
138
+ proxyProcess.kill('SIGTERM');
139
+ await proxyProcess;
140
+ }
141
+ catch {
142
+ // Ignore errors
143
+ }
144
+ console.log('Proxy server stopped.');
145
+ }
146
+ });
147
+ // Wait for proxy to start
148
+ console.log('Waiting for proxy server to start (up to 30 seconds)...');
149
+ const fileExists = await waitForFile(PORT_FILE, MAX_WAIT_TIME_MS);
150
+ if (!fileExists) {
151
+ throw new Error(`Proxy server failed to create port file after ${MAX_WAIT_TIME_MS / 1000} seconds`);
152
+ }
153
+ const PROXY_PORT = await readPortFile(PORT_FILE);
154
+ const portAvailable = await isPortAvailable(PROXY_PORT);
155
+ if (!portAvailable) {
156
+ throw new Error(`Port ${PROXY_PORT} is already in use by another process`);
157
+ }
158
+ const isListening = await checkPortListening(PROXY_PORT);
159
+ if (!isListening) {
160
+ throw new Error(`Proxy server not listening on port ${PROXY_PORT}`);
161
+ }
162
+ console.log(`Proxy server is ready on port ${PROXY_PORT}!`);
163
+ // Configure yarn
164
+ await execa('yarn', ['config', 'set', 'npmRegistryServer', `http://[::1]:${PROXY_PORT}`]);
165
+ console.log(`Set npmRegistryServer to http://[::1]:${PROXY_PORT}`);
166
+ registerCleanup(async () => {
167
+ try {
168
+ await execa('yarn', ['config', 'unset', 'npmRegistryServer']);
169
+ console.log('Cleared npmRegistryServer configuration');
170
+ }
171
+ catch {
172
+ }
173
+ });
174
+ // Run yarn install
175
+ console.log('Running yarn install...');
176
+ try {
177
+ await execa('yarn', ['install'], { stdio: 'inherit' });
178
+ }
179
+ catch (err) {
180
+ throw new Error('yarn install failed');
181
+ }
182
+ // Success
183
+ await cleanup(0);
184
+ }
185
+ catch (err) {
186
+ console.error('Error:', err instanceof Error ? err.message : String(err));
187
+ await cleanup(1);
188
+ }
189
+ }
190
+ // Signal handling
191
+ ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach(signal => {
192
+ process.on(signal, async () => {
193
+ console.log(`Received ${signal}, cleaning up...`);
194
+ for (const handler of signalHandlers) {
195
+ try {
196
+ await handler();
197
+ }
198
+ catch (err) {
199
+ console.error('Signal handler error:', err);
200
+ }
201
+ }
202
+ await cleanup(1);
203
+ });
204
+ });
205
+ // Start the program
206
+ main().catch(err => {
207
+ console.error('Unhandled error:', err);
208
+ cleanup(1);
209
+ });
package/dist/index.js CHANGED
File without changes
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 优雅退出
3
+ * 本函数是对process.exit的封装,同时执行资源释放动作,程序必须统一调用本方法退出,决不允许直接调用{@link process.exit}来退出。
4
+ */
5
+ export declare function gracefulShutdown(): Promise<void>;
6
+ /**
7
+ * 注册进程shutdown hook程序
8
+ * @note 本shutdown hook程序不支持KILL -9强制杀进程命令
9
+ */
10
+ export declare function registerProcessShutdownHook(): void;
@@ -0,0 +1,36 @@
1
+ import { deletePortFile } from "../port.js";
2
+ import logger from "../utils/logger.js";
3
+ /**
4
+ * 优雅退出
5
+ * 本函数是对process.exit的封装,同时执行资源释放动作,程序必须统一调用本方法退出,决不允许直接调用{@link process.exit}来退出。
6
+ */
7
+ export async function gracefulShutdown() {
8
+ try {
9
+ logger.info('Shutdown...');
10
+ await deletePortFile();
11
+ logger.info('Shutdown completed.');
12
+ process.exit(0);
13
+ }
14
+ catch (err) {
15
+ logger.error('Failed to clean:', err);
16
+ process.exit(1);
17
+ }
18
+ }
19
+ /**
20
+ * 注册进程shutdown hook程序
21
+ * @note 本shutdown hook程序不支持KILL -9强制杀进程命令
22
+ */
23
+ export function registerProcessShutdownHook() {
24
+ process.on('SIGINT', async () => {
25
+ logger.info('收到 SIGINT(Ctrl+C)');
26
+ await gracefulShutdown();
27
+ });
28
+ process.on('SIGTERM', async () => {
29
+ logger.info('收到 SIGTERM');
30
+ await gracefulShutdown();
31
+ });
32
+ process.on('uncaughtException', async (err) => {
33
+ logger.info('uncaughtException:', err);
34
+ await gracefulShutdown();
35
+ });
36
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { Server as HttpServer } from 'node:http';
3
+ import { Server as HttpsServer } from 'node:https';
4
+ export declare function startProxyServer(proxyConfigPath?: string, localYarnConfigPath?: string, globalYarnConfigPath?: string, port?: number): Promise<HttpServer | HttpsServer>;
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ import http, { createServer } from 'node:http';
3
+ import https, { createServer as createHttpsServer } from 'node:https';
4
+ import { promises as fsPromises, readFileSync } from 'node:fs';
5
+ import { load } from 'js-yaml';
6
+ import fetch from 'node-fetch';
7
+ import { homedir } from 'os';
8
+ import { join, resolve } from 'path';
9
+ import { URL } from 'url';
10
+ import logger from "../utils/logger.js";
11
+ import ConcurrencyLimiter from "../utils/ConcurrencyLimiter.js";
12
+ import { gracefulShutdown, registerProcessShutdownHook } from "./gracefullShutdown.js";
13
+ import { writePortFile } from "../port.js";
14
+ const { readFile } = fsPromises;
15
+ const limiter = new ConcurrencyLimiter(Infinity);
16
+ function removeEndingSlashAndForceStartingSlash(str) {
17
+ if (!str)
18
+ return '/';
19
+ let trimmed = str.trim();
20
+ if (trimmed === '/')
21
+ return '/';
22
+ if (trimmed === '')
23
+ return '/';
24
+ if (!trimmed.startsWith('/'))
25
+ trimmed = '/' + trimmed;
26
+ return trimmed.replace(/\/+$/, '');
27
+ }
28
+ function normalizeUrl(httpOrHttpsUrl) {
29
+ if (!httpOrHttpsUrl.startsWith("http"))
30
+ throw new Error("http(s) url must starts with 'http(s)://'");
31
+ try {
32
+ const urlObj = new URL(httpOrHttpsUrl);
33
+ if (urlObj.protocol === 'http:' && (urlObj.port === '80' || urlObj.port === '')) {
34
+ urlObj.port = '';
35
+ }
36
+ else if (urlObj.protocol === 'https:' && (urlObj.port === '443' || urlObj.port === '')) {
37
+ urlObj.port = '';
38
+ }
39
+ return urlObj.toString().replace(/\/+$/, '');
40
+ }
41
+ catch (e) {
42
+ throw new Error(`Invalid URL: ${httpOrHttpsUrl}`, { cause: e });
43
+ }
44
+ }
45
+ function resolvePath(path) {
46
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : resolve(path);
47
+ }
48
+ function removeRegistryPrefix(tarballUrl, registries) {
49
+ const normalizedTarball = normalizeUrl(tarballUrl);
50
+ const normalizedRegistries = registries
51
+ .map(r => normalizeUrl(r.normalizedRegistryUrl))
52
+ .sort((a, b) => b.length - a.length);
53
+ for (const normalizedRegistry of normalizedRegistries) {
54
+ if (normalizedTarball.startsWith(normalizedRegistry)) {
55
+ return normalizedTarball.slice(normalizedRegistry.length) || '/';
56
+ }
57
+ }
58
+ throw new Error(`Can't find tarball url ${tarballUrl} does not match given registries ${normalizedRegistries}`);
59
+ }
60
+ async function readProxyConfig(proxyConfigPath = './.registry-proxy.yml') {
61
+ let config = undefined;
62
+ const resolvedPath = resolvePath(proxyConfigPath);
63
+ try {
64
+ const content = await readFile(resolvedPath, 'utf8');
65
+ config = load(content);
66
+ }
67
+ catch (e) {
68
+ logger.error(`Failed to load proxy config from ${resolvedPath}:`, e);
69
+ await gracefulShutdown();
70
+ }
71
+ if (!config?.registries) {
72
+ logger.error('Missing required "registries" field in config');
73
+ await gracefulShutdown();
74
+ }
75
+ return config;
76
+ }
77
+ async function readYarnConfig(path) {
78
+ try {
79
+ const content = await readFile(resolvePath(path), 'utf8');
80
+ return load(content);
81
+ }
82
+ catch (e) {
83
+ logger.warn(`Failed to load Yarn config from ${path}:`, e);
84
+ return {};
85
+ }
86
+ }
87
+ async function loadProxyInfo(proxyConfigPath = './.registry-proxy.yml', localYarnConfigPath = './.yarnrc.yml', globalYarnConfigPath = join(homedir(), '.yarnrc.yml')) {
88
+ const [proxyConfig, localYarnConfig, globalYarnConfig] = await Promise.all([
89
+ readProxyConfig(proxyConfigPath),
90
+ readYarnConfig(localYarnConfigPath),
91
+ readYarnConfig(globalYarnConfigPath)
92
+ ]);
93
+ const registryMap = new Map();
94
+ for (const [proxiedRegUrl, proxyRegConfig] of Object.entries(proxyConfig.registries)) {
95
+ const normalizedProxiedRegUrl = normalizeUrl(proxiedRegUrl);
96
+ let token = proxyRegConfig?.npmAuthToken;
97
+ if (!token) {
98
+ const yarnConfigs = [localYarnConfig, globalYarnConfig];
99
+ for (const yarnConfig of yarnConfigs) {
100
+ if (yarnConfig.npmRegistries) {
101
+ const foundEntry = Object.entries(yarnConfig.npmRegistries)
102
+ .find(([registryUrl]) => normalizedProxiedRegUrl === normalizeUrl(registryUrl));
103
+ if (foundEntry) {
104
+ const [, registryConfig] = foundEntry;
105
+ if (registryConfig?.npmAuthToken) {
106
+ token = registryConfig.npmAuthToken;
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ registryMap.set(normalizedProxiedRegUrl, { normalizedRegistryUrl: normalizedProxiedRegUrl, token });
114
+ }
115
+ const registries = Array.from(registryMap.values());
116
+ const https = proxyConfig.https;
117
+ const basePath = removeEndingSlashAndForceStartingSlash(proxyConfig.basePath);
118
+ return { registries, https, basePath };
119
+ }
120
+ async function fetchFromRegistry(registry, targetUrl, reqFromDownstreamClient, limiter) {
121
+ await limiter.acquire();
122
+ try {
123
+ logger.info(`Fetching from upstream: ${targetUrl}`);
124
+ const headersFromDownstreamClient = reqFromDownstreamClient.headers;
125
+ const authorizationHeaders = registry.token ? { Authorization: `Bearer ${registry.token}` } : {};
126
+ // 合并 headersFromDownstreamClient 和 authorizationHeaders
127
+ const mergedHeaders = { ...headersFromDownstreamClient, ...authorizationHeaders, };
128
+ // (mergedHeaders as any).connection = "keep-alive"; 不允许私自添加 connection: keep-alive header,应当最终下游客户端自己的选择
129
+ // 替换“Host”头为upstream的host
130
+ const upstreamHost = new URL(registry.normalizedRegistryUrl).host;
131
+ if (mergedHeaders.host) {
132
+ logger.info(`Replace 'Host=${mergedHeaders.host}' header in downstream request to upstream 'Host=${upstreamHost}' header when proxying to upstream ${targetUrl}.`);
133
+ mergedHeaders.host = upstreamHost;
134
+ }
135
+ const response = await fetch(targetUrl, { headers: mergedHeaders });
136
+ if (response.ok) {
137
+ logger.debug(`Success response from upstream ${targetUrl}: ${response.status} ${response.statusText}
138
+ content-type=${response.headers.get('content-type')} content-encoding=${response.headers.get('content-encoding')} content-length=${response.headers.get('content-length')} transfer-encoding=${response.headers.get('transfer-encoding')}`);
139
+ return response;
140
+ }
141
+ else {
142
+ logger.info(`Failure response from upstream ${targetUrl}: ${response.status} ${response.statusText}
143
+ content-type=${response.headers.get('content-type')} content-encoding=${response.headers.get('content-encoding')} content-length=${response.headers.get('content-length')} transfer-encoding=${response.headers.get('transfer-encoding')}
144
+ body=${await response.text()}`);
145
+ return null;
146
+ }
147
+ }
148
+ catch (e) {
149
+ if (e instanceof Error) {
150
+ logger.error(e.code === 'ECONNREFUSED'
151
+ ? `Registry ${registry.normalizedRegistryUrl} unreachable [ECONNREFUSED]`
152
+ : `Error from ${registry.normalizedRegistryUrl}: ${e.message}`);
153
+ }
154
+ else {
155
+ logger.error("Unknown error", e);
156
+ }
157
+ return null;
158
+ }
159
+ finally {
160
+ limiter.release();
161
+ }
162
+ }
163
+ async function writeResponseToDownstreamClient(registryInfo, targetUrl, resToDownstreamClient, upstreamResponse, reqFromDownstreamClient, proxyInfo, proxyPort, registryInfos) {
164
+ logger.info(`Writing upstream registry server ${registryInfo.normalizedRegistryUrl}'s ${upstreamResponse.status}${upstreamResponse.statusText ? (' "' + upstreamResponse.statusText + '"') : ''} response to downstream client.`);
165
+ if (!upstreamResponse.ok)
166
+ throw new Error("Only 2xx upstream response is supported");
167
+ try {
168
+ const contentType = upstreamResponse.headers.get("content-type");
169
+ if (!contentType) {
170
+ logger.error(`Response from upstream content-type header is absent, ${targetUrl} `);
171
+ await gracefulShutdown();
172
+ }
173
+ else if (contentType.includes('application/json')) { // JSON 处理逻辑
174
+ const data = await upstreamResponse.json();
175
+ if (data.versions) { // 处理node依赖包元数据
176
+ logger.info("Write package meta data application/json response from upstream to downstream", targetUrl);
177
+ const host = reqFromDownstreamClient.headers.host /*|| `[::1]:${proxyPort}`*/;
178
+ const baseUrl = `${proxyInfo.https ? 'https' : 'http'}://${host}${proxyInfo.basePath === '/' ? '' : proxyInfo.basePath}`;
179
+ for (const versionKey in data.versions) {
180
+ const packageVersion = data.versions[versionKey];
181
+ const tarball = packageVersion?.dist?.tarball;
182
+ if (tarball) {
183
+ const path = removeRegistryPrefix(tarball, registryInfos);
184
+ const proxiedTarballUrl = `${baseUrl}${path}${new URL(tarball).search || ''}`;
185
+ packageVersion.dist.tarball = proxiedTarballUrl;
186
+ }
187
+ }
188
+ }
189
+ else {
190
+ logger.info("Write none meta data application/json response from upstream to downstream", targetUrl);
191
+ }
192
+ const bodyData = JSON.stringify(data);
193
+ resToDownstreamClient.removeHeader('Transfer-Encoding');
194
+ // 默认是 connection: keep-alive 和 keep-alive: timeout=5,这里直接给它咔嚓掉
195
+ resToDownstreamClient.setHeader('Connection', 'close');
196
+ resToDownstreamClient.removeHeader('Keep-Alive');
197
+ resToDownstreamClient.setHeader('content-type', contentType);
198
+ resToDownstreamClient.setHeader('content-length', Buffer.byteLength(bodyData));
199
+ logger.info(`Response to downstream client headers`, JSON.stringify(resToDownstreamClient.getHeaders()), targetUrl);
200
+ resToDownstreamClient.writeHead(upstreamResponse.status).end(bodyData);
201
+ }
202
+ else if (contentType.includes('application/octet-stream')) { // 二进制流处理
203
+ logger.info("Write application/octet-stream response from upstream to downstream", targetUrl);
204
+ if (!upstreamResponse.body) {
205
+ logger.error(`Empty response body from upstream ${targetUrl}`);
206
+ resToDownstreamClient.writeHead(502).end('Empty Upstream Response');
207
+ }
208
+ else {
209
+ // write back to client
210
+ // 准备通用响应头信息
211
+ const safeHeaders = new Map();
212
+ safeHeaders.set("Content-Type", contentType);
213
+ // 复制所有可能需要的头信息(不包含安全相关的敏感头信息,如access-control-allow-origin、set-cookie、server、strict-transport-security等,这意味着代理服务器向下游客户端屏蔽了这些认证等安全数据)
214
+ // 也不能包含cf-cache-status、cf-ray(Cloudflare 特有字段)可能干扰客户端解析。
215
+ const headersToCopy = ['cache-control', 'connection', 'content-encoding', 'content-length', /*'date',*/ 'etag', 'last-modified', 'transfer-encoding', 'vary',];
216
+ headersToCopy.forEach(header => {
217
+ const value = upstreamResponse.headers.get(header);
218
+ if (value)
219
+ safeHeaders.set(header, value);
220
+ });
221
+ // 必须使用 ServerResponse.setHeaders(safeHeaders)来覆盖现有headers而不是ServerResponse.writeHead(status,headers)来合并headers!
222
+ // 这个坑害我浪费很久事件来调试!
223
+ resToDownstreamClient.setHeaders(safeHeaders);
224
+ // 调试代码
225
+ resToDownstreamClient.removeHeader('content-encoding');
226
+ resToDownstreamClient.removeHeader('Transfer-Encoding');
227
+ resToDownstreamClient.removeHeader('content-length');
228
+ resToDownstreamClient.setHeader('transfer-encoding', 'chunked');
229
+ // 默认是 connection: keep-alive 和 keep-alive: timeout=5,这里直接给它咔嚓掉
230
+ resToDownstreamClient.removeHeader('connection');
231
+ resToDownstreamClient.setHeader('connection', 'close');
232
+ resToDownstreamClient.removeHeader('Keep-Alive');
233
+ resToDownstreamClient.removeHeader('content-type');
234
+ resToDownstreamClient.setHeader('content-type', contentType);
235
+ logger.info(`Response to downstream client headers`, JSON.stringify(resToDownstreamClient.getHeaders()), targetUrl);
236
+ // 不再writeHead()而是设置状态码,然后执行pipe操作
237
+ resToDownstreamClient.statusCode = upstreamResponse.status;
238
+ resToDownstreamClient.statusMessage = upstreamResponse.statusText;
239
+ // resToDownstreamClient.writeHead(upstreamResponse.status);
240
+ // stop pipe when req from client is closed accidentally.
241
+ const cleanup = () => {
242
+ reqFromDownstreamClient.off('close', cleanup);
243
+ logger.info(`Req from downstream client is closed, stop pipe from upstream ${targetUrl} to downstream client.`);
244
+ // upstreamResponse.body?.unpipe();
245
+ };
246
+ reqFromDownstreamClient.on('close', cleanup);
247
+ reqFromDownstreamClient.on('end', () => logger.info("Req from downstream client ends."));
248
+ // clean up when server closes connection to downstream client.
249
+ const cleanup0 = () => {
250
+ resToDownstreamClient.off('close', cleanup0);
251
+ logger.info(`Close connection to downstream client, upstream url is ${targetUrl}`);
252
+ // upstreamResponse.body?.unpipe();
253
+ };
254
+ resToDownstreamClient.on('close', cleanup0);
255
+ // write back body data (chunked probably)
256
+ // pipe upstream body to downstream client
257
+ upstreamResponse.body.pipe(resToDownstreamClient, { end: true });
258
+ upstreamResponse.body
259
+ .on('data', (chunk) => logger.debug(`Chunk transferred from ${targetUrl} to downstream client size=${chunk.length}`))
260
+ .on('end', () => {
261
+ logger.info(`Upstream server ${targetUrl} response.body ended.`);
262
+ // resToDownstreamClient.end();
263
+ })
264
+ // connection will be closed automatically when all chunk data is transferred (after stream ends).
265
+ .on('close', () => {
266
+ logger.info(`Upstream server ${targetUrl} closed connection.`);
267
+ })
268
+ .on('error', (err) => {
269
+ const errMsg = `Stream error: ${err.message}`;
270
+ logger.error(errMsg);
271
+ resToDownstreamClient.destroy(new Error(errMsg, { cause: err, }));
272
+ reqFromDownstreamClient.destroy(new Error(errMsg, { cause: err, }));
273
+ });
274
+ }
275
+ }
276
+ else {
277
+ logger.warn(`Write unsupported content-type=${contentType} response from upstream to downstream ${targetUrl}`);
278
+ const bodyData = await upstreamResponse.text();
279
+ resToDownstreamClient.removeHeader('Transfer-Encoding');
280
+ // 默认是 connection: keep-alive 和 keep-alive: timeout=5,这里直接给它咔嚓掉
281
+ resToDownstreamClient.setHeader('Connection', 'close');
282
+ resToDownstreamClient.removeHeader('Keep-Alive');
283
+ resToDownstreamClient.setHeader('content-type', contentType);
284
+ resToDownstreamClient.setHeader('content-length', Buffer.byteLength(bodyData));
285
+ logger.info(`Response to downstream client headers`, JSON.stringify(resToDownstreamClient.getHeaders()), targetUrl);
286
+ resToDownstreamClient.writeHead(upstreamResponse.status).end(bodyData);
287
+ }
288
+ }
289
+ catch (err) {
290
+ logger.error('Failed to write upstreamResponse:', err);
291
+ if (!resToDownstreamClient.headersSent) {
292
+ resToDownstreamClient.writeHead(502).end('Internal Server Error');
293
+ }
294
+ }
295
+ }
296
+ function getDownstreamClientIp(req) {
297
+ // 如果经过代理(如 Nginx),取 X-Forwarded-For 的第一个 IP
298
+ const forwardedFor = req.headers['x-forwarded-for'];
299
+ if (forwardedFor) {
300
+ return forwardedFor.toString().split(',')[0].trim();
301
+ }
302
+ // 直接连接时,取 socket.remoteAddress
303
+ return req.socket.remoteAddress;
304
+ }
305
+ export async function startProxyServer(proxyConfigPath, localYarnConfigPath, globalYarnConfigPath, port = 0) {
306
+ const proxyInfo = await loadProxyInfo(proxyConfigPath, localYarnConfigPath, globalYarnConfigPath);
307
+ const registryInfos = proxyInfo.registries;
308
+ const basePathPrefixedWithSlash = removeEndingSlashAndForceStartingSlash(proxyInfo.basePath);
309
+ logger.info('Active registries:', registryInfos.map(r => r.normalizedRegistryUrl));
310
+ logger.info('Proxy base path:', basePathPrefixedWithSlash);
311
+ logger.info('HTTPS:', !!proxyInfo.https);
312
+ const requestHandler = async (reqFromDownstreamClient, resToDownstreamClient) => {
313
+ const downstreamUserAgent = reqFromDownstreamClient.headers["user-agent"]; // "curl/x.x.x"
314
+ const downstreamIp = getDownstreamClientIp(reqFromDownstreamClient);
315
+ const downstreamRequestedHttpMethod = reqFromDownstreamClient.method; // "GET", "POST", etc.
316
+ const downstreamRequestedHost = reqFromDownstreamClient.headers.host; // "example.com:8080"
317
+ const downstreamRequestedFullPath = reqFromDownstreamClient.url; // "/some/path?param=1&param=2"
318
+ logger.info(`Received downstream request from '${downstreamUserAgent}' ${downstreamIp} ${downstreamRequestedHttpMethod} ${downstreamRequestedHost} ${downstreamRequestedFullPath}
319
+ Proxy server request handler rate limit is ${limiter.maxConcurrency}`);
320
+ if (!downstreamRequestedFullPath || !downstreamRequestedHost) {
321
+ logger.warn(`400 Invalid Request, downstream ${downstreamUserAgent} req.url is absent or downstream.headers.host is absent.`);
322
+ resToDownstreamClient.writeHead(400).end('Invalid Request');
323
+ return;
324
+ }
325
+ const baseUrl = `${proxyInfo.https ? 'https' : 'http'}://${downstreamRequestedHost}`;
326
+ const fullUrl = new URL(downstreamRequestedFullPath, baseUrl);
327
+ if (!fullUrl.pathname.startsWith(basePathPrefixedWithSlash)) {
328
+ resToDownstreamClient.writeHead(404).end('Not Found');
329
+ return;
330
+ }
331
+ const path = basePathPrefixedWithSlash === '/'
332
+ ? fullUrl.pathname
333
+ : fullUrl.pathname.slice(basePathPrefixedWithSlash.length);
334
+ const search = fullUrl.search || '';
335
+ const targetPath = path + search;
336
+ logger.info(`Proxying to ${targetPath}`);
337
+ // 按配置顺序尝试注册表,获取第一个成功响应
338
+ let successfulResponseFromUpstream = null;
339
+ let targetRegistry = null;
340
+ let targetUrl = null;
341
+ for (const registry_i of registryInfos) {
342
+ targetRegistry = registry_i;
343
+ targetUrl = `${targetRegistry.normalizedRegistryUrl}${targetPath}`;
344
+ if (reqFromDownstreamClient.destroyed) {
345
+ // 如果下游客户端自己提前断开(或取消)请求,那么这里不再逐个fallback方式请求(fetch)上游数据了,直接退出循环并返回
346
+ logger.warn(`Downstream ${reqFromDownstreamClient.headers["user-agent"]} request is destroyed, no need to proxy request to upstream ${targetUrl} any more.`);
347
+ return;
348
+ }
349
+ const okResponseOrNull = await fetchFromRegistry(targetRegistry, targetUrl, reqFromDownstreamClient, limiter);
350
+ if (okResponseOrNull) {
351
+ successfulResponseFromUpstream = okResponseOrNull;
352
+ break;
353
+ }
354
+ }
355
+ // 统一回写响应
356
+ if (successfulResponseFromUpstream) {
357
+ await writeResponseToDownstreamClient(targetRegistry, targetUrl, resToDownstreamClient, successfulResponseFromUpstream, reqFromDownstreamClient, proxyInfo, port, registryInfos);
358
+ }
359
+ else {
360
+ resToDownstreamClient.writeHead(404).end('All upstream registries failed');
361
+ }
362
+ };
363
+ let server;
364
+ if (proxyInfo.https) {
365
+ const { key, cert } = proxyInfo.https;
366
+ const keyPath = resolvePath(key);
367
+ const certPath = resolvePath(cert);
368
+ try {
369
+ await fsPromises.access(keyPath);
370
+ await fsPromises.access(certPath);
371
+ }
372
+ catch (e) {
373
+ logger.error(`HTTPS config error: key or cert file not found`, e);
374
+ await gracefulShutdown();
375
+ }
376
+ const httpsOptions = {
377
+ key: readFileSync(keyPath),
378
+ cert: readFileSync(certPath),
379
+ };
380
+ server = createHttpsServer(httpsOptions, requestHandler);
381
+ logger.info("Proxy server's maxSockets is", https.globalAgent.maxSockets);
382
+ }
383
+ else {
384
+ server = createServer(requestHandler);
385
+ logger.info("Proxy server's maxSockets is", http.globalAgent.maxSockets);
386
+ }
387
+ // server参数暂时写死
388
+ const serverMaxConnections = 10000;
389
+ const serverTimeoutMs = 60000;
390
+ logger.info(`Proxy server's initial maxConnections is ${server.maxConnections}, adjusting to ${serverMaxConnections}`);
391
+ server.maxConnections = serverMaxConnections;
392
+ logger.info(`Proxy server's initial timeout is ${server.timeout}ms, adjusting to ${serverTimeoutMs}ms`);
393
+ server.timeout = serverTimeoutMs;
394
+ const promisedServer = new Promise((resolve, reject) => {
395
+ const errHandler = async (err) => {
396
+ if (err.code === 'EADDRINUSE') {
397
+ logger.error(`Port ${port} is in use, please specify a different port or free it.`, err);
398
+ await gracefulShutdown();
399
+ }
400
+ logger.error('Server error:', err);
401
+ reject(err);
402
+ };
403
+ const connectionHandler = (socket) => {
404
+ logger.info("Server on connection");
405
+ socket.setTimeout(60000);
406
+ socket.setKeepAlive(true, 30000);
407
+ };
408
+ server.on('error', errHandler /*this handler will call 'reject'*/);
409
+ server.on('connection', connectionHandler);
410
+ // 为了代理服务器的安全性,暂时只监听本机ipv6地址【::1】,不能对本机之外暴露本代理服务地址避免造成安全隐患
411
+ // 注意:截止目前yarn客户端如果通过localhost:<port>来访问本服务,可能会报错ECONNREFUSED错误码,原因是yarn客户端环境解析“localhost”至多个地址,它会尝试轮询每个地址。
412
+ const ipv6OnlyHost = '::1';
413
+ const listenOptions = { port, host: ipv6OnlyHost, ipv6Only: true };
414
+ server.listen(listenOptions, async () => {
415
+ const addressInfo = server.address();
416
+ port = addressInfo.port; // 回写上层局部变量
417
+ await writePortFile(port);
418
+ logger.info(`Proxy server running on ${proxyInfo.https ? 'https' : 'http'}://[${ipv6OnlyHost}]:${port}${basePathPrefixedWithSlash === '/' ? '' : basePathPrefixedWithSlash}`);
419
+ resolve(server);
420
+ });
421
+ });
422
+ return promisedServer;
423
+ }
424
+ // 当前模块是否是直接运行的入口文件,而不是被其他模块导入的
425
+ if (import.meta.url === `file://${process.argv[1]}`) {
426
+ registerProcessShutdownHook();
427
+ const [, , configPath, localYarnPath, globalYarnPath, port] = process.argv;
428
+ startProxyServer(configPath, localYarnPath, globalYarnPath, parseInt(port, 10) || 0).catch(async (err) => {
429
+ logger.error('Failed to start server:', err);
430
+ await gracefulShutdown();
431
+ });
432
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "com.jimuwd.xian.registry-proxy",
3
- "version": "1.0.129",
3
+ "version": "1.0.131",
4
4
  "description": "A lightweight npm registry proxy with fallback support",
5
5
  "type": "module",
6
- "main": "dist/index.js",
6
+ "main": "dist/server/index.js",
7
7
  "bin": {
8
- "registry-proxy": "dist/index.js"
8
+ "registry-proxy": "dist/server/index.js"
9
9
  },
10
10
  "files": [
11
11
  "dist"
@@ -15,6 +15,7 @@
15
15
  "deploy": "yarn build && yarn npm publish"
16
16
  },
17
17
  "dependencies": {
18
+ "execa": "9.5.2",
18
19
  "js-yaml": "4.1.0",
19
20
  "node-fetch": "3.3.2"
20
21
  },