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 +29 -17
- package/dist/client/yarn-install.d.ts +2 -0
- package/dist/client/yarn-install.js +209 -0
- package/dist/index.js +0 -0
- package/dist/server/gracefullShutdown.d.ts +10 -0
- package/dist/server/gracefullShutdown.js +36 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +432 -0
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
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://
|
|
191
|
-
echo "Set npmRegistryServer to http://
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
591
|
-
echo "Set npmRegistryServer to http://
|
|
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,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¶m=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.
|
|
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
|
},
|