diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d240501..1d97872 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,18 +46,18 @@ jobs: - if: ${{ startsWith(matrix.os, 'windows') }} run: pip.exe install setuptools - if: ${{ startsWith(matrix.os, 'macos') }} - run: brew install python-setuptools + run: brew install python-setuptools icu4c - if: ${{ !startsWith(matrix.os, 'windows') && !startsWith(matrix.os, 'macos') }} run: python3 -m pip install setuptools - if: ${{ startsWith(matrix.os, 'ubuntu') && matrix.node < 25 }} run: | sudo apt update - sudo apt install -y gcc-10 g++-10 libreadline-dev libncurses5-dev + sudo apt install -y gcc-10 g++-10 libreadline-dev libncurses5-dev libicu-dev sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 --slave /usr/bin/g++ g++ /usr/bin/g++-10 --slave /usr/bin/gcov gcov /usr/bin/gcov-10 - if: ${{ startsWith(matrix.os, 'ubuntu') && matrix.node >= 25 }} run: | sudo apt update - sudo apt install -y gcc-11 g++-11 libreadline-dev libncurses5-dev + sudo apt install -y gcc-11 g++-11 libreadline-dev libncurses5-dev libicu-dev sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 --slave /usr/bin/g++ g++ /usr/bin/g++-11 --slave /usr/bin/gcov gcov /usr/bin/gcov-11 - run: npm install --ignore-scripts - run: npm run build-debug @@ -93,13 +93,13 @@ jobs: with: bun-version: ${{ matrix.bun }} - if: ${{ startsWith(matrix.os, 'macos') }} - run: brew install python-setuptools + run: brew install python-setuptools icu4c - if: ${{ !startsWith(matrix.os, 'macos') }} run: python3 -m pip install setuptools - if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | sudo apt update - sudo apt install -y gcc-10 g++-10 libreadline-dev libncurses5-dev + sudo apt install -y gcc-10 g++-10 libreadline-dev libncurses5-dev libicu-dev sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 --slave /usr/bin/g++ g++ /usr/bin/g++-10 --slave /usr/bin/gcov gcov /usr/bin/gcov-10 - run: bun install --ignore-scripts - run: bun run build-debug @@ -163,7 +163,7 @@ jobs: - if: ${{ startsWith(matrix.os, 'windows') }} run: pip.exe install setuptools - if: ${{ startsWith(matrix.os, 'macos') }} - run: brew install python-setuptools + run: brew install python-setuptools icu4c - run: npm install --ignore-scripts - run: ${{ env.NODE_BUILD_CMD_LEGACY }} -u ${{ secrets.GITHUB_TOKEN }} - run: ${{ env.NODE_BUILD_CMD_MODERN }} -u ${{ secrets.GITHUB_TOKEN }} @@ -182,6 +182,7 @@ jobs: needs: test steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - run: apt-get update && apt-get install -y libicu-dev - run: npm install --ignore-scripts - run: ${{ env.NODE_BUILD_CMD_LEGACY }} -u ${{ secrets.GITHUB_TOKEN }} @@ -193,6 +194,7 @@ jobs: needs: test steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - run: apt-get update && apt-get install -y libicu-dev - run: npm install --ignore-scripts - run: ${{ env.NODE_BUILD_CMD_MODERN }} -u ${{ secrets.GITHUB_TOKEN }} @@ -204,7 +206,7 @@ jobs: needs: test steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - run: apk add build-base git python3 py3-setuptools libstdc++ readline-dev ncurses-dev --update-cache + - run: apk add build-base git python3 py3-setuptools libstdc++ readline-dev ncurses-dev icu-dev icu-static --update-cache - run: npm install --ignore-scripts - run: ${{ env.NODE_BUILD_CMD_LEGACY }} -u ${{ secrets.GITHUB_TOKEN }} - run: ${{ env.NODE_BUILD_CMD_MODERN }} -u ${{ secrets.GITHUB_TOKEN }} @@ -225,7 +227,7 @@ jobs: - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - run: | docker run --rm -v $(pwd):/tmp/project --entrypoint /bin/sh --platform linux/${{ matrix.arch }} node:20-alpine -c "\ - apk add build-base git python3 py3-setuptools libstdc++ readline-dev ncurses-dev --update-cache && \ + apk add build-base git python3 py3-setuptools libstdc++ readline-dev ncurses-dev icu-dev icu-static --update-cache && \ cd /tmp/project && \ npm install --ignore-scripts && \ ${{ env.NODE_BUILD_CMD_LEGACY }} -u ${{ secrets.GITHUB_TOKEN }} && \ @@ -247,6 +249,7 @@ jobs: - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - run: | docker run --rm -v $(pwd):/tmp/project --entrypoint /bin/sh --platform linux/${{ matrix.arch }} node:20-bullseye -c "\ + apt-get update && apt-get install -y libicu-dev && \ cd /tmp/project && \ npm install --ignore-scripts && \ ${{ env.NODE_BUILD_CMD_LEGACY }} -u ${{ secrets.GITHUB_TOKEN }}" @@ -267,6 +270,7 @@ jobs: - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - run: | docker run --rm -v $(pwd):/tmp/project --entrypoint /bin/sh --platform linux/${{ matrix.arch }} node:20-bookworm -c "\ + apt-get update && apt-get install -y libicu-dev && \ cd /tmp/project && \ npm install --ignore-scripts && \ ${{ env.NODE_BUILD_CMD_MODERN }} -u ${{ secrets.GITHUB_TOKEN }}" diff --git a/binding.gyp b/binding.gyp index d5582af..f1fff11 100644 --- a/binding.gyp +++ b/binding.gyp @@ -24,6 +24,12 @@ }, }, 'conditions': [ + # ICU is statically linked into the SQLite static library on + # non-Windows; the final .node must resolve its ICU symbols. (See + # deps/sqlite3.gyp for why Windows is excluded.) + ['OS != "win"', { + 'libraries': [' would +// fail to load there. Static linking is only possible where the ICU archives +// are -fPIC, which holds on macOS (Homebrew) and Alpine (musl). Debian/Ubuntu +// (glibc) ship non-PIC static archives, so there we link ICU dynamically +// against the system .so (the consumer must have ICU installed at runtime). +// See `useStatic` below. +// +// Usage: +// node icu.js include -> the ICU include directory (for #include ) +// node icu.js libs -> newline-separated linker inputs (static archive +// paths or -L/-l flags), then the C++ runtime / +// system libraries ICU depends on. +// +// Discovery order: pkg-config (Linux/Alpine) -> Homebrew icu4c (macOS) -> +// common system locations. Set ICU_ROOT to override (expects ICU_ROOT/lib and +// ICU_ROOT/include). +// +// ICU is not enabled on Windows (see deps/sqlite3.gyp), so this script only +// ever runs on macOS and Linux. +// === + +const {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const isMac = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; +const isAlpine = isLinux && fs.existsSync('/etc/alpine-release'); + +// We static-link ICU only where the static archives are position-independent +// (-fPIC) and can therefore be linked into a shared object (the .node): +// * macOS — Homebrew's icu4c archives are PIC. +// * Alpine — musl builds everything PIC, so icu-static is PIC. +// Debian/Ubuntu (glibc) ship NON-PIC static archives (libicu*.a), which fail to +// link into a shared object ("recompile with -fPIC"), so on glibc Linux we link +// ICU dynamically against the distro .so instead — those consumers must have +// ICU installed at runtime. ICU_ALLOW_DYNAMIC=1 forces dynamic everywhere as a +// local-dev escape hatch. +const useStatic = + (isMac || isAlpine) && process.env.ICU_ALLOW_DYNAMIC !== '1'; + +function run(cmd) { + try { + return execSync(cmd, {stdio: ['ignore', 'pipe', 'ignore']}).toString().trim(); + } catch { + return ''; + } +} + +function firstDir(candidates) { + return candidates.find(p => p && fs.existsSync(p)) || ''; +} + +// An include dir is only useful if the ICU headers are actually under it +// (/unicode/utypes.h). Validating this lets us reject a misconfigured +// pkg-config .pc and fall through to another discovery method, instead of +// emitting a bogus path that fails later with a confusing missing-header error. +function hasIcuHeaders(dir) { + return !!dir && fs.existsSync(path.join(dir, 'unicode', 'utypes.h')); +} + +function fail(message) { + process.stderr.write(`deps/icu.js: ${message}\n`); + process.exit(1); +} + +// Locate the ICU lib and include directories. Returns {libDir, includeDir}. +function locate() { + if (process.env.ICU_ROOT) { + const root = process.env.ICU_ROOT; + return {libDir: path.join(root, 'lib'), includeDir: path.join(root, 'include')}; + } + + // pkg-config (Debian's libicu-dev and Alpine's icu-dev ship icu-i18n.pc). + // Require both the lib dir and the actual ICU headers before trusting it. + const pcLibDir = run('pkg-config --variable=libdir icu-i18n'); + const pcIncDir = run('pkg-config --variable=includedir icu-i18n'); + if (pcLibDir && fs.existsSync(pcLibDir) && hasIcuHeaders(pcIncDir)) { + return {libDir: pcLibDir, includeDir: pcIncDir}; + } + + // Homebrew icu4c (macOS, keg-only so not on default search paths). + if (isMac) { + let prefix = run('brew --prefix icu4c'); + if (!prefix || !fs.existsSync(prefix)) { + prefix = firstDir(['/opt/homebrew/opt/icu4c', '/usr/local/opt/icu4c']); + } + if (prefix) { + return {libDir: path.join(prefix, 'lib'), includeDir: path.join(prefix, 'include')}; + } + } + + // Common system locations (Debian multiarch, Alpine, manual installs). + const libDir = firstDir([ + '/usr/lib/x86_64-linux-gnu', + '/usr/lib/aarch64-linux-gnu', + '/usr/lib/arm-linux-gnueabihf', + '/usr/lib', + '/usr/local/lib', + ]); + const includeDir = ['/usr/include', '/usr/local/include'].find(hasIcuHeaders) || ''; + return {libDir, includeDir}; +} + +// ICU static archives, in dependency order (i18n -> uc -> data). +const ARCHIVE_NAMES = ['libicui18n', 'libicuuc', 'libicudata']; + +// Full paths to the ICU static archives, so the linker pulls them in +// statically and the resulting binary stays self-contained. +function staticLibInputs(loc) { + return ARCHIVE_NAMES.map(name => { + const full = loc.libDir && path.join(loc.libDir, name + '.a'); + if (full && fs.existsSync(full)) { + return full; + } + // On the static platforms (macOS, Alpine) a missing archive is fatal: we + // must not silently produce a dynamically-linked binary, since zero-cache + // ships these prebuilds onto images (e.g. Alpine) that have no ICU. + fail( + `static ICU archive ${name}.a not found in ${loc.libDir || '(unknown library dir)'}.\n` + + ` This platform links ICU statically to stay self-contained, so the build is aborting\n` + + ` rather than linking ICU dynamically. Install the static ICU libraries (icu-dev +\n` + + ` icu-static on Alpine, icu4c via Homebrew on macOS), or set ICU_ALLOW_DYNAMIC=1 to\n` + + ` allow a dynamic fallback for local development.`, + ); + return null; // unreachable; fail() exits + }); +} + +// Ordinary -l flags, resolved against the system ICU shared libraries. Used on +// glibc Linux (Debian/Ubuntu), whose static archives are not -fPIC and so can't +// be linked into a shared object; the consumer must have ICU at runtime. +function dynamicLibInputs(loc) { + const out = []; + if (loc.libDir) { + out.push('-L' + loc.libDir); + } + for (const name of ARCHIVE_NAMES) { + out.push('-l' + name.replace(/^lib/, '')); + } + return out; +} + +function libsOutput(loc) { + const out = useStatic ? staticLibInputs(loc) : dynamicLibInputs(loc); + // C++ runtime + system libraries that ICU depends on. + if (isMac) { + out.push('-lc++'); + } else { + out.push('-lstdc++', '-lm', '-lpthread', '-ldl'); + } + return out; +} + +const mode = process.argv[2]; +const loc = locate(); + +if (mode === 'include') { + if (!hasIcuHeaders(loc.includeDir)) { + fail( + `could not find the ICU headers (unicode/utypes.h) in ${loc.includeDir || '(unknown include dir)'}.\n` + + ` Install the ICU development package (libicu-dev on Debian, icu-dev on Alpine,\n` + + ` icu4c via Homebrew on macOS), or set ICU_ROOT to an ICU install prefix.`, + ); + } + process.stdout.write(loc.includeDir); +} else { + process.stdout.write(libsOutput(loc).join('\n')); +} diff --git a/deps/sqlite3.gyp b/deps/sqlite3.gyp index 3e777c4..cad0a81 100755 --- a/deps/sqlite3.gyp +++ b/deps/sqlite3.gyp @@ -57,7 +57,16 @@ # This is currently required by better-sqlite3. 'SQLITE_ENABLE_COLUMN_METADATA', ], - }] + }], + # Unicode-aware LIKE/upper()/lower() via SQLite's bundled ICU extension, + # statically linked so the prebuilt binaries stay self-contained. + # Not enabled on Windows yet: static ICU there means building it from + # source (vcpkg), which is impractically slow in CI. Windows therefore + # keeps SQLite's ASCII-only LIKE for now. + ['OS != "win"', { + 'defines': ['SQLITE_ENABLE_ICU'], + 'include_dirs': ['