From da3f00c25b9e21b4f6e4acaea89cffbd000bef57 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 17 Aug 2026 18:07:10 +0100 Subject: [PATCH 01/23] feat: run the backend on nodejs-mobile 24 (Node 24.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the embedded runtime from nodejs-mobile v18.20.4 to the digidem fork's v24.19.0-0. The fork versions releases `-` and renamed its assets, so the download script changes shape as well as version. Two things the upgrade forces rather than merely allows: `--no-experimental-fetch` is gone from iOS argv — Node removed the flag in 23, and passing an unknown flag aborts before any JS runs. The iOS build now serves WebAssembly through a polyfill inside nodejs-mobile itself and has a working `fetch`, so the whole iOS-only shim stack goes with it: `index.ios.js`, the polywasm/undici installers, the SIMD-wasm alias and the loader-entry redirect. Both platforms now bundle from one entry, differing only in the `__loadAddon` banner. better-sqlite3 11 doesn't compile against V8 13.6 (Node 24 dropped the `ObjectTemplate::SetAccessor` overload it uses), and as a raw V8 addon it needs an ABI-matched prebuild per Node version, so the tree is pinned to one 12.10.0 through `overrides` and consumes the new ABI 137 prebuilds. Also picked up along the way: - TMPDIR now points at a real directory. An Android app process has none and there is no `/tmp`, which is where `os.tmpdir()` otherwise lands; reading it needed the credentials fix that arrived with this release. - V8's on-disk code cache is enabled via `NODE_COMPILE_CACHE`. Env var rather than `module.enableCompileCache()` so it covers `loader.mjs` and the Sentry chunk, which compile before any of our JS could call the runtime API. The backend flushes it at `ready` instead of leaving it to node's exit hook — the low-memory killer and iOS's suspended-app kill both skip that hook, so the cache would rarely be written at all. - The 24.x NodeMobile.xcframework has no x86_64 simulator slice, so the x64-simulator prebuild leg and its `lipo` pass are gone. - `readNodeJsMobileVersions()` matched `NODE_MODULE_VERSION (.+)`, which on the Node 24 header hits the `NODE_EMBEDDER_MODULE_VERSION` passthrough first and yields a garbage ABI in prebuild URLs. - Node 24's `v8config.h` #errors below C++20; the NDK defaults to gnu++17. - Sentry events carry the mobile revision as a `nodejs_mobile` tag; `contexts.runtime` only has the upstream Node version. --- .github/workflows/android-tests.yml | 2 +- .github/workflows/e2e-reusable.yml | 2 +- CONTRIBUTING.md | 12 ++- README.md | 8 +- android/CMakeLists.txt | 5 + android/src/main/cpp/jni-bridge.cpp | 12 +++ .../java/com/comapeo/core/NodeJSService.kt | 29 ++++++ apps/e2e/src/tests/map-server.ts | 7 +- backend/index.ios.js | 12 --- backend/index.js | 6 +- backend/lib/assert-webassembly.js | 10 -- backend/lib/compile-cache.js | 30 ++++++ backend/lib/install-fetch.js | 28 ------ backend/lib/install-polywasm.js | 29 ------ backend/lib/sentry.js | 8 +- backend/package-lock.json | 77 ++++++++------ backend/package.json | 4 +- backend/rolldown.config.ts | 94 ++---------------- docs/BUILD.md | 23 +++-- ios/AppLifecycleDelegate.swift | 32 ++++++ ios/NodeJSService.swift | 8 +- ios/NodeMobile.xcframework/Info.plist | 9 +- .../NodeMobile.framework/Headers/NodeMobile.h | 0 .../NodeMobile.framework/Info.plist | Bin 915 -> 916 bytes .../Modules/module.modulemap | 0 .../_CodeSignature/CodeResources | 2 +- .../ios-arm64/NodeMobile.framework/Info.plist | Bin 890 -> 889 bytes package-lock.json | 1 + scripts/download-nodejs-mobile.sh | 17 ++-- scripts/lib/ios-frameworks.ts | 35 ++----- scripts/lib/node-versions.ts | 10 +- scripts/lib/prebuilds.ts | 10 +- 32 files changed, 238 insertions(+), 284 deletions(-) delete mode 100644 backend/index.ios.js delete mode 100644 backend/lib/assert-webassembly.js create mode 100644 backend/lib/compile-cache.js delete mode 100644 backend/lib/install-fetch.js delete mode 100644 backend/lib/install-polywasm.js rename ios/NodeMobile.xcframework/{ios-arm64_x86_64-simulator => ios-arm64-simulator}/NodeMobile.framework/Headers/NodeMobile.h (100%) rename ios/NodeMobile.xcframework/{ios-arm64_x86_64-simulator => ios-arm64-simulator}/NodeMobile.framework/Info.plist (70%) rename ios/NodeMobile.xcframework/{ios-arm64_x86_64-simulator => ios-arm64-simulator}/NodeMobile.framework/Modules/module.modulemap (100%) rename ios/NodeMobile.xcframework/{ios-arm64_x86_64-simulator => ios-arm64-simulator}/NodeMobile.framework/_CodeSignature/CodeResources (98%) diff --git a/.github/workflows/android-tests.yml b/.github/workflows/android-tests.yml index 969bbe87..e64abe36 100644 --- a/.github/workflows/android-tests.yml +++ b/.github/workflows/android-tests.yml @@ -17,7 +17,7 @@ permissions: contents: read env: - NODEJS_MOBILE_VERSION: v18.20.4 + NODEJS_MOBILE_VERSION: v24.19.0-0 # NDK the generated example app builds against. Sourced from React Native's # node_modules/react-native/gradle/libs.versions.toml (Expo's default); keep # in sync on RN bumps. We install it explicitly (with retry) so Gradle never diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 0901172b..1d8c3cda 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -41,7 +41,7 @@ on: required: true env: - NODEJS_MOBILE_VERSION: v18.20.4 + NODEJS_MOBILE_VERSION: v24.19.0-0 jobs: # Decide whether the expensive build + paid BrowserStack device jobs run. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2ee699f..d5027d20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,11 +40,13 @@ debug ID in the bundle, the consuming app uploads the maps with `comapeo-rn-upload-sourcemaps`, and Sentry matches them by that ID. The backend deliberately does **not** run with `--enable-source-maps` in any -variant. nodejs-mobile pins Node 18, whose `findSourceMap()` re-parses the whole -map on every `Error.stack` format — roughly 320 ms and 250–470 MB of garbage per -error for our 19 MB map, enough to wedge the event loop for tens of seconds on a -low-end device. For a stack you have in a terminal rather than in Sentry, -`comapeo-rn-symbolicate` remaps it offline from the shipped maps. +variant. Measured on the Node 18 nodejs-mobile used to pin, `findSourceMap()` +re-parsed the whole map on every `Error.stack` format — roughly 320 ms and +250–470 MB of garbage per error for our 19 MB map, enough to wedge the event +loop for tens of seconds on a low-end device. Node has since reworked its +source-map cache; the flag stays off until that's re-measured on device. For a +stack you have in a terminal rather than in Sentry, `comapeo-rn-symbolicate` +remaps it offline from the shipped maps. ## Repository layout diff --git a/README.md b/README.md index 36f1d48b..8f72bcfa 100644 --- a/README.md +++ b/README.md @@ -364,10 +364,10 @@ place of the flags. The maps live in sibling `nodejs-sourcemaps/` directories (not under the bundled `nodejs-project/` assets), so they are **not** shipped inside your APK/IPA. The -backend runs without Node's `--enable-source-maps` in every variant — on the -Node 18 that nodejs-mobile pins, that flag re-parses the entire map on every -error stack and can wedge the event loop for tens of seconds on a low-end -device. To remap a stack you have in a terminal rather than in Sentry: +backend runs without Node's `--enable-source-maps` in every variant — measured +on Node 18, that flag re-parsed the entire map on every error stack and could +wedge the event loop for tens of seconds on a low-end device. To remap a stack +you have in a terminal rather than in Sentry: ```sh adb logcat -d | npx comapeo-rn-symbolicate diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 32d48654..67d24a87 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -33,6 +33,11 @@ add_library(${CMAKE_PROJECT_NAME} SHARED include_directories(libnode/include/node/) include_directories(src/main/cpp) +# Node 24's v8config.h #errors below C++20; the NDK's clang defaults to gnu++17. +set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON) + add_library( libnode SHARED IMPORTED ) diff --git a/android/src/main/cpp/jni-bridge.cpp b/android/src/main/cpp/jni-bridge.cpp index 0c43b39c..362447b6 100644 --- a/android/src/main/cpp/jni-bridge.cpp +++ b/android/src/main/cpp/jni-bridge.cpp @@ -97,6 +97,16 @@ class NodeJSService : public JavaClass { log("initialize: %s", nativeDataDir.c_str()); } + /// node reads TMPDIR and NODE_COMPILE_CACHE while the Environment is + /// created, so assigning `process.env` from JS is too late — callers must + /// set them here, before `startNodeWithArguments`. + static void setEnv(alias_ref, alias_ref name, alias_ref value) { + const auto nativeName = name->toStdString(); + const auto nativeValue = value->toStdString(); + setenv(nativeName.c_str(), nativeValue.c_str(), 1); + log("setEnv: %s=%s", nativeName.c_str(), nativeValue.c_str()); + } + static jint startNodeWithArguments(alias_ref, alias_ref> arguments) { log("Starting NodeJS with arguments."); @@ -141,6 +151,8 @@ class NodeJSService : public JavaClass { javaClassStatic()->registerNatives({ makeNativeMethod("initialize", NodeJSService::initialize), + makeNativeMethod("setEnv", + NodeJSService::setEnv), makeNativeMethod("startNodeWithArguments", NodeJSService::startNodeWithArguments), }); diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index 5a6813d5..92a015c1 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -211,6 +211,9 @@ class NodeJSService( @JvmStatic external fun initialize(dataDir: String) + @JvmStatic + external fun setEnv(name: String, value: String) + @JvmStatic external fun startNodeWithArguments(args: Array): Int @@ -353,6 +356,30 @@ class NodeJSService( startupWatchdogJob.getAndSet(null)?.cancel() } + /** + * Environment node inherits from this process. Must run before + * [startNodeWithArguments] — node reads all of these while creating the + * Environment, so assigning `process.env` from JS would be too late. + * + * An Android app process has no `TMPDIR` and there is no `/tmp`, which + * `os.tmpdir()` otherwise falls back to; anything writing there fails with + * ENOENT. `NODE_COMPILE_CACHE` is V8's on-disk code cache — the backend + * flushes it once boot reaches `ready` rather than leaving it to node's + * exit hook, which the low-memory killer routinely denies us. + * + * Both live under `cacheDir`, so the OS may reclaim them under storage + * pressure — the right semantics for scratch and regenerable data. + */ + private fun applyNodeEnvironment() { + val tmpDir = File(cacheDir, "tmp") + val compileCacheDir = File(cacheDir, "node-compile-cache") + tmpDir.mkdirs() + compileCacheDir.mkdirs() + setEnv("TMPDIR", tmpDir.absolutePath) + setEnv("NODE_COMPILE_CACHE", compileCacheDir.absolutePath) + setEnv("NODE_COMPILE_CACHE_PORTABLE", "1") + } + /** Positionals are read by backend/index.js; `--sentry*` flags by backend/loader.mjs. */ private fun buildBackendArgs(entryPath: String): Array { // 4th positional: default config path, or "" when the app bundled @@ -508,6 +535,8 @@ class NodeJSService( bootSpans["node-spawn"] = it } + withContext(Dispatchers.IO) { applyNodeEnvironment() } + val exitCode = startNodeWithArguments( buildBackendArgs(jsFile.absolutePath) ) diff --git a/apps/e2e/src/tests/map-server.ts b/apps/e2e/src/tests/map-server.ts index cd0035f0..a559cf7b 100644 --- a/apps/e2e/src/tests/map-server.ts +++ b/apps/e2e/src/tests/map-server.ts @@ -6,9 +6,10 @@ import type { TestContext } from './utils' // device: in-process inside nodejs-mobile, reached over loopback HTTP by // the app's `fetch`. The module's own suite covers route/logic behaviour // in Node — these only check that the real request → response pipeline -// (which differs on nodejs-mobile, e.g. the polyfilled fetch/Response -// globals) delivers each response shape. They use the built-in `fallback` -// map only: no project, no uploaded SMP, no network. +// delivers each response shape once it's running on nodejs-mobile, where +// the server side builds its responses from the `Response`/`Request` +// globals under a jitless V8. They use the built-in `fallback` map only: +// no project, no uploaded SMP, no network. export function test({ describe, expect, it }: TestContext) { describe('map server', () => { it('getBaseUrl() returns a valid URL', async () => { diff --git a/backend/index.ios.js b/backend/index.ios.js deleted file mode 100644 index 23dd12cb..00000000 --- a/backend/index.ios.js +++ /dev/null @@ -1,12 +0,0 @@ -// iOS-only entry. Installs the polywasm WebAssembly polyfill, then the -// undici-backed `fetch`/`Response`/`Request` globals (absent on this -// jitless nodejs-mobile build), before running the shared `index.js` -// boot for side effects. ESM evaluates imports in source-order of the -// entry's import declarations, so `install-polywasm.js` runs first and -// gives undici a working `WebAssembly` global, then `install-fetch.js` -// loads undici and installs the globals the map server needs. - -import "./lib/install-polywasm.js"; -import "./lib/assert-webassembly.js"; -import "./lib/install-fetch.js"; -import "./index.js"; diff --git a/backend/index.js b/backend/index.js index 5b04ce68..ed4d932f 100644 --- a/backend/index.js +++ b/backend/index.js @@ -4,6 +4,7 @@ import ensureError from "ensure-error"; import Fastify from "fastify"; import { ComapeoRpc } from "./lib/comapeo-rpc.js"; +import { flushCompileCacheAfterBoot } from "./lib/compile-cache.js"; import { createComapeo } from "./lib/create-comapeo.js"; import { createMapServer } from "./lib/create-map-server.js"; import { SimpleRpcServer } from "./lib/simple-rpc.js"; @@ -15,10 +16,6 @@ import { observeSyncSessions } from "./lib/sync-observer.js"; // when Sentry is off (the metrics layer never got its SDK). const MEMORY_SAMPLE_INTERVAL_MS = 60_000; -// Shared/Android entry. Android's nodejs-mobile build ships the -// undici-backed `fetch`/`Response`/`Request` globals the map server needs; -// iOS lacks them and installs them first via `index.ios.js` → `install-fetch.js`. - // `KEEP_THESE_FROM_BACKEND` in `scripts/build-backend.ts` mirrors this // directory into the on-device bundle. const MIGRATIONS_FOLDER_PATH = fileURLToPath( @@ -311,6 +308,7 @@ async function withPhase(phase, fn) { controlIpcServer.setReadinessPhase("ready"); metrics.bootOutcome("started"); + flushCompileCacheAfterBoot(); startMemorySampler(); sampleStorageSize(privateStorageDir); } catch (error) { diff --git a/backend/lib/assert-webassembly.js b/backend/lib/assert-webassembly.js deleted file mode 100644 index e37a1fc8..00000000 --- a/backend/lib/assert-webassembly.js +++ /dev/null @@ -1,10 +0,0 @@ -// undici's `client-h1.js` calls `WebAssembly.compile` at module-init, so the -// `WebAssembly` global (installed by `install-polywasm.js`) must already exist -// when `install-fetch.js` imports undici. Imported between the two in -// `index.ios.js` to turn a wrong import order into a loud, clear failure -// instead of an opaque undici init error later. -if (typeof globalThis.WebAssembly === "undefined") { - throw new Error( - "WebAssembly global missing; install-polywasm.js must run before install-fetch.js", - ); -} diff --git a/backend/lib/compile-cache.js b/backend/lib/compile-cache.js new file mode 100644 index 00000000..4a4ff084 --- /dev/null +++ b/backend/lib/compile-cache.js @@ -0,0 +1,30 @@ +import { flushCompileCache, getCompileCacheDir } from "node:module"; + +/** + * Persist V8's code cache for everything compiled so far. + * + * The cache itself is enabled by native through `NODE_COMPILE_CACHE` + * rather than `module.enableCompileCache()`, because the env var is read + * when the Environment is created — early enough to cover `loader.mjs` + * and the Sentry chunk, which are the two biggest compiles on the boot + * path and both run before any JS of ours could call the runtime API. + * + * Node's own flush runs from an `exit` handler, which on mobile is the + * one moment we can't count on: Android's low-memory killer takes the + * `:ComapeoCore` process outright, and iOS usually kills the app while + * suspended without ever reaching `applicationWillTerminate`. A cache + * that only lands on a clean exit would rarely land at all, so flush at + * `ready` instead — by then every module on the boot path, which is the + * set worth caching, has been compiled. + * + * No-ops when the env var is unset. + */ +export function flushCompileCacheAfterBoot() { + const dir = getCompileCacheDir(); + if (!dir) return; + // After the `ready` frame is on the wire — the flush is synchronous. + setImmediate(() => { + flushCompileCache(); + console.log(`Compile cache flushed to ${dir}`); + }); +} diff --git a/backend/lib/install-fetch.js b/backend/lib/install-fetch.js deleted file mode 100644 index 2767b3f1..00000000 --- a/backend/lib/install-fetch.js +++ /dev/null @@ -1,28 +0,0 @@ -// nodejs-mobile on iOS ships Node 18 without the undici-backed global -// `fetch`/`Response`/`Request`/`Headers` (the jitless `v8_enable_webassembly=false` -// build skips the bootstrap that installs them; `ReadableStream` is still -// present). `@comapeo/map-server` serves over `@whatwg-node` + `itty-router`, -// both of which construct `new Response(...)`/`new Request(...)` against the -// globals — so without these the HTTP handler throws "Response is not defined" -// and never replies, and a fetch to the map server hangs. -// -// Pull the implementations from undici (which loads because -// `install-polywasm.js` ran first) onto `globalThis`. Imported only from -// `index.ios.js`; Android keeps its native globals and pays no cost. - -import { fetch, Headers, Request, Response, FormData, File } from "undici"; - -const impls = { fetch, Headers, Request, Response, FormData, File }; -const g = /** @type {Record} */ ( - /** @type {unknown} */ (globalThis) -); -const installed = []; -for (const [name, value] of Object.entries(impls)) { - if (typeof g[name] === "undefined") { - g[name] = value; - installed.push(name); - } -} -console.log( - `[install-fetch] installed ${installed.length ? installed.join(",") : "nothing (globals present)"}`, -); diff --git a/backend/lib/install-polywasm.js b/backend/lib/install-polywasm.js deleted file mode 100644 index 031fa552..00000000 --- a/backend/lib/install-polywasm.js +++ /dev/null @@ -1,29 +0,0 @@ -// Pure-JS WebAssembly polyfill, installed before anything that touches -// undici. nodejs-mobile on iOS runs V8 with `--jitless` and is built -// with `v8_enable_webassembly=false`, so `globalThis.WebAssembly` is -// absent (or a partial stub). undici's `lazyllhttp()` runs at -// module-init and would throw `WebAssembly is not defined` before the -// entry can boot. -// -// Imported only from `index-ios.js` — Android keeps native WebAssembly -// and pays no cost for polywasm. -// -// We swap polywasm in whenever the native `WebAssembly.compile` isn't -// callable: `typeof WebAssembly === "undefined"` is not enough, since a -// partial stub that defines the namespace but no methods would slip -// past and break undici lazily. - -import { WebAssembly as PolyWebAssembly } from "polywasm"; - -const native = globalThis.WebAssembly; -const hasNativeCompile = native && typeof native.compile === "function"; -if (!hasNativeCompile) { - globalThis.WebAssembly = PolyWebAssembly; - console.log( - `[install-polywasm] installed polywasm (native.compile=${ - native ? typeof native.compile : "no global" - })`, - ); -} else { - console.log("[install-polywasm] native WebAssembly.compile present, skipping"); -} diff --git a/backend/lib/sentry.js b/backend/lib/sentry.js index 56b7291d..9cbe41bf 100644 --- a/backend/lib/sentry.js +++ b/backend/lib/sentry.js @@ -185,7 +185,13 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame, storageDir } integrations: (defaults) => config?.debug ? [...defaults, Sentry.consoleIntegration()] : defaults, initialScope: { - tags: { proc: "fgs", layer: "node" }, + // `contexts.runtime` already carries the upstream Node version; the + // mobile revision of the embedded build only lives here. + tags: { + proc: "fgs", + layer: "node", + nodejs_mobile: process.versions.mobile ?? "unknown", + }, // Native-derived user.id (monthly/permanent hash) — same value the // FGS and RN layers set, so one launch reports one user. ...(argv.sentryUserId ? { user: { id: argv.sentryUserId } } : {}), diff --git a/backend/package-lock.json b/backend/package-lock.json index 4f8cf22b..dc870778 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,13 +27,12 @@ "ensure-error": "^5.0.0", "fastify": "4.29.1", "framed-stream": "1.0.1", - "polywasm": "0.2.0", "tiny-typed-emitter": "2.1.0" }, "devDependencies": { "@sentry/bundler-plugins": "10.70.0", "@sentry/rollup-plugin": "5.4.0", - "@types/node": "18.19.130", + "@types/node": "24.12.2", "@types/streamx": "2.9.5", "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", @@ -673,6 +672,21 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/@digidem/types/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@digidem/types/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -2634,12 +2648,12 @@ } }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.16.0" } }, "node_modules/@types/readable-stream": { @@ -3222,14 +3236,17 @@ } }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/big-sparse-array": { @@ -5780,6 +5797,21 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/multi-core-indexer/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/multi-core-indexer/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", @@ -6429,12 +6461,6 @@ ], "license": "MIT" }, - "node_modules/polywasm": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/polywasm/-/polywasm-0.2.0.tgz", - "integrity": "sha512-13Lg5S4fkF6onHsIeITWBnrPYhPe6iqdZCGJ8K+RAlL3qvGptPuWR4aQtULmbkGW2/XzXxWoHKlWsYRAvfcLnA==", - "license": "MIT" - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -7601,21 +7627,6 @@ "better-sqlite3": "^12.8.0" } }, - "node_modules/styled-map-package-api/node_modules/better-sqlite3": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", - "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/styled-map-package-api/node_modules/mbtiles-reader": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mbtiles-reader/-/mbtiles-reader-2.0.1.tgz", @@ -7984,9 +7995,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/universalify": { diff --git a/backend/package.json b/backend/package.json index a34bf1b5..27dbc217 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,13 +31,12 @@ "ensure-error": "^5.0.0", "fastify": "4.29.1", "framed-stream": "1.0.1", - "polywasm": "0.2.0", "tiny-typed-emitter": "2.1.0" }, "devDependencies": { "@sentry/bundler-plugins": "10.70.0", "@sentry/rollup-plugin": "5.4.0", - "@types/node": "18.19.130", + "@types/node": "24.12.2", "@types/streamx": "2.9.5", "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", @@ -48,6 +47,7 @@ "typescript": "5.9.3" }, "overrides": { + "better-sqlite3": "12.10.0", "require-addon": "1.1.0", "sodium-native": "5.1.0", "sodium-universal": "5.0.1" diff --git a/backend/rolldown.config.ts b/backend/rolldown.config.ts index 5af349a5..3ae41192 100644 --- a/backend/rolldown.config.ts +++ b/backend/rolldown.config.ts @@ -55,62 +55,10 @@ const ANDROID_SOURCEMAPS_MAIN = const IOS_SOURCEMAPS = process.env.SOURCEMAPS_DIR_IOS ?? `${IOS_OUT}-sourcemaps`; -/** - * iOS-only: redirects undici's `require('../llhttp/llhttp_simd-wasm.js')` - * call to the non-SIMD wasm module beside it. polywasm 0.2 doesn't - * implement WASM SIMD (opcode 0xfd) — it compiles the SIMD bytes - * successfully but throws `Unsupported instruction: 0xFD` lazily on - * the first export call, which undici's try/catch around `compile` - * doesn't intercept. Aliasing at bundle time forces the non-SIMD - * path so the SIMD bytes never reach polywasm. - */ -function aliasUndiciSimdWasmPlugin(): Plugin { - return { - name: "alias-undici-simd-wasm", - resolveId(source, importer) { - if ( - source === "../llhttp/llhttp_simd-wasm.js" && - importer && - importer.includes("/undici/lib/dispatcher/") - ) { - return path.resolve(path.dirname(importer), "../llhttp/llhttp-wasm.js"); - } - return null; - }, - }; -} - -/** - * iOS only: redirect `loader.mjs`'s dynamic `import("./index.js")` to - * `index.ios.js` (the polywasm-installing wrapper that re-imports - * `index.js`). Without this, rolldown resolves the literal `./index.js` - * specifier from loader.mjs to the source `index.js` and emits a - * second chunk that bypasses the polywasm install — undici then - * throws `ReferenceError: WebAssembly is not defined` at module-init - * inside the loaded backend. Android resolves `./index.js` to the - * `index.js` entry naturally; the redirect is iOS-specific. - */ -function redirectLoaderIndexToPolywasmEntryPlugin(): Plugin { - return { - name: "redirect-loader-index-to-polywasm-entry", - resolveId(source, importer) { - if ( - source === "./index.js" && - importer && - importer.endsWith("/loader.mjs") - ) { - return path.join(__dirname, "index.ios.js"); - } - return null; - }, - }; -} - /** * Runtime data files copied alongside the rolldown output into the per- - * platform output dir. Identical for Android and iOS: only the bundled - * JS differs (iOS prefixes a polywasm bootstrap and aliases undici's - * SIMD wasm — see `aliasUndiciSimdWasmPlugin` above). + * platform output dir. Identical for Android and iOS; only the bundled JS + * differs, and then only in the `__loadAddon` banner. * * - `package.json`: required by Node's module resolver to set the * unpacked nodejs-project tree's module type. @@ -183,24 +131,13 @@ const sharedInput: Pick = { }; function buildPlugins({ - platform, outDir, debugIdMap, }: { - platform: "android" | "ios"; outDir: string; debugIdMap: Map; }): Plugin[] { return [ - // iOS-only: redirect undici's SIMD llhttp wasm to the non-SIMD - // module so polywasm doesn't trip on opcode 0xfd at runtime. See - // aliasUndiciSimdWasmPlugin above. - ...(platform === "ios" ? [aliasUndiciSimdWasmPlugin()] : []), - // iOS-only: redirect loader.mjs's `import("./index.js")` to the - // polywasm-installing entry so the polyfill is in place before - // undici's module-init `WebAssembly.compile`. See - // redirectLoaderIndexToPolywasmEntryPlugin above. - ...(platform === "ios" ? [redirectLoaderIndexToPolywasmEntryPlugin()] : []), // Native addon loader rewrite is identical for both platforms: // every loader pattern (`bindings`, `node-gyp-build`, `require.addon`) // becomes `__loadAddon(name, version)`. The helper itself differs @@ -246,21 +183,12 @@ function cleanOutputDirPlugin(dir: string): Plugin { // argv, conditionally dynamic-imports `./lib/sentry-init.js` (which // brings in `@sentry/node-core` + `@sentry/opentelemetry` + the // OpenTelemetry SDK) and initialises Sentry, then dynamic-imports -// `./index.mjs` (the platform-appropriate bundle of either -// `index.js` or `index.ios.js`). -const ANDROID_INPUT = { +// `./index.mjs`. +const INPUT = { loader: path.join(__dirname, "loader.mjs"), index: path.join(__dirname, "index.js"), }; -// iOS uses a thin entry that imports `lib/install-polywasm.js` first -// so polywasm replaces the absent `globalThis.WebAssembly` before the -// shared `index.js` (and undici through the maps plugin) is evaluated. -const IOS_INPUT = { - loader: path.join(__dirname, "loader.mjs"), - index: path.join(__dirname, "index.ios.js"), -}; - const sharedOutput: OutputOptions = { format: "esm", sourcemap: true, @@ -272,12 +200,8 @@ const sharedOutput: OutputOptions = { }; /** - * Two outputs from the same source tree: Android and iOS. - * Android gets the full bundle — its nodejs-mobile build permits JIT, so undici - * (and therefore the maps fastify plugin) loads cleanly. iOS uses a wrapper - * entry (`index-ios.js`) that installs polywasm as `globalThis.WebAssembly` - * before the shared `index.js` runs, so undici can compile its non-SIMD - * llhttp wasm under nodejs-mobile's jitless V8. + * Two outputs from the same source tree and the same entries: Android and + * iOS. They differ only in the `__loadAddon` banner. * * Each output's `banner` defines `__loadAddon(name, version)` with the * platform-appropriate `process.dlopen` target — Android does @@ -295,7 +219,7 @@ const iosDebugIds = new Map(); const config: RolldownOptions[] = [ { - input: ANDROID_INPUT, + input: INPUT, ...sharedInput, output: { ...sharedOutput, @@ -306,7 +230,6 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(ANDROID_OUT_MAIN), ...buildPlugins({ - platform: "android", outDir: ANDROID_OUT_MAIN, debugIdMap: androidMainDebugIds, }), @@ -318,7 +241,7 @@ const config: RolldownOptions[] = [ ], }, { - input: IOS_INPUT, + input: INPUT, ...sharedInput, output: { ...sharedOutput, @@ -329,7 +252,6 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(IOS_OUT), ...buildPlugins({ - platform: "ios", outDir: IOS_OUT, debugIdMap: iosDebugIds, }), diff --git a/docs/BUILD.md b/docs/BUILD.md index ac51a014..81539016 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -56,7 +56,7 @@ runtime/process side (how native talks to the backend over sockets) see `0x4000` — required for Android 15's 16 KB page sizes. The iOS xcframework-wrapping step (`xcodebuild -create-xcframework`, -`install_name_tool`, `lipo`) is **darwin-gated**: on Linux CI the script builds +`install_name_tool`) is **darwin-gated**: on Linux CI the script builds the Android artifacts and skips iOS. This is why the npm publish runs on macOS (see [`release.yml`](../.github/workflows/release.yml)) — publishing from Linux would ship an empty `ios/Frameworks/`. @@ -72,10 +72,11 @@ so multiple versions coexist without collision: ``` android/src/main/jniLibs/arm64-v8a/ - libbetter-sqlite3__11.10.0.so - libbetter-sqlite3__12.9.0.so # two majors, side by side + libbetter-sqlite3__12.10.0.so libsodium-native__5.1.0.so - … + … # npm can leave two versions of the + # same addon in the tree; the `__` + # suffix is what lets both ship ``` `android/build.gradle` adds `src/main/jniLibs/` to `jniLibs.srcDirs` (next to @@ -88,8 +89,7 @@ mmap'd at load time rather than extracted. ``` ios/Frameworks/ - better-sqlite3__11.10.0.xcframework - better-sqlite3__12.9.0.xcframework + better-sqlite3__12.10.0.xcframework sodium-native__5.1.0.xcframework … ``` @@ -162,11 +162,18 @@ The native modules, declared in | `simdle-native` | yes | `digidem/simdle-native-nodejs-mobile` | | `sodium-native` | yes | `digidem/sodium-native-nodejs-mobile` | +`better-sqlite3` is the one non-NAPI entry: it links V8's C++ symbols directly, +so a prebuild only loads on the Node ABI it was built against, and its asset +name carries that ABI (`-node-137-` for nodejs-mobile 24). The NAPI six are +ABI-stable and the same artifact serves every Node version. This is also why the +tree pins one `better-sqlite3` through `overrides` — every distinct version is a +separate prebuild to publish, and 11.x doesn't compile against V8 13.6 at all. + `NATIVE_MODULES` records only *which* deps are native (and whether they use the NAPI ABI). **Versions are not listed here** — they're resolved from the installed tree (`npm ls`) at build time, so the source of truth stays -`backend/package.json` + lockfile (via `@comapeo/core` and an `overrides` pin on -`sodium-native`). Bumping a backend dep that pulls a new addon version +`backend/package.json` + lockfile (via `@comapeo/core` and the `overrides` pins). +Bumping a backend dep that pulls a new addon version automatically fetches the matching prebuild on the next `backend:build`; there's no manifest line to forget. The plan's proposed `node-native/modules.json` was not adopted — the in-code constant plus lockfile resolution covers it. diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index 9fc7abf9..859ce92a 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -51,6 +51,19 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { .appendingPathComponent("Frameworks") setenv("NATIVE_LIB_DIR", frameworksDir, 1) + // V8's on-disk code cache. Read while the Environment is created, + // so it has to be an env var rather than `module.enableCompileCache()` + // — that way it also covers `loader.mjs` itself. The backend + // flushes it at `ready`; iOS usually kills the app while suspended, + // so node's exit-hook flush rarely runs. + // + // Caches/ is purgeable and excluded from backup, which is what + // regenerable data wants. TMPDIR and HOME are already set by iOS. + if let compileCacheDir = AppLifecycleDelegate.resolveCompileCacheDir() { + setenv("NODE_COMPILE_CACHE", compileCacheDir, 1) + setenv("NODE_COMPILE_CACHE_PORTABLE", "1", 1) + } + let cStrings = arguments.map { strdup($0)! } defer { cStrings.forEach { free($0) } } @@ -100,6 +113,25 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { #endif } + /// `Library/Caches/comapeo/node-compile-cache`, created on demand. nil if + /// the directory can't be created — node then runs without a code cache, + /// which costs startup time but nothing else. + private static func resolveCompileCacheDir() -> String? { + let fm = FileManager.default + guard let caches = try? fm.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + let dir = caches + .appendingPathComponent("comapeo", isDirectory: true) + .appendingPathComponent("node-compile-cache", isDirectory: true) + guard (try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) != nil + else { return nil } + return dir.path + } + /// App-private writable directory passed as `privateStorageDir`. /// Falls back to NSTemporaryDirectory only if Application Support /// is unavailable — degraded start beats crashing on launch. diff --git a/ios/NodeJSService.swift b/ios/NodeJSService.swift index 157e0800..d39c4411 100644 --- a/ios/NodeJSService.swift +++ b/ios/NodeJSService.swift @@ -618,12 +618,6 @@ class NodeJSService { let completionSem = nodeCompletionSemaphore lock.unlock() - // argv mirrors Android. `--no-experimental-fetch` disables Node's - // built-in fetch + lazy undici: nodejs-mobile iOS runs V8 with - // `--jitless` (App Store), which kills WebAssembly; undici's HTTP/1 - // client calls `WebAssembly.compile` at module init and crashes - // the process. Android keeps the flag for argv parity. - // // Open boot.node-spawn BEFORE buildSentryArgs so the trace flag // forwards node-spawn's span ID — Node-side spans then nest // under it rather than the transaction. Closed by @@ -638,7 +632,7 @@ class NodeJSService { let defaultConfigPath = resolveDefaultConfigPath() ?? "" // 5th positional: consumer's online map style URL, or "" when unset. let defaultOnlineStyleUrl = resolveDefaultOnlineStyleUrl() ?? "" - var args: [String] = ["node", "--no-experimental-fetch"] + var args: [String] = ["node"] args.append(contentsOf: [ jsPath, comapeoSocketPath, diff --git a/ios/NodeMobile.xcframework/Info.plist b/ios/NodeMobile.xcframework/Info.plist index aa327e3a..29a0bbcd 100644 --- a/ios/NodeMobile.xcframework/Info.plist +++ b/ios/NodeMobile.xcframework/Info.plist @@ -8,7 +8,7 @@ BinaryPath NodeMobile.framework/NodeMobile LibraryIdentifier - ios-arm64 + ios-arm64-simulator LibraryPath NodeMobile.framework SupportedArchitectures @@ -17,23 +17,22 @@ SupportedPlatform ios + SupportedPlatformVariant + simulator BinaryPath NodeMobile.framework/NodeMobile LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath NodeMobile.framework SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator CFBundlePackageType diff --git a/ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Headers/NodeMobile.h b/ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Headers/NodeMobile.h similarity index 100% rename from ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Headers/NodeMobile.h rename to ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Headers/NodeMobile.h diff --git a/ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Info.plist b/ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Info.plist similarity index 70% rename from ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Info.plist rename to ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Info.plist index f636d2228aa5f7721565de067f76418e749ba00f..518a3a9ba241b5562370f6c44dbae00a414818ee 100644 GIT binary patch delta 144 zcmbQtK81b5D@GPmHw%-=?-)y*!;FmGEDRIl1^6=yGV=3Mi!*aea}rDPi$aXd^i1Og zgwcdSqChD#gD^vLH$y|?5JQv6OPF?;XEIeYH84$Mn$5I`X*n}HGZ!-tGas`60~jzd NLTCnkC=I2U#Q;HWA&US2 delta 143 zcmbQjKAC;PD@GP$cT3~R?-)y*LX8aF%+2Ek_%jPK^7B%QGjmIG5=-)nLJZCIOydQF z(S$*wA%>d5=(=ui@W=>{qW?p7~1~6b^ OgwPE7P#Q`xivj=@93e^o diff --git a/ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Modules/module.modulemap b/ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Modules/module.modulemap similarity index 100% rename from ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/Modules/module.modulemap rename to ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/Modules/module.modulemap diff --git a/ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/_CodeSignature/CodeResources b/ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/_CodeSignature/CodeResources similarity index 98% rename from ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/_CodeSignature/CodeResources rename to ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/_CodeSignature/CodeResources index 466e09a2..5837d425 100644 --- a/ios/NodeMobile.xcframework/ios-arm64_x86_64-simulator/NodeMobile.framework/_CodeSignature/CodeResources +++ b/ios/NodeMobile.xcframework/ios-arm64-simulator/NodeMobile.framework/_CodeSignature/CodeResources @@ -10,7 +10,7 @@ Info.plist - JdtDpBBsAZoFmxiD9J9HiJbf/c4= + qW710eFzEyg8jtSM1RI2sUF+FQ4= Modules/module.modulemap diff --git a/ios/NodeMobile.xcframework/ios-arm64/NodeMobile.framework/Info.plist b/ios/NodeMobile.xcframework/ios-arm64/NodeMobile.framework/Info.plist index 1b858e78926c20e77d4c6fb58343465cb0db29ed..6c279088d50a9a5473978ea457d43e51effbffa6 100644 GIT binary patch delta 124 zcmeyx_LFVH3r4n3BU3jElgaNHvkk+HjNL2@6C*MUGV=3M^NT}_%=AoSU_1~9C}L(1 zW@zqaXlNW_XfnB*X_4+b#*d7Dn53BGn3S2+nKYSXnev#bnHrednR=Ms TFuiB`$n=@%>*OeAdB)!WtlK6v delta 125 zcmey#_KR)93r6-ZBO?z(Bjd^M7_$vSjSQVljUqA&GV=3M^NT|a&Gn38U_1~f#L(2p zAl%T@+0?)wDa6otau?Gg-S>>282>U!Gs!clFljJpF&Q(NGDR^ZFl933F;z1)Fm*8X VGQDN`!1Rgf3)8pBk<9Xpe*n9yCRhLf diff --git a/package-lock.json b/package-lock.json index de2e75fa..25910cdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@comapeo/ipc": "9.0.1" }, "bin": { + "comapeo-rn-symbolicate": "build/cli/symbolicate.js", "comapeo-rn-upload-sourcemaps": "build/cli/upload-sourcemaps.js" }, "devDependencies": { diff --git a/scripts/download-nodejs-mobile.sh b/scripts/download-nodejs-mobile.sh index eb059a70..a8808149 100755 --- a/scripts/download-nodejs-mobile.sh +++ b/scripts/download-nodejs-mobile.sh @@ -10,8 +10,8 @@ set -euo pipefail # ./scripts/download-nodejs-mobile.sh # both platforms # ./scripts/download-nodejs-mobile.sh --platform android # Android only # ./scripts/download-nodejs-mobile.sh --platform ios # iOS only -# ./scripts/download-nodejs-mobile.sh --platform all v18.20.4 # explicit version -# NODEJS_MOBILE_VERSION=v18.20.4 ./scripts/... # via env var +# ./scripts/download-nodejs-mobile.sh --platform all v24.19.0-0 # explicit version +# NODEJS_MOBILE_VERSION=v24.19.0-0 ./scripts/... # via env var SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -25,15 +25,14 @@ while [[ $# -gt 0 ]]; do esac done -VERSION="${VERSION:-${NODEJS_MOBILE_VERSION:-v18.20.4}}" +VERSION="${VERSION:-${NODEJS_MOBILE_VERSION:-v24.19.0-0}}" TAG="$VERSION" [[ "$TAG" != v* ]] && TAG="v$TAG" FILE_VERSION="${TAG#v}" -# Fork that ships 16 KB-aligned libnode for Android 15+ 16 KB-page -# devices. Tracks upstream nodejs-mobile/nodejs-mobile and adds the -# `-Wl,-z,max-page-size=16384` flag (upstream PR -# nodejs-mobile/nodejs-mobile#155). Switch back to upstream once -# that PR is merged + released. +# Fork carrying the mobile patch series on top of an upstream Node +# release. Releases are tagged `v-`; the +# revision is readable at runtime as `process.versions.mobile` while +# `process.version` stays upstream's. BASE_URL="https://github.com/digidem/nodejs-mobile/releases/download/${TAG}" # Short fingerprint of BASE_URL so swapping the source repo # invalidates both the `/tmp` zip cache and the per-target marker. @@ -78,7 +77,7 @@ download() { echo "==> $name: downloading $TAG from $BASE_URL" curl -fSL --retry 3 --retry-delay 5 \ -o "$zip" \ - "${BASE_URL}/nodejs-mobile-v${FILE_VERSION}-${name}.zip" + "${BASE_URL}/nodejs-mobile-${name}-${FILE_VERSION}.zip" fi rm -rf "$target" diff --git a/scripts/lib/ios-frameworks.ts b/scripts/lib/ios-frameworks.ts index 7a1563b7..e2fab3c2 100644 --- a/scripts/lib/ios-frameworks.ts +++ b/scripts/lib/ios-frameworks.ts @@ -19,13 +19,11 @@ const $$ = $({ stdio: "inherit" }); * Mach-O exec inside //.framework/, rewrite * its install name with `install_name_tool -id @rpath/...`, and * write a minimal Info.plist next to it. - * 2. lipo the two simulator binaries into one fat Mach-O so a single - * simulator slice covers both Apple Silicon and Intel hosts. - * 3. xcodebuild -create-xcframework with the device framework and - * the lipo'd simulator framework → /.xcframework. + * 2. xcodebuild -create-xcframework with the device and simulator + * frameworks → /.xcframework. * - * `xcodebuild`, `lipo`, and `install_name_tool` are macOS-only Xcode - * command-line tools, so callers must gate on `process.platform`. + * `xcodebuild` and `install_name_tool` are macOS-only Xcode command-line + * tools, so callers must gate on `process.platform`. */ export async function packageIosFrameworks({ pairs, @@ -58,36 +56,19 @@ export async function packageIosFrameworks({ const buildPerArchFramework = async (arch: string, srcNode: string) => buildFramework({ instanceKey, moduleWorkDir, arch, srcNode }); - const [deviceNode, armSimNode, x64SimNode] = await Promise.all([ + const [deviceNode, simNode] = await Promise.all([ findNodeForArch(prebuildsDir, name, version, "ios-arm64"), findNodeForArch(prebuildsDir, name, version, "ios-arm64-simulator"), - findNodeForArch(prebuildsDir, name, version, "ios-x64-simulator"), ]); const deviceFramework = await buildPerArchFramework("arm64", deviceNode); - const armSimFramework = await buildPerArchFramework( + const simFramework = await buildPerArchFramework( "arm64-simulator", - armSimNode, + simNode, ); - const x64SimFramework = await buildPerArchFramework( - "x64-simulator", - x64SimNode, - ); - - // lipo the two simulator slices into one fat Mach-O. - const simFatDir = join(moduleWorkDir, "simulator"); - const simFatFramework = join(simFatDir, `${instanceKey}.framework`); - mkdirSync(simFatFramework, { recursive: true }); - writeFileSync( - join(simFatFramework, "Info.plist"), - buildFrameworkPlist(instanceKey), - ); - await $({ - stdio: "inherit", - })`lipo -create ${join(armSimFramework, instanceKey)} ${join(x64SimFramework, instanceKey)} -output ${join(simFatFramework, instanceKey)}`; const xcframeworkPath = join(frameworksDir, `${instanceKey}.xcframework`); - await $$`xcodebuild -create-xcframework -framework ${deviceFramework} -framework ${simFatFramework} -output ${xcframeworkPath}`; + await $$`xcodebuild -create-xcframework -framework ${deviceFramework} -framework ${simFramework} -output ${xcframeworkPath}`; }), ); diff --git a/scripts/lib/node-versions.ts b/scripts/lib/node-versions.ts index d02a3db1..0db160b9 100644 --- a/scripts/lib/node-versions.ts +++ b/scripts/lib/node-versions.ts @@ -18,10 +18,12 @@ export function readNodeJsMobileVersions() { const content = readFileSync(nodeVersionFilePath, "utf-8"); - const major = content.match(/#define NODE_MAJOR_VERSION (.+)/)?.[1]; - const minor = content.match(/#define NODE_MINOR_VERSION (.+)/)?.[1]; - const patch = content.match(/#define NODE_PATCH_VERSION (.+)/)?.[1]; - const abi = content.match(/#define NODE_MODULE_VERSION (.+)/)?.[1]; + // Digits only: Node 24's header defines NODE_MODULE_VERSION twice, and the + // first is the `NODE_EMBEDDER_MODULE_VERSION` passthrough. + const major = content.match(/#define NODE_MAJOR_VERSION (\d+)/)?.[1]; + const minor = content.match(/#define NODE_MINOR_VERSION (\d+)/)?.[1]; + const patch = content.match(/#define NODE_PATCH_VERSION (\d+)/)?.[1]; + const abi = content.match(/#define NODE_MODULE_VERSION (\d+)/)?.[1]; return { major, minor, patch, abi }; } diff --git a/scripts/lib/prebuilds.ts b/scripts/lib/prebuilds.ts index 52125d74..62caa5e3 100644 --- a/scripts/lib/prebuilds.ts +++ b/scripts/lib/prebuilds.ts @@ -9,10 +9,12 @@ import type { NativePair } from "./native-modules.ts"; type PrebuildTarget = { platform: "android" | "ios"; arch: string }; export const ANDROID_ARCHS = ["arm", "arm64", "x64"] as const; -// Phase 2: device + both simulator slices. xcframework packaging combines -// them into one multi-slice artifact per addon — Xcode picks the right -// slice at app build time based on the build destination. -export const IOS_ARCHS = ["arm64", "arm64-simulator", "x64-simulator"] as const; +// Device + simulator. xcframework packaging combines them into one +// multi-slice artifact per addon — Xcode picks the right slice at app build +// time based on the build destination. No x86_64 simulator: nodejs-mobile 24 +// ships no x86_64 slice for an addon to link against, so Intel Macs can't run +// the simulator regardless. +export const IOS_ARCHS = ["arm64", "arm64-simulator"] as const; const PREBUILD_TARGETS: PrebuildTarget[] = [ ...ANDROID_ARCHS.map((arch) => ({ platform: "android" as const, arch })), From 3e9efbcc83ebb901cd53e3dc1e9abac2a597f0ef Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 17 Aug 2026 18:14:11 +0100 Subject: [PATCH 02/23] perf: fetch the lite nodejs-mobile flavour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lite` drops ICU, the inspector, `node:sqlite` and TypeScript type-stripping, and on iOS also V8's compiled tiers — dead weight there, since it runs jitless and serves WebAssembly through the bundled polyfill. None of it is a loss. The v18 build we came from reports 0 ICU symbols and no `icudt` data, i.e. it was already `--with-intl=none`, so `full` would *add* an `Intl` this backend has never had; the two `Intl` references that survive bundling are a Sentry helper that returns early unless `process.versions.icu` is set, and mapbox style-spec expression evaluators whose exposure is unchanged from v18. We use the `better-sqlite3` addon rather than `node:sqlite`, ship plain JS, and every `node:inspector` reference in the bundle is an `await import()` inside a try/catch or an opt-in Sentry integration. Addon prebuilds are flavour-neutral: both flavours ship byte-identical headers and export the same V8 symbol set, and every symbol our better-sqlite3 prebuild imports resolves against the lite `libnode`. Sizes against the v18 build this replaces, rather than against full: android arm64 61 -> 65 MB android armv7a 57 -> 59 MB android x86_64 63 -> 64 MB ios device slice 53 -> 44 MB --- CONTRIBUTING.md | 6 +++++- backend/lib/compile-cache.js | 24 +++++++----------------- scripts/download-nodejs-mobile.sh | 23 ++++++++++++++++++++--- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5027d20..67cb5bac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,11 @@ npm run setup # fetch nodejs-mobile, build the backend, install the test ap `npm run setup` runs, in order: - `download:nodejs-mobile` — pulls `NodeMobile.xcframework` (iOS) and `libnode.so` - per ABI plus headers (Android) into place; these are not committed. + per ABI plus headers (Android) into place; these are not committed. It fetches + the `lite` runtime flavour, which drops ICU, the inspector, `node:sqlite` and + TypeScript type-stripping — none of which we use. `NODEJS_MOBILE_FLAVOR=full` + fetches the full one; the two ship identical headers, so addon prebuilds work + against either. - `backend:build` — bundles the Node.js backend (`backend/`) that gets embedded in the app. `npm install` alone does **not** build it. - installs dependencies for the two test apps (`apps/integration`, `apps/e2e`). diff --git a/backend/lib/compile-cache.js b/backend/lib/compile-cache.js index 4a4ff084..9092bfa9 100644 --- a/backend/lib/compile-cache.js +++ b/backend/lib/compile-cache.js @@ -1,28 +1,18 @@ import { flushCompileCache, getCompileCacheDir } from "node:module"; /** - * Persist V8's code cache for everything compiled so far. + * Persist V8's code cache for everything compiled so far. No-op unless native + * set `NODE_COMPILE_CACHE`. * - * The cache itself is enabled by native through `NODE_COMPILE_CACHE` - * rather than `module.enableCompileCache()`, because the env var is read - * when the Environment is created — early enough to cover `loader.mjs` - * and the Sentry chunk, which are the two biggest compiles on the boot - * path and both run before any JS of ours could call the runtime API. - * - * Node's own flush runs from an `exit` handler, which on mobile is the - * one moment we can't count on: Android's low-memory killer takes the - * `:ComapeoCore` process outright, and iOS usually kills the app while - * suspended without ever reaching `applicationWillTerminate`. A cache - * that only lands on a clean exit would rarely land at all, so flush at - * `ready` instead — by then every module on the boot path, which is the - * set worth caching, has been compiled. - * - * No-ops when the env var is unset. + * Flushed here rather than left to node's `exit` handler: the low-memory killer + * and iOS's suspended-app kill both skip that hook, so the cache would rarely + * be written at all. By `ready` the whole boot path — the set worth caching — + * has compiled. */ export function flushCompileCacheAfterBoot() { const dir = getCompileCacheDir(); if (!dir) return; - // After the `ready` frame is on the wire — the flush is synchronous. + // The flush is synchronous; let the `ready` frame reach the wire first. setImmediate(() => { flushCompileCache(); console.log(`Compile cache flushed to ${dir}`); diff --git a/scripts/download-nodejs-mobile.sh b/scripts/download-nodejs-mobile.sh index a8808149..b47184f2 100755 --- a/scripts/download-nodejs-mobile.sh +++ b/scripts/download-nodejs-mobile.sh @@ -12,6 +12,7 @@ set -euo pipefail # ./scripts/download-nodejs-mobile.sh --platform ios # iOS only # ./scripts/download-nodejs-mobile.sh --platform all v24.19.0-0 # explicit version # NODEJS_MOBILE_VERSION=v24.19.0-0 ./scripts/... # via env var +# NODEJS_MOBILE_FLAVOR=full ./scripts/... # full runtime SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -34,12 +35,28 @@ FILE_VERSION="${TAG#v}" # revision is readable at runtime as `process.versions.mobile` while # `process.version` stays upstream's. BASE_URL="https://github.com/digidem/nodejs-mobile/releases/download/${TAG}" -# Short fingerprint of BASE_URL so swapping the source repo + +# `lite` drops ICU, the inspector, `node:sqlite` and TypeScript +# type-stripping, and on iOS also V8's compiled tiers (dead there — it runs +# jitless). None of that is a loss for us: the v18 build we came from was +# already `--with-intl=none`, so `full` would *add* an `Intl` we have never +# had; we use `better-sqlite3` rather than `node:sqlite`; and we ship plain +# JS. It saves ~16 MB per Android ABI and ~27 MB on the iOS device slice. +# Addon prebuilds are flavor-neutral — both flavors ship identical headers +# and export the same V8 symbol set. +FLAVOR="${NODEJS_MOBILE_FLAVOR:-lite}" +case "$FLAVOR" in + lite) ASSET_FLAVOR="-lite" ;; + full) ASSET_FLAVOR="" ;; + *) echo "Error: NODEJS_MOBILE_FLAVOR must be 'lite' or 'full', got '$FLAVOR'" >&2; exit 1 ;; +esac + +# Short fingerprint of the source URL + flavor so swapping either # invalidates both the `/tmp` zip cache and the per-target marker. # Same TAG from a different repo would otherwise silently reuse a # cached zip from the wrong source (this exact bug bit CI when we # pointed BASE_URL at the digidem fork without bumping the tag). -SOURCE_HASH="$(printf '%s' "$BASE_URL" | shasum | cut -c1-8)" +SOURCE_HASH="$(printf '%s' "${BASE_URL}${ASSET_FLAVOR}" | shasum | cut -c1-8)" # Downloads and extracts a release zip into the project tree. # @@ -77,7 +94,7 @@ download() { echo "==> $name: downloading $TAG from $BASE_URL" curl -fSL --retry 3 --retry-delay 5 \ -o "$zip" \ - "${BASE_URL}/nodejs-mobile-${name}-${FILE_VERSION}.zip" + "${BASE_URL}/nodejs-mobile-${name}${ASSET_FLAVOR}-${FILE_VERSION}.zip" fi rm -rf "$target" From 0903660696e20043a6753a0e75f59b9a6511ce95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:54:49 +0000 Subject: [PATCH 03/23] chore(deps-dev): bump rolldown Bumps the minor-and-patch group with 1 update in the /backend directory: [rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown). Updates `rolldown` from 1.2.3 to 1.2.4 - [Release notes](https://github.com/rolldown/rolldown/releases) - [Changelog](https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md) - [Commits](https://github.com/rolldown/rolldown/commits/v1.2.4/packages/rolldown) --- updated-dependencies: - dependency-name: rolldown dependency-version: 1.2.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] --- backend/package-lock.json | 128 +++++++++++++++++++------------------- backend/package.json | 2 +- 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 4f8cf22b..a6f4dff6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,7 +38,7 @@ "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", "patch-package": "8.0.1", - "rolldown": "1.2.3", + "rolldown": "1.2.4", "tar-stream": "3.2.0", "type-fest": "5.8.0", "typescript": "5.9.3" @@ -1768,9 +1768,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.143.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", - "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -1860,9 +1860,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", - "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -1877,9 +1877,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -1894,9 +1894,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -1911,9 +1911,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", - "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -1928,9 +1928,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", - "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -1945,9 +1945,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", - "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -1965,9 +1965,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", - "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -1985,9 +1985,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", - "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -2005,9 +2005,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", - "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -2025,9 +2025,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", - "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -2045,9 +2045,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", - "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -2065,9 +2065,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", - "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -2082,9 +2082,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", - "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -2099,9 +2099,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", - "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -6869,13 +6869,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", - "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.143.0", + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6885,20 +6885,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.3", - "@rolldown/binding-darwin-arm64": "1.2.3", - "@rolldown/binding-darwin-x64": "1.2.3", - "@rolldown/binding-freebsd-x64": "1.2.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", - "@rolldown/binding-linux-arm64-gnu": "1.2.3", - "@rolldown/binding-linux-arm64-musl": "1.2.3", - "@rolldown/binding-linux-ppc64-gnu": "1.2.3", - "@rolldown/binding-linux-s390x-gnu": "1.2.3", - "@rolldown/binding-linux-x64-gnu": "1.2.3", - "@rolldown/binding-linux-x64-musl": "1.2.3", - "@rolldown/binding-openharmony-arm64": "1.2.3", - "@rolldown/binding-win32-arm64-msvc": "1.2.3", - "@rolldown/binding-win32-x64-msvc": "1.2.3" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/rpc-reflector": { diff --git a/backend/package.json b/backend/package.json index a34bf1b5..b73ccb7a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,7 +42,7 @@ "@types/tar-stream": "3.1.4", "magic-string": "0.30.21", "patch-package": "8.0.1", - "rolldown": "1.2.3", + "rolldown": "1.2.4", "tar-stream": "3.2.0", "type-fest": "5.8.0", "typescript": "5.9.3" From 05b643f73e659a0e9646601bfd04bdb66fafc271 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 00:19:39 +0100 Subject: [PATCH 04/23] fix: move better-sqlite3 to 13.x so it stops aborting on device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12.10.0 built against Node 24 crashes on real hardware. The backend reaches ready and aborts ~86ms later, on every boot: # node[2591]: node::RemoveEnvironmentCleanupHook(...) at ../src/api/hooks.cc:142 # Assertion failed: (env) != nullptr Fatal signal 6 (SIGABRT) Everything downstream in the e2e run is fallout — RPC_TIMEOUT, socket connect timeouts, `device-id` never rendering. `libbetter-sqlite3__12.10.0.so` was the only shipped addon importing `Add`/`RemoveEnvironmentCleanupHook`, the raw V8 embedder API; the other six are N-API and import none of it. 13.0.0 migrated to N-API. The shipped binary now has zero V8 symbols and zero cleanup-hook references, so the crash class is gone by construction rather than patched. It also stops being ABI-locked: one artifact serves every Node version, which is why `usesNapi` flips and the prebuild URL loses its `-node-` infix. 13.x dropped `bindings` for its own `lib/binding.js`, which knows only linux/darwin/win32 prebuild paths before falling back to node-gyp build dirs — none of which exist on device — so the addon-loader gains a pattern for it. Because every pattern here matches upstream source text, a miss would silently ship a resolver that only fails on a device; the plugin now fails the build instead when that specific resolver goes unrewritten. Verified: 11/11 Android lifecycle tests and 8/8 iOS XCTest, 12 clean boots, zero occurrences of the crash signature. --- backend/package-lock.json | 339 +----------------- backend/package.json | 2 +- .../rollup-plugin-addon-loader.js | 42 ++- docs/BUILD.md | 26 +- scripts/lib/native-modules.ts | 4 +- 5 files changed, 61 insertions(+), 352 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index dc870778..a47f0a9a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -3236,17 +3236,15 @@ } }, "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + "node": ">=22" } }, "node_modules/big-sparse-array": { @@ -3264,40 +3262,6 @@ "b4a": "^1.3.1" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bogon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.2.0.tgz", @@ -3522,12 +3486,6 @@ "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -3951,30 +3909,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -4070,15 +4004,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -4339,15 +4264,6 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/ensure-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ensure-error/-/ensure-error-5.0.0.tgz", @@ -4451,15 +4367,6 @@ "bare-events": "^2.7.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-0.15.0.tgz", @@ -4600,12 +4507,6 @@ "reusify": "^1.0.4" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4713,12 +4614,6 @@ "streamx": "^2.13.0" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -4813,12 +4708,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5126,12 +5015,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/into-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-8.0.1.tgz", @@ -5704,18 +5587,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -5764,12 +5635,6 @@ "unix-path-resolve": "^1.0.2" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -5836,22 +5701,13 @@ "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==", "license": "ISC" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", - "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-fetch": { @@ -5946,15 +5802,6 @@ "node": ">=14.0.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -6461,33 +6308,6 @@ ], "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -6619,16 +6439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/queue-tick": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", @@ -6725,21 +6535,6 @@ "integrity": "sha512-u7xCM93XqKEvPTP6xZp2ehttcAemKnh73oKNf1FvzuVCfpt6dILDt1Kxl1LeBjm2iNIeR49VGFhy4Iz3yOun+Q==", "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/read-write-mutexify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/read-write-mutexify/-/read-write-mutexify-2.1.0.tgz", @@ -7249,51 +7044,6 @@ "simdle-native": "^1.1.1" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-invariant": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", @@ -7548,15 +7298,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/styled-map-package": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/styled-map-package/-/styled-map-package-3.0.0.tgz", @@ -7745,48 +7486,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar-fs/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar-stream": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", @@ -7918,18 +7617,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", @@ -8221,12 +7908,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/backend/package.json b/backend/package.json index 27dbc217..acb37b8c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,7 +47,7 @@ "typescript": "5.9.3" }, "overrides": { - "better-sqlite3": "12.10.0", + "better-sqlite3": "13.0.3", "require-addon": "1.1.0", "sodium-native": "5.1.0", "sodium-universal": "5.0.1" diff --git a/backend/rollup-plugins/rollup-plugin-addon-loader.js b/backend/rollup-plugins/rollup-plugin-addon-loader.js index cf08d0f0..9af92925 100644 --- a/backend/rollup-plugins/rollup-plugin-addon-loader.js +++ b/backend/rollup-plugins/rollup-plugin-addon-loader.js @@ -24,14 +24,15 @@ import path from "node:path"; * from the package.json that owns the file being transformed — not a * hand-maintained map. * - * Better-sqlite3 specifically: its `database.js` does - * `require('bindings')('better_sqlite3.node')` lazily, on first - * `new Database(...)` call. The rewrite catches that callsite at - * bundle time so when the lazy initialization runs at runtime, it - * loads our prebuilt addon via `__loadAddon('better-sqlite3', '')` - * — the underscore-vs-hyphen mismatch (`better_sqlite3.node` filename - * vs. `better-sqlite3` package name) becomes moot because the rewrite - * replaces the call entirely. + * Better-sqlite3 specifically: up to 12.x it resolved through + * `require('bindings')('better_sqlite3.node')`, lazily on the first + * `new Database(...)`. From 13.x it uses its own `lib/binding.js`, + * which knows only linux/darwin/win32 prebuild paths before falling + * back to node-gyp build dirs. Both shapes are rewritten to + * `__loadAddon('better-sqlite3', '')`, which also makes the + * underscore-vs-hyphen mismatch (`better_sqlite3.node` filename vs. + * `better-sqlite3` package name) moot. A miss on the 13.x resolver is + * a build error rather than a device-only failure — see `transform`. * * @returns {import('rolldown').Plugin} */ @@ -52,6 +53,15 @@ export default function addonLoaderPlugin() { pattern: /require\.addon\(['"]\.['"],\s+__filename\)/g, replacement: (n, v) => `__loadAddon('${n}', '${v}')`, }, + { + // better-sqlite3 >= 13 dropped `bindings` for its own resolver in + // `lib/binding.js`, which knows only linux/darwin/win32 prebuild paths + // and then falls back to node-gyp build dirs — none of which exist on + // device. Short-circuit it at the head of that fallback; the + // caller-supplied `nativeBinding` branches above it still work. + pattern: /let filename = getPrebuildPath\(\);/g, + replacement: (n, v) => `return DEFAULT_ADDON = __loadAddon('${n}', '${v}');`, + }, ]; // Per-directory cache for `readContainingPackage` lookups. The walk @@ -80,6 +90,22 @@ export default function addonLoaderPlugin() { for (const { pattern, replacement } of replacements) { magicString.replaceAll(pattern, replacement(name, version)); } + + // Every pattern here matches upstream source text, so an upstream + // reshuffle turns a rewrite into a silent no-op — and the bundle then + // ships a resolver that only fails once it's on a device. Fail the + // build instead for the resolver we know we must intercept. + if ( + id.replace(/\\/g, "/").endsWith("better-sqlite3/lib/binding.js") && + !magicString.hasChanged() + ) { + throw new Error( + `${id}: no addon-loader rewrite applied. better-sqlite3's binding ` + + `resolver changed shape; update the patterns in ` + + `rollup-plugin-addon-loader.js.`, + ); + } + if (!magicString.hasChanged()) return null; return { diff --git a/docs/BUILD.md b/docs/BUILD.md index 81539016..6e9465c7 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -16,8 +16,7 @@ runtime/process side (how native talks to the backend over sockets) see `jniLibs//lib__.so` on Android, `__.xcframework` on iOS. The `.node` files never touch the device filesystem at runtime. - Filenames are **versioned** (`name__version`, double-underscore separator) so - two majors of the same module can coexist — and they do today - (`better-sqlite3` 11 and 12 ship side by side). + two majors of the same module can coexist if npm can't dedupe them. - A rollup plugin rewrites every addon `require(...)` to a generated `__loadAddon(name, version)` helper that `process.dlopen`s the right file. No runtime monkey-patching; the resolution is baked at build time. @@ -72,7 +71,7 @@ so multiple versions coexist without collision: ``` android/src/main/jniLibs/arm64-v8a/ - libbetter-sqlite3__12.10.0.so + libbetter-sqlite3__13.0.3.so libsodium-native__5.1.0.so … # npm can leave two versions of the # same addon in the tree; the `__` @@ -89,7 +88,7 @@ mmap'd at load time rather than extracted. ``` ios/Frameworks/ - better-sqlite3__12.10.0.xcframework + better-sqlite3__13.0.3.xcframework sodium-native__5.1.0.xcframework … ``` @@ -113,12 +112,12 @@ the app bundle (iOS doesn't extract JS the way Android does). The backend's source calls addons through the usual loader shims (`require('node-gyp-build')(__dirname)`, `require('bindings')(...)`, -`require.addon(...)`). At bundle time, `backend/rollup-plugins/rollup-plugin-addon-loader.js` +`require.addon(...)`, and better-sqlite3 13's own `lib/binding.js` resolver). At bundle time, `backend/rollup-plugins/rollup-plugin-addon-loader.js` rewrites each of those call sites to a generated helper: ```js require('node-gyp-build')(__dirname) → __loadAddon('sodium-native', '5.1.0') -require('bindings')({ bindings: '…' }) → __loadAddon('better-sqlite3', '12.9.0') +let filename = getPrebuildPath() → __loadAddon('better-sqlite3', '13.0.3') require.addon('.', __filename) → __loadAddon('fs-native-extensions', '1.5.0') ``` @@ -154,7 +153,7 @@ The native modules, declared in | Module | NAPI? | Prebuild source | |---|---|---| -| `better-sqlite3` | no | `digidem/better-sqlite3-nodejs-mobile` | +| `better-sqlite3` | yes | `digidem/better-sqlite3-nodejs-mobile` | | `crc-native` | yes | `digidem/crc-native-nodejs-mobile` | | `fs-native-extensions` | yes | `digidem/fs-native-extensions-nodejs-mobile` | | `quickbit-native` | yes | `digidem/quickbit-native-nodejs-mobile` | @@ -162,12 +161,13 @@ The native modules, declared in | `simdle-native` | yes | `digidem/simdle-native-nodejs-mobile` | | `sodium-native` | yes | `digidem/sodium-native-nodejs-mobile` | -`better-sqlite3` is the one non-NAPI entry: it links V8's C++ symbols directly, -so a prebuild only loads on the Node ABI it was built against, and its asset -name carries that ABI (`-node-137-` for nodejs-mobile 24). The NAPI six are -ABI-stable and the same artifact serves every Node version. This is also why the -tree pins one `better-sqlite3` through `overrides` — every distinct version is a -separate prebuild to publish, and 11.x doesn't compile against V8 13.6 at all. +All seven are NAPI, so one artifact per (module, platform, arch) serves every +Node version. `better-sqlite3` only joined them at 13.0.0: up to 12.x it linked +V8's C++ symbols directly, which meant a prebuild per Node ABI (asset names +carried a `-node--` infix) — and, on Node 24, an abort inside +`node::RemoveEnvironmentCleanupHook` on real devices shortly after boot. The +tree still pins one `better-sqlite3` through `overrides`, since `@comapeo/core` +asks for `^11.10.0` and 11.x doesn't compile against V8 13.6 at all. `NATIVE_MODULES` records only *which* deps are native (and whether they use the NAPI ABI). **Versions are not listed here** — they're resolved from the installed diff --git a/scripts/lib/native-modules.ts b/scripts/lib/native-modules.ts index fec75454..05083229 100644 --- a/scripts/lib/native-modules.ts +++ b/scripts/lib/native-modules.ts @@ -11,7 +11,9 @@ export type NativeModule = { name: string; usesNapi: boolean }; * `---.tar.gz` (NAPI) */ export const NATIVE_MODULES: readonly NativeModule[] = [ - { name: "better-sqlite3", usesNapi: false }, + // N-API since 13.0.0; earlier majors were raw V8 and needed a prebuild per + // Node ABI. + { name: "better-sqlite3", usesNapi: true }, // Native module seems may cause issues on some devices. If so, exclude from list to use JS version. // https://github.com/digidem/comapeo-mobile/issues/1096 { name: "crc-native", usesNapi: true }, From 60764449493fec4dce10d0a362ded777ba75efb3 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 07:25:21 +0100 Subject: [PATCH 05/23] fix(ios): keep aliasing undici's SIMD llhttp away from polywasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing this alias alongside the polywasm/fetch installers was wrong. Those installers are genuinely redundant now — the runtime supplies `WebAssembly` and `fetch` — but the alias targets a different thing: the npm `undici@6` this backend bundles, not Node's built-in undici. nodejs-mobile 24's bootstrap sets `UNDICI_NO_WASM_SIMD=1` to steer undici off the SIMD build of llhttp, but that env var is an undici 7.x feature, so the bundled 6.23.0 copy ignores it and calls `WebAssembly.compile(llhttp_simd-wasm)` unconditionally. polywasm compiles function bodies lazily, so that compile succeeds and then throws `Unsupported instruction: 0xFD` on the first parser callback — past the try/catch undici wraps the compile in. `@comapeo/core`'s maps plugin and `secret-stream-http` (via `@comapeo/map-server`) both import `fetch` from that bundled copy, so this covers online map styles and peer blob/SMP fetches. Verified: the SIMD payload is present in both bundles without the alias, and absent from the iOS bundle with it. The e2e suite could not have caught this — its map-server tests use the built-in fallback map only, with no project, no uploaded SMP and no network. Also from review: don't set TMPDIR/NODE_COMPILE_CACHE to a directory we failed to create (and check `isDirectory`, since `mkdirs()` returns false when the directory already exists); correct the JNI comment, which claimed TMPDIR must be set before startup — only NODE_COMPILE_CACHE must; drop stale `lipo` and non-NAPI references. --- .../java/com/comapeo/core/NodeJSService.kt | 33 ++++++++++---- backend/rolldown.config.ts | 44 ++++++++++++++++++- scripts/build-backend.ts | 11 ++--- scripts/lib/ios-frameworks.ts | 2 +- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index 92a015c1..a01ca7ef 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -358,8 +358,11 @@ class NodeJSService( /** * Environment node inherits from this process. Must run before - * [startNodeWithArguments] — node reads all of these while creating the - * Environment, so assigning `process.env` from JS would be too late. + * [startNodeWithArguments]: `NODE_COMPILE_CACHE` is read while the + * Environment is created, so setting it from JS would be too late. (`TMPDIR` + * would in fact survive a later `process.env` assignment — that routes + * through `uv_os_setenv`, so even native `getenv` callers see it — but it is + * set here so it holds for anything reading it before our JS runs.) * * An Android app process has no `TMPDIR` and there is no `/tmp`, which * `os.tmpdir()` otherwise falls back to; anything writing there fails with @@ -367,17 +370,29 @@ class NodeJSService( * flushes it once boot reaches `ready` rather than leaving it to node's * exit hook, which the low-memory killer routinely denies us. * - * Both live under `cacheDir`, so the OS may reclaim them under storage - * pressure — the right semantics for scratch and regenerable data. + * Both live under `cacheDir`: regenerable, and reclaimable under storage + * pressure. A variable is left unset rather than pointed at a directory we + * failed to create — node falling back to its own default beats handing it + * a path that will ENOENT on first use. */ private fun applyNodeEnvironment() { + // `mkdirs()` returns false when the directory already exists, so the + // result to trust is `isDirectory`, not the return value. + fun ensureDir(dir: File): Boolean { + dir.mkdirs() + if (dir.isDirectory) return true + Log.w(TAG, "could not create ${dir.absolutePath}; leaving its env var unset") + return false + } + val tmpDir = File(cacheDir, "tmp") + if (ensureDir(tmpDir)) setEnv("TMPDIR", tmpDir.absolutePath) + val compileCacheDir = File(cacheDir, "node-compile-cache") - tmpDir.mkdirs() - compileCacheDir.mkdirs() - setEnv("TMPDIR", tmpDir.absolutePath) - setEnv("NODE_COMPILE_CACHE", compileCacheDir.absolutePath) - setEnv("NODE_COMPILE_CACHE_PORTABLE", "1") + if (ensureDir(compileCacheDir)) { + setEnv("NODE_COMPILE_CACHE", compileCacheDir.absolutePath) + setEnv("NODE_COMPILE_CACHE_PORTABLE", "1") + } } /** Positionals are read by backend/index.js; `--sentry*` flags by backend/loader.mjs. */ diff --git a/backend/rolldown.config.ts b/backend/rolldown.config.ts index 3ae41192..4f6a2d44 100644 --- a/backend/rolldown.config.ts +++ b/backend/rolldown.config.ts @@ -55,10 +55,44 @@ const ANDROID_SOURCEMAPS_MAIN = const IOS_SOURCEMAPS = process.env.SOURCEMAPS_DIR_IOS ?? `${IOS_OUT}-sourcemaps`; +/** + * iOS-only: redirect undici's `require('../llhttp/llhttp_simd-wasm.js')` to the + * non-SIMD module beside it. + * + * nodejs-mobile 24 does serve `WebAssembly` from a bundled polywasm on iOS, and + * its bootstrap sets `UNDICI_NO_WASM_SIMD=1` to steer undici off the SIMD build + * — but that env var is an undici 7.x feature, so it only reaches Node's + * *built-in* undici. We bundle npm `undici@6`, which ignores it and calls + * `WebAssembly.compile(llhttp_simd-wasm)` unconditionally + * (`dispatcher/client-h1.js`). polywasm compiles function bodies lazily, so + * that compile *succeeds* and then throws `Unsupported instruction: 0xFD` on + * the first parser callback — past the try/catch undici wraps the compile in. + * + * `@comapeo/core`'s maps plugin and `secret-stream-http` (via + * `@comapeo/map-server`) both import `fetch` from that bundled copy, so this + * covers online map styles and peer blob/SMP fetches. Aliasing at bundle time + * keeps the SIMD bytes out of the iOS bundle entirely. + */ +function aliasUndiciSimdWasmPlugin(): Plugin { + return { + name: "alias-undici-simd-wasm", + resolveId(source, importer) { + if ( + source === "../llhttp/llhttp_simd-wasm.js" && + importer && + importer.includes("/undici/lib/dispatcher/") + ) { + return path.resolve(path.dirname(importer), "../llhttp/llhttp-wasm.js"); + } + return null; + }, + }; +} + /** * Runtime data files copied alongside the rolldown output into the per- * platform output dir. Identical for Android and iOS; only the bundled JS - * differs, and then only in the `__loadAddon` banner. + * differs, in the `__loadAddon` banner and the undici SIMD alias above. * * - `package.json`: required by Node's module resolver to set the * unpacked nodejs-project tree's module type. @@ -131,13 +165,19 @@ const sharedInput: Pick = { }; function buildPlugins({ + platform, outDir, debugIdMap, }: { + platform: "android" | "ios"; outDir: string; debugIdMap: Map; }): Plugin[] { return [ + // iOS-only: keep the SIMD llhttp bytes out of the bundle — the runtime's + // UNDICI_NO_WASM_SIMD only steers Node's built-in undici, not the npm copy + // we bundle. See aliasUndiciSimdWasmPlugin above. + ...(platform === "ios" ? [aliasUndiciSimdWasmPlugin()] : []), // Native addon loader rewrite is identical for both platforms: // every loader pattern (`bindings`, `node-gyp-build`, `require.addon`) // becomes `__loadAddon(name, version)`. The helper itself differs @@ -230,6 +270,7 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(ANDROID_OUT_MAIN), ...buildPlugins({ + platform: "android", outDir: ANDROID_OUT_MAIN, debugIdMap: androidMainDebugIds, }), @@ -252,6 +293,7 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(IOS_OUT), ...buildPlugins({ + platform: "ios", outDir: IOS_OUT, debugIdMap: iosDebugIds, }), diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index bea5bff8..8ea8f826 100755 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -59,8 +59,9 @@ const IOS_FRAMEWORKS_WORK_DIR = join(SCRATCH_DIR, "frameworks"); rmSync(SCRATCH_DIR, { force: true, recursive: true }); // 1. Native module ABI is read from the libnode header laid down by -// `npm run download:nodejs-mobile`. Used to pin non-NAPI prebuild -// URLs (better-sqlite3 today). +// `npm run download:nodejs-mobile`. Every module is NAPI as of +// better-sqlite3 13, so nothing consumes this today — `downloadPrebuilds` +// discards it per-pair when `usesNapi`. Kept for the next raw-V8 addon. const { abi: NODE_ABI } = readNodeJsMobileVersions(); // 2. Enumerate every concrete (name, version) pair of native modules @@ -116,17 +117,17 @@ await audit16kAlignment({ }); // 7. iOS: wrap each (name, version) as `__.xcframework` -// (device + lipo'd simulator slices). Embed & Sign at app build +// (device + simulator slices). Embed & Sign at app build // time; bundled JS loads it via `process.dlopen` against // `.app/Frameworks/.framework/`. // -// `xcodebuild`, `lipo`, and `install_name_tool` are macOS-only Xcode +// `xcodebuild` and `install_name_tool` are macOS-only Xcode // command-line tools, so this whole pass is gated on // `process.platform`. Linux CI runners (Android workflow) skip it // cleanly — they don't consume `ios/Frameworks/`. if (process.platform !== "darwin") { console.log( - "Skipping iOS xcframework wrapping — requires macOS Xcode toolchain (xcodebuild/lipo/install_name_tool). " + + "Skipping iOS xcframework wrapping — requires macOS Xcode toolchain (xcodebuild/install_name_tool). " + `Current platform: ${process.platform}.`, ); } else { diff --git a/scripts/lib/ios-frameworks.ts b/scripts/lib/ios-frameworks.ts index e2fab3c2..88d1038d 100644 --- a/scripts/lib/ios-frameworks.ts +++ b/scripts/lib/ios-frameworks.ts @@ -78,7 +78,7 @@ export async function packageIosFrameworks({ /** * Build one `.framework/` directory for the given arch. * Returns the absolute path to the framework directory so it can be - * fed into `lipo`/`xcodebuild` downstream. + * fed into `xcodebuild` downstream. * * `install_name_tool -id` rewrites the Mach-O's `LC_ID_DYLIB` from the * upstream prebuild's `.node` (a name dyld cannot resolve inside From b89612ac55d2d76a76055407af26441babbf7867 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 09:39:23 +0100 Subject: [PATCH 06/23] test(ios): fail the build if the SIMD llhttp payload reaches the iOS bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redirect that keeps undici's SIMD llhttp away from polywasm matches an upstream import specifier, so an undici reshuffle turns it into a silent no-op and the SIMD bytes come back. Nothing downstream notices: the failure needs a real network fetch on a jitless device, and the e2e map-server tests deliberately use the built-in fallback map with no project, no uploaded SMP and no network. That is why removing the redirect went unnoticed in the first place. Assert the outcome rather than the mechanism — after writing the iOS bundle, fail if it still contains the SIMD payload. The marker is the first slice of that module's base64 that differs from its non-SIMD sibling, so it identifies the specific wasm build without depending on module names surviving minification. No undici installed means nothing to assert, which is the right answer if the dependency ever goes away (see #232). Verified in both directions: the build passes as-is, and fails with an actionable message when the redirect is made not to match. --- backend/rolldown.config.ts | 68 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/backend/rolldown.config.ts b/backend/rolldown.config.ts index 4f6a2d44..911f9109 100644 --- a/backend/rolldown.config.ts +++ b/backend/rolldown.config.ts @@ -1,4 +1,4 @@ -import { rmSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { cp } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -73,7 +73,7 @@ const IOS_SOURCEMAPS = * covers online map styles and peer blob/SMP fetches. Aliasing at bundle time * keeps the SIMD bytes out of the iOS bundle entirely. */ -function aliasUndiciSimdWasmPlugin(): Plugin { +function aliasUndiciSimdWasmPlugin(outDir: string): Plugin { return { name: "alias-undici-simd-wasm", resolveId(source, importer) { @@ -86,7 +86,69 @@ function aliasUndiciSimdWasmPlugin(): Plugin { } return null; }, + // Assert the outcome, not the mechanism: the redirect above is matched on + // an upstream specifier, so an undici reshuffle turns it into a silent + // no-op and the SIMD bytes come back. Nothing downstream would notice — + // the failure needs a real network fetch on a jitless device, which no + // test in this repo makes. + writeBundle() { + const emitted = readEmittedBundle(outDir); + if (!emitted) return; + const simd = undiciWasmMarker("llhttp_simd-wasm.js"); + if (simd && emitted.includes(simd)) { + throw new Error( + "alias-undici-simd-wasm: the SIMD llhttp payload is in the iOS " + + "bundle. polywasm compiles it lazily, so it will throw " + + "`Unsupported instruction: 0xFD` on the first request rather than " + + "at compile time. The redirect in this plugin no longer matches " + + "undici's import — update it.", + ); + } + }, + }; +} + +/** Concatenated JS of every chunk written to `outDir`, or null if absent. */ +function readEmittedBundle(outDir: string): string | null { + if (!existsSync(outDir)) return null; + const files = [ + ...readdirSync(outDir) + .filter((f) => f.endsWith(".mjs")) + .map((f) => path.join(outDir, f)), + ...(existsSync(path.join(outDir, "chunks")) + ? readdirSync(path.join(outDir, "chunks")) + .filter((f) => f.endsWith(".mjs")) + .map((f) => path.join(outDir, "chunks", f)) + : []), + ]; + return files.length ? files.map((f) => readFileSync(f, "utf8")).join("") : null; +} + +/** + * A slice of ``'s base64 payload that does not appear in its sibling, + * so it identifies that specific wasm build in a bundle. Returns null when + * undici isn't installed — the assertion then has nothing to check, which is + * the correct answer if the dependency ever goes away (see issue #232). + */ +function undiciWasmMarker(module: string): string | null { + const dir = path.join(__dirname, "node_modules/undici/lib/llhttp"); + const read = (f: string) => { + const p = path.join(dir, f); + if (!existsSync(p)) return null; + return readFileSync(p, "utf8").match(/[A-Za-z0-9+/]{200,}={0,2}/)?.[0] ?? null; }; + const target = read(module); + const sibling = read( + module === "llhttp_simd-wasm.js" ? "llhttp-wasm.js" : "llhttp_simd-wasm.js", + ); + if (!target || !sibling) return null; + let i = 0; + while (i < Math.min(target.length, sibling.length) && target[i] === sibling[i]) { + i++; + } + // 64 chars past the first divergence is far more than enough to be unique, + // and short enough to survive minification (these are string literals). + return target.slice(i, i + 64) || null; } /** @@ -177,7 +239,7 @@ function buildPlugins({ // iOS-only: keep the SIMD llhttp bytes out of the bundle — the runtime's // UNDICI_NO_WASM_SIMD only steers Node's built-in undici, not the npm copy // we bundle. See aliasUndiciSimdWasmPlugin above. - ...(platform === "ios" ? [aliasUndiciSimdWasmPlugin()] : []), + ...(platform === "ios" ? [aliasUndiciSimdWasmPlugin(outDir)] : []), // Native addon loader rewrite is identical for both platforms: // every loader pattern (`bindings`, `node-gyp-build`, `require.addon`) // becomes `__loadAddon(name, version)`. The helper itself differs From 9c565f251d9d788e286732ffaf01c0d3bcd33b3c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 12:52:29 +0100 Subject: [PATCH 07/23] refactor: address review on the node 24 environment changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop NODE_COMPILE_CACHE_PORTABLE on both platforms. It keys cache entries by path relative to the cache dir, which only helps when the modules and the cache move together. Ours never do: the cache is in cacheDir/Library Caches and the JS is in filesDir/the app bundle, so the relative path still contains the varying component. Verified by moving the module dir between runs — the entry hash changes either way, so the setting bought nothing. - Report a failure to create either directory to Sentry via `logCapture`, the existing helper for notable non-exception events, rather than a local log line. A device that can never write there silently pays the cold-compile cost on every launch, so the rate is worth seeing. - Trim the addon-loader comments to what a maintainer of the current code needs: no better-sqlite3 version history, and no restating between the file header and the pattern it describes. --- .../java/com/comapeo/core/NodeJSService.kt | 41 +++++++++---------- .../rollup-plugin-addon-loader.js | 21 ++++------ ios/AppLifecycleDelegate.swift | 32 ++++++++++----- 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index a01ca7ef..80e5ec0c 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -359,39 +359,38 @@ class NodeJSService( /** * Environment node inherits from this process. Must run before * [startNodeWithArguments]: `NODE_COMPILE_CACHE` is read while the - * Environment is created, so setting it from JS would be too late. (`TMPDIR` - * would in fact survive a later `process.env` assignment — that routes - * through `uv_os_setenv`, so even native `getenv` callers see it — but it is - * set here so it holds for anything reading it before our JS runs.) + * Environment is created, so setting it from JS would be too late. * - * An Android app process has no `TMPDIR` and there is no `/tmp`, which - * `os.tmpdir()` otherwise falls back to; anything writing there fails with - * ENOENT. `NODE_COMPILE_CACHE` is V8's on-disk code cache — the backend - * flushes it once boot reaches `ready` rather than leaving it to node's - * exit hook, which the low-memory killer routinely denies us. + * An Android app process has no `TMPDIR`, and there is no `/tmp` for + * `os.tmpdir()` to fall back to, so anything writing there fails with + * ENOENT. `NODE_COMPILE_CACHE` is V8's on-disk code cache; the backend + * flushes it at `ready` rather than leaving it to node's exit hook, which + * the low-memory killer routinely denies us. * * Both live under `cacheDir`: regenerable, and reclaimable under storage * pressure. A variable is left unset rather than pointed at a directory we * failed to create — node falling back to its own default beats handing it - * a path that will ENOENT on first use. + * a path that ENOENTs on first use. */ private fun applyNodeEnvironment() { // `mkdirs()` returns false when the directory already exists, so the // result to trust is `isDirectory`, not the return value. - fun ensureDir(dir: File): Boolean { + fun ensureDir(name: String): File? { + val dir = File(cacheDir, name) dir.mkdirs() - if (dir.isDirectory) return true - Log.w(TAG, "could not create ${dir.absolutePath}; leaving its env var unset") - return false + if (dir.isDirectory) return dir + logCapture( + SentryCategories.BOOT, + "could not create node $name dir; leaving its env var unset", + level = "warning", + tags = mapOf("dir" to name), + ) + return null } - val tmpDir = File(cacheDir, "tmp") - if (ensureDir(tmpDir)) setEnv("TMPDIR", tmpDir.absolutePath) - - val compileCacheDir = File(cacheDir, "node-compile-cache") - if (ensureDir(compileCacheDir)) { - setEnv("NODE_COMPILE_CACHE", compileCacheDir.absolutePath) - setEnv("NODE_COMPILE_CACHE_PORTABLE", "1") + ensureDir("tmp")?.let { setEnv("TMPDIR", it.absolutePath) } + ensureDir("node-compile-cache")?.let { + setEnv("NODE_COMPILE_CACHE", it.absolutePath) } } diff --git a/backend/rollup-plugins/rollup-plugin-addon-loader.js b/backend/rollup-plugins/rollup-plugin-addon-loader.js index 9af92925..3f3918e7 100644 --- a/backend/rollup-plugins/rollup-plugin-addon-loader.js +++ b/backend/rollup-plugins/rollup-plugin-addon-loader.js @@ -24,15 +24,9 @@ import path from "node:path"; * from the package.json that owns the file being transformed — not a * hand-maintained map. * - * Better-sqlite3 specifically: up to 12.x it resolved through - * `require('bindings')('better_sqlite3.node')`, lazily on the first - * `new Database(...)`. From 13.x it uses its own `lib/binding.js`, - * which knows only linux/darwin/win32 prebuild paths before falling - * back to node-gyp build dirs. Both shapes are rewritten to - * `__loadAddon('better-sqlite3', '')`, which also makes the - * underscore-vs-hyphen mismatch (`better_sqlite3.node` filename vs. - * `better-sqlite3` package name) moot. A miss on the 13.x resolver is - * a build error rather than a device-only failure — see `transform`. + * The rewrite also makes better-sqlite3's underscore-vs-hyphen mismatch + * (`better_sqlite3.node` filename vs. `better-sqlite3` package name) + * moot, since the call is replaced wholesale. * * @returns {import('rolldown').Plugin} */ @@ -54,11 +48,10 @@ export default function addonLoaderPlugin() { replacement: (n, v) => `__loadAddon('${n}', '${v}')`, }, { - // better-sqlite3 >= 13 dropped `bindings` for its own resolver in - // `lib/binding.js`, which knows only linux/darwin/win32 prebuild paths - // and then falls back to node-gyp build dirs — none of which exist on - // device. Short-circuit it at the head of that fallback; the - // caller-supplied `nativeBinding` branches above it still work. + // better-sqlite3's own resolver: it knows only linux/darwin/win32 + // prebuild paths and then falls back to node-gyp build dirs, none of + // which exist on device. Short-circuit at the head of that fallback so + // the caller-supplied `nativeBinding` branches above it still work. pattern: /let filename = getPrebuildPath\(\);/g, replacement: (n, v) => `return DEFAULT_ADDON = __loadAddon('${n}', '${v}');`, }, diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index 859ce92a..8ac5e82c 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -61,7 +61,6 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { // regenerable data wants. TMPDIR and HOME are already set by iOS. if let compileCacheDir = AppLifecycleDelegate.resolveCompileCacheDir() { setenv("NODE_COMPILE_CACHE", compileCacheDir, 1) - setenv("NODE_COMPILE_CACHE_PORTABLE", "1", 1) } let cStrings = arguments.map { strdup($0)! } @@ -115,20 +114,31 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { /// `Library/Caches/comapeo/node-compile-cache`, created on demand. nil if /// the directory can't be created — node then runs without a code cache, - /// which costs startup time but nothing else. + /// which costs startup time but nothing else. Reported so the rate is + /// visible; a device that can never write here pays the cold-compile cost + /// on every launch. private static func resolveCompileCacheDir() -> String? { let fm = FileManager.default - guard let caches = try? fm.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - let dir = caches + let dir: URL + do { + dir = try fm.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) .appendingPathComponent("comapeo", isDirectory: true) .appendingPathComponent("node-compile-cache", isDirectory: true) - guard (try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) != nil - else { return nil } + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + logCapture( + category: SentryCategories.boot, + message: "could not create node compile-cache dir; running without one", + level: .warning, + tags: ["error": "\(error)"] + ) + return nil + } return dir.path } From 3dfc08b3ad2e2fd2b539c6611419dfb17ae17fa0 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 13:33:23 +0100 Subject: [PATCH 08/23] fix(backend): flush control frames before closing IPC sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `broadcast()` only queues a frame — streamx defers the real write to the next tick — while `ServerHelper.close()` calls `destroySoon()` in the same tick. The deferred write then hit an ended socket, so the `stopping` frame never reached native, `close()` rejected on the resulting socket error and left `#state` stuck at "closing", and every shutdown logged a bogus "Client sent invalid message" plus an `ipcError` metric sample. `SocketMessagePort` now exposes `drained()`, `SimpleRpcServer.close()` awaits it for every client before delegating to `super.close()`, and `ServerHelper.close()` waits on the socket's "close" event directly instead of `once()`, which rejects if the socket errors first. Closes #231 --- backend/index.js | 4 ++-- backend/lib/message-port.js | 13 +++++++++++++ backend/lib/server-helper.js | 7 ++++++- backend/lib/server-helper.test.mjs | 19 +++++++++++++++++++ backend/lib/simple-rpc.js | 17 +++++++++++++++++ backend/lib/simple-rpc.test.mjs | 22 ++++++++++++++++++++++ backend/package-lock.json | 1 + backend/package.json | 1 + backend/types/streamx.d.ts | 9 +++++++++ 9 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 backend/types/streamx.d.ts diff --git a/backend/index.js b/backend/index.js index ed4d932f..f6b1a6e2 100644 --- a/backend/index.js +++ b/backend/index.js @@ -112,8 +112,8 @@ const controlIpcServer = new SimpleRpcServer({ resolveInit(rootKey); }, shutdown: async () => { - // Broadcast BEFORE close: AF_UNIX guarantees this frame reaches - // peers before EOF, so they can tell graceful shutdown from a crash. + // Broadcast BEFORE close: this only queues the frame, and close() + // flushes each client's stream before destroying its socket. controlIpcServer.broadcast({ type: "stopping" }); // Each close is isolated so one failure can't leak the others. /** diff --git a/backend/lib/message-port.js b/backend/lib/message-port.js index 09d93b7b..84d4932c 100644 --- a/backend/lib/message-port.js +++ b/backend/lib/message-port.js @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import FramedStream from "framed-stream"; +import { Writable } from "streamx"; import ensureError from "ensure-error"; /** @@ -75,6 +76,18 @@ export class SocketMessagePort extends EventTarget { this.#framedStream.write(Buffer.from(JSON.stringify(message))); } + /** + * Resolves once queued frames have reached the socket. `postMessage()` only + * queues — streamx does the real write on a later tick — so anything that + * tears the socket down must wait on this first. + * + * @returns {Promise} `false` if there was nothing left to flush + */ + async drained() { + if (this.#state === "closed") return false; + return Writable.drained(this.#framedStream); + } + start() { if (this.#state !== "idle") return; this.#state = "active"; diff --git a/backend/lib/server-helper.js b/backend/lib/server-helper.js index 153dc381..dbc32d47 100644 --- a/backend/lib/server-helper.js +++ b/backend/lib/server-helper.js @@ -90,11 +90,16 @@ export class ServerHelper extends TypedEmitter { await once(this.#server, "listening"); } this.#state = "closing"; + /** @type {Promise[]} */ const closePromises = [once(this.#server, "close")]; // Close all open connections, otherwise the server won't close for (const socket of this.#connections) { if (socket.destroyed || socket.closed) continue; - closePromises.push(once(socket, "close")); + // Not `once()`: it rejects if the socket errors first, and a socket + // erroring during teardown is expected — "close" still follows. + closePromises.push( + new Promise((resolve) => socket.once("close", resolve)), + ); // Destroy the socket gracefully once all data is sent socket.destroySoon(); } diff --git a/backend/lib/server-helper.test.mjs b/backend/lib/server-helper.test.mjs index 97fd4adf..20bb8c38 100644 --- a/backend/lib/server-helper.test.mjs +++ b/backend/lib/server-helper.test.mjs @@ -36,6 +36,25 @@ test("close() destroys open connections and resolves", async (t) => { await clientClosed; // the client end observes the server-side teardown }); +test("close() resolves when a connection errors during teardown", async (t) => { + /** @type {Array} */ + const accepted = []; + const server = new ServerHelper((socket) => accepted.push(socket)); + const path = socketPath(); + await server.listen(path); + + await connectSocket(t, path); + await waitFor(() => accepted.length === 1, { message: "connection accepted" }); + const serverSocket = /** @type {import('node:net').Socket} */ (accepted[0]); + serverSocket.on("error", () => {}); + + const closed = server.close(); + serverSocket.destroy(new Error("teardown failure")); + + await closed; + assert.equal(server.state, "closed"); +}); + test("close() is a no-op when already closed", async () => { const server = new ServerHelper(() => {}); const path = socketPath(); diff --git a/backend/lib/simple-rpc.js b/backend/lib/simple-rpc.js index 663a85bb..83b6783d 100644 --- a/backend/lib/simple-rpc.js +++ b/backend/lib/simple-rpc.js @@ -154,4 +154,21 @@ export class SimpleRpcServer extends ServerHelper { } } } + + /** + * Flush every client before the sockets go away: `broadcast()` only queues + * the frame, and `super.close()` destroys the socket under it. + * + * @override + */ + async close() { + await Promise.all( + [...this.#clients].map((client) => + client.drained().catch((e) => { + console.error("close: client drain failed", e); + }), + ), + ); + await super.close(); + } } diff --git a/backend/lib/simple-rpc.test.mjs b/backend/lib/simple-rpc.test.mjs index 30102bc6..5556c6a5 100644 --- a/backend/lib/simple-rpc.test.mjs +++ b/backend/lib/simple-rpc.test.mjs @@ -78,6 +78,28 @@ test("broadcast delivers a frame to a connected client", async (t) => { }); }); +// Regression test for the dropped `stopping` frame: shutdown broadcasts and +// closes in the same tick, and the queued frame used to hit an already-ended +// socket on the next tick. +test("broadcast followed by close in the same tick still delivers", async (t) => { + const { server, path } = await startServer(t, {}); + + const socket = await connectSocket(t, path); + const client = new SocketMessagePort(socket); + /** @type {Array<{ type?: string }>} */ + const frames = []; + client.addEventListener("message", (event) => frames.push(event.data)); + client.start(); + + server.broadcast({ type: "stopping" }); + await server.close(); + + assert.equal(server.state, "closed"); + await waitFor(() => frames.some((f) => f && f.type === "stopping"), { + message: "stopping delivered despite same-tick close", + }); +}); + test("an unknown message type is ignored without throwing", async (t) => { let called = false; const { path } = await startServer(t, { diff --git a/backend/package-lock.json b/backend/package-lock.json index a47f0a9a..074d8c4c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,6 +27,7 @@ "ensure-error": "^5.0.0", "fastify": "4.29.1", "framed-stream": "1.0.1", + "streamx": "2.23.0", "tiny-typed-emitter": "2.1.0" }, "devDependencies": { diff --git a/backend/package.json b/backend/package.json index acb37b8c..34b8da8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "ensure-error": "^5.0.0", "fastify": "4.29.1", "framed-stream": "1.0.1", + "streamx": "2.23.0", "tiny-typed-emitter": "2.1.0" }, "devDependencies": { diff --git a/backend/types/streamx.d.ts b/backend/types/streamx.d.ts new file mode 100644 index 00000000..5b770021 --- /dev/null +++ b/backend/types/streamx.d.ts @@ -0,0 +1,9 @@ +import "streamx"; + +// `Writable.drained` exists since streamx 2.13 but is missing from +// @types/streamx (2.9.5). +declare module "streamx" { + namespace Writable { + function drained(ws: unknown): Promise; + } +} From e505f60b6a0c8a61d552f87057750179d00ae198 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 13:42:06 +0100 Subject: [PATCH 09/23] perf(backend): use Node's built-in undici instead of bundling 6.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodejs-mobile 24 embeds undici, so the npm copy that @comapeo/core and secret-stream-http import was 389 KB of duplicate bundle — the largest package in the backend bundle. Alias `undici` at bundle time to a shim that exports the global `fetch` and recovers `Agent` from Node's global dispatcher. The dispatcher only exists once Node's internal undici has initialised, which `new Request()` forces cheaply; secret-stream-http subclasses `Agent` at module scope, so the shim has to resolve eagerly and throws if it cannot. With the npm copy gone, the llhttp wasm payloads leave the bundle entirely, so `aliasUndiciSimdWasmPlugin` and its build-time assertion have nothing left to guard. --- backend/lib/undici-shim.js | 55 +++++++++++++++ backend/lib/undici-shim.test.mjs | 66 ++++++++++++++++++ backend/rolldown.config.ts | 113 ++----------------------------- 3 files changed, 127 insertions(+), 107 deletions(-) create mode 100644 backend/lib/undici-shim.js create mode 100644 backend/lib/undici-shim.test.mjs diff --git a/backend/lib/undici-shim.js b/backend/lib/undici-shim.js new file mode 100644 index 00000000..427a61de --- /dev/null +++ b/backend/lib/undici-shim.js @@ -0,0 +1,55 @@ +/** + * Shim for the npm `undici` package, aliased in at bundle time. + * + * Node 24 embeds undici, so bundling the npm copy (389 KB, the largest package + * in the backend bundle) only ships a second one. `fetch` is a global; `Agent` + * has no public export, so it is recovered from the global dispatcher, which + * Node installs only once its internal undici has initialised — hence the + * `new Request()` below. `secret-stream-http` subclasses `Agent` at module + * scope, so this has to resolve eagerly. We ship exactly one runtime + * (nodejs-mobile 24), so a miss throws here instead of degrading silently. + * + * @module + */ + +const GLOBAL_DISPATCHER_KEY = Symbol.for("undici.globalDispatcher.1"); + +try { + // Constructing the Request is the side effect: it makes Node initialise its + // internal undici, which is what installs the global dispatcher read below. + // eslint-disable-next-line no-new + new Request("http://localhost/"); +} catch { + // Reported below, together with what the dispatcher slot ended up holding. +} + +const dispatcher = /** @type {{ constructor?: unknown } | undefined} */ ( + /** @type {Record} */ ( + /** @type {unknown} */ (globalThis) + )[GLOBAL_DISPATCHER_KEY] +); + +const AgentCandidate = dispatcher?.constructor; + +if ( + typeof AgentCandidate !== "function" || + typeof AgentCandidate.prototype?.dispatch !== "function" +) { + throw new Error( + "undici-shim: could not recover undici's Agent from " + + "globalThis[Symbol.for('undici.globalDispatcher.1')] (got " + + `${typeof dispatcher}). This runtime does not expose a built-in undici ` + + "the way nodejs-mobile 24 does — either restore the bundled undici " + + "dependency or update this shim.", + ); +} + +/** Node's built-in undici `Agent`. Honours the `connect` option. */ +export const Agent = /** @type {typeof import("undici").Agent} */ ( + /** @type {unknown} */ (AgentCandidate) +); + +/** Node's global `fetch`, which honours a per-request `dispatcher`. */ +export const fetch = /** @type {typeof import("undici").fetch} */ ( + /** @type {unknown} */ (globalThis.fetch) +); diff --git a/backend/lib/undici-shim.test.mjs b/backend/lib/undici-shim.test.mjs new file mode 100644 index 00000000..3725fba6 --- /dev/null +++ b/backend/lib/undici-shim.test.mjs @@ -0,0 +1,66 @@ +import { createServer } from "node:http"; +import { connect } from "node:net"; +import assert from "node:assert/strict"; +import { after, test } from "node:test"; + +import { Agent, fetch } from "./undici-shim.js"; + +const server = createServer((_req, res) => { + res.end("hello"); +}); +/** @type {Promise} */ +const listening = new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); +}); + +after(() => { + server.close(); +}); + +/** @returns {Promise} */ +async function origin() { + await listening; + const address = server.address(); + assert.ok(address && typeof address === "object", "server has a TCP address"); + return `http://127.0.0.1:${address.port}`; +} + +test("exports a callable fetch", async () => { + assert.equal(typeof fetch, "function"); + const response = await fetch(`${await origin()}/`); + assert.equal(response.status, 200); + assert.equal(await response.text(), "hello"); +}); + +test("Agent is a dispatcher class accepting a connect option", () => { + assert.equal(typeof Agent, "function"); + const agent = new Agent({ connect: () => {} }); + assert.equal(typeof agent.dispatch, "function"); +}); + +test("a subclass's custom connect is used for the request", async () => { + let connectCalls = 0; + + class CountingAgent extends Agent { + constructor() { + super({ + connect: ({ hostname, port }, callback) => { + connectCalls++; + const socket = connect({ host: hostname, port: Number(port) }, () => { + callback(null, socket); + }); + socket.once("error", (err) => { + callback(err, null); + }); + }, + }); + } + } + + const agent = new CountingAgent(); + const response = await fetch(`${await origin()}/`, { dispatcher: agent }); + + assert.equal(await response.text(), "hello"); + assert.equal(connectCalls, 1, "the subclass's connect handled the request"); + await agent.close(); +}); diff --git a/backend/rolldown.config.ts b/backend/rolldown.config.ts index 911f9109..2961b407 100644 --- a/backend/rolldown.config.ts +++ b/backend/rolldown.config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { rmSync } from "node:fs"; import { cp } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -55,106 +55,10 @@ const ANDROID_SOURCEMAPS_MAIN = const IOS_SOURCEMAPS = process.env.SOURCEMAPS_DIR_IOS ?? `${IOS_OUT}-sourcemaps`; -/** - * iOS-only: redirect undici's `require('../llhttp/llhttp_simd-wasm.js')` to the - * non-SIMD module beside it. - * - * nodejs-mobile 24 does serve `WebAssembly` from a bundled polywasm on iOS, and - * its bootstrap sets `UNDICI_NO_WASM_SIMD=1` to steer undici off the SIMD build - * — but that env var is an undici 7.x feature, so it only reaches Node's - * *built-in* undici. We bundle npm `undici@6`, which ignores it and calls - * `WebAssembly.compile(llhttp_simd-wasm)` unconditionally - * (`dispatcher/client-h1.js`). polywasm compiles function bodies lazily, so - * that compile *succeeds* and then throws `Unsupported instruction: 0xFD` on - * the first parser callback — past the try/catch undici wraps the compile in. - * - * `@comapeo/core`'s maps plugin and `secret-stream-http` (via - * `@comapeo/map-server`) both import `fetch` from that bundled copy, so this - * covers online map styles and peer blob/SMP fetches. Aliasing at bundle time - * keeps the SIMD bytes out of the iOS bundle entirely. - */ -function aliasUndiciSimdWasmPlugin(outDir: string): Plugin { - return { - name: "alias-undici-simd-wasm", - resolveId(source, importer) { - if ( - source === "../llhttp/llhttp_simd-wasm.js" && - importer && - importer.includes("/undici/lib/dispatcher/") - ) { - return path.resolve(path.dirname(importer), "../llhttp/llhttp-wasm.js"); - } - return null; - }, - // Assert the outcome, not the mechanism: the redirect above is matched on - // an upstream specifier, so an undici reshuffle turns it into a silent - // no-op and the SIMD bytes come back. Nothing downstream would notice — - // the failure needs a real network fetch on a jitless device, which no - // test in this repo makes. - writeBundle() { - const emitted = readEmittedBundle(outDir); - if (!emitted) return; - const simd = undiciWasmMarker("llhttp_simd-wasm.js"); - if (simd && emitted.includes(simd)) { - throw new Error( - "alias-undici-simd-wasm: the SIMD llhttp payload is in the iOS " + - "bundle. polywasm compiles it lazily, so it will throw " + - "`Unsupported instruction: 0xFD` on the first request rather than " + - "at compile time. The redirect in this plugin no longer matches " + - "undici's import — update it.", - ); - } - }, - }; -} - -/** Concatenated JS of every chunk written to `outDir`, or null if absent. */ -function readEmittedBundle(outDir: string): string | null { - if (!existsSync(outDir)) return null; - const files = [ - ...readdirSync(outDir) - .filter((f) => f.endsWith(".mjs")) - .map((f) => path.join(outDir, f)), - ...(existsSync(path.join(outDir, "chunks")) - ? readdirSync(path.join(outDir, "chunks")) - .filter((f) => f.endsWith(".mjs")) - .map((f) => path.join(outDir, "chunks", f)) - : []), - ]; - return files.length ? files.map((f) => readFileSync(f, "utf8")).join("") : null; -} - -/** - * A slice of ``'s base64 payload that does not appear in its sibling, - * so it identifies that specific wasm build in a bundle. Returns null when - * undici isn't installed — the assertion then has nothing to check, which is - * the correct answer if the dependency ever goes away (see issue #232). - */ -function undiciWasmMarker(module: string): string | null { - const dir = path.join(__dirname, "node_modules/undici/lib/llhttp"); - const read = (f: string) => { - const p = path.join(dir, f); - if (!existsSync(p)) return null; - return readFileSync(p, "utf8").match(/[A-Za-z0-9+/]{200,}={0,2}/)?.[0] ?? null; - }; - const target = read(module); - const sibling = read( - module === "llhttp_simd-wasm.js" ? "llhttp-wasm.js" : "llhttp_simd-wasm.js", - ); - if (!target || !sibling) return null; - let i = 0; - while (i < Math.min(target.length, sibling.length) && target[i] === sibling[i]) { - i++; - } - // 64 chars past the first divergence is far more than enough to be unique, - // and short enough to survive minification (these are string literals). - return target.slice(i, i + 64) || null; -} - /** * Runtime data files copied alongside the rolldown output into the per- * platform output dir. Identical for Android and iOS; only the bundled JS - * differs, in the `__loadAddon` banner and the undici SIMD alias above. + * differs, in the `__loadAddon` banner. * * - `package.json`: required by Node's module resolver to set the * unpacked nodejs-project tree's module type. @@ -209,7 +113,9 @@ function copyStaticAssetsPlugin(outDir: string): Plugin { * specifiers resolve to the runtime builtin rather than a polyfill. * - `resolve.alias` swaps `@node-rs/crc32` (a native addon that can't * be rolled up) for a pure-JS shim. `@comapeo/core` pulls it in - * indirectly. + * indirectly. `undici` is aliased the same way — Node 24 embeds it, + * so the npm copy `@comapeo/core` and `secret-stream-http` import is + * 389 KB of duplicate bundle (see `lib/undici-shim.js`). * * CommonJS and JSON inputs are handled by rolldown natively, so the * former `@rollup/plugin-commonjs`, `@rollup/plugin-json`, and @@ -222,24 +128,19 @@ const sharedInput: Pick = { resolve: { alias: { "@node-rs/crc32": path.join(__dirname, "lib", "node-rs-crc32-shim.js"), + undici: path.join(__dirname, "lib", "undici-shim.js"), }, }, }; function buildPlugins({ - platform, outDir, debugIdMap, }: { - platform: "android" | "ios"; outDir: string; debugIdMap: Map; }): Plugin[] { return [ - // iOS-only: keep the SIMD llhttp bytes out of the bundle — the runtime's - // UNDICI_NO_WASM_SIMD only steers Node's built-in undici, not the npm copy - // we bundle. See aliasUndiciSimdWasmPlugin above. - ...(platform === "ios" ? [aliasUndiciSimdWasmPlugin(outDir)] : []), // Native addon loader rewrite is identical for both platforms: // every loader pattern (`bindings`, `node-gyp-build`, `require.addon`) // becomes `__loadAddon(name, version)`. The helper itself differs @@ -332,7 +233,6 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(ANDROID_OUT_MAIN), ...buildPlugins({ - platform: "android", outDir: ANDROID_OUT_MAIN, debugIdMap: androidMainDebugIds, }), @@ -355,7 +255,6 @@ const config: RolldownOptions[] = [ plugins: [ cleanOutputDirPlugin(IOS_OUT), ...buildPlugins({ - platform: "ios", outDir: IOS_OUT, debugIdMap: iosDebugIds, }), From 6e772000359f0851595ae3276d9e306e7da3c086 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 18 Aug 2026 13:55:32 +0100 Subject: [PATCH 10/23] test(e2e): surface stalled spec and capture BrowserStack diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app jasmine suite intermittently never renders all-tests-done, and a stalled run left nothing to diagnose: no spec name in CI output, no device logs, no screenshot (Maestro's extendedWaitUntil dies before the screenshot step). In the e2e app, always render the currently-running spec (testID current-spec), give the progress counter a testID (test-progress), and wrap jasmineEnv.execute() in a 240s watchdog — inside Maestro's 300s window — that renders all-tests-done plus a 'Suite timed out during: ' failure, so a stall reaches the screenshot step and names the culprit. Replace the NoopGlobalErrors stub with a real implementation over ErrorUtils and Hermes' rejection tracker (neither is wired up in Release), so uncaught errors and unhandled rejections are console.error'd, routed to jasmine, and surfaced in the UI instead of silently hanging the suite. In the run-browserstack-maestro action, on the final failed attempt download each failed session's device logs and screenshots (URLs verified against a real past build's session JSON), print the app-tagged device-log lines into the job log, and upload everything as a browserstack-diagnostics- artifact. Collection is best-effort and the upload step runs only after the run step has already failed, so diagnostics can never mask the real failure or change the action's exit semantics. --- .../run-browserstack-maestro/action.yml | 76 +++ apps/e2e/src/TestRunner.tsx | 502 ++++++++++++------ docs/TESTING.md | 10 + maestro/e2e.yaml | 3 + 4 files changed, 416 insertions(+), 175 deletions(-) diff --git a/.github/actions/run-browserstack-maestro/action.yml b/.github/actions/run-browserstack-maestro/action.yml index bfcf82d0..a6136169 100644 --- a/.github/actions/run-browserstack-maestro/action.yml +++ b/.github/actions/run-browserstack-maestro/action.yml @@ -35,6 +35,15 @@ inputs: connection resetting during launchApp), never on a real test failure. required: false default: "3" + artifact_name: + description: >- + Name of the workflow artifact that receives failure diagnostics + (device logs, screenshots, session JSON). Defaults to + browserstack-diagnostics-. Artifact names must be unique + per workflow run, so override this if one workflow calls this action + more than once per platform. + required: false + default: "" runs: using: composite @@ -73,6 +82,7 @@ runs: PLATFORM: ${{ inputs.platform }} TIMEOUT: ${{ fromJson(inputs.timeout) }} MAX_ATTEMPTS: ${{ fromJson(inputs.max_attempts) }} + DIAG_DIR: ${{ runner.temp }}/browserstack-diagnostics shell: bash run: | #shell set -o pipefail @@ -184,6 +194,61 @@ runs: done } + # Saves what BrowserStack already recorded (deviceLogs: true above) + # for each failed session into DIAG_DIR — device logs, screenshots, + # session JSON — for the upload step, and prints the app-relevant + # device-log lines so the stalled/failed spec is named in the job log. + # Best-effort by design: always returns 0 so a fetch error here can + # never mask the real failure. + collect_diagnostics() { + local build_id=$1 attempt=$2 + mkdir -p "$DIAG_DIR" || return 0 + printf '%s\n' "$BUILD_RESPONSE" > "$DIAG_DIR/attempt${attempt}-build.json" || true + while IFS=$'\t' read -r device session_id; do + [ -n "$session_id" ] || continue + local slug prefix detail + slug=$(printf '%s' "$device" | tr -cs 'A-Za-z0-9._-' '-') + prefix="$DIAG_DIR/attempt${attempt}-${slug}" + detail=$(curl --show-error -s -u "$AUTH" "$API/builds/$build_id/sessions/$session_id") || continue + printf '%s\n' "$detail" > "${prefix}-session.json" || true + while IFS=$'\t' read -r tc_name tc_status device_log screenshots video; do + [ -n "$tc_name" ] || continue + local tc_slug base + tc_slug=$(printf '%s' "$tc_name" | tr -cs 'A-Za-z0-9._-' '-') + base="${prefix}-${tc_slug}" + if [ -n "$device_log" ]; then + curl --show-error -s -u "$AUTH" -o "${base}-device.log" "$device_log" || true + if [ -s "${base}-device.log" ]; then + echo "::group::Device log (app lines) — $device / $tc_name ($tc_status)" + matches=$({ grep -E 'ReactNativeJS|\[e2e\]|ComapeoCore|Comapeo:NodeJS' "${base}-device.log" || true; } | tail -n 200) + if [ -n "$matches" ]; then + printf '%s\n' "$matches" + else + echo "(no app-tagged lines — expected on iOS, where console output is not persisted to the device log; use the screenshots)" + fi + echo "::endgroup::" + fi + fi + if [ -n "$screenshots" ]; then + curl --show-error -sL -u "$AUTH" -o "${base}-screenshots.zip" "$screenshots" || true + if [ -s "${base}-screenshots.zip" ] \ + && unzip -o -q -d "${base}-screenshots" "${base}-screenshots.zip"; then + rm -f "${base}-screenshots.zip" + fi + fi + if [ -n "$video" ]; then + echo "Video for $device / $tc_name: $video" + fi + done < <(printf '%s\n' "$detail" \ + | jq -r '.testcases.data[]?.testcases[]? | select(.status != "passed") + | [.name, .status, .device_log // "", .screenshots // "", .video // ""] | @tsv') + done < <(printf '%s\n' "$BUILD_RESPONSE" \ + | jq -r '.devices[]? as $d | $d.sessions[]? | select(.status != "passed") + | [(($d.device // $d.os // "device") + "-" + ($d.os_version // "")), .id] | @tsv') + echo "Diagnostics saved for upload as a workflow artifact." + return 0 + } + # Returns 0 only if there is at least one failed session and every # failed session is infra-class: either its structured session # `error.message` matches SESSION_ERR_RE (the session never started) @@ -243,12 +308,23 @@ runs: continue fi + collect_diagnostics "$BUILD_ID" "$attempt" || true echo "Tests failed (status: $STATUS)" exit 1 done exit 1 + # Runs only after the run step has already failed, so uploading (or a + # failure to upload) cannot change the action's outcome or exit code. + - name: Upload BrowserStack diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact_name != '' && inputs.artifact_name || format('browserstack-diagnostics-{0}', inputs.platform) }} + path: ${{ runner.temp }}/browserstack-diagnostics + if-no-files-found: ignore + - name: Stop BrowserStack build on cancel if: cancelled() && steps.run.outputs.build_id != '' env: diff --git a/apps/e2e/src/TestRunner.tsx b/apps/e2e/src/TestRunner.tsx index 17ed48a7..0702051a 100644 --- a/apps/e2e/src/TestRunner.tsx +++ b/apps/e2e/src/TestRunner.tsx @@ -1,187 +1,339 @@ -import { useState } from 'react' +import { useState } from "react"; import jasmineRequire, { - type JasmineDoneInfo, -} from 'jasmine-core/lib/jasmine-core/jasmine' -import { Button, ScrollView, Text, View } from 'react-native' + type JasmineDoneInfo, +} from "jasmine-core/lib/jasmine-core/jasmine"; +import { Button, ScrollView, Text, View, type ErrorUtils } from "react-native"; -import { test as basicTest } from './tests/basic' -import { test as mapServerTest } from './tests/map-server' -import { test as projectCrudTest } from './tests/project-crud' +import { test as basicTest } from "./tests/basic"; +import { test as mapServerTest } from "./tests/map-server"; +import { test as projectCrudTest } from "./tests/project-crud"; type TestResult = { - id: string - name: string - passed: boolean - errors: Array<{ message: string; stack: string }> -} + id: string; + name: string; + passed: boolean; + errors: Array<{ message: string; stack: string }>; +}; type TestState = - | { status: 'idle' | 'pending'; results: Array } - | { status: 'done'; info: JasmineDoneInfo; results: Array } + | { status: "idle" | "pending"; results: Array } + | { + status: "done"; + overallStatus: JasmineDoneInfo["overallStatus"] | "timedOut"; + timedOutDuring?: string; + results: Array; + }; // Default of 5s is too short for IPC-heavy tests on slow CI devices. -const DEFAULT_TIMEOUT_INTERVAL_MS = 60_000 +const DEFAULT_TIMEOUT_INTERVAL_MS = 60_000; -export function TestRunner() { - const [testState, setTestState] = useState({ - status: 'idle', - results: [], - }) - - async function runTests() { - const jasmineCore = jasmineRequire.core(jasmineRequire) - - const jasmineEnv = jasmineCore.getEnv({ - suppressLoadErrors: true, - GlobalErrors: NoopGlobalErrors, - }) - - jasmineEnv.addReporter({ - jasmineStarted: () => { - console.log('[e2e] jasmine started') - setTestState({ status: 'pending', results: [] }) - }, - jasmineDone: (info) => { - console.log(`[e2e] jasmine done: ${info.overallStatus}`) - setTestState((prev) => { - if (prev.status === 'done') { - throw new Error( - `Invalid state transition from '${prev.status}' to 'done'.`, - ) - } - - return { - status: 'done', - info, - results: prev.results, - } - }) - }, - specStarted: (result) => { - console.log(`[e2e] spec started: ${result.fullName}`) - }, - specDone: (result) => { - const describeText = result.fullName.replaceAll(result.description, '') - - if (result.status === 'passed') { - console.log(`[e2e] PASS: ${result.fullName}`) - } else { - console.log( - `[e2e] FAIL: ${result.fullName} — ${result.failedExpectations - .map((e) => e.message) - .join(' | ')}`, - ) - for (const err of result.failedExpectations) { - if (err.stack) console.log(`[e2e] stack: ${err.stack}`) - } - } - - setTestState((prev) => { - if (prev.status === 'done') { - throw new Error( - `Invalid state transition from '${prev.status}' to 'done'.`, - ) - } - - return { - status: 'pending', - results: [ - ...prev.results, - { - id: result.id, - name: describeText - ? `${describeText} > ${result.description}` - : result.description, - passed: result.status === 'passed', - errors: result.failedExpectations.map((err) => ({ - message: err.message, - stack: err.stack, - })), - }, - ], - } - }) - }, - }) - - const { describe, it, expect, expectAsync, jasmine, beforeEach, afterEach } = - jasmineRequire.interface(jasmineCore, jasmineEnv) - - jasmine.DEFAULT_TIMEOUT_INTERVAL = DEFAULT_TIMEOUT_INTERVAL_MS - - const ctx = { - describe, - it, - expect, - expectAsync, - jasmine, - beforeEach, - afterEach, - } - - // 👇 Register tests here! - basicTest(ctx) - mapServerTest(ctx) - projectCrudTest(ctx) - - await jasmineEnv.execute() - } - - return ( - -