feat(mobile): run the TypeScript backend on Node 18 in local mode (batch 7-9)

The web app migrated its server to TypeScript (server.cts) on Express 5 /
openid-client 6 / kysely, none of which ran on the old embedded Node 12.19.
Rebuild the local-mode pipeline for Node 18:

- sync-nodejs-project.sh: sync server.cts + .cts/.mts + setup/ + seed data.
- transpile-node18.js (replaces transpile-node12.js): esbuild TS->CJS in place,
  keeping .cts/.mts filenames (Node 18's CJS loader loads unknown extensions via
  the .js handler, so the explicit-extension require() graph keeps resolving).
- prepare-local-mode-deps.js: bundle ESM-only kysely to CJS, stub openid-client
  (unused on-device), and rewrite path-to-regexp's \p{} regexes to ASCII (the
  no-ICU Node 18 build can't compile unicode-property escapes).
- build-better-sqlite3-node18.sh (replaces the Node-12 build/fix scripts):
  cross-compile for all three ABIs against the Node 18 ABI, injecting libnode.so
  as a NEEDED entry so V8 symbols resolve at dlopen.
- main.js: entry -> server.cts; drop the now-native randomUUID/hkdfSync polyfills
  (keep the Intl shim); add TMPDIR + a startup-error relay to the WebView.
- nodejs-project/package.json: server dep set matched to the web app.

Verified end-to-end on the x86_64 emulator: boot -> migrations -> seed -> login
-> create bill -> force-stop -> relaunch -> data persists. arm64/armv7 prebuilds
are built and link-verified; a physical-device run is still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 09:42:53 -05:00
parent 3e115dd349
commit 2c29ec45bd
13 changed files with 623 additions and 763 deletions

View File

