Skip to content

Commit b848104

Browse files
luoqingmingclaude
andcommitted
feat(network): 遵循 HTTP(S)_PROXY / NO_PROXY,覆盖全部网络路径
Node 的内置 fetch 不读取 HTTP(S)_PROXY,在公司代理/VPN 后面 CLI 的每个请求 都会直接连接失败,只能收到"可能是代理导致"的提示。现在所有网络路径在 src/utils/runtime.ts 统一解析代理: - resolveProxy:https 目标用 HTTPS_PROXY(回落 HTTP_PROXY),http 目标用 HTTP_PROXY,大小写均可;NO_PROXY 支持 `*`、前缀点/`*.` 子域匹配、host:port - webFetch:有代理时经 undici 的 fetch + ProxyAgent(CONNECT 隧道),否则内置 fetch;API 请求、Hermes base 下载、HTTP Range 请求、source map 下载全部接入 - proxyAgentFor:node-fetch 上传与 registry 版本检查(http.get)使用 https-proxy-agent 的 CONNECT 隧道(http 目标同样走隧道:绝对 URI 形式的 http-proxy-agent 会重写已缓冲的请求头,破坏流式请求体的分帧) - 上传的 multipart 文件段声明 knownLength,node-fetch 改发 Content-Length 而不是 chunked,对象存储与代理都更稳 - 有代理时跳过对上传主机的直连 TCP 延迟探测(探测结果对代理路径无意义) - 依赖:undici ^6.28(engines 与本项目一致,Node >= 18.17)、 https-proxy-agent ^7.0.6;均按需加载,无代理时不会 require - bin: RNU_DEBUG=1 时同时打印错误的 cause 链(fetch 失败的真实原因) - scripts/smoke-lib: 在 Node 上用本地模拟 API + 正向代理做端到端验证 (apps、publish 上传、NO_PROXY 绕过),CI 的 node18-smoke 也会跑 - tests: resolveProxy 单元测试;README 说明代理变量 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tusm6iL2itjJZDiemujAeL
1 parent af3fffa commit b848104

13 files changed

Lines changed: 505 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ export RNU_LANG=en # CLI language (default: zh for pushy, en for cresc)
165165
export RNU_DEBUG=1 # print stack traces for errors
166166
```
167167

168+
`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` (upper or lower case) are honored by every request: the API, package and bundle uploads, source map and Hermes base downloads, and the registry version check.
169+
168170
## Sentry Sourcemaps
169171

170172
When `ios/sentry.properties` or `android/sentry.properties` exists, `bundle` uploads sourcemaps for OTA packages. The default matching path is Sentry Debug IDs; the CLI no longer infers release/dist from the native package.

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ export RNU_LANG=en # 界面语言(默认:pushy 为 zh,cresc 为 en)
156156
export RNU_DEBUG=1 # 出错时打印完整堆栈
157157
```
158158

159+
所有请求都遵循 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`(大小写均可):API 调用、原生包与热更包上传、source map 与 Hermes base 下载、registry 版本检查。
160+
159161
## Sentry Sourcemap
160162

161163
当项目存在 `ios/sentry.properties``android/sentry.properties` 时,`bundle` 会为 OTA 包上传 sourcemap。默认使用 Sentry Debug ID 匹配,不再根据原生包推导 release/dist。

bun.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"fs-extra": "^11.4.0",
7272
"global-dirs": "^4.0.0",
7373
"gradle-to-js": "^2.0.1",
74+
"https-proxy-agent": "^7.0.6",
7475
"i18next": "^26.4.0",
7576
"node-fetch": "^2.6.1",
7677
"plist": "^5.0.0",
@@ -81,6 +82,7 @@
8182
"registry-auth-token": "^5.1.1",
8283
"source-map": "0.6.1",
8384
"tty-table": "5.0",
85+
"undici": "^6.28.0",
8486
"yauzl": "^3.4.0",
8587
"yazl": "3.3.1"
8688
},

scripts/smoke-lib.js

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// a dependency or an API that needs a newer runtime fails here rather than on
55
// a user's machine; the test suite itself runs under bun and cannot tell.
66

7-
const { spawnSync } = require('node:child_process');
7+
const { spawn, spawnSync } = require('node:child_process');
88
const fs = require('node:fs');
99
const path = require('node:path');
1010

@@ -66,3 +66,248 @@ function runCli(args) {
6666
runCli(['help']);
6767
runCli(['-v']);
6868
console.log('smoke: `help` and `-v` ran without warnings');
69+
70+
// --- proxy scenario ---------------------------------------------------------
71+
// Node's built-in fetch ignores HTTP(S)_PROXY, so the CLI routes every request
72+
// itself (src/utils/runtime.ts): every client opens a CONNECT tunnel through
73+
// the proxy. A mock API and a local forward proxy show that the API call, the
74+
// registry check and the multipart upload all tunnel through the proxy, and
75+
// that NO_PROXY switches it off again.
76+
77+
const http = require('node:http');
78+
const net = require('node:net');
79+
const os = require('node:os');
80+
81+
function listen(server) {
82+
return new Promise((resolve) =>
83+
server.listen(0, '127.0.0.1', () => resolve(server.address().port)),
84+
);
85+
}
86+
87+
function createMockApi(requests) {
88+
return http.createServer((req, res) => {
89+
requests.push(`${req.method} ${req.url}`);
90+
const json = (status, body) => {
91+
res.writeHead(status, { 'content-type': 'application/json' });
92+
res.end(JSON.stringify(body));
93+
};
94+
const url = req.url ?? '/';
95+
if (url.startsWith('/registry/')) {
96+
return json(200, { versions: {}, 'dist-tags': { latest: '0.0.0' } });
97+
}
98+
if (url === '/api/app/list') {
99+
return json(200, {
100+
data: [{ id: 100, name: 'DemoApp', platform: 'android' }],
101+
});
102+
}
103+
if (url === '/api/upload') {
104+
return json(200, {
105+
url: `http://${req.headers.host}/oss/upload`,
106+
formData: { key: 'hash-from-oss' },
107+
});
108+
}
109+
if (url === '/oss/upload') {
110+
req.resume();
111+
req.on('end', () => {
112+
res.writeHead(204);
113+
res.end();
114+
});
115+
return;
116+
}
117+
if (url === '/api/app/100/version/create') {
118+
req.resume();
119+
req.on('end', () => json(200, { id: 1 }));
120+
return;
121+
}
122+
json(404, { message: `unhandled ${url}` });
123+
});
124+
}
125+
126+
function createForwardProxy(seen) {
127+
const proxy = http.createServer((req, res) => {
128+
seen.push(`${req.method} ${req.url}`);
129+
const target = new URL(req.url);
130+
// hop-by-hop headers are not forwarded; the client below frames the body
131+
// itself (a copied transfer-encoding would make the upstream reject it)
132+
const {
133+
connection: _connection,
134+
'proxy-connection': _proxyConnection,
135+
'keep-alive': _keepAlive,
136+
'transfer-encoding': _transferEncoding,
137+
...headers
138+
} = req.headers;
139+
const upstream = http.request(
140+
{
141+
host: target.hostname,
142+
port: target.port,
143+
path: `${target.pathname}${target.search}`,
144+
method: req.method,
145+
headers: { ...headers, host: target.host },
146+
},
147+
(response) => {
148+
res.writeHead(response.statusCode ?? 502, response.headers);
149+
response.pipe(res);
150+
},
151+
);
152+
upstream.on('error', (error) => {
153+
res.writeHead(502);
154+
res.end(String(error));
155+
});
156+
req.pipe(upstream);
157+
});
158+
proxy.on('connect', (req, socket, head) => {
159+
seen.push(`CONNECT ${req.url}`);
160+
const [host, port] = req.url.split(':');
161+
const tunnel = net.connect(Number(port), host, () => {
162+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
163+
if (head?.length) tunnel.write(head);
164+
socket.pipe(tunnel);
165+
tunnel.pipe(socket);
166+
});
167+
tunnel.on('error', () => socket.destroy());
168+
socket.on('error', () => tunnel.destroy());
169+
});
170+
return proxy;
171+
}
172+
173+
async function proxyScenario() {
174+
const apiRequests = [];
175+
const seenByProxy = [];
176+
const api = createMockApi(apiRequests);
177+
const proxy = createForwardProxy(seenByProxy);
178+
const apiPort = await listen(api);
179+
const proxyPort = await listen(proxy);
180+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-smoke-project-'));
181+
const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-smoke-cache-'));
182+
fs.writeFileSync(
183+
path.join(project, 'update.json'),
184+
JSON.stringify({ android: { appId: 100, appKey: 'key' } }),
185+
);
186+
fs.writeFileSync(path.join(project, 'bundle.ppk'), 'fake-ppk');
187+
const baseEnv = {
188+
...process.env,
189+
NO_INTERACTIVE: 'true',
190+
RNU_AUTO_UPDATE: '0',
191+
PUSHY_REGISTRY: `http://127.0.0.1:${apiPort}/api`,
192+
npm_config_registry: `http://127.0.0.1:${apiPort}/registry/`,
193+
XDG_CACHE_HOME: cache,
194+
HTTP_PROXY: `http://127.0.0.1:${proxyPort}`,
195+
http_proxy: `http://127.0.0.1:${proxyPort}`,
196+
HTTPS_PROXY: '',
197+
https_proxy: '',
198+
NO_PROXY: '',
199+
no_proxy: '',
200+
};
201+
// asynchronous: spawnSync would block this process, and with it the mock
202+
// API and the proxy the CLI is talking to
203+
const run = (args, env) =>
204+
new Promise((resolve, reject) => {
205+
const child = spawn(
206+
process.execPath,
207+
[path.join(lib, 'bin.js'), ...args],
208+
{ cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] },
209+
);
210+
let stdout = '';
211+
let stderr = '';
212+
child.stdout.setEncoding('utf8');
213+
child.stderr.setEncoding('utf8');
214+
child.stdout.on('data', (chunk) => {
215+
stdout += chunk;
216+
});
217+
child.stderr.on('data', (chunk) => {
218+
stderr += chunk;
219+
});
220+
const timer = setTimeout(() => {
221+
child.kill('SIGTERM');
222+
reject(new Error(`pushy ${args.join(' ')} timed out`));
223+
}, 30_000);
224+
child.on('error', (error) => {
225+
clearTimeout(timer);
226+
reject(error);
227+
});
228+
child.on('close', (status) => {
229+
clearTimeout(timer);
230+
if (status !== 0) {
231+
console.error(stdout);
232+
console.error(stderr);
233+
reject(new Error(`pushy ${args.join(' ')} exited with ${status}`));
234+
return;
235+
}
236+
resolve({ stdout, stderr });
237+
});
238+
});
239+
// tunnels to the mock host: the API client (undici), the registry check
240+
// (http.get) and the upload (node-fetch) each open their own
241+
const tunnels = () =>
242+
seenByProxy.filter((line) => line === `CONNECT 127.0.0.1:${apiPort}`)
243+
.length;
244+
try {
245+
await run(['apps', '--no-interactive'], baseEnv);
246+
if (tunnels() < 2) {
247+
throw new Error(
248+
`the API call or the registry check bypassed HTTP_PROXY: ${JSON.stringify(seenByProxy)}`,
249+
);
250+
}
251+
if (!apiRequests.includes('GET /api/app/list')) {
252+
throw new Error(
253+
`apps did not reach the API: ${JSON.stringify(apiRequests)}`,
254+
);
255+
}
256+
seenByProxy.length = 0;
257+
apiRequests.length = 0;
258+
await run(
259+
[
260+
'publish',
261+
'bundle.ppk',
262+
'--platform',
263+
'android',
264+
'--name',
265+
'v1',
266+
'--no-interactive',
267+
],
268+
baseEnv,
269+
);
270+
if (tunnels() < 2) {
271+
throw new Error(
272+
`the upload bypassed HTTP_PROXY: ${JSON.stringify(seenByProxy)}`,
273+
);
274+
}
275+
if (!apiRequests.includes('POST /oss/upload')) {
276+
throw new Error(
277+
`the upload did not arrive: ${JSON.stringify(apiRequests)}`,
278+
);
279+
}
280+
if (!apiRequests.includes('POST /api/app/100/version/create')) {
281+
throw new Error(
282+
`publish did not reach the API: ${JSON.stringify(apiRequests)}`,
283+
);
284+
}
285+
seenByProxy.length = 0;
286+
apiRequests.length = 0;
287+
await run(['apps', '--no-interactive'], {
288+
...baseEnv,
289+
NO_PROXY: '127.0.0.1',
290+
});
291+
if (seenByProxy.length > 0) {
292+
throw new Error(`NO_PROXY was ignored: ${JSON.stringify(seenByProxy)}`);
293+
}
294+
if (!apiRequests.includes('GET /api/app/list')) {
295+
throw new Error(
296+
`the API call did not arrive directly: ${JSON.stringify(apiRequests)}`,
297+
);
298+
}
299+
console.log(
300+
'smoke: API, registry check and upload honor HTTP_PROXY and NO_PROXY',
301+
);
302+
} finally {
303+
api.close();
304+
proxy.close();
305+
fs.rmSync(project, { recursive: true, force: true });
306+
fs.rmSync(cache, { recursive: true, force: true });
307+
}
308+
}
309+
310+
proxyScenario().catch((error) => {
311+
console.error(error);
312+
process.exit(1);
313+
});

0 commit comments

Comments
 (0)