Skip to content

Commit 07b1c25

Browse files
committed
Add musl libc support (Alpine/OpenWrt) with Alpine E2E
1 parent 1344750 commit 07b1c25

12 files changed

Lines changed: 516 additions & 13 deletions

File tree

‎.github/workflows/e2e.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ jobs:
4242
persist-credentials: false
4343
- run: docker compose -f tests/e2e/linux/compose.yaml run --rm e2e
4444

45+
e2e-linux-musl:
46+
if: github.repository == 'version-fox/vfox-nodejs'
47+
name: Linux musl container (${{ matrix.runs-on }})
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
runs-on: [ubuntu-latest, ubuntu-26.04-arm]
52+
runs-on: ${{ matrix.runs-on }}
53+
timeout-minutes: 60
54+
steps:
55+
- uses: actions/checkout@v7
56+
with:
57+
persist-credentials: false
58+
- run: docker compose -f tests/e2e/linux/compose.alpine.yaml run --rm e2e
59+
4560
e2e-windows:
4661
if: github.repository == 'version-fox/vfox-nodejs'
4762
name: Windows container (${{ matrix.runs-on }})

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ sudo apt install --assume-yes libatomic1
1919
vfox use --global nodejs@26.10.0
2020
```
2121

22+
### Example: Alpine / musl
23+
24+
```bash
25+
apk add libstdc++
26+
vfox add nodejs
27+
vfox install nodejs@22.23.3
28+
vfox use --global nodejs@22.23.3
29+
```
30+
2231
You can verify this using the following command:
2332

2433
```bash
2534
node -v
2635
```
2736

37+
musl details (OpenWrt, tarball matrix, mirror fallback): see [docs/musl.md](docs/musl.md).
38+
2839
## Mirror
2940

3041
See [docs/mirror.md](docs/mirror.md) for details.

‎docs/e2e.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pin `VFOX_VERSION` and `MIRROR` explicitly to widen or narrow the matrix.
5252
| `NODE_VERSION` | `22.23.3` | Pinned Node.js release installed by the suite |
5353
| `E2E_MAX_JOBS` | `3` (Linux), `2` (Windows) | Parallel containers per host |
5454
| `E2E_RETRY_ATTEMPTS`| `3` | Attempts per network-touching exec (linear 10s backoff) |
55+
| `E2E_LINUX_BASE` | `ubuntu` | Linux userland: `ubuntu` (glibc) or `alpine` (musl, see docs/musl.md) |
5556

5657
## Ubuntu
5758

@@ -81,6 +82,22 @@ cd ./vfox-nodejs/
8182
DOCKER_SOCK="$XDG_RUNTIME_DIR/docker.sock" docker compose -f tests/e2e/linux/compose.yaml run --rm e2e
8283
```
8384

