From fba54e938688e43679ba23e7a022324ab9315502 Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 23 Aug 2026 13:27:24 +0200 Subject: [PATCH 1/5] fix(native): load the addon on first use, not at import `nativeBinding = loadNativeBinding()` ran at module scope, so `import 'wreq-js'` threw whenever the addon could not be resolved. An import that throws takes the host process down at startup and leaves an embedder no way to recover: it cannot catch what a static import does, so its only defence is to make its own import dynamic and demote the dependency. That is exactly what our largest consumer did, over five commits: fix(build): include wreq-js native assets in standalone fix(codex): avoid startup crash when wreq-js is unavailable fix(codex): make wreq-js import lazy to prevent startup crash fix(postinstall): extend native module repair to cover wreq-js for pnpm fix(infrastructure): move wreq-js to optionalDependencies It is still costing their users. diegosouzapw/OmniRoute#10171 is our `Unsupported platform:` string reaching a reporter on native Windows and WSL2 through a handler that misread it as Android/Termux: the server announced itself as running, every request returned a bare 500, and the log stayed empty while they worked through `~/.cache` and `XDG_CACHE_HOME` for a fault that was ours and nowhere near either. The addon now resolves on first use through `binding()`, which memoises the failure as well as the addon so a broken install does not re-run `require` on every call. Importing the package cannot fail; using it still reports the same diagnostic, at a point where a caller can catch it. `isNativeAvailable()` is the supported way to ask in advance, for hosts that need to choose a fallback rather than handle a throw. The regression test runs the built bundle from a temporary directory outside the repository and outside any node_modules, so neither `../rust/*.node` nor `@wreq-js/binding-*` resolves, and asserts that the import still succeeds there. --- docs/api-reference/overview.mdx | 2 + docs/api-reference/utilities.mdx | 36 ++++++++ src/test/unit/native-loading.spec.ts | 96 +++++++++++++++++++++ src/wreq-js.ts | 119 +++++++++++++++++++-------- 4 files changed, 220 insertions(+), 33 deletions(-) create mode 100644 src/test/unit/native-loading.spec.ts diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 1205089..c31ef10 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -24,6 +24,7 @@ import { getOperatingSystems, getEmulationHeaders, resolveProfile, + isNativeAvailable, // Classes Headers, @@ -52,6 +53,7 @@ import { | [`getOperatingSystems()`](/api-reference/utilities#getoperatingsystems) | List available operating systems | | [`getEmulationHeaders()`](/api-reference/utilities#getemulationheaders) | Read the headers a browser profile injects | | [`resolveProfile()`](/api-reference/utilities#resolveprofile) | Resolve a family alias to a concrete profile | +| [`isNativeAvailable()`](/api-reference/utilities#isnativeavailable) | Check whether the native addon can be loaded | ## TypeScript support diff --git a/docs/api-reference/utilities.mdx b/docs/api-reference/utilities.mdx index aef267c..bf0b6ca 100644 --- a/docs/api-reference/utilities.mdx +++ b/docs/api-reference/utilities.mdx @@ -164,6 +164,42 @@ const response = await fetch('https://example.com', { --- +## isNativeAvailable() + +Report whether the native addon for the current platform can be loaded. + +### Signature + +```typescript +function isNativeAvailable(): boolean +``` + +### Returns + +`true` when the addon is loadable, `false` otherwise. + +The addon is loaded on first use, not when the module is imported, so `import 'wreq-js'` +never throws — a host that cannot run without it is free to check and fall back instead of +wrapping its own import in a `try`/`catch`. Calling any other export while this returns +`false` throws with the underlying reason. + +It returns `false` when the platform has no published addon, when the matching +`@wreq-js/binding-*` package was skipped at install time (`--no-optional`, a strict package +manager layout, or an install performed for a different platform), or when an addon is +present but refuses to load, such as on an ABI mismatch. + +### Example + +```typescript +import { isNativeAvailable } from 'wreq-js'; + +if (!isNativeAvailable()) { + console.warn('wreq-js native addon unavailable, falling back to global fetch'); +} +``` + +--- + ## Headers The `Headers` class for working with HTTP headers. diff --git a/src/test/unit/native-loading.spec.ts b/src/test/unit/native-loading.spec.ts new file mode 100644 index 0000000..b4ce513 --- /dev/null +++ b/src/test/unit/native-loading.spec.ts @@ -0,0 +1,96 @@ +import assert from "node:assert"; +import { execFile } from "node:child_process"; +import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { describe, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { isNativeAvailable } from "../../wreq-js.js"; + +const execFileAsync = promisify(execFile); +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const bundle = resolve(projectRoot, "dist", "wreq-js.js"); + +/** + * Run `script` against a copy of the built bundle placed somewhere the native + * addon cannot be resolved from: outside the repository, so `../rust/*.node` is + * absent, and outside any `node_modules`, so the `@wreq-js/binding-*` packages + * are not reachable either. + */ +async function runWithoutAddon(script: (specifier: string) => string): Promise { + const dir = mkdtempSync(resolve(tmpdir(), "wreq-js-no-addon-")); + + try { + const copied = resolve(dir, "wreq-js.mjs"); + copyFileSync(bundle, copied); + + const { stdout } = await execFileAsync( + process.execPath, + ["--input-type=module", "--eval", script(`file://${copied}`)], + { cwd: dir }, + ); + + return stdout.trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("native addon loading", () => { + test("isNativeAvailable() reports the addon this suite runs against", () => { + assert.strictEqual(isNativeAvailable(), true); + }); + + test("importing the package does not throw when the addon is missing", async (t) => { + if (!existsSync(bundle)) { + t.skip("dist bundle not built"); + return; + } + + // Importing a module that throws takes down the host process at startup and + // leaves an embedder no way to recover, so the addon must load on first use + // rather than at module scope. + const output = await runWithoutAddon( + (specifier) => `await import(${JSON.stringify(specifier)}); console.log("imported");`, + ); + + assert.strictEqual(output, "imported"); + }); + + test("isNativeAvailable() returns false when the addon is missing", async (t) => { + if (!existsSync(bundle)) { + t.skip("dist bundle not built"); + return; + } + + const output = await runWithoutAddon( + (specifier) => + `const m = await import(${JSON.stringify(specifier)}); console.log(String(m.isNativeAvailable()));`, + ); + + assert.strictEqual(output, "false"); + }); + + test("using the library without the addon throws the load error", async (t) => { + if (!existsSync(bundle)) { + t.skip("dist bundle not built"); + return; + } + + const output = await runWithoutAddon( + (specifier) => ` + const m = await import(${JSON.stringify(specifier)}); + try { + await m.fetch("http://127.0.0.1:1/"); + console.log("no-throw"); + } catch (error) { + console.log(error instanceof Error ? error.message : String(error)); + } + `, + ); + + assert.match(output, /Failed to load native module|Unsupported platform/); + }); +}); diff --git a/src/wreq-js.ts b/src/wreq-js.ts index 299198c..5ca60bd 100644 --- a/src/wreq-js.ts +++ b/src/wreq-js.ts @@ -126,7 +126,7 @@ interface NativeRequestOptions { onRequestEvent?: (event: RequestEvent) => void; } -let nativeBinding: { +type NativeBinding = { request: (options: NativeRequestOptions, requestId: number, enableCancellation?: boolean) => Promise; cancelRequest: (requestId: number) => void; readBodyChunk: (handleId: number) => Promise; @@ -264,12 +264,63 @@ function loadNativeBinding() { ); } -nativeBinding = loadNativeBinding(); +let nativeBinding: NativeBinding | undefined; +let nativeLoadError: unknown; + +/** + * Resolve the native addon, loading it on first use. + * + * The addon is deliberately not loaded at module scope. Importing a module that + * throws takes down the host process at startup, which leaves an embedder no way + * to degrade gracefully - its only defence is to make its own import dynamic. A + * failure here surfaces when the library is actually used instead, where it can + * be caught. Both outcomes are memoised so a failed load does not re-run + * `require` on every call. + */ +function binding(): NativeBinding { + if (nativeBinding) { + return nativeBinding; + } + + if (nativeLoadError !== undefined) { + throw nativeLoadError; + } + + try { + const loaded: NativeBinding = loadNativeBinding(); + nativeBinding = loaded; + return loaded; + } catch (error) { + nativeLoadError = error; + throw error; + } +} + +/** + * Report whether the native addon for the current platform can be loaded. + * + * Importing this package never throws, so an embedder that must keep running + * without wreq-js can probe with this rather than wrapping the import itself. + * Returns `false` on an unsupported platform, a binding package that was skipped + * (`--no-optional`, a strict package manager layout, an install performed for a + * different platform), or an addon that exists but refuses to load. Calling any + * other export in those cases throws with the underlying reason. + */ +export function isNativeAvailable(): boolean { + try { + binding(); + return true; + } catch { + return false; + } +} const websocketFinalizer = typeof FinalizationRegistry === "function" ? new FinalizationRegistry((connection: NativeWebSocketConnection) => { - void nativeBinding.websocketClose(connection).catch(() => undefined); + void binding() + .websocketClose(connection) + .catch(() => undefined); }) : undefined; @@ -284,7 +335,7 @@ const bodyHandleFinalizer = handle.released = true; try { - nativeBinding.cancelBody(handle.id); + binding().cancelBody(handle.id); } catch { // Best-effort cleanup; ignore binding-level failures. } @@ -701,7 +752,7 @@ function releaseNativeBody(handle: NativeBodyHandle): void { handle.released = true; try { - nativeBinding.cancelBody(handle.id); + binding().cancelBody(handle.id); } catch { // Best-effort cleanup; ignore binding errors. } @@ -722,7 +773,7 @@ function createNativeBodyStream(handle: NativeBodyHandle): ReadableStream({ async pull(controller) { try { - const chunk = await nativeBinding.readBodyChunk(handle.id); + const chunk = await binding().readBodyChunk(handle.id); if (chunk === null) { releaseNativeBody(handle); @@ -1039,7 +1090,7 @@ export class Response { if (this.nativeHandleAvailable && this.payload.bodyHandle !== null) { this.nativeHandleAvailable = false; try { - return await nativeBinding.readBodyAll(this.payload.bodyHandle); + return await binding().readBodyAll(this.payload.bodyHandle); } catch (error) { // Handle already consumed or error if (String(error).includes("Body handle") && String(error).includes("not found")) { @@ -1106,7 +1157,7 @@ export class Transport { this.disposed = true; try { - nativeBinding.dropTransport(this.id); + binding().dropTransport(this.id); } catch (error) { throw new RequestError(String(error)); } @@ -1160,7 +1211,7 @@ export class Session implements SessionHandle { async clearCookies(): Promise { this.ensureActive(); try { - nativeBinding.clearSession(this.id); + binding().clearSession(this.id); } catch (error) { throw new RequestError(String(error)); } @@ -1169,7 +1220,7 @@ export class Session implements SessionHandle { getCookies(url: string | URL): Record { this.ensureActive(); try { - return nativeBinding.getCookies(this.id, String(url)); + return binding().getCookies(this.id, String(url)); } catch (error) { throw new RequestError(String(error)); } @@ -1178,7 +1229,7 @@ export class Session implements SessionHandle { getAllCookies(): SessionCookie[] { this.ensureActive(); try { - return nativeBinding.getAllCookies(this.id); + return binding().getAllCookies(this.id); } catch (error) { throw new RequestError(String(error)); } @@ -1187,7 +1238,7 @@ export class Session implements SessionHandle { setCookie(name: string, value: string, url: string | URL): void { this.ensureActive(); try { - nativeBinding.setCookie(this.id, name, value, String(url)); + binding().setCookie(this.id, name, value, String(url)); } catch (error) { throw new RequestError(String(error)); } @@ -1226,7 +1277,7 @@ export class Session implements SessionHandle { options: normalized.options, openDispatchMode: "deferred", connect: (callbacks) => - nativeBinding.websocketConnectSession({ + binding().websocketConnectSession({ url: normalized.url, sessionId: this.id, transportId, @@ -1254,7 +1305,7 @@ export class Session implements SessionHandle { const ownsTransport = this.defaults.ownsTransport; try { - nativeBinding.dropSession(this.id); + binding().dropSession(this.id); } catch (error) { if (!ownsTransport || !transportId) { throw new RequestError(String(error)); @@ -1262,7 +1313,7 @@ export class Session implements SessionHandle { // Fall through to transport cleanup and surface the original error after. const originalError = error; try { - nativeBinding.dropTransport(transportId); + binding().dropTransport(transportId); } catch { // Ignore transport cleanup errors when a session drop error already occurred. } @@ -1271,7 +1322,7 @@ export class Session implements SessionHandle { if (ownsTransport && transportId) { try { - nativeBinding.dropTransport(transportId); + binding().dropTransport(transportId); } catch (error) { throw new RequestError(String(error)); } @@ -2452,7 +2503,7 @@ async function dispatchRequest( let payload: NativeResponse; try { - payload = (await nativeBinding.request(options, requestId, false)) as NativeResponse; + payload = (await binding().request(options, requestId, false)) as NativeResponse; } catch (error) { if (error instanceof RequestError) { throw error; @@ -2466,7 +2517,7 @@ async function dispatchRequest( const requestId = generateRequestId(); const cancelNative = () => { try { - nativeBinding.cancelRequest(requestId); + binding().cancelRequest(requestId); } catch { // Cancellation is best-effort; ignore binding errors here. } @@ -2476,7 +2527,7 @@ async function dispatchRequest( // (impossible here since we checked `!signal` above). Cast is safe; avoids non-null assertion lint. const abortHandler = setupAbort(signal, cancelNative) as AbortHandler; - const pending = Promise.race([nativeBinding.request(options, requestId, true), abortHandler.promise]); + const pending = Promise.race([binding().request(options, requestId, true), abortHandler.promise]); let payload: NativeResponse; @@ -2657,7 +2708,7 @@ export async function createTransport(options?: CreateTransportOptions): Promise }; applyNativeEmulationMode(transportOptions, mode); - const id = nativeBinding.createTransport(transportOptions); + const id = binding().createTransport(transportOptions); return new Transport(id); } catch (error) { @@ -2681,18 +2732,18 @@ export async function createSession(options?: CreateSessionOptions): Promise { */ export function getProfiles(): BrowserProfile[] { if (!cachedProfiles) { - cachedProfiles = nativeBinding.getProfiles() as BrowserProfile[]; + cachedProfiles = binding().getProfiles() as BrowserProfile[]; } return cachedProfiles; @@ -2847,7 +2898,7 @@ function getProfileSet(): Set { */ export function getOperatingSystems(): EmulationOS[] { if (!cachedOperatingSystems) { - const fromNative = nativeBinding.getOperatingSystems?.() as EmulationOS[] | undefined; + const fromNative = binding().getOperatingSystems?.() as EmulationOS[] | undefined; cachedOperatingSystems = fromNative && fromNative.length > 0 ? fromNative : [...SUPPORTED_OSES]; } @@ -2925,7 +2976,7 @@ export function getEmulationHeaders(browser?: BrowserProfile | BrowserAlias, os? let tuples = cachedEmulationHeaders.get(cacheKey); if (!tuples) { - const readEmulationHeaders = nativeBinding.getEmulationHeaders; + const readEmulationHeaders = binding().getEmulationHeaders; if (!readEmulationHeaders) { throw new RequestError("getEmulationHeaders is not available in this build of the native addon"); } @@ -3372,7 +3423,7 @@ export class WebSocket { onError: callbacks.onError, }; applyNativeEmulationMode(nativeOptions, emulationMode); - return nativeBinding.websocketConnect(nativeOptions); + return binding().websocketConnect(nativeOptions); }, legacyCallbacks: extractLegacyWebSocketCallbacks(optionsCandidate), }; @@ -3645,10 +3696,12 @@ export class WebSocket { const connection = this._connection; const closeOptions = this._closeOptions; - void nativeBinding.websocketClose(connection, closeOptions).catch((error) => { - this.handleNativeError(String(error)); - this.finalizeClosed({ code: 1006, reason: "" }, false); - }); + void binding() + .websocketClose(connection, closeOptions) + .catch((error) => { + this.handleNativeError(String(error)); + this.finalizeClosed({ code: 1006, reason: "" }, false); + }); } addEventListener( @@ -3797,7 +3850,7 @@ export class WebSocket { const sendTask = async () => { try { const payload = await this.normalizeSendPayload(data); - await nativeBinding.websocketSend(connection, payload); + await binding().websocketSend(connection, payload); } catch (error) { this.handleNativeError(String(error)); this.finalizeClosed({ code: 1006, reason: "" }, false); @@ -3913,7 +3966,7 @@ export async function websocket( onError: callbacks.onError, }; applyNativeEmulationMode(nativeOptions, emulationMode); - return nativeBinding.websocketConnect(nativeOptions); + return binding().websocketConnect(nativeOptions); }, legacyCallbacks: normalized.legacyCallbacks, }); From d756d99400ef9335fb5fb7805717248c7f36fcaa Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 23 Aug 2026 13:33:32 +0200 Subject: [PATCH 2/5] fix(packaging): drop os/cpu from the main package Since 3.1.0 the published `wreq-js` tarball is JavaScript and nothing else - 0.66 MB across eight files, with every addon living in a `@wreq-js/binding-*` package that carries its own `os`, `cpu` and `libc`. The root `os` and `cpu` fields are left over from the 60 MB tarball that shipped the binaries inline, and they now do active harm. npm refuses to install a package whose `os` excludes the running platform and fails the whole tree with EBADPLATFORM. That verdict does not care that the tarball is portable JavaScript: npm error code EBADPLATFORM npm error notsup Unsupported platform for wreq-js@3.1.0: wanted {"os":"linux,win32","cpu":"x64,arm64"} (current: {"os":"darwin"...}) `os` listed darwin, linux and win32, so on Android the install of any dependent died outright. The only way to depend on us there was to make the dependency optional, which is what our largest consumer did, and which costs them the dependency everywhere else too. Platform selection belongs to the binding packages, and it already works there: npm skips an optional dependency whose platform does not match and carries on. Removing the fields lets the JavaScript install anywhere, lets the right addon be selected where one exists, and leaves platforms without one to the graceful path that now exists for them - the import resolves, `isNativeAvailable()` answers false, and use reports why. This is the same layout esbuild and swc use for the same reason. --- package.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/package.json b/package.json index b637f7b..3f22ee1 100644 --- a/package.json +++ b/package.json @@ -98,15 +98,6 @@ "engines": { "node": ">=20.0.0" }, - "os": [ - "darwin", - "linux", - "win32" - ], - "cpu": [ - "x64", - "arm64" - ], "files": [ "dist" ], From 85c0dd4fb625d09fe5c2d4bf8283a658206c9daa Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 23 Aug 2026 15:19:30 +0200 Subject: [PATCH 3/5] feat(android): build and load the addon on Termux Discussion #110 asked for Android in March and was told no. The probe in August built the target cleanly, so what was left was the load, and the two things that made it look expensive turn out not to be. The addon carries NEEDED libc++_shared.so, because btls-sys asks cmake for CMAKE_ANDROID_STL_TYPE=c++_shared. That library ships with the NDK rather than with Android, which is the usual reason an NDK-built .so fails to load on a device. Termux resolves it from $PREFIX/lib, and its nodejs package declares TERMUX_PKG_DEPENDS="libc++, openssl, c-ares, libicu, libsqlite, ..." so every Termux that can run Node already has it. The dependency is satisfied by construction on the only Android userland that can load us. The other was testing. termux/termux-docker publishes the Termux userland - Bionic, the $PREFIX layout, pkg - as an image, with an aarch64 tag that matches what we build. The Android SDK is only on the x86_64 runner images, so the probe builds there and reaches the aarch64 image through binfmt. It installs nodejs with pkg, which pulls libc++ the same way a user would, and runs the existing smoke test. Peers ship this target without any of that: rollup builds aarch64-linux-android and then excludes android from its test step. `resolveNativePlatform` gains the target. Node reports platform "android" under Termux, and Bionic is neither glibc nor musl, so it is matched ahead of the linux branches rather than sent through detectLibc(). optionalDependencies is deliberately left alone. `napi pre-publish` rewrites it from the npm/ directories during a release, so the entry appears when the package it names is published in the same run. Adding it by hand ahead of that is what produced the versionless placeholders that broke every `npm ci` on master once 3.1.0 published the names they referred to. --- .github/workflows/experiment-targets.yml | 32 ++++++++++++++ npm/android-arm64/README.md | 3 ++ npm/android-arm64/package.json | 55 ++++++++++++++++++++++++ package.json | 3 +- scripts/smoke-native.cjs | 1 + src/wreq-js.ts | 8 ++++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 npm/android-arm64/README.md create mode 100644 npm/android-arm64/package.json diff --git a/.github/workflows/experiment-targets.yml b/.github/workflows/experiment-targets.yml index 68229df..b8b0ffd 100644 --- a/.github/workflows/experiment-targets.yml +++ b/.github/workflows/experiment-targets.yml @@ -88,6 +88,38 @@ jobs: echo "--- NEEDED ---" readelf -d rust/*.node | grep NEEDED || true + - name: Load the addon in a real Termux userland + # The question this probe exists to answer. btls-sys links libc++ as a + # shared library, so the addon carries NEEDED libc++_shared.so, which + # ships with the NDK rather than with Android. Termux resolves it from + # $PREFIX/lib, and its nodejs package depends on libc++, so any Termux + # that can run Node already has it - but that has to be demonstrated, + # not assumed. + # + # termux/termux-docker is the Termux userland (Bionic, $PREFIX layout, + # pkg) as an image. The aarch64 tag matches the addon we just built and + # needs binfmt on an x86_64 runner; the Android SDK is only on the + # x86_64 runner images, so building here and emulating for the load is + # the way round that works. + run: | + set -eux + docker run --rm --privileged aptman/qus -s -- -p aarch64 + + docker run --rm --privileged \ + -e WREQ_PLATFORM_ARCH=android-arm64 \ + -v "$PWD:/data/data/com.termux/files/home/wreq-js" \ + -w /data/data/com.termux/files/home/wreq-js \ + termux/termux-docker:aarch64 \ + /entrypoint.sh bash -lc ' + set -eux + pkg update -y + pkg install -y nodejs + node --version + # Prove the loader resolves the addon the way Termux will see it. + node -e "console.log(process.platform, process.arch)" + node scripts/smoke-native.cjs + ' + - name: Upload addon uses: actions/upload-artifact@v7 with: diff --git a/npm/android-arm64/README.md b/npm/android-arm64/README.md new file mode 100644 index 0000000..76d0cd0 --- /dev/null +++ b/npm/android-arm64/README.md @@ -0,0 +1,3 @@ +# `@wreq-js/binding-android-arm64` + +This is the **aarch64-linux-android** binary for `@wreq-js/binding` diff --git a/npm/android-arm64/package.json b/npm/android-arm64/package.json new file mode 100644 index 0000000..51d95f2 --- /dev/null +++ b/npm/android-arm64/package.json @@ -0,0 +1,55 @@ +{ + "name": "@wreq-js/binding-android-arm64", + "version": "3.1.0", + "cpu": [ + "arm64" + ], + "main": "wreq-js.android-arm64.node", + "files": [ + "wreq-js.android-arm64.node" + ], + "description": "Node.js/TypeScript HTTP client with browser TLS fingerprint impersonation (JA3/JA4). Bypass Cloudflare and anti-bot detection. Rust-powered, fetch()-compatible.", + "keywords": [ + "cloudflare", + "cloudflare-bypass", + "anti-bot", + "bypass", + "tls-fingerprint", + "browser-fingerprint", + "ja3", + "ja4", + "impersonation", + "browser-emulation", + "tls", + "http2", + "fetch", + "http-client", + "web-scraping", + "web-scraper", + "crawler", + "typescript", + "nodejs", + "rust", + "wreq", + "browser" + ], + "author": "Oleksandr Herasymov ", + "homepage": "https://wreq.sqdsh.win", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sqdshguy/wreq-js.git" + }, + "bugs": { + "url": "https://github.com/sqdshguy/wreq-js/issues" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "android" + ] +} diff --git a/package.json b/package.json index 3f22ee1..defdd73 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,8 @@ "x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "x86_64-pc-windows-msvc", - "aarch64-pc-windows-msvc" + "aarch64-pc-windows-msvc", + "aarch64-linux-android" ] } } diff --git a/scripts/smoke-native.cjs b/scripts/smoke-native.cjs index ea97789..a625fe9 100644 --- a/scripts/smoke-native.cjs +++ b/scripts/smoke-native.cjs @@ -12,6 +12,7 @@ const targetMap = { "aarch64-unknown-linux-musl": "linux-arm64-musl", "x86_64-pc-windows-msvc": "win32-x64-msvc", "aarch64-pc-windows-msvc": "win32-arm64-msvc", + "aarch64-linux-android": "android-arm64", }; const platformArch = platformArchOverride ?? (target ? targetMap[target] : undefined); diff --git a/src/wreq-js.ts b/src/wreq-js.ts index 5ca60bd..02ce948 100644 --- a/src/wreq-js.ts +++ b/src/wreq-js.ts @@ -188,6 +188,7 @@ const NATIVE_PLATFORMS = [ "linux-arm64-musl", "win32-x64-msvc", "win32-arm64-msvc", + "android-arm64", ] as const; type NativePlatform = (typeof NATIVE_PLATFORMS)[number]; @@ -196,6 +197,13 @@ function resolveNativePlatform(): NativePlatform | undefined { const platform = process.platform; const arch = process.arch; + // Termux runs Node on Android, where process.platform is "android". Its Bionic + // libc is neither glibc nor musl, so it is matched before the linux branches + // rather than routed through detectLibc(). + if (platform === "android" && arch === "arm64") { + return "android-arm64"; + } + if (platform === "darwin" && arch === "x64") { return "darwin-x64"; } From 6564d59353039f069f4d1393055a83753519edec Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 23 Aug 2026 15:29:05 +0200 Subject: [PATCH 4/5] ci(android): set the smoke target inside the Termux shell /entrypoint.sh hands off to a login shell, which rebuilds the environment from Termux profile, so WREQ_PLATFORM_ARCH passed through docker -e never reached the test: Error: Set WREQ_TARGET or WREQ_PLATFORM_ARCH to locate the native binding. Everything before that point worked: pkg installed nodejs 26.4.0 and the runtime reported "android arm64", which is what the loader keys off. Export it where the test runs, and check libc++_shared.so is present while we are in there, since the whole probe turns on that library being part of a Termux that can run Node. --- .github/workflows/experiment-targets.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/experiment-targets.yml b/.github/workflows/experiment-targets.yml index b8b0ffd..80399d9 100644 --- a/.github/workflows/experiment-targets.yml +++ b/.github/workflows/experiment-targets.yml @@ -112,11 +112,23 @@ jobs: termux/termux-docker:aarch64 \ /entrypoint.sh bash -lc ' set -eux + # /entrypoint.sh hands off to a login shell, which rebuilds the + # environment from Termux profile, so docker -e does not survive + # into here. Set it where the test actually runs. + export WREQ_PLATFORM_ARCH=android-arm64 + pkg update -y pkg install -y nodejs node --version - # Prove the loader resolves the addon the way Termux will see it. + + # The loader keys off these two, and Bionic answers "android". node -e "console.log(process.platform, process.arch)" + + # The dependency this probe exists to check. libc++ is baseline in + # Termux and a declared dependency of its nodejs package, so it is + # here before we ask for it - show that rather than assume it. + ls -l "$PREFIX/lib/libc++_shared.so" + node scripts/smoke-native.cjs ' From f390d2a5d7855fd5a59a7b7c524bc7cdf4c77a08 Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 23 Aug 2026 15:36:45 +0200 Subject: [PATCH 5/5] ci(android): build and smoke test android-arm64 for release The probe answered its question. In a real Termux userland, on the addon this workflow now builds: android arm64 -rwx------ 1 system system 1374336 .../usr/lib/libc++_shared.so smoke-ok wreq-js.android-arm64.node profiles=133 libc++_shared.so is in $PREFIX/lib before anything asks for it - not pulled in by the nodejs package, already part of the base userland - so the NDK dependency that made this target look expensive costs nothing on the one Android userland that can load us. The target moves into the release matrix on the same terms as the others: built, smoke tested, uploaded, and now reachable through napi.targets, which the publish job turns into a platform package. It cross-compiles on ubuntu-24.04 because the Android SDK is only on the x86_64 runner images, and reaches the aarch64 Termux image through binfmt for the load. Documented in the installation table and the README. --- .github/workflows/build.yml | 65 +++++++++++++++++++++++++++++++++++++ README.md | 2 +- docs/installation.mdx | 1 + 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d933749..dba3655 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,11 @@ jobs: arch: arm64 generator: Ninja build: npm run build:rust -- --target aarch64-pc-windows-msvc + # Cross-compiled: the Android SDK is only on the x86_64 runner images. + - host: ubuntu-24.04 + target: aarch64-linux-android + arch: x64 + build: npm run build:rust -- --target aarch64-linux-android name: Build - ${{ matrix.settings.target }} runs-on: ${{ matrix.settings.host }} @@ -238,6 +243,36 @@ jobs: print("pointed the ASM language at clang-cl for windows-arm64") PY + - name: Locate libclang for bindgen (Android) + if: matrix.settings.target == 'aarch64-linux-android' + # The image ships Clang, so no apt install: asking for clang and + # libclang-dev here once sat for an hour before being cancelled. + run: | + set -eux + libclang=$(find /usr/lib/llvm-* -name "libclang.so*" 2>/dev/null | head -1) + test -n "$libclang" + echo "LIBCLANG_PATH=$(dirname "$libclang")" >> "$GITHUB_ENV" + + - name: Build native module (Android) + if: matrix.settings.target == 'aarch64-linux-android' + env: + CARGO_BUILD_TARGET: aarch64-linux-android + CARGO_TARGET_DIR: rust/target + run: | + set -eux + NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + SYSROOT="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" + export PATH="$NDK_BIN:$PATH" + + # API 24 is Termux's floor; btls-sys asks cmake for 21. + export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang" + export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++" + export AR_aarch64_linux_android="$NDK_BIN/llvm-ar" + export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$CC_aarch64_linux_android" + export BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-linux-android24 --sysroot=$SYSROOT" + + ${{ matrix.settings.build }} + - name: Build native module (x86_64 musl target) if: matrix.settings.target == 'x86_64-unknown-linux-musl' env: @@ -396,6 +431,36 @@ jobs: node:20-alpine \ node scripts/smoke-native.cjs + - name: Smoke test native addon (Termux, android-arm64) + if: matrix.settings.target == 'aarch64-linux-android' + # termux/termux-docker is the Termux userland - Bionic, the $PREFIX + # layout, pkg - as an image. The aarch64 tag matches what we just built + # and needs binfmt here, since the Android SDK pins the build to x86_64. + # + # This exists because the addon carries NEEDED libc++_shared.so, which + # ships with the NDK rather than with Android. Termux has it in + # $PREFIX/lib, so the load works - assert that rather than trust it. + run: | + set -eux + docker run --rm --privileged aptman/qus -s -- -p aarch64 + + docker run --rm --privileged \ + -v "$PWD:/data/data/com.termux/files/home/wreq-js" \ + -w /data/data/com.termux/files/home/wreq-js \ + termux/termux-docker:aarch64 \ + /entrypoint.sh bash -lc ' + set -eux + # /entrypoint.sh hands off to a login shell, which rebuilds the + # environment, so docker -e does not reach in here. + export WREQ_PLATFORM_ARCH=android-arm64 + + pkg update -y + pkg install -y nodejs + node -e "console.log(process.platform, process.arch)" + ls -l "$PREFIX/lib/libc++_shared.so" + node scripts/smoke-native.cjs + ' + - name: Upload artifacts uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index 26e7e01..92a6f37 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ npm install wreq-js # or: yarn add wreq-js / pnpm add wreq-js / bun add wreq-js ``` -Prebuilt native binaries ship for macOS (Intel and Apple Silicon), Linux (x64 and arm64, glibc and musl), and Windows (x64 and arm64). Each one lives in its own `@wreq-js/binding-*` package listed under `optionalDependencies`, so an install downloads only the addon your platform actually loads. Platforms outside that list are not supported; build from source with a Rust toolchain instead (see [docs/BUILD.md](docs/BUILD.md)). +Prebuilt native binaries ship for macOS (Intel and Apple Silicon), Linux (x64 and arm64, glibc and musl), Windows (x64 and arm64), and Android arm64 under Termux. Each one lives in its own `@wreq-js/binding-*` package listed under `optionalDependencies`, so an install downloads only the addon your platform actually loads. Platforms outside that list are not supported; build from source with a Rust toolchain instead (see [docs/BUILD.md](docs/BUILD.md)). Node.js 20 or newer. diff --git a/docs/installation.mdx b/docs/installation.mdx index 1974b57..f53d2d8 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -42,6 +42,7 @@ Configured native targets in `package.json` include: | Linux | arm64 (musl) | | Windows | x64 | | Windows | arm64 | +| Android | arm64 (Termux) | ## Building from source