com.jimuwd.xian.registry-proxy 1.0.33 → 1.0.35

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.
Files changed (3) hide show
  1. package/dist/index.js +74 -102
  2. package/package.json +1 -1
  3. package/src/index.ts +99 -110
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { homedir } from 'os';
8
8
  import { join, resolve } from 'path';
9
9
  import { URL } from 'url';
10
10
  import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
11
12
  const { readFile, writeFile } = fsPromises;
12
13
  class ConcurrencyLimiter {
13
14
  maxConcurrency;
@@ -138,6 +139,60 @@ async function loadProxyInfo(proxyConfigPath = './.registry-proxy.yml', localYar
138
139
  const basePath = removeEndingSlashAndForceStartingSlash(proxyConfig.basePath);
139
140
  return { registries, https, basePath };
140
141
  }
142
+ async function fetchFromRegistry(registry, path, search, limiter) {
143
+ await limiter.acquire();
144
+ try {
145
+ const targetUrl = `${registry.normalizedRegistryUrl}${path}${search || ''}`;
146
+ console.log(`Fetching from: ${targetUrl}`);
147
+ const headers = registry.token ? { Authorization: `Bearer ${registry.token}` } : undefined;
148
+ const response = await fetch(targetUrl, { headers });
149
+ console.log(`Response from ${targetUrl}: ${response.status} ${response.statusText}`);
150
+ return response.ok ? response : null;
151
+ }
152
+ catch (e) {
153
+ if (e instanceof Error) {
154
+ console.error(e.code === 'ECONNREFUSED'
155
+ ? `Registry ${registry.normalizedRegistryUrl} unreachable [ECONNREFUSED]`
156
+ : `Error from ${registry.normalizedRegistryUrl}: ${e.message}`);
157
+ }
158
+ return null;
159
+ }
160
+ finally {
161
+ limiter.release();
162
+ }
163
+ }
164
+ async function writeResponse(res, response, req, proxyInfo, proxyPort, registryInfos) {
165
+ const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
166
+ if (contentType.includes('application/json')) {
167
+ const data = await response.json();
168
+ if (data.versions) {
169
+ const host = req.headers.host || `localhost:${proxyPort}`;
170
+ const baseUrl = `${proxyInfo.https ? 'https' : 'http'}://${host}${proxyInfo.basePath === '/' ? '' : proxyInfo.basePath}`;
171
+ for (const version in data.versions) {
172
+ const tarball = data.versions[version]?.dist?.tarball;
173
+ if (tarball) {
174
+ const path = removeRegistryPrefix(tarball, registryInfos);
175
+ data.versions[version].dist.tarball = `${baseUrl}${path}${new URL(tarball).search || ''}`;
176
+ }
177
+ }
178
+ }
179
+ res.writeHead(200, { 'Content-Type': 'application/json' });
180
+ res.end(JSON.stringify(data));
181
+ }
182
+ else {
183
+ const safeHeaders = {
184
+ 'content-type': contentType,
185
+ 'connection': 'keep-alive',
186
+ };
187
+ ['cache-control', 'etag', 'last-modified', 'content-encoding'].forEach(header => {
188
+ const value = response.headers.get(header);
189
+ if (value)
190
+ safeHeaders[header] = value;
191
+ });
192
+ res.writeHead(response.status, safeHeaders);
193
+ await pipeline(Readable.fromWeb(response.body), res);
194
+ }
195
+ }
141
196
  export async function startProxyServer(proxyConfigPath, localYarnConfigPath, globalYarnConfigPath, port = 0) {
142
197
  const proxyInfo = await loadProxyInfo(proxyConfigPath, localYarnConfigPath, globalYarnConfigPath);
143
198
  const registryInfos = proxyInfo.registries;
@@ -148,118 +203,35 @@ export async function startProxyServer(proxyConfigPath, localYarnConfigPath, glo
148
203
  let proxyPort;
149
204
  const requestHandler = async (req, res) => {
150
205
  if (!req.url || !req.headers.host) {
151
- console.error('Invalid request: missing URL or host header');
152
206
  res.writeHead(400).end('Invalid Request');
153
207
  return;
154
208
  }
155
209
  const fullUrl = new URL(req.url, `${proxyInfo.https ? 'https' : 'http'}://${req.headers.host}`);
156
- console.log(`Proxy server received request on ${fullUrl.toString()}`);
157
210
  if (!fullUrl.pathname.startsWith(basePathPrefixedWithSlash)) {
158
- console.error(`Path ${fullUrl.pathname} does not match basePath ${basePathPrefixedWithSlash}`);
159
211
  res.writeHead(404).end('Not Found');
160
212
  return;
161
213
  }
162
- const relativePathPrefixedWithSlash = basePathPrefixedWithSlash === '/' ? fullUrl.pathname : fullUrl.pathname.slice(basePathPrefixedWithSlash.length);
163
- console.log(`Proxying: ${relativePathPrefixedWithSlash}`);
164
- // 修改为按顺序尝试注册表,找到第一个成功响应即返回
165
- for (const { normalizedRegistryUrl, token } of registryInfos) {
166
- await limiter.acquire();
167
- let response = null;
168
- try {
169
- const targetUrl = `${normalizedRegistryUrl}${relativePathPrefixedWithSlash}${fullUrl.search || ''}`;
170
- console.log(`Fetching from: ${targetUrl}`);
171
- const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
172
- response = await fetch(targetUrl, { headers });
173
- console.log(`Response from ${targetUrl}: ${response.status} ${response.statusText}`);
174
- if (response.ok) {
175
- const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
176
- if (contentType.includes('application/json')) {
177
- // application/json 元数据
178
- try {
179
- const data = await response.json();
180
- if (data.versions) {
181
- const requestHeadersHostFromYarnClient = req.headers.host || 'localhost:' + proxyPort;
182
- console.log("Request headers.host from yarn client is", requestHeadersHostFromYarnClient);
183
- const proxyBaseUrlNoSuffixedWithSlash = `${proxyInfo.https ? 'https' : 'http'}://${requestHeadersHostFromYarnClient}${basePathPrefixedWithSlash === '/' ? '' : basePathPrefixedWithSlash}`;
184
- console.log("proxyBaseUrlNoSuffixedWithSlash", proxyBaseUrlNoSuffixedWithSlash);
185
- for (const version in data.versions) {
186
- const dist = data.versions[version]?.dist;
187
- if (dist?.tarball) {
188
- const originalUrl = new URL(dist.tarball);
189
- const originalSearchParamsStr = originalUrl.search || '';
190
- const tarballPathPrefixedWithSlash = removeRegistryPrefix(dist.tarball, registryInfos);
191
- dist.tarball = `${proxyBaseUrlNoSuffixedWithSlash}${tarballPathPrefixedWithSlash}${originalSearchParamsStr}`;
192
- if (!tarballPathPrefixedWithSlash.startsWith("/"))
193
- console.error("bad tarballPath, must be PrefixedWithSlash", tarballPathPrefixedWithSlash);
194
- }
195
- }
196
- }
197
- res.writeHead(200, { 'Content-Type': 'application/json' });
198
- res.end(JSON.stringify(data));
199
- return;
200
- }
201
- catch (e) {
202
- console.error('Failed to parse JSON response:', e);
203
- // 继续尝试下一个注册表
204
- }
205
- }
206
- else {
207
- if (!response.body) {
208
- console.error(`Empty response body from ${response.url}, status: ${response.status}`);
209
- continue;
210
- }
211
- // 设置正确的响应头
212
- const contentLength = response.headers.get('Content-Length');
213
- const safeHeaders = {
214
- 'content-type': contentType,
215
- 'connection': 'keep-alive'
216
- };
217
- if (contentLength && !isNaN(Number(contentLength))) {
218
- safeHeaders['content-length'] = contentLength;
219
- }
220
- // 复制其他可能有用的头信息
221
- const headersToCopy = ['cache-control', 'etag', 'last-modified'];
222
- headersToCopy.forEach(header => {
223
- const value = response?.headers.get(header);
224
- if (value) {
225
- safeHeaders[header] = value;
226
- }
227
- });
228
- res.writeHead(response.status, safeHeaders);
229
- // 使用 pipeline 处理流
230
- const { pipeline } = await import('stream');
231
- const { promisify } = await import('util');
232
- const pipelineAsync = promisify(pipeline);
233
- try {
234
- // 将 ReadableStream 转换为 Node.js Readable
235
- const nodeStream = Readable.fromWeb(response.body);
236
- // 双向终止保护
237
- req.on('close', () => nodeStream.destroy());
238
- res.on('close', () => nodeStream.destroy());
239
- await pipelineAsync(nodeStream, res);
240
- return; // 成功完成管道传输后返回
241
- }
242
- catch (pipeError) {
243
- console.error(`Stream pipeline error for ${relativePathPrefixedWithSlash}:`, pipeError);
244
- if (!res.headersSent) {
245
- res.writeHead(502).end('Stream Error');
246
- }
247
- continue;
248
- }
249
- }
250
- }
251
- }
252
- catch (e) {
253
- console.error(`Failed to fetch from ${normalizedRegistryUrl}:`, e);
254
- }
255
- finally {
256
- // 不需要手动销毁 ReadableStream
257
- limiter.release();
214
+ const path = basePathPrefixedWithSlash === '/'
215
+ ? fullUrl.pathname
216
+ : fullUrl.pathname.slice(basePathPrefixedWithSlash.length);
217
+ // 顺序尝试注册表,获取第一个成功响应
218
+ let successfulResponse = null;
219
+ for (const registry of registryInfos) {
220
+ if (req.destroyed)
221
+ break;
222
+ const response = await fetchFromRegistry(registry, path, fullUrl.search, limiter);
223
+ if (response) {
224
+ successfulResponse = response;
225
+ break;
258
226
  }
259
227
  }
260
- // 所有注册表都尝试失败
261
- console.error(`All registries failed for ${relativePathPrefixedWithSlash}`);
262
- res.writeHead(404).end('Not Found - All upstream registries failed');
228
+ // 统一回写响应
229
+ if (successfulResponse) {
230
+ await writeResponse(res, successfulResponse, req, proxyInfo, proxyPort, registryInfos);
231
+ }
232
+ else {
233
+ res.writeHead(404).end('All upstream registries failed');
234
+ }
263
235
  };
264
236
  let server;
265
237
  if (proxyInfo.https) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.jimuwd.xian.registry-proxy",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "type": "module",
5
5
  "description": "A lightweight npm registry proxy with fallback support",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {homedir} from 'os';
9
9
  import {join, resolve} from 'path';
10
10
  import {URL} from 'url';
11
11
  import {Readable} from "node:stream";
12
+ import {pipeline} from "node:stream/promises";
12
13
 
13
14
  const {readFile, writeFile} = fsPromises;
14
15
 
@@ -186,6 +187,79 @@ async function loadProxyInfo(
186
187
  return {registries, https, basePath};
187
188
  }
188
189
 
190
+ async function fetchFromRegistry(
191
+ registry: RegistryInfo,
192
+ path: string,
193
+ search: string,
194
+ limiter: ConcurrencyLimiter
195
+ ): Promise<Response | null> {
196
+ await limiter.acquire();
197
+ try {
198
+ const targetUrl = `${registry.normalizedRegistryUrl}${path}${search || ''}`;
199
+ console.log(`Fetching from: ${targetUrl}`);
200
+ const headers = registry.token ? { Authorization: `Bearer ${registry.token}` } : undefined;
201
+ const response = await fetch(targetUrl, { headers });
202
+ console.log(`Response from ${targetUrl}: ${response.status} ${response.statusText}`);
203
+ return response.ok ? response : null;
204
+ } catch (e) {
205
+ if (e instanceof Error) {
206
+ console.error(
207
+ (e as any).code === 'ECONNREFUSED'
208
+ ? `Registry ${registry.normalizedRegistryUrl} unreachable [ECONNREFUSED]`
209
+ : `Error from ${registry.normalizedRegistryUrl}: ${e.message}`
210
+ );
211
+ }
212
+ return null;
213
+ } finally {
214
+ limiter.release();
215
+ }
216
+ }
217
+
218
+ async function writeResponse(
219
+ res: ServerResponse,
220
+ response: Response,
221
+ req: IncomingMessage,
222
+ proxyInfo: ProxyInfo,
223
+ proxyPort: number,
224
+ registryInfos: RegistryInfo[]
225
+ ): Promise<void> {
226
+ const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
227
+
228
+ if (contentType.includes('application/json')) {
229
+ const data = await response.json() as PackageData;
230
+ if (data.versions) {
231
+ const host = req.headers.host || `localhost:${proxyPort}`;
232
+ const baseUrl = `${proxyInfo.https ? 'https' : 'http'}://${host}${proxyInfo.basePath === '/' ? '' : proxyInfo.basePath}`;
233
+
234
+ for (const version in data.versions) {
235
+ const tarball = data.versions[version]?.dist?.tarball;
236
+ if (tarball) {
237
+ const path = removeRegistryPrefix(tarball, registryInfos);
238
+ data.versions[version]!.dist!.tarball = `${baseUrl}${path}${new URL(tarball).search || ''}`;
239
+ }
240
+ }
241
+ }
242
+ res.writeHead(200, { 'Content-Type': 'application/json' });
243
+ res.end(JSON.stringify(data));
244
+ } else {
245
+ const safeHeaders: OutgoingHttpHeaders = {
246
+ 'content-type': contentType,
247
+ 'connection': 'keep-alive',
248
+ };
249
+
250
+ ['cache-control', 'etag', 'last-modified', 'content-encoding'].forEach(header => {
251
+ const value = response.headers.get(header);
252
+ if (value) safeHeaders[header] = value;
253
+ });
254
+
255
+ res.writeHead(response.status, safeHeaders);
256
+ await pipeline(
257
+ Readable.fromWeb(response.body as any),
258
+ res
259
+ );
260
+ }
261
+ }
262
+
189
263
  export async function startProxyServer(
190
264
  proxyConfigPath?: string,
191
265
  localYarnConfigPath?: string,
@@ -204,129 +278,44 @@ export async function startProxyServer(
204
278
 
205
279
  const requestHandler = async (req: IncomingMessage, res: ServerResponse) => {
206
280
  if (!req.url || !req.headers.host) {
207
- console.error('Invalid request: missing URL or host header');
208
281
  res.writeHead(400).end('Invalid Request');
209
282
  return;
210
283
  }
211
284
 
212
285
  const fullUrl = new URL(req.url, `${proxyInfo.https ? 'https' : 'http'}://${req.headers.host}`);
213
- console.log(`Proxy server received request on ${fullUrl.toString()}`)
214
286
  if (!fullUrl.pathname.startsWith(basePathPrefixedWithSlash)) {
215
- console.error(`Path ${fullUrl.pathname} does not match basePath ${basePathPrefixedWithSlash}`);
216
287
  res.writeHead(404).end('Not Found');
217
288
  return;
218
289
  }
219
290
 
220
- const relativePathPrefixedWithSlash = basePathPrefixedWithSlash === '/' ? fullUrl.pathname : fullUrl.pathname.slice(basePathPrefixedWithSlash.length);
221
- console.log(`Proxying: ${relativePathPrefixedWithSlash}`);
222
-
223
- // 修改为按顺序尝试注册表,找到第一个成功响应即返回
224
- for (const {normalizedRegistryUrl, token} of registryInfos) {
225
- await limiter.acquire();
226
- let response: Response | null = null;
227
- try {
228
- const targetUrl = `${normalizedRegistryUrl}${relativePathPrefixedWithSlash}${fullUrl.search || ''}`;
229
- console.log(`Fetching from: ${targetUrl}`);
230
- const headers = token ? {Authorization: `Bearer ${token}`} : undefined;
231
- response = await fetch(targetUrl, {headers});
232
- console.log(`Response from ${targetUrl}: ${response.status} ${response.statusText}`);
233
-
234
- if (response.ok) {
235
- const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
236
-
237
- if (contentType.includes('application/json')) {
238
- // application/json 元数据
239
- try {
240
- const data = await response.json() as PackageData;
241
- if (data.versions) {
242
- const requestHeadersHostFromYarnClient = req.headers.host || 'localhost:' + proxyPort;
243
- console.log("Request headers.host from yarn client is", requestHeadersHostFromYarnClient);
244
- const proxyBaseUrlNoSuffixedWithSlash = `${proxyInfo.https ? 'https' : 'http'}://${requestHeadersHostFromYarnClient}${basePathPrefixedWithSlash === '/' ? '' : basePathPrefixedWithSlash}`;
245
- console.log("proxyBaseUrlNoSuffixedWithSlash", proxyBaseUrlNoSuffixedWithSlash);
246
- for (const version in data.versions) {
247
- const dist = data.versions[version]?.dist;
248
- if (dist?.tarball) {
249
- const originalUrl = new URL(dist.tarball);
250
- const originalSearchParamsStr = originalUrl.search || '';
251
- const tarballPathPrefixedWithSlash = removeRegistryPrefix(dist.tarball, registryInfos);
252
- dist.tarball = `${proxyBaseUrlNoSuffixedWithSlash}${tarballPathPrefixedWithSlash}${originalSearchParamsStr}`;
253
- if (!tarballPathPrefixedWithSlash.startsWith("/")) console.error("bad tarballPath, must be PrefixedWithSlash", tarballPathPrefixedWithSlash);
254
- }
255
- }
256
- }
257
- res.writeHead(200, {'Content-Type': 'application/json'});
258
- res.end(JSON.stringify(data));
259
- return;
260
- } catch (e) {
261
- console.error('Failed to parse JSON response:', e);
262
- // 继续尝试下一个注册表
263
- }
264
- } else {
265
- if (!response.body) {
266
- console.error(`Empty response body from ${response.url}, status: ${response.status}`);
267
- continue;
268
- }
269
-
270
- // 设置正确的响应头
271
- const contentLength = response.headers.get('Content-Length');
272
- const safeHeaders: OutgoingHttpHeaders = {
273
- 'content-type': contentType,
274
- 'connection': 'keep-alive'
275
- };
276
-
277
- if (contentLength && !isNaN(Number(contentLength))) {
278
- safeHeaders['content-length'] = contentLength;
279
- }
280
-
281
- // 复制其他可能有用的头信息
282
- const headersToCopy = ['cache-control', 'etag', 'last-modified'];
283
- headersToCopy.forEach(header => {
284
- const value = response?.headers.get(header);
285
- if (value) {
286
- safeHeaders[header] = value;
287
- }
288
- });
289
-
290
- res.writeHead(response.status, safeHeaders);
291
-
292
- // 使用 pipeline 处理流
293
- const {pipeline} = await import('stream');
294
- const {promisify} = await import('util');
295
- const pipelineAsync = promisify(pipeline);
296
-
297
- try {
298
- // 将 ReadableStream 转换为 Node.js Readable
299
- const nodeStream = Readable.fromWeb(response.body as any);
300
-
301
- // 双向终止保护
302
- req.on('close', () => nodeStream.destroy());
303
- res.on('close', () => nodeStream.destroy());
304
-
305
- await pipelineAsync(
306
- nodeStream,
307
- res
308
- );
309
- return; // 成功完成管道传输后返回
310
- } catch (pipeError) {
311
- console.error(`Stream pipeline error for ${relativePathPrefixedWithSlash}:`, pipeError);
312
- if (!res.headersSent) {
313
- res.writeHead(502).end('Stream Error');
314
- }
315
- continue;
316
- }
317
- }
318
- }
319
- } catch (e) {
320
- console.error(`Failed to fetch from ${normalizedRegistryUrl}:`, e);
321
- } finally {
322
- // 不需要手动销毁 ReadableStream
323
- limiter.release();
291
+ const path = basePathPrefixedWithSlash === '/'
292
+ ? fullUrl.pathname
293
+ : fullUrl.pathname.slice(basePathPrefixedWithSlash.length);
294
+
295
+ // 顺序尝试注册表,获取第一个成功响应
296
+ let successfulResponse: Response | null = null;
297
+ for (const registry of registryInfos) {
298
+ if (req.destroyed) break;
299
+
300
+ const response = await fetchFromRegistry(
301
+ registry,
302
+ path,
303
+ fullUrl.search,
304
+ limiter
305
+ );
306
+
307
+ if (response) {
308
+ successfulResponse = response;
309
+ break;
324
310
  }
325
311
  }
326
312
 
327
- // 所有注册表都尝试失败
328
- console.error(`All registries failed for ${relativePathPrefixedWithSlash}`);
329
- res.writeHead(404).end('Not Found - All upstream registries failed');
313
+ // 统一回写响应
314
+ if (successfulResponse) {
315
+ await writeResponse(res, successfulResponse, req, proxyInfo, proxyPort, registryInfos);
316
+ } else {
317
+ res.writeHead(404).end('All upstream registries failed');
318
+ }
330
319
  };
331
320
 
332
321
  let server: HttpServer | HttpsServer;