85+
## Alpine (musl)
86+
87+
Same host requirement. The Alpine suite builds
88+
`tests/e2e/linux/Dockerfile.alpine` (musl userland with `libstdc++`) and
89+
asserts the musl userland before running the standard flow, proving the
90+
plugin selects the `*-musl` tarballs
91+
([issue #29](https://github.com/version-fox/vfox-nodejs/issues/29)).
92+
See [docs/musl.md](musl.md) for the tarball matrix.
93+
94+
```bash
95+
docker compose -f tests/e2e/linux/compose.alpine.yaml run --rm e2e
96+
```
97+
98+
Scope it down locally the same way, e.g.
99+
`VFOX_VERSION=latest MIRROR=default E2E_MAX_JOBS=1` in front of the command.
100+
84101
## Windows 11 Pro
85102

86103
Windows containers cannot be emulated across architectures, so the ARM64

‎docs/musl.md‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# musl libc support (Alpine, OpenWrt/ImmortalWrt)
2+
3+
Node.js official Linux tarballs link glibc (`ld-linux-*`). On musl systems
4+
there is no glibc loader, so the stock download fails with `not found` /
5+
`Error loading shared library ld-linux-aarch64.so.1`.
6+
See [issue #29](https://github.com/version-fox/vfox-nodejs/issues/29).
7+
8+
This plugin detects musl and installs a musl tarball instead.
9+
10+
## Requirements
11+
12+
On musl systems the Node.js binary additionally needs `libstdc++`
13+
(per [nodejs/unofficial-builds](https://github.com/nodejs/unofficial-builds)):
14+
15+
```sh
16+
# Alpine
17+
apk add libstdc++
18+
19+
# OpenWrt/ImmortalWrt (opkg)
20+
opkg update && opkg install libstdcpp
21+
```
22+
23+
Without it `node --version` fails with
24+
`Error relocating ... symbol not found` even when the musl tarball is used.
25+
The plugin checks this in its `PostInstall` hook: on musl Linux without
26+
`libstdc++.so.6` at a well-known location the install fails fast with the
27+
matching command (`apk add libstdc++` on Alpine,
28+
`opkg update && opkg install libstdcpp` on OpenWrt) instead of failing later
29+
with the cryptic relocation error.
30+
31+
## Which tarball is installed
32+
33+
| Host | Tarball | Source |
34+
| ---- | ------- | ------ |
35+
| Linux glibc, x64/arm64 | `node-v<V>-linux-x64.tar.gz` etc. | `nodejs.org/dist` (or `$VFOX_NODEJS_MIRROR`) |
36+
| Linux musl, x64 | `node-v<V>-linux-x64-musl.tar.gz` | official index when present, else unofficial |
37+
| Linux musl, arm64 | `node-v<V>-linux-arm64-musl.tar.gz` | `unofficial-builds.nodejs.org` (official never ships it) |
38+
39+
Notes:
40+
41+
- Official `nodejs.org/dist` only carries `linux-x64-musl` for recent
42+
releases (roughly `26.8+`, `24.20+`, `22.23.3+`) and never carries
43+
`linux-arm64-musl`. Older releases and all `arm64-musl` installs fall back
44+
to `https://unofficial-builds.nodejs.org/download/release` automatically.
45+
- A mirror set via `$VFOX_NODEJS_MIRROR` (see [mirror](mirror.md)) is tried
46+
first. A `SHASUMS256.txt` fetch failure still errors (bogus mirrors stay
47+
rejected); only a missing musl filename falls back to unofficial.
48+
- musl builds exist only for `x64` and `arm64`. Other Linux architectures
49+
(e.g. `x86`) error out with a pointer to the README.
50+
- After install the plugin returns a note pointing at the README so the
51+
`libstdc++` requirement is visible in normal `vfox install` output.
52+
53+
## Detection and override
54+
55+
Detection order in `lib/util.lua`:
56+
57+
1. `$VFOX_NODEJS_LIBC`: `musl` forces musl, `gnu`/`glibc` forces glibc.
58+
Useful on exotic userlands and in tests.
59+
2. `RUNTIME.envType` when a future vfox core provides it (`musl`/`gnu`).
60+
3. File probes readable from Lua: `/etc/alpine-release`,
61+
`/lib`/`/usr/lib`/`/lib64` `ld-musl-*.so.1`. musl wins when both linkers
62+
exist (e.g. `gcompat` shims). Non-Linux is never musl.
63+
64+
```sh
65+
# Force musl selection (debugging only)
66+
VFOX_NODEJS_LIBC=musl vfox install nodejs@22.23.3
67+
```
68+
69+
## E2E coverage
70+
71+
The musl path runs in Docker, never against a local musl host:
72+
73+
```sh
74+
docker compose -f tests/e2e/linux/compose.alpine.yaml run --rm e2e
75+
```
76+
77+
The suite asserts the Alpine userland (`ld-musl-*` present, no `ld-linux-*`,
78+
`ldd` reports musl) and then runs the standard flow
79+
(install with checksum, `node`/`npm`, shorthands, legacy files, search).
80+
See [e2e](e2e.md) for the shared knobs (`VFOX_VERSION`, `MIRROR`,
81+
`NODE_VERSION`, `E2E_MAX_JOBS`, `E2E_RETRY_ATTEMPTS`).

‎hooks/post_install.lua‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Copyright 2026 Han Li and contributors
2+
--
3+
-- Licensed under the Apache License, Version 2.0 (the "License");
4+
-- you may not use this file except in compliance with the License.
5+
-- You may obtain a copy of the License at
6+
--
7+
-- http://www.apache.org/licenses/LICENSE-2.0
8+
--
9+
-- Unless required by applicable law or agreed to in writing, software
10+
-- distributed under the License is distributed on an "AS IS" BASIS,
11+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
-- See the License for the specific language governing permissions and
13+
-- limitations under the License.
14+
15+
local util = require("util")
16+
17+
--- Fail fast when the musl Node.js binary cannot run: it needs libstdc++,
18+
--- which musl distributions do not install by default. Without this check
19+
--- the install looks successful and only `node --version` fails later with
20+
--- `Error relocating ... symbol not found`.
21+
--- See https://github.com/version-fox/vfox-nodejs/issues/29
22+
--- @param ctx table Context information
23+
function PLUGIN:PostInstall(ctx)
24+
if RUNTIME.osType ~= "linux" then
25+
return
26+
end
27+
if not util.is_musl() then
28+
return
29+
end
30+
if util.has_libstdcxx() then
31+
return
32+
end
33+
error("Node.js (musl build) requires libstdc++, which was not found on this system. Run `" .. util.libstdcxx_hint() .. "` (possibly as root), then reinstall. See " .. util.ReadmeUrl)
34+
end

‎hooks/pre_install.lua‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,33 @@ function PLUGIN:PreInstall(ctx)
6565
end
6666
end
6767

68-
local filename = util.FileName:format(version, osType, arch_type, ext)
68+
local filename = nil
6969
local baseUrl = util.getBaseUrl() .. util.NodeBaseUrl:format(version)
70+
local is_musl = false
71+
local musl_note = nil
72+
if RUNTIME.osType == "linux" then
73+
is_musl = util.is_musl()
74+
if is_musl then
75+
if arch_type == "x64" then
76+
arch_type = "x64-musl"
77+
elseif arch_type == "arm64" then
78+
arch_type = "arm64-musl"
79+
else
80+
error(
81+
"Node.js musl builds are only available for x64 and arm64, got "
82+
.. tostring(arch_type)
83+
.. ". See "
84+
.. util.ReadmeUrl
85+
)
86+
end
87+
filename = util.FileName:format(version, osType, arch_type, ext)
88+
musl_note = "musl libc detected, installed " .. filename .. ". See " .. util.ReadmeUrl
89+
print("Note: " .. musl_note)
90+
end
91+
end
92+
if filename == nil then
93+
filename = util.FileName:format(version, osType, arch_type, ext)
94+
end
7095

7196
local resp, err = http.get({
7297
url = baseUrl .. "SHASUMS256.txt",
@@ -75,12 +100,41 @@ function PLUGIN:PreInstall(ctx)
75100
error("get checksum failed")
76101
end
77102
local checksum = util.get_checksum(resp.body, filename)
78-
if checksum == nil then
79-
error("Node.js package " .. filename .. " is not listed in " .. baseUrl .. "SHASUMS256.txt")
103+
if checksum ~= nil then
104+
local result = {
105+
version = version,
106+
url = baseUrl .. filename,
107+
sha256 = checksum,
108+
}
109+
if musl_note ~= nil then
110+
result.note = musl_note
111+
end
112+
return result
113+
end
114+
115+
-- musl fallback to unofficial builds: official nodejs.org/dist only ships
116+
-- linux-x64-musl for recent releases and never ships linux-arm64-musl,
117+
-- while unofficial-builds covers both (including older releases).
118+
-- A SHASUMS fetch failure still errors (bogus mirrors must stay rejected);
119+
-- only a missing filename falls through here.
120+
-- See https://github.com/version-fox/vfox-nodejs/issues/29
121+
if is_musl then
122+
local unofficialBase = util.UnofficialBaseUrl .. util.NodeBaseUrl:format(version)
123+
local uresp, uerr = http.get({
124+
url = unofficialBase .. "SHASUMS256.txt",
125+
})
126+
if uerr == nil and uresp.status_code == 200 then
127+
local uchecksum = util.get_checksum(uresp.body, filename)
128+
if uchecksum ~= nil then
129+
print("Note: " .. filename .. " is not listed in " .. baseUrl .. ", using unofficial musl build")
130+
return {
131+
version = version,
132+
url = unofficialBase .. filename,
133+
sha256 = uchecksum,
134+
note = musl_note,
135+
}
136+
end
137+
end
80138
end
81-
return {
82-
version = version,
83-
url = baseUrl .. filename,
84-
sha256 = checksum,
85-
}
139+
error("Node.js package " .. filename .. " is not listed in " .. baseUrl .. "SHASUMS256.txt")
86140
end

‎lib/util.lua‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ local UTIL = {}
1717
UTIL.NodeBaseUrl = "/v%s/"
1818
UTIL.FileName = "node-v%s-%s-%s%s"
1919
UTIL.VersionSourceUrl = "/index.json"
20+
UTIL.UnofficialBaseUrl = "https://unofficial-builds.nodejs.org/download/release"
21+
UTIL.ReadmeUrl = "https://github.com/version-fox/vfox-nodejs/blob/main/README.md"
2022

2123
function UTIL.getBaseUrl()
2224
local mirror = os.getenv("VFOX_NODEJS_MIRROR")
@@ -27,6 +29,93 @@ function UTIL.getBaseUrl()
2729
return mirror:gsub("/$", "")
2830
end
2931

32+
function UTIL.file_exists(path)
33+
local f = io.open(path, "rb")
34+
if f then
35+
f:close()
36+
return true
37+
end
38+
return false
39+
end
40+
41+
--- Detect musl libc on Linux (Alpine, OpenWrt/ImmortalWrt, ...).
42+
--- Order: explicit $VFOX_NODEJS_LIBC override, future RUNTIME.envType,
43+
--- then well-known linker/marker files. musl wins when both linkers exist
44+
--- (e.g. gcompat shims on Alpine). Non-Linux is never musl.
45+
--- See https://github.com/version-fox/vfox-nodejs/issues/29
46+
function UTIL.is_musl()
47+
local override = os.getenv("VFOX_NODEJS_LIBC")
48+
if override == "musl" then
49+
return true
50+
end
51+
if override == "gnu" or override == "glibc" then
52+
return false
53+
end
54+
if RUNTIME ~= nil and RUNTIME.envType ~= nil then
55+
return RUNTIME.envType == "musl"
56+
end
57+
if RUNTIME ~= nil and RUNTIME.osType ~= nil and RUNTIME.osType ~= "linux" then
58+
return false
59+
end
60+
if UTIL.file_exists("/etc/alpine-release") then
61+
return true
62+
end
63+
local musl_paths = {
64+
"/lib/ld-musl-x86_64.so.1",
65+
"/usr/lib/ld-musl-x86_64.so.1",
66+
"/lib64/ld-musl-x86_64.so.1",
67+
"/lib/ld-musl-aarch64.so.1",
68+
"/usr/lib/ld-musl-aarch64.so.1",
69+
"/lib64/ld-musl-aarch64.so.1",
70+
"/lib/ld-musl-armhf.so.1",
71+
"/usr/lib/ld-musl-armhf.so.1",
72+
"/lib/ld-musl-x86.so.1",
73+
"/usr/lib/ld-musl-x86.so.1",
74+
}
75+
for _, p in ipairs(musl_paths) do
76+
if UTIL.file_exists(p) then
77+
return true
78+
end
79+
end
80+
return false
81+
end
82+
83+
--- Whether libstdc++.so.6 is visible at a well-known location.
84+
--- Node.js musl tarballs fail with `Error relocating ... symbol not found`
85+
--- when it is missing.
86+
function UTIL.has_libstdcxx()
87+
local lib_paths = {
88+
"/usr/lib/libstdc++.so.6",
89+
"/lib/libstdc++.so.6",
90+
"/usr/lib64/libstdc++.so.6",
91+
"/lib64/libstdc++.so.6",
92+
"/usr/lib/x86_64-linux-gnu/libstdc++.so.6",
93+
"/lib/x86_64-linux-gnu/libstdc++.so.6",
94+
"/usr/lib/aarch64-linux-gnu/libstdc++.so.6",
95+
"/lib/aarch64-linux-gnu/libstdc++.so.6",
96+
"/usr/lib/arm-linux-gnueabihf/libstdc++.so.6",
97+
"/lib/arm-linux-gnueabihf/libstdc++.so.6",
98+
}
99+
for _, p in ipairs(lib_paths) do
100+
if UTIL.file_exists(p) then
101+
return true
102+
end
103+
end
104+
return false
105+
end
106+
107+
--- Install command for the missing libstdc++ runtime, picked by probing
108+
--- the system package manager (Alpine apk, OpenWrt opkg).
109+
function UTIL.libstdcxx_hint()
110+
if UTIL.file_exists("/etc/alpine-release") or UTIL.file_exists("/sbin/apk") or UTIL.file_exists("/usr/bin/apk") then
111+
return "apk add libstdc++"
112+
end
113+
if UTIL.file_exists("/etc/openwrt_release") or UTIL.file_exists("/bin/opkg") or UTIL.file_exists("/usr/bin/opkg") then
114+
return "opkg update && opkg install libstdcpp"
115+
end
116+
return "install libstdc++ with the system package manager (Alpine: apk add libstdc++, OpenWrt: opkg install libstdcpp)"
117+
end
118+
30119
function UTIL.compare_versions(v1o, v2o)
31120
local v1 = v1o.version
32121
local v2 = v2o.version

0 commit comments

Comments
 (0)