@ -8,7 +8,7 @@ const path = require('path');
// this process by nodejs-mobile-cordova). The copy under node_modules/ is // this process by nodejs-mobile-cordova). The copy under node_modules/ is
// whichever architecture it happened to be built for last; replace it with // whichever architecture it happened to be built for last; replace it with
// the prebuild matching this device's actual arch before anything requires // the prebuild matching this device's actual arch before anything requires
// better-sqlite3. See scripts/build-better-sqlite3-arm.sh. // better-sqlite3. See scripts/build-better-sqlite3-node18.sh.
(function installBetterSqlite3Prebuild() { (function installBetterSqlite3Prebuild() {
const archDirs = { const archDirs = {
arm64: 'arm64-v8a', arm64: 'arm64-v8a',
@ -31,60 +31,14 @@ const path = require('path');
} }
})(); })();
// Node 12.19 predates crypto.randomUUID (added in Node 14.17), used by // nodejs-mobile's Node 18.20.4 build ships WITHOUT ICU (verified:
// services/authService.js for session token IDs. // process.versions.icu === undefined, typeof Intl === 'undefined'), so `Intl`
const nodeCrypto = require('crypto'); // is entirely absent. Polyfill just the bits the server uses — en-US number
if (typeof nodeCrypto.randomUUID !== 'function') {
nodeCrypto.randomUUID = function () {
const b = nodeCrypto.randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = b.toString('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
};
}
// Node 12.19 predates crypto.hkdfSync (added in Node 15.0), used by
// services/encryptionService.js's deriveKey to derive the token-encryption
// key from TOKEN_ENCRYPTION_KEY. Implements HKDF-Extract/Expand (RFC 5869)
// via createHmac.
if (typeof nodeCrypto.hkdfSync !== 'function') {
nodeCrypto.hkdfSync = function (digest, ikm, salt, info, keylen) {
const hashLen = nodeCrypto.createHash(digest).digest().length;
const saltBuf = salt && salt.length ? Buffer.from(salt) : Buffer.alloc(hashLen, 0);
const ikmBuf = Buffer.isBuffer(ikm) ? ikm : Buffer.from(ikm);
const infoBuf = Buffer.isBuffer(info) ? info : Buffer.from(info || '');
const prk = nodeCrypto.createHmac(digest, saltBuf).update(ikmBuf).digest();
let t = Buffer.alloc(0);
let okm = Buffer.alloc(0);
let counter = 1;
while (okm.length < keylen) {
t = nodeCrypto
.createHmac(digest, prk)
.update(t)
.update(infoBuf)
.update(Buffer.from([counter]))
.digest();
okm = Buffer.concat([okm, t]);
counter++;
}
return okm.slice(0, keylen);
};
}
// nodejs-mobile-cordova 0.4.3 embeds Node 12.19 built without ICU, so
// `Intl` is entirely undefined. Polyfill just the bits the server uses
// (utils/money.js, routes/dataSources.js, routes/status.js) — en-US number
// formatting with thousands separators, and a no-op DateTimeFormat whose // formatting with thousands separators, and a no-op DateTimeFormat whose
// resolvedOptions().timeZone the callers already treat as optional. // resolvedOptions().timeZone the callers already treat as optional.
//
// (crypto.randomUUID and crypto.hkdfSync are NATIVE on Node 18 — the Node-12
// shims for them are gone. See PLAN/FUTURE Batch 6.)
if (typeof global.Intl === 'undefined') { if (typeof global.Intl === 'undefined') {
global.Intl = { global.Intl = {
NumberFormat: function (_locale, options) { NumberFormat: function (_locale, options) {
@ -114,6 +68,10 @@ if (typeof global.Intl === 'undefined') {
// on-device — nodejs-mobile-cordova copies nodejs-project here at runtime). // on-device — nodejs-mobile-cordova copies nodejs-project here at runtime).
process.env.DB_PATH = path.join(__dirname, 'data', 'bills.db'); process.env.DB_PATH = path.join(__dirname, 'data', 'bills.db');
process.env.BACKUP_PATH = path.join(__dirname, 'backups'); process.env.BACKUP_PATH = path.join(__dirname, 'backups');
// Android has no /tmp; point os.tmpdir() at app-writable storage so the daily
// worker's temp-export cleanup doesn't error (ENOENT scandir '/tmp').
process.env.TMPDIR = path.join(__dirname, 'tmp');
try { fs.mkdirSync(process.env.TMPDIR, { recursive: true }); } catch (_) { /* best effort */ }
process.env.PORT = process.env.PORT || '3000'; process.env.PORT = process.env.PORT || '3000';
process.env.BIND_HOST = '127.0.0.1'; process.env.BIND_HOST = '127.0.0.1';
// The Capacitor WebView's origin (androidScheme: 'https') is cross-origin // The Capacitor WebView's origin (androidScheme: 'https') is cross-origin
@ -123,13 +81,30 @@ process.env.CORS_ORIGIN = 'https://localhost';
const cordova = require('cordova-bridge'); const cordova = require('cordova-bridge');
// Wait for the WebView to hand over the device-bound encryption key (stored // Surface fatal errors to logcat and to the WebView (LoadingScreen) instead of
// in Android Keystore / iOS Keychain — see src/crypto.ts) before starting the // dying silently behind an endless "Starting…" spinner.
// server, so encryptionService.js picks up TOKEN_ENCRYPTION_KEY on first use. function reportError(where, err) {
const msg = err && err.stack ? err.stack : String(err);
console.error('[main.js] ' + where + ':', msg);
try {
cordova.channel.post('message', { type: 'serverError', message: String((err && err.message) || err) });
} catch (_) { /* channel may not be ready */ }
}
process.on('uncaughtException', (err) => reportError('uncaughtException', err));
process.on('unhandledRejection', (err) => reportError('unhandledRejection', err));
// Wait for the WebView to hand over the device-bound encryption key (stored in
// Android Keystore / iOS Keychain — see src/crypto.ts) before starting the
// server, so encryptionService picks up TOKEN_ENCRYPTION_KEY on first use.
cordova.channel.on('message', function (msg) { cordova.channel.on('message', function (msg) {
if (msg && msg.type === 'encryptionKey' && typeof msg.key === 'string') { if (msg && msg.type === 'encryptionKey' && typeof msg.key === 'string') {
process.env.TOKEN_ENCRYPTION_KEY = msg.key; process.env.TOKEN_ENCRYPTION_KEY = msg.key;
require('./server/server.js'); try {
require('./server/server.cts');
} catch (err) {
reportError('server failed to start', err);
}
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -2,20 +2,21 @@
"name": "nodejs-project", "name": "nodejs-project",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"//": "Server-side runtime deps only (client deps ship prebuilt in dist/). Versions mirror ../../../bill-tracker. openid-client is intentionally omitted — it is ESM-only and unused in single-user local mode; scripts/prepare-local-mode-deps.js installs a CJS stub. kysely is ESM-only and gets bundled to CJS by the same script.",
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^13.0.0", "@simplewebauthn/server": "^13.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"kysely": "^0.29.3",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.9", "nodemailer": "^9.0.3",
"openid-client": "^5.7.1",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"xlsx": "^0.18.5" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"nodejs-mobile-gyp": "^0.4.0" "nodejs-mobile-gyp": "^0.4.0"

View File

@ -1,98 +0,0 @@
#!/usr/bin/env bash
# Cross-compiles better-sqlite3 for arm64-v8a and armeabi-v7a (real Android
# devices) and writes the resulting better_sqlite3.node binaries into
# nodejs-assets/nodejs-project/prebuilds/<abi>/. main.js installs the one
# matching the device's actual process.arch at runtime — see main.js's
# installBetterSqlite3Prebuild().
#
# The existing x86_64 build (emulator) in
# nodejs-assets/nodejs-project/node_modules/better-sqlite3/build/Release/
# already covers nodejs-assets/nodejs-project/prebuilds/x86_64/ via
# fix-better-sqlite3-android.sh; re-copy it manually if that build changes:
# cp nodejs-assets/nodejs-project/node_modules/better-sqlite3/build/Release/better_sqlite3.node \
# nodejs-assets/nodejs-project/prebuilds/x86_64/
#
# Requires: Android NDK (ANDROID_HOME or ANDROID_NDK_HOME set), and
# `npm install` already run in nodejs-assets/nodejs-project (so
# better-sqlite3's source + nodejs-mobile-gyp are present).
#
# Usage: ./scripts/build-better-sqlite3-arm.sh
set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_SRC="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
PREBUILDS_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds"
GYP="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/.bin/nodejs-mobile-gyp"
NODEDIR="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/libs/android/libnode"
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" ]]; then
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
[[ -n "$SDK" ]] && NDK="$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1)"
fi
NDK="${NDK%/}"
if [[ ! -d "$PKG_SRC" ]]; then
echo "Error: $PKG_SRC not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
echo "Error: Android NDK not found. Set ANDROID_NDK_HOME (or ANDROID_HOME with an ndk/ subdir)." >&2
exit 1
fi
if [[ ! -x "$GYP" ]]; then
echo "Error: $GYP not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64"
API=24 # matches android/variables.gradle minSdkVersion
# Each ABI's libnode.so ships gzipped; better-sqlite3's binding.gyp (via
# nodejs-mobile's common.gypi) needs the real .so to add it as a NEEDED entry.
for abi in arm64-v8a armeabi-v7a; do
LIBNODE_DIR="$NODEDIR/bin/$abi"
if [[ ! -f "$LIBNODE_DIR/libnode.so" && -f "$LIBNODE_DIR/libnode.so.gz" ]]; then
gunzip -k "$LIBNODE_DIR/libnode.so.gz"
fi
done
build_one() {
local abi="$1" gyp_arch="$2" cc="$3" cxx="$4"
local build_dir="$MOBILE_DIR/build-output/better-sqlite3-$abi"
echo "── Building better-sqlite3 for $abi ($gyp_arch) ──"
rm -rf "$build_dir"
mkdir -p "$build_dir"
rsync -a --exclude=build --exclude=prebuilds "$PKG_SRC/" "$build_dir/"
# v8::Object::CreationContext() was removed in newer V8; nodejs-mobile's
# bundled V8 (Node 12.19) only has the old non-checked accessor. Same patch
# as fix-better-sqlite3-android.sh.
sed -i 's/obj->GetCreationContext().ToLocalChecked()/obj->CreationContext()/' \
"$build_dir/src/util/binder.cpp"
(
cd "$build_dir"
CC="$TOOLCHAIN/bin/$cc" \
CXX="$TOOLCHAIN/bin/$cxx" \
AR="$TOOLCHAIN/bin/llvm-ar" \
GYP_DEFINES="OS=android" \
"$GYP" configure --target=12.19.0 --arch="$gyp_arch" --nodedir="$NODEDIR"
CC="$TOOLCHAIN/bin/$cc" \
CXX="$TOOLCHAIN/bin/$cxx" \
AR="$TOOLCHAIN/bin/llvm-ar" \
"$GYP" build
)
mkdir -p "$PREBUILDS_DIR/$abi"
cp "$build_dir/build/Release/better_sqlite3.node" "$PREBUILDS_DIR/$abi/better_sqlite3.node"
echo "Wrote $PREBUILDS_DIR/$abi/better_sqlite3.node"
}
build_one arm64-v8a arm64 aarch64-linux-android${API}-clang aarch64-linux-android${API}-clang++
build_one armeabi-v7a arm armv7a-linux-androideabi${API}-clang armv7a-linux-androideabi${API}-clang++
echo "Done. Verify with: file $PREBUILDS_DIR/*/better_sqlite3.node"

View File

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Cross-compiles better-sqlite3 for the embedded Node 18.20.4 (nodejs-mobile) ABI
# for all three Android ABIs and writes the resulting better_sqlite3.node binaries
# into nodejs-assets/nodejs-project/prebuilds/<abi>/. main.js installs the one
# matching the device's actual process.arch at runtime — see
# installBetterSqlite3Prebuild().
#
# Unlike the old Node-12 build, NO binder.cpp patch is needed: Node 18's V8 10.2
# still exposes the (deprecated) v8::Object::CreationContext() that better-sqlite3
# 12.x uses, so it compiles as-is (deprecation warning only).
#
# Requires: Android NDK (ANDROID_HOME or ANDROID_NDK_HOME set), the Node 18 runtime
# staged into the plugin (scripts/install-node18-runtime.sh), and `npm install`
# already run in nodejs-assets/nodejs-project.
#
# Usage: ./scripts/build-better-sqlite3-node18.sh [abi ...] (default: all three)
set -euo pipefail
NODE_TARGET="18.20.4"
API=24 # matches android/variables.gradle minSdkVersion
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_SRC="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
PREBUILDS_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds"
GYP="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/.bin/nodejs-mobile-gyp"
NODEDIR="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/libs/android/libnode"
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" ]]; then
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
[[ -n "$SDK" ]] && NDK="$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1)"
fi
NDK="${NDK%/}"
[[ -d "$PKG_SRC" ]] || { echo "Error: $PKG_SRC not found. Run npm install in nodejs-project first." >&2; exit 1; }
[[ -n "$NDK" && -d "$NDK" ]] || { echo "Error: Android NDK not found (set ANDROID_NDK_HOME or ANDROID_HOME)." >&2; exit 1; }
[[ -x "$GYP" ]] || { echo "Error: $GYP not found. Run npm install in nodejs-project first." >&2; exit 1; }
grep -q "18" "$NODEDIR/include/node/node_version.h" || { echo "Error: $NODEDIR is not the Node 18 runtime — run scripts/install-node18-runtime.sh first." >&2; exit 1; }
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64"
abi_arch() { case "$1" in arm64-v8a) echo arm64;; armeabi-v7a) echo arm;; x86_64) echo x64;; *) echo "" ;; esac; }
abi_cc() { case "$1" in
arm64-v8a) echo "aarch64-linux-android${API}-clang" ;;
armeabi-v7a) echo "armv7a-linux-androideabi${API}-clang" ;;
x86_64) echo "x86_64-linux-android${API}-clang" ;;
esac; }
build_one() {
local abi="$1"
local gyp_arch cc cxx
gyp_arch="$(abi_arch "$abi")"; cc="$(abi_cc "$abi")"; cxx="${cc}++"
[[ -n "$gyp_arch" ]] || { echo "Unknown ABI: $abi" >&2; exit 1; }
# better-sqlite3's binding.gyp (via nodejs-mobile's common.gypi) needs the real
# libnode.so (not the .gz) to add it as a NEEDED entry at link time.
local libnode_dir="$NODEDIR/bin/$abi"
if [[ ! -f "$libnode_dir/libnode.so" && -f "$libnode_dir/libnode.so.gz" ]]; then
gunzip -k "$libnode_dir/libnode.so.gz"
fi
local build_dir="$MOBILE_DIR/build-output/better-sqlite3-$abi"
echo "── Building better-sqlite3 for $abi ($gyp_arch) against Node $NODE_TARGET ──"
rm -rf "$build_dir"; mkdir -p "$build_dir"
rsync -a --exclude=build --exclude=prebuilds "$PKG_SRC/" "$build_dir/"
(
cd "$build_dir"
CC="$TOOLCHAIN/bin/$cc" CXX="$TOOLCHAIN/bin/$cxx" AR="$TOOLCHAIN/bin/llvm-ar" \
GYP_DEFINES="OS=android" "$GYP" configure \
--target="$NODE_TARGET" --arch="$gyp_arch" --nodedir="$NODEDIR"
# nodejs-mobile's common.gypi only adds libnode.so as a NEEDED entry when
# target_arch=="x86_64", but gyp reports "x64" — so the rule never fires and
# the addon can't resolve V8/Node symbols (e.g. v8::Isolate::GetCurrent) at
# dlopen time on Android. Inject the ABI's libnode.so into the link so its
# SONAME (libnode.so) is recorded as NEEDED. (patchelf isn't required.)
local mk="build/better_sqlite3.target.mk"
if [[ -f "$mk" ]] && ! grep -q 'libnode\.so' "$mk"; then
sed -i "s#^\([[:space:]]*\)-llog\$#\1-llog $libnode_dir/libnode.so#" "$mk"
grep -q 'libnode\.so' "$mk" || { echo "Error: failed to inject libnode.so into $mk" >&2; exit 1; }
fi
CC="$TOOLCHAIN/bin/$cc" CXX="$TOOLCHAIN/bin/$cxx" AR="$TOOLCHAIN/bin/llvm-ar" \
make -C build BUILDTYPE=Release
)
mkdir -p "$PREBUILDS_DIR/$abi"
cp "$build_dir/build/Release/better_sqlite3.node" "$PREBUILDS_DIR/$abi/better_sqlite3.node"
echo "Wrote $PREBUILDS_DIR/$abi/better_sqlite3.node"
}
ABIS=("$@")
[[ ${#ABIS[@]} -eq 0 ]] && ABIS=(x86_64 arm64-v8a armeabi-v7a)
for abi in "${ABIS[@]}"; do build_one "$abi"; done
echo "Done. Verify with: file $PREBUILDS_DIR/*/better_sqlite3.node"

View File

@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Re-applies the two patches better-sqlite3 needs to load correctly under
# nodejs-mobile (Node 12.19 / Android x86_64 emulator). Required after any
# fresh `npm install` in nodejs-assets/nodejs-project, since both patches
# target files inside node_modules.
#
# Usage: ./scripts/fix-better-sqlite3-android.sh
# Run from the mobile project root, after `npm install` has already built
# better-sqlite3 once via nodejs-mobile-gyp (so build/better_sqlite3.target.mk
# and the Release/ dir already exist).
set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
LIBNODE="$MOBILE_DIR/android/app/libs/cdvnodejsmobile/libnode/bin/x86_64/libnode.so"
if [[ ! -d "$PKG_DIR" ]]; then
echo "Error: $PKG_DIR not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
# 1. v8::Object::CreationContext() was removed in newer V8; nodejs-mobile's
# bundled V8 (Node 12.19) only has the old non-checked accessor.
BINDER="$PKG_DIR/src/util/binder.cpp"
if grep -q 'GetCreationContext().ToLocalChecked()' "$BINDER"; then
sed -i 's/obj->GetCreationContext().ToLocalChecked()/obj->CreationContext()/' "$BINDER"
echo "Patched $BINDER"
else
echo "$BINDER already uses CreationContext() (no patch needed)"
fi
# 2. nodejs-mobile-cordova's common.gypi only adds the libnode.so NEEDED entry
# when target_arch=="x86_64", but gyp reports "x64" for 64-bit x86, so the
# rule never fires. Add it to LIBS directly so the addon can resolve V8/Node
# symbols (e.g. v8::Isolate::GetCurrent) at dlopen time on Android.
TARGET_MK="$PKG_DIR/build/better_sqlite3.target.mk"
if [[ ! -f "$TARGET_MK" ]]; then
echo "Error: $TARGET_MK not found. Run npm install to build better-sqlite3 first." >&2
exit 1
fi
if ! grep -q "libnode.so" "$TARGET_MK"; then
sed -i "/^\t-llog \\\\\$/a\\\\\t$LIBNODE" "$TARGET_MK"
echo "Patched $TARGET_MK"
else
echo "$TARGET_MK already references libnode.so (no patch needed)"
fi
# Rebuild and relink with the patched sources/Makefile.
cd "$PKG_DIR/build"
rm -f Release/better_sqlite3.node Release/obj.target/better_sqlite3.node
make BUILDTYPE=Release better_sqlite3.node
# Keep the x86_64 prebuild (emulator) in sync — main.js picks the right one
# for the device's process.arch at runtime. arm64-v8a/armeabi-v7a prebuilds
# are produced separately by build-better-sqlite3-arm.sh.
PREBUILD_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds/x86_64"
mkdir -p "$PREBUILD_DIR"
cp "$PKG_DIR/build/Release/better_sqlite3.node" "$PREBUILD_DIR/better_sqlite3.node"
echo "Done. Verify with: readelf -d \"$PKG_DIR/build/Release/better_sqlite3.node\" | grep libnode"

View File

@ -0,0 +1,95 @@
#!/usr/bin/env node
// Makes the two ESM-only server dependencies loadable by the embedded Node 18
// (nodejs-mobile) runtime, which cannot require() ESM. Run AFTER
// `npm install` inside nodejs-assets/nodejs-project (re-run after any install).
//
// • kysely (used: routes/categories.cts -> db/kysely.cts) — bundled to a
// self-contained CommonJS file; its package entry is repointed at it.
// • openid-client (UNUSED in single-user local mode, but eagerly require()'d
// at boot via auth.cts/authOidc.cts -> oidcService.cts) — replaced with a
// CommonJS stub so boot doesn't crash. Any actual use throws.
//
// Both are loaded on the web app (Node 22, native ESM); this only affects the
// on-device copy.
'use strict';
const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild'); // from the mobile project's own node_modules
const PROJECT = path.join(__dirname, '..', 'nodejs-assets', 'nodejs-project');
const NM = path.join(PROJECT, 'node_modules');
function fail(msg) {
console.error('prepare-local-mode-deps: ' + msg);
process.exit(1);
}
// ── kysely → CommonJS ────────────────────────────────────────────────────────
(function bundleKysely() {
const dir = path.join(NM, 'kysely');
if (!fs.existsSync(dir)) fail('kysely not installed — run `npm install` in nodejs-project first.');
const entry = path.join(dir, 'dist', 'index.js'); // ESM entry
const outfile = path.join(dir, 'dist', 'index.cjs');
esbuild.buildSync({
entryPoints: [entry],
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
outfile,
logLevel: 'silent',
});
const pkgPath = path.join(dir, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.main = './dist/index.cjs';
pkg.exports = {
'.': { require: './dist/index.cjs', import: './dist/index.js', default: './dist/index.cjs' },
'./package.json': './package.json',
};
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(' kysely -> bundled to CommonJS (dist/index.cjs)');
})();
// ── openid-client → CommonJS stub ────────────────────────────────────────────
(function stubOpenidClient() {
const dir = path.join(NM, 'openid-client');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'openid-client', version: '0.0.0-local-stub', main: 'index.js' }, null, 2) + '\n',
);
fs.writeFileSync(
path.join(dir, 'index.js'),
"'use strict';\n" +
'// Local-mode stub. OIDC/SSO is not available when running on-device.\n' +
'function notSupported() { throw new Error("OIDC is not available in local mode"); }\n' +
'module.exports = new Proxy({}, { get: function () { return notSupported; } });\n',
);
console.log(' openid-client -> CommonJS stub (OIDC disabled in local mode)');
})();
// ── ICU-less \p{} regex compatibility ────────────────────────────────────────
// nodejs-mobile's Node 18 ships WITHOUT ICU, so V8 cannot resolve Unicode
// property escapes (\p{...}) — any regex using them throws "Invalid property
// name" at compile time. path-to-regexp (express 5's router) uses
// \p{ID_Start}/\p{ID_Continue} at module load to validate route-param names,
// which crashes server boot. Rewrite to ASCII-safe classes; every route param
// in this app is ASCII (:id, :userId, …). (minimatch also contains \p{} but only
// as lazy POSIX-class strings that this app's globs never trigger — left as-is.)
(function patchPathToRegexp() {
const f = path.join(NM, 'path-to-regexp', 'dist', 'index.js');
if (!fs.existsSync(f)) return;
let s = fs.readFileSync(f, 'utf8');
const before = s;
s = s.replace(/\\p\{ID_Start\}/g, 'a-zA-Z').replace(/\\p\{ID_Continue\}/g, 'a-zA-Z0-9');
if (s !== before) {
fs.writeFileSync(f, s);
console.log(' path-to-regexp -> ASCII identifier regex (ICU-less \\p{} fix)');
}
})();
console.log('prepare-local-mode-deps: done.');

View File

@ -1,17 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copies the bill-tracker backend source into mobile/nodejs-assets/nodejs-project/server/ # Copies the bill-tracker backend into mobile/nodejs-assets/nodejs-project/server/
# so it can be bundled into the Android app and run by nodejs-mobile in local mode. # so it can be bundled into the Android app and run by nodejs-mobile (Node 18) in
# local mode.
#
# The backend is now TypeScript (server.cts + .cts/.mts). Node 18 can't run TS, so
# this transpiles the server's own sources to CommonJS in place (keeping filenames;
# see scripts/transpile-node18.js). Dependencies run as-is on Node 18 and are NOT
# transpiled — install them separately (see the "Next" steps printed at the end).
# #
# Usage: ./scripts/sync-nodejs-project.sh [--source /path/to/bill-tracker] # Usage: ./scripts/sync-nodejs-project.sh [--source /path/to/bill-tracker]
# Run from the mobile project root. # --source defaults to ../bill-tracker (sibling directory). Run from mobile root.
#
# --source defaults to ../bill-tracker (sibling directory).
set -euo pipefail set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse --source flag
REPO_ROOT="" REPO_ROOT=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -19,14 +22,13 @@ while [[ $# -gt 0 ]]; do
*) echo "Unknown arg: $1" >&2; exit 1 ;; *) echo "Unknown arg: $1" >&2; exit 1 ;;
esac esac
done done
if [[ -z "$REPO_ROOT" ]]; then if [[ -z "$REPO_ROOT" ]]; then
REPO_ROOT="$(cd "$MOBILE_DIR/../bill-tracker" && pwd)" REPO_ROOT="$(cd "$MOBILE_DIR/../bill-tracker" && pwd)"
fi fi
if [[ ! -f "$REPO_ROOT/server.js" ]]; then if [[ ! -f "$REPO_ROOT/server.cts" ]]; then
echo "Error: Bill Tracker source not found at $REPO_ROOT" >&2 echo "Error: Bill Tracker source not found at $REPO_ROOT (no server.cts)." >&2
echo "Use --source /path/to/bill-tracker to specify the Bill Tracker project root." >&2 echo "Use --source /path/to/bill-tracker to specify the project root." >&2
exit 1 exit 1
fi fi
@ -36,43 +38,41 @@ echo "Syncing backend source from $REPO_ROOT -> $DEST"
rm -rf "$DEST" rm -rf "$DEST"
mkdir -p "$DEST" mkdir -p "$DEST"
cp "$REPO_ROOT/server.js" "$DEST/" cp "$REPO_ROOT/server.cts" "$DEST/"
for dir in db routes services middleware utils workers; do # Backend source trees. `types/` is type-only (erased by the transpile) but cheap
cp -R "$REPO_ROOT/$dir" "$DEST/$dir" # insurance; `setup/` holds firstRun.cts (required by server.cts on empty DB).
for dir in db routes services middleware utils workers setup types; do
[[ -d "$REPO_ROOT/$dir" ]] && cp -R "$REPO_ROOT/$dir" "$DEST/$dir"
done done
# routes/user.js requires ../scripts/seedDemoData (relative to server/). # routes/user.cts requires ../scripts/seedDemoData.cts (relative to server/).
mkdir -p "$DEST/scripts" mkdir -p "$DEST/scripts"
cp "$REPO_ROOT/scripts/seedDemoData.js" "$DEST/scripts/" cp "$REPO_ROOT/scripts/seedDemoData.cts" "$DEST/scripts/"
# Drop dev SQLite DB files and node_modules if they were copied along with db/. # Seed JSON read at first-run init:
rm -f "$DEST"/db/*.db "$DEST"/db/*.db-* "$DEST"/db/*.db-shm "$DEST"/db/*.db-wal # db/data/*.json -> carried with db/ above (database.cts reads them)
# docs/top_200_*.json -> db/migrations/versionedMigrations.cts reads ../../docs/
mkdir -p "$DEST/docs"
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
# Drop dev SQLite DBs and any stray node_modules that rode along with db/.
rm -f "$DEST"/db/*.db "$DEST"/db/*.db-* 2>/dev/null || true
rm -rf "$DEST/db/node_modules" rm -rf "$DEST/db/node_modules"
# Seed-data JSON files read by db/database.js during first-run initialization. # Transpile the server's TypeScript sources to CommonJS in place (Node 18).
mkdir -p "$DEST/docs" # Runs BEFORE dist/ is copied so the browser ESM bundle isn't touched.
cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/" node "$MOBILE_DIR/scripts/transpile-node18.js" "$DEST"
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
cp "$REPO_ROOT/docs/merchant_store_match_us_nems_online_5k_v0_2.json" "$DEST/docs/"
# nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support # Built frontend, served as static files by server.cts (path.join(__dirname,'dist')).
# optional chaining (?.) / nullish coalescing (??) used in this project and
# in some npm dependencies (e.g. express-rate-limit). Transpile server-side
# .js files down to Node 12-compatible syntax in place. Runs before the
# frontend dist/ is copied below so its browser ESM bundle isn't rewritten
# to CJS (which breaks with "module is not defined" in the WebView).
node "$MOBILE_DIR/scripts/transpile-node12.js" "$DEST"
# Built frontend, served as static files by server.js (path.join(__dirname, 'dist')).
cp -R "$REPO_ROOT/dist" "$DEST/dist" cp -R "$REPO_ROOT/dist" "$DEST/dist"
# Also transpile already-installed dependencies, if present (run cat <<EOF
# `npm install` in nodejs-assets/nodejs-project after this script to pick up Done syncing server source.
# new/updated deps, then re-run this script to transpile them).
NODE_MODULES="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules"
if [ -d "$NODE_MODULES" ]; then
node "$MOBILE_DIR/scripts/transpile-node12.js" "$NODE_MODULES"
fi
echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install" Next (only when deps changed):
1. cd "$MOBILE_DIR/nodejs-assets/nodejs-project" && npm install
2. node "$MOBILE_DIR/scripts/prepare-local-mode-deps.js" # kysely->CJS, stub openid-client
3. "$MOBILE_DIR/scripts/install-node18-runtime.sh" # (re)apply Node 18 runtime + bridge patch
4. "$MOBILE_DIR/scripts/build-better-sqlite3-node18.sh" # better-sqlite3 for the Node 18 ABI
EOF

View File

@ -1,69 +0,0 @@
#!/usr/bin/env node
// Transpiles all .js files under a directory (in place) to syntax supported
// by nodejs-mobile-cordova's embedded Node 12.19 — primarily optional
// chaining (?.) and nullish coalescing (??) used by some npm dependencies
// and by this project's own source.
//
// Usage: node transpile-node12.js <dir> [<dir> ...]
'use strict';
const fs = require('fs');
const path = require('path');
// Resolve esbuild from this project's own node_modules (no longer parent repo)
const esbuild = require('esbuild');
const SKIP_DIRS = new Set(['.bin']);
function walk(dir, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
walk(full, cb);
} else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs') || entry.name === 'package.json') {
cb(full);
}
}
}
let count = 0;
let pkgCount = 0;
for (const dir of process.argv.slice(2)) {
if (!fs.existsSync(dir)) continue;
walk(dir, (file) => {
if (path.basename(file) === 'package.json') {
// esbuild converts ESM (import/export) to CJS (module.exports) below,
// but Node still treats .js files as ES modules if the nearest
// package.json has "type": "module" — drop that so the converted
// CJS output is loaded correctly under Node 12.
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
if (pkg.type === 'module') {
delete pkg.type;
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');
pkgCount++;
}
return;
}
const src = fs.readFileSync(file, 'utf8');
let out;
try {
out = esbuild.transformSync(src, { target: 'node12', format: 'cjs', loader: 'js' }).code;
} catch (err) {
console.warn(`Skipping ${file}: ${err.message.split('\n')[0]}`);
return;
}
// Node 12 doesn't understand the "node:" builtin-module prefix
// (added in Node 14.18/16), used by some dependencies (e.g. express-rate-limit).
out = out.replace(/require\((['"])node:([a-z/_-]+)\1\)/g, "require($1$2$1)");
if (out !== src) {
fs.writeFileSync(file, out);
count++;
}
});
}
console.log(`Transpiled ${count} file(s) and dropped "type": "module" from ${pkgCount} package.json file(s) for Node 12 compatibility.`);

View File

@ -0,0 +1,72 @@
#!/usr/bin/env node
// Transpiles the Bill Tracker server's TypeScript sources to plain CommonJS for
// the embedded Node 18.20.4 runtime (nodejs-mobile). Node 18 runs modern JS
// syntax natively, so — unlike the old Node-12 pass — this ONLY strips
// TypeScript; it does not rewrite dependency syntax and does not touch
// node_modules (their JS runs as-is on Node 18).
//
// The server is CommonJS-with-TS: files use require()/module.exports plus
// `import type`/`as typeof import()` for types, and require() each other with
// EXPLICIT `.cts`/`.mts` extensions (115 .cts + 40 .mts call sites). We therefore
// transpile each .cts/.mts file IN PLACE, keeping its original name: esbuild
// strips the types and emits CJS, and Node 18's CommonJS loader loads unknown
// extensions (.cts/.mts) through the default `.js` handler as CommonJS — so every
// explicit-extension require() path keeps resolving with zero path rewriting.
//
// Usage: node transpile-node18.js <serverDir> [<dir> ...]
'use strict';
const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild');
const SKIP_DIRS = new Set(['.bin', 'node_modules', 'dist']);
function loaderFor(file) {
if (/\.(cts|mts|ts)$/.test(file)) return 'ts';
if (/\.(cjs|js)$/.test(file)) return 'js';
return null;
}
function walk(dir, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isSymbolicLink()) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, cb);
else if (loaderFor(entry.name)) cb(full);
}
}
let count = 0;
let failed = 0;
for (const dir of process.argv.slice(2)) {
if (!fs.existsSync(dir)) continue;
walk(dir, (file) => {
const src = fs.readFileSync(file, 'utf8');
let out;
try {
out = esbuild.transformSync(src, {
loader: loaderFor(file),
format: 'cjs',
target: 'node18',
sourcefile: file,
}).code;
} catch (err) {
console.error(`FAILED ${file}: ${err.message.split('\n')[0]}`);
failed++;
return;
}
if (out !== src) {
fs.writeFileSync(file, out);
count++;
}
});
}
if (failed) {
console.error(`Transpile finished with ${failed} failure(s).`);
process.exit(1);
}
console.log(`Transpiled ${count} file(s) to CommonJS for Node 18.`);