Skip to content

Commit 3d136c0

Browse files
authored
feat(http): centralize HTTP client with proxy and custom-CA support (#1686)
Track B of [voidzero-dev/setup-vp#73](voidzero-dev/setup-vp#73) — makes `vp` work through Socket Firewall Free (sfw) and other TLS-intercepting proxies. ## What - **New `vite_shared::shared_http_client()`**: process-wide `reqwest::Client` that honors `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`, picks up macOS System Settings / Windows registry proxies (`system-proxy` feature), loads custom CAs from `SSL_CERT_FILE` and `NODE_EXTRA_CA_CERTS` (additive, Node-style), and has connect + request timeouts. `VP_INSECURE_TLS` (truthy only — `1`/`true`/`yes`/`on`) is a diagnostic escape hatch with a loud warning. - **All `reqwest::get` / `reqwest::Client::new()` call sites** in `vite_install` and `vite_js_runtime` routed through the shared client. - **`vite_shared::format_error_chain`**: walks `Error::source()` so users see `... invalid peer certificate: UnknownIssuer` instead of the opaque `error sending request for url`. Wired into both `Error::Reqwest` variants and the four `DownloadFailed { reason }` sites. - **New `install-e2e-test-sfw` CI job** (Linux / macOS / Windows): downloads upstream `sfw`, runs `sfw vp i -g pnpm@…` + `sfw vp install` against a fresh `vitejs/vite` clone. Gated on the `test: sfw` label for PRs; unconditional on push-to-main. `VP_INSECURE_TLS=1` is scoped to the Linux entry only (workaround for [SocketDev/sfw-free#30](SocketDev/sfw-free#30) / [#43](SocketDev/sfw-free#43) — present-but-empty EKU rejected by rustls). ## Out of scope / follow-ups - Switch to `rustls-native-certs` so OS-installed CAs work without env vars — Track B step 5, separate PR. - Drop `vp_insecure_tls` from the Linux matrix once SocketDev/sfw-free ships the EKU fix (one-line change). Refs voidzero-dev/setup-vp#73
1 parent 0bfac6c commit 3d136c0

12 files changed

Lines changed: 543 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,131 @@ jobs:
889889
echo ""
890890
done
891891
892+
install-e2e-test-sfw:
893+
name: Local CLI `vp install` E2E test (Socket Firewall Free)
894+
needs:
895+
- download-previous-rolldown-binaries
896+
# Run if: not a PR (push-to-main / workflow_dispatch), OR PR has 'test: sfw' label.
897+
# Heavy job (3 OSes × real registry traffic) — gated to avoid running on every PR.
898+
if: >-
899+
github.event_name != 'pull_request' ||
900+
contains(github.event.pull_request.labels.*.name, 'test: sfw')
901+
strategy:
902+
fail-fast: false
903+
matrix:
904+
include:
905+
- os: ubuntu-latest
906+
target: x86_64-unknown-linux-gnu
907+
sfw_asset: sfw-free-linux-x86_64
908+
vp_bin: vp
909+
# Only Linux needs the TLS-bypass: the ubuntu-latest runner
910+
# doesn't preinstall Node 22.18 into vp's cache, so vp's HttpClient
911+
# (rustls) fetches `nodejs.org/.../SHASUMS256.txt` through sfw and
912+
# hits the upstream EKU bug. macOS/Windows runners ship Node in
913+
# vp's cache already, so vp never calls its HttpClient through sfw
914+
# in this test — leave the verification on there.
915+
vp_insecure_tls: '1'
916+
- os: namespace-profile-mac-default
917+
target: aarch64-apple-darwin
918+
sfw_asset: sfw-free-macos-arm64
919+
vp_bin: vp
920+
vp_insecure_tls: ''
921+
- os: windows-latest
922+
target: x86_64-pc-windows-msvc
923+
sfw_asset: sfw-free-windows-x86_64.exe
924+
# On Windows vp ships as `vp.exe`; sfw spawns its child process
925+
# directly without applying PATHEXT, so the bare `vp` lookup fails.
926+
vp_bin: vp.exe
927+
vp_insecure_tls: ''
928+
runs-on: ${{ matrix.os }}
929+
steps:
930+
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
931+
- uses: ./.github/actions/clone
932+
933+
- name: Setup Dev Drive
934+
if: runner.os == 'Windows'
935+
uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0
936+
with:
937+
drive-size: 12GB
938+
drive-format: ReFS
939+
env-mapping: |
940+
CARGO_HOME,{{ DEV_DRIVE }}/.cargo
941+
RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup
942+
943+
- uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24)
944+
with:
945+
save-cache: ${{ github.ref_name == 'main' }}
946+
cache-key: install-e2e-test-sfw-${{ matrix.os }}
947+
target-dir: ${{ runner.os == 'Windows' && format('{0}/target', env.DEV_DRIVE) || '' }}
948+
949+
- uses: oxc-project/setup-node@ab97f03642370d79a7e96dd286bd02a1be40e0ba # v1.3.0
950+
951+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
952+
with:
953+
name: rolldown-binaries
954+
path: ./rolldown/packages/rolldown/src
955+
merge-multiple: true
956+
957+
- name: Build with upstream
958+
uses: ./.github/actions/build-upstream
959+
with:
960+
target: ${{ matrix.target }}
961+
962+
- name: Build CLI
963+
run: |
964+
pnpm bootstrap-cli:ci
965+
# Mirror the per-OS path form used by the `test` job (ci.yml ~L266):
966+
# GITHUB_PATH on Windows needs a Windows-style path, otherwise child
967+
# processes spawned by tools like sfw (which use CreateProcess and
968+
# not bash's PATH munging) can't resolve `vp.exe`.
969+
if [[ "$RUNNER_OS" == "Windows" ]]; then
970+
echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH
971+
else
972+
echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH
973+
fi
974+
975+
- name: Download sfw
976+
run: |
977+
set -euo pipefail
978+
mkdir -p "$RUNNER_TEMP/sfw-bin"
979+
# `--remove-on-error` (curl 7.83+) prevents leaving a zero-byte file
980+
# on failure so the next step's `sfw --version` fails clearly rather
981+
# than with "exec format error" on an empty binary.
982+
curl --fail --location --remove-on-error --retry 3 --retry-delay 2 \
983+
--output "$RUNNER_TEMP/sfw-bin/sfw${{ runner.os == 'Windows' && '.exe' || '' }}" \
984+
"https://github.com/SocketDev/sfw-free/releases/latest/download/${{ matrix.sfw_asset }}"
985+
if [[ "${{ runner.os }}" != "Windows" ]]; then
986+
chmod +x "$RUNNER_TEMP/sfw-bin/sfw"
987+
fi
988+
echo "$RUNNER_TEMP/sfw-bin" >> "$GITHUB_PATH"
989+
990+
- name: Verify sfw on PATH
991+
run: sfw --version
992+
993+
- name: Run `sfw vp install` against a real repo
994+
# TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `vp_insecure_tls`
995+
# from the Linux matrix entry once sfw ships the EKU fix. Verified
996+
# against sfw v1.11.0 (releases/latest as of 2026-05-28): on Linux,
997+
# vp's HTTPS request to nodejs.org through sfw still fails with
998+
# "invalid peer certificate: UnknownIssuer" because sfw's CA carries a
999+
# present-but-empty Extended Key Usage extension that rustls rejects.
1000+
# macOS/Windows runners cache Node 22.18 in vp's directory, so vp
1001+
# doesn't call its HttpClient through sfw — leaving TLS verification
1002+
# enabled there gives us coverage that the bypass *isn't* used on
1003+
# those platforms.
1004+
env:
1005+
VP_INSECURE_TLS: ${{ matrix.vp_insecure_tls }}
1006+
run: |
1007+
set -euo pipefail
1008+
# Force the registry-fetch path: install a pinned pnpm globally so
1009+
# vp downloads it (and therefore traverses sfw) rather than reusing
1010+
# whatever's preinstalled on the runner.
1011+
sfw "${{ matrix.vp_bin }}" i -g pnpm@9.15.0
1012+
# Then exercise `vp install` inside a real repo, also through sfw.
1013+
git clone --depth 1 https://github.com/vitejs/vite.git "$RUNNER_TEMP/vite"
1014+
cd "$RUNNER_TEMP/vite"
1015+
sfw "${{ matrix.vp_bin }}" install --no-frozen-lockfile
1016+
8921017
done:
8931018
runs-on: ubuntu-latest
8941019
if: always()
@@ -899,6 +1024,9 @@ jobs:
8991024
- cli-e2e-test
9001025
- cli-e2e-test-musl
9011026
- cli-snap-test
1027+
# Skipped on unlabeled PRs; counted on push-to-main and labeled PRs.
1028+
# `contains(needs.*.result, 'failure')` ignores "skipped" results.
1029+
- install-e2e-test-sfw
9021030
steps:
9031031
- run: exit 1
9041032
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379

Cargo.lock

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

crates/vite_error/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ serde_yml = { workspace = true }
2121
thiserror = { workspace = true }
2222
tokio = { workspace = true }
2323
vite_path = { workspace = true }
24+
vite_shared = { workspace = true }
2425
vite_str = { workspace = true }
2526
vite_workspace = { workspace = true }
2627
wax = { workspace = true }

crates/vite_error/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ pub enum Error {
106106
#[error(transparent)]
107107
Semver(#[from] semver::Error),
108108

109-
#[error(transparent)]
109+
// `#[error("{}", ...)]` not `transparent`: surface the full `source()`
110+
// chain (TLS handshake → UnknownIssuer, hyper IO errors, etc.) instead of
111+
// just reqwest's top-level "error sending request for url (...)" message.
112+
// Keeps `From<reqwest::Error>` and `source()` semantics intact, so 404
113+
// detection via `e.status()` at call sites still works.
114+
#[error("{}", vite_shared::format_error_chain(.0))]
110115
Reqwest(#[from] reqwest::Error),
111116

112117
#[error(transparent)]

crates/vite_install/src/request.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ impl HttpClient {
5959
}
6060

6161
async fn get(&self, url: &str) -> Result<Response, Error> {
62-
vite_shared::ensure_tls_provider();
62+
let client = vite_shared::shared_http_client();
6363

64-
let response = (|| async { reqwest::get(url).await?.error_for_status() })
64+
let response = (|| async { client.get(url).send().await?.error_for_status() })
6565
.retry(
6666
ExponentialBuilder::default()
6767
.with_jitter()

crates/vite_js_runtime/src/download.rs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,22 @@ pub async fn download_file(
3737
target_path: &AbsolutePath,
3838
message: &str,
3939
) -> Result<(), Error> {
40-
vite_shared::ensure_tls_provider();
40+
let client = vite_shared::shared_http_client();
4141

4242
tracing::debug!("Downloading {url} to {target_path:?}");
4343

44-
let response = (|| async { reqwest::get(url).await?.error_for_status() })
44+
let response = (|| async { client.get(url).send().await?.error_for_status() })
4545
.retry(
4646
ExponentialBuilder::default()
4747
.with_jitter()
4848
.with_min_delay(Duration::from_millis(500))
4949
.with_max_times(3),
5050
)
5151
.await
52-
.map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?;
52+
.map_err(|e| Error::DownloadFailed {
53+
url: url.into(),
54+
reason: vite_shared::format_error_chain(&e).into(),
55+
})?;
5356

5457
// Get Content-Length for progress bar
5558
let total_size = response.content_length();
@@ -113,19 +116,22 @@ pub async fn download_file(
113116
/// Download text content from a URL with retry logic
114117
#[expect(clippy::disallowed_types, reason = "HTTP response body is a String")]
115118
pub async fn download_text(url: &str) -> Result<String, Error> {
116-
vite_shared::ensure_tls_provider();
119+
let client = vite_shared::shared_http_client();
117120

118121
tracing::debug!("Downloading text from {url}");
119122

120-
let content = (|| async { reqwest::get(url).await?.text().await })
123+
let content = (|| async { client.get(url).send().await?.error_for_status()?.text().await })
121124
.retry(
122125
ExponentialBuilder::default()
123126
.with_jitter()
124127
.with_min_delay(Duration::from_millis(500))
125128
.with_max_times(3),
126129
)
127130
.await
128-
.map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?;
131+
.map_err(|e| Error::DownloadFailed {
132+
url: url.into(),
133+
reason: vite_shared::format_error_chain(&e).into(),
134+
})?;
129135

130136
Ok(content)
131137
}
@@ -138,12 +144,11 @@ pub async fn fetch_with_cache_headers(
138144
url: &str,
139145
if_none_match: Option<&str>,
140146
) -> Result<CachedFetchResponse, Error> {
141-
vite_shared::ensure_tls_provider();
147+
let client = vite_shared::shared_http_client();
142148

143149
tracing::debug!("Fetching with cache headers from {url}");
144150

145151
let response = (|| async {
146-
let client = reqwest::Client::new();
147152
let mut request = client.get(url);
148153

149154
if let Some(etag) = if_none_match {
@@ -159,7 +164,10 @@ pub async fn fetch_with_cache_headers(
159164
.with_max_times(3),
160165
)
161166
.await
162-
.map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?;
167+
.map_err(|e| Error::DownloadFailed {
168+
url: url.into(),
169+
reason: vite_shared::format_error_chain(&e).into(),
170+
})?;
163171

164172
// Check for 304 Not Modified
165173
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
@@ -182,10 +190,10 @@ pub async fn fetch_with_cache_headers(
182190
.and_then(|v| v.to_str().ok())
183191
.and_then(parse_max_age);
184192

185-
let body = response
186-
.text()
187-
.await
188-
.map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?;
193+
let body = response.text().await.map_err(|e| Error::DownloadFailed {
194+
url: url.into(),
195+
reason: vite_shared::format_error_chain(&e).into(),
196+
})?;
189197

190198
Ok(CachedFetchResponse { body: Some(body), etag, max_age, not_modified: false })
191199
}

crates/vite_js_runtime/src/error.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ pub enum Error {
5858
#[error(transparent)]
5959
Io(#[from] std::io::Error),
6060

61-
/// HTTP request error
62-
#[error(transparent)]
61+
/// HTTP request error.
62+
///
63+
/// Surface the full `source()` chain (TLS handshake / connect / hyper
64+
/// IO) rather than reqwest's top-level message only. Body-streaming
65+
/// failures inside `download_file` propagate via `?` into this variant,
66+
/// so the chain has to be exposed here — not at the call site.
67+
#[error("{}", vite_shared::format_error_chain(.0))]
6368
Reqwest(#[from] reqwest::Error),
6469

6570
/// Join error from tokio

0 commit comments

Comments
 (0)