From b70ea5b172294eed189a56df017e4e853a67ca23 Mon Sep 17 00:00:00 2001 From: fogtape Date: Fri, 2 Oct 2026 17:28:58 +0800 Subject: [PATCH 1/3] build: retain full ICU and tailor Node 26 lite to danmu_api Keep GBK/Big5 decoding, Chinese collation and NFKC normalization in both flavors. Remove optional features unused by the core, align iOS linking and boot smokes, and regenerate the recipe with verified tree hashes. Assisted-by: Codex Signed-off-by: fogtape --- .github/workflows/build.yml | 2 +- .github/workflows/ios-simulator-tests.yml | 9 +- .github/workflows/verify-patches.yml | 7 + README.md | 17 +- docs/BUILDING.md | 72 ++++---- docs/DANMU-LITE.md | 44 +++++ docs/EMBEDDING.md | 10 +- docs/FAQ.md | 13 +- docs/RELEASING.md | 5 +- docs/VERSION-LINES.md | 5 +- expected-tree.txt | 2 +- mobile-src/tools/ios_framework_prepare.sh | 20 ++- .../mobile-test/smoke/mobile-features.js | 98 +++++++---- .../mobile-test/smoke/run-android-smoke.sh | 4 +- .../tools/mobile-test/smoke/run-ios-smoke.sh | 5 +- ...S-dest-os-and-toolchain-configure.py.patch | 15 +- scripts/package-preview.py | 7 +- scripts/test-mobile-flavors.py | 157 ++++++++++++++++++ 18 files changed, 365 insertions(+), 127 deletions(-) create mode 100644 docs/DANMU-LITE.md create mode 100755 scripts/test-mobile-flavors.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48d58f044d..eb69d9b77b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -596,7 +596,7 @@ jobs: if [ "$NODEJS_MOBILE_FLAVOR" = "full" ]; then ./configure --with-intl=full-icu --v8-enable-temporal-support else - ./configure --with-intl=none --v8-disable-temporal-support + ./configure --with-intl=full-icu --v8-disable-temporal-support --without-ffi --without-amaro --without-inspector --without-sqlite --disable-single-executable-application --v8-disable-object-print fi - name: Combine diff --git a/.github/workflows/ios-simulator-tests.yml b/.github/workflows/ios-simulator-tests.yml index 9c953235aa..4795137a53 100644 --- a/.github/workflows/ios-simulator-tests.yml +++ b/.github/workflows/ios-simulator-tests.yml @@ -55,12 +55,9 @@ permissions: jobs: ios-simulator-tests: - # Both flavors run the SAME curated list, no exclusions: the whole list - # verified passing on a local lite build despite iOS lite dropping the - # WebAssembly + Intl globals (--v8-lite-mode + intl=none) — the ICU tests - # self-skip via common.hasIntl and the WebAssembly-named test only - # inspects a string fixture. Each flavor fetches its own framework from - # the same Build run (which produces both). + # Both flavors run the same curated list with full ICU enabled. iOS uses + # jitless V8/polywasm in both flavors. Each leg fetches its own framework + # from this Build run; no flavor-specific test exclusions are added. strategy: fail-fast: false matrix: diff --git a/.github/workflows/verify-patches.yml b/.github/workflows/verify-patches.yml index db8a4cb395..9c84cf896e 100644 --- a/.github/workflows/verify-patches.yml +++ b/.github/workflows/verify-patches.yml @@ -84,6 +84,13 @@ jobs: - name: Reconstruct + verify run: scripts/prepare.sh out + - uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Check danmu_api flavor configuration and ICU contract + run: python3 scripts/test-mobile-flavors.py out + - name: Check mobile toolchain actions run: | python3 -m pip install setuptools packaging diff --git a/README.md b/README.md index 07dbef96d0..956c246138 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ mobile build is identified by `process.versions.mobile` (e.g. `"26.10.0-0"`). Node 26 prereleases are published from `recipe-v26` after the release gates pass. CI preview packages are also available from successful **Build** runs. Published Node 24 assets remain on the Releases page. -Node 26 full builds enable Temporal and `node:ffi`. Lite builds also enable -`node:ffi`, while Temporal stays disabled with the no-ICU flavor. See +Node 26 full builds enable Temporal and `node:ffi`. New lite builds retain +full ICU and disable both Temporal and `node:ffi`. See [version-specific configuration](docs/VERSION-LINES.md#node-26-configuration). Download the zips from [Releases](../../releases) — four per release: @@ -32,12 +32,13 @@ nodejs-mobile-android-X.Y.Z-R.zip nodejs-mobile-ios-X.Y.Z-R.zip nodejs-mobile-android-lite-X.Y.Z-R.zip nodejs-mobile-ios-lite-X.Y.Z-R.zip ``` -**`full`** is the general-purpose binary — pick it unless size is critical. -**`lite`** reduces size by dropping ICU, the inspector, -`node:sqlite`, and TypeScript type-stripping (see -[BUILDING.md](docs/BUILDING.md#the-lite-variant) for the -full list and the caveats — the `Intl` one needs checking against your -dependency tree). +**`full`** is the general-purpose binary. **`lite`** retains full ICU for +`danmu_api`'s Chinese sorting, Unicode normalization and GBK/Big5 decoding, +but omits Inspector, SQLite/Web Storage, TypeScript type-stripping and SEA. +Node 26 lite also omits Temporal and `node:ffi`. +See [BUILDING.md](docs/BUILDING.md#the-lite-variant) and the +[danmu_api compatibility contract](docs/DANMU-LITE.md). Older release assets +keep the feature set they were built with; these changes apply to new builds. To embed it in an app, most people use a plugin rather than the raw library: [nodejs-mobile-react-native](https://github.com/nodejs-mobile/nodejs-mobile-react-native) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 1c403b4aa9..5f562de307 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -60,11 +60,10 @@ The build action selects the host triple for snapshot tools and a separate mobile triple for the library. Android ARM snapshots use i686 on x64 Linux hosts, matching V8’s 32-bit host tools. Android target archives use the supplied NDK and SDK level; iOS target archives use the device/simulator SDK and iOS 14 -minimum deployment target. Lite builds omit Temporal with ICU and need no Rust. -Both flavors include `node:ffi`. Full builds use `--with-intl=full-icu` so -Temporal can load named time zones; small-ICU currently fails that path. -This increases the full library’s bundled ICU data size. Lite retains -`--with-intl=none`. +minimum deployment target. Lite builds omit Temporal and FFI and need no Rust. +Both flavors use `--with-intl=full-icu`; full builds need the complete data for +Temporal named time zones, while both flavors need it for Chinese collation +and legacy character decoders. ## Android — build on Linux @@ -168,45 +167,34 @@ To build it instead of the default, set `NODEJS_MOBILE_FLAVOR=lite` on either target's build command. The build ships in two flavors (selected by `NODEJS_MOBILE_FLAVOR`, default -`full`). **`full`** is the general-purpose binary all consumers get. **`lite`** -is a smaller binary for consumers that don't need the full feature set, built by -layering feature-drops — and on Android one V8 configuration change, [pointer -compression](#pointer-compression-android-lite-only) — on top of the full -configure, so the full binary and its test gate are unchanged. +`full`). Both bundle **full ICU**. The lite flavor is tailored to `danmu_api`: +it keeps Chinese collation, NFKC normalization, legacy text decoders, networking, +compression and JavaScript module loading, while removing unused optional features. -What `lite` drops (all already-available upstream `configure` flags, so no extra -patch-stack surface): - -| Cut | Why it can be dropped | +| Lite cut | Compatibility cost | |---|---| -| `--without-amaro` (TS type-stripping) | for consumers shipping plain `.js` | -| `--without-inspector` | not used in production | -| `--without-sqlite` | for consumers using the `better-sqlite3` addon, not `node:sqlite` | -| `--with-intl=none` (no ICU) | for consumers that use no `Intl.*` — verify against your own dependency tree (a dependency may reference `Intl` from a code path you never call) | -| **iOS only:** `--v8-lite-mode` | drops the compiled JIT + V8 WASM engine, both **dead on iOS** (it runs jitless; WebAssembly is served by the bundled polywasm polyfill — see [FAQ](./FAQ.md#does-fetch-work-what-about-webassembly)). This is the big lever. | - -Dead-code stripping (`--gc-sections`) applies to **both** Android flavors: the -linker only discards unreferenced sections, so it costs no functionality. - -Historical Node 24 shipping sizes (arm64, after symbol strip; these are -not measurements of the Node 26 port): - -- **iOS:** the dead-code removal above cut the lite device slice - **54.5 → 33.8 MB** (−38%); full shrinks by the same ~20 MB since every cut - is flavor-neutral (its size is measured by the release CI). -- **Android:** **61.5 MB (full)** / **45.6 MB (lite)** with gc-sections on - both flavors; no - `--v8-lite-mode` (Android keeps the JIT and V8's native WASM for undici). - -Those figures were measured before pointer compression was turned on for -Android; it aims at the heap rather than at the binary, and the release CI -re-measures the shipping artifacts on every release. - -`build-id` (`-Wl,--build-id=sha1`) is emitted on the Android `libnode.so` in -**both** flavors so crash reporters (e.g. Sentry) can symbolicate native -crashes. The safeguard for `intl=none` is running your own application's test -suite against the lite binary — it catches `Intl` breakage from future -dependency changes. +| `--without-amaro` | Cannot execute TypeScript through Node's built-in type stripping; JS/MJS/CJS and ESM remain supported | +| `--without-inspector` | No Node Inspector debugging | +| `--without-sqlite` | No `node:sqlite` or Node Web Storage; the core uses files/JSON/Redis and the pure UniDB implementation | +| `--disable-single-executable-application` | No SEA executable packaging; embedding and external core directories remain supported | +| `--v8-disable-object-print` | Omits native debugger object-print helpers; JavaScript logging remains supported | +| `--v8-disable-temporal-support` | No Temporal API; ordinary Date/timers remain supported | +| `--without-ffi` | No experimental `node:ffi`; embedding through `node::Start` and N-API are separate capabilities | + +Android retains the JIT and V8's native WebAssembly, which Undici uses for +`fetch`. Its 64-bit lite builds also retain the pointer-compression setting +below. Both iOS flavors retain the existing jitless `--v8-lite-mode` and +polywasm implementation; lite does not add an Android-style 4 GB pointer cage. +Full ICU archives are linked in both iOS flavors. + +Dead-code stripping and Android build IDs continue to apply to both flavors. +Older published size measurements used no-ICU lite builds and do not describe +this feature set. Measure freshly compiled artifacts before quoting size savings. + +The device boot smokes now assert full ICU, GBK/GB2312/Big5 decoding, Chinese +sorting and NFKC normalization, and check the expected full/lite optional +feature set. See [DANMU-LITE.md](DANMU-LITE.md) for the compatibility contract +and the recipe-level configure/archive tests. ### Pointer compression (Android lite only) diff --git a/docs/DANMU-LITE.md b/docs/DANMU-LITE.md new file mode 100644 index 0000000000..3748d11cbc --- /dev/null +++ b/docs/DANMU-LITE.md @@ -0,0 +1,44 @@ +# danmu_api runtime contract + +Current recipes build full ICU into full and lite on Android and iOS. This is +required by the core's `TextDecoder('gbk')` source requests, +`localeCompare(..., 'zh-CN')` resource sorting and `normalize('NFKC')` title keys. +Default small-ICU data does not provide the required legacy decoders or Chinese +collation; an ICU data-file override cannot fix a runtime built without ICU. + +Lite removes Amaro, Inspector, SQLite/Web Storage, SEA executable packaging +and V8 native-debugger object-print support. Node 26 lite also removes Temporal +and FFI. Core JS/MJS/CJS modules and the pure `@dan-uni/dan-any` UniDB do not +require these features. OpenCC remains a JavaScript dependency. + +Keep HTTP/HTTPS/TLS/crypto, DNS, fetch/Undici, WebAssembly, compression including +Brotli/zstd, filesystem/module loading, streams, AsyncLocalStorage and the +existing Worker/child-process APIs. The core and Android/Flutter hosts use +these capabilities. WASI/HTTP2 are not separately trimmed in this change. + +Android retains JIT/native WASM. Lite uses pointer compression only on 64-bit +Android; it primarily reduces heap memory and changes the direct V8-addon ABI. +iOS retains its existing jitless/polywasm setup without pointer compression. + +## Validation + +After materialization, run from the recipe directory: + +```sh +python3 scripts/test-mobile-flavors.py out +``` + +This invokes Android's configure wrapper for full/lite on arm, arm64 and +x86_64 using a configure recorder, evaluates the real iOS flavor/archive +selection, and runs the shipped ICU regression assertions on host Node. +It checks configuration and archive consistency, not a cross-compilation. + +The Android and iOS boot smokes run the same ICU assertions on freshly built +artifacts and verify the expected optional feature set. Node 26 full still +exercises native FFI calls/callbacks and Temporal date/timezone behavior; +lite asserts those features are absent. Existing fetch and curated device +tests continue to cover networking and the WASM implementation. + +Previously published archives remain unchanged. Compile, run device tests and +measure new artifacts before releasing or updating either App's pinned URL and +checksum. Future core/dependency updates must be checked against this contract. diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index e4f2fcb123..9bbfdab60a 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -121,11 +121,11 @@ Only the options upstream marks `kAllowedInEnvvar` are accepted, and ### Situational -**`NODE_ICU_DATA`** — meaningful only on the `full` flavor, which is built -`--with-intl=small-icu` on both platforms. Ship a full `icudt*.dat` as an asset -and point at it if you need locales beyond the small set. It is a **no-op on -`lite`**, which is `--with-intl=none` and has no `Intl` at all. See -[BUILDING.md](./BUILDING.md) for the flavor split. +**`NODE_ICU_DATA`** — current full and lite builds bundle full ICU, so no +external data file is needed for Chinese locales or GBK/Big5 decoding. +If overriding ICU data, use data matching the runtime's ICU version. Older +small-ICU builds may need an external file; older no-ICU lite binaries cannot +be fixed by supplying a data file. See [BUILDING.md](./BUILDING.md). **`NODE_USE_SYSTEM_CA=1`** — only if you need user- or MDM-installed CAs honoured, and understand that it buys less than it appears to: iOS has no API to diff --git a/docs/FAQ.md b/docs/FAQ.md index 7665d01896..ce3d28eccd 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -27,13 +27,12 @@ Not every API is supported on mobile, the main reason for this being that the mo - **Android < 8.0**: values can be inconsistent — some devices power cores on and off as an energy-saving strategy, so a core that is off at the moment of the call reports zero. - `os.availableParallelism()` — subject to the same platform limits as `os.cpus()`; treat the value as advisory. -On the **`lite`** flavor (built without ICU — see -[BUILDING.md](./BUILDING.md#the-lite-variant)), JavaScript features that -depend on internationalization support are unavailable — `Intl.*`, and RegExp -Unicode property escapes such as `/\p{Letter}+/u`. The default `full` flavor -supports them, with English-only locale data built in (`small-icu`); point -`NODE_ICU_DATA` at a full `icudt*.dat` for more locales -([EMBEDDING.md](./EMBEDDING.md#situational)). +Both current flavors bundle full ICU, including `Intl`, Unicode +normalization, Chinese sorting and legacy decoders such as GBK and Big5. +Lite drops optional features listed in [BUILDING.md](./BUILDING.md#the-lite-variant), +including Inspector, SQLite/Web Storage and TypeScript type-stripping. +Older releases may have small-ICU full builds and no-ICU lite builds; consult +the build recipe for the exact release. WebAssembly is a special case on iOS — see [the next question](#does-fetch-work-what-about-webassembly). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6a817eef8c..d35d0908b1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -80,8 +80,9 @@ The four archives are `SHA256SUMS`. Android includes arm64-v8a, armeabi-v7a and x86_64. iOS includes arm64 device and simulator slices. Verify the checksum after downloading. -Node 26.10 full builds enable Temporal and `node:ffi`; lite enables `node:ffi` -and omits Temporal with ICU. The older 26.1 release disables both features. +Node 26 full builds enable Temporal and `node:ffi`; the new lite recipe +omits both and retains full ICU. Already published 26.10.0-0 lite assets +have FFI and no ICU; the older 26.1 release disables both features. iOS uses jitless V8 and the bundled WebAssembly polyfill. See [VERSION-LINES.md](VERSION-LINES.md) for compatibility limits. diff --git a/docs/VERSION-LINES.md b/docs/VERSION-LINES.md index a39fdc5f9b..f2cf54f2f7 100644 --- a/docs/VERSION-LINES.md +++ b/docs/VERSION-LINES.md @@ -52,8 +52,9 @@ Android archives use the matching NDK compiler driver and position-independent code. iOS frameworks link only the target Rust archive, alongside `libffi`, `libnode_base`, and `libncrypto_engine`. -Lite builds enable `node:ffi` but retain `--with-intl=none` and explicitly -disable Temporal, which currently requires bundled ICU. They do not need Rust. +New lite builds retain `--with-intl=full-icu` and explicitly disable +Temporal and `node:ffi`. They do not need Rust. Already published 26.10.0-0 +lite assets have FFI and no ICU; changing the recipe does not modify those assets. iOS libffi uses its precompiled executable trampoline table for native-to-JS callbacks rather than generating executable callback code at runtime. Boot smokes exercise C calls, native callbacks, and Temporal date/timezone behavior; diff --git a/expected-tree.txt b/expected-tree.txt index 6b0b73b9aa..ea155970f3 100644 --- a/expected-tree.txt +++ b/expected-tree.txt @@ -14,4 +14,4 @@ # run prepare.sh (or regenerate-patches.py, which prints the hash) and # update the value below in the same commit. If you forget, CI fails and # prints the hash it got. -a1fc1ec2274055e5a9d3f308c710c37f6e691466 +f31c26a65e8e11d527ba0f94cb5b151b51ecbe03 diff --git a/mobile-src/tools/ios_framework_prepare.sh b/mobile-src/tools/ios_framework_prepare.sh index f0824ed595..fc72082dca 100755 --- a/mobile-src/tools/ios_framework_prepare.sh +++ b/mobile-src/tools/ios_framework_prepare.sh @@ -17,7 +17,7 @@ NODELIB_PROJECT_PATH='tools/ios-framework' XCODE_PROJECT_PATH='tools/ios-framework/NodeMobile.xcodeproj/project.pbxproj' # Flavor switch (mobile-only; see "The lite variant" in docs/BUILDING.md on the -# recipe branch). "full" (default) is unchanged; "lite" is the size-reduced +# recipe branch). "full" (default) includes all features; "lite" is the size-reduced # build. Read here and threaded into both configure blocks and the static-lib # link list. FLAVOR="${NODEJS_MOBILE_FLAVOR:-full}" @@ -44,10 +44,9 @@ V8_LITE_MODE="--v8-lite-mode" V8_NO_TURBOFAN_GYP_DEFINES="v8_enable_turbofan=0" V8_DISABLE_MAGLEV="--v8-disable-maglev" # Temporal needs bundled ICU and Rust; require it in full builds. -# FFI is present in both flavors. lite retains its no-ICU size tradeoff. +# Lite retains full ICU but omits Temporal and FFI. LITE_FLAGS="--v8-enable-temporal-support" if [ "$FLAVOR" = "lite" ]; then - INTL="none" # lite additionally drops features size-constrained consumers don't need. # Deliberately no --experimental-enable-pointer-compression, unlike Android # lite: V8 reserves the 4GB cage as one mmap of 4GB + (4GB - page) which it @@ -56,7 +55,7 @@ if [ "$FLAVOR" = "lite" ]; then # most 7.375GB of address space, so that reservation can never succeed and V8 # aborts the process during Isolate init. See "Pointer compression" in # docs/BUILDING.md on the recipe branch. - LITE_FLAGS="--v8-disable-temporal-support --without-amaro --without-inspector --without-sqlite" + LITE_FLAGS="--v8-disable-temporal-support --without-ffi --without-amaro --without-inspector --without-sqlite --disable-single-executable-application --v8-disable-object-print" fi declare -a outputs_common=( @@ -73,7 +72,6 @@ declare -a outputs_common=( "libnghttp2.a" "libnode.a" "libnode_base.a" - "libffi.a" "libopenssl.a" "libsimdjson.a" "libsimdutf.a" @@ -88,6 +86,10 @@ declare -a outputs_common=( "libhighway.a" "libzlib.a" "libzstd.a" + # Full ICU is linked in both flavors. + "libicudata.a" + "libicui18n.a" + "libicuucx.a" ) # Static libs present only in the full flavor. lite's configure flags mean these # are never built, so for lite they are neither copied nor linked (their @@ -95,7 +97,9 @@ declare -a outputs_common=( # the existing grep -vF idiom): # libcrdtp -- --without-inspector # libsqlite -- --without-sqlite -# libicu* -- --with-intl=none (no ICU) +# libffi -- --without-ffi +# libnode_crates -- --v8-disable-temporal-support +# Full ICU is in outputs_common; only optional feature archives are omitted. # Full ICU embeds its data in libicudata; libicustubdata is small-ICU only. # NB: libv8_snapshot stays in outputs_common — it is the runtime isolate-setup # lib (setup-isolate-deserialize) linked by BOTH flavors. libv8_init @@ -109,11 +113,9 @@ declare -a outputs_common=( # libgtest/libgtest_main are test-only. declare -a outputs_full_only=( "libnode_crates.a" + "libffi.a" "libcrdtp.a" "libsqlite.a" - "libicudata.a" - "libicui18n.a" - "libicuucx.a" ) declare -a outputs_x64_only=() declare -a outputs_arm64_only=( diff --git a/mobile-src/tools/mobile-test/smoke/mobile-features.js b/mobile-src/tools/mobile-test/smoke/mobile-features.js index 3c439d5bbf..0ea3b19c47 100644 --- a/mobile-src/tools/mobile-test/smoke/mobile-features.js +++ b/mobile-src/tools/mobile-test/smoke/mobile-features.js @@ -1,46 +1,80 @@ 'use strict'; -// Exercise the compiled feature set on the mobile runtime, including the -// native callback path (a successful require alone cannot test libffi). +// Keep danmu_api's decoding, normalization and Chinese sorting available in +// both flavors. The boot harness supplies the expected artifact flavor. const assert = require('node:assert/strict'); if (process.platform === 'android' || process.platform === 'ios') { assert.ok(process.versions.mobile, 'mobile version metadata must be present'); assert.equal(process.versions.mobile.split('-')[0], process.versions.node); } +const flavor = globalThis.NODEJS_MOBILE_EXPECT_FLAVOR || + process.env.NODEJS_MOBILE_EXPECT_FLAVOR || + (process.config.variables.node_use_sqlite ? 'full' : 'lite'); +assert.ok(['full', 'lite'].includes(flavor), 'invalid expected build flavor'); +const lite = flavor === 'lite'; +assert.equal(Boolean(process.config.variables.node_use_sqlite), !lite); +assert.equal(Boolean(process.config.variables.node_use_amaro), !lite); +assert.equal(Boolean(process.config.variables.v8_enable_inspector), !lite); +if (lite) { + assert.throws(() => require('node:sqlite'), { code: 'ERR_UNKNOWN_BUILTIN_MODULE' }); + assert.throws(() => require('node:inspector'), { code: 'ERR_INSPECTOR_NOT_AVAILABLE' }); +} + +console.log('NODEJS_MOBILE_FEATURE_STAGE icu'); +assert.ok(process.versions.icu, 'ICU must be compiled into both flavors'); +assert.equal(process.config.variables.icu_small, false, 'full ICU data is required'); +assert.equal('12A'.normalize('NFKC'), '12A'); +assert.ok(new Intl.Collator('zh-CN').resolvedOptions().locale.startsWith('zh')); +assert.deepEqual(['张', '阿', '李'].sort((a, b) => a.localeCompare(b, 'zh-CN')), + ['阿', '李', '张']); +for (const [encoding, bytes] of [ + ['gbk', [0xd6, 0xd0, 0xce, 0xc4]], + ['gb2312', [0xd6, 0xd0, 0xce, 0xc4]], + ['big5', [0xa4, 0xa4, 0xa4, 0xe5]], +]) { + assert.equal(new TextDecoder(encoding, { fatal: true }).decode(Uint8Array.from(bytes)), '中文'); +} -console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-load'); -const ffi = require('node:ffi'); -const sizeType = ['arm', 'ia32'].includes(process.arch) ? 'uint32' : 'uint64'; -const sizeValue = (value) => sizeType === 'uint64' ? BigInt(value) : value; -const { lib, functions } = ffi.dlopen(null, { - abs: { arguments: ['int32'], return: 'int32' }, - qsort: { arguments: ['pointer', sizeType, sizeType, 'function'], return: 'void' }, -}); -console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-call'); -assert.equal(functions.abs(-42), 42); -const numbers = Buffer.alloc(12); -[3, 1, 2].forEach((value, index) => numbers.writeInt32LE(value, index * 4)); -let callbackCalls = 0; -console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-callback'); -const callback = lib.registerCallback( - { arguments: ['pointer', 'pointer'], return: 'int32' }, - (left, right) => { - callbackCalls += 1; - return ffi.toBuffer(left, 4, true).readInt32LE() - - ffi.toBuffer(right, 4, true).readInt32LE(); - }, -); -try { - functions.qsort(ffi.getRawPointer(numbers), sizeValue(3), sizeValue(4), callback); - assert.deepEqual([0, 4, 8].map((offset) => numbers.readInt32LE(offset)), [1, 2, 3]); - assert.ok(callbackCalls > 0, 'native libffi callback did not run'); -} finally { - lib.unregisterCallback(callback); - lib.close(); +const ffiEnabled = Boolean(process.config.variables.node_use_ffi); +assert.equal(ffiEnabled, !lite, 'FFI must be enabled only in full builds'); +if (!ffiEnabled) { + assert.throws(() => require('node:ffi'), { code: 'ERR_UNKNOWN_BUILTIN_MODULE' }); +} else { + console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-load'); + const ffi = require('node:ffi'); + const sizeType = ['arm', 'ia32'].includes(process.arch) ? 'uint32' : 'uint64'; + const sizeValue = (value) => sizeType === 'uint64' ? BigInt(value) : value; + const { lib, functions } = ffi.dlopen(null, { + abs: { arguments: ['int32'], return: 'int32' }, + qsort: { arguments: ['pointer', sizeType, sizeType, 'function'], return: 'void' }, + }); + console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-call'); + assert.equal(functions.abs(-42), 42); + const numbers = Buffer.alloc(12); + [3, 1, 2].forEach((value, index) => numbers.writeInt32LE(value, index * 4)); + let callbackCalls = 0; + console.log('NODEJS_MOBILE_FEATURE_STAGE ffi-callback'); + const callback = lib.registerCallback( + { arguments: ['pointer', 'pointer'], return: 'int32' }, + (left, right) => { + callbackCalls += 1; + return ffi.toBuffer(left, 4, true).readInt32LE() - + ffi.toBuffer(right, 4, true).readInt32LE(); + }, + ); + try { + functions.qsort(ffi.getRawPointer(numbers), sizeValue(3), sizeValue(4), callback); + assert.deepEqual([0, 4, 8].map((offset) => numbers.readInt32LE(offset)), [1, 2, 3]); + assert.ok(callbackCalls > 0, 'native libffi callback did not run'); + } finally { + lib.unregisterCallback(callback); + lib.close(); + } } console.log('NODEJS_MOBILE_FEATURE_STAGE temporal'); const temporalEnabled = !!process.config.variables.v8_enable_temporal_support; +assert.equal(temporalEnabled, !lite, 'Temporal must be enabled only in full builds'); assert.equal(typeof globalThis.Temporal, temporalEnabled ? 'object' : 'undefined'); if (temporalEnabled) { assert.equal(Temporal.PlainDate.from('2024-02-28').add({ days: 1 }).toString(), '2024-02-29'); @@ -48,4 +82,4 @@ if (temporalEnabled) { const spring = Temporal.ZonedDateTime.from('2026-03-08T01:30-05:00[America/New_York]'); assert.equal(spring.add({ hours: 1 }).hour, 3, 'Temporal timezone data/DST is unavailable'); } -console.log(`NODEJS_MOBILE_FEATURES_OK ffi=1 temporal=${Number(temporalEnabled)}`); +console.log(`NODEJS_MOBILE_FEATURES_OK flavor=${flavor} icu=full ffi=${Number(ffiEnabled)} temporal=${Number(temporalEnabled)}`); diff --git a/mobile-src/tools/mobile-test/smoke/run-android-smoke.sh b/mobile-src/tools/mobile-test/smoke/run-android-smoke.sh index 5c12e74b8e..aea72b9cdf 100755 --- a/mobile-src/tools/mobile-test/smoke/run-android-smoke.sh +++ b/mobile-src/tools/mobile-test/smoke/run-android-smoke.sh @@ -21,7 +21,9 @@ adbx push "$SCRIPT_DIR/smoke.js" "$DEST/" >/dev/null adbx push "$SCRIPT_DIR/mobile-features.js" "$DEST/" >/dev/null adbx shell "chmod 755 $DEST/node_smoke" -OUT=$(adbx shell "cd $DEST && LD_LIBRARY_PATH=$DEST TMPDIR=$DEST ./node_smoke smoke.js; echo SMOKE_EXIT:\$?") +FLAVOR="${NODEJS_MOBILE_FLAVOR:-full}" +case "$FLAVOR" in full|lite) ;; *) echo "Invalid build flavor: $FLAVOR" >&2; exit 1 ;; esac +OUT=$(adbx shell "cd $DEST && NODEJS_MOBILE_EXPECT_FLAVOR=$FLAVOR LD_LIBRARY_PATH=$DEST TMPDIR=$DEST ./node_smoke smoke.js; echo SMOKE_EXIT:\$?") echo "----- device output -----" echo "$OUT" echo "--------------------------" diff --git a/mobile-src/tools/mobile-test/smoke/run-ios-smoke.sh b/mobile-src/tools/mobile-test/smoke/run-ios-smoke.sh index dbbc70f59f..e39e57a219 100755 --- a/mobile-src/tools/mobile-test/smoke/run-ios-smoke.sh +++ b/mobile-src/tools/mobile-test/smoke/run-ios-smoke.sh @@ -28,7 +28,10 @@ POLL_SECONDS="${SMOKE_POLL_SECONDS:-180}" # PASS write is skipped and only the atexit FAIL fallback fires. Letting the # event loop drain returns 0 from node_start -> a clean PASS verdict. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -EXPR="$(cat "$SCRIPT_DIR/mobile-features.js") +FLAVOR="${NODEJS_MOBILE_FLAVOR:-full}" +case "$FLAVOR" in full|lite) ;; *) echo "Invalid build flavor: $FLAVOR" >&2; exit 1 ;; esac +EXPR="globalThis.NODEJS_MOBILE_EXPECT_FLAVOR = '${FLAVOR}'; +$(cat "$SCRIPT_DIR/mobile-features.js") console.log('${MARKER} ' + process.version + ' ' + process.platform + ' ' + process.arch);" # Launch once; echo the app output to stderr (for log visibility) and print the diff --git a/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch b/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch index fcc0bd1d52..d2cfcc098f 100644 --- a/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch +++ b/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch @@ -26,12 +26,12 @@ Co-authored-by: Vincent Breitmoser Signed-off-by: fogtape Assisted-by: Codex --- - android_configure.py | 76 +++++++++++++++++++++++++++++++++++--------- + android_configure.py | 75 +++++++++++++++++++++++++++++++++++--------- configure.py | 8 +++++ - 2 files changed, 69 insertions(+), 15 deletions(-) + 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/android_configure.py b/android_configure.py -index 5cea0393f4..71daaf7250 100644 +index 5cea0393f4..5593019b06 100644 --- a/android_configure.py +++ b/android_configure.py @@ -1,25 +1,14 @@ @@ -71,7 +71,7 @@ index 5cea0393f4..71daaf7250 100644 toolchain_path = android_ndk_path + "/toolchains/llvm/prebuilt/darwin-x86_64" elif platform.system() == "Linux": -@@ -65,13 +54,70 @@ elif platform.system() == "Linux": +@@ -65,13 +54,69 @@ elif platform.system() == "Linux": os.environ['PATH'] += os.pathsep + toolchain_path + "/bin" os.environ['CC'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang" os.environ['CXX'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang++" @@ -115,14 +115,13 @@ index 5cea0393f4..71daaf7250 100644 + sys.exit(1) +print("\033[92mInfo: \033[0m" + "Build flavor: " + flavor) + -+# Temporal named time zones require the complete ICU data bundle. ++# Both flavors need complete ICU for decoding and Chinese collation. +intl = "full-icu" +# Full builds require Temporal explicitly so a missing Rust toolchain fails -+# instead of silently shipping a different feature set. FFI is enabled in both. ++# instead of silently shipping a different feature set. Lite drops FFI. +extra_flags = " --v8-enable-temporal-support" +if flavor == "lite": -+ intl = "none" -+ extra_flags = " --v8-disable-temporal-support --without-amaro --without-inspector --without-sqlite" ++ extra_flags = " --v8-disable-temporal-support --without-ffi --without-amaro --without-inspector --without-sqlite --disable-single-executable-application --v8-disable-object-print" + # Pointer compression: 32-bit compressed pointers inside a 4GB cage — the + # size lever on the JS heap rather than on the binary. lite only, because it + # changes the V8 ABI for addons that include V8's headers directly (N-API diff --git a/scripts/package-preview.py b/scripts/package-preview.py index 624322bf53..57899c6ea0 100644 --- a/scripts/package-preview.py +++ b/scripts/package-preview.py @@ -43,8 +43,11 @@ def main(): flavor=flavor, recipe_commit=sha, source_tree=tree, branch=os.environ.get('GITHUB_REF_NAME', ''), run_url=f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}", - preview=True, enabled_features=['node:ffi'] + (['Temporal'] if flavor == 'full' else []), - disabled_features=[] if flavor == 'full' else ['Temporal']) + preview=True, enabled_features=['full-icu'] + (['node:ffi', 'Temporal'] if flavor == 'full' else []), + disabled_features=[] if flavor == 'full' else [ + 'node:ffi', 'Temporal', 'node:sqlite', 'Web Storage', + 'Inspector', 'TypeScript type-stripping', 'SEA', + 'V8 native debugger object print']) (artifact / 'BUILD-INFO.json').write_text(json.dumps(info, indent=2) + '\n') package = output / f'{name}-{version}-preview.zip' with zipfile.ZipFile(package, 'w', zipfile.ZIP_DEFLATED) as archive: diff --git a/scripts/test-mobile-flavors.py b/scripts/test-mobile-flavors.py new file mode 100755 index 0000000000..f64ff8054d --- /dev/null +++ b/scripts/test-mobile-flavors.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Check flavor flags, iOS archive selection and danmu_api ICU regressions.""" +import hashlib +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest +import zipfile + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else ROOT / 'out' +if len(sys.argv) > 1: + del sys.argv[1] +MAJOR = int(next(line.strip().lstrip('v').split('.')[0] + for line in (ROOT / 'upstream-base.txt').read_text().splitlines() + if line.strip() and not line.startswith('#'))) +# android_configure supports Linux/macOS build hosts; simulate Linux so this +# configure-only test also runs on Termux's Python (which reports Android). +ANDROID_WRAPPER = [sys.executable, '-c', + "import platform,runpy,sys; platform.system=lambda:'Linux'; " + "sys.argv=sys.argv[1:]; runpy.run_path(sys.argv[0],run_name='__main__')", + str(SOURCE / 'android_configure.py')] + +CUTS = {'--without-amaro', '--without-inspector', '--without-sqlite', + '--disable-single-executable-application', '--v8-disable-object-print'} +if MAJOR >= 26: + CUTS |= {'--without-ffi', '--v8-disable-temporal-support'} + + +class MobileFlavors(unittest.TestCase): + def test_android_flags_and_pointer_compression(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + ndk = folder / 'ndk' + ndk.mkdir() + (ndk / 'marker').touch() + capture = folder / 'configured.json' + configure = folder / 'configure' + configure.write_text(f'#!{sys.executable}\nimport json,sys\n' + f'open({str(capture)!r}, "w").write(json.dumps(sys.argv[1:]))\n') + configure.chmod(0o755) + for flavor in ('full', 'lite'): + for arch in ('arm', 'arm64', 'x86_64'): + with self.subTest(flavor=flavor, arch=arch): + env = dict(os.environ, NODEJS_MOBILE_FLAVOR=flavor) + env.pop('NODEJS_MOBILE_SCCACHE', None) + subprocess.run(ANDROID_WRAPPER + [str(ndk), '24', arch], cwd=folder, env=env, + check=True, capture_output=True, text=True) + flags = set(json.loads(capture.read_text())) + self.assertIn('--with-intl=full-icu', flags) + self.assertIn('--shared', flags) + self.assertIn('--cross-compiling', flags) + if flavor == 'lite': + self.assertTrue(CUTS <= flags, CUTS - flags) + else: + self.assertFalse(CUTS & flags) + if MAJOR >= 26: + self.assertIn('--v8-enable-temporal-support', flags) + self.assertEqual('--experimental-enable-pointer-compression' in flags, + flavor == 'lite' and arch != 'arm') + env['NODEJS_MOBILE_FLAVOR'] = 'invalid' + result = subprocess.run(ANDROID_WRAPPER + [str(ndk), '24', 'arm64'], cwd=folder, env=env, + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + + def test_ios_flags_and_framework_archives(self): + script = (SOURCE / 'tools/ios_framework_prepare.sh').read_text() + prefix = script.split('build_for_arm64_device() {', 1)[0] + probe = prefix + ''' +printf '\nINTL:%s\nFLAGS:%s\n' "$INTL" "$LITE_FLAGS" +for archive in "${outputs_arm64[@]}"; do printf 'LIB:%s\n' "$archive"; done +''' + project = (SOURCE / 'tools/ios-framework/NodeMobile.xcodeproj/project.pbxproj').read_text() + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / 'ios-flavor-probe.sh' + path.write_text(probe) + for flavor in ('full', 'lite'): + with self.subTest(flavor=flavor): + result = subprocess.run(['bash', str(path)], + env=dict(os.environ, NODEJS_MOBILE_FLAVOR=flavor), + capture_output=True, text=True, check=True) + lines = result.stdout.splitlines() + self.assertIn('INTL:full-icu', lines) + flags = set(shlex.split(next(line[6:] for line in lines + if line.startswith('FLAGS:')))) + archives = {line[4:] for line in lines if line.startswith('LIB:')} + self.assertTrue({'libicudata.a', 'libicui18n.a', 'libicuucx.a'} <= archives) + self.assertNotIn('libicustubdata.a', archives) + self.assertNotIn('libicustubdata.a', project) + for archive in archives: + self.assertIn(archive, project, f'{archive} has no Xcode link entry') + for archive in ('libcrdtp.a', 'libsqlite.a'): + self.assertEqual(archive in archives, flavor == 'full') + if flavor == 'lite': + self.assertTrue(CUTS <= flags, CUTS - flags) + else: + self.assertFalse(CUTS & flags) + if MAJOR >= 26: + for archive in ('libffi.a', 'libnode_crates.a'): + self.assertEqual(archive in archives, flavor == 'full') + + def test_preview_metadata_matches_flavors(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + source, output = folder / 'input', folder / 'output' + for platform in ('android', 'ios'): + for flavor in ('full', 'lite'): + suffix = '-lite' if flavor == 'lite' else '' + artifact = source / f'nodejs-mobile-{platform}{suffix}' + required = ['include/node/node_version.h'] + if platform == 'android': + required += [f'bin/{abi}/libnode.so' + for abi in ('arm64-v8a', 'armeabi-v7a', 'x86_64')] + else: + required += ['NodeMobile.xcframework/Info.plist'] + for name in required: + path = artifact / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'fixture') + subprocess.run([sys.executable, str(ROOT / 'scripts/package-preview.py'), + str(source), str(output)], cwd=ROOT, + env=dict(os.environ, GITHUB_REPOSITORY='fogtape/nodejs-mobile', + GITHUB_RUN_ID='1'), check=True, + capture_output=True, text=True) + for package in output.glob('*.zip'): + with zipfile.ZipFile(package) as archive: + info = json.loads(archive.read('BUILD-INFO.json')) + self.assertIn('full-icu', info['enabled_features']) + lite = info['flavor'] == 'lite' + for feature in ('node:ffi', 'Temporal'): + self.assertEqual(feature in info['enabled_features'], not lite) + self.assertEqual(feature in info['disabled_features'], lite) + lines = (output / 'SHA256SUMS').read_text().splitlines() + self.assertEqual(len(lines), 4) + for line in lines: + digest, name = line.split() + self.assertEqual(hashlib.sha256((output / name).read_bytes()).hexdigest(), digest) + + def test_required_icu_smoke_on_host(self): + # Exercise the exact shipped smoke with a host Node that has full ICU. + # FFI/Temporal are specific to the Node 26 host build and are tested by CI. + source = (SOURCE / 'tools/mobile-test/smoke/mobile-features.js').read_text() + icu = source.split("console.log('NODEJS_MOBILE_FEATURE_STAGE icu');", 1)[1] + if MAJOR >= 26: + icu = icu.split('const ffiEnabled =', 1)[0] + else: + icu = icu.split('console.log(`NODEJS_MOBILE_FEATURES_OK', 1)[0] + subprocess.run(['node', '-e', "const assert=require('node:assert/strict');\n" + icu], + check=True, capture_output=True, text=True) + + +if __name__ == '__main__': + unittest.main() From 7b99525ecaea653726f5d5da082bd727e8783349 Mon Sep 17 00:00:00 2001 From: fogtape Date: Fri, 2 Oct 2026 21:55:04 +0800 Subject: [PATCH 2/3] build: profile lite ICU data and strip Android runtime symbols --- .github/workflows/build.yml | 7 + .github/workflows/verify-patches.yml | 3 + README.md | 4 +- docs/BUILDING.md | 10 +- docs/DANMU-LITE.md | 83 +++- docs/EMBEDDING.md | 2 +- docs/FAQ.md | 6 +- docs/RELEASING.md | 2 +- docs/VERSION-LINES.md | 3 +- expected-tree.txt | 2 +- mobile-src/tools/android_build.sh | 5 + mobile-src/tools/ios_framework_prepare.sh | 6 +- .../mobile-test/smoke/mobile-features.js | 93 +++- mobile-src/tools/strip-android-runtime.py | 58 +++ ...S-dest-os-and-toolchain-configure.py.patch | 9 +- ...ICU-data-for-danmu_api-lite-runtimes.patch | 465 ++++++++++++++++++ patches/files.map | 4 + patches/series | 1 + scripts/package-preview.py | 5 +- scripts/test-mobile-flavors.py | 45 +- scripts/test-runtime-stripping.py | 57 +++ 21 files changed, 826 insertions(+), 44 deletions(-) create mode 100755 mobile-src/tools/strip-android-runtime.py create mode 100644 patches/build-profile-ICU-data-for-danmu_api-lite-runtimes.patch create mode 100644 scripts/test-runtime-stripping.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb69d9b77b..f1a8c7494b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -249,6 +249,13 @@ jobs: - name: Stage NAPI/addon headers run: ./tools/copy_libnode_headers.sh android + # Run on cache hits too: older cached SDK copies may still carry symbols. + - name: Strip lite runtime symbols + if: matrix.flavor == 'lite' + env: + MY_ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + run: python3 tools/strip-android-runtime.py --ndk "$MY_ANDROID_NDK_HOME" out_android + # After the header staging, not right after Build, so the saved tree is # byte-identical to what the combined actions/cache used to save from its # post step. Guarded on the same condition as Build: nothing to save when diff --git a/.github/workflows/verify-patches.yml b/.github/workflows/verify-patches.yml index 9c84cf896e..e8b596139d 100644 --- a/.github/workflows/verify-patches.yml +++ b/.github/workflows/verify-patches.yml @@ -91,6 +91,9 @@ jobs: - name: Check danmu_api flavor configuration and ICU contract run: python3 scripts/test-mobile-flavors.py out + - name: Check lite stripping preserves embedding exports + run: python3 scripts/test-runtime-stripping.py out + - name: Check mobile toolchain actions run: | python3 -m pip install setuptools packaging diff --git a/README.md b/README.md index 956c246138..ea96237e63 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Node 26 prereleases are published from `recipe-v26` after the release gates pass. CI preview packages are also available from successful **Build** runs. Published Node 24 assets remain on the Releases page. Node 26 full builds enable Temporal and `node:ffi`. New lite builds retain -full ICU and disable both Temporal and `node:ffi`. See +a custom ICU data profile and disable both Temporal and `node:ffi`. See [version-specific configuration](docs/VERSION-LINES.md#node-26-configuration). Download the zips from [Releases](../../releases) — four per release: @@ -32,7 +32,7 @@ nodejs-mobile-android-X.Y.Z-R.zip nodejs-mobile-ios-X.Y.Z-R.zip nodejs-mobile-android-lite-X.Y.Z-R.zip nodejs-mobile-ios-lite-X.Y.Z-R.zip ``` -**`full`** is the general-purpose binary. **`lite`** retains full ICU for +**`full`** is the general-purpose binary. **`lite`** embeds a custom ICU data profile for `danmu_api`'s Chinese sorting, Unicode normalization and GBK/Big5 decoding, but omits Inspector, SQLite/Web Storage, TypeScript type-stripping and SEA. Node 26 lite also omits Temporal and `node:ffi`. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 5f562de307..5b3b585d55 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -167,7 +167,7 @@ To build it instead of the default, set `NODEJS_MOBILE_FLAVOR=lite` on either target's build command. The build ships in two flavors (selected by `NODEJS_MOBILE_FLAVOR`, default -`full`). Both bundle **full ICU**. The lite flavor is tailored to `danmu_api`: +`full`). Full bundles **complete ICU data**; lite bundles the reviewed **danmu ICU profile**. The lite flavor is tailored to `danmu_api`: it keeps Chinese collation, NFKC normalization, legacy text decoders, networking, compression and JavaScript module loading, while removing unused optional features. @@ -185,13 +185,17 @@ Android retains the JIT and V8's native WebAssembly, which Undici uses for `fetch`. Its 64-bit lite builds also retain the pointer-compression setting below. Both iOS flavors retain the existing jitless `--v8-lite-mode` and polywasm implementation; lite does not add an Android-style 4 GB pointer cage. -Full ICU archives are linked in both iOS flavors. +Both iOS flavors link ICU; lite profiles its embedded data. + +Android lite SDK copies also remove non-runtime symbols/debug sections while +verifying all dynamic exports remain unchanged. See the retained ICU locales +and encodings in [DANMU-LITE.md](DANMU-LITE.md). Dead-code stripping and Android build IDs continue to apply to both flavors. Older published size measurements used no-ICU lite builds and do not describe this feature set. Measure freshly compiled artifacts before quoting size savings. -The device boot smokes now assert full ICU, GBK/GB2312/Big5 decoding, Chinese +The device boot smokes assert the ICU API/data contract, GBK/GB2312/Big5 decoding, Chinese sorting and NFKC normalization, and check the expected full/lite optional feature set. See [DANMU-LITE.md](DANMU-LITE.md) for the compatibility contract and the recipe-level configure/archive tests. diff --git a/docs/DANMU-LITE.md b/docs/DANMU-LITE.md index 3748d11cbc..49b409025d 100644 --- a/docs/DANMU-LITE.md +++ b/docs/DANMU-LITE.md @@ -1,10 +1,38 @@ # danmu_api runtime contract -Current recipes build full ICU into full and lite on Android and iOS. This is -required by the core's `TextDecoder('gbk')` source requests, -`localeCompare(..., 'zh-CN')` resource sorting and `normalize('NFKC')` title keys. -Default small-ICU data does not provide the required legacy decoders or Chinese -collation; an ICU data-file override cannot fix a runtime built without ICU. +Full builds embed complete ICU data. Lite keeps the complete ICU API mode +(`--with-intl=full-icu`, `icu_small=false`) but embeds a reviewed data profile +from `tools/icu/danmu-lite.json`. Do not substitute Node's default small-ICU +filter: it removes legacy encoding converters and does not satisfy the core. + +## ICU data retained by lite + +- Every web encoding exposed by Node's TextDecoder, including GBK/GB2312, + GB18030, Big5, Shift-JIS, EUC-JP, ISO-2022-JP, EUC-KR, European/Windows + encodings and their label aliases. Converter dependency tables are kept too. +- English, simplified/traditional Chinese, Cantonese, Japanese and Korean + locale families and their regional variants/parent fallbacks. Keep their + collation, calendar/date, number/currency/unit, relative-time, plural, list, + display-name and segmentation resources. +- Unicode normalization/property data for all scripts, including NFC/NFD and + NFKC/NFKD; IDNA/UTS46 domain handling; all time zones; break rules and + dictionaries for segmentation. A foreign-language text does not require + that language's locale-formatting resources to be decoded or normalized. + +Lite removes unrelated locale families, non-web converter tables, +transliteration and rule-based spellout data. Other explicit Intl locales +may fall back to a retained locale. Adding a source that requires a new +locale's formatting/collation must extend the profile; future feature use +cannot be inferred from today's imports. + +`prepare-mobile-icu.py` uses the host ICU tools, checks required resources +before/after trimming and lets icupkg validate package dependencies. A unique +intermediate package name prevents an installed host's full ICU from hiding +missing resources. The final conventional data entry point is preserved. +With the pinned ICU 78.3, data goes from 33,105,536 to 11,024,064 bytes. This +measures data only, not a complete newly compiled libnode or compressed ZIP. + +## Other lite subtractions Lite removes Amaro, Inspector, SQLite/Web Storage, SEA executable packaging and V8 native-debugger object-print support. Node 26 lite also removes Temporal @@ -13,13 +41,28 @@ require these features. OpenCC remains a JavaScript dependency. Keep HTTP/HTTPS/TLS/crypto, DNS, fetch/Undici, WebAssembly, compression including Brotli/zstd, filesystem/module loading, streams, AsyncLocalStorage and the -existing Worker/child-process APIs. The core and Android/Flutter hosts use -these capabilities. WASI/HTTP2 are not separately trimmed in this change. +existing Worker/child-process APIs. WASI/HTTP2 are not separately trimmed in +this change; Undici itself references node:http2. Android retains JIT/native WASM. Lite uses pointer compression only on 64-bit Android; it primarily reduces heap memory and changes the direct V8-addon ABI. iOS retains its existing jitless/polywasm setup without pointer compression. +## Android symbol stripping + +Android lite SDK copies are stripped with the NDK's `llvm-strip +--strip-unneeded`. The linked build output remains available locally for +debugging. CI also trims restored lite cache entries before upload. The +helper compares every dynamic symbol before/after and refuses a changed ABI; +node::Start and exported N-API functions remain available. Non-runtime debug +sections and the ordinary symbol/string tables are removed. Full SDK copies +retain their previous symbol policy. + +The apps may already strip their packaged libraries: producer-side stripping +reduces SDK downloads, but repeated stripping does not reduce an already +stripped APK a second time. Header declarations are needed to compile the +embedders and do not become a separate runtime payload. + ## Validation After materialization, run from the recipe directory: @@ -29,16 +72,16 @@ python3 scripts/test-mobile-flavors.py out ``` This invokes Android's configure wrapper for full/lite on arm, arm64 and -x86_64 using a configure recorder, evaluates the real iOS flavor/archive -selection, and runs the shipped ICU regression assertions on host Node. -It checks configuration and archive consistency, not a cross-compilation. - -The Android and iOS boot smokes run the same ICU assertions on freshly built -artifacts and verify the expected optional feature set. Node 26 full still -exercises native FFI calls/callbacks and Temporal date/timezone behavior; -lite asserts those features are absent. Existing fetch and curated device -tests continue to cover networking and the WASM implementation. - -Previously published archives remain unchanged. Compile, run device tests and -measure new artifacts before releasing or updating either App's pinned URL and -checksum. Future core/dependency updates must be checked against this contract. +x86_64 using a configure recorder, evaluates the real iOS flavor/archive and +ICU profile selection, and runs shipped ICU regressions on host Node. + +Both platform boot smokes check all 39 canonical TextDecoder encodings, +streaming/multibyte examples, retained locale APIs, normalization, IDNA and +time zones. Lite additionally verifies that unselected locale data was +actually removed. Node 26 full still exercises native FFI calls/callbacks and +Temporal timezone behavior; lite asserts those features are absent. + +Before publishing, complete cross-compilation and device gates and measure +actual new artifacts. Previously published archives and either app's pinned +URL/checksum remain unchanged. Future core/dependency updates must be checked +against this contract. diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 9bbfdab60a..86e98dcc40 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -121,7 +121,7 @@ Only the options upstream marks `kAllowedInEnvvar` are accepted, and ### Situational -**`NODE_ICU_DATA`** — current full and lite builds bundle full ICU, so no +**`NODE_ICU_DATA`** — current full and lite builds embed the required ICU data, so no external data file is needed for Chinese locales or GBK/Big5 decoding. If overriding ICU data, use data matching the runtime's ICU version. Older small-ICU builds may need an external file; older no-ICU lite binaries cannot diff --git a/docs/FAQ.md b/docs/FAQ.md index ce3d28eccd..cd21c0c1ba 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -27,7 +27,11 @@ Not every API is supported on mobile, the main reason for this being that the mo - **Android < 8.0**: values can be inconsistent — some devices power cores on and off as an energy-saving strategy, so a core that is off at the moment of the call reports zero. - `os.availableParallelism()` — subject to the same platform limits as `os.cpus()`; treat the value as advisory. -Both current flavors bundle full ICU, including `Intl`, Unicode +Lite limits locale-specific data to English/Chinese/Cantonese/Japanese/Korean, +while keeping all Node web text encodings, Unicode normalization and time zones. +Other locale formatting may fall back; see [DANMU-LITE.md](DANMU-LITE.md). + +Both current flavors bundle ICU, including `Intl`, Unicode normalization, Chinese sorting and legacy decoders such as GBK and Big5. Lite drops optional features listed in [BUILDING.md](./BUILDING.md#the-lite-variant), including Inspector, SQLite/Web Storage and TypeScript type-stripping. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index d35d0908b1..8c0b861a00 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -81,7 +81,7 @@ The four archives are arm64 device and simulator slices. Verify the checksum after downloading. Node 26 full builds enable Temporal and `node:ffi`; the new lite recipe -omits both and retains full ICU. Already published 26.10.0-0 lite assets +omits both and embeds the danmu ICU data profile. Already published 26.10.0-0 lite assets have FFI and no ICU; the older 26.1 release disables both features. iOS uses jitless V8 and the bundled WebAssembly polyfill. See [VERSION-LINES.md](VERSION-LINES.md) for compatibility limits. diff --git a/docs/VERSION-LINES.md b/docs/VERSION-LINES.md index f2cf54f2f7..4dbe019875 100644 --- a/docs/VERSION-LINES.md +++ b/docs/VERSION-LINES.md @@ -52,7 +52,8 @@ Android archives use the matching NDK compiler driver and position-independent code. iOS frameworks link only the target Rust archive, alongside `libffi`, `libnode_base`, and `libncrypto_engine`. -New lite builds retain `--with-intl=full-icu` and explicitly disable +New lite builds retain the complete API mode `--with-intl=full-icu`, embed +the danmu ICU data profile, and explicitly disable Temporal and `node:ffi`. They do not need Rust. Already published 26.10.0-0 lite assets have FFI and no ICU; changing the recipe does not modify those assets. iOS libffi uses its precompiled executable trampoline table for native-to-JS diff --git a/expected-tree.txt b/expected-tree.txt index ea155970f3..21b09459d8 100644 --- a/expected-tree.txt +++ b/expected-tree.txt @@ -14,4 +14,4 @@ # run prepare.sh (or regenerate-patches.py, which prints the hash) and # update the value below in the same commit. If you forget, CI fails and # prints the hash it got. -f31c26a65e8e11d527ba0f94cb5b151b51ecbe03 +cf1e359f446d47683db958b3f70a66ad52ecbb61 diff --git a/mobile-src/tools/android_build.sh b/mobile-src/tools/android_build.sh index 179846acd9..6efc2ffcd0 100755 --- a/mobile-src/tools/android_build.sh +++ b/mobile-src/tools/android_build.sh @@ -51,6 +51,11 @@ BUILD_ARCH() { echo "Could not find libnode.so file after compilation" exit 1 fi + # Keep the linked build output for debugging; only trim the copied lite SDK. + if [ "${NODEJS_MOBILE_FLAVOR:-full}" = "lite" ]; then + python3 tools/strip-android-runtime.py --ndk "$ANDROID_NDK_PATH" \ + "out_android/$TARGET_ARCH_FOLDER/libnode.so" + fi } if [ $# -eq 2 ]; then diff --git a/mobile-src/tools/ios_framework_prepare.sh b/mobile-src/tools/ios_framework_prepare.sh index fc72082dca..998573d0ec 100755 --- a/mobile-src/tools/ios_framework_prepare.sh +++ b/mobile-src/tools/ios_framework_prepare.sh @@ -21,6 +21,7 @@ XCODE_PROJECT_PATH='tools/ios-framework/NodeMobile.xcodeproj/project.pbxproj' # build. Read here and threaded into both configure blocks and the static-lib # link list. FLAVOR="${NODEJS_MOBILE_FLAVOR:-full}" +ICU_PROFILE_GYP_DEFINES="" if [ "$FLAVOR" != "full" ] && [ "$FLAVOR" != "lite" ]; then echo "Error: NODEJS_MOBILE_FLAVOR must be 'full' or 'lite'"; exit 1 fi @@ -47,6 +48,7 @@ V8_DISABLE_MAGLEV="--v8-disable-maglev" # Lite retains full ICU but omits Temporal and FFI. LITE_FLAGS="--v8-enable-temporal-support" if [ "$FLAVOR" = "lite" ]; then + ICU_PROFILE_GYP_DEFINES="node_mobile_icu_profile=danmu-lite.json" # lite additionally drops features size-constrained consumers don't need. # Deliberately no --experimental-enable-pointer-compression, unlike Android # lite: V8 reserves the 4GB cage as one mmap of 4GB + (4GB - page) which it @@ -130,7 +132,7 @@ fi build_for_arm64_device() { make clean - GYP_DEFINES="target_arch=arm64 host_os=mac target_os=ios $V8_NO_TURBOFAN_GYP_DEFINES" + GYP_DEFINES="target_arch=arm64 host_os=mac target_os=ios $V8_NO_TURBOFAN_GYP_DEFINES $ICU_PROFILE_GYP_DEFINES" export GYP_DEFINES ./configure \ --dest-os=ios \ @@ -168,7 +170,7 @@ build_for_arm64_device() { build_for_arm64_simulator() { make clean - GYP_DEFINES="target_arch=arm64 host_os=mac target_os=ios $V8_NO_TURBOFAN_GYP_DEFINES" + GYP_DEFINES="target_arch=arm64 host_os=mac target_os=ios $V8_NO_TURBOFAN_GYP_DEFINES $ICU_PROFILE_GYP_DEFINES" export GYP_DEFINES ./configure \ --dest-os=ios \ diff --git a/mobile-src/tools/mobile-test/smoke/mobile-features.js b/mobile-src/tools/mobile-test/smoke/mobile-features.js index 0ea3b19c47..5aa5da27b6 100644 --- a/mobile-src/tools/mobile-test/smoke/mobile-features.js +++ b/mobile-src/tools/mobile-test/smoke/mobile-features.js @@ -22,7 +22,7 @@ if (lite) { console.log('NODEJS_MOBILE_FEATURE_STAGE icu'); assert.ok(process.versions.icu, 'ICU must be compiled into both flavors'); -assert.equal(process.config.variables.icu_small, false, 'full ICU data is required'); +assert.equal(process.config.variables.icu_small, false, 'ICU must retain the complete API mode'); assert.equal('12A'.normalize('NFKC'), '12A'); assert.ok(new Intl.Collator('zh-CN').resolvedOptions().locale.startsWith('zh')); assert.deepEqual(['张', '阿', '李'].sort((a, b) => a.localeCompare(b, 'zh-CN')), @@ -35,6 +35,95 @@ for (const [encoding, bytes] of [ assert.equal(new TextDecoder(encoding, { fatal: true }).decode(Uint8Array.from(bytes)), '中文'); } +// Every Node TextDecoder web encoding stays available, including encodings +// not requested by today's sources. Check streaming against a one-shot decode. +const encodingHashes = { + "ibm866": "3c8cc5cb485f93d2bb20ea06c4d6808fcae1d924105a0ec4ee2b280457c14e14", + "windows-1252": "cc916e51644a12e8de4ad160910c171a58621ee5dc3a6da6f8b00f8684085f33", + "iso-8859-6": "beba4e6cf97dce8317ea76b14b77dbe4d2b3d8920b6b0a3fa9235ab532629f82", + "big5": "7a7e1f2dbbff786abf55250e204d6db2433cd6ee699cc27374b1d6ecd28865ea", + "gbk": "9cc45cb5d63e234da6349daddb9b38cc6d3a677a6c1e0bc2877d10bbf673c0b2", + "windows-1250": "03772ed2e875bd125544fe7f243ea9a1dd163a057030970b26d8b6dd4c79a6e5", + "windows-1251": "b16600cf4e6d1a2d4659b6a2cc96caa5ddc3e103ecfb07c5154d05fd54b174b3", + "windows-1253": "e4570135cbc6e3d53eae99c2be1af17c86f4a744bd55757470d2143ece00da0b", + "windows-1254": "4a8e99647c3e28e6a5234ac8b124e5614a3f99dc68ec948fb67da163e210e4f3", + "windows-1255": "870c5c5e687fabcddc1209bc1263f6d6e9d6f594baed8ab280dcdeeb5607207d", + "windows-1256": "6f6e8626197b1b6b280a079d1d842daa09600a39fdb3d1e99596e943c61cc98b", + "windows-1257": "d19a4e888879e36a450470073fc0344cffdfffa40ad82fb433de9f9b40b5c048", + "windows-1258": "e79b48db126bc71dfcf1723e9f6350af101d1eb494e29d736ecf9530113cd361", + "euc-kr": "f82d4597ae2fd7b4cc85f13509cacd40a11d0355de01a146fe6beb3d3ed8d060", + "euc-jp": "9d9e4b1cf9bad807e677e7af6b49f34b58e8767669fce9f2d85fc57c3fc32c39", + "iso-2022-jp": "0a53580eb9bb28a3585042beac7a16697ba21b4abf179771423705eacba3ad4d", + "iso-8859-8": "b43535e7aaeb7bcf8bd8465326ef9ace96e351494306f963fa24cf312e5aaf18", + "iso-8859-8-i": "b43535e7aaeb7bcf8bd8465326ef9ace96e351494306f963fa24cf312e5aaf18", + "iso-8859-2": "a5871b0f978b840b9fad23483563caf9edf42c1828bff529f7594779ebaf5210", + "iso-8859-3": "e83895f2b7d7b82b9356298e197f7ddef190d53209cdf3b46e9eca4d4a582847", + "iso-8859-4": "449076e20ebf45ebbf44f24e39e98684dd2a6e07467ba3b8ba4192eb9405e2e3", + "iso-8859-10": "282514fbd01219c48fc84a8e45654368f161e1c5ab33fc028748688b9acb217f", + "iso-8859-15": "9b58b26dbd8fbff2917ab21d989323703946ba491a1eb15cdb2af7ecf9581e97", + "iso-8859-5": "9f31ddc0f7444afa24ddc2241f303bcd712296d7f2ca1e6bc9f5d1e9163df86f", + "iso-8859-7": "71069977a6798ab799df960847c927edfc3f787ac238f73702d7f37ef8cc1a1c", + "koi8-r": "fb0243455e64ef7026d46b057cfaeb41fef148d7d29a78fde21feda264ac02ee", + "macintosh": "54112bce885d7b1abc9ba5e06e21900b89ea0f7e5da25e393c0bdf72d0ea4a30", + "shift_jis": "63214e041f92daeea69d4ff39caf1cdd9dc39551b2a313e1fa0f9a22163b26f8", + "utf-16le": "c46bd82c3031aaac9e602e1dba920942cefe5427897a162ab7e2a8f5139e2304", + "windows-874": "6a2c7940c3d682164044abd7db7706dfff0307c39092937230f7554ce9846756", + "gb18030": "9cc45cb5d63e234da6349daddb9b38cc6d3a677a6c1e0bc2877d10bbf673c0b2", + "iso-8859-13": "4426f6d2f1b025cdf6d2b46080e2840b0ce85666d424ec909ccab226b34ebcc8", + "iso-8859-14": "f03afb7e01e66cac3cd7ed1a084173244f55b7c2e7fce44969aeade1077d8560", + "iso-8859-16": "2de1faef4dc524c9b94fd90885997e4fe6c2be7c672a1c03a10dcb0edd69487e", + "koi8-u": "896c218aaf12ca1b0489a01d8d2780b0e9de4253e24f0117d5486dfd87acf593", + "utf-8": "0f1a0d9c96b61c6dd842f73714f9e10c01c40383217f0a095c08145ef36b081b", + "utf-16be": "d82384cdf8ccf910d979149596e6eac1305878422cf44cbf028a97d6844a9e88", + "x-mac-cyrillic": "784db55e1c90195e69a4f96d755548fe48a4a6c327d1138cc731af07afec272c", + "x-user-defined": "fb4341fe90799717efc22f5de56d20a92c13e3711e94c9d4433fa4aabaf57c57" +}; +for (const [encoding, expected] of Object.entries(encodingHashes)) { + const bytes = Uint8Array.from({ length: 256 }, (_, index) => index); + const decoded = new TextDecoder(encoding).decode(bytes); + assert.equal(require('node:crypto').createHash('sha256').update(decoded).digest('hex'), + expected, `${encoding}: data differs from complete ICU`); + const streaming = new TextDecoder(encoding); + let chunks = ''; + for (let index = 0; index < bytes.length; index += 7) { + chunks += streaming.decode(bytes.slice(index, index + 7), { stream: true }); + } + chunks += streaming.decode(); + assert.equal(chunks, decoded, `${encoding}: streaming decode differs`); +} +for (const [encoding, bytes, expected] of [ + ['shift_jis', [0x93, 0xfa, 0x96, 0x7b], '日本'], + ['euc-jp', [0xc6, 0xfc, 0xcb, 0xdc], '日本'], + ['iso-2022-jp', [0x1b, 0x24, 0x42, 0x46, 0x7c, 0x4b, 0x5c, 0x1b, 0x28, 0x42], '日本'], + ['euc-kr', [0xc7, 0xd1, 0xb1, 0xdb], '한글'], +]) { + assert.equal(new TextDecoder(encoding, { fatal: true }).decode(Uint8Array.from(bytes)), expected); +} +for (const locale of ['zh-CN', 'zh-TW', 'zh-HK', 'zh-SG', 'zh-MO', + 'en-US', 'en-GB', 'en-AU', 'ja-JP', 'ko-KR']) { + for (const name of ['Collator', 'DateTimeFormat', 'NumberFormat', 'RelativeTimeFormat', + 'PluralRules', 'ListFormat', 'DisplayNames', 'Segmenter']) { + assert.equal(Intl[name].supportedLocalesOf([locale]).length, 1, `${name}: ${locale}`); + } + assert.ok(new Intl.DateTimeFormat(locale, { timeZone: 'Asia/Shanghai' }).format(0)); + assert.ok(new Intl.NumberFormat(locale, { style: 'currency', currency: 'CNY' }).format(1234.5)); + assert.ok(new Intl.NumberFormat(locale, { style: 'unit', unit: 'kilometer-per-hour' }).format(60)); + assert.ok(new Intl.DisplayNames(locale, { type: 'language' }).of('fr')); + assert.ok(new Intl.DisplayNames(locale, { type: 'region' }).of('US')); + assert.ok([...new Intl.Segmenter(locale, { granularity: 'word' }).segment('中文日本어 test')].length); +} +for (const timeZone of ['UTC', 'Asia/Taipei', 'Asia/Hong_Kong', 'Asia/Tokyo', + 'Asia/Seoul', 'America/New_York', 'Europe/London', 'Australia/Sydney']) { + assert.ok(new Intl.DateTimeFormat('en-US', { timeZone }).format(0)); +} +assert.equal(require('node:url').domainToASCII('中文.cn'), 'xn--fiq228c.cn'); +assert.equal(require('node:url').domainToASCII('faß.de'), 'xn--fa-hia.de'); +assert.equal('①ff'.normalize('NFKC'), '1ff'); +if (lite) { + assert.deepEqual(Intl.DateTimeFormat.supportedLocalesOf(['de-DE']), [], + 'lite must embed the selected ICU locale profile, not complete locale data'); +} + const ffiEnabled = Boolean(process.config.variables.node_use_ffi); assert.equal(ffiEnabled, !lite, 'FFI must be enabled only in full builds'); if (!ffiEnabled) { @@ -82,4 +171,4 @@ if (temporalEnabled) { const spring = Temporal.ZonedDateTime.from('2026-03-08T01:30-05:00[America/New_York]'); assert.equal(spring.add({ hours: 1 }).hour, 3, 'Temporal timezone data/DST is unavailable'); } -console.log(`NODEJS_MOBILE_FEATURES_OK flavor=${flavor} icu=full ffi=${Number(ffiEnabled)} temporal=${Number(temporalEnabled)}`); +console.log(`NODEJS_MOBILE_FEATURES_OK flavor=${flavor} icu=${lite ? 'danmu' : 'full'} ffi=${Number(ffiEnabled)} temporal=${Number(temporalEnabled)}`); diff --git a/mobile-src/tools/strip-android-runtime.py b/mobile-src/tools/strip-android-runtime.py new file mode 100755 index 0000000000..76494b169f --- /dev/null +++ b/mobile-src/tools/strip-android-runtime.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Strip copied Android runtimes without changing the dynamic-linking ABI.""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def dynamic_symbols(nm, library): + return subprocess.check_output( + [str(nm), '-D', '--format=posix', str(library)], text=True) + + +def strip_library(tools, library): + suffix = '.exe' if os.name == 'nt' else '' + nm, strip = (tools / ('llvm-' + name + suffix) for name in ('nm', 'strip')) + before = dynamic_symbols(nm, library) + if '_ZN4node5StartEiPPc ' not in before: + raise RuntimeError(f'{library}: missing node::Start embedding entry point') + original_size = library.stat().st_size + # Leave the input intact on any tool or symbol verification failure. + with tempfile.TemporaryDirectory(prefix='.strip-', dir=library.parent) as temp: + staged = Path(temp) / library.name + shutil.copy2(library, staged) + subprocess.run([str(strip), '--strip-unneeded', str(staged)], check=True) + if dynamic_symbols(nm, staged) != before: + raise RuntimeError(f'{library}: strip changed dynamic symbols') + if staged.stat().st_size > original_size: + raise RuntimeError(f'{library}: stripped file grew unexpectedly') + os.replace(staged, library) + print(f'{library}: {original_size} -> {library.stat().st_size} bytes; ' + 'dynamic symbols unchanged') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + tools = parser.add_mutually_exclusive_group(required=True) + tools.add_argument('--ndk', type=Path) + tools.add_argument('--tools', type=Path, help='LLVM bin directory (local verification)') + parser.add_argument('path', type=Path, help='libnode.so or out_android directory') + args = parser.parse_args() + if args.ndk: + suffix = '.exe' if os.name == 'nt' else '' + candidates = sorted((args.ndk / 'toolchains/llvm/prebuilt').glob('*/bin/llvm-strip' + suffix)) + if len(candidates) != 1: + parser.error('expected one NDK prebuilt LLVM tool directory') + args.tools = candidates[0].parent + libraries = [args.path] if args.path.is_file() else sorted(args.path.glob('*/libnode.so')) + if not libraries: + parser.error(f'no libnode.so found at {args.path}') + for library in libraries: + strip_library(args.tools, library) + + +if __name__ == '__main__': + main() diff --git a/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch b/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch index d2cfcc098f..ee3d17e9fa 100644 --- a/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch +++ b/patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch @@ -26,12 +26,12 @@ Co-authored-by: Vincent Breitmoser Signed-off-by: fogtape Assisted-by: Codex --- - android_configure.py | 75 +++++++++++++++++++++++++++++++++++--------- + android_configure.py | 76 +++++++++++++++++++++++++++++++++++--------- configure.py | 8 +++++ - 2 files changed, 68 insertions(+), 15 deletions(-) + 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/android_configure.py b/android_configure.py -index 5cea0393f4..5593019b06 100644 +index 5cea0393f4..c76ec94c8f 100644 --- a/android_configure.py +++ b/android_configure.py @@ -1,25 +1,14 @@ @@ -71,7 +71,7 @@ index 5cea0393f4..5593019b06 100644 toolchain_path = android_ndk_path + "/toolchains/llvm/prebuilt/darwin-x86_64" elif platform.system() == "Linux": -@@ -65,13 +54,69 @@ elif platform.system() == "Linux": +@@ -65,13 +54,70 @@ elif platform.system() == "Linux": os.environ['PATH'] += os.pathsep + toolchain_path + "/bin" os.environ['CC'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang" os.environ['CXX'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang++" @@ -121,6 +121,7 @@ index 5cea0393f4..5593019b06 100644 +# instead of silently shipping a different feature set. Lite drops FFI. +extra_flags = " --v8-enable-temporal-support" +if flavor == "lite": ++ GYP_DEFINES += " node_mobile_icu_profile=danmu-lite.json" + extra_flags = " --v8-disable-temporal-support --without-ffi --without-amaro --without-inspector --without-sqlite --disable-single-executable-application --v8-disable-object-print" + # Pointer compression: 32-bit compressed pointers inside a 4GB cage — the + # size lever on the JS heap rather than on the binary. lite only, because it diff --git a/patches/build-profile-ICU-data-for-danmu_api-lite-runtimes.patch b/patches/build-profile-ICU-data-for-danmu_api-lite-runtimes.patch new file mode 100644 index 0000000000..e400f4dc16 --- /dev/null +++ b/patches/build-profile-ICU-data-for-danmu_api-lite-runtimes.patch @@ -0,0 +1,465 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: fogtape +Date: Fri, 2 Oct 2026 12:00:00 +0800 +Subject: [PATCH] build: profile ICU data for danmu_api lite runtimes + +Keep complete ICU API mode while filtering the lite data payload to the +core's retained locale families and all Node TextDecoder web encodings. +Preserve converter dependencies, Unicode normalization, IDNA, segmentation +and complete timezone coverage. Validate required resources before and after +trimming, and use an independent intermediate package so a host's complete +ICU cannot hide missing data. + +The host genrb's StringBaseResource returns its existing char16_t buffer +directly. The bundled public char16ptr header has the compatibility helper +in global scope, so icu::toUCharPtr fails when rebuilding this host tool. +This does not change the linked mobile runtime. +--- + deps/icu-small/source/tools/genrb/reslist.h | 2 +- + tools/icu/danmu-lite.json | 287 ++++++++++++++++++++ + tools/icu/icu-generic.gyp | 19 +- + tools/icu/prepare-mobile-icu.py | 83 ++++++ + 4 files changed, 385 insertions(+), 6 deletions(-) + create mode 100644 tools/icu/danmu-lite.json + create mode 100755 tools/icu/prepare-mobile-icu.py + +diff --git a/deps/icu-small/source/tools/genrb/reslist.h b/deps/icu-small/source/tools/genrb/reslist.h +index 17797bc36c..93f5384d85 100644 +--- a/deps/icu-small/source/tools/genrb/reslist.h ++++ b/deps/icu-small/source/tools/genrb/reslist.h +@@ -326,7 +326,7 @@ public: + StringBaseResource(int8_t type, const char16_t *value, int32_t len, UErrorCode &errorCode); + virtual ~StringBaseResource(); + +- const char16_t *getBuffer() const { return icu::toUCharPtr(fString.getBuffer()); } ++ const char16_t *getBuffer() const { return fString.getBuffer(); } + int32_t length() const { return fString.length(); } + + virtual void handlePreWrite(uint32_t *byteOffset) override; +diff --git a/tools/icu/danmu-lite.json b/tools/icu/danmu-lite.json +new file mode 100644 +index 0000000000..fb9240da2b +--- /dev/null ++++ b/tools/icu/danmu-lite.json +@@ -0,0 +1,287 @@ ++{ ++ "comment": "danmu_api custom ICU: preserve en/zh/yue/ja/ko locale families, all Node TextDecoder web encodings, complete Unicode normalization, IDNA, segmentation and time zones; remove unrelated locale families, non-web converter tables, transliteration and rule-based spellout data.", ++ "variables": { ++ "locales": { ++ "only": [ ++ "en", ++ "en_001", ++ "en_150", ++ "en_AE", ++ "en_AG", ++ "en_AI", ++ "en_AS", ++ "en_AT", ++ "en_AU", ++ "en_BB", ++ "en_BE", ++ "en_BI", ++ "en_BM", ++ "en_BS", ++ "en_BW", ++ "en_BZ", ++ "en_CA", ++ "en_CC", ++ "en_CH", ++ "en_CK", ++ "en_CM", ++ "en_CX", ++ "en_CY", ++ "en_CZ", ++ "en_DE", ++ "en_DG", ++ "en_DK", ++ "en_DM", ++ "en_EE", ++ "en_ER", ++ "en_ES", ++ "en_FI", ++ "en_FJ", ++ "en_FK", ++ "en_FM", ++ "en_FR", ++ "en_GB", ++ "en_GD", ++ "en_GE", ++ "en_GG", ++ "en_GH", ++ "en_GI", ++ "en_GM", ++ "en_GS", ++ "en_GU", ++ "en_GY", ++ "en_HK", ++ "en_HU", ++ "en_ID", ++ "en_IE", ++ "en_IL", ++ "en_IM", ++ "en_IN", ++ "en_IO", ++ "en_IT", ++ "en_JE", ++ "en_JM", ++ "en_JP", ++ "en_KE", ++ "en_KI", ++ "en_KN", ++ "en_KY", ++ "en_LC", ++ "en_LR", ++ "en_LS", ++ "en_LT", ++ "en_LV", ++ "en_MG", ++ "en_MH", ++ "en_MO", ++ "en_MP", ++ "en_MS", ++ "en_MT", ++ "en_MU", ++ "en_MV", ++ "en_MW", ++ "en_MY", ++ "en_NA", ++ "en_NF", ++ "en_NG", ++ "en_NH", ++ "en_NL", ++ "en_NO", ++ "en_NR", ++ "en_NU", ++ "en_NZ", ++ "en_PG", ++ "en_PH", ++ "en_PK", ++ "en_PL", ++ "en_PN", ++ "en_PR", ++ "en_PT", ++ "en_PW", ++ "en_RH", ++ "en_RO", ++ "en_RW", ++ "en_SB", ++ "en_SC", ++ "en_SD", ++ "en_SE", ++ "en_SG", ++ "en_SH", ++ "en_SI", ++ "en_SK", ++ "en_SL", ++ "en_SS", ++ "en_SX", ++ "en_SZ", ++ "en_TC", ++ "en_TK", ++ "en_TO", ++ "en_TT", ++ "en_TV", ++ "en_TZ", ++ "en_UA", ++ "en_UG", ++ "en_UM", ++ "en_US", ++ "en_US_POSIX", ++ "en_VC", ++ "en_VG", ++ "en_VI", ++ "en_VU", ++ "en_WS", ++ "en_ZA", ++ "en_ZM", ++ "en_ZW", ++ "ja", ++ "ja_JP", ++ "ja_JP_TRADITIONAL", ++ "ko", ++ "ko_CN", ++ "ko_KP", ++ "ko_KR", ++ "root", ++ "yue", ++ "yue_CN", ++ "yue_HK", ++ "yue_Hans", ++ "yue_Hans_CN", ++ "yue_Hant", ++ "yue_Hant_CN", ++ "yue_Hant_HK", ++ "yue_Hant_MO", ++ "zh", ++ "zh_CN", ++ "zh_HK", ++ "zh_Hans", ++ "zh_Hans_CN", ++ "zh_Hans_HK", ++ "zh_Hans_MO", ++ "zh_Hans_MY", ++ "zh_Hans_SG", ++ "zh_Hant", ++ "zh_Hant_HK", ++ "zh_Hant_MO", ++ "zh_Hant_MY", ++ "zh_Hant_TW", ++ "zh_MO", ++ "zh_SG", ++ "zh_TW" ++ ] ++ }, ++ "none": { ++ "only": [] ++ } ++ }, ++ "trees": { ++ "ROOT": "locales", ++ "coll": "locales", ++ "curr": "locales", ++ "lang": "locales", ++ "region": "locales", ++ "unit": "locales", ++ "zone": "locales", ++ "brkitr": "locales", ++ "rbnf": "none", ++ "translit": "none", ++ "converters": { ++ "only": [ ++ "cns-11643-1992", ++ "euc-jp-2007", ++ "gb18030-2022", ++ "ibm-1089_P100-1995", ++ "ibm-1168_P100-2002", ++ "ibm-1363_P11B-1998", ++ "ibm-1373_P100-2002", ++ "ibm-1375_P100-2008", ++ "ibm-1386_P100-2001", ++ "ibm-5012_P100-1999", ++ "ibm-5346_P100-1998", ++ "ibm-5347_P100-1998", ++ "ibm-5348_P100-1997", ++ "ibm-5349_P100-1998", ++ "ibm-5350_P100-1998", ++ "ibm-5354_P100-1998", ++ "ibm-5478_P100-1995", ++ "ibm-866_P100-1995", ++ "ibm-878_P100-1996", ++ "ibm-9005_X110-2007", ++ "ibm-912_P100-1995", ++ "ibm-913_P100-2000", ++ "ibm-914_P100-1995", ++ "ibm-915_P100-1995", ++ "ibm-921_P100-1995", ++ "ibm-923_P100-1998", ++ "ibm-943_P15A-2003", ++ "ibm-9447_P100-2002", ++ "ibm-9448_X100-2005", ++ "ibm-9449_P100-2002", ++ "ibm-949_P110-1999", ++ "ibm-949_P11A-1999", ++ "ibm-970_P110_P110-2006_U2", ++ "iso-8859_10-1998", ++ "iso-8859_14-1998", ++ "jisx-212", ++ "macos-0_2-10.2", ++ "macos-7_3-10.2", ++ "windows-874-2000", ++ "windows-936-2000", ++ "windows-949-2000", ++ "windows-950-2000" ++ ] ++ } ++ }, ++ "remove": [], ++ "keep": [ ++ "pool.res", ++ "supplementalData.res", ++ "zoneinfo64.res", ++ "cnvalias.icu", ++ "brkitr/root.res", ++ "cns-11643-1992.cnv", ++ "euc-jp-2007.cnv", ++ "gb18030-2022.cnv", ++ "ibm-1089_P100-1995.cnv", ++ "ibm-1168_P100-2002.cnv", ++ "ibm-1363_P11B-1998.cnv", ++ "ibm-1373_P100-2002.cnv", ++ "ibm-1375_P100-2008.cnv", ++ "ibm-1386_P100-2001.cnv", ++ "ibm-5012_P100-1999.cnv", ++ "ibm-5346_P100-1998.cnv", ++ "ibm-5347_P100-1998.cnv", ++ "ibm-5348_P100-1997.cnv", ++ "ibm-5349_P100-1998.cnv", ++ "ibm-5350_P100-1998.cnv", ++ "ibm-5354_P100-1998.cnv", ++ "ibm-5478_P100-1995.cnv", ++ "ibm-866_P100-1995.cnv", ++ "ibm-878_P100-1996.cnv", ++ "ibm-9005_X110-2007.cnv", ++ "ibm-912_P100-1995.cnv", ++ "ibm-913_P100-2000.cnv", ++ "ibm-914_P100-1995.cnv", ++ "ibm-915_P100-1995.cnv", ++ "ibm-921_P100-1995.cnv", ++ "ibm-923_P100-1998.cnv", ++ "ibm-943_P15A-2003.cnv", ++ "ibm-9447_P100-2002.cnv", ++ "ibm-9448_X100-2005.cnv", ++ "ibm-9449_P100-2002.cnv", ++ "ibm-949_P110-1999.cnv", ++ "ibm-949_P11A-1999.cnv", ++ "ibm-970_P110_P110-2006_U2.cnv", ++ "iso-8859_10-1998.cnv", ++ "iso-8859_14-1998.cnv", ++ "jisx-212.cnv", ++ "macos-0_2-10.2.cnv", ++ "macos-7_3-10.2.cnv", ++ "windows-874-2000.cnv", ++ "windows-936-2000.cnv", ++ "windows-949-2000.cnv", ++ "windows-950-2000.cnv", ++ "nfkc.nrm", ++ "nfkc_cf.nrm", ++ "nfkc_scf.nrm", ++ "uts46.nrm", ++ "coll/ucadata.icu" ++ ] ++} +diff --git a/tools/icu/icu-generic.gyp b/tools/icu/icu-generic.gyp +index c4e8c6fbb9..d293800d14 100644 +--- a/tools/icu/icu-generic.gyp ++++ b/tools/icu/icu-generic.gyp +@@ -7,6 +7,7 @@ + + { + 'variables': { ++ 'node_mobile_icu_profile%': '', + 'icu_src_derb': [ + '<(icu_path)/source/tools/genrb/derb.c', + '<(icu_path)/source/tools/genrb/derb.cpp' +@@ -224,6 +225,11 @@ + # full data - no trim needed + 'sources': [ '<(SHARED_INTERMEDIATE_DIR)/icudt<(icu_ver_major)_dat.<(icu_asm_ext)' ], + 'dependencies': [ 'genccode#host', 'icupkg#host', 'icu_implementation#host', 'icu_uconfig' ], ++ 'conditions': [ ++ [ 'node_mobile_icu_profile != ""', { ++ 'dependencies': [ 'genrb#host', 'iculslocs#host' ], ++ } ], ++ ], + 'include_dirs': [ + '<(icu_path)/source/common', + ], +@@ -231,12 +237,15 @@ + { + # Copy the .dat file, swapping endianness if needed. + 'action_name': 'icupkg', +- 'inputs': [ '<(icu_data_in)' ], ++ 'inputs': [ '<(icu_data_in)', 'prepare-mobile-icu.py', ++ 'icutrim.py', 'danmu-lite.json' ], + 'outputs':[ '<(SHARED_INTERMEDIATE_DIR)/icudt<(icu_ver_major)<(icu_endianness).dat' ], +- 'action': [ '<(PRODUCT_DIR)/icupkg<(EXECUTABLE_SUFFIX)', +- '-t<(icu_endianness)', +- '<@(_inputs)', +- '<@(_outputs)', ++ 'action': [ '<(python)', 'prepare-mobile-icu.py', ++ '--tools', '<(PRODUCT_DIR)', ++ '--input', '<(icu_data_in)', ++ '--output', '<@(_outputs)', ++ '--endian', '<(icu_endianness)', ++ '--profile', '<(node_mobile_icu_profile)', + ], + }, + { +diff --git a/tools/icu/prepare-mobile-icu.py b/tools/icu/prepare-mobile-icu.py +new file mode 100755 +index 0000000000..1e79535e55 +--- /dev/null ++++ b/tools/icu/prepare-mobile-icu.py +@@ -0,0 +1,83 @@ ++#!/usr/bin/env python3 ++"""Prepare complete or profiled ICU data without enabling Node's small-ICU mode.""" ++import argparse ++import json ++from pathlib import Path ++import re ++import subprocess ++import sys ++import tempfile ++ ++ ++def listing(icupkg, data): ++ return set(subprocess.check_output([str(icupkg), '-l', str(data)], text=True).splitlines()) ++ ++ ++def required_items(profile, original): ++ required = set(profile['keep']) ++ # Protect selected locale resources and aliases, including parent fallbacks. ++ for tree, settings in profile['trees'].items(): ++ if isinstance(settings, str): ++ settings = profile['variables'][settings] ++ prefix = '' if tree in ('ROOT', 'converters') else tree + '/' ++ extension = '.cnv' if tree == 'converters' else '.res' ++ for item in settings.get('only', []): ++ name = prefix + item + extension ++ if tree == 'converters' or name in original: ++ required.add(name) ++ return required ++ ++ ++def prepare(tools, source, output, endian, profile_path): ++ icupkg = tools / 'icupkg' ++ output.parent.mkdir(parents=True, exist_ok=True) ++ if not profile_path: ++ subprocess.run([str(icupkg), '-t' + endian, str(source), str(output)], check=True) ++ return ++ profile = json.loads(Path(profile_path).read_text()) ++ original = listing(icupkg, source) ++ required = required_items(profile, original) ++ if missing := required - original: ++ raise RuntimeError('ICU source is missing required data: ' + ', '.join(sorted(missing))) ++ match = re.fullmatch(r'icudt(\d+)[bl]\.dat', source.name) ++ if not match: ++ raise RuntimeError(f'unrecognized ICU source data filename: {source.name}') ++ # An independent package name prevents an installed host's complete ICU ++ # from supplying resources that were deleted from the candidate package. ++ name = 'dmlt' + match[1].zfill(3) + endian + '.dat' ++ with tempfile.TemporaryDirectory(prefix='icu-profile-', dir=output.parent) as folder: ++ folder = Path(folder) ++ result = subprocess.run([ ++ sys.executable, str(Path(__file__).with_name('icutrim.py')), ++ '-P', str(tools), '-D', str(source), '-T', str(folder / 'trim'), ++ '-F', str(Path(profile_path).resolve()), '-O', name, ++ '-e', 'little' if endian == 'l' else 'big', ++ ], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) ++ if result.returncode: ++ raise RuntimeError('ICU profile generation failed:\n' + result.stdout[-8000:]) ++ trimmed = folder / 'trim' / name ++ if missing := required - listing(icupkg, trimmed): ++ raise RuntimeError('ICU trimming removed required data: ' + ', '.join(sorted(missing))) ++ staged = folder / output.name ++ subprocess.run([str(icupkg), '-t' + endian, str(trimmed), str(staged)], check=True) ++ # icupkg verifies data dependencies while repackaging. The conventional ++ # package/entry-point name must remain icudt for V8 and Node. ++ staged.replace(output) ++ print(f'ICU profile {Path(profile_path).name}: ' ++ f'{source.stat().st_size} -> {output.stat().st_size} bytes') ++ ++ ++def main(): ++ parser = argparse.ArgumentParser(description=__doc__) ++ parser.add_argument('--tools', type=Path, required=True) ++ parser.add_argument('--input', type=Path, required=True) ++ parser.add_argument('--output', type=Path, required=True) ++ parser.add_argument('--endian', choices=('l', 'b'), required=True) ++ parser.add_argument('--profile', default='') ++ args = parser.parse_args() ++ prepare(args.tools.resolve(), args.input.resolve(), args.output.resolve(), ++ args.endian, args.profile) ++ ++ ++if __name__ == '__main__': ++ main() diff --git a/patches/files.map b/patches/files.map index a1309817d1..70a3c0c623 100644 --- a/patches/files.map +++ b/patches/files.map @@ -132,3 +132,7 @@ lib-install-a-WebAssembly-polyfill-when-the-engine-h.patch lib/internal/process/ build-mobile-Temporal-Rust-toolsets-and-libffi-iOS-c.patch deps/crates/crates.gyp build-mobile-Temporal-Rust-toolsets-and-libffi-iOS-c.patch deps/libffi/generate-headers.py build-mobile-Temporal-Rust-toolsets-and-libffi-iOS-c.patch deps/libffi/libffi.gyp +build-profile-ICU-data-for-danmu_api-lite-runtimes.patch deps/icu-small/source/tools/genrb/reslist.h +build-profile-ICU-data-for-danmu_api-lite-runtimes.patch tools/icu/danmu-lite.json +build-profile-ICU-data-for-danmu_api-lite-runtimes.patch tools/icu/icu-generic.gyp +build-profile-ICU-data-for-danmu_api-lite-runtimes.patch tools/icu/prepare-mobile-icu.py diff --git a/patches/series b/patches/series index fd1fa5d7b3..6ce282ba2b 100644 --- a/patches/series +++ b/patches/series @@ -19,3 +19,4 @@ doc-build-mobile-notes-and-ignores.patch ci-remove-upstream-only-workflows-and-config.patch lib-install-a-WebAssembly-polyfill-when-the-engine-h.patch build-mobile-Temporal-Rust-toolsets-and-libffi-iOS-c.patch +build-profile-ICU-data-for-danmu_api-lite-runtimes.patch diff --git a/scripts/package-preview.py b/scripts/package-preview.py index 57899c6ea0..bd32575107 100644 --- a/scripts/package-preview.py +++ b/scripts/package-preview.py @@ -43,7 +43,10 @@ def main(): flavor=flavor, recipe_commit=sha, source_tree=tree, branch=os.environ.get('GITHUB_REF_NAME', ''), run_url=f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}", - preview=True, enabled_features=['full-icu'] + (['node:ffi', 'Temporal'] if flavor == 'full' else []), + preview=True, icu_api_mode='full-icu', + icu_data_profile='complete' if flavor == 'full' else 'danmu-lite', + android_symbols='stripped' if platform == 'android' and flavor == 'lite' else 'default', + enabled_features=(['full-icu', 'node:ffi', 'Temporal'] if flavor == 'full' else ['icu', 'danmu-icu-profile']), disabled_features=[] if flavor == 'full' else [ 'node:ffi', 'Temporal', 'node:sqlite', 'Web Storage', 'Inspector', 'TypeScript type-stripping', 'SEA', diff --git a/scripts/test-mobile-flavors.py b/scripts/test-mobile-flavors.py index f64ff8054d..4a43415a57 100755 --- a/scripts/test-mobile-flavors.py +++ b/scripts/test-mobile-flavors.py @@ -41,7 +41,7 @@ def test_android_flags_and_pointer_compression(self): capture = folder / 'configured.json' configure = folder / 'configure' configure.write_text(f'#!{sys.executable}\nimport json,sys\n' - f'open({str(capture)!r}, "w").write(json.dumps(sys.argv[1:]))\n') + f'open({str(capture)!r}, "w").write(json.dumps({{"flags":sys.argv[1:],"gyp":__import__("os").environ.get("GYP_DEFINES","")}}))\n') configure.chmod(0o755) for flavor in ('full', 'lite'): for arch in ('arm', 'arm64', 'x86_64'): @@ -50,7 +50,10 @@ def test_android_flags_and_pointer_compression(self): env.pop('NODEJS_MOBILE_SCCACHE', None) subprocess.run(ANDROID_WRAPPER + [str(ndk), '24', arch], cwd=folder, env=env, check=True, capture_output=True, text=True) - flags = set(json.loads(capture.read_text())) + captured = json.loads(capture.read_text()) + flags = set(captured['flags']) + self.assertEqual('node_mobile_icu_profile=danmu-lite.json' in captured['gyp'], + flavor == 'lite') self.assertIn('--with-intl=full-icu', flags) self.assertIn('--shared', flags) self.assertIn('--cross-compiling', flags) @@ -71,7 +74,7 @@ def test_ios_flags_and_framework_archives(self): script = (SOURCE / 'tools/ios_framework_prepare.sh').read_text() prefix = script.split('build_for_arm64_device() {', 1)[0] probe = prefix + ''' -printf '\nINTL:%s\nFLAGS:%s\n' "$INTL" "$LITE_FLAGS" +printf '\nINTL:%s\nFLAGS:%s\nICU_PROFILE:%s\n' "$INTL" "$LITE_FLAGS" "$ICU_PROFILE_GYP_DEFINES" for archive in "${outputs_arm64[@]}"; do printf 'LIB:%s\n' "$archive"; done ''' project = (SOURCE / 'tools/ios-framework/NodeMobile.xcodeproj/project.pbxproj').read_text() @@ -85,6 +88,7 @@ def test_ios_flags_and_framework_archives(self): capture_output=True, text=True, check=True) lines = result.stdout.splitlines() self.assertIn('INTL:full-icu', lines) + self.assertIn('ICU_PROFILE:' + ('node_mobile_icu_profile=danmu-lite.json' if flavor == 'lite' else ''), lines) flags = set(shlex.split(next(line[6:] for line in lines if line.startswith('FLAGS:')))) archives = {line[4:] for line in lines if line.startswith('LIB:')} @@ -129,8 +133,11 @@ def test_preview_metadata_matches_flavors(self): for package in output.glob('*.zip'): with zipfile.ZipFile(package) as archive: info = json.loads(archive.read('BUILD-INFO.json')) - self.assertIn('full-icu', info['enabled_features']) lite = info['flavor'] == 'lite' + self.assertEqual(info['icu_api_mode'], 'full-icu') + self.assertEqual(info['icu_data_profile'], 'danmu-lite' if lite else 'complete') + self.assertEqual('full-icu' in info['enabled_features'], not lite) + self.assertEqual('danmu-icu-profile' in info['enabled_features'], lite) for feature in ('node:ffi', 'Temporal'): self.assertEqual(feature in info['enabled_features'], not lite) self.assertEqual(feature in info['disabled_features'], lite) @@ -140,6 +147,34 @@ def test_preview_metadata_matches_flavors(self): digest, name = line.split() self.assertEqual(hashlib.sha256((output / name).read_bytes()).hexdigest(), digest) + def test_icu_profile_required_resources_exist_in_pinned_data(self): + # A version upgrade must fail loudly if renamed ICU data invalidates the + # reviewed locale/converter closure. Parse the actual canned package. + import bz2 + import importlib.util + import re + import struct + major = re.search(r'"(\d+)\.', + (SOURCE / 'deps/icu-small/source/common/unicode/uvernum.h').read_text()).group(1) + canned = SOURCE / f'deps/icu-small/source/data/in/icudt{major}l.dat' + data = canned.read_bytes() if canned.exists() else bz2.decompress( + canned.with_suffix('.dat.bz2').read_bytes()) + header = struct.unpack_from(' 1 else ROOT / 'out' +if len(sys.argv) > 1: + del sys.argv[1] + + +class RuntimeStripping(unittest.TestCase): + def test_exports_runtime_and_failure_atomicity(self): + clang = shutil.which('clang++') + strip = shutil.which('llvm-strip') + self.assertIsNotNone(clang, 'clang++ is required') + self.assertIsNotNone(strip, 'llvm-strip is required') + tools = Path(strip).parent + helper = SOURCE / 'tools/strip-android-runtime.py' + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + source = directory / 'embedder.cc' + source.write_text('namespace node { int Start(int argc, char**) { return argc + 40; } }\n' + 'extern "C" int napi_test_export() { return 42; }\n') + library = directory / 'libnode.so' + subprocess.run([clang, '-shared', '-fPIC', '-g', '-O0', str(source), + '-o', str(library)], check=True, capture_output=True) + before = library.stat().st_size + command = [sys.executable, str(helper), '--tools', str(tools), str(library)] + subprocess.run(command, check=True, capture_output=True) + self.assertLess(library.stat().st_size, before) + runtime = ctypes.CDLL(str(library)) + start = getattr(runtime, '_ZN4node5StartEiPPc') + start.argtypes = [ctypes.c_int, ctypes.c_void_p] + start.restype = ctypes.c_int + self.assertEqual(start(2, None), 42) + self.assertEqual(runtime.napi_test_export(), 42) + digest = hashlib.sha256(library.read_bytes()).digest() + subprocess.run(command, check=True, capture_output=True) + self.assertEqual(hashlib.sha256(library.read_bytes()).digest(), digest) + + # A failed entry-point/ABI check must leave the caller's file intact. + invalid = directory / 'invalid.so' + invalid.write_bytes(b'not an ELF library') + result = subprocess.run(command[:-1] + [str(invalid)], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(invalid.read_bytes(), b'not an ELF library') + + +if __name__ == '__main__': + unittest.main() From 28fc4a86fa5ed16a71a7ca50ecf5034437c07d8d Mon Sep 17 00:00:00 2001 From: fogtape Date: Fri, 2 Oct 2026 21:59:33 +0800 Subject: [PATCH 3/3] ci: install LLVM tools for runtime strip regression --- .github/workflows/verify-patches.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/verify-patches.yml b/.github/workflows/verify-patches.yml index e8b596139d..a14779cafc 100644 --- a/.github/workflows/verify-patches.yml +++ b/.github/workflows/verify-patches.yml @@ -91,6 +91,11 @@ jobs: - name: Check danmu_api flavor configuration and ICU contract run: python3 scripts/test-mobile-flavors.py out + - name: Install LLVM tools for strip regression + run: | + sudo apt-get update + sudo apt-get install -y llvm + - name: Check lite stripping preserves embedding exports run: python3 scripts/test-runtime-stripping.